LLM 流式输出工程实践:SSE、背压、断流重连与JSON 流解析的 6 个生产陷阱

流式输出让 AI 应用从"等待黑盒"变成"实时打字机",但从 demo 到生产,中间有一片雷区。


为什么你的流式输出"看起来没在流"

第一次给 LLM 应用加流式输出的时候,大多数人都踩过同一个坑:代码写好了,stream=True 也加了,但浏览器端依然是等好几秒,然后"咔"一下把所有文字一起甩出来。

这不是 LLM 的问题,也不是你的代码问题------是中间层把流给"攒"了。

本文从生产角度梳理 LLM 流式输出的完整技术栈:SSE 协议细节、Nginx/Caddy 代理配置、背压处理、断流重连、tool_call streaming 下的 partial JSON 解析,以及 6 个真实踩过的生产陷阱。每个问题都有可以直接用的代码。


1. SSE 协议:你以为你懂,但可能没懂

SSE(Server-Sent Events)是 LLM 流式输出的事实标准传输协议。OpenAI、Anthropic、Google Gemini 的 API 都用它。理解协议本身是排查问题的基础。

1.1 SSE 帧格式

SSE 是纯文本协议,每个事件由若干字段组成,以空行结束:

复制代码
data: {"choices":[{"delta":{"content":"你好"},"index":0}]}

data: {"choices":[{"delta":{"content":",世界"},"index":0}]}

data: [DONE]

关键规则:

  • Content-Type 必须是 text/event-stream
  • 每个字段 field: value\n,事件结束是 \n\n(两个换行)
  • data: 可以多行拼接(每行 data: 前缀,最终值用 \n 连接)
  • id: 字段的值会被存为 Last-Event-ID,断线重连时自动携带
  • : comment 是注释行,不触发解析器,但能防止代理层因空闲超时关闭连接

1.2 为什么不用 WebSocket?

常见问题:为什么 LLM API 选 SSE 而不是 WebSocket?

SSE 的优势在于简单

  • HTTP/1.1 原生支持,不需要协议升级
  • HTTP/2 下天然多路复用,多个流共享一个 TCP 连接
  • 浏览器 EventSource 内置自动重连和 Last-Event-ID 续传
  • 代理/CDN/负载均衡器理解 HTTP,无需特殊配置(除了关缓冲)

WebSocket 的优势在于双向:当需要音频输入/文字输出的全双工通信时才考虑 WebSocket。如果你只是推 LLM 文本 token,SSE 足够了。

1.3 EventSource vs fetch + ReadableStream

浏览器内置的 EventSource 有一个重大限制:只支持 GET 请求,无法自定义请求头

javascript 复制代码
// ❌ EventSource 的局限
const es = new EventSource('/api/chat/stream');
// 无法传 Authorization header
// 无法发 POST body

生产中几乎所有 LLM 应用都需要传认证信息和请求体,所以实际用的是 fetch + ReadableStream

