流式输出(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 规范,常见的坑:

  1. data: 后面的空格差异

    • 标准:data: {"choices":...}(有空格)
    • 某些平台:data:{"choices":...}(无空格)
    • 解析时若按 if line.startswith("data: ") 判断,无空格的格式会被跳过
  2. [DONE] 标记位置

    • OpenAI:单独一行 data: [DONE]
    • 某些平台:可能放在最后一条数据的末尾,或根本不发
  3. chunk 内字段结构不同

    • 非 OpenAI 平台可能使用 text 而非 delta.content,或直接返回纯文本

打印原始 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)

每个 chunkAIMessageChunk 对象,.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

关键理解点:

  1. return StreamingResponse 时 generator 还没执行——只是告诉框架"这是个流式响应"
  2. 每次 yield 触发一次 ASGI send 事件——数据不是攒到完毕才发,而是每 yield 一次就发一次
  3. 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

后端需要保证的几点

  1. data: 后的空格必须严格:前端按 startsWith("data: ") 过滤,无空格的数据会被静默跳过,表现为"前端收不到任何内容"
  2. 流结束必须发 [DONE]:前端依赖这个标记判断流是否正常结束,否则一直等待直到超时
  3. yield 每条消息格式完整:每条 SSE 消息必须是完整的 data: {...}\n\n,不能分两次 yield 拼成一条
  4. 错误不要直接断连:异常时 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,不需要协议升级。

posted @ 2026-06-12 12:51  第十昵称  阅读(153)  评论(1)    收藏  举报