ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

vLLM流式输出丢失结束标签:从现象到源码修复

vLLM流式输出丢失结束标签:从现象到源码修复

如果你在生产环境里同时用 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(model="MiniMax-M2") params = SamplingParams(temperature=0.0, max_tokens=64) result = llm.generate(["写一句话,结尾用<|end|>标记"], params) print(result[0].outputs[0].text)

底层outputs[0].text里确实有<|end|>。

这就把问题范围缩小了:生成引擎没问题,模型没问题,丢标签的动作发生在 vLLM 的流式响应封装层,或者说,发生在负责解析输出内容的 parser 那一层。

2. 从日志到源码:三层排查,锁定 parser

2.1 先看原始 token,再看业务返回

排查的第一步,是确认结束标签到底有没有作为 token 被生成出来。

开启 vLLM 的详细日志,或者直接在推理代码里打印request_id对应的output对象:

output = await engine.generate( prompt, sampling_params, request_id=request_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 应该是结束标签对应的 id,text末尾也包含它。这证明原始 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 的归属问题

整理一下逻辑链条:

  1. MiniMax M2 使用<|end|>作为普通文本末尾的终止标记,vLLM 的 tokenizer 把它注册为 special token。
  2. vLLM 的流式响应 parser 在处理特殊 token 时,默认不把它们放进content。
  3. 非流式接口因为走的是另一条序列化路径,直接拿了完整text,所以能保留<|end|>。
  4. 流式接口需要按 token 增量切分,切分时特殊 token 被丢弃,于是 delta 序列里没有这个标签。

有人可能问,为什么很多模型用<|im_end|>没有这个问题?因为它们把结束标签作为“停止条件”,模型生成到该 token 就停了,vLLM 也不会把这个 token 当作最后内容返回。但 MiniMax M2 的结束标签更像是“正文末尾的句号”,模型期待它出现在生成文本里,客户端也依赖它。两套约定混在一起,parser 就按了自己的默认偏好处理。

3. 修复实操:三套能落地的方案

3.1 方案一:业务侧兜底补标签

最简单、风险最小的方式:不在 vLLM 层修,而是在业务侧把所有 stream chunk 拼完后,检测<|end|>是否存在,没有就根据finish_reason=stop自动补上。

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( content=expected_end, )

这里要注意:向最后一个 chunk 追加结束标签后,finish_reason仍然保留在同一个 chunk 里,符合 OpenAI 协议。客户端收到这个 chunk 时,会先看到delta.content里有结束标签,紧接着finish_reason=stop,逻辑上完全自洽。

这个 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 场景会用到 MCP(Model 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 协议返回、业务拼接结果三层对齐。比出了故障再排查要省事得多。这个坑踩过一次之后,我后来写任何接大模型流式输出的工具,都会默认留一个“末尾标签校验”的开关,宁可多一次判断,也不让结束标记悄无声息地消失。

返回列表