
如果你在生产环境里同时用 vLLM 和 MiniMax M2迟早会碰到一个特别不起眼但极其折磨人的问题流式请求返回的最后一帧里没有结束标签。finish_reason 明明写着 stop对话却像被人掐断了一样少一个明确的终止标记。这篇文章记录的就是一次完整复盘问题现象、排查路径、根因定位以及最后在业务侧和源码侧分别做的修复方案。先说结论这个锅大概率不在模型也不在 vLLM 的生成引擎而是出在响应序列化层的 parser。它负责把原始 token 流整理成 OpenAI 协议里的 content 字段在整理过程中把某些特殊 token 当成“内部标记”吞掉了。MiniMax M2 的结束标签恰好就是这样一个特殊 token于是流式输出里只剩正文没有“句号”。1. 问题现场丢失的结束标签到底怎么了1.1 复现路径和现象我们用 vLLM 部署了 MiniMax M2通过兼容 OpenAI 的/v1/chat/completions接口对外提供服务。最初一切正常直到业务方反馈非流式请求返回的content末尾带了一个|end|但流式请求返回的完整拼接文本末尾没有这个标签。复现方式很简单起一个标准服务vllm serve MiniMax-M2 \ --served-model-name minimax-m2 \ --max-model-len 32768 \ --chat-template minimax_m2.jinja然后分别用普通请求和流式请求测curl http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -d {model:minimax-m2,messages:[{role:user,content:写一句话结尾用|end|标记}],stream:false}非流式返回的choices[0].message.content末尾有|end|。换成stream: true之后把所有choices[0].delta.content拼起来内容是“写一句话结尾用|end|标记”对应的模型输出但结尾没有|end|。更隐蔽的是最后一个 chunk 长这样{ choices: [ { delta: {}, finish_reason: stop } ] }没有任何 content但finish_reason是正确的 stop。也就是说vLLM 认为自己已经正常结束但最终文本里丢了一个 token。1.2 少了这个标签会引发什么连锁反应有人会觉得反正有finish_reason业务侧补一个标签不就行了如果只是展示给用户看确实无所谓。但我们在对接下游时遇到了真实影响对话历史回灌时拼接出的用户侧消息没有结束标记模型容易把上一轮未完成的输出继续补全。做流式关键词触发时业务方把|end|当作“句子完成”的信号丢了这个信号触发逻辑直接不执行。日志归档时抓包保存的原始文本缺少终止符后续做语料清洗时多了一堆“半句话”。这类问题不是偶发而是每次流式请求都稳定复现说明不是采样随机性而是某个环节做了确定性过滤。1.3 初步排除模型侧问题一开始我怀疑是 MiniMax M2 在流式生成时模型本身就没生成结束标签。为了验证直接关掉业务层使用 vLLM 最底层的生成接口打日志from vllm import LLM, SamplingParams llm LLM(modelMiniMax-M2) params SamplingParams(temperature0.0, max_tokens64) result llm.generate([写一句话结尾用|end|标记], params) print(result[0].outputs[0].text)底层outputs[0].text里确实有|end|。这就把问题范围缩小了生成引擎没问题模型没问题丢标签的动作发生在 vLLM 的流式响应封装层或者说发生在负责解析输出内容的 parser 那一层。2. 从日志到源码三层排查锁定 parser2.1 先看原始 token再看业务返回排查的第一步是确认结束标签到底有没有作为 token 被生成出来。开启 vLLM 的详细日志或者直接在推理代码里打印request_id对应的output对象output await engine.generate( prompt, sampling_params, request_idrequest_id, ) for res in output: token_ids res.outputs[0].token_ids text res.outputs[0].text last_token_id token_ids[-1]在流式迭代器跑完后token_ids的最后一个 token 应该是结束标签对应的 idtext末尾也包含它。这证明原始 token 流是完整的。问题只出现在 OpenAI 协议层的 delta 构造环节。vLLM 在流式返回时不是简单地把每一次new_token对应的文本直接塞进delta.content而是有两个与文本解析相关的操作根据聊天模板拆分reasoning_content和content。将已完成 token 与原始文本做 diff提取增量字符串。一旦 parser 认为某个特殊 token 不属于“用户可见正文”它就会在增量计算中被过滤掉。2.2 聊天模板 parser 干了什么MiniMax M2 的对话模板里通常定义了content和reasoning_content两类区块。vLLM 的chat_utils.py会解析模板中的变量名把生成结果分成多个字段。我们的配置里模板关键部分大约是{%- if generation_prompt %} {%- set outputs generate(reasoning_content) %} {{ outputs.reasoning_content }} |end_reasoning| {{ outputs.content }} |end| {%- endif %}这个generate函数在 vLLM 里是自定义模板函数它会把生成文本按照模板变量拆开。注意这里的问题如果模板把|end|写在了outputs.content之外parser 在拆分生成结果时可能把|end|划到“非正文”区域然后在构造content字段时直接丢弃。我一开始怀疑是这个原因所以做了个对照实验把模板中的|end|挪进outputs.content内部{{ outputs.content |end| }}结果流式输出里出现了结束标签。但这样改模板会让语义变得很奇怪因为|end|本质是控制符不是模型正文的一部分而且会影响训练时定义的 tokenizer 行为。这个方法只能用于排查不能作为正式修复。2.3 定位到响应序列化逻辑模板实验证明了方向但真正要修的是响应序列化。打开 vLLM 的vllm/entrypoints/openai/serving_chat.py流式响应构造的核心是_create_chat_completion_stream_response。每次生成新 token 时它会把当前增量文本和缓存文本做对比delta_text new_text[len(previous_text):]接着调用一个内部方法判断增量文本需要放到delta.content还是delta.reasoning_content。这个判断依赖_extract_content和_extract_reasoning_content两个辅助函数。它们内部会基于模板字段名或正则做切割。问题就出在这里切割完正文后_extract_content会过滤掉所有特殊 token。MiniMax M2 的结束标签在 tokenizer 里是注册过的|end|id 对应special_token_ids中的某一个。对于 vLLM 的 parser 来说特殊 token 就是用来标识“字段开始/结束”的不属于正文所以在提取 content 时被舍弃了。换句话说结束标签在原始输出里存在但在“按字段提取正文”的 parser 眼里它只是一个标记不是内容。2.4 真正原因特殊 token 的归属问题整理一下逻辑链条MiniMax M2 使用|end|作为普通文本末尾的终止标记vLLM 的 tokenizer 把它注册为 special token。vLLM 的流式响应 parser 在处理特殊 token 时默认不把它们放进content。非流式接口因为走的是另一条序列化路径直接拿了完整text所以能保留|end|。流式接口需要按 token 增量切分切分时特殊 token 被丢弃于是 delta 序列里没有这个标签。有人可能问为什么很多模型用|im_end|没有这个问题因为它们把结束标签作为“停止条件”模型生成到该 token 就停了vLLM 也不会把这个 token 当作最后内容返回。但 MiniMax M2 的结束标签更像是“正文末尾的句号”模型期待它出现在生成文本里客户端也依赖它。两套约定混在一起parser 就按了自己的默认偏好处理。3. 修复实操三套能落地的方案3.1 方案一业务侧兜底补标签最简单、风险最小的方式不在 vLLM 层修而是在业务侧把所有 stream chunk 拼完后检测|end|是否存在没有就根据finish_reasonstop自动补上。def collect_stream(stream): full_text finish_reason None for chunk in stream: delta chunk.choices[0].delta if delta and delta.content: full_text delta.content if chunk.choices[0].finish_reason: finish_reason chunk.choices[0].finish_reason if finish_reason stop and not full_text.endswith(|end|): full_text |end| return full_text优点改动小几分钟就能上线对 vLLM 无侵入。缺点只治标。如果以后有其他特殊 token 也被丢弃需要不断补白名单。而且如果客户端很多每个客户端都要改。适合场景临时规避、验证业务逻辑、或者无法修改 vLLM 集群配置的团队。3.2 方案二通过请求参数强制走非流式解析如果只有少数接口必须保证完整文本可以在请求时禁用 vLLM 内部的“字段拆分 parser”。具体做法是修改聊天模板让 vLLM 不要解析reasoning_content只保留一个content字段{%- if generation_prompt %} {{ generate(content) }} {%- endif %}配合请求参数extra_body{chat_template_kwargs: {no_reasoning: True}}不同 vLLM 版本略有差异vLLM 会跳过 reasoning_content 的解析逻辑把模型生成文本从头到尾当作一个整体content。我们实测过关闭 reasoning 解析后流式输出的 delta 里会包含|end|。代价是如果你确实需要 reasoning 内容和正式回答分离这个方案不能接受。适合场景MiniMax M2 只做对话、不需要思维链切分的情况。3.3 方案三改源码在流式响应里补回特殊 token这是根治方案但需要维护一个自己的 vLLM fork。我们选择在serving_chat.py中增加一个配置开关keep_special_tokens_in_content默认关闭。开启后流式响应的增量计算不再过滤特定结束标签。核心 patch 思路# 在增量文本构造结束后新增一个保留逻辑 def _append_delta_content(delta_text, new_text, previous_text, token_ids, special_tokens_keep): # 原有逻辑过滤 special token for token_id, token_str in zip(token_ids, ...): if token_id in special_token_ids: if token_id not in special_tokens_keep: continue delta_text token_str return delta_text更稳妥的做法不是直接管 token而是在整个 stream 结束时如果检测到finish_reason stop且当前 content 末尾缺少配置的terminator就手动向最后一个 chunk 追加一个delta.content。if finish_reason stop: expected_end getattr(self, custom_eos_token, |end|) if not content.endswith(expected_end): final_delta ChatCompletionStreamResponseDelta( contentexpected_end, )这里要注意向最后一个 chunk 追加结束标签后finish_reason仍然保留在同一个 chunk 里符合 OpenAI 协议。客户端收到这个 chunk 时会先看到delta.content里有结束标签紧接着finish_reasonstop逻辑上完全自洽。这个 patch 需要在 vLLM 的_create_chat_completion_stream_response循环外部处理而不是在循环内部追加。因为如果结束标签在最后一个 token 已经被_extract_content丢弃循环内部拿不到它只能在序列化完成前做最后的补救。3.4 三个方案对比方案侵入性是否根治适用场景维护成本业务侧补字符串无否客户端可控、快速验证低关闭 reasoning 解析低部分不需要思维链拆分低修改 vLLM 源码高是需要完整保留特殊 token 的生产环境需跟随上游版本如果你只是内部用我建议方案一。如果你给外部客户提供 API且客户使用流式输出做语义解析我建议直接上方案三。方案二适合在排查阶段做对照实验不太适合作为长期配置因为会丢失模型本身可能提供的 reasoning 内容。4. 同类问题横向盘点不要在同一个 parser 上踩第二次4.1 部署 DeepSeek / Qwen 系列时也会遇到内容字段分离问题不只是 MiniMax M2。社区里有一堆关于 vLLM 部署 DeepSeek 模型时reasoning_content和content丢失内容的问题。DeepSeek 的输出仍然有完整的思维链标签但 vLLM 的 parser 在上层通过模板变量拆分后经常出现某些 chunk 没有content、只有reasoning_content的情况。比如请求一个开放性问题模型先输出一大段思考再输出回答。流式返回时delta里会突然出现空 content 的帧或者 content 首字缺失。这类问题和结束标签丢失本质相同parser 在切分字段时对多重特殊 token 的位置、顺序、配对非常敏感。我们后续在测试一个类似 Qwen 新模型的配置项目内部叫 qwen3.8-flash-next严格说是基于 Qwen 系列的测试分支时同样观察到流式输出末端生成符号被吞。经验是换模型后先做一次“流式完整性测试”把流式拼接文本和底层outputs[0].text做逐字符 diff不要只在对话页面点两个问题就结束。4.2 MCP 工具流式输出到文件时的截断问题现在很多 Agent 场景会用到 MCPModel Context Protocol工具让模型把生成内容流式写入本地文件。我们当时接了一个 MCP 文件写入工具客户端收到模型流式输出后每拿到一段文本就 append 到文件里并以|end|作为写文件的终止信号。结果就是文件内容看起来完整但末尾少了一个终止标记脚本里检测 EOF 的逻辑永远等不到结束文件被超时强制关闭。这个问题和上面 vLLM 丢标签是叠加的vLLM 丢了|end|MCP 客户端收到 stop 后直接关闭了流甚至没有机会做“最后一段 flush”。后来我把文件写入触发条件改成了finish_reason stop而不是text.endswith(|end|)问题才缓解。这提醒我一点在 AI 应用接入层要尽量避免把某个特殊 token 当作唯一的流结束信号。协议层的finish_reason才应该是权威信号特殊 token 最多只能作为辅助校验。4.3 安装 vLLM 会影响 torch 版本这件事再补一个环境层面的坑。有同事在一台已经装好 PyTorch 的机器上直接pip install vllm安装程序把原有 torch 替换成了 vLLM 依赖的版本。这个动作本身不一定报错但会悄无声息改变模型加载时的算子行为。我们在排查丢标签问题时一开始怀疑是 flash attention 版本导致输出异常于是把 torch 版本回退了一版。回退后 MiniMax M2 底层生成结果还是包含|end|但 vLLM 的 parser 表现出了不同的行为——有些特殊 token 在 tokenizer 里的注册顺序变了导致针对特殊 token 的过滤逻辑命中范围也变了。所以如果你也遇到“更新环境后某些 token 消失”先看pip freeze里 torch、vllm、transformers 三个包版本是否兼容。vLLM 对 torch 版本有强约束不要随意用--upgrade装最新版。4.4 通用的排查清单打印底层outputs[0].text和token_ids确认模型是否生成了该 token。对比非流式和流式接口返回内容差异。查看聊天模板里generate函数拆分流程确认结束标签归属哪个字段。在流式响应构造逻辑里打印new_text、previous_text和delta_text的增量。检查 tokenizer 里结束标签是否注册为 special token若是观察 vLLM 是否有过滤特殊 token 的逻辑。最后再检查环境依赖版本排除 torch 占用影响。5. 复盘心得我在实际排查里最深刻的体会是遇到“丢内容”别急着怀疑模型也别急着怀疑显存先看 parser。这类问题往往不是模型没生成而是某一层序列化逻辑把“非正文”当成了“不需要返回的控制符”。MiniMax M2 的结束标签被丢本质是特殊 token 归属策略与业务语义之间的冲突。最后的建议是如果你在生产环境部署深度求索、通义等系列模型或者任何带有自定义特殊符号的新模型上线前花半小时做一次流式完整性校验把底层文本、OpenAI 协议返回、业务拼接结果三层对齐。比出了故障再排查要省事得多。这个坑踩过一次之后我后来写任何接大模型流式输出的工具都会默认留一个“末尾标签校验”的开关宁可多一次判断也不让结束标记悄无声息地消失。