Prhub

#57045 [UX] Add thinking support to `vllm chat`

原始 PR 作者 mgoin 合并时间 2026-09-16 16:54 文件变更 1 提交数 2 评论 2 代码增减 +19 / -5

执行摘要

`vllm chat` 流式展示 reasoning,思考阶段不再静默

PR body 明确说明动机:"When using vllm chat there was no output while the model was thinking when using a reasoning parser, so this PR starts reading from reasoning or reasoning_content fields to show those deltas"。作者贴出的 Before 截图注释为 "very long wait while thinking before response",After 为 "immediate response streaming after <think> tag",可见痛点是首字等待体感极差,而非功能缺失。

值得精读,但只需 5 分钟:重点看 _print_chat_stream 的状态机与 TTFT 打点两处设计决策,而不是 19 行的体量。

  • 想理解 vLLM 前端如何处理 reasoning 字段差异的读者,可以关注 getattr(delta, "reasoning", ...) or getattr(delta, "reasoning_content", ...) 这种"双字段兼容 + 安全降级"的写法,这在对接多家 OpenAI 兼容后端时是低成本高收益的模式。
  • 关注 CLI/前端体验的工程师应注意到"thinking 不进返回值"这一取舍:它保证多轮历史不被思考链污染,但也意味着需要 reasoning 回传的 interleaved thinking 场景不能靠这里兜底。
  • 如果你维护下游脚本消费 vllm chat 输出,需要评估新增 <think> / </think> 标签对解析逻辑的冲击。
讨论亮点

讨论非常轻量,只有两条人工审批与一条 bot 提示:

  • DarkLight1337(APPROVED):"Could you attach sample output in the PR description for illustration?" —— 唯一实质性反馈,属于可验证性/文档性要求,作者随后补上了 Before/After 截图。
  • hmellor(APPROVED):空评论直接放行并合并,说明 maintainer 认为改动范围足够安全。
  • claude[bot](COMMENTED):fork PR 不跑自动 review,需 maintainer 手动 @claude review 触发,本 PR 实际未执行自动审查。
  • Issue 评论区只有 mgoin 的 "/ci run" 与 github-actions 触发的 Buildkite CI #89136,属于常规 CI 触发,无技术讨论。

值得注意的是:TTFT 口径变化、<think> 标签硬编码写入 stdout、completions 路径未同步这三点,review 中均无人提及。

实现拆解

  1. 入口与作用域:唯一改动文件是 vllm/entrypoints/cli/openai.py,核心函数 _print_chat_stream 是 vllm chat 交互式会话中消费 OpenAI 流式响应的唯一渲染与统计入口,其返回值会被用于拼接该轮助手回复。

  2. 新增 reasoning 解析与双字段兼容:在循环内对 chunk.choices[0].delta 增加 reasoning = getattr(delta, "reasoning", None) or getattr(delta, "reasoning_content", None)。vLLM 的 reasoning parser 通常输出 reasoning_content,部分 OpenAI 兼容后端输出 reasoning;用 getattr 而非属性直取,可在旧版 SDK 或未开 reasoning 的场景下安全降级为 None。

  3. 思考内容的状态机打印:新增局部变量 in_reasoning: bool。首个 reasoning 增量到达时打印 <think> 并置位,随后逐增量透传;正文 delta.content 到达时先补 \n</think> 再复位,然后照旧累加并打印正文。流结束时若仍处于 in_reasoning(例如 max_tokens 在思考中途耗尽),再补一次 </think>,避免终端留下悬空标签。

  4. TTFT 语义调整:原来 TTFT 只在首个 delta.content 处打点,现在改为 if ttft is None and (reasoning or delta.content),即首个可见字符(思考或正文)即计时,使 --stats 的 TTFT 更贴近用户真实感知。

  5. 返回值保持不变:output 仅累加 delta.content,thinking 文本不进入返回值,因此多轮 vllm chat 历史里不会把思考链回灌给模型。

  6. 测试与文档配套:本次没有任何测试文件、文档或配置改动;同文件的 _print_completion_stream(/v1/completions 的 chunk.choices[0].text 路径)也未同步支持 reasoning。

文件 模块 状态 重要度
vllm/entrypoints/cli/openai.py 命令行 modified 6.2

关键符号

_print_chat_stream _print_metrics

关键源码片段

vllm/entrypoints/cli/openai.py core-logic

唯一的改动文件,也是 `vllm chat` 交互式会话的流式渲染与统计入口;`_print_chat_stream` 的新状态机决定了思考内容何时、以何种标签展示,并顺带调整了 TTFT 打点位置。

