OpenAI 兼容协议的流式响应,最后一定会有一个 data: [DONE] 结尾;Anthropic 原生协议最后一定会有一个 message_stop 事件。收到它,才算这次生成完整结束,用量统计也才准确。
客户端一直收数据,突然连接关闭,没有 [DONE],也没有异常。Python 侧常见的是 RemoteProtocolError: peer closed connection without sending complete message body (incomplete chunked read),Node 侧常见的是 TypeError: terminated 或者 SocketError: other side closed。这类是链路上的某一环把连接掐了。
流已经开始,服务端在流里插入一条错误事件,比如 data: {"error":{"message":"...","type":"overloaded_error"}},然后关闭连接。注意这条错误是 HTTP 200 之后才出现的,看状态码永远是 200,只有解析内容才能发现。
| 现象 | 真实原因 | 定位方法 |
|---|---|---|
| 没有结束标记 | 连接被中间层掐断 | 对比直连和经代理的耗时 |
| 内容卡住不动 | 代理开启了缓冲 | 看是否一次性吐出全部 |
| 收到 error 事件 | 上游过载或内部错误 | 打印流里的每条数据 |
| JSON 解析失败 | 数据被按字节切开 | 检查是否有累积缓冲 |
| 中文乱码 | 多字节字符被截断 | 用增量解码而非逐块解码 |
客户端界面显示已经完成,日志里却没有结束标记;或者界面卡住不动,日志其实早就正常结束了。以日志里的结束标记为准,界面的状态更新可能被前端逻辑吞掉了,尤其在快速连续渲染时。
httpx.RemoteProtocolError: peer closed connection without sending complete message body (incomplete chunked read)
json.decoder.JSONDecodeError: Unterminated string starting at: line 1 column 1 (char 0)
第二句的成因几乎都是同一件事:一行 SSE 数据被网络拆成了两次到达,你只拿到前半段就急着 json.loads。
openai.APIError: Expecting value: line 1 column 1 (char 0) 一般说明收到的不是 JSON,可能是 HTML 错误页,也可能是半截数据。
fetch failed、UND_ERR_SOCKET、other side closed,都指向同一个方向:对端在响应完成前关闭了连接。
前端直连时,中断在控制台里通常显示成 net::ERR_INCOMPLETE_CHUNKED_ENCODING 或者 Failed to fetch。这两个提示都指向「响应没传完」,不是跨域问题,不要往跨域方向查,否则会一直在改请求头。
一段 SSE 数据由若干行组成:event: 行说明事件类型,data: 行承载 JSON,空行表示一块事件结束。解析时必须按空行分块,不能按行解析。
| 事件名 | 出现时机 | 里面有什么 |
|---|---|---|
message_start | 流刚开始 | 消息 id、模型名 |
content_block_delta | 每吐出一小段 | 增量文本 |
content_block_stop | 一个内容块结束 | 块序号 |
message_delta | 收尾阶段 | 结束原因、用量 |
message_stop | 流正常结束 | 无 |
data: [DONE] | OpenAI 兼容协议结束 | 无 |
data: [DONE] 是数据行的内容,不是合法的 JSON。解析时先判断这个字符串,再走 JSON 解析,否则一定会抛解析异常。
反向代理默认会把上游响应攒到一定大小再转发。上游是流式的,客户端却看到长时间空白后一次性出现全部内容,看起来像卡住然后突然完成。关掉缓冲:
location /v1/ {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}
proxy_http_version 1.1 和 Connection: "" 这两行是配套的,缺了它们分块传输可能被降级。
有的环节对响应做压缩,压缩算法必须攒够一块数据才能输出,流式效果同样被破坏掉。流式接口上关掉响应压缩是最省事的做法。
带思考的模型在推理阶段不吐任何字节,某些网关的空闲判定是 30 秒到 60 秒,正好卡在这段空窗。把读等待时间放宽到 600 秒。
在服务器本机直接请求网关,再经公网域名请求一次。本机流式正常、域名卡住,就是代理配置的问题,重点看缓冲和分块传输这两项;两次都卡住,往上去看上游状态。分开测一次,比在配置里反复猜要快。
自己实现流式转发时,把上游的分块原样转发即可,不要拆包重组。任何一次手工分块都可能切在字符中间,客户端就会看到乱码。
一个中文字在 UTF-8 里占三个字节。网络分块不按字符边界切,一块数据可能在某个字的中间断掉。逐块 decode("utf-8") 就会在那一块上报解码错误,或者显示成乱码方块。
import codecs
decoder = codecs.getincrementaldecoder("utf-8")(errors="ignore")
for raw in resp.iter_content(chunk_size=None):
text = decoder.decode(raw) # 跨块保留半个字符
if text:
print(text, end="", flush=True)
print(decoder.decode(b"", final=True)) # 收尾时必须再调一次
结尾那次调用不能省,否则最后一个字符如果只到了一部分,会被永久丢掉。
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8"); // 默认就是流式,会自动缓存半个字符
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n"); // 按空行分块
buffer = parts.pop(); // 最后一块可能不完整,留着
for (const part of parts) {
const line = part.split("\n").find(l => l.startsWith("data:"));
if (!line) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
try {
const json = JSON.parse(payload);
const delta = json.choices?.[0]?.delta?.content || "";
if (delta) render(delta);
} catch (e) { /* 半截数据,丢掉等下一块 */ }
}
}
注意 buffer = parts.pop() 这一行:最后一段可能是不完整的块,必须留到下一轮再拼,否则会出现「偶尔少几个字」。
import json, codecs, requests
decoder = codecs.getincrementaldecoder("utf-8")(errors="ignore")
buffer = ""
full = ""
finished = False
with requests.post(
"https://xyuapi.top/v1/chat/completions",
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"},
json={"model": "claude-sonnet-4-5-thinking", "max_tokens": 2048,
"stream": True, "messages": [{"role": "user", "content": "写一段介绍"}]},
stream=True, timeout=(10, 600),
) as resp:
resp.raise_for_status()
for chunk in resp.iter_content(chunk_size=None):
buffer += decoder.decode(chunk)
if "\n\n" not in buffer:
continue
blocks = buffer.split("\n\n")
buffer = blocks.pop()
for block in blocks:
for line in block.splitlines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
finished = True
break
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
if "error" in obj:
print("流内错误:", obj["error"])
continue
delta = (obj.get("choices") or [{}])[0].get("delta", {}).get("content")
if delta:
full += delta
print(delta, end="", flush=True)
print("\n完成标志:", finished, "已收字数:", len(full))
stream=True 传给请求方法本身而不是只写在 body 里;chunk_size=None 让底层按到达即回调的方式吐数据;解析前先判断 [DONE]。这三点里漏掉任何一个,都会出现解析异常或者内容丢失。
把 finished 记进日志。没有完成标志却有内容,说明是中断;两者都没有,说明根本没开始生成。
每次流结束时,比较累计收到的字符数和结束标记的状态。两项同时正常才算成功,任何一项异常都记一条日志。有了这条日志,中断是偶发还是规律,几天就能看出来。
中断时用户已经看到了一部分文字,把这段保留下来,别清空重来。清空重来会让界面闪一下再重新出现,体验很差。
一是把已生成的内容作为助手消息放进对话历史,让模型接着写;二是直接给用户已收到的部分,附一句「生成中断」。内容超过八成时选第二种更划算,因为续写要再付一次请求的钱,收益很小;内容不到三成时选第一种。
流式请求同样是按次计费,重试就是第二次付费。判断依据是已完成比例,不是心情。
| 已完成比例 | 建议动作 |
|---|---|
| 低于三成 | 重试一次,并把已有内容带上续写 |
| 三成到八成 | 提示中断,允许用户手动继续 |
| 高于八成 | 直接使用已有内容,不重试 |
自动重连能救回一部分偶发断流,但每一次重连都是一次新的计费请求。合理的做法是:已完成比例低于三成时自动重连一次,超过就不再自动重连,改成给用户一个继续生成的按钮,由用户决定花不花这一次请求。
把每次中断记成一条结构化日志。
日志里固定四个字段:模型名、是否收到结束标记、已收字符数、耗时。攒上几十条之后,规律自己就浮出来了,是某个模型容易断,还是某个时段容易断,一眼就能看出来。
| 顺序 | 检查项 | 通过标准 |
|---|---|---|
| 1 | 是否收到结束标记 | 有 [DONE] 或 message_stop |
| 2 | 代理是否关缓冲 | 内容持续吐出而非一次性出现 |
| 3 | 读等待时间 | 中途不出现 504 |
| 4 | 分块解析方式 | 按空行分块,留残余 |
| 5 | 中文解码 | 用增量解码器,收尾再调一次 |
| 6 | 流内错误处理 | 打印出 error 事件内容 |
curl -N -s https://xyuapi.top/v1/chat/completions \
-H "Authorization: Bearer sk-你的Key" \
-H "Content-Type: application/json" \
-d '{"model":"claude-sonnet-4-5-thinking","max_tokens":1024,"stream":true,"messages":[{"role":"user","content":"写一段300字介绍"}]}'
-N 表示不做输出缓冲,数据到一条打一条。不加这个参数,你会看到内容一次性全部出现,然后误判成代理开了缓冲,白改一通配置。
命令跑完,滚动到最后一行。有 data: [DONE] 就是正常结束,没有就是被切断了。这一步能把「服务端生成完了但链路断了」和「服务端根本没生成完」分开,两种情况的处理方向完全相反。
结束前的用量数据块要能跳过。
有的实现会在结束前再发一条带用量统计的数据块,里面是各种计数。它同样是合法 JSON,解析时不要因为里面没有 choices 就抛错,取不到增量内容就跳过继续读下一条。
界面上要把状态切过去。
中断之后,界面上的状态必须从「生成中」切成「已中断」,不要一直转圈。一直转圈会让用户以为还在生成,白等几分钟才来问,最后还是绕回同一个问题。
分块要按空行切,不能按换行切。
SSE 的一块事件由多行组成,按换行切会把 event: 行和 data: 行拆散,解析就会漏事件。按空行切之后,最后一段不完整的内容要留在缓冲区里等下一块,这个细节决定了会不会偶尔少几个字。
小鱼API 是 AI API 接入平台,走 OpenAI 兼容协议,主入口 https://xyuapi.top/v1,备用 https://xyuai.cc/v1。Chatbox、Cherry Studio、NextChat、Cline 这些客户端都支持流式输出,填上地址和令牌即可使用。
| 模型 | 单次价格 |
|---|---|
| gemini-2.5-pro | 0.031 元/次 |
| deepseek-v3.2-thinking | 0.049 元/次 |
| claude-sonnet-4-5-thinking | 0.09 元/次 |
| claude-opus-4-5-thinking | 0.12 元/次 |
流式输出本身不额外收费,一次请求一个固定价,输入长短也不影响价格。所以长文本生成建议一律开流式:不涨价,还能把超时和中断的风险降下来。