流式输出实战:SSE 解析、chunk 拼装与断流重试
结论先放这儿
| 事项 | 要点 |
|---|---|
| SSE 格式 | data: 前缀逐行读,[DONE] 收尾,冒号开头是心跳注释 |
| usage 统计 | 请求参数带 include_usage,最后一个 chunk 里给 |
| 断流重试 | 首字节前可安全重试,输出中途断了重试会重复计费 |
| 前端渲染 | 按 50-100ms 节流重绘,别每个 token 都刷 |
流式是体验工程的第一优先级,首 token 延迟比总时长更影响体感。这篇把流式接入的工程细节补齐,与语音篇的流水线、扛量篇的限流配着看。
一、SSE 长什么样
流式接口返回的是一条持续打开的 HTTP 连接,服务端逐块推数据,每块都是一小段 JSON。OpenAI 兼容接口的每一块长这样:
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
三件事要注意。每块以 data: 开头,解析时按行切、去掉前缀再 json.loads。结尾的 [DONE] 是文本不是 JSON,要单独判断。还有一类以冒号开头的行(: keep-alive),是 SSE 规范里的心跳注释,直接跳过。
二、chunk 拼装与 usage
每块 delta 里只有增量文本,要自己拼。拼接的同时统计 token 数:
python
import json, httpx
def stream_chat(messages):
content = []
usage = None
with httpx.stream('POST', BASE_URL + '/chat/completions',
headers=HEADERS, json={
'model': MODEL, 'messages': messages,
'stream': True,
'stream_options': {'include_usage': True},
}, timeout=120) as r:
for line in r.iter_lines():
if not line.startswith('data:'):
continue
payload = line[5:].strip()
if payload == '[DONE]':
break
chunk = json.loads(payload)
if chunk.get('usage'):
usage = chunk['usage'] # 最后一块才有
for choice in chunk.get('choices', []):
delta = choice.get('delta', {}).get('content')
if delta:
content.append(delta)
yield delta
print('usage:', usage)
两个细节容易踩。usage 挂在最后一个 chunk 里,且要显式传 include_usage 才有,不传就永远拿不到对账数据。choices 可能为空列表(只有 usage 的那块),遍历前判一下。
三、断流重试的重试边界
流式的重试比普通请求危险。连接还没建立、首字节还没到的时候重试是安全的,什么都没发生。输出到一半断了,重试等于从头再生成一次,用户看到内容闪重,账单计两次。
所以重试逻辑按阶段分。首字节超时,放心重试。输出中途断,有两种处理,要完整答案就带着已拼内容重新请求(让模型从已有内容续写),要体感就只提示用户重发。对账以 usage 为准,重试导致的双份计费要在日志里能对出来。
python
def stream_with_retry(messages, retries=2):
for i in range(retries + 1):
try:
first = None
gen = stream_chat(messages)
for delta in gen:
if first is None:
first = delta # 首字节到了,之后断流不再整体重试
yield delta
return
except httpx.ReadTimeout:
if first is not None:
raise # 输出中途断,交给上层决定
if i == retries:
raise
四、前端渲染别每个 token 都刷
拿到 delta 直接往 DOM 里塞,Markdown 渲染会闪,半个代码块会错位。两个惯用做法。按时间节流,50-100ms 批量刷一次。按句渲染,攒到标点再重绘,顺带给 TTS 之类的下游切句用。
写在最后
流式接入三件事,SSE 逐行解析(心跳注释和 [DONE] 单独处理),增量拼接加 usage 对账,重试按阶段分层(首字节前安全,输出中途要小心)。配上节流渲染,体感和账单都稳。
本文方法与代码按自己的项目实践整理,与语音篇的流水线、扛量篇的对账配着用。