跳到主要内容

流式输出

流式输出是大模型应用里最常见的交互优化之一。

它不会让模型生成得更快,也不一定减少总耗时,但能让用户更早看到结果开始出现,从而显著改善体感延迟。

一句话概括:

流式输出把一次完整回答拆成多个增量片段,边生成、边传输、边渲染。

如果不做流式输出,用户必须等模型完整生成、服务端拼装结果、网络传输完成后,才能看到最终答案。对于长回答、代码生成、总结、Agent 推理过程,这种等待会很明显。

流式输出要解决的不只是“前端一个字一个字显示”,还包括协议、取消、错误、审计、安全、指标和资源释放。


在请求链路中的位置

一次典型流式请求大致是:

用户请求
-> API 网关 / 鉴权 / 限流
-> prompt / messages 组装
-> tokenizer / chat template
-> 推理服务 prefill
-> decode 逐 token 生成
-> server chunk 封装
-> SSE / WebSocket / HTTP chunked 传输
-> 客户端增量解析
-> UI 增量渲染
-> finish_reason / usage / 日志落库

真正的模型生成仍然发生在 decode 阶段。流式输出只是把 decode 过程中产生的 token 或文本片段尽早发送给客户端。

因此它和这些工程模块强相关:

  • 推理参数max_tokensstoptemperature 会影响输出长度和停止方式。
  • KV Cache:长时间流式生成会持续占用 KV Cache。
  • 并发与批处理:continuous batching 会影响首 token 和 token 间延迟。
  • 可观测性:需要记录 TTFT、ITL、取消率和错误位置。

为什么需要流式输出

LLM 推理通常分成两个阶段:

阶段做什么用户体感
Prefill处理输入 prompt,构建 KV Cache用户还看不到输出
Decode每次生成一个或少量 token可以逐步把结果推给用户

如果回答需要生成 1000 个 token,非流式模式下用户要等 1000 个 token 全部生成完。

流式模式下,只要第一个输出 token 生成出来,就可以开始返回:

非流式:
等待 12s -> 一次性展示完整回答

流式:
等待 1.2s -> 开始展示 -> 持续输出 -> 12s 完成

适合开启流式输出的场景:

  • 聊天助手。
  • 长文总结。
  • 代码生成。
  • RAG 问答。
  • Agent 执行过程展示。
  • 需要用户随时中断的交互。

不一定适合流式输出的场景:

  • 分类、路由、打分等短输出任务。
  • 严格结构化 JSON,且下游必须拿完整对象后才能处理。
  • 批处理任务。
  • 后台异步任务。
  • 输出需要先经过完整审核再展示的高风险场景。

工程上可以同时支持两种模式:交互式入口默认 streaming,后台任务默认非 streaming。


OpenAI-compatible Streaming 结构

很多推理服务会采用 OpenAI-compatible 的 Chat Completions 流式格式。

请求里通常通过 stream: true 开启:

{
"model": "model-name",
"messages": [
{"role": "user", "content": "解释一下 KV Cache"}
],
"stream": true
}

服务端返回的是一串事件,而不是一个完整 JSON。

一个典型 chunk 类似:

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"KV"},"finish_reason":null}]}

结束时通常会返回:

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

需要理解几个核心字段:

字段含义
chunk一次流式传输片段,不一定等于一个 token
delta相对前一次响应的增量内容
delta.content新生成的文本片段
delta.role有些实现会在首个 chunk 返回 assistant
finish_reason停止原因,例如 stoplengthtool_calls
[DONE]流结束标记,不是 JSON 对象

注意:chunk 不等于 token。

原因包括:

  • 服务端可能把多个 token 合并成一个 chunk。
  • tokenizer token 和可见字符不是一一对应。
  • 中文、emoji、特殊符号可能跨字节或跨 token。
  • 网络层、代理层可能改变 flush 粒度。

前端和业务层不要假设“每个 chunk 是一个字”或“每个 chunk 是一个 token”。


SSE、WebSocket 和 HTTP Chunked

流式输出常见传输方式包括 SSE、WebSocket 和 HTTP chunked response。

方式特点适合场景
SSE基于 HTTP,服务端单向推送,浏览器原生支持 EventSource大多数聊天输出
WebSocket双向长连接,协议更灵活需要双向实时控制的复杂交互
HTTP chunked直接分块返回响应体服务端到客户端的简单流式转发

SSE 是 LLM 应用里最常见的选择,因为:

  • 和 HTTP 基础设施兼容较好。
  • 客户端实现简单。
  • 天然是文本事件流。
  • 适合“客户端发请求,服务端持续返回”的模式。

