流式输出
流式输出是大模型应用里最常见的交互优化之一。
它不会让模型生成得更快,也不一定减少总耗时,但能让用户更早看到结果开始出现,从而显著改善体感延迟。
一句话概括:
流式输出把一次完整回答拆成多个增量片段,边生成、边传输、边渲染。
如果不做流式输出,用户必须等模型完整生成、服务端拼装结果、网络传输完成后,才能看到最终答案。对于长回答、代码生成、总结、Agent 推理过程,这种等待会很明显。
流式输出要解决的不只是“前端一个字一个字显示”,还包括协议、取消、错误、审计、安全、指标和资源释放。
在请求链路中的位置
一次典型流式请求大致是:
用户请求
-> API 网关 / 鉴权 / 限流
-> prompt / messages 组装
-> tokenizer / chat template
-> 推理服务 prefill
-> decode 逐 token 生成
-> server chunk 封装
-> SSE / WebSocket / HTTP chunked 传输
-> 客户端增量解析
-> UI 增量渲染
-> finish_reason / usage / 日志落库
真正的模型生成仍然发生在 decode 阶段。流式输出只是把 decode 过程中产生的 token 或文本片段尽早发送给客户端。
因此它和这些工程模块强相关:
- 推理参数:
max_tokens、stop、temperature会影响输出长度和停止方式。 - 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 | 停止原因,例如 stop、length、tool_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。
| 指标 | 全称 | 含义 |
|---|---|---|
| TTFT | Time To First Token | 从发起请求到收到首个输出片段的时间 |
| ITL | Inter 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 动效,而是一个可靠的生产能力。