接大模型接口时,把 stream=True 加上去只是第一步。
真正耗时间的是后面这些事:输出偶尔出现乱码 、网络抖一下重试后内容重复了 、用户点了停止但后台还在计费。
这些问题的根源都在同一个地方------流式响应返回的不再是一个完整的 JSON,而是一串被网络任意切分的事件。客户端要自己把它拼回去。
下面按「报文结构 → 三个坑 → 可运行的实现」来讲。
一、流式响应到底返回了什么
流式接口用的是 SSE(Server-Sent Events)。响应的 Content-Type 是 text/event-stream,body 是一串以空行分隔的帧:
代码块
css
data: {"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant"}}]}
data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"你"}}]}
data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"好"}}]}
data: [DONE]
三个要点:
- 每帧以
data:开头 ,帧与帧之间用一个空行 (\n\n)分隔 - 内容是增量的 ,在
choices[0].delta里,不是message - 结束标志是一帧
data: [DONE],收到就该主动关闭连接
delta 里除了 content,还可能出现 role(第一帧)、tool_calls(函数调用)、以及部分厂商的 reasoning_content(思维链)。解析时不要假设 delta.content 一定存在 ,否则遇到工具调用那一帧就会 KeyError。
二、三个必踩的坑
坑 1:按 chunk 切分会切断 JSON
最容易写错的版本是这样的------注意它把 chunk 当成了一行:
python
scss
for chunk in resp.iter_bytes():
for line in chunk.decode("utf-8").split("\n"): # ← 问题在这一行
if line.startswith("data:"):
obj = json.loads(line[5:]) # 偶发 JSONDecodeError
这段代码在本地跑往往没问题,上线后却偶尔抛 JSONDecodeError。原因是:一个 chunk 不等于一行。
TCP 是字节流,一次 read 返回多少字节,由网络、缓冲区和对端的写入节奏共同决定。一个完整的帧被拆进两次 read,完全正常:
代码块
arduino
第一次 read: data: {"id":"chat","choices":[{"delta":{"con
第二次 read: tent":"你好"}}]}
两次 split("\n") 拿到的都是残缺的 JSON,json.loads 必然失败。
正确做法是维护一个缓冲区,先拼、再按空行切帧。
这里有个细节值得说清楚:如果你用的是 iter_lines(),它内部已经做了行缓冲,会把跨 chunk 的半行拼回来,所以不会出现上面的问题------这也是为什么很多人写了很久都没踩到这个坑。
但即使如此,仍有两件事要处理:
- 有些反向代理会把换行改写成
\r\n,分隔符要同时兼容\n\n和\r\n\r\n - SSE 规范允许一帧里出现多行
data:,按规范它们应当用\n连接后再解析,不能只取第一行
所以自己按空行聚合成帧仍然是最稳妥的写法。
坑 2:UTF-8 多字节字符被截断
这个坑更隐蔽,表现为输出里偶尔冒出 �。
一个中文字在 UTF-8 里占 3 个字节,emoji 占 4 个。如果一次 read 正好切在字符中间:
代码块
arduino
第一次 read 的字节: ... E4 BD ← 「你」的前 2 个字节
第二次 read 的字节: A0 ... ← 「你」的最后 1 个字节
每次拿到 chunk 都直接 .decode("utf-8") 的话,第一次会因为字节不完整而抛异常,或者用了 errors="replace" 就变成一个 �。
正确做法是用增量解码器,让它把不完整的字节留在内部缓冲里,等下一个 chunk 到了再拼:
python
ini
import codecs
decoder = codecs.getincrementaldecoder("utf-8")()
text = decoder.decode(raw_bytes, final=False)
好消息是:如果用的是 httpx 这类现代 HTTP 客户端,它的 iter_text() 内部已经用了增量解码器,不会有这个问题。但如果你的代码是直接读 socket、或者用某些老库逐 chunk 拿 bytes,就必须自己处理。
坑 3:流已经开始就不能简单重试
这是最容易被忽略、后果也最严重的一个。
假设你在客户端加了「失败自动重试」。当连接在吐出 20 个字之后断开,重试会发生什么?
代码块
第一次请求的输出:今天天气
连接断开 → 自动重试
第二次请求的输出:今天天气真不错
最终用户看到的: 今天天气今天天气真不错
内容重复了,而且用户已经开始阅读了。
所以重试必须加一个前置条件:
只有「一个 token 都还没吐出来」时才能重试。
一旦已经产生过输出,唯一正确的做法是把错误抛给上层,由业务决定是提示用户重试、还是把已输出的内容保留下来。
另外还有两个配套细节:
- 只重试可重试的错误 :网络层面的传输错误、
429(限流)、5xx(服务端错误)可以重试;401(密钥错)、400(参数错)、404(模型不存在)重试多少次都一样,属于浪费 429要读Retry-After响应头,服务端已经告诉你该等多久了,自己瞎猜的退避时间可能太短
三、一份可运行的实现
下面这段用 httpx,把上面三个坑都处理了。依赖:pip install httpx。
python
python
# -*- coding: utf-8 -*-
"""流式调用:SSE 分帧、增量解码、重试边界。"""
import json
import random
import time
import httpx
def stream_once(url, key, payload, timeout=120.0):
"""单次流式请求,逐段 yield 文本增量。"""
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
buf = ""
with httpx.Client(timeout=timeout) as client:
with client.stream("POST", url, json=payload, headers=headers) as resp:
if resp.status_code >= 400:
resp.read() # 错误体是普通 JSON,先读出来再抛
resp.raise_for_status()
# iter_text() 内部用增量解码器,跨 chunk 的多字节字符不会被切坏
for chunk in resp.iter_text():
# 统一换行,兼容网关把 \n 改写成 \r\n 的情况
buf += chunk.replace("\r\n", "\n")
# 关键:先聚合,再按空行切帧;不要直接按行 json.loads
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
for line in frame.split("\n"):
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue # 畸形帧跳过,不要因此中断整个流
choices = obj.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
text = delta.get("content")
if text:
yield text
def stream_chat(url, key, payload, max_retries=2, base_delay=0.5):
"""带重试的流式调用:只在尚未产出任何内容时才重试。"""
for attempt in range(max_retries + 1):
emitted = False
try:
for piece in stream_once(url, key, payload):
emitted = True
yield piece
return
except httpx.HTTPStatusError as err:
status = err.response.status_code
retryable = status == 429 or status >= 500
if emitted or not retryable or attempt == max_retries:
raise
# 429 优先听服务端的 Retry-After
wait = None
if status == 429:
raw = err.response.headers.get("Retry-After")
if raw and raw.isdigit():
wait = float(raw)
if wait is None:
wait = base_delay * (2 ** attempt) + random.uniform(0, 0.2)
time.sleep(wait)
except httpx.TransportError:
if emitted or attempt == max_retries:
raise
time.sleep(base_delay * (2 ** attempt) + random.uniform(0, 0.2))
调用方式和量首 token 延迟:
python
python
import os
payload = {
"model": "your-model",
"messages": [{"role": "user", "content": "用三句话介绍什么是 SSE"}],
"stream": True,
}
# 接口地址和密钥从环境变量读,不要写死在代码里
base_url = os.environ["LLM_BASE_URL"] # 形如「协议://主机/v1」
api_key = os.environ["LLM_API_KEY"]
t0 = time.perf_counter()
ttft = None
pieces = []
gen = stream_chat(base_url + "/chat/completions", api_key, payload)
try:
for piece in gen:
if ttft is None:
ttft = time.perf_counter() - t0 # 首 token 延迟
pieces.append(piece)
finally:
gen.close() # 关键:中断时主动关闭,否则上游会继续生成、继续计费
total = time.perf_counter() - t0
print(f"首 token 延迟 {ttft:.2f}s,总耗时 {total:.2f}s")
print("".join(pieces))
四、为什么一定要单独量首 token 延迟
很多人用「总耗时」来评估接口快慢,这在流式场景下会得出错误结论。
一个输出 800 字的回答,总耗时 20 秒,体感却可能很好------因为它第 0.8 秒就开始往外吐字了。反过来,一个总耗时 3 秒但首 token 要等 2.8 秒的接口,用户会觉得它「卡住了」。
这两个指标衡量的是完全不同的东西:
| 指标 | 含义 | 用户感知 |
|---|---|---|
| 首 token 延迟(TTFT) | 从发起请求到收到第一个字 | 「快不快」 |
| 总耗时 | 全部生成完 | 「什么时候能复制走」 |
优化方向也不一样。TTFT 高通常是排队、路由或模型预热的问题;总耗时高则多半是输出太长或解码速度不够。
所以在做接口选型或压测时,建议把 TTFT 的 p50/p95 和总耗时分开记录。只看平均值会掩盖长尾------而长尾才是用户抱怨的来源。
五、中断处理:点了停止就该真的停
最后说一个容易被忽略的成本问题。
用户点了「停止生成」,如果客户端只是停止读取、不关闭连接,上游可能仍在继续生成 token,而这些都是要计费的。
用生成器实现时,正确的做法是在 finally 里显式关闭:
python
csharp
gen = stream_chat(url, key, payload)
try:
for piece in gen:
print(piece, end="", flush=True)
if user_pressed_stop():
break
finally:
gen.close()
httpx 的 client.stream() 在上下文退出时会关闭连接,而生成器被 close() 时会触发 GeneratorExit,把上下文一起退掉。如果漏了这一步,那个「已经没人看的回答」会一直生成到最大 token 数。
如果是 asyncio 场景,还要注意 asyncio.CancelledError 会在 await 点抛出,finally 里的清理代码要能容忍它。
六、小结
| 问题 | 根因 | 处理方式 |
|---|---|---|
| JSON 解析偶发失败 | 网络把一帧切成了两半 | 维护缓冲区,按空行聚合后再解析 |
输出出现 � |
多字节字符被 chunk 边界截断 | 用增量解码器(httpx 的 iter_text 已内置) |
| 重试后内容重复 | 流已产出内容仍在重试 | 只有「零输出」时才允许重试 |
| 白烧 token | 中断时没关闭连接 | 在 finally 里 gen.close() |
| 觉得接口慢但说不清 | 用总耗时代替了 TTFT | 两个指标分开统计 p50/p95 |
流式接口的代码量不大,但边界条件集中在很短的几行里。把这四个处理对,线上基本就不会再出这类问题了。