快速结论:这个报错通常出现在 vLLM 的 scale-out token 路径上,也就是走 render → /inference/v1/generate → derender 这条链路时:客户端传入的 stop 字符串或 stop_token_ids 没有在 derender 侧被截断,stop 文本会泄漏进最终 content/text,而且 include_stop_str_in_output 被静默忽略;在带 --tokens-only 的宿主上,stop 字符串则完全不生效。优先排查 derender 链路和 stop 处理相关版本/补丁,而不是先怀疑模型或采样参数。
适用环境:Issue 中确认的复现环境为 GKE、1×A100-40G、Qwen3-8B(代码走查场景),独立复核使用 vllm serve Qwen/Qwen3-0.6B --max-model-len 4096,在 0.27.1 与 0.29.0 上均可复现,两条路径的 online_derenderer.py 在两个版本中一致。其余 Python、CUDA、PyTorch 版本 Issue 未给出明确结论,不要臆测。
最快修复方案:暂无确认的一步修复方案。Issue 中已明确的进展是:Bug B(--tokens-only 静默禁用 stop)已在 #57058 修掉;Bug A 的 streaming 侧采取临时拒绝策略(带 stop 字符串时 /derender 返回 400),批量侧修复仍在 #59510;另有 #61757 通过丢弃 stop token 关闭了该 Issue,但 stop 字符串与 completions derender 侧未被覆盖。
注意事项:不要只靠“丢掉最后一个 token”来当作完整修复,这条只覆盖 stop token 场景,不能覆盖 stop 字符串截断和 completions derender 侧。Streaming 侧当前的拒绝行为是临时规避,不是最终修复;derender 侧批量 stop 处理在 Issue 关闭时仍无 owner,需要关注 #59510 状态。所有改动上线前建议做 A/B 对照,因为该 bug 的两条路径 token 数与 finish_reason 完全一致,只有 stop 处理和 stop_reason 不同。
问题场景
用户在 scale-out 部署形态下使用 vLLM:客户端先调用 /v1/chat/completions/render 或 completions render,把生成的 GenerateRequest 转发到 scale-out 宿主的 /inference/v1/generate,再调用 /v1/chat/completions/derender 得到最终响应。问题出现在这条 render → generate → derender 链路的 derender 环节,涉及 chat 批量、completions 批量以及 chat 流式三种形态。触发条件与是否配置 parser 无关,Issue 中的独立复核明确说明“no parser at all”也能复现。
报错原文
[Bug]: Scale-out token path breaks stop strings: stop text leaks into derendered output (include_stop_str_in_output ignored) and --tokens-only silently disables stop entirely
该 Issue 本身没有抛出 Python 异常或堆栈,属于“静默错误”类问题,核心现象是输出内容与耦合式 vllm serve 不一致。可对照的行为描述如下(原文摘录):
Result: a request with stop=["\n\n"] returns clean truncated text from a coupled vllm serve server,
but through render → generate → derender the client receives the stop string —
plus any partial text decoded past the match point — embedded in content / text.
stop_reason is null on every derendered response.
stop_token_ids leak the same way.
复现对照表中的表现(原文表格摘录):
| case | ordinary route | derender |
| chat, non-streaming, stop: ["three"] | …count "one two | …count "one two three |
| chat, non-streaming, stop_token_ids: [2326] | …count "one two | …count "one two three |
| completions, non-streaming, stop: ["number"] | …rolling a | …rolling a number |
| chat, streaming, stop: ["three"] (14 chunks) | …count "one two | …count "one two three |
| no-stop controls (chat, completions, streamed chat) | byte-identical on both paths | |
原因分析
Issue 中给出的共享根因是:stop 字符串的执行在引擎内部属于文本级操作,位于增量 detokenizer 中;而 scale-out 协议传输的是 token ID,detokenizer 有意不对 token ID 做截断。具体表现为:
- 即便
include_stop_str_in_output=False时某个 token 被排除在 detokenization 之外,它仍会被追加回self.token_ids(vllm/v1/engine/detokenizer.py:111-127)。这是设计行为,只清理文本。 ServingTokens基于完整 token ID 列表构建响应,丢弃了引擎已截断的文本(vllm/entrypoints/scale_out/token_in_token_out/serving.py:326批量、:451流式)。- derenderer 会解码它收到的每一个 token(
vllm/renderers/online_derenderer.py:126、:189、:413以及流式_detokenize_delta路径)。在vllm/entrypoints/scale_out/和online_derenderer.py中检索include_stop_str_in_output与check_stop_strings,命中数为零。
由此导致 Bug A 的两类泄漏:stop 字符串文本进入 content/text;stop_token_ids 同理,因为 detokenizer 在文本层面跳过了 stop token,却把它的 ID 追加回 token_ids(skipped_stop_token_id),derender 又把它解码出来。Issue 指出这需要token 级规则而非文本扫描:当 finish_reason == "stop" 且最后一个 token 属于请求的 stop_token_ids 时,在解码前丢弃它,除非 include_stop_str_in_output 为真。
此外,derender 后的 stop_reason 恒为 null:GenerateResponseChoice 没有该字段,online_derenderer.py 构建 chat/completion choice 时只填 finish_reason,而耦合式路由会从 output.stop_reason 填入该字段。#47313 为 generate 响应补上了 stop_reason(在 2026-09-30 被标为 stale,但仍可合并)。
Bug B 是另一条独立路径:带 --tokens-only 的宿主会静默地完全不应用 stop 字符串。该问题已在 #57058 修复。
环境排查
- 确认 vLLM 版本:Issue 在 0.27.1 与 0.29.0 上均复现,
online_derenderer.py在这两个版本中一致。 - 确认部署形态:是否为 render →
/inference/v1/generate→ derender 的 scale-out 链路,而非耦合式vllm serve。 - 确认 scale-out 宿主是否带
--tokens-only,用于区分 Bug A 与 Bug B。 - 确认请求形态:chat/completions、流式/非流式,是否配置 parser(该 bug 与 parser 无关)。
- 确认是否使用
stop字符串或stop_token_ids,两者都会泄漏。 - 如需提交环境信息,可附上
collect_env.py输出;Issue 中未给出 CUDA、PyTorch、Python 的具体版本结论。
解决步骤
- 先用 A/B 对照确认问题归属:把同一个请求分别走耦合式
vllm serve和 render → generate → derender 两条路径,比较choices[0].message.content/text。若两条路径 token 数和finish_reason一致,仅文本结尾多了 stop 内容,则命中 Bug A。 - 若 host 带
--tokens-only且 stop 字符串完全不生效,属于 Bug B,升级到包含 #57058 的版本即可。 - 对 Bug A 的 streaming 侧:当前实现会在请求带 stop 字符串时让
/derender返回 400,这是临时拒绝策略。可优先尝试在 streaming 场景下避免在 derender 侧依赖 stop 字符串,改为在客户端侧处理。 - 对 Bug A 的批量侧:等待 #59510 合入。该 PR 的修复逻辑是:derender 丢弃末尾 stop token、用
check_stop_strings裁剪 stop 字符串,并设置stop_reason。 - 如果只想验证 stop token 是否泄漏,可优先尝试确认最后一个 token 是否属于请求的
stop_token_ids;若是且finish_reason == "stop",在解码前去丢弃它(除非include_stop_str_in_output为真)。注意这只是 token 级规则,不覆盖 stop 字符串场景。 - 关于
stop_reason恒为null:可关注 #47313(为 generate 响应增加stop_reason),合并后 derender 可透传该字段,批量 stop 处理也无需重新扫描即可知道哪个 stop 触发。 - 升级或打补丁后,重新跑一遍上面的 A/B 对照,包括 chat 批量、completions 批量、chat 流式,以及无 stop 的对照组。
验证方法
用同一请求分别走耦合式 vllm serve 和 render → generate → derender,逐项比对:
choices[0].message.content(chat)或choices[0].text(completions)的结尾是否仍包含 stop 字符串。stop_token_ids对应的 token 是否仍出现在 derender 输出中。stop_reason是否仍为null。- 无 stop 的对照组在两条路径上是否仍保持 byte-identical。
- 流式场景下,derender 在带 stop 字符串的请求上是否返回 400(临时行为),以及取消该限制后 stop 是否被正确截断。
Issue 中的状态是 #61757 通过丢弃 stop token 关闭了该 Issue,但复核者指出 stop 字符串和 completions derender 侧未被覆盖,相关工作仍在 #59510,因此验证时应以 #59510 的覆盖范围为准,不要仅凭 Issue 关闭就认定全部场景已修复。
参考来源
AI 工具推荐
想把多个 AI 模型放在一个入口?
GamsGo AI 集成 ChatGPT、DeepSeek、Gemini、Claude、Midjourney、Veo 等常用模型,适合写作、绘图、视频和日常 AI 工作流。
推广链接:通过此链接购买,我可能获得佣金,不影响你的价格。
这个方案解决了吗?
可以继续搜索完整报错,或查看同一工具的其他排查指南。


