Streaming 不是逐字打印:从 vLLM 看大模型增量输出的状态机
很多人第一次看大模型 streaming,会把它理解成:
模型每生成一个 token,服务端就把这个 token 打给前端。
这个理解只对了一半。
如果只是普通文本补全,确实可以近似这样想。但一旦进入 chat completion,事情马上复杂起来:输出里可能有普通回答、reasoning、tool call name、tool call arguments、控制 token、usage chunk、finish_reason。更麻烦的是,tool call arguments 可能不是一次性出来的,而是一段一段流出来的半截 JSON。
所以更准确的说法是:
LLM streaming 不是逐字打印。
它是服务端把内部 token 生产流,整理成客户端能理解的增量协议事件。
这篇只讲 streaming 本身,不讲 UI 展示 bug,也不讲 Mermaid。主线是 vLLM 的 OpenAIServingChat.chat_completion_stream_generator():它到底怎样把 engine 的 RequestOutput 异步流,转成 OpenAI 兼容的 chat.completion.chunk。
源码阅读版本是 vLLM commit 752a3a504485790a2e8491cacbb35c137339ad34。
1. 先分清三层 streaming
“streaming” 这个词至少有三层含义:
第一层:HTTP/SSE 层
服务端不断发 data: {...}\n\n,最后发 data: [DONE]\n\n。
第二层:serving engine 层
LLM 经过 prefill 后进入 decode,每轮为每个活跃请求生成后续 token。
第三层:语义 parser 层
把 token delta 分类成 content、reasoning、tool_calls,必要时先缓存,不马上发。
OpenAI 文档里说,Chat Completions 开启 stream=True 后,返回的是 data-only server-sent events;每个 chunk 里的增量放在 delta 字段,不是最终 message 字段。delta 可能包含 role、content,也可能什么内容都没有。
这个“也可能什么都没有”很关键。
它说明 streaming API 的最小单位不是“人类可见字符”,而是“协议事件”。有些事件只是占位、状态、结束标记或统计信息。vLLM 也遵守这个思路:它 yield 出来的不是裸 token,而是 SSE 片段:
yield f"data: {data}\n\n"
...
yield "data: [DONE]\n\n"
这就是 HTTP 层的 streaming。
2. 为什么不能简单 append 文本
看一个工具调用输出。完整形式可能长这样:
{"name": "search", "arguments": {"query": "vLLM"}}
但 streaming 下,模型可能分成很多 delta:
{"name"
: "search"
, "arguments"
: {"query"
: "vLLM"}}
如果服务端把每一段都当普通文本吐给用户,体验会很差:
用户看到一堆半截 JSON。
前端不知道这是正文还是工具调用。
工具执行器不知道参数是否完整。
中途失败时不知道该回滚哪一段。
所以 streaming 下的 parser 必须维护增量状态。它要判断当前 delta 属于哪一类:
普通 content
reasoning
tool call name
tool call arguments
控制 token
还不能发给用户的半截结构
这也是 vLLM 里 Parser.parse_delta() 的职责。抽象接口的 docstring 直接说它是在解析单个 streaming delta,并通过内部 stream state 编排 reasoning 和 tool call 提取。
换句话说,streaming 的关键不是“收到一点就发一点”,而是:
收到一点 -> 更新状态机 -> 判断是否形成可发布 delta -> 再决定发不发。
3. vLLM 的 streaming 主循环
chat_completion_stream_generator() 接收一个 result_generator:
result_generator: AsyncIterator[RequestOutput]
这个 generator 来自 engine。它背后的世界是调度、prefill、decode、KV cache、continuous batching。serving 层不直接跑 Transformer forward,它只消费 engine 不断产出的 RequestOutput。
主循环长这样:
async for res in result_generator:
...
for output in res.outputs:
delta_text = output.text
...
第一次迭代时,vLLM 先发一个带 role 的空内容 chunk:
DeltaMessage(role=role, content="")
这一步的意义是让客户端尽早知道 assistant message 已经开始。真正的文本、reasoning 或 tool_calls 会在后续 chunk 里来。
之后,每个 output 会走三步:
1. 取出 delta_text / token_ids / logprobs
2. 如果有 parser,调用 parser.parse_delta(...)
3. 把 DeltaMessage 包成 ChatCompletionStreamResponse,再 yield SSE
核心分支是:
if parser is not None:
delta_message = parser.parse_delta(...)
else:
delta_message = DeltaMessage(content=delta_text)
没有 parser 时,才近似等于“把文本 delta 直接发出去”。只要启用了 reasoning parser 或 tool parser,就不能这么粗糙。
4. 最重要的一行:可以不发
vLLM streaming 里有一段非常能说明问题:
if delta_message is None:
if output.finish_reason is None and not request.return_token_ids:
continue
delta_message = DeltaMessage()
这段逻辑的意思是:
如果 parser 判断当前 delta 还不能形成用户可见消息,
并且这次也不是最终 chunk,
那就直接 continue,不给客户端发这个 chunk。
为什么需要这样?
因为某些 token 只是结构边界或控制 token。例如:
</think>
<tool_call>
JSON 的半截 key
函数参数的半截字符串
这些东西对 parser 有意义,对用户不一定有意义。服务端先吞掉它们,是为了避免把内部格式泄漏给客户端。
这也是 streaming 用户体验的第一条原则:
低延迟不是无脑早发。
真正的低延迟,是尽早发“稳定且语义正确”的东西。
5. parser 其实在维护 phase
vLLM 的 DelegatingParser.parse_delta() 里有一个内部状态对象。它会记住:
reasoning 是否已经结束
tool call 文本是否已经开始
上一段 text 是什么
上一段 token_ids 是什么
历史 tool call 数量
函数名是否已经返回
这些状态让 parser 可以处理跨 chunk 边界的问题。
例如 reasoning 到 content 的切换可能正好发生在一个 delta 中:
...思考内容</think>最终回答第一句
这个 delta 里既有 reasoning 的结束,又有 content 的开始。parser 不能简单把整段归到 reasoning,也不能简单把整段归到 content。它要识别边界、更新 phase,再把边界之后的内容转成可发布的 content delta。
tool call 也是一样。函数名和参数可能不是同时完成的。parser 需要知道现在还在函数名阶段,还是已经进入 arguments 阶段。否则前端会收到错位的 tool_calls。
所以 streaming parser 的本质更像一个小型编译器:
token delta -> 增量词法/语法状态 -> 结构化 DeltaMessage
它不是字符串拼接器。
6. streaming 和非 streaming 的根本差异
非 streaming 可以等模型完整输出后再解析:
reasoning, content, tool_calls = parser.parse(output.text, request)
这时候 parser 有完整字符串,JSON 完不完整、reasoning 边界在哪里、tool call 是否成功,都可以一次性判断。
streaming 不行。它每次只看到当前 delta,必须在信息不完整的情况下做决定:
发出去:用户体验更快,但可能发错半截结构。
先缓存:语义更稳,但用户看到内容更晚。
这就是 streaming 的核心 tradeoff。
做得好的系统,不是把所有东西都缓存到最后,那就退化成非 streaming;也不是每个 token 都直接发,那会泄漏内部格式。它要在两者之间找边界:
普通内容:尽量早发
reasoning:按产品策略决定是否发
tool call:结构未稳定前谨慎发
usage / metrics:通常放在最终 chunk
错误:用流式 error event 结束
7. 和大模型内部原理有什么关系
从模型角度看,自回归 LLM 一次只预测下一个 token:
prompt -> token_1 -> token_2 -> token_3 -> ...
推理服务通常分成两个阶段:
prefill:处理整段输入,建立 KV cache,产出第一个输出 token
decode:每轮基于 KV cache 生成下一个 token
这解释了两个重要体验指标:
TTFT:time to first token,用户等多久看到第一个输出
TBT / TPOT:后续 token 之间是否稳定
Orca 提出 iteration-level scheduling,解决固定 batch 必须等整批请求都结束的问题。vLLM 的 PagedAttention 论文解决动态增长的 KV cache 管理问题,让多请求并发 decode 更可控。Sarathi-Serve 进一步讨论 chunked prefill 和 stall-free scheduling:长 prompt 的 prefill 不应该长时间堵住正在 decode 的请求。
这些论文讲的是 engine 层 streaming 的底座:如何让 token 持续、稳定、低延迟地产生。
但到了 OpenAI Chat Completion API,服务端还要多做一层:
engine 产生 token 流
serving 层产生协议事件流
parser 产生语义 delta 流
这三者不是一回事。
8. 不要混淆 StreamingLLM 和 API streaming
还有一个容易混淆的名字:StreamingLLM。
StreamingLLM 那篇论文研究的是长上下文场景下,如何用 attention sink 让模型在有限窗口缓存下仍然稳定处理很长的 token 流。它关注的是注意力和 KV cache 策略。
本文讲的 API streaming 关注的是另一件事:
模型输出如何被服务端包装成客户端可消费的增量事件。
两者都叫 streaming,但层次不同:
StreamingLLM:模型上下文和 attention 层的问题
Chat Completion streaming:服务协议和增量解析层的问题
把这两个概念分清,很多源码会更好读。
9. 一套可复用的 streaming 设计原则
看完 vLLM 这段代码,可以总结出几条通用原则。
第一,首包要快,但首包不一定有文本。
先发 role chunk,可以让客户端建立 assistant message,占位、滚动、光标都能先动起来。
第二,用户可见流和内部 token 流要解耦。
内部 token 可以包含控制符和半截结构。用户应该看到的是结构化 delta,而不是内部格式。
第三,parser 必须有状态。
只看当前 token,无法判断它属于 reasoning 结尾、tool call 参数,还是普通 content。需要 previous_text、previous_token_ids 和 phase 状态。
第四,允许空 delta 和被吞掉的 delta。
协议层 chunk 不等于可见文本。为了语义正确,有些 delta 就应该被过滤。
第五,最终 chunk 要负责收口。
finish_reason、stop_reason、usage、metrics、system_fingerprint 这些信息,很多都只能在生成结束时准确给出。
10. 最后怎么记
我现在会这样记 vLLM 的 chat streaming:
它不是“模型吐 token,HTTP 打 token”。
它是:
EngineClient.generate 产生 RequestOutput
OpenAIServingChat 维护每个 choice 的输出状态
Parser.parse_delta 把 token delta 归类成语义 delta
ChatCompletionStreamResponse 把语义 delta 包成 OpenAI chunk
SSE 把 chunk 一段段送给客户端
真正难的地方不是 yield f"data: ..."。
真正难的是:在输出还不完整时,系统仍然要尽快给用户反馈,同时不泄漏半截 reasoning、半截 JSON、半截 tool call,也不破坏最终 OpenAI 兼容协议。
这就是大模型 streaming 的核心:
延迟优化是表层。
增量状态机才是骨架。
参考
- OpenAI Streaming API responses
- OpenAI Responses streaming events reference
- HTML Standard: Server-sent events
- vLLM
chat_completion_stream_generator()源码 - vLLM
Parser.parse_delta()抽象接口 - Orca: A Distributed Serving System for Transformer-Based Generative Models
- Efficient Memory Management for Large Language Model Serving with PagedAttention
- Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve
- Efficient Streaming Language Models with Attention Sinks