这是《LLM 应用开发 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 生成式模型逐 token 输出,完整结果可能需要数秒。流式响应可以让用户更早看到进展,但它会带来传输协议、超时、安全过滤、输出解析和可观测性问题。
6.1 非流式与流式
非流式:
Client -> Server -> LLM -> 完整结果 -> Client
流式:
Client -> Server -> LLM -> chunk 1 -> chunk 2 -> ... -> done -> Client
流式的收益:
- 首字延迟更低;
- 用户感知更自然;
- 便于显示进度;
- 支持中途取消;
- 长输出体验更好。
流式的代价:
- 连接管理更复杂;
- 输出校验延后;
- 异常处理状态更多;
- 中间内容可能短暂违规;
- 日志和监控需要聚合;
- 代理和网关配置要求更高。
6.2 SSE 协议
HTTP 流式常用 Server-Sent Events:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
事件格式:
data: {"delta":"你"}
data: {"delta":"好"}
data: [DONE]
字段建议:
| 字段 | 说明 |
|---|---|
| id | 流 ID |
| event | message / error / done |
| delta | 增量文本 |
| finish_reason | 结束原因 |
| usage | 聚合后的 token 用量 |
6.3 Python 服务示例
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
import json
app = FastAPI()
async def fake_stream(prompt: str):
for word in ["订单", "已", "发货", "。"]:
await asyncio.sleep(0.2)
yield f"data: {json.dumps({'delta': word}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
@app.post("/chat/stream")
async def chat_stream(body: dict):
return StreamingResponse(
fake_stream(body.get("prompt", "")),
media_type="text/event-stream",
)
生产环境还要处理认证、限流、断连、取消和异常事件。
6.4 前端处理
async function streamChat(prompt: string, onDelta: (text: string) => void) {
const res = await fetch("/chat/stream", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({prompt}),
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.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 parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";
for (const part of parts) {
const line = part.replace(/^data: /, "");
if (line === "[DONE]") return;
onDelta(JSON.parse(line).delta ?? "");
}
}
}
6.5 取消与超时
用户关闭页面并不等于服务端自动停止所有工作。
处理要点:
- 使用
AbortController取消前端请求; - 服务端感知连接断开;
- 向模型服务透传取消;
- 工具任务检查取消标记;
- 已完成部分写入审计;
- 异步任务返回任务 ID;
- 设置空闲超时和总超时。
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
const res = await fetch(url, {signal: controller.signal});
6.6 输出安全与格式
流式输出难以在发送前完整审核,可用分层策略:
高风险内容:先完整生成,审核后再返回
中风险内容:敏感词窗口过滤 + 结束后复核
低风险内容:流式返回 + 结束后审计
结构化输出通常不建议直接流式给业务调用方:
LLM stream
-> 服务端聚合
-> JSON 解析
-> 校验
-> 返回结构化结果
如果必须流式展示,可以流式展示说明文本,最终再提交结构化字段。
6.7 网关与代理
常见问题:
| 问题 | 处理 |
|---|---|
| 响应被缓冲 | 关闭代理缓冲 |
| 连接提前断开 | 增大读写超时 |
| 压缩导致聚合 | 谨慎启用 gzip |
| HTTP/1 连接数限制 | 使用 HTTP/2 或减少并发流 |
| 跨域错误 | 正确配置 CORS |
| 心跳缺失 | 定期发送注释或空事件 |
Nginx 示例:
location /chat/stream {
proxy_pass http://llm_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 300s;
}
6.8 可观测性
流式请求不能只记录 HTTP 200。
| 指标 | 说明 |
|---|---|
| first token latency | 首字延迟 |
| total latency | 总延迟 |
| chunks | 分片数量 |
| output tokens | 输出 token |
| stream cancel rate | 取消率 |
| disconnect rate | 断连率 |
| parse error rate | 聚合解析失败率 |
| guardrail block rate | 安全拦截率 |
日志聚合:
stream_id
|-- chunk logs
+-- final summary
保存最终聚合文本和 token 用量,分片日志可按需采样。
6.9 常见故障排查
| 现象 | 排查 |
|---|---|
| 前端一直等最后响应 | 代理缓冲未关闭 |
| 输出乱码 | 解码字符边界处理 |
| JSON 解析失败 | 分片重组逻辑 |
| 连接 60 秒断开 | 代理读写超时 |
| 用户取消后仍扣费 | 未透传取消 |
| 输出中途违规 | 安全策略分层 |
| 监控延迟偏低 | 只记录了首包 |
本章小结
流式响应优化的是用户体验,不改变模型生成机制。工程上要处理 SSE 传输、分片聚合、取消、超时、安全审核和最终指标统计。对业务系统而言,先聚合再校验往往比把半成品结构化内容直接交给下游更稳。
思考题
- 首字延迟和总延迟分别反映什么问题?
- 为什么结构化输出通常要先聚合再校验?
- 用户取消请求后,服务端应做哪些清理?
- 反向代理 buffering 对流式响应有什么影响?
- 流式场景如何平衡实时展示和安全过滤?