大模型流式输出原理:SSE(服务器发送事件)与 Python 实现
系列:Python + FastAPI 大模型应用基础(第 4 篇)
目标:从协议层理解 SSE,使用 FastAPI 实现可运行的流式接口,并使用 Python 客户端正确解析事件。
1. 为什么大模型需要流式输出
普通 HTTP 接口通常等待所有结果生成完毕后一次性返回:
text
客户端发送请求
↓
服务器生成完整回答
↓
客户端一次性收到完整回答
如果模型生成完整回答需要 15 秒,用户在这 15 秒内看不到任何内容,容易误以为系统卡住。
流式输出会把已经生成的内容立即发送给客户端:
text
第 1 秒:Python
第 2 秒:Python 的
第 3 秒:Python 的装饰器
第 4 秒:Python 的装饰器本质上......
流式输出不一定缩短模型完成全部内容所需的总时间,但通常可以缩短 Time To First Token(首个 Token 等待时间)带来的体感延迟。
2. SSE 是什么
SSE(Server-Sent Events,服务器发送事件)是一种基于 HTTP 的服务器单向推送机制。
它的通信方向是:
text
客户端建立 HTTP 连接
↓
服务器持续向客户端发送事件
↓
连接结束或客户端主动断开
SSE 响应的常见 Content-Type 是:
http
Content-Type: text/event-stream
最小 SSE 事件:
text
data: 你好
注意 data: 你好 后面有一个空行。SSE 使用空行表示当前事件结束。
3. SSE 事件格式
SSE 常见字段包括:
| 字段 | 作用 | 示例 |
|---|---|---|
data |
事件携带的数据 | data: {"content":"你"} |
event |
自定义事件类型 | event: token |
id |
事件编号 | id: 1001 |
retry |
浏览器重连等待时间,单位为毫秒 | retry: 3000 |
: |
注释,可作为心跳 | : heartbeat |
一个完整事件:
text
id: 1
event: token
data: {"content":"Python"}
结束事件:
text
event: done
data: {"finish_reason":"stop"}
错误事件:
text
event: error
data: {"message":"模型流式响应中断"}
SSE 规范允许一个事件包含多行 data。客户端应收集同一事件的所有 data 行,直到遇到空行,再把它们组合起来处理。
4. SSE、普通 HTTP 和 WebSocket 的区别
| 对比项 | 普通 HTTP | SSE | WebSocket |
|---|---|---|---|
| 通信方式 | 一次请求、一次响应 | 服务器持续推送 | 客户端与服务器双向通信 |
| 底层协议 | HTTP | HTTP | HTTP 握手后升级协议 |
| 实现复杂度 | 低 | 较低 | 较高 |
| 自动重连 | 需要自行实现 | 浏览器 EventSource 支持 |
需要自行实现 |
| 适合场景 | 普通接口 | 模型文本流、通知、日志流 | 实时聊天、协同编辑、游戏 |
大模型文本主要是服务器向客户端持续输出,因此 SSE 通常已经够用。如果业务需要客户端和服务器高频双向发送消息,再考虑 WebSocket。
5. 第一层实现:不调用模型,先验证 SSE
学习一个协议时,应先隔离外部依赖。下面的程序不需要模型 API Key,只模拟服务器逐段输出文本。
5.1 创建项目
text
sse_demo/
├── sse_minimal.py
└── requirements.txt
requirements.txt:
text
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
requests>=2.31,<3
httpx>=0.27,<1
pydantic>=2.7,<3
创建环境并安装依赖:
powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
5.2 编写最小 SSE 服务
新建 sse_minimal.py:
python
import asyncio
import json
from collections.abc import AsyncIterator
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI(title="SSE 最小示例")
def encode_sse(event: str, data: dict[str, object]) -> str:
"""把 Python 字典编码成一个完整的 SSE 事件。"""
# ensure_ascii=False 可以让中文直接以 UTF-8 形式发送
json_data = json.dumps(
data,
ensure_ascii=False,
separators=(",", ":"),
)
# SSE 事件必须用空行结束,因此结尾是两个换行符
return f"event: {event}\ndata: {json_data}\n\n"
async def generate_demo_events() -> AsyncIterator[str]:
"""模拟模型逐段生成文本。"""
text_pieces = [
"Python ",
"流式输出 ",
"可以让用户 ",
"更早看到模型回答。",
]
for index, text_piece in enumerate(text_pieces, start=1):
# 模拟模型生成每一段文本所需的时间
await asyncio.sleep(0.6)
yield encode_sse(
event="token",
data={
"index": index,
"content": text_piece,
},
)
# 使用独立的 done 事件明确告诉客户端:流已经正常结束
yield encode_sse(
event="done",
data={"finish_reason": "stop"},
)
@app.get("/demo/stream")
async def demo_stream() -> StreamingResponse:
"""返回一个持续输出 SSE 事件的 HTTP 响应。"""
return StreamingResponse(
generate_demo_events(),
media_type="text/event-stream",
headers={
# 禁止中间缓存保存流式响应
"Cache-Control": "no-cache",
# 提示 Nginx 不要缓存整个响应后再一次性转发
"X-Accel-Buffering": "no",
},
)
启动服务:
powershell
.\.venv\Scripts\python.exe -m uvicorn sse_minimal:app --reload
使用 curl.exe 测试:
powershell
# -N 表示关闭 curl 自己的输出缓冲,便于观察逐段到达的数据
curl.exe -N "http://127.0.0.1:8000/demo/stream"
预期会逐步看到:
text
event: token
data: {"index":1,"content":"Python "}
event: token
data: {"index":2,"content":"流式输出 "}
event: done
data: {"finish_reason":"stop"}
只要这个示例可以逐段显示,就说明 FastAPI、客户端和本地网络链路支持 SSE。此时再接入模型,可以减少排错变量。
6. 第二层实现:FastAPI 代理真实模型流
真实架构如下:
text
Python 客户端 / 网页
↓ POST JSON
自己的 FastAPI 流式接口
↓ stream=true
上游大模型流式接口
↓ SSE 数据
FastAPI 解析、转换并继续推送
↓
客户端逐段显示
为什么不让前端直接请求模型服务?
- API Key 不能放在浏览器或小程序;
- 后端需要统一完成用户鉴权;
- 后端需要限制输入长度和调用频率;
- 后端需要记录耗时、错误和 Token 用量;
- 更换模型服务商时,不应要求所有前端同时修改。
7. 完整 FastAPI 流式代理代码
新建 app.py:
python
import json
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, field_validator
class LLMStreamError(RuntimeError):
"""调用或解析上游模型流时发生的可预期异常。"""
@dataclass(frozen=True)
class Settings:
"""上游模型服务配置。"""
api_key: str
base_url: str
model: str
@classmethod
def from_env(cls) -> "Settings":
"""读取并校验模型环境变量。"""
api_key = os.getenv("LLM_API_KEY", "").strip()
base_url = os.getenv("LLM_BASE_URL", "").strip().rstrip("/")
model = os.getenv("LLM_MODEL", "").strip()
missing = []
if not api_key:
missing.append("LLM_API_KEY")
if not base_url:
missing.append("LLM_BASE_URL")
if not model:
missing.append("LLM_MODEL")
if missing:
raise RuntimeError(f"缺少环境变量:{', '.join(missing)}")
if not base_url.startswith("https://"):
raise RuntimeError("远程模型地址必须使用 HTTPS")
return cls(api_key=api_key, base_url=base_url, model=model)
class ChatStreamRequest(BaseModel):
"""自己的流式聊天接口允许接收的字段。"""
user_message: str = Field(min_length=1, max_length=4000)
temperature: float = Field(default=0.2, ge=0, le=2)
@field_validator("user_message")
@classmethod
def message_must_not_be_blank(cls, value: str) -> str:
"""阻止只有空格或换行符的请求。"""
cleaned_value = value.strip()
if not cleaned_value:
raise ValueError("user_message 不能为空白字符串")
return cleaned_value
def encode_sse(event: str, data: dict[str, Any]) -> str:
"""将一个业务事件编码成标准 SSE 文本。"""
json_data = json.dumps(
data,
ensure_ascii=False,
separators=(",", ":"),
)
return f"event: {event}\ndata: {json_data}\n\n"
async def iter_upstream_tokens(
http_client: httpx.AsyncClient,
settings: Settings,
user_message: str,
temperature: float,
) -> AsyncIterator[str]:
"""连接上游模型,并逐个产出文本片段。"""
request_url = f"{settings.base_url}/chat/completions"
request_headers = {
"Authorization": f"Bearer {settings.api_key}",
"Content-Type": "application/json",
}
request_body = {
"model": settings.model,
"messages": [
{
"role": "system",
"content": (
"你是一名严谨的 Python 教师。"
"不确定时请明确说明,不要编造。"
),
},
{"role": "user", "content": user_message},
],
"temperature": temperature,
"stream": True,
}
try:
# stream() 不会等待整个响应体下载完成
async with http_client.stream(
method="POST",
url=request_url,
headers=request_headers,
json=request_body,
) as response:
if response.status_code != 200:
# 不把上游完整响应转发给客户端,避免泄露内部信息
raise LLMStreamError(
f"模型服务返回异常状态码:{response.status_code}"
)
# 按行读取上游常见的 SSE 响应
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
raw_data = line.removeprefix("data:").strip()
if raw_data == "[DONE]":
return
try:
event_data: dict[str, Any] = json.loads(raw_data)
except json.JSONDecodeError as exc:
raise LLMStreamError(
"上游模型返回了无效的流式 JSON"
) from exc
# 某些结束或用量事件可能没有 choices,允许安全跳过空列表
choices = event_data.get("choices")
if not isinstance(choices, list):
raise LLMStreamError("上游流式事件缺少 choices")
if not choices:
continue
first_choice = choices[0]
if not isinstance(first_choice, dict):
raise LLMStreamError("上游 choice 结构不正确")
delta = first_choice.get("delta")
if isinstance(delta, dict):
content = delta.get("content")
if isinstance(content, str) and content:
yield content
# 部分兼容接口使用 finish_reason 表示本次生成正常结束
if first_choice.get("finish_reason") is not None:
return
# 连接关闭不等于业务完成;缺少结束标记时按中断处理
raise LLMStreamError(
"模型流已关闭,但没有收到明确的结束标记"
)
except httpx.TimeoutException as exc:
raise LLMStreamError("等待模型流式响应超时") from exc
except httpx.ConnectError as exc:
raise LLMStreamError("无法连接模型服务") from exc
except httpx.HTTPError as exc:
raise LLMStreamError("读取模型流时发生网络异常") from exc
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""在 FastAPI 生命周期内复用模型 HTTP 连接池。"""
settings = Settings.from_env()
# read=90 表示单次读取等待最多 90 秒
timeout = httpx.Timeout(
connect=5.0,
read=90.0,
write=10.0,
pool=5.0,
)
http_client = httpx.AsyncClient(timeout=timeout)
app.state.settings = settings
app.state.http_client = http_client
yield
# 应用关闭时释放连接池
await http_client.aclose()
app = FastAPI(
title="大模型 SSE 流式代理",
version="1.0.0",
lifespan=lifespan,
)
@app.post("/api/v1/chat/stream")
async def chat_stream(
chat_request: ChatStreamRequest,
request: Request,
) -> StreamingResponse:
"""把上游模型流转换成自己的 SSE 业务事件。"""
settings: Settings = request.app.state.settings
http_client: httpx.AsyncClient = request.app.state.http_client
async def event_generator() -> AsyncIterator[str]:
"""把模型 Token 包装成 token、done、error 三类事件。"""
try:
async for text_piece in iter_upstream_tokens(
http_client=http_client,
settings=settings,
user_message=chat_request.user_message,
temperature=chat_request.temperature,
):
# 客户端已经关闭页面时,停止继续读取和转发模型流
if await request.is_disconnected():
return
yield encode_sse(
event="token",
data={"content": text_piece},
)
yield encode_sse(
event="done",
data={"finish_reason": "stop"},
)
except LLMStreamError as exc:
# 流开始后通常无法再把 HTTP 200 改成 500
# 因此使用独立 error 事件把失败状态告诉客户端
yield encode_sse(
event="error",
data={"message": str(exc)},
)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
设置环境变量并启动:
powershell
$env:LLM_API_KEY = "替换为真实密钥"
$env:LLM_BASE_URL = "https://替换为模型服务地址/v1"
$env:LLM_MODEL = "替换为真实模型标识"
.\.venv\Scripts\python.exe -m uvicorn app:app --reload
8. 使用 Python 客户端正确解析 SSE
不能把每一行都直接当成独立事件。下面的解析器会处理 event、多行 data、注释和空行边界。
新建 client.py:
python
import json
from collections.abc import Iterator
from typing import Any
import requests
def iter_sse_events(
response: requests.Response,
) -> Iterator[tuple[str, str]]:
"""把 text/event-stream 响应解析成 (事件名, 数据) 元组。"""
event_name = "message"
data_lines: list[str] = []
# decode_unicode=True 根据响应编码把字节转换成字符串
for line in response.iter_lines(decode_unicode=True):
# 空行表示一个 SSE 事件结束
if line == "":
if data_lines:
yield event_name, "\n".join(data_lines)
# 为下一个事件重置临时状态
event_name = "message"
data_lines = []
continue
# 以冒号开头的是注释或心跳,不属于业务数据
if line.startswith(":"):
continue
field_name, separator, field_value = line.partition(":")
if not separator:
continue
# SSE 允许冒号后存在一个可选空格
field_value = field_value.lstrip(" ")
if field_name == "event":
event_name = field_value
elif field_name == "data":
data_lines.append(field_value)
# 某些不规范服务可能在结束连接前没有发送最后一个空行
if data_lines:
yield event_name, "\n".join(data_lines)
def main() -> None:
"""调用自己的 FastAPI 流式接口并实时打印文本。"""
request_body = {
"user_message": "请解释 Python 生成器的作用。",
"temperature": 0.2,
}
try:
with requests.post(
"http://127.0.0.1:8000/api/v1/chat/stream",
json=request_body,
# stream=True 表示不要等待完整响应全部下载
stream=True,
timeout=(5, 120),
) as response:
response.raise_for_status()
# SSE 使用 UTF-8;显式设置可避免服务端漏写字符集时中文乱码
response.encoding = "utf-8"
for event_name, raw_data in iter_sse_events(response):
try:
data: dict[str, Any] = json.loads(raw_data)
except json.JSONDecodeError:
print("\n收到无法解析的 SSE 数据")
continue
if event_name == "token":
content = data.get("content", "")
if isinstance(content, str):
# flush=True 让文本立即显示到终端
print(content, end="", flush=True)
elif event_name == "done":
print("\n\n流式回答已完成")
elif event_name == "error":
print(f"\n\n流式调用失败:{data.get('message')}")
return
except requests.exceptions.Timeout:
print("连接或读取流式响应超时")
except requests.exceptions.ConnectionError:
print("无法连接 FastAPI 服务")
except requests.exceptions.HTTPError as exc:
print(f"FastAPI 返回 HTTP 错误:{exc.response.status_code}")
except requests.exceptions.RequestException as exc:
print(f"请求发生异常:{exc}")
if __name__ == "__main__":
main()
运行客户端:
powershell
.\.venv\Scripts\python.exe client.py
9. 为什么服务端要重新定义 token、done 和 error
可以把上游模型的原始事件直接透传给前端,但这会让前端与某个模型厂商的字段结构强绑定。
本文在自己的后端定义统一事件:
text
token:本次收到一段可展示文本
done:模型回答正常结束
error:模型流或网络出现错误
以后更换模型服务商时,只需要修改后端的上游解析器,前端仍然处理同样的三种业务事件。
这就是 Adapter(适配器)思想:把外部变化隔离在系统边界内。
10. 为什么流开始后不能随意修改 HTTP 状态码
FastAPI 返回 StreamingResponse 后,响应头通常会先发送:
http
HTTP/1.1 200 OK
Content-Type: text/event-stream
如果模型在输出到一半时失败,HTTP 状态码已经发送,服务器不能再把之前的 200 改成 500。因此流式协议必须在数据层表达错误:
text
event: error
data: {"message":"模型流式响应中断"}
客户端不能只检查最开始的 HTTP 200,还必须监听 error 事件。
11. 浏览器 EventSource 的限制
浏览器原生 EventSource 使用方便,并支持自动重连,但它主要通过 GET 建立连接,不能像普通 fetch 一样自由发送 POST JSON 请求和任意请求头。
大模型聊天通常需要提交较长的用户问题,因此常见选择是:
- 使用
fetch()发送 POST,并手动读取ReadableStream; - 先 POST 创建生成任务,再用 EventSource GET 订阅任务事件;
- 使用本文的 Python
requests客户端; - 业务确实需要双向通信时使用 WebSocket。
不能仅因为 SSE 常与 EventSource 一起出现,就认为 SSE 接口只能使用 GET。SSE 描述的是响应数据格式,服务端也可以对 POST 请求返回 text/event-stream。
12. 代理和部署为什么会让"流式"变成"一次性返回"
程序代码逐段 yield,不代表用户一定逐段收到。中间层可能进行缓冲:
text
FastAPI
↓
Nginx / 网关 / CDN
↓
浏览器或客户端
如果 Nginx 等待内容积累到一定大小后再发送,前端看起来就像普通非流式接口。
排查方向:
- 确认响应类型为
text/event-stream; - 确认应用确实逐段
yield; - 使用
curl.exe -N排除前端渲染问题; - 检查反向代理是否启用响应缓冲;
- 检查网关、CDN 和负载均衡的空闲超时;
- 检查客户端自身是否等待完整响应后才打印。
X-Accel-Buffering: no 常用于提示 Nginx 不要缓冲,但最终行为仍取决于实际部署配置,不能把一个响应头当成所有网关的通用保证。
13. 心跳与空闲超时
如果模型长时间没有输出,中间代理可能把连接判断为空闲并关闭。SSE 可以发送注释作为心跳:
text
: heartbeat
心跳不包含业务数据,客户端可以忽略。生产系统需要根据代理空闲超时设计心跳间隔,但不要高频发送无意义数据。
本文的完整代理没有额外创建并发心跳任务,避免用简化代码掩盖协程取消、队列和资源释放问题。需要心跳时,可以让"上游读取任务"和"定时心跳任务"通过异步队列统一向响应生成器发送事件。
14. 客户端断开后为什么要停止上游调用
用户关闭页面后,如果后端仍然读取模型输出:
- 会继续占用连接;
- 可能继续产生 Token 费用;
- 无人消费生成结果;
- 大量无效任务可能耗尽连接池。
本文通过:
python
if await request.is_disconnected():
return
检查客户端是否断开。当生成器退出时,async with http_client.stream(...) 会关闭上游响应。
需要注意,客户端断开检测和上游计费停止的实际效果还取决于服务商是否支持请求取消,以及取消信号到达服务商的时间。
15. 对抗性审查:生产环境还缺少什么
15.1 用户鉴权
当前接口没有验证调用者身份,只适合本地学习。生产环境必须在建立模型流之前完成身份认证、权限检查和额度判断。
15.2 并发与连接限制
每个 SSE 请求会保持一段时间的连接。需要限制单用户并发数、系统最大连接数和最长生成时间,避免恶意用户耗尽资源。
15.3 错误信息脱敏
不能把 API Key、上游完整响应、内部地址或堆栈直接发送给客户端。对外错误应稳定简洁,详细信息进入内部脱敏日志。
15.4 内容审核
流式输出会在完整回答生成前把片段发给用户,因此事后审核可能已经来不及。如果业务风险高,需要设计输入审核、分段审核或先完整生成再审核的方案。后者会牺牲流式体验。
15.5 自动重连可能造成重复生成
浏览器 EventSource 自动重连适合通知流,但聊天生成任务重新连接后不应默认重新调用模型。更可靠的方案是使用任务 ID、事件 ID 和服务端结果缓存恢复已生成内容。
15.6 模型接口差异
并非所有模型都使用 choices[0].delta.content 和 [DONE]。接入前必须根据官方文档验证事件格式,不能凭接口名称推断兼容性。
16. 本篇总结
本文完成了 SSE 从协议到代码的完整闭环:
- SSE 是基于 HTTP 的服务器单向推送机制;
text/event-stream使用空行划分事件;event表示事件类型,data携带数据;- FastAPI 使用
StreamingResponse逐段返回内容; httpx.AsyncClient.stream()可以异步读取上游模型流;- 后端把厂商事件转换成统一的
token、done、error; - Python 客户端必须按事件边界解析,而不是把每行都当作事件;
- 流开始后发生错误,需要通过 SSE
error事件表达; - 代理缓冲、空闲超时和客户端断开都会影响最终效果。
下一篇将进入多模型统一接入问题,使用适配器模式隔离不同模型服务商的接口差异。
17. 练习题
- 修改最小示例,让每个事件都带有递增的
id; - 在 Python 客户端中统计首个
token到达所需时间; - 主动停止客户端,观察 FastAPI 是否结束生成器;
- 增加输入长度限制,并测试 FastAPI 的 422 响应;
- 为
error事件增加稳定的错误代码,而不暴露内部异常; - 思考为什么聊天流不能直接依赖 EventSource 自动重连;
- 研究当前部署环境的代理缓冲和空闲超时配置。