前段时间在做一件事:把一个跑在本地的大模型对话智能体,接进微信客服的消息通道,让它先接客,遇到搞不定的再转给真人。功能本身不复杂,真正花掉我三天时间的是四个字——落地稳定。
这篇不讲业务规划,只讲实现过程中踩到的技术坑,以及我是怎么把它们一个个钉住的。
一、转人工之后,为什么消息发不出去
微信客服的消息通道有个状态机,会话在待接入和接待中时,属于不同的状态。最直觉的写法是:检测到需要转人工,就调用发消息接口推一句「正在为您转接人工客服,请稍候」。
结果接口返回错误码 95018,消息发不出去。
原因在于:会话一旦被切到「待接入」或「接待中」,它就不再是一条普通的客户消息会话了,此时再走统一的消息发送接口会被拒。正确的姿势是做状态变更时,从变更接口的返回体里取一个事件消息码,再用事件消息接口把提示语发出去。也就是说,这句提示语在语义上不是「我主动发的一条消息」,而是「状态流转事件附带的一条通知」。
这个坑值得单独拎出来讲,因为它的报错信息完全指不到根因,只有把接口文档里「状态变更」和「消息发送」两套接口的边界看清楚,才知道该走哪条路。
二、转给谁:把「指派」这件事做对
想清楚这件事的分层很重要。转人工不是一个动作,而是三个:
- 先把会话放进待接入队列;
- 再从中挑一个真正能接的人在岗坐席,直接指派;
- 挑不到就老老实实留在队列里,等人工来捞。
第二步是最容易被忽略的。客服系统里通常会有一个坐席列表接口,返回每个人的状态。这里的关键是状态值语义:必须挑「接待中」的那一类人,而不是「已停止」或者「未知状态」。于是我封了一个挑选逻辑:遍历坐席列表,过滤出在岗的人,有就指派,一个都没有就返回空,回到队列。
为什么这么在意「挑不到」这个分支?因为在真实的客服体系里,没有任何接口可以替坐席把接待开关打开。人不在岗,就是不在岗,程序再聪明也变不出一个活人。所以代码里必须有这条回退路径,否则一旦没有人在岗,会话就会变成一条「转接成功但没人接」的僵尸记录。
最终的目标解析顺序被我固定成了三级:
- 配置里显式指定了坐席 → 直接用;
- 没指定 → 自动挑一个在岗坐席;
- 挑不到 → 回落待接入队列。
这个顺序有个额外好处:调试的时候把自动指派关掉,行为完全退化成「只进队列」,定位问题非常干净。
三、看不见的失败,才是最难查的失败
自动指派上线后,我遇到最难受的一种情况:功能看起来是好的,会话也确实进了待接入,但就是没人被指派上。日志里没有任何报错。
这类「静默降级」是分布式的老毛病了——每一步都走了兜底分支,每一步都不报错,最后结果就是不对。我的解法是把每一次转人工的决策过程,完整打一行结构化日志出来:
servicer/list: total=8 named=6 on-duty=0 stopped=5 unknown-status=1 department-only=2
reason=no_on_duty_servicer
这一行日志能回答很多问题:接口到底返回了几个人、有几个是具名的、有几个在岗、几个已停止、状态值有没有出现没见过的枚举、有没有只属于部门而没绑定个人的记录。落到具体项目上,这一次的问题就是 on-duty=0——不是代码没跑,是真的没人在岗。
我的经验是:凡是「有兜底、不报错、但结果不对」的逻辑,都必须打印决策依据。否则你会在「代码看起来没错」这个结论上原地打转。
四、让模型自己决定「喊人」
转人工还有一个更细的问题:谁来判定这一刻该转?
纯靠关键词命中很容易误伤,而让模型输出结构化的字段又要改造提示词协议。我最后选了一个很轻的做法:约定一个特殊标记,比如 [[HANDOFF]],当模型的回复里出现这个标记时,就拦截这条回复,不发给用户,转而触发转人工流程;标记本身也从回复里剥掉。
做这个决定时踩过一个配置上的坑,值得所有写 Python 配置的人记住:「配置默认值」写在哪里,决定了它到底有没有生效。
很多框架会把默认值集中声明在一个字典里,但那个字典只是用来做「允许哪些配置项」的白名单校验,它并不会在运行时把默认值注入到读配置的地方。如果你在业务代码里直接读某个键,而配置文件里又没显式写,拿到的是 None,不是字典里声明的默认值。
所以正确做法是:默认值必须在读取处显式兜底,例如 config.get("xxx") or "[[HANDOFF]]"。同时要意识到另一个方向的风险:如果旧的配置文件里显式写了空值,它会覆盖代码里的默认值——升级默认值这件事,对已经写过该配置的老部署是无效的。
五、进程被启动了两遍,端口被抢了
功能跑通之后开始部署,然后遇到了一个特别经典的经典问题:Errno 98 address already in use。
排查下来,根因是配置结构的重复表达。这个项目的通道配置有两处可以声明实例:一处是扁平的「通道类型」配置,一处是支持多实例的「通道实例」列表。当同一类通道同时出现在这两处时,启动流程会把它当成两个独立通道各起一遍,第二遍就抢端口失败。
修法很直接:只要某个类型已经出现在实例列表里,就丢弃扁平配置里的同类型条目,以实例列表为唯一事实来源。这类问题的通用教训是——同一个概念有两种配置入口时,必须明确谁是权威,另一处要显式忽略,而不是「都能用」。
六、停止脚本漏杀进程,新旧实例并存
接着是第二个部署坑,比端口冲突更隐蔽:重启之后服务行为不一致,像是改的代码没生效。
原因是停服务脚本用进程名匹配,而实际运行的进程,工作目录才是区分实例的关键。有人手动在项目根目录敲过启动命令,这个进程的命令行字符串跟脚本预期的模式不完全一致,于是被漏掉了。旧进程还活着,新进程已经起来了,请求随机落到两个版本上——这比直接报错更可怕。
修法是改用「工作目录等于部署目录」作为判定条件:凡是工作目录落在目标部署路径下的进程,全部认作待停止的实例。同时补了一条运维纪律写进文档:统一走部署脚本,不要手动敲启动命令。技术手段和流程约束,两条腿都得有。
七、把部署脚本做厚一点
踩完这两个坑,我顺手把部署流程脚本化了,一共六个子命令:deploy、start、stop、restart、status、logs。
其中 deploy 做的事,是一个可复现的升级流程:
- 备份现有配置文件(升级不丢配置,是底线);
- 按工作目录停掉旧进程(复用前面那条规则);
- 用 rsync 同步代码,并排除数据目录(代码可以覆盖,数据绝不能覆盖);
- 按需安装依赖(requirements 有变化才装);
- 重新启动并输出状态。
这套流程的价值不在于「省了几条命令」,而在于把「哪些目录属于代码、哪些属于数据」这条边界固化进脚本里。人手动操作时,迟早会有人用 cp -r 把生产数据一起覆盖掉。
八、测试收尾
转人工相关的用例一共 39 条,启动去重相关的用例 3 条,全部通过。转人工这部分逻辑分支多(有坐席、无坐席、指派被拒、状态值异常),我为每一条回退路径都写了一组用例——这类「兜底分支」正是线上最容易出问题、又最难手工复现的地方,交给测试是最划算的。
小结
回头复盘,这次真正有价值的其实不是那 9 个提交,而是几个可以复用的判断:
- 接口的「状态变更」和「消息发送」往往是两套语义,报错码指向不了根因时,先回去读边界。
- 凡是自动化流程,都要为「资源不存在」设计回退路径,并且明确回退后的可观测状态。指望程序去改变外部世界的前提条件(比如把坐席叫回工位),是设计错误。
- 静默降级必须留下决策日志,否则调试就是在猜。
- 配置默认值要写在读取处,集中声明的字典常常只负责校验,不负责注入。
- 同一概念两个配置入口时,必须定出唯一权威。
- 进程识别靠工作目录,不靠进程名,并且要用脚本约束住人。
功能上线只是中点,让它在凌晨三点无人值守时也按预期工作,才是终点。








