大模型流式输出里一个看不见的字符:我怎么修掉 vLLM 里丢失 的 bug
上篇讲了调度器崩引擎的问题,这篇讲个 "小" 得多、但同样生产事故的 bug:一个不该被吞掉的结束标签,被状态机静默吃掉了。
现象:思考过程少了个 "句号"
有用户报:用 MiniMax M2 模型,开了自动 tool calling,输出变成了这样:
期望:I should add them.</think><minimax:tool_call>...
实际 content:<think>I should add them.
工具调用本身解析对了,唯独结尾的 不见了。客户端靠这个标签区分 "思考过程" 和 "正式回答",标签一丢,流式输出永远不知道思考什么时候结束。
而且它不报错、不崩、不 warning—— 就是悄悄少了几个字符。这种 bug 最难查,因为输出看起来 "差不多对"。
根因:一个状态机,两种语义
vLLM 的 tool/reasoning parser 是个状态机。 在不同模式下含义不一样:
- 普通模式: 是 "思考结束" 的边界信号,遇到它应该发出 REASONING_END 事件,把思考从正文里剥掉。
- append-think 模式:用户要求把思考保留在正文里,这时 不是边界,它就是普通文本数据,应该原样留着。
问题就出在这台状态机的一条转移规则上:
(ParserState.CONTENT, "THINK_END"): Transition(ParserState.CONTENT, ()),
# ^^ 空元组 = 不发任何事件
在 append-think 模式下,正文一开始就处于 CONTENT 状态。这时文本里再遇到 ,命中了上面这条规则 ——匹配到了,但什么事件都不发,于是这个字符被静默消费掉了。
它对普通模式是对的(普通模式下 由 REASONING→CONTENT 那条转移处理),但 append-think 模式走的是另一条路。同一个字符,在两种语义下被错误地共用了一条 "吃掉它" 的规则。
讽刺的是,开头的 没丢 —— 因为状态机里压根没有 (CONTENT, "THINK_START") 这条转移,它就 "漏网" 成了普通文本,反而对了。
修复:让它把字符吐出来
改动只有一行:这条转移匹配到 时,发一个 TEXT_CHUNK 事件,把这个字面字符追加到正文,而不是吞掉。
(ParserState.CONTENT, "THINK_END"): Transition(
ParserState.CONTENT, (EventType.TEXT_CHUNK,)),
普通模式的 REASONING→THINK_END 仍然负责 "思考结束" 边界,所以原有行为一点没变 —— 这就是为什么这种 parser bug 不能乱改边界条件:你以为在修一个 mode,很容易顺手改坏另一个 mode。
真正的功夫在测试里
修一行很简单,难的是怎么证明 "改对了、没改坏别的"。
这种流式 parser 最坑的地方是 chunk 边界: 这个 8 个字符,可能一次吐出来,也可能被切成 </th + ink>,甚至跨好几个 chunk。状态机是增量喂字符的,边界切在哪,直接决定它能不能正确识别。
所以回归测试不是喂一次完整字符串就完了,而是要把同一个输入在不同的 chunk 切分方式下都喂一遍:
- 整串一次性;
- 在 中间切开;
- 标签每个字符单独一个 chunk。
只要有一个切法没过,就是状态机有 bug。
这也是我做开源贡献攒下的一个经验:流式 /parser 类问题,主动测不同的 chunk 边界。边界用例本身往往就是最有价值的测试贡献。
这个 bug 的启发
- "静默吞数据" 比 "崩" 更危险。崩了你立刻知道;悄悄少个标签,要等客户端解析错乱才暴露,而且很难定位。
- 状态机里同一个 token 在不同模式下语义不同,是 parser 类 bug 的高发地。改任何一条转移前,先问:还有哪些模式会走到这条边?
- 修复要最小且隔离。一个 TEXT_CHUNK 事件,而不是重写整个状态机 —— 给 maintainer 评审的风险最小,也最容易合。
基于向 vLLM 提交的真实 PR(MiniMax M2 append-think parser 保留闭合 ),含不同 chunk 边界的回归测试。