SSE 响应头通常需要:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

如果经过 Nginx、CDN、API Gateway,还要注意关闭或降低缓冲,否则服务端虽然在流式写出,用户仍然可能等到缓冲区满才看到内容。

常见代理配置问题:

  • Nginx 默认 buffering 导致 chunk 被攒住。
  • 平台网关设置了响应超时。
  • CDN 不支持长时间 event stream。
  • Serverless 平台不支持真正流式 flush。
  • 压缩中间件把小 chunk 合并后再发。

上线前必须用真实链路验证,而不是只在本地 curl 通过。


TTFT 与 ITL

流式输出最重要的两个体验指标是 TTFT 和 ITL。

指标全称含义
TTFTTime To First Token从发起请求到收到首个输出片段的时间
ITLInter Token Latency相邻输出 token 或片段之间的间隔

TTFT 影响“多久开始说话”。

ITL 影响“说话是否顺滑”。

用户体感通常是:

  • TTFT 低:模型很快开始响应。
  • TTFT 高:用户觉得系统卡住了。
  • ITL 稳定:输出像连续打字。
  • ITL 抖动大:输出一阵一阵卡顿。
  • ITL 高:每个字慢慢蹦出来。

TTFT 受这些因素影响:

  • 输入 token 长度。
  • prompt 组装和 RAG 检索耗时。
  • prefill 计算耗时。
  • 请求排队时间。
  • batch 调度策略。
  • 网络和代理缓冲。

ITL 受这些因素影响:

  • 模型大小。
  • GPU 算力。
  • decode batch size。
  • 并发数量。
  • KV Cache 压力。
  • speculative decoding。
  • 服务端 flush 策略。

压测时不要只看总延迟。对于聊天类应用,TTFT 和 ITL 通常比 E2E latency 更能解释用户体验。


前端消费与渲染

前端处理流式输出的核心是:增量读取、增量解析、增量渲染。

使用 fetch 读取 stream 时,逻辑通常是:

const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages, stream: true }),
});

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
const { value, done } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });

const events = buffer.split('\n\n');
buffer = events.pop() || '';

for (const event of events) {
if (!event.startsWith('data: ')) continue;

const payload = event.slice(6).trim();
if (payload === '[DONE]') return;

const chunk = JSON.parse(payload);
const text = chunk.choices?.[0]?.delta?.content || '';
appendToMessage(text);
}
}

实现时要注意:

  • 一个网络包里可能有多个 SSE event。
  • 一个 SSE event 也可能被拆到多个网络包里。
  • TextDecoder 要使用 streaming 模式,避免多字节字符被截断。
  • 不要每个 chunk 都触发昂贵的 Markdown 全量渲染。
  • UI 更新需要节流,否则长回答会造成主线程卡顿。
  • 最终完成后再做一次完整 Markdown / 代码高亮渲染。

常见渲染策略:

策略说明
纯文本增量追加最简单,性能最好
Markdown 延迟渲染输出中先显示文本,完成后渲染 Markdown
分段渲染按段落或代码块边界渲染
requestAnimationFrame 合并把多个 chunk 合并到一帧里更新

如果边输出边解析 Markdown,要特别小心半截代码块、半截表格、半截链接和未闭合 HTML。


中断与取消生成

流式输出必须支持取消。

用户点击“停止生成”时,至少要做三件事:

前端停止读取
-> 后端取消上游请求
-> 推理服务释放生成任务和 KV Cache

前端可以使用 AbortController

const controller = new AbortController();

fetch('/api/chat', {
method: 'POST',
body: JSON.stringify(payload),
signal: controller.signal,
});

// 用户点击停止
controller.abort();

后端不能只关闭和浏览器的连接,还要把取消信号传给模型服务。

否则会出现:

  • 用户界面已经停止,但 GPU 还在继续生成。
  • KV Cache 没有及时释放。
  • 并发槽位被僵尸请求占用。
  • 计费和日志仍按完整生成计算。
  • 长回答被取消后仍然挤压其他请求。

取消链路需要在压测里专门验证,尤其是高并发下的大量取消。


服务端转发

很多业务系统不会让浏览器直接访问模型服务,而是通过自己的后端转发:

浏览器
-> 业务后端
-> LLM Gateway
-> 推理服务

业务后端转发时要避免破坏流式特性。

常见错误:

  • 后端先把上游完整响应读完,再一次性返回给前端。
  • 框架默认开启 response buffering。
  • 中间件对响应体做压缩或 JSON 包装。
  • 日志中间件缓存完整 body。
  • 异常处理层把 stream 错误变成普通 JSON。

