两台机器,跑的是同一个系列的模型,报错率几乎一模一样:一台 3.29%,一台 3.32%。报错都出在同一个地方:agent 调完工具、把结果塞回去请求下一轮的时候。
Hugging Face 上正好有人发了一套「修好的聊天模板」,README 写着能消除 80% 以上的卡顿、KV 缓存 100% 命中、在 llama.cpp 上快 80%。我两台都试了。第一台换完,19 种参数形状从挂 7 个变成挂 0 个。第二台的隔离副本上,同一个坏请求照样 500,返回只用了 3 毫秒。
那个 3 毫秒是全文最有用的数字。想直接拿判据的看第 4 章,一个除法;想知道 README 里那 80% 怎么来的,看第 2 章。
1. 一个 3.3% 的报错
先说聊天模板是什么。你给推理服务发的是一个 messages 数组,模型认识的却是一长串带特殊标记的文本。中间负责「拼」的是一段 Jinja 小程序,就是聊天模板,通常跟着模型权重一起发布。
第一台是一台 M1 Max,跑 Qwen3.6-35B-A3B,前面挂一个我自己的 Python 小服务负责套模板。五天里约 4,900 次请求,162 次报同一个错:
template render failed: Can only get item pairs from a mapping
162 次按天分布是 30、69、35、16、12,天天都有,不是某一次事故。其中 95 次的历史消息里带着工具调用。
(两份笔记里分母一份写 4,850、一份写 4,927,是同一天前后查了两次、时间窗边界不同,所以报错率我写的是 3.29%,按 4,850 算是 3.34%。量级不变。)
原因不难找。OpenAI 格式里,tool_call.function.arguments 标准写法是一个 JSON 字符串。官方模板却默认它是个字典,直接对它用 |items 取键值对。字符串没有键值对,Jinja 当场抛错,模型一个字都还没算。
2. README 里的 80%,测试脚本里一次引擎都没调过
社区版模板叫 v21.3,16,289 字节,官方那份 7,764 字节。逐行比对相似度 0.22,几乎是重写。
它确实加了不少东西:接受字符串形式的 arguments、兼容中途插进来的 system 消息、兼容两家的思考内容字段、工具结果过长时截断、连续出错时往上下文里塞纠错提示。仓库自带的 9 个单测我跑了,9/9 过。
然后我去找那几个性能数字的出处。仓库里的测试脚本 184 行,全是纯 Jinja 的输入输出夹具。我数了一下:调用 llama.cpp、vLLM、MLX、HTTP 客户端或任何 benchmark 的地方,0 处。
模板只负责拼字符串,它能影响的性能只有两样:拼出来的 prompt 多长,以及前后两轮拼出来的前缀是不是逐字节相同(决定 KV 缓存能不能复用)。这两样都得在真引擎上量。一个没碰过引擎的测试套件,可以证明「拼得对」,证明不了「快 80%」。
判据很短:看到任何性能数字,先找生成它的那段代码有没有真的调用被测系统。 没有,就当它是作者的预期。
3. 第一台:19 种形状,官方挂 7 个
换现役文件之前我先做了离线对照。把 19 类消息形状分别喂给两份模板,只渲染、不推理:普通聊天、带工具的首轮和续轮、并行工具、中途 system 消息、7 种 arguments 形状(字典、JSON 字符串、坏字符串、数组、标量、null 等)、结构化报错、两种思考字段、超长工具结果。
- 官方模板:7/19 失败
- v21.3:0/19 失败
- 渲染耗时 p95:0.119 毫秒 → 0.193 毫秒
渲染多出来的 0.07 毫秒可以忽略。值得算的是它多拼出来的 token,因为那会进 prefill:
模板额外开销占比 = 新模板多出的 token ÷ 原 prompt 总 token
这是个除法,分子基本是常数,分母是你的 prompt 有多长。我在隔离实例上跑了 46 次真实请求,两条臂各 23 次,全部成功:
- 短的工具调用 prompt:多约 96 token,相对增幅中位数 30.4%,配对延迟中位数慢 6.4%
- 一个真实的 25K token 长 prompt:多 107 到 111 token,占 0.42% 到 0.44%;两次配对延迟一次 +4.2%、一次 −5.8%,平均 −0.8%,就是噪声,测不出区别
所以同一个模板,对短请求是一笔看得见的固定成本,对长请求等于没有。你的流量是哪种,决定这笔账怎么算。
协议层面,v21.3 那条臂坏参数、未知工具、原始 XML 漏出、思考内容漏出、重复调用同一个坏工具全是 0。我就切了:只重启套模板的那个小服务,模型进程 PID 没变。切换后第一批 13 个请求,12 个成功,1 个是维护窗口里的连接错误,模板渲染错误 0 个。
到这儿结论很朴素:它修的是稳定性,没修出速度。README 里那些数字,我一个都没复现到,也没有测到相反的证据,因为我根本没测 KV 缓存命中(第 6 章)。
4. 第二台:5 毫秒的 500
第二台是一台带 16GB 显存的笔记本,跑 llama.cpp,模型是同系列的一个蒸馏版。一万多次请求,3.32% 报错,看起来是同一个病。我差点直接照搬第一台的结论。
报错文本先露了馅:
Failed to parse tool call arguments as JSON
这句和第一台那句不一样。但文本只是间接证据,我想要一个不读源码也能用的判据,于是对现役服务发了两个最小请求,都设 max_tokens=1,历史里塞一个工具调用,只是 arguments 一个合法、一个是截断的坏字符串:
- 合法历史:HTTP 200,1,632 毫秒
- 坏历史:HTTP 500,5 毫秒
失败耗时 ÷ 同规模正常请求耗时 = 5 ÷ 1632 ≈ 0.3%
一个请求只要进了模型,哪怕只生成 1 个 token,也得先把整段 prompt 过一遍 prefill。那需要上千毫秒。5 毫秒连 prefill 的零头都不够,说明它根本没走到模型那一步,死在了前面的某个预处理里。
接着要回答:预处理里到底是不是模板?我用同一个镜像版本起了个隔离副本,挂一个 0.5B 的小模型跑在 CPU 上,把模板换成 v21.3:
- 合法历史:200
- 坏历史:500,3 毫秒,同一句报错
换了模板,失败的位置和耗时都没动。到这一步不看源码也能下结论了:错误发生在模板之前,换模板够不着它。
后来我还是去读了那个版本的源码,对上了。llama.cpp 里有个函数叫 func_args_not_string(),当模板声明自己支持「字典形式的 arguments」时,它会在 Jinja 执行之前,先把历史里所有字符串 arguments 强制 json.parse 一遍,解析失败直接抛 500。官方模板和 v21.3 都声明了这个能力,所以两边都走这条路。上游有两个 issue 描述的是同一个连锁故障,两个放宽解析的 PR 当时都没合并。
这个判据可以迁移到任何多层服务:
失败耗时 ≪ 最便宜的那次正常处理 → 它死在那次处理之前
改哪一层之前,先用耗时把出错的层定位出来。
5. 341 次报错,其实是 39 个会话
第二台那 3.32%,具体是 10,278 次请求里 341 次 JSON 解析失败(错误总数 361,其余 20 次是别的原因,模板渲染错误 0 次)。
341 次听起来像 341 个独立事故。我把每次请求的 prompt 做了哈希:
平均每个坏会话被重放的次数 = 报错次数 ÷ 不同 prompt 哈希数 = 341 ÷ 39 ≈ 8.7
341 次只对应 39 个不同的 prompt,分成 75 串连续爆发,最长一串 20 次。
机制是这样的:模型某次生成工具调用时只吐了半截 JSON,调用方照样把它存进历史。从此这个会话每发一次请求,历史里都带着那半截,每次都在预处理阶段 500。agent 一重试,就又多一条报错。一个坏 token 毒死整个会话。
这对优先级判断很要紧。看「3.32% 的请求失败」,你会以为每 30 次调用就要碰上一次;看「39 个会话被毒死」,修法就很清楚了:在请求进 llama.cpp 之前,把历史里解析不了的 arguments 拦下来做降级,或者给解析器打个宽容补丁。换模板对这两件事一点帮助都没有。
另外,我在那台机器的隔离副本上也顺手做了个 10 次的小 A/B,复杂的数组型工具参数,两份模板都是 10/10 成功、10/10 符合 schema。但 v21.3 的 prompt 从 250 token 变成 450,生成的 token 从 55 变成 99,平均耗时从 781 毫秒到 1,928 毫秒。生成为什么也变长,我没查清。小模型的绝对值不能外推,但它至少说明这个模板不是白送的。
最后的判决:第一台换,第二台不换。
6. 我没做到的
- 没有逐条重放那 162 个原始请求。 我的调用日志只存可读文本,而且在 32K 处截断,没法逐字节还原。我用的是同根因的 19 类形状矩阵加一组真实工具循环来覆盖,所以不能说「162/162 修好」。
- 没测 KV 缓存命中。 第一台的隔离实例没开前缀缓存。README 里「100% 命中」那条,我既没证实也没证伪。
- 隔离实例上下文只开到 65,536,最长测到约 25.8K token。模型标称的 262K 上限没碰。
- 第二台的隔离验证用的是 0.5B 小模型跑 CPU,不是现役那个 35B 蒸馏版。它足够证明失败发生在推理之前,但 token 和耗时的那组对比只能看方向。想测 v21.3 能不能减少「第一次就吐出半截 JSON」,得拿真模型做 A/B,那台机器 16GB 显存放不下第二个实例。
- 第一台切换后我只有 13 个请求的即时样本。 当时挂了一个两小时后复查的定时任务,现在回头找不到它的输出了,所以「长期报错归零」这句我只能说到 13 个请求为止。
7. 换一个「修好的」模板之前,先问四句
- 报错文本里有没有
template render failed这类字样?没有的话,先别动模板。 - 发一个
max_tokens=1的坏请求,看它几毫秒返回。远小于一次 prefill 的耗时,错误就在模型前面的某层,再看那一层是不是模板。 - 把报错按 prompt 哈希去重。报错数 ÷ 哈希数明显大于 1,你面对的是被毒死的会话,先修历史清洗。
- 算一下「多出的 token ÷ 你的典型 prompt 长度」。短请求为主的话,这个模板是要花钱的;性能宣传先找它的测试代码有没有调过引擎。