AI 客服回复空白,不一定是模型坏了:5 层排查顺序与真实判据

2026-10-29 · AI智工坊 · 一手排障记录,数字均来自真机运行与接口返回原文

「抱歉,这次没生成出内容。」——这句话我们收到过太多次。第一次的反应是换模型,第二次的反应是把上下文砍短,第三次开始怀疑硬件。真正修好它花了两周,结论说出来有点扫兴:空回复只是最外层的症状,模型本身一次都没坏。

这篇文章把我实际走过的五层按顺序写下来。之所以强调「顺序」,是因为这五层的症状互相伪装:模型层的问题看起来像路由层,路由层的问题看起来像通道层,通道层的问题看起来像模型不会用工具。顺序错了,你会在错误的那一层无限重试。

一、三个看起来无关的现象,其实是同一个后端

先把我们遇到的三个客户侧投诉摆出来,它们后来被证明分别落在五层里的不同层:

客户看到的现象第一反应真实根因所在层
生成文档回「这次没生成出内容」模型能力不行模型层:思考文本吃满输出预算
交付了几十字的碎片,但没报错模型偷懒模型层:合格判据缺失,碎片被当成成品
客户问「出图多少钱」,客服真给他画了张图模型不听话路由层:任务识别抢在意图识别前面

第三个例子值得多说一句:它不是体验问题,是成本事故——每一次误判都在真金白银地烧算力。所以排查顺序的第一原则是:先排除"花了钱但做错事"的那一类,再谈话术。

二、第一层:空回复的真相是「预算被思考吃光了」

我们在本地部署的是一个 27B 的开源模型,默认开思考(reasoning)。现象很怪:同样是这个模型,聊天正常,一出文档就空白。

把返回体整段打出来,一行就定位了:

finish_reason = "length"          # 不是 stop
reasoning_content = (很长一段思考文本)
content = "好的,我来帮您"          # 几十字,甚至空字符串

机制是:开思考时,模型先写思考,思考也计入 max_tokens。预算给小了,思考就把额度吃干净,轮到正文时已经没有余量,于是 finish_reason 变成 length,content 只剩开场白。

为什么"重试两次"是错药:同样的参数重试,思考长度基本一样,结果也一样。更糟的是碎片非空——你的代码判了 if content: 就往下走了,用户拿到的是「好的,我来帮您」这种半句话。这比报错危险得多。

正确的诊断动作只有一个:拿不到正文时,先看 finish_reason 和 reasoning_content,不要先改 prompt。

三、第二层:关思考的参数,写在顶层是无效的

知道是思考吃预算之后,最自然的动作是加一个参数把思考关掉。我们加的是一般文档里最常见的那种写法,结果完全没效果:

写法实测结果结论
顶层 enable_thinking: false仍产出约 225 字思考文本,仍然撞 length,正文仍不完整压不住,别再试
chat_template_kwargs: {"enable_thinking": false}finish_reason = "stop",正文完整,35 秒真正生效
同一任务开思考(对照)正文常被截断,91 秒慢且不稳

原因是这类模型的思考开关是在 chat template 里判断的,顶层字段传不到模板那一层,于是被静默忽略——不报错、不告警,你以为关了,其实没关。这一条最坑的地方在于:它让你误判成「模型就是写不好长文」。

顺带一个反直觉的结论:关掉思考反而更快(35s vs 91s)。思考不是无成本的"更聪明",它是"用输出预算买推理";在写作这类任务上,这笔交易经常是亏的。

四、第三层:光关思考不够,还要有「合格判据」

关思考解决了大部分空回复,但还剩一类:正文有了,却只有几十字,finish_reason 是 stop。这种"合法但不合格"的输出,任何基于 finish_reason 的判断都拦不住。

我们最后把判定写成一条合成判据,而不是单看某一个字段:

合格 = content 有正文 and (finish_reason == "stop" or len(content) >= 400)

它同时挡住两种失败:

失败形态finish_reason合成判据能否拦住
思考吃空预算,正文为空或开场白length能(长度不达 400)
思考吃空预算,但正文"非空"length能(length 且短)
模型提前收尾,正文只有两句stop能(长度不达 400)

再配一条阶梯式降级,而不是单次调用:先按关思考 + 中等预算跑;不合格就升预算重跑;仍不合格再换策略。阶梯的价值不在"多试一次",而在每一次都换了参数——同参数重试只是把失败重放一遍。

五、第四层:路由顺序,让客服在问价时真去干活

这一层跟模型完全无关。客户的提问是「AI 出图多少钱」,客服的回答是一张图和一个报价单。

根因是一行顺序:后端先跑任务识别(看有没有"出图/写文档/翻译"这类动词),再跑意图识别(看是不是在问价)。"出图"两个字先命中了任务识别,于是系统认为客户要下单,直接开工。

修法不是加词表,而是加一条优先级更高的短路判断:命中价格词(多少钱/价格/报价/收费/怎么付/费用/便宜/优惠/价位)且没有"要产出"的动词(写/生成/起草/拟/做一份/出一份/整理/总结/翻译)→ 判定为问价,不进入任务通道。

这条规则的关键在 and 而不是 or:「帮我写一份报价方案」里有"报价",但它是真任务,必须继续走生成;「AI 出图多少钱」里有"出图"这个任务词,但它是问价。只看一头的规则一定会误伤另一头。

还有一个细节:报价条目在 FAQ 列表里必须排最前面。否则「数字人多少钱」会先命中产品介绍条目,客户问价却收到一段产品说明书。

六、第五层:通道与状态机——那类「不报错」的故障

最后这层最阴,因为它不产生任何错误日志。

6.1 取数中继放在临时目录