正确做法是边读上游、边写下游,并在客户端断开时取消上游。

伪代码:

open upstream stream
for each upstream chunk:
validate / transform if needed
write chunk to downstream
flush
on client disconnect:
abort upstream request
release local resources

如果后端需要插入审计、安全或内容处理逻辑,必须明确处理“半截内容”的状态,而不是假设永远能拿到完整答案。


错误处理

流式输出的错误处理比普通 JSON 响应复杂。

因为错误可能发生在不同阶段:

阶段示例客户端表现
首 chunk 前鉴权失败、限流、参数错误可以返回普通 HTTP 错误
输出过程中上游断开、模型 worker 崩溃、超时已经显示了部分内容
结束阶段usage 统计失败、日志写入失败用户可能已看到完整文本

如果首 chunk 还没发出,可以返回标准错误:

{
"error": {
"message": "rate limit exceeded",
"type": "rate_limit_error"
}
}

如果已经开始流式输出,HTTP 状态码通常已经是 200,后续错误只能通过 stream event 传递,或者由连接异常体现。

建议定义内部错误事件格式,例如:

event: error
data: {"code":"upstream_timeout","message":"模型服务超时"}

客户端收到后应该:

  • 停止 loading 状态。
  • 保留已生成内容。
  • 标记回答未完成。
  • 允许用户重试。
  • 不把半截内容当作完整可信答案。

对于结构化输出,流中断尤其危险。半截 JSON 不能直接进入下游业务系统。


Finish Reason

finish_reason 表示模型为什么停止输出。

常见值包括:

含义工程含义
stop正常命中 EOS 或 stop sequence通常可视为完整回答
length达到 max_tokens 上限可能被截断
tool_calls生成了工具调用需要进入工具执行链路
content_filter被内容安全策略截断需要展示安全提示或走审核
error服务或上游异常不能视为完整回答

不要只看是否收到 [DONE]

更可靠的判断是:

  • 是否收到结束事件。
  • finish_reason 是什么。
  • 是否有错误事件。
  • 是否达到业务要求的完整结构。
  • usage 和 token 统计是否正常返回。

如果 finish_reason = length,应该在日志、UI 或下游状态中标记为“被截断”,尤其是总结、代码、JSON 和工具调用场景。


工具调用与流式输出

工具调用会让流式协议更复杂。

普通文本输出是增量拼接:

delta.content += "..."

工具调用可能是增量拼接函数名和参数:

delta.tool_calls[0].function.name
delta.tool_calls[0].function.arguments

其中 arguments 往往是 JSON 字符串的增量片段,不能在每个 chunk 到达时直接解析。

错误示例:

