调用大模型接口时,流式返回能明显降低首字延迟,但解析环节出错的概率也远高于一次性返回。本文只讨论一件事:Python 侧如何把一条持续到达的 HTTP 响应流,正确还原成完整文本。需要先说明,本文写作时未获得该接口的官方事实资料(端点、参数名、分片字段结构、结束标记均未提供),因此下文提到的具体字段名和标记一律按“常见约定”表述,落地前请以官方文档为准。
一、流式响应的本质是一条连接上不断追加的字节
服务端不会等全部内容生成完再返回,而是边生成边把分片写进响应体。客户端拿到的不是一份完整 JSON,而是一串按 SSE(Server-Sent Events,Content-Type 通常是 text/event-stream)约定的文本帧。SSE 的基本单位是行:以 data: 开头的行携带载荷,空行表示一帧结束。很多实现在最后一帧发送一个固定标记表示流结束,最常见的写法是 [DONE]。这些属于协议层约定,与具体业务字段无关。
二、requests:stream=True 加 iter_lines
import json, requests
def stream_chat(url, headers, payload, timeout=(10, 60)):
with requests.post(url, headers=headers, json=payload,
stream=True, timeout=timeout) as r:
r.raise_for_status()
for raw in r.iter_lines(decode_unicode=False):
if not raw:
continue
line = raw.decode("utf-8", errors="replace").strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
# 解析 data,按增量或累计两种模式分别处理
几个要点。第一,必须加 stream=True,否则 requests 会先把整个响应体读进内存,流式等于白开。第二,timeout 建议用元组区分连接超时与读取超时;在流式场景下,读取超时实际是“两次数据到达之间的最大间隔”,设得太短会在模型长时间思考时被误判为断开。第三,iter_lines(decode_unicode=False) 后自己 decode 更稳,因为 decode_unicode=True 依赖响应头推断编码,服务端未明确 charset 时可能猜错。
三、httpx:同步与异步写法一致
with httpx.stream("POST", url, headers=headers, json=payload,
timeout=httpx.Timeout(60, connect=10)) as r:
for line in r.iter_lines():
...
异步版本把 with 换成 async with,把 client.stream 与 aiter_lines 配合使用即可。httpx 的 iter_lines 内部已做增量解码,但如果你用的是 iter_bytes 或 iter_raw,就要自己维护一个 bytearray 缓冲区,按换行符切分,只有确认切出一整行之后才 decode。这一点在第 4 节还会展开。
四、缓冲区残留:流式解析最容易踩的坑
第一种残留是“最后一行没有换行符就断开”。很多按行产出的实现只在遇到换行符时才吐出一行,连接中断时末尾那半行仍留在缓冲区里,直接丢弃就会丢字。稳妥做法是在循环结束后再 flush 一次缓冲区,把剩余字节解码后尝试解析。
第二种残留是“多字节字符被截断”。UTF-8 下一个汉字占 3 字节,如果按 chunk 边界 decode,就会得到替换字符。要么坚持按完整行 decode,要么使用增量解码器逐块喂入。
第三种残留是重复输出。断线重连时如果服务端按整段重发,而客户端把新收到的内容直接追加到已有文本后面,用户就会看到两遍甚至三遍相同内容。
五、增量拼接与去重
流式返回的分片有两种常见语义。一种是“累计文本”,每一帧都从头到当前的全部内容,正确做法是覆盖而不是追加:保存上一次的值,只取多出来的后缀。另一种是“增量片段”,每一帧只包含这次新产生的内容,直接追加即可。两者外观相似,混淆就会得到重复或缺失的文本。判断方法很简单:打印前两帧做对比,看第二帧是否包含第一帧的全部内容。
还有一个细节:拼接时不要对解析出的片段调用 strip()。模型输出的空格与换行是有意义的,逐帧去空白会破坏中英文混排的间距和代码块的缩进。strip 只应作用在协议行上,用来判断 data: 前缀,不应作用在内容字段上。
六、断线重连的处理
流式连接因网络抖动断开是常态,工程上通常这样组织:维护“已确认文本”和“本次连接新收到文本”两个变量,确认一帧后再合并,不要直接往同一个字符串上反复追加;重连时如果服务端不支持断点续传,就整段重新生成并覆盖,而不是续写追加;如果服务端支持传入已生成内容做续写,再按增量片段处理。合并前做一次前缀比对,能挡掉大部分重复。
七、其他容易忽略的细节
反向代理是流式的常见杀手。中间若有 Nginx 一类代理,需要关闭响应缓冲,否则客户端看到的仍是一次性返回。raise_for_status() 要在开始读流之前调用,因为错误响应的响应体可能根本不是 SSE 格式。异常路径下要用 with 或 try/finally 保证连接关闭,否则长连接会堆积。调试时不要直接打印整个响应,逐行打印其 repr,才能看见不可见的回车符与尾部空白。
以上均为客户端解析层面的通用工程做法,不涉及任何特定服务端的字段定义。接口的端点、请求参数、分片字段名与结束标记,请以官方文档为准。