javascript 复制代码
// ✅ fetch + ReadableStream 方案
async function* streamChat(messages) {
  const response = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`,
    },
    body: JSON.stringify({ messages }),
    signal: controller.signal,
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

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

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

    buffer += decoder.decode(value, { stream: true });
    
    // 按 \n\n 分割 SSE 事件
    const events = buffer.split('\n\n');
    buffer = events.pop() ?? ''; // 最后一个可能不完整,留到下次
    
    for (const event of events) {
      const dataLine = event.split('\n').find(l => l.startsWith('data: '));
      if (!dataLine) continue;
      
      const data = dataLine.slice(6);
      if (data === '[DONE]') return;
      
      try {
        const chunk = JSON.parse(data);
        const delta = chunk.choices?.[0]?.delta?.content;
        if (delta) yield delta;
      } catch (e) {
        console.warn('Failed to parse SSE chunk:', data);
      }
    }
  }
}

注意这里有个细节:buffer 的拆分逻辑\n\n 是事件分隔符,但网络传输可能在任意位置截断,所以必须维护一个跨 read() 调用的 buffer,不能每次独立解析。


2. 代理层:每个配置项都是陷阱

生产陷阱 #1:Nginx 默认配置会静默缓冲所有流式响应

这是最常见的"流式但不流"原因。Nginx 默认开启 proxy_buffering,会把上游响应全部缓冲到内存/磁盘后再转发给客户端。对于 LLM 流式输出,这意味着客户端要等整个响应完成才能看到内容。

2.1 Nginx 完整配置

nginx 复制代码
location /api/chat/stream {
    proxy_pass http://backend:3000;
    
    # ✅ 关键:关闭代理缓冲
    proxy_buffering off;
    proxy_cache off;
    
    # ✅ 告诉 Nginx 不要缓冲(应用层 header,双重保险)
    proxy_set_header X-Accel-Buffering no;
    
    # ✅ HTTP/1.1 保持连接
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    
    # ✅ 长超时:LLM 生成可能持续几分钟
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    
    # ✅ 关闭 gzip:压缩算法需要缓冲足够数据才能压缩
    gzip off;
}

每一项都有原因:

  • proxy_buffering off:核心,必须
  • proxy_cache off:防止缓存策略覆盖 buffering 设置
  • proxy_set_header X-Accel-Buffering no:应用层也能控制,双重保险
  • proxy_http_version 1.1:HTTP/1.0 每次请求后关闭连接,流式场景必须用 1.1
  • proxy_set_header Connection '':清空 Connection header,防止影响长连接
  • gzip off:gzip 必须积累足够数据才能压缩,对流式来说就是强制缓冲

生产陷阱 #2:gzip 是隐蔽的流式杀手

很多工程师关了 proxy_buffering 但忘了关 gzip,然后发现响应还是以"批次"到达而不是逐 token。原因是 gzip 压缩需要攒够数据才能发出第一个压缩块。

2.2 Caddy 配置

caddyfile 复制代码
reverse_proxy /api/chat/* localhost:8000 {
    flush_interval -1  # -1 = 立即 flush,CRITICAL

    transport http {
        compression off  # 禁用压缩
        response_header_timeout 10m
        dial_timeout 10s
        keepalive 2m
    }
}

Caddy 的 flush_interval -1 等同于 Nginx 的 proxy_buffering off,同样关键。

2.3 应用层的 flush

代理配置对了,应用层也要配合:

python 复制代码
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

async def stream_llm_response(messages):
    async with openai.AsyncOpenAI() as client:
        stream = await client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            stream=True,
        )
        async for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                yield f"data: {chunk.model_dump_json()}\n\n"
        
        yield "data: [DONE]\n\n"

@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest):
    return StreamingResponse(
        stream_llm_response(request.messages),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
            "Connection": "keep-alive",
        }
    )

3. 背压:当 LLM 比网络快时

生产陷阱 #3:忽略背压导致内存爆炸

背压(Backpressure)是流式系统的经典问题:当生产者(LLM)速度超过消费者(网络/客户端)时,中间需要缓冲或限速。

3.1 Node.js 背压检测

javascript 复制代码
app.post('/api/chat/stream', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('X-Accel-Buffering', 'no');
  
  const stream = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages: req.body.messages,
    stream: true,
  });

  for await (const chunk of stream) {
    const delta = chunk.choices[0]?.delta?.content;
    if (!delta) continue;

    const data = `data: ${JSON.stringify(chunk)}\n\n`;
    
    // 检查背压:write() 返回 false 表示缓冲区已满
    const canContinue = res.write(data);
    
    if (!canContinue) {
      // 等待 drain 事件,防止内存溢出
      await new Promise(resolve => res.once('drain', resolve));
    }
  }

  res.write('data: [DONE]\n\n');
  res.end();
});

res.write() 返回 false 时,说明写缓冲区已满,必须等待 drain 事件。

3.2 Python asyncio 背压

python 复制代码
import asyncio

async def stream_with_backpressure(messages, response_queue: asyncio.Queue):
    async with openai.AsyncOpenAI() as client:
        stream = await client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            stream=True,
        )
        async for chunk in stream:
            # maxsize 满时自动等待------背压生效
            await response_queue.put(chunk)
        
        await response_queue.put(None)  # 结束信号

# 使用:maxsize 限制缓冲区大小
queue = asyncio.Queue(maxsize=50)
producer = asyncio.create_task(stream_with_backpressure(messages, queue))
consumer = asyncio.create_task(send_to_client(queue, websocket))
await asyncio.gather(producer, consumer)

asyncio.Queue(maxsize=50) 配合 await queue.put() 是 Python 异步背压的标准写法。


4. 断流重连:不丢 token 的设计

生产陷阱 #4:断线重连重复生成,用户看到重复内容

网络不稳定时,SSE 连接会中断。如果没有续传实现,新连接会触发全新的 LLM 生成------用户看到重复内容,服务端浪费算力。

4.1 基于 Last-Event-ID 的断点续传

python 复制代码
import redis
import uuid
import json

r = redis.Redis()

@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest, last_event_id: str = Header(None)):
    generation_id = request.generation_id or str(uuid.uuid4())
    
    # 携带 Last-Event-ID 时尝试续传
    if last_event_id:
        cached = r.get(f"stream:{generation_id}")
        if cached:
            resume_data = json.loads(cached)
            return StreamingResponse(
                resume_stream(generation_id, resume_data, last_event_id),
                media_type="text/event-stream",
            )
    
    return StreamingResponse(
        generate_and_cache(generation_id, request.messages),
        media_type="text/event-stream",
    )

async def generate_and_cache(generation_id: str, messages):
    token_index = 0
    token_buffer = []
    
    async with openai.AsyncOpenAI() as client:
        stream = await client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            stream=True,
        )
        async for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                token_buffer.append(delta)
                # 每 token 缓存到 Redis(TTL 5 分钟)
                r.setex(
                    f"stream:{generation_id}",
                    300,
                    json.dumps({"tokens": token_buffer, "done": False})
                )
                # 用 token_index 作为 event id
                yield f"id: {token_index}\ndata: {chunk.model_dump_json()}\n\n"
                token_index += 1
        
        r.setex(
            f"stream:{generation_id}",
            300,
            json.dumps({"tokens": token_buffer, "done": True})
        )
        yield "data: [DONE]\n\n"

4.2 客户端指数退避重连

javascript 复制代码
class ResilientLLMStream {
  constructor(url, body, options = {}) {
    this.url = url;
    this.body = body;
    this.generationId = body.generation_id || crypto.randomUUID();
    this.lastEventId = null;
    this.retryCount = 0;
    this.maxRetries = options.maxRetries ?? 3;
    this.baseDelay = options.baseDelay ?? 1000;
    this.onToken = options.onToken ?? (() => {});
    this.onDone = options.onDone ?? (() => {});
  }

  async start() {
    while (this.retryCount <= this.maxRetries) {
      try {
        await this.connect();
        return;
      } catch (err) {
        if (err.name === 'AbortError') return;
        
        this.retryCount++;
        if (this.retryCount > this.maxRetries) throw err;
        
        // 指数退避
        const delay = this.baseDelay * Math.pow(2, this.retryCount - 1);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }

  async connect() {
    const headers = {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    };
    
    if (this.lastEventId !== null) {
      headers['Last-Event-ID'] = this.lastEventId;
    }

    const response = await fetch(this.url, {
      method: 'POST',
      headers,
      body: JSON.stringify({ ...this.body, generation_id: this.generationId }),
    });

    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

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

      buffer += decoder.decode(value, { stream: true });
      const events = buffer.split('\n\n');
      buffer = events.pop() ?? '';

      for (const event of events) {
        const lines = event.split('\n');
        let id = null, data = null;

        for (const line of lines) {
          if (line.startsWith('id: ')) id = line.slice(4);
          if (line.startsWith('data: ')) data = line.slice(6);
        }

        if (id) this.lastEventId = id;
        if (!data) continue;
        if (data === '[DONE]') { this.onDone(); return; }

        try {
          const chunk = JSON.parse(data);
          const token = chunk.choices?.[0]?.delta?.content;
          if (token) this.onToken(token);
        } catch (e) {
          console.warn('Parse error:', data);
        }
      }
    }
  }
}

5. partial JSON 解析:tool_call streaming 的噩梦

生产陷阱 #5:直接 JSON.parse() tool_call arguments 导致崩溃

tool_callarguments 字段会被分散到多个 chunk:

json 复制代码
// chunk 1: {"arguments": "{\"lo"}
// chunk 2: {"arguments": "ca"}
// chunk 3: {"arguments": "tion\": \"Beijing\"}"}

直接对每个 chunk 做 JSON.parse() 必然报错。

5.1 正确的 tool_call 累积模式

typescript 复制代码
interface ToolCallAccumulator {
  [index: number]: {
    id: string;
    name: string;
    arguments: string;
  }
}

async function processStreamWithTools(stream: AsyncIterable<ChatCompletionChunk>) {
  const toolCallAcc: ToolCallAccumulator = {};
  let content = '';

  for await (const chunk of stream) {
    const delta = chunk.choices[0]?.delta;
    if (!delta) continue;

    if (delta.content) {
      content += delta.content;
      onToken(delta.content);
    }

    if (delta.tool_calls) {
      for (const tc of delta.tool_calls) {
        const idx = tc.index;
        
        if (!toolCallAcc[idx]) {
          toolCallAcc[idx] = {
            id: tc.id ?? '',
            name: tc.function?.name ?? '',
            arguments: '',
          };
        }
        
        // ✅ 累积,不解析
        if (tc.function?.arguments) {
          toolCallAcc[idx].arguments += tc.function.arguments;
        }
      }
    }

    // ✅ finish_reason 为 tool_calls 时才解析
    if (chunk.choices[0]?.finish_reason === 'tool_calls') {
      for (const [idx, tc] of Object.entries(toolCallAcc)) {
        try {
          const args = JSON.parse(tc.arguments);
          await executeTool(tc.name, tc.id, args);
        } catch (e) {
          // 降级:使用 jsonrepair 修复
          const repaired = jsonRepair(tc.arguments);
          await executeTool(tc.name, tc.id, JSON.parse(repaired));
        }
      }
    }
  }

  return { content, toolCalls: Object.values(toolCallAcc) };
}

关键原则:累积 argumentsfinish_reason === 'tool_calls' 时才解析 JSON,不要在每个 delta chunk 里解析。

5.2 实时 UI 的增量解析

如果需要在 arguments 还没完整时就更新 UI,可以用 jsonrepair

javascript 复制代码
import { jsonrepair } from 'jsonrepair';

function tryParsePartialJSON(partial) {
  try {
    return JSON.parse(partial);
  } catch {
    try {
      return JSON.parse(jsonrepair(partial));
    } catch {
      return null;
    }
  }
}

// 实时更新 UI(仅用于预览,不用于执行工具)
if (tc.function?.arguments) {
  toolCallAcc[idx].arguments += tc.function.arguments;
  const partial = tryParsePartialJSON(toolCallAcc[idx].arguments);
  if (partial) {
    updateToolCallUI(tc.id, partial);
  }
}

jsonrepair 对常见不完整 JSON(缺闭括号、未完成字符串)修复成功率很高,但只用于 UI 预览,真正执行工具时等完整的 JSON。


6. keepalive:防止代理层"以为没人了"

生产陷阱 #6:慢速模型被代理层 60s 超时断开

思考型模型(如 DeepSeek-R1)在"思考"阶段可能完全没有 token 输出,持续几十秒。这段时间很多代理层会因"空闲超时"断开连接。

6.1 服务端 keepalive 注释行

SSE 规范支持以 : 开头的注释行,不触发客户端事件监听器,但让连接上有数据流动:

python 复制代码
import asyncio

async def stream_with_keepalive(generation_coro, keepalive_interval=15):
    """包装生成器,每 15s 发送 keepalive 注释"""
    
    async def keepalive_loop(queue: asyncio.Queue):
        while True:
            await asyncio.sleep(keepalive_interval)
            await queue.put(': keepalive\n\n')  # SSE 注释行
    
    queue = asyncio.Queue()
    
    async def producer():
        try:
            async for chunk in generation_coro:
                await queue.put(chunk)
        finally:
            await queue.put(None)
    
    keepalive_task = asyncio.create_task(keepalive_loop(queue))
    producer_task = asyncio.create_task(producer())
    
    try:
        while True:
            item = await queue.get()
            if item is None:
                break
            yield item
    finally:
        keepalive_task.cancel()
        producer_task.cancel()

注释格式是 : any text\n\n(冒号开头,双换行)。代理层看到数据就不会超时,但客户端解析器跳过注释行,不影响业务逻辑。

6.2 超时配置调整

nginx 复制代码
# Nginx:针对流式端点单独设置
location /api/chat/stream {
    proxy_read_timeout 600s;  # 思考型模型可能需要 5-10 分钟
    proxy_send_timeout 600s;
    # ... 其他配置
}
python 复制代码
# OpenAI SDK 超时配置
client = openai.AsyncOpenAI(
    timeout=httpx.Timeout(
        connect=10.0,
        read=600.0,   # 读取超时 10 分钟
        write=10.0,
        pool=10.0,
    )
)

TTFT 优化:从感受到数字

TTFT(Time To First Token,首 token 延迟)是流式输出的核心体验指标。研究显示,即使总生成时间相同,用户感知流式界面比批量响应快 40% 。业界 p50 目标是 < 800ms

全链路 TTFT 拆解:

复制代码
用户点击 → 前端发请求 → 网关/LB → 应用服务器 → LLM 推理服务 → 第一个 token
                                                                  ↓
前端显示第一个字 ← 浏览器渲染 ← 网络传输 ← 应用 flush ← 第一个 chunk

每个环节都可能增加延迟。除了已讨论的代理缓冲外,还有一个隐蔽问题:某些浏览器需要接收到足够大的初始 chunk(通常 2KB)才开始渲染

python 复制代码
async def stream_with_padding(messages):
    first_token_sent = False
    
    async with openai.AsyncOpenAI() as client:
        stream = await client.chat.completions.create(
            model="gpt-4o", messages=messages, stream=True,
        )
        async for chunk in stream:
            if not first_token_sent:
                # 发送 2KB padding 注释,确保浏览器立即开始渲染
                yield ': ' + ' ' * 2048 + '\n\n'
                first_token_sent = True
            yield f"data: {chunk.model_dump_json()}\n\n"
    
    yield "data: [DONE]\n\n"

生产 Checklist

类别 检查项 不做的后果
代理配置 Nginx proxy_buffering off 流式变批量输出
代理配置 Nginx gzip off(流式路径) gzip 缓冲导致延迟
代理配置 proxy_read_timeout >= 300s 长生成被超时断开
应用层 每次 yield 后立即 flush token 被攒批发出
应用层 keepalive 注释每 15s 一次 思考型模型被代理超时
背压 Node.js: res.write() drain 检测 内存无限增长
背压 Python: asyncio.Queue(maxsize=N) 生产者过快导致 OOM
断流 服务端缓存 generation_id + tokens 重连触发重复生成
断流 客户端指数退避重试(最多 3 次) 服务端请求风暴
tool_call 累积 arguments 后再 JSON.parse 每 chunk 解析必然崩溃
TTFT 初始 2KB padding chunk 浏览器延迟渲染
监控 记录 TTFT p50/p95 无法发现退化

总结

LLM 流式输出的核心挑战不在 LLM 本身,而在全链路的传输工程

  1. SSE 协议层 :理解 data:/id:/:/retry: 字段语义;用 fetch + ReadableStream 替代 EventSource
  2. 代理层proxy_buffering off + gzip off + 超时配置,三项缺一不可
  3. 背压 :检测 drain/Queue 阻塞,不要无限缓冲
  4. 断流重连Last-Event-ID + 服务端 token 缓存,续传而不是重发
  5. partial JSON :累积 argumentsfinish_reason === 'tool_calls' 再解析
  6. keepalive :每 15s 发 : keepalive\n\n 注释,维持思考型模型的连接

这些问题每一个单独看都不复杂,但在生产中同时踩到三四个,排查起来会非常痛苦。希望这篇文章能帮你在踩之前就绕过去。


本文代码基于 Python 3.11+ / Node.js 20+ / OpenAI SDK v2,实际使用时根据你的 SDK 版本做调整。

相关推荐
AI浩2 小时前
OpenCV 检测流程中损坏 JPEG 图片的定位与清理
人工智能·opencv·计算机视觉
算力视野2 小时前
AMD Instinct MI325X/MI350X路线图深度解析:288GB HBM3e如何硬刚英伟达?
人工智能·gpu算力
中间件XL2 小时前
ai-agent框架spring ai/alibaba源码原理分析(二) 模型,chat模型,chatclient
人工智能·ai agent·spring ai·agent框架
得物技术2 小时前
用 LLM Agent 重构告警排查流程|得物技术
java·人工智能·后端
容智信息2 小时前
提示词工程不是写长说明书,而是做语义压缩
人工智能·prompt·安全威胁分析·提示词·智能体
zandy10112 小时前
体系化AI创新赋能产业升级 联想集团树立智能时代企业创新标杆
大数据·人工智能
dehuisun2 小时前
openspec基础实战
人工智能
MacroZheng2 小时前
阿里Qoder + GLM-5.1,夯爆了!
前端·vue.js·人工智能