后端要查天气和实时资讯,走的是一个转发脚本。这个脚本最初被放在临时目录(/tmp)里,用 setsid nohup 起的——没有服务单元、没有开机自启。

结果:机器一重启、或者临时目录被清理,中继就静默消失。而当时后端的取数逻辑只走中继,于是所有联网问题 100% 失败,日志上只看到"查询无结果",看起来像天气接口挂了。

两条修法,缺一不可:

  1. 把长期依赖的服务从临时目录搬到家目录,并交给系统服务管理器(开机自启 + 崩溃自动重启 + 日志落盘)。一次性起的进程是定时炸弹,不是部署。
  2. 取数改成"本机直连优先、中继兜底"。我们之前有个错误结论叫"这台机器没有外网",实测它有真直连;把唯一通道押在中继上,等于给整条链路加了一个单点。

6.2 订单状态机的三个坑

坑表现原因
跨天金额撞车今天分配的价格,命中了昨天一张未付的单唯一金额只在本日池内查重,没查历史未付
状态只写盘不回填标记"已付款"后渲染订单页直接报类型错台账 append-only,内存里的对象 paid_amount 仍是空
待付款劫持新下单客户说"我要买 X",系统当成"给上一单补需求",永远建不了新单"待补需求"的判定排在"新下单识别"之前,且窗口无限长

第三个坑的修法值得记:任何"承接上一轮上下文"的判断,都必须排在"识别新意图"之后,并且带时间窗。否则一次没付完的单会变成黑洞,把后面所有新需求吸进去。

七、端到端验收:真实模型跑完要 100 秒,别用 10 秒超时判死

这类后端做端到端测试有个陷阱:本地 27B 真跑一次生成,大约 100 秒。而绝大多数 WebSocket / HTTP 客户端的默认单次等待是 10 秒。

更隐蔽的是写法。我们的测试脚本里曾经这样写:

try:
    msg = ws.wait_for(timeout=10)
except Timeout:
    break      # ← 错:第一次空窗就退出,长任务永远等不到交付

正确写法是 continue 并累计总时长上限。break 和 continue 一字之差,决定了你的测试是"验证了交付"还是"只验证了没崩"。

还有一个纯粹的环境坑:在中文 Windows 控制台里跑测试脚本,如果不设 UTF-8,打印带 emoji 的日志会直接抛 UnicodeEncodeError。看着像后端崩了,其实只是测试脚本在打印时崩了。下结论前先把编码设好——这一类"假崩溃"骗过我们不止一次。

八、上线前的自查清单(照顺序打勾)

  1. 拿到空回复时,先看 finish_reason 与 reasoning_content,再看 prompt。
  2. 确认关思考的参数写在 chat_template_kwargs 里,不是顶层字段;并在返回体里验证确实生效(finish_reason=stop)。
  3. 合格判据是"正文 + 长度/结束原因"的合成条件,不是 if content:。
  4. 降级阶梯的每一级都换了参数;同参数重试不算重试。
  5. 跑一遍"问价"用例,确认没有真的去生成东西(这条是成本红线)。
  6. 所有长期依赖的服务都不在临时目录,且有开机自启与崩溃重启。
  7. 关键外部依赖至少有两条路(直连 + 兜底),不是单点。
  8. 端到端测试的等待策略是累计超时,不是单次 10 秒;测试环境编码已设 UTF-8。
  9. 每次改完都跑固定的回归集(对话 / 文档 / 出图 / 报价 / 风格),而不是只测刚改的那条。

九、说句实话:什么时候才该换模型

这五层排查下来,我们一次都没换模型。但换模型确实有它该出场的时候,判据是这三条同时成立:

反过来,只要症状是"空、短、慢、答非所问",八成在这五层里,跟模型是谁关系不大。把参数问题当能力问题,代价是一直在换模型、一直在原地。

如果你手上也有一个"时不时回不出内容"的客服或助理后端,可以先按这份清单从第一层往下走一遍,多数情况不用动模型。需要人一起看现场的话:

免费自检清单(本文这五层整理成的 9 项验收 + 3 条边界,不用留联系方式):下载《AI 客服/助理后端:上线前 9 项验收清单》→

看企业服务与报价 →

相关实录:AI 客服的 5 类真实故障复盘 · 单卡跑 27B 做客服的限流与降级 · 主模型怎么选:13 个场景的应变对比

常见问题

Q1:为什么"重试两次"解决不了空回复?

因为思考长度由同一组参数决定,同参数重试基本得到同样长度的思考,也就同样撞预算上限。要让重试有意义,必须每级换参数(升预算、换关思考方式、换任务拆分)。

Q2:怎么一眼看出是思考吃掉了预算?

两个信号同时出现即可确认:finish_reason 是 length,且 reasoning_content 很长而 content 很短或为空。如果 finish_reason 是 stop 但正文很短,那是另一类问题(提前收尾),要看判据而不是预算。

Q3:线上模型也会有这个问题吗?

会,但形态不同:线上模型通常把思考放在单独的字段或干脆不暴露,你看到的是"输出被截断"。判断方法一致——先看结束原因,再看正文长度,别先改 prompt。

Q4:这套五层顺序能套到别的后端吗?

能。抽象出来就是:①输出预算 → ②参数是否真生效 → ③合格判据 → ④意图路由优先级 → ⑤外部依赖与状态机。任何"生成式 + 有外部工具 + 有交易状态"的后端都适用这个顺序。

Q5:本地模型和线上模型,排障重点一样吗?

前四层一样;第五层差别很大。本地部署多出"进程会不会被重启带走""临时目录会不会被清"这类问题,线上则多出配额、限流、灰度。所以本地部署更该把"长期依赖放哪、由谁拉起"当成一等公民。