流式输出(Streaming)原理与踩坑经验
流式输出(Streaming)原理与踩坑经验
本人在日常开发中,遇到流式输出相关的问题,一般都需要靠大模型协助定位问题,归其根本是因为我对流式输出的原理认识不足。所以本篇文章记录我学习流式输出的原理,以及在实际开发中遇到的问题。
整体流程:
大模型生成 token
↓
打包成 chunk(一个或多个 token)
↓
SSE 协议封装(data: {...}\n\n)
↓
FastAPI StreamingResponse 转发
↓
前端 fetch + ReadableStream
有时候为了统一调用方式,还需要把非openai 的调用封装成统一格式。流式输出的本质,是看清大模型生成的数据如何被’切块’和’封装’,像流水线一样在网络中流转,最终在屏幕上 实时解析、逐字显现 ,形成我们看到的连贯对话。下面从底层协议开始,逐个环节拆开讲。
流式输出的原理
流式输出(Streaming Output)指数据不是一次性全部返回,而是像水流一样逐段、逐部分传输到接收端。大模型生成内容时,通常按"token-by-token"方式逐步生成,每生成一部分就可以立即发送给客户端,而非等待整个内容生成完成。这种方式通常依赖持久化 HTTP 连接或 Server-Sent Events (SSE) 协议,服务端在生成每个文本块(chunk)后立即推送,客户端可实时处理和展示,适用于长文本生成、实时交互场景。
token 与 chunk 的区别: token 是模型生成的最小单位,chunk 是网络传输的最小单位。模型逐个 token 生成,但服务端会把若干 token 攒成一个 chunk 再发送,减少网络请求次数。
模型逐个生成 token:
[你] [好] [世] [界] [,] [今] [天]
服务端攒成 chunk 发送(每次发送的 token 数量不固定):
┌─────────┐ ┌──────┐ ┌──────────────┐
│ 你 好 世 │ → │ 界 ,│ → │ 今 天 │ → ...
└─────────┘ └──────┘ └──────────────┘
chunk 1 chunk 2 chunk 3
(3个token) (2个token) (2个token)
一个 chunk 可能包含 1 个 token,也可能包含多个 token,取决于服务端的缓冲策略。
SSE 协议详解
上面讲的 chunk 要在网络上传输,需要一套协议来承载,这就是 SSE。流式输出的底层协议一般是 SSE(Server-Sent Events),它是 W3C 标准,基于 HTTP 长连接实现服务端推送。
SSE 数据格式标准
SSE 的消息由若干行组成,每行以 key: value 的格式组织,支持的字段:
| 字段 | 含义 | 示例 |
|---|---|---|
data |
数据内容(可多行拼接) | data: {"text": "你好"} |
event |
事件类型,默认为 message |
event: error |
id |
事件 ID,用于断线重连 | id: 12345 |
retry |
重连间隔(毫秒) | retry: 3000 |
消息之间用空行 \n\n 分隔。标准 SSE 格式示例:
data: {"choices":[{"delta":{"content":"你好"}}]}
data: {"choices":[{"delta":{"content":",世"}}]}
data: [DONE]
最后一行 data: [DONE] 是流结束标记,但要注意某些非标准平台可能不发这个标记,或者把它拼到前一条数据的末尾。
核心 1:大模型 API 的流式输出格式详解
要理解整条链路,首先要搞清楚"大模型到底返回了什么"。
这部分以 OpenAI 格式为主线讲解,原因有三:GPT-3.5/4 是最早大规模商用的 LLM,其 API 格式被开发者最先熟悉;LangChain、LlamaIndex 等主流框架默认以 OpenAI 格式设计,生态绑定深;后来的云厂商(阿里通义、火山引擎、DeepSeek、智谱)为降低开发者迁移成本,主动提供 OpenAI 兼容接口,进一步巩固了其事实标准的地位。OpenAI 格式本身没有技术上不可替代的优势,只是市场选择了它作为"最大公约数"。
OpenAI 标准格式
当设置 stream=True 调用 OpenAI 兼容 API 时,服务端返回的是 SSE 格式的流,每个 chunk 被包在 data: ...\n\n 里,data: 后面是一个 JSON 对象:
data: {"choices":[{"delta":{"content":"你好"},"index":0,"finish_reason":null}],"id":"chatcmpl-xxx","object":"chat.completion.chunk"}
data: {"choices":[{"delta":{"content":",世"},"index":0,"finish_reason":null}],"id":"chatcmpl-xxx","object":"chat.completion.chunk"}
data: [DONE]
其中每个 JSON 的结构如下:
{
"choices": [
{
"delta": {"content": "你好"},
"index": 0,
"finish_reason": null
}
],
"id": "chatcmpl-xxx",
"object": "chat.completion.chunk"
}
delta.content:当前 chunk 的增量文本。第一个 chunk 的delta通常不带content,而是带role: "assistant",后续 chunk 才有content。前端或后端拼接文本时如果直接取delta.content,第一个 chunk 拿到空值是正常的,不要因此跳过。finish_reason:最后一个 chunk 为"stop"(模型自然结束)或"length"(max_tokens 截断),其余为null。排查问题时注意区分这两种情况:stop说明模型正常输出完,length说明输出被截断了。
非标准格式的常见差异点
有些平台的 SSE 格式不严格遵循 OpenAI 规范,常见的坑:
-
data:后面的空格差异- 标准:
data: {"choices":...}(有空格) - 某些平台:
data:{"choices":...}(无空格) - 解析时若按
if line.startswith("data: ")判断,无空格的格式会被跳过
- 标准:
-
[DONE]标记位置- OpenAI:单独一行
data: [DONE] - 某些平台:可能放在最后一条数据的末尾,或根本不发
- OpenAI:单独一行
-
chunk 内字段结构不同
- 非 OpenAI 平台可能使用
text而非delta.content,或直接返回纯文本
- 非 OpenAI 平台可能使用
打印原始 SSE 数据进行对比
import requests
response = requests.post(url, json=payload, stream=True)
for line in response.iter_lines():
if line:
print(repr(line)) # 打印原始字节
- repr() 是 Python 内置函数,返回对象的精确字符串表示,会把不可见字符(换行、空格等)显示出来
这是我调试流式问题时最常用的手段,直接看原始数据比猜结构要快得多。
核心 2:LangChain 的流式输出原理
LangChain 的 ChatOpenAI 内部调用 OpenAI API,然后把原始 JSON chunk 转成 AIMessageChunk 对象,每次 yield 对应 OpenAI 返回的一个 chunk。理解这层映射关系,排查问题时才能知道 LangChain 返回的字段对应原始数据的哪个位置。
AIMessageChunk 的结构
在用 LangChain 处理流式输出时,每个 chunk 是 AIMessageChunk 对象。它的核心字段:
chunk.content # 文本内容(str),大多数场景直接取这个
chunk.tool_calls # 工具调用信息(list),模型调用 function calling 时用
chunk.additional_kwargs # 额外元信息(dict),如部分平台的特殊字段
一般场景下只用 .content 就够了。如果模型同时返回工具调用(function calling),就需要处理 tool_calls。另外,现在很多模型的输出会将思考内容与非思考内容分开,思考内容存放在 additional_kwargs 中。
OpenAI 原始 chunk 与 AIMessageChunk 的映射关系:
| OpenAI 原始 chunk | LangChain AIMessageChunk |
|
|---|---|---|
| 文本内容 | choices[0].delta.content |
.content |
| 工具调用 | choices[0].delta.tool_calls |
.tool_calls(已解析为结构化对象) |
| 额外字段 | JSON 顶层字段 | 统一放入 .additional_kwargs(如思考内容) |
| 结束标记 | finish_reason: "stop" |
流结束后单独处理,chunk 本身无标记 |
| 跨平台兼容 | 只适用于 OpenAI 格式 | 统一接口,切换模型不改业务代码 |
AIMessageChunk 支持 __add__ 累加,可以用 sum_chunk += chunk 把所有 chunk 累加得到完整的 AIMessage。
统一模型调用接口
项目中同时依赖多个大模型平台时,需要统一调用接口,业务代码只写一套,切换模型改配置就行。根据平台是否提供 OpenAI 兼容接口,有两种方案:
方案一:平台提供 OpenAI 兼容接口(推荐)
直接替换 base_url 即可,无需额外封装:
from langchain_openai import ChatOpenAI
model = ChatOpenAI(
model="qwen-plus",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key="your-key",
streaming=True
)
很多云厂商(阿里云、火山引擎、DeepSeek)都提供 OpenAI 兼容接口,能直接这样用。
方案二:平台不提供兼容接口
需要封装自定义 LangChain LLM 类,本质是格式转换——把平台返回的自定义 chunk 格式转为 LangChain 的 GenerationChunk:
from langchain_core.language_models.llms import BaseLLM
from langchain_core.outputs import GenerationChunk
import httpx
class MyCustomLLM(BaseLLM):
async def _astream(self, prompt, **kwargs):
# 1. 调用平台的流式接口(异步调用防阻塞)
async with httpx.AsyncClient() as client:
async with client.stream("POST", api_url, json={"prompt": prompt}) as resp:
async for line in resp.aiter_lines():
if line:
# 2. 解析平台自定义格式,提取文本
chunk_text = self._parse_custom_format(line)
# 3. 转为 LangChain 标准格式
yield GenerationChunk(text=chunk_text)
async def _agenerate(self, prompt, **kwargs):
# 非流式走这里,可以聚合流式结果
return self._astream(prompt, **kwargs)
关键就是三步:调平台接口 -> 解析自定义格式 -> yield 标准 GenerationChunk。
解析流式输出的 AIMessageChunk 数据
LangChain 的流式调用非常简洁,不需要手动解析 SSE 格式:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
model = ChatOpenAI(model="gpt-4o-mini", streaming=True)
messages = [HumanMessage(content="你好,介绍一下自己")]
# 同步流式
for chunk in model.stream(messages):
print(chunk.content, end="", flush=True)
# 异步流式(FastAPI 等异步框架中使用)
async for chunk in model.astream(messages):
print(chunk.content, end="", flush=True)
每个 chunk 是 AIMessageChunk 对象,.content 是本次增量文本。如果需要拿到完整响应,可以累加:
full_response = ""
async for chunk in model.astream(messages):
full_response += chunk.content
print(full_response)
核心 3:FastAPI 服务的流式输出原理
FastAPI 是一个基于 Python 的 Web 框架,底层依赖 Starlette(一个轻量级 ASGI 框架)。简单理解:FastAPI = Starlette + 参数校验 + API 文档生成。StreamingResponse 实际上是 Starlette 提供的,FastAPI 直接拿过来用。
基本实现
from fastapi.responses import StreamingResponse
async def generate():
async for chunk in model.astream(messages):
yield f"data: {chunk.json()}\n\n" # 注意这里需要手动封装成SSE格式
@app.post("/chat")
async def chat():
return StreamingResponse(generate(), media_type="text/event-stream")
StreamingResponse 的完整流转过程(简单理解一下就行)
为了理解 StreamingResponse 底层发生了什么,我们从头到尾看一条请求的完整路径:
客户端发起 POST /chat
↓
Uvicorn(ASGI 服务器) 接收 TCP 连接,解析 HTTP 请求
↓
ASGI 协议将请求传递给 Starlette
↓
Starlette 路由匹配 -> 调用 chat() 函数
↓
chat() return StreamingResponse(generate(), ...)
此时 generate() 里的代码还没执行!!
↓
Starlette 拿到 StreamingResponse,识别到它是一个 async generator
于是进入"流式模式"
↓
Starlette 调用 generate()
generate() 内部 await model.astream()
↓
当 generate() 执行 yield 时:
StreamingResponse 将 yield 出的内容封装成 ASGI 的 "http.response.body" 事件
↓
Uvicorn 收到 ASGI 事件,将数据通过 HTTP/1.1 的 chunked 编码
写入 TCP socket
↓
客户端收到第一个 chunk
关键理解点:
- return StreamingResponse 时 generator 还没执行——只是告诉框架"这是个流式响应"
- 每次 yield 触发一次 ASGI send 事件——数据不是攒到完毕才发,而是每 yield 一次就发一次
- yield 结束后(generator 耗尽或 return),框架自动发送结束信号——所以客户端知道流结束了
这就是"边算边传"在代码层面的本质。
关键:保持连接不断开
async def generate():
try:
async for chunk in model.astream(messages):
yield f"data: {chunk.json()}\n\n"
except Exception as e:
yield f"event: error\ndata: {str(e)}\n\n"
finally:
yield "data: [DONE]\n\n"
- 一定要处理异常,否则前端的连接会直接断开而没有错误信息
[DONE]标记让前端知道流结束了
StreamingResponse vs EventSourceResponse
我日常开发用的基本都是 EventSourceResponse,两者区别如下:
- StreamingResponse(Starlette 原生):通用的流式响应类,可以返回任何内容(文本、SSE、二进制流),灵活性最高
- EventSourceResponse(来自
sse-starlette库):专门为 SSE 设计的封装,内部帮你处理了 SSE 格式(自动拼接data:、event:、id:等字段),代码更简洁
如果你用的是 sse-starlette,写法是这样的:
from sse_starlette.sse import EventSourceResponse
async def generate():
async for chunk in model.astream(messages):
yield f"{json.dumps(answer_json)}" # 自动包装成 data: xxx\n\n
@app.post("/chat")
async def chat():
return EventSourceResponse(generate())
answer_json是一个 JSON 字符串,可以自定义字段。- 注意:
EventSourceResponse内部已经默认设置了media_type="text/event-stream",不需要显式传参。 - 其他常用的响应类:
FileResponse(文件下载)、HTMLResponse(返回 HTML)、JSONResponse(返回 JSON)。
核心 4:流式响应迭代器详解
前面三节分别讲了大模型返回什么格式(核心1)、LangChain 怎么封装(核心2)、FastAPI 怎么转发(核心3)。如果你的服务需要直接处理原始 HTTP 流(比如代理非标准上游、或调试流式问题),就需要了解 HTTP 响应迭代器。
HTTP 底层跑在 TCP 上,而 TCP 是流式协议,不保证消息边界——服务端一次发出的两条 SSE 消息,TCP 可能拆到不同的包里。比如服务端发出:
data: {"content":"你好"}\n\n
data: {"content":"世界"}\n\n
TCP 实际到达客户端的数据可能是:
第 1 次 read(): data: {"content":"你
第 2 次 read(): 好"}\n\ndata: {"content":"世界"}\n\n
如果直接按每次 read() 的结果解析,第 1 次拿到的是不完整的 JSON,解析直接报错。因此解析 SSE 必须按 \n 做行分割并维护 buffer,不能直接按 TCP 包读取。这就是 iter_lines() / aiter_lines() 是解析 SSE 默认选择的原因:它们内部已经做好了 buffer 拼接和行分割。
迭代器的层次关系(从底层到高层):
流式响应迭代器的种类有很多,这里只列举几个常用的:
HTTP 响应原始数据
↓
iter_raw() / iter_content() —— 原始字节,未经处理,TCP 收到什么就返回什么
↓
iter_bytes() / aiter_bytes() —— 解码了 Transfer-Encoding,返回 bytes,仍需自己按 \n 分割
↓
iter_text() / aiter_text() —— bytes 解码为字符串
↓
iter_lines() / aiter_lines() —— 按 \n 切分行,SSE 解析直接用这个
requests(同步):
| 方法 | 返回类型 | 适用场景 |
|---|---|---|
iter_lines() |
str(按行) | 最常用,解析 SSE 格式 |
iter_content(chunk_size) |
bytes(按块) | 调试用,查看 TCP 分包情况 |
iter_chunked(chunk_size) |
bytes(按块) | 调试用,控制每次读取大小 |
httpx(异步):
| 方法 | 返回类型 | 适用场景 |
|---|---|---|
aiter_lines() |
str(按行) | 最常用,解析 SSE 格式 |
aiter_bytes() |
bytes(原始字节) | 调试用,自己维护 buffer |
aiter_text() |
str(文本块) | 调试用,已解码但不分行 |
aiter_raw() |
bytes(原始 HTTP 块) | 最底层,未经编码/压缩处理 |
用 aiter_bytes() 调试原始字节
日常解析 SSE 只用 iter_lines() / aiter_lines() 就够。其他方法只在调试时降级使用——比如 aiter_lines() 取不到数据时,换 aiter_bytes() 看原始字节,确认服务端有没有在发数据。
当 aiter_lines() 取不到数据时,降级到 aiter_bytes() 看原始数据:
async with httpx.AsyncClient() as client:
async with client.stream("POST", url, json=payload) as resp:
async for chunk in resp.aiter_bytes():
print(f"[RAW BYTES] {chunk}") # 看到底有没有数据
为什么用 ChatOpenAI 时不需要 iter_lines()?
直接用 requests / httpx 调 OpenAI API 时,你在协议层,拿到的是原始 SSE 字节流,必须自己用 iter_lines() 做行分割再解析 JSON。而用 ChatOpenAI 时,SSE 解析已经在 LangChain 内部完成了:
HTTP 响应
→ iter_lines() 按行分割 ← LangChain 内部
→ 解析 data: {...} JSON ← LangChain 内部
→ 提取 delta.content ← LangChain 内部
→ 包装成 AIMessageChunk ← LangChain 内部
→ yield 给你(.content 直接用) ← 你只看到这一层
所以 for chunk in model.stream() 或 async for chunk in model.astream() 拿到的已经是解析好的对象,直接 .content 就行,不需要关心 SSE 格式。
curl 测试 SSE
curl -N -X POST http://localhost:8000/chat \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "你好"}]}'
-N 参数禁用 curl 的 buffering,逐行显示数据。这是最快速的排查方式——先确认服务端能不能正常流式输出,再排查前端。这条原则适用于所有涉及网络的问题:先确定哪一端出问题,再深入细节。
实际踩坑经验
前端如何消费流式输出(后端视角)
作为后端开发,不需要写前端代码,但需要理解前端怎么消费你的流,才能在联调和排查问题时知道"是后端发错了,还是前端解析错了"。
前端有两种主流接收方式:
| 方式 | 支持的方法 | 自定义请求头 | 适用场景 |
|---|---|---|---|
EventSource |
仅 GET | 不支持 | 推送通知等只读场景 |
fetch + ReadableStream |
POST/GET 均支持 | 支持 | 大模型调用(几乎所有场景) |
大模型场景需要 POST 传消息体,所以前端几乎都用 fetch + ReadableStream。
后端需要保证的几点
data:后的空格必须严格:前端按startsWith("data: ")过滤,无空格的数据会被静默跳过,表现为"前端收不到任何内容"- 流结束必须发
[DONE]:前端依赖这个标记判断流是否正常结束,否则一直等待直到超时 yield每条消息格式完整:每条 SSE 消息必须是完整的data: {...}\n\n,不能分两次 yield 拼成一条- 错误不要直接断连:异常时 yield 一条
event: error消息再关闭,否则前端只知道连接断了,不知道原因
常见问题
chunk 被半路截断导致乱码
一个中文字符在 UTF-8 中占 3 字节,TCP 分包可能把一个字拆到两个 chunk 里。前端如果用 TextDecoder 解码时必须开启流式模式({stream: true}),否则拆开的字节会解码成乱码。
后端能做什么:尽量保证每条 SSE 消息是完整的文本,不要把一个字符的字节拆到两条消息里。一般用 yield f"data: {json.dumps(...)}\n\n" 这种方式,JSON 序列化后的字符串不会被截断,问题基本不会出在后端。
Buffer 累积问题
底层 TCP 会把多条 SSE 消息合并到一个数据包里发送,前端 reader.read() 一次拿到的可能是半条、一条或多条消息拼在一起。所以前端需要维护一个 buffer,按 \n\n 分割消息,把不完整的部分留到下一次读取。
后端能做什么:保证每条 SSE 消息以 \n\n 结尾,不要省略。这是前端分割消息的唯一依据,格式不规范会导致前端解析混乱。
FastAPI 中 yield 与 return 的区别
在 FastAPI 的 async generator 中,yield 是"每产生一块就发出去",而 return 是"全部生成后再发"。
SSE vs WebSocket
有人可能会问,大模型流式返回为什么不用 WebSocket?先简单介绍一下 WebSocket 是什么。
WebSocket 是一种全双工通信协议,客户端和服务端都可以随时向对方发数据。它通过 HTTP 握手升级到 WS 协议:
# 客户端发起升级请求
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
# 服务端同意升级
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
握手完成后,连接从 HTTP 切换到 WS 协议,之后双方可以随时收发消息。WebSocket 在实时聊天、协同编辑、在线游戏等场景很常见。
WebSocket 和 SSE 的对比如下:
| 特性 | SSE | WebSocket |
|---|---|---|
| 方向 | 服务端 -> 客户端单向 | 全双工双向 |
| 协议 | HTTP(直接复用) | WS(需先升级握手) |
| 自动重连 | 浏览器内置支持 | 需自行实现 |
| 适用场景 | 推送通知、流式文本 | 实时聊天、协同编辑、游戏 |
大模型流式输出是典型的"服务端生成、客户端接收"场景,客户端只需要读不需要写,用 SSE 完全够用,而且比 WebSocket 轻量——直接走 HTTP,不需要协议升级。

浙公网安备 33010602011771号