{"city":"北

这只是半截参数,不是非法业务输入。

工程建议:

  • 按 tool call id 聚合参数片段。
  • finish_reason = tool_calls 后再解析 JSON。
  • 解析失败要保留原始增量内容用于排查。
  • 工具执行结果再进入下一轮模型调用。
  • UI 上区分“正在思考文本”和“准备调用工具”。

如果业务需要实时展示 Agent 过程,最好把模型 token stream、工具调用事件、工具结果事件设计成统一事件流,而不是把所有东西都塞进 content


内容安全与审计

流式输出会给内容安全带来一个特殊问题:内容还没完整生成时,就已经展示给用户了。

常见策略有三种:

策略说明代价
输出前审核等完整回答生成并审核后再展示失去流式体验
增量审核每隔一段文本做安全判断实现复杂,可能误判半截句子
事后拦截发现风险后停止输出并替换提示用户可能已看到部分内容

不同业务风险不同,策略也不同。

高风险场景,例如医疗、金融、未成年人、公开发布内容,不能只依赖前端流式展示。至少需要:

  • 输入审核。
  • 输出增量审核或最终审核。
  • 安全策略命中后的停止生成。
  • 命中原因和请求上下文审计。
  • 高风险用户或高风险任务的降级策略。

日志审计也要适配流式输出。

建议记录:

  • request id。
  • user id / tenant id。
  • model。
  • prompt / template 版本。
  • start time、first token time、end time。
  • input tokens、output tokens。
  • finish_reason。
  • 是否用户取消。
  • 是否上游异常。
  • 安全策略命中位置。
  • 完整输出或脱敏后的完整输出。

如果只记录每个 chunk,排查会很困难;如果只记录最终文本,又会丢失中途错误和取消状态。更好的方式是记录最终聚合结果,并额外记录关键事件。


性能和资源影响

流式输出改善的是体感,不是免费性能优化。

它会带来一些额外成本:

  • 长连接数量增加。
  • 服务端需要保持 response writer。
  • 代理和网关连接占用时间变长。
  • 客户端断开检测更重要。
  • 小 chunk 频繁 flush 会增加网络和 CPU 开销。
  • 日志、审计、安全处理变成增量状态机。

对推理服务来说,真正占用 GPU 的是生成任务本身。只要请求还在 decode,KV Cache 和调度资源就仍然被占用。

因此要设置:

  • 最大输出 token。
  • 单请求超时。
  • 空闲连接超时。
  • 客户端断开后的上游取消。
  • 每用户并发限制。
  • 每租户 token 预算。
  • 长回答限流策略。

否则流式输出很容易被误用成“无限长输出接口”。


压测建议

流式输出压测不能只看 QPS。

至少要记录:

指标说明
TTFT p50 / p90 / p99首 token 体验
ITL p50 / p90 / p99输出顺滑度
E2E latency完整回答耗时
output tokens输出长度分布
active streams同时存在的流连接数
cancel rate用户取消比例
stream error rate流中断或错误比例
finish_reason 分布是否大量 length 或异常停止

压测样本要覆盖:

  • 短问短答。
  • 长输入短输出。
  • 短输入长输出。
  • RAG 检索后输出。
  • 工具调用。
  • 用户中途取消。
  • 高并发下的长连接。

如果只用短 prompt、短输出压测,无法代表真实聊天系统。


常见问题

本地是流式,线上不是流式

通常是代理或网关缓冲导致。

排查顺序:

  • 服务端是否真的 flush。
  • Nginx / API Gateway 是否开启 buffering。
  • CDN 是否支持 event stream。
  • 响应头是否正确。
  • 是否被 gzip 等压缩中间件合并。
  • 前端是否等完整 response 后才解析。

首 token 很慢

常见原因:

  • 输入上下文太长。
  • RAG 检索或 rerank 慢。
  • 请求排队。
  • prefill batch 压力大。
  • 模型过大或硬件不足。
  • prompt 组装里有慢接口。

流式输出不能解决首 token 前的等待,只能让首 token 后的内容逐步展示。

输出过程中一卡一卡

常见原因:

  • GPU decode 压力大。
  • batch 调度抖动。
  • 网络或代理缓冲。
  • 前端渲染太重。
  • Markdown / 代码高亮每个 chunk 全量执行。
  • 后端把多个 chunk 攒起来再发。

用户点停止后 GPU 还在跑

说明取消信号没有传到推理服务,或者推理服务没有及时释放任务。

需要检查:

  • 前端是否 abort。
  • 业务后端是否监听 client disconnect。
  • 上游请求是否被取消。
  • 推理框架是否支持取消生成。
  • 日志里取消状态是否正确记录。

流式 JSON 经常解析失败

半截 JSON 本来就不能解析。

应当先聚合完整输出,再在结束后解析;如果需要边生成边校验,要使用专门的增量 parser,并明确处理未闭合字符串、数组和对象。


工程 Checklist

上线流式输出前,至少确认:

  • API 支持 stream: true 和非流式模式。
  • SSE / WebSocket 协议格式稳定。
  • 代理、网关、CDN 不会缓冲响应。
  • 前端能处理半截 event 和多 event 粘包。
  • 前端渲染有节流,不会长文本卡顿。
  • 用户取消会传到模型服务。
  • 服务端会释放 KV Cache 和请求资源。
  • 首 chunk 前错误和流中错误有不同处理。
  • finish_reason 被记录和展示。
  • length 截断不会被当成完整成功。
  • 工具调用参数在结束后再解析。
  • 内容安全策略适配流式展示。
  • 日志能重建最终输出和关键事件。
  • 压测覆盖 TTFT、ITL、取消和长连接。

小结

流式输出是大模型工程里连接“推理性能”和“用户体验”的关键能力。

它的核心价值不是减少模型计算,而是把生成过程提前暴露给用户,让系统从“长时间无响应”变成“尽快开始反馈”。

工程上要同时关注:

  • 协议格式。
  • chunk / delta 语义。
  • TTFT 和 ITL。
  • 前端增量渲染。
  • 中断和资源释放。
  • 错误处理。
  • 工具调用。
  • 内容安全。
  • 日志审计。
  • 真实链路压测。

只有这些链路都处理好,流式输出才不是一个 UI 动效,而是一个可靠的生产能力。