LLMNotes

第 06 章:流式响应

zjc 于 2026-01-06 发布

这是《LLM 应用开发 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 生成式模型逐 token 输出,完整结果可能需要数秒。流式响应可以让用户更早看到进展,但它会带来传输协议、超时、安全过滤、输出解析和可观测性问题。

6.1 非流式与流式

非流式:
Client -> Server -> LLM -> 完整结果 -> Client

流式:
Client -> Server -> LLM -> chunk 1 -> chunk 2 -> ... -> done -> Client

流式的收益:

  1. 首字延迟更低;
  2. 用户感知更自然;
  3. 便于显示进度;
  4. 支持中途取消;
  5. 长输出体验更好。

流式的代价:

  1. 连接管理更复杂;
  2. 输出校验延后;
  3. 异常处理状态更多;
  4. 中间内容可能短暂违规;
  5. 日志和监控需要聚合;
  6. 代理和网关配置要求更高。

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 取消与超时

用户关闭页面并不等于服务端自动停止所有工作。

处理要点:

  1. 使用 AbortController 取消前端请求;
  2. 服务端感知连接断开;
  3. 向模型服务透传取消;
  4. 工具任务检查取消标记;
  5. 已完成部分写入审计;
  6. 异步任务返回任务 ID;
  7. 设置空闲超时和总超时。
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 传输、分片聚合、取消、超时、安全审核和最终指标统计。对业务系统而言,先聚合再校验往往比把半成品结构化内容直接交给下游更稳。

思考题

  1. 首字延迟和总延迟分别反映什么问题?
  2. 为什么结构化输出通常要先聚合再校验?
  3. 用户取消请求后,服务端应做哪些清理?
  4. 反向代理 buffering 对流式响应有什么影响?
  5. 流式场景如何平衡实时展示和安全过滤?