把 stream=True 加上去,接口就能一个字一个字往外吐了,看起来已经完成。
但上线之后,真正让人回头改代码的往往不是这个参数,而是它带来的一连串边界问题:解析偶发失败、输出里混进替换字符、重试之后内容重复、用户点了停止但后台还在跑。
这些问题的根源是同一个:流式响应返回的不再是一个完整的 JSON,而是一串被网络任意切分的事件,客户端必须自己把它拼回去。
流式接口用的是 SSE(Server-Sent Events)。响应的 Content-Type 是 text/event-stream,body 是一串以空行分隔的帧:
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。
最容易写错的版本是这样的——注意它把 chunk 当成了一行:
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,完全正常:
第一次 read: data: {"id":"chat","choices":[{"delta":{"con
第二次 read: tent":"你好"}}]}两次 split("\n") 拿到的都是残缺的 JSON,json.loads 必然失败。
正确做法是维护一个缓冲区,先拼、再按空行切帧。
补充一句:如果你用的是 iter_lines(),它内部已经做了行缓冲,会把跨 chunk 的半行拼回来,所以不会出现上面的问题——这也是为什么很多人写了很久都没遇到这个问题。
但还有两件事要处理:
\r\n,分隔符要同时兼容 \n\n 和 \r\n\r\ndata:,按规范它们应当用 \n 连接后再解析,不能只取第一行所以自己按空行聚合成帧,仍然是最稳妥的写法。
这个问题更隐蔽,表现为输出里偶尔冒出 �。
一个中文字在 UTF-8 里占 3 个字节,emoji 占 4 个。如果一次 read 正好切在字符中间:
第一次 read 的字节: ... E4 BD ← 「你」的前 2 个字节
第二次 read 的字节: A0 ... ← 「你」的最后 1 个字节每次拿到 chunk 都直接 .decode("utf-8") 的话,第一次会因为字节不完整而抛异常,或者用了 errors="replace" 就变成一个 �。
正确做法是用增量解码器,让它把不完整的字节留在内部缓冲里,等下一个 chunk 到了再拼:
import codecs
decoder = codecs.getincrementaldecoder("utf-8")()
text = decoder.decode(raw_bytes, final=False)好消息是:如果用的是 httpx 这类现代 HTTP 客户端,它的 iter_text() 内部已经用了增量解码器,不会有这个问题。但如果你的代码是直接读 socket、或者用某些老库逐 chunk 拿 bytes,就必须自己处理。
这是最容易被忽略、后果也最严重的一个。
假设你在客户端加了「失败自动重试」。当连接在吐出 20 个字之后断开,重试会发生什么?
第一次请求的输出:今天天气
连接断开 → 自动重试
第二次请求的输出:今天天气真不错
最终用户看到的: 今天天气今天天气真不错内容重复了,而且用户已经开始阅读了。
所以重试必须加一个前置条件:
只有「一个 token 都还没吐出来」时才能重试。
一旦已经产生过输出,唯一正确的做法是把错误抛给上层,由业务决定是提示用户重试、还是把已输出的内容保留下来。
另外还有两个配套细节:
429(限流)、5xx(服务端错误)可以重试;401(密钥错)、400(参数错)、404(模型不存在)重试多少次都一样,属于浪费429 要读 Retry-After 响应头,服务端已经告诉你该等多久了,自己瞎猜的退避时间可能太短下面这段用 httpx,把上面三个坑都处理了。依赖:pip install httpx。
# -*- 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 延迟:
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))很多人用「总耗时」来评估接口快慢,这在流式场景下会得出错误结论。
一个输出 800 字的回答,总耗时 20 秒,体感却可能很好——因为它第 0.8 秒就开始往外吐字了。反过来,一个总耗时 3 秒但首 token 要等 2.8 秒的接口,用户会觉得它「卡住了」。
这两个指标衡量的是完全不同的东西:
指标 | 含义 | 用户感知 |
|---|---|---|
首 token 延迟(TTFT) | 从发起请求到收到第一个字 | 「快不快」 |
总耗时 | 全部生成完 | 「什么时候能复制走」 |
优化方向也不一样。TTFT 高通常是排队、路由或模型预热的问题;总耗时高则多半是输出太长或解码速度不够。
所以在做接口选型或压测时,建议把 TTFT 的 p50/p95 和总耗时分开记录。只看平均值会掩盖长尾——而长尾才是用户抱怨的来源。
还有一个成本问题容易被忽略。
用户点了「停止生成」,如果客户端只是停止读取、不关闭连接,上游可能仍在继续生成 token,而这些都是要计费的。
用生成器实现时,正确的做法是在 finally 里显式关闭:
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 |
流式接口的代码量不大,但边界条件高度集中——分帧、解码、重试、中断,四个问题加起来也就几十行。把这几十行写对,线上就不会再出这类问题了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。