Claude 流式输出中断:断在哪、怎么接回来

流式中断有三种断法

正常结束长什么样

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 解析失败数据被按字节切开检查是否有累积缓冲
中文乱码多字节字符被截断用增量解码而非逐块解码

用户看到的现象和日志经常对不上

客户端界面显示已经完成,日志里却没有结束标记;或者界面卡住不动,日志其实早就正常结束了。以日志里的结束标记为准,界面的状态更新可能被前端逻辑吞掉了,尤其在快速连续渲染时。

报错原文对照

Python 客户端里常见的几句

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 兼容 SDK 时

openai.APIError: Expecting value: line 1 column 1 (char 0) 一般说明收到的不是 JSON,可能是 HTML 错误页,也可能是半截数据。

Node 侧的错误

fetch failedUND_ERR_SOCKETother side closed,都指向同一个方向:对端在响应完成前关闭了连接。

浏览器控制台里的表现

前端直连时,中断在控制台里通常显示成 net::ERR_INCOMPLETE_CHUNKED_ENCODING 或者 Failed to fetch。这两个提示都指向「响应没传完」,不是跨域问题,不要往跨域方向查,否则会一直在改请求头。

SSE 协议本身长什么样

事件的基本单位

一段 SSE 数据由若干行组成:event: 行说明事件类型,data: 行承载 JSON,空行表示一块事件结束。解析时必须按空行分块,不能按行解析。

事件名出现时机里面有什么
message_start流刚开始消息 id、模型名
content_block_delta每吐出一小段增量文本
content_block_stop一个内容块结束块序号
message_delta收尾阶段结束原因、用量
message_stop流正常结束
data: [DONE]OpenAI 兼容协议结束

[DONE] 不是一个事件名

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.1Connection: "" 这两行是配套的,缺了它们分块传输可能被降级。

压缩会把流攒起来

有的环节对响应做压缩,压缩算法必须攒够一块数据才能输出,流式效果同样被破坏掉。流式接口上关掉响应压缩是最省事的做法。

空闲超时会把思考阶段的连接断掉

带思考的模型在推理阶段不吐任何字节,某些网关的空闲判定是 30 秒到 60 秒,正好卡在这段空窗。把读等待时间放宽到 600 秒。

本地直连和经代理各测一次

在服务器本机直接请求网关,再经公网域名请求一次。本机流式正常、域名卡住,就是代理配置的问题,重点看缓冲和分块传输这两项;两次都卡住,往上去看上游状态。分开测一次,比在配置里反复猜要快。

自己实现流式转发时,把上游的分块原样转发即可,不要拆包重组。任何一次手工分块都可能切在字符中间,客户端就会看到乱码。

中文乱码:多字节字符被切开

现象的成因

一个中文字在 UTF-8 里占三个字节。网络分块不按字符边界切,一块数据可能在某个字的中间断掉。逐块 decode("utf-8") 就会在那一块上报解码错误,或者显示成乱码方块。

Python 用增量解码器

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))   # 收尾时必须再调一次

结尾那次调用不能省,否则最后一个字符如果只到了一部分,会被永久丢掉。

前端用 TextDecoder 的流式模式

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() 这一行:最后一段可能是不完整的块,必须留到下一轮再拼,否则会出现「偶尔少几个字」。

一套稳得住的流式解析

Python 完整写法

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 关掉命令行缓冲

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: 行拆散,解析就会漏事件。按空行切之后,最后一段不完整的内容要留在缓冲区里等下一块,这个细节决定了会不会偶尔少几个字。

接入地址和计费

一个 Key 调全系列模型

小鱼API 是 AI API 接入平台,走 OpenAI 兼容协议,主入口 https://xyuapi.top/v1,备用 https://xyuai.cc/v1。Chatbox、Cherry Studio、NextChat、Cline 这些客户端都支持流式输出,填上地址和令牌即可使用。

按次计费的价格

模型单次价格
gemini-2.5-pro0.031 元/次
deepseek-v3.2-thinking0.049 元/次
claude-sonnet-4-5-thinking0.09 元/次
claude-opus-4-5-thinking0.12 元/次

流式输出本身不额外收费,一次请求一个固定价,输入长短也不影响价格。所以长文本生成建议一律开流式:不涨价,还能把超时和中断的风险降下来。

相关阅读

🚀 想要立即使用?来小鱼API体验全系列AI模型

查看全部产品

支持Gemini / Claude / GPT / Grok / DeepSeek · 国内直连 · 支付宝/微信