「抱歉,这次没生成出内容。」——这句话我们收到过太多次。第一次的反应是换模型,第二次的反应是把上下文砍短,第三次开始怀疑硬件。真正修好它花了两周,结论说出来有点扫兴:空回复只是最外层的症状,模型本身一次都没坏。
这篇文章把我实际走过的五层按顺序写下来。之所以强调「顺序」,是因为这五层的症状互相伪装:模型层的问题看起来像路由层,路由层的问题看起来像通道层,通道层的问题看起来像模型不会用工具。顺序错了,你会在错误的那一层无限重试。
先把我们遇到的三个客户侧投诉摆出来,它们后来被证明分别落在五层里的不同层:
| 客户看到的现象 | 第一反应 | 真实根因所在层 |
|---|---|---|
| 生成文档回「这次没生成出内容」 | 模型能力不行 | 模型层:思考文本吃满输出预算 |
| 交付了几十字的碎片,但没报错 | 模型偷懒 | 模型层:合格判据缺失,碎片被当成成品 |
| 客户问「出图多少钱」,客服真给他画了张图 | 模型不听话 | 路由层:任务识别抢在意图识别前面 |
第三个例子值得多说一句:它不是体验问题,是成本事故——每一次误判都在真金白银地烧算力。所以排查顺序的第一原则是:先排除"花了钱但做错事"的那一类,再谈话术。
我们在本地部署的是一个 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 列表里必须排最前面。否则「数字人多少钱」会先命中产品介绍条目,客户问价却收到一段产品说明书。
最后这层最阴,因为它不产生任何错误日志。
后端要查天气和实时资讯,走的是一个转发脚本。这个脚本最初被放在临时目录(/tmp)里,用 setsid nohup 起的——没有服务单元、没有开机自启。
结果:机器一重启、或者临时目录被清理,中继就静默消失。而当时后端的取数逻辑只走中继,于是所有联网问题 100% 失败,日志上只看到"查询无结果",看起来像天气接口挂了。
两条修法,缺一不可:
| 坑 | 表现 | 原因 |
|---|---|---|
| 跨天金额撞车 | 今天分配的价格,命中了昨天一张未付的单 | 唯一金额只在本日池内查重,没查历史未付 |
| 状态只写盘不回填 | 标记"已付款"后渲染订单页直接报类型错 | 台账 append-only,内存里的对象 paid_amount 仍是空 |
| 待付款劫持新下单 | 客户说"我要买 X",系统当成"给上一单补需求",永远建不了新单 | "待补需求"的判定排在"新下单识别"之前,且窗口无限长 |
第三个坑的修法值得记:任何"承接上一轮上下文"的判断,都必须排在"识别新意图"之后,并且带时间窗。否则一次没付完的单会变成黑洞,把后面所有新需求吸进去。
这类后端做端到端测试有个陷阱:本地 27B 真跑一次生成,大约 100 秒。而绝大多数 WebSocket / HTTP 客户端的默认单次等待是 10 秒。
更隐蔽的是写法。我们的测试脚本里曾经这样写:
try:
msg = ws.wait_for(timeout=10)
except Timeout:
break # ← 错:第一次空窗就退出,长任务永远等不到交付
正确写法是 continue 并累计总时长上限。break 和 continue 一字之差,决定了你的测试是"验证了交付"还是"只验证了没崩"。
还有一个纯粹的环境坑:在中文 Windows 控制台里跑测试脚本,如果不设 UTF-8,打印带 emoji 的日志会直接抛 UnicodeEncodeError。看着像后端崩了,其实只是测试脚本在打印时崩了。下结论前先把编码设好——这一类"假崩溃"骗过我们不止一次。
finish_reason 与 reasoning_content,再看 prompt。chat_template_kwargs 里,不是顶层字段;并在返回体里验证确实生效(finish_reason=stop)。if content:。这五层排查下来,我们一次都没换模型。但换模型确实有它该出场的时候,判据是这三条同时成立:
反过来,只要症状是"空、短、慢、答非所问",八成在这五层里,跟模型是谁关系不大。把参数问题当能力问题,代价是一直在换模型、一直在原地。
如果你手上也有一个"时不时回不出内容"的客服或助理后端,可以先按这份清单从第一层往下走一遍,多数情况不用动模型。需要人一起看现场的话:
免费自检清单(本文这五层整理成的 9 项验收 + 3 条边界,不用留联系方式):下载《AI 客服/助理后端:上线前 9 项验收清单》→
相关实录:AI 客服的 5 类真实故障复盘 · 单卡跑 27B 做客服的限流与降级 · 主模型怎么选:13 个场景的应变对比
因为思考长度由同一组参数决定,同参数重试基本得到同样长度的思考,也就同样撞预算上限。要让重试有意义,必须每级换参数(升预算、换关思考方式、换任务拆分)。
两个信号同时出现即可确认:finish_reason 是 length,且 reasoning_content 很长而 content 很短或为空。如果 finish_reason 是 stop 但正文很短,那是另一类问题(提前收尾),要看判据而不是预算。
会,但形态不同:线上模型通常把思考放在单独的字段或干脆不暴露,你看到的是"输出被截断"。判断方法一致——先看结束原因,再看正文长度,别先改 prompt。
能。抽象出来就是:①输出预算 → ②参数是否真生效 → ③合格判据 → ④意图路由优先级 → ⑤外部依赖与状态机。任何"生成式 + 有外部工具 + 有交易状态"的后端都适用这个顺序。
前四层一样;第五层差别很大。本地部署多出"进程会不会被重启带走""临时目录会不会被清"这类问题,线上则多出配额、限流、灰度。所以本地部署更该把"长期依赖放哪、由谁拉起"当成一等公民。