# vllm/entrypoints/cli/openai.py
# `vllm chat` 的核心流式渲染函数:本次改动集中在这里,负责把
# OpenAI 兼容的流式 delta 逐块打印到终端,同时统计 TTFT 与 TPS。
def _print_chat_stream(stream, stats: bool = False) -> str:
    # output 只累加正文 content,thinking 文本不会进入返回值,
    # 因此多轮会话历史里不会把模型的思考链再回灌给模型。
    output: str = ""
    # in_reasoning 用于记录当前是否处于 <think> 段落内,
    # 决定何时补上开闭标签,避免标签重复或悬空。
    in_reasoning: bool = False
    start: float = time.perf_counter()
    ttft: float | None = None
    completion_tokens: int = 0
    for chunk in stream:
        # usage 通常只出现在最后一个(或 allow_usage 开启时的每个)chunk。
        if chunk.usage is not None:
            completion_tokens = chunk.usage.completion_tokens
        if not chunk.choices:
            continue
        delta = chunk.choices[0].delta
        # 兼容两种 reasoning 字段命名:vLLM reasoning parser 一般给
        # reasoning_content,部分 OpenAI 兼容后端给 reasoning;
        # 用 getattr 兜底可以避免旧版 SDK 缺少该字段时抛 AttributeError。
        reasoning = getattr(delta, "reasoning", None) or getattr(
            delta, "reasoning_content", None
        )
        # TTFT 打点从 " 首个正文 token" 提前到 " 首个可见 token",
        # 让 --stats 输出的 TTFT 与用户实际看到首字的时刻对齐。
        if ttft is None and (reasoning or delta.content):
            ttft = time.perf_counter() - start
        if reasoning:
            if not in_reasoning:
                # 只在思考段开始时打印一次开标签。
                print("<think>", flush=True)
                in_reasoning = True
            print(reasoning, end="", flush=True)
        if delta.content:
            if in_reasoning:
                # 正文出现即代表思考结束,补一个闭合标签并复位状态。
                print("\n</think>", flush=True)
                in_reasoning = False
            output += delta.content
            print(delta.content, end="", flush=True)
    if in_reasoning:
        # 流在思考中途结束(例如 max_tokens 耗尽)时仍要闭合标签,
        # 否则终端会留下未关闭的 <think> 段落。
        print("\n</think>", end="", flush=True)
    print()
    if stats:
        _print_metrics(start, ttft, completion_tokens)
    return output
​
​
# 注意:同文件的 _print_completion_stream 本次未同步改动,
# /v1/completions 路径(chunk.choices[0].text)仍不支持 reasoning 流。

评论区精华

要求在 PR 描述中补充示例输出 documentation

DarkLight1337 在 APPROVE 的同时提出:"Could you attach sample output in the PR description for illustration?",希望作者用实际输出说明改动效果。

结论:作者在 PR 描述中补充了 Before/After 两张终端截图(Before 标注 "very long wait while thinking before response"),并获 hmellor 批准合并。 · 已解决

TTFT 统计口径变更未被讨论 正确性

diff 将 TTFT 打点条件从 `if delta.content` 改为 `if ttft is None and (reasoning or delta.content)`,使启用 `--stats` 时 TTFT 的语义对 reasoning 模型发生变化,但没有任何 reviewer 就此提问。

结论:变更被默认接受并随 PR 合并;属于隐式行为变更,建议后续在 `--stats` 文档或 changelog 中说明。 · unresolved

fork PR 未执行自动 review other

claude[bot] 提示:"This pull request is from a fork — automated review is disabled. A repository maintainer can comment `@claude review` to run a one-time review.",本 PR 未触发一次性自动审查。

结论:仅有人工轻量审批(DarkLight1337、hmellor),无人审查 `_print_completion_stream` 未同步、`<think>` 标签硬编码等边界问题。 · informational

风险与影响

  1. CLI 输出格式变更(低-中):<think> / </think> 直接 print 到 stdout 且无开关。把 vllm chat 输出重定向到文件或管道、或用脚本做快照对比的用户,会看到额外标签;没有 --no-thinking 之类的回退选项,只能用 --stats 之外的方式绕过。

  2. TTFT 统计口径变化(中):ttft 打点提前到首个 reasoning token,所有启用 --stats 的用户在开启 reasoning parser 后 TTFT 数值语义改变,历史数据不可直接比较。对不使用 reasoning parser 的模型无影响。

  3. 状态机边界(低-中):若服务端在单次响应内多次交错返回 reasoning 与 content,会反复打印成对的 <think> / </think>;流被截断时的闭合分支已处理,但没有测试覆盖这些分支。

  4. 缺少测试覆盖(中):仓库没有针对 _print_chat_stream 的流式打印与 TTFT 语义的回归测试,本次也未新增,状态机与 getattr 兼容逻辑属于"改坏不易被发现"的类型。

  5. 路径不一致(低):_print_completion_stream 仍只读 text,走 completions 端点的 reasoning 流照旧静默,两个渲染函数的用户体验出现分层。

  6. 类型注解强化无风险:output: str、start: float、completion_tokens: int 等显式注解是纯风格改动,不改变运行行为。

  • 用户影响:仅影响 vllm chat 命令行交互体验,且是正向提升 —— 使用 DeepSeek / Qwen / Kimi 等带 reasoning parser 的模型时,首字体感延迟从数十秒降到首个思考 token 到达,作者截图已直观展示。
  • 系统影响:不触碰推理引擎、调度器、KV cache、API server,对吞吐、延迟、显存零影响。
  • 团队与生态影响:改动位于 vllm/entrypoints/cli,与 Rust 前端、Responses API 等前端能力补齐方向同属一条线;<think> 标签格式虽未写入文档,但可能被用户视为既定输出格式,后续调整需注意兼容。
  • 后续工作:completions 路径同步、reasoning 展示开关、CLI 流式输出测试是三个自然的 follow-up。
CLI 输出格式变更 TTFT 统计口径变化 缺少测试覆盖 completions 路径未同步 无关闭开关

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论