大模型流式输出实现原理深度解析:从 SSE 协议到断流补偿
大模型流式输出实现原理深度解析从 SSE 协议到断流补偿为什么 ChatGPT 能一个字一个字往外蹦而不是等 30 秒一次性吐出来当你点击停止生成服务端怎么感知到、模型层如何优雅收场如果你同时调用 3 个模型做对比流式输出该怎么管这篇文章从 OpenAI 协议出发深入到 SSE 连接机制、多流管理、断流续传把流式输出从能用讲到可控。一、为什么大模型必须支持流式输出大模型推理有天然的高延迟特征。一个复杂问题的回答可能需要 10-30 秒如果等全部生成完毕再返回用户体验极差。模式响应方式首字延迟用户感知适用场景非流式等全部 token 生成完毕一次性返回完整 JSON10-30s长时间白屏等待焦虑批量处理、API 集成流式每生成一个 token 立即推送0.5-1s逐字出现即时反馈对话、写作、代码生成核心指标对比指标非流式流式首字可见时间TTFT完整生成时间10-30s200ms - 1s用户可中断性等全部完成随时停止带宽占用低单次传输中持续推送连接保持短连接长连接持续推送中关键洞察流式输出的核心不是快而是把延迟从用户的感知中隐藏掉。用户看到第一个字就开始阅读后续 token 在阅读过程中陆续到达——这个过程叫可感知性能优化。二、OpenAI 流式协议详解2.1 开启流式stream: trueOpenAI Chat Completions API 开启流式非常简单只需将stream参数设为true非流式请求 POST https://api.openai.com/v1/chat/completions { model: gpt-4, messages: [...], stream: false } 响应一次性返回完整 JSON { id: chatcmpl-xxx, choices: [{ message: { content: 完整回答... } }] }流式请求 POST https://api.openai.com/v1/chat/completions { model: gpt-4, messages: [...], stream: true, stream_options: { include_usage: true } }2.2 流式响应的数据格式流式响应使用 SSEServer-Sent Events协议每个 chunk 都是一个 JSON 对象以data:前缀发送第一个 chunk包含角色信息 data: {id:chatcmpl-xxx,object:chat.completion.chunk, choices:[{delta:{role:assistant,content:},index:0}]} 中间 chunk逐 token 推送 data: {id:chatcmpl-xxx,object:chat.completion.chunk, choices:[{delta:{content:今天},index:0}]} data: {id:chatcmpl-xxx,object:chat.completion.chunk, choices:[{delta:{content:天气},index:0}]} 最后一个 chunk包含 finish_reason data: {id:chatcmpl-xxx,object:chat.completion.chunk, choices:[{delta:{},finish_reason:stop,index:0}], usage:{prompt_tokens:15,completion_tokens:42,total_tokens:57}} 结束标记 data: [DONE]2.3 Chunk 字段详解字段类型出现时机说明delta.rolestring仅第一个 chunkassistantdelta.contentstring每个 token chunk本次增量文本片段delta.tool_callsarrayFunction calling 时工具调用的增量参数finish_reasonstring | null最后一个 chunkstop/length/tool_calls/content_filterusageobject最后一个 chunk需开启 include_usageprompt_tokens, completion_tokens, total_tokenssystem_fingerprintstring每个 chunk后端配置指纹用于排查确定性差异关键洞察delta.content是增量而非累积。前端需要自行拼接fullContent delta.content。如果 chunk 到达顺序乱掉拼接结果就会错乱——好在 SSE 基于 TCP 保证顺序。三、SSE 协议深度解析3.1 SSE 是什么SSEServer-Sent Events是 HTML5 标准的一部分允许服务端向客户端单向推送数据流。它比 WebSocket 更轻量天然适合服务端持续产出、客户端只接收的场景——正是流式输出的最佳拍档。对比维度SSEWebSocketHTTP 轮询通信方向单向服务端→客户端全双工客户端轮询协议HTTP/1.1 或 HTTP/2独立协议ws://HTTP自动重连内置EventSource API需手动实现N/A二进制支持仅文本UTF-8文本二进制实现复杂度低中低适用场景流式推送、通知、进度条实时双向通信低频状态查询3.2 SSE 连接的建立与维持客户端 → 服务端HTTP 请求客户端发送一个标准 HTTP 请求 GET /v1/chat/completions HTTP/1.1 Host: api.openai.com Accept: text/event-stream Content-Type: application/json Authorization: Bearer sk-xxx {model: gpt-4, messages: [...], stream: true}服务端 → 客户端SSE 响应头服务端返回 SSE 头 HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive Transfer-Encoding: chunked X-Request-Id: req_xxxSSE 消息格式规范每条消息由一个或多个字段组成以空行分隔 id: 1 event: message data: {content:你好} 多行 data 会自动拼接 data: 第一行\n data: 第二行\n 服务端可自定义事件类型 event: error data: {code: 429, message: rate limit exceeded}3.3 客户端 SSE 实现两种客户端实现方式EventSource浏览器原生最简单浏览器内置 API自动处理重连和解析一行new EventSource(url)即可适合标准 SSE 场景。缺点是只支持 GET 请求无法自定义请求头和 body——所以调 OpenAI API 时不够用。fetch ReadableStream推荐最灵活手动用response.body.getReader()逐块读取核心流程是读二进制 → 解码为文本 → 按行分割 → 解析data:前缀 → 提取 delta.content。优势在于支持 POST 自定义 Header且通过AbortController可随时中断。// fetch ReadableStream 核心流程伪代码 const response await fetch(url, { method: POST, body: ..., signal: abortController.signal }); const reader response.body.getReader(); while (true) { const { done, value } await reader.read(); if (done) break; // 解码 → 按 \n 拆行 → 匹配 data: → JSON.parse → 取 delta.content for (const line of decodeAndSplit(value)) { if (line.startsWith(data: )) { const token JSON.parse(line.slice(6)).choices[0].delta.content; if (token) renderToken(token); } } }两种方式的共同关键signalAbortController用于中断reader.read()返回{done: true}时流结束。四、服务端流式实现架构4.1 整体数据流服务端流式输出的核心是异步生成器async generator。模型每产出一个 tokengenerator 就 yield 一次框架如 FastAPI 的StreamingResponse负责把每次 yield 的结果封装为 SSE 格式推送给客户端。关键三步模型推理引擎提供generate_stream()接口逐 token yield封装层将每个 token 包装为 OpenAI 兼容的 chunk JSON加上data:前缀和\n\n后缀框架层FastAPIStreamingResponse设置Content-Type: text/event-stream持续推送4.2 核心实现从模型 token 到 SSE chunk# 核心骨架——完整实现只有这一种模式 async def sse_event_generator(model, messages): # 1. 首个 chunk角色声明 yield format_chunk({delta: {role: assistant}}) # 2. 逐 token 推送 async for token in model.generate_stream(messages): yield format_chunk({delta: {content: token}}) # 3. 结束标记 yield format_chunk({finish_reason: stop, usage: {...}}) yield data: [DONE]\n\n app.post(/v1/chat/completions) async def chat_completions(request): return StreamingResponse( sse_event_generator(request.model, request.messages), media_typetext/event-stream, )Nginx 反向代理的关键配置location /v1/chat/completions { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; chunked_transfer_encoding on; }关键原则proxy_buffering off是流式输出的生死线。如果 Nginx 开了缓冲它会攒够 4KB 才发给客户端——用户看到的效果就是卡住 10 秒突然蹦出一大段流式彻底失效。五、用户主动关闭连接服务端如何处理这是流式输出最棘手的场景。用户点击停止生成 → 客户端断开 SSE 连接 → 服务端必须感知到并优雅终止推理。5.1 客户端关闭的行为// 1. EventSource 方式 source.close(); // 2. fetch AbortController const controller new AbortController(); fetch(url, { signal: controller.signal }); // 用户点击停止 stopButton.onclick () controller.abort();客户端 close/abort 后TCP 连接发送 FIN 包服务端 socket 变为可读read 返回 0 或抛出异常。5.2 服务端的感知与响应服务端感知客户端断连有两种方式方式一FastAPI 的request.is_disconnected()。在每个 chunk yield 前检查发现断开则主动 break 并通知模型取消推理。这是最上层的方式适合 FastAPI/Starlette 框架。方式二ASGI 的asyncio.CancelledError。当客户端断开 TCP 连接ASGI 服务uvicorn会取消对应的协程generator 在 yield 时抛出 CancelledError。这是更底层的方式框架无关。两种方式殊途同归在生成循环中插入客户端还活着吗的检查点发现断开立即终止模型推理。# 核心模式在 token 生成循环中检查断开 async for token in model_stream: if await request.is_disconnected(): await engine.abort_request(rid) break yield format_chunk(token)5.3 模型层的优雅终止服务端感知到断连后最关键的一步是通知模型引擎停止推理。不同推理框架的处理方式推理框架取消推理的 API行为vLLMabort_request(request_id)从调度队列移除已计算的 KV Cache 可复用GPU 立即释放TGI关闭 gRPC/HTTP 流连接推理进程抛异常清理本次资源OpenAI SDK循环中break关闭 HTTP 连接后端自动终止自研引擎CancellationToken需引擎原生支持中断推理关键洞察如果服务端不主动 abort 模型推理GPU 会继续跑完整个生成过程——既浪费算力又挤占其他用户的推理资源。即使用户已经断开模型还在对空气说话。六、多模型调用场景下的流管理当一次请求涉及多个模型调用如并行对比、Agent chain流的生命周期管理会复杂很多。6.1 常见多模型流场景场景示例流的关系复杂度并行对比同时调 GPT-4 Claude对比回答并行独立流中链式调用Agent 工具调用模型 A 规划 → 模型 B 执行 → 模型 A 总结串行依赖流高Fan-out/Fan-in模型 A 拆解任务 → 3 个 SubAgent 并行执行 → 聚合结果串行并行混合很高6.2 并行流管理独立 AbortController并行多模型的核心思路每个模型的流有自己独立的 AbortController存放在 Map 中。用户取消某个模型时只 abort 对应的 controller其他模型不受影响。// 核心数据结构每个模型独立控制 const controllers new Map(); // 启动 → 各自 fetch带独立 signal fetch(url, { signal: controllers.get(modelId).signal }) // 取消单个 → 不影响其他 controllers.get(claude)?.abort(); // 取消全部 → 用户离开页面时 controllers.forEach(c c.abort());6.3 服务端多流协调服务端并行对接多个模型的推理引擎核心是每个模型流对应一个asyncio.Event取消信号。Event 是协作式取消——在 token loop 中检查if event.is_set(): break而非暴力Task.cancel()。# 核心骨架 event asyncio.Event() async for chunk in model_stream: if event.is_set(): await stream.close() break yield chunk关键原则多流场景下用户取消一个模型如GPT-4 的答案够了Claude 不用等了应该精准取消目标流而非一刀切全部停掉。这需要服务端维护Mapuser_id:model_id → Event的映射。6.4 链式调用的流切换链式调用模型 A → 工具 → 模型 B的流管理本质是串行步骤 每步独立可取消。每一步创建新的 AbortController前端展示阶段标识正在规划... / 正在执行...用户可随时中断当前步骤和后续步骤。七、断流补偿用户重连后如何看到之前的内容7.1 问题定义用户场景正在看 AI 回答刷新了页面或网络断开再打开时想看到刚才生成的内容。怎么补偿补偿需求分两个层次已生成内容的恢复刷新后能立刻看到之前的回答未完成流的续推模型继续推理尚未产出的内容推给重连的用户7.2 方案一服务端缓存 全量重放简单、推荐核心思路每个 token 到达时追加到 Rediskey 为stream:{session_id}:{message_id}。重连时先查缓存拿到已有内容渲染再判断流是否结束——未结束则续接 SSE。# 写每个 chunk 追加到 Redis await redis.append(fstream:{sid}:{mid}, token) # 读重连时一次性取回全部已生成内容 content await redis.get(fstream:{sid}:{mid}) # 标记完成 await redis.set(fstream:{sid}:{mid}:done, 1, ex3600) # 补偿接口返回 return { content: content, is_complete: is_done, resume_url: f/api/messages/{mid}/stream/resume if not is_done else None }7.3 方案二SSE 断线续传 EventSource.lastEventIdSSE 协议原生支持断线重连——服务端在每个 chunk 中带id字段客户端断线后浏览器自动重连并带上Last-Event-ID请求头服务端据此从断点续推。但该机制依赖浏览器实现且无法恢复断连期间丢失的 chunk可靠度有限。# 服务端给每个 chunk 编号 yield fid: {counter}\n yield fdata: {json.dumps(chunk)}\n\n # 重连时浏览器自动发送 Header: Last-Event-ID: 427.4 方案三应用层双通道补偿对于重连间隔较长的场景改用应用层主动补偿客户端重连后先调用/api/messages/sync获取已有内容和续接地址渲染已有内容后再决定是否续接 SSE。// 重连补偿流程两步走 const { content, isComplete, resumeUrl } await fetch(/sync).then(r r.json()); renderContent(content); // 1. 立即渲染已有内容 if (!isComplete) connectSSE(resumeUrl); // 2. 流未结束则续接 SSE7.5 各方案对比方案实现复杂度实时性可靠性适用场景Redis 缓存 全量重放低高高绝大多数场景推荐SSE 原生 lastEventId低高中丢事件不可恢复网络抖动型断连双通道应用层补偿中高很高高标准产品长文本场景推荐实践方案一Redis 缓存 方案三的前端 sync 逻辑。SSE 原生的 lastEventId 机制作为辅助——你控制不了用户用什么网络环境两层保障最稳妥。八、总结大模型流式输出的实现远不止stream: true一个参数。从 SSE 连接机制到断流恢复每一步都有工程细节SSE 协议层SSE 基于 HTTP单向推送比 WebSocket 更轻量Nginx 必须关闭proxy_buffering否则流式失效Content-Type: text/event-stream是服务端和客户端的共同约定EventSource API 自动处理重连fetch ReadableStream 提供更精细控制用户主动关闭的处理服务端通过request.is_disconnected()或 ASGI CancelledError 感知断连必须显式调用推理引擎的 abort 接口如 vLLMabort_request否则 GPU 空转浪费算力建议使用asyncio.Event做协作式取消而非Task.cancel()的强制终止多模型流管理并行场景每个模型独立 AbortController 独立 asyncio.Event 信号量链式场景step-by-step 切换流每一步可独立取消原则用户取消一个模型不应影响其他模型的流断流补偿Redis 逐 token 缓存是最简单有效的方案客户端重连后先 sync 已有内容再判断是否需要续接 SSESSE 的idLast-Event-ID是原生续传机制但不应作为唯一保障一句话总结流式输出是一个管道——从 GPU 的 token 出口经过推理引擎、SSE 协议、Nginx 反向代理最终抵达用户的浏览器。管道上任何一个节点的缓冲、中断或错误处理不到位都会让流式变成块式。把每一层都处理好了用户才会感受到AI 思考的过程就在眼前展开的体验。