首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >中转站调用 Claude 流式输出卡住、断流、首字很慢怎么办?缓冲、超时、代理和重试的排查方法

中转站调用 Claude 流式输出卡住、断流、首字很慢怎么办?缓冲、超时、代理和重试的排查方法

原创
作者头像
stilldebugging
发布于 2026-11-03 11:21:11
发布于 2026-11-03 11:21:11
70
举报

流式调用出问题,量三个数:首字等了多久、中间最长停了多久、有没有收到结束事件。 这三个数分别对应三种故障:首字慢、卡住、断流。

本文用一段探针脚本取这三个数,脚本以灵眸 AI 的 Claude 接口(Anthropic 协议)为例,换其他中转站只需改 BASE_URL。 取到数以后,从离你最近的一层往外查:客户端、自己的反向代理或网关、本地网络和代理、中转站、上游。多数问题出在自己的反向代理和超时设置上。

TOC

一、速查表

现象

探针里看到

多半是

详见

首字慢

响应头很快到,首个正文片段很晚

模型在思考、输入太长、排队

2.2

输出攒着一次涌出

直连间隔小,经过自己的代理间隔大

反向代理在缓冲

3.1

停住一会儿又继续

间隔大,期间有 ping 或什么都没有

模型在思考;或某一层攒数据、网络抖动

3.2

输出到一半结束

没有 message_stop,也没有 error

读取超时、空闲超时、客户端超时、代理换了出口 IP

3.1、3.2

流里报错

收到 error 事件,HTTP 状态码仍是 200

上游在流开始后出错

3.4

非流式请求 504 或 524

等不到第一个字节

网关或源站超时

3.3

二、先量:探针脚本

2.1 脚本

下面的脚本绕开业务代码,向 /v1/messages 发一次流式请求,输出响应头到达时间、首个事件、首个思考片段、首个正文片段的时间,事件间最大间隔,各类事件数量,是否收到 message_stop,以及 error 事件、连接异常和请求 ID。

代码语言:python
复制
import json, os, sys, time
import requests

BASE = os.environ["BASE_URL"]  # 中转站地址,例如 https://你的中转站域名
MODEL = os.environ.get("MODEL", "claude-sonnet-5-5")
PROMPT = sys.argv[1] if len(sys.argv) > 1 else "用三百字介绍一下 TCP 三次握手。"

def probe():
    t0 = time.time()
    st = {"events": {}, "first_event": None, "first_thinking": None, "first_text": None,
          "max_gap": 0.0, "gap_at": "", "stop_reason": None, "usage": None,
          "done": False, "error": None, "broken": None}
    last, cur_event = t0, None
    try:
        # timeout=(连接, 读取):读取超时指两次收到数据之间的间隔,不是整个响应的时长
        r = requests.post(f"{BASE}/v1/messages", stream=True, timeout=(10, 120), headers={
            "Authorization": f"Bearer {os.environ['API_KEY']}",
            "anthropic-version": "2023-06-01", "content-type": "application/json"},
            json={"model": MODEL, "max_tokens": 1024, "stream": True,
                  "messages": [{"role": "user", "content": PROMPT}]})
        st["status"], st["headers_s"] = r.status_code, time.time() - t0
        st["request_ids"] = {k: v for k, v in r.headers.items() if "request-id" in k.lower()}
        r.encoding = "utf-8"
        if r.status_code != 200:
            st["error"] = r.text[:300]  # 非 200 时返回的是普通 JSON 错误,不是事件流
        for line in (r.iter_lines(decode_unicode=True) if r.status_code == 200 else []):
            now = time.time()
            if not line:
                continue
            if line.startswith("event:"):
                cur_event = line[6:].strip()
                st["events"][cur_event] = st["events"].get(cur_event, 0) + 1
                if st["first_event"] is None:
                    st["first_event"] = now - t0
                elif now - last > st["max_gap"]:
                    st["max_gap"], st["gap_at"] = now - last, cur_event
                last = now
            elif line.startswith("data:"):
                data = json.loads(line[5:])
                if cur_event == "content_block_delta":
                    kind = data["delta"]["type"]
                    if kind == "thinking_delta":
                        st["first_thinking"] = st["first_thinking"] or now - t0
                    elif kind == "text_delta":
                        st["first_text"] = st["first_text"] or now - t0
                elif cur_event == "message_delta":
                    st["stop_reason"], st["usage"] = data["delta"].get("stop_reason"), data.get("usage")
                elif cur_event == "message_stop":
                    st["done"] = True
                elif cur_event == "error":
                    st["error"] = data
    except requests.exceptions.RequestException as e:
        st["broken"] = f"{type(e).__name__}: {e}"
    st["total_s"] = time.time() - t0
    return st

if __name__ == "__main__":
    s = probe()
    f = lambda v: "无" if v is None else f"{v:.2f}s"
    print(f"HTTP 状态 {s.get('status')},响应头到达 {f(s.get('headers_s'))},请求 ID {s.get('request_ids')}")
    print(f"首个事件 {f(s['first_event'])},首个思考片段 {f(s['first_thinking'])},首个正文片段 {f(s['first_text'])}")
    print(f"事件间最大间隔 {s['max_gap']:.2f}s(出现在 {s['gap_at'] or '无'} 之前),总耗时 {s['total_s']:.2f}s")
    print(f"事件计数 {s['events']}")
    print(f"正常结束(message_stop) {s['done']},stop_reason {s['stop_reason']},usage {s['usage']}")
    if s["error"]:
        print("错误内容:", s["error"])
    if s["broken"]:
        print("连接中断:", s["broken"])
    elif not s["done"] and not s["error"]:
        print("流提前结束:没有收到 message_stop,也没有错误事件")

用法:BASE_URL=https://你的中转站域名 API_KEY=sk-... python stream_probe.py。Key 放环境变量,不要写进脚本。Base URL 不带 /v1,路径由脚本拼接;鉴权头和模型 ID 的写法因中转站而异,按它的文档改。脚本用本地模拟的事件流测过五种情形(正常、首字慢、中途停顿、提前断开、流内错误),没有用真实 Key 运行过。

2.2 怎么读结果

  • 首字慢没有通用的"正常值"。 首字时间随模型、输入长度和是否开启思考而变。同一个提示词连测 10 次取中位数,当作自己的基线;每次只改一个变量:换更小的模型,缩短输入,对比带与不带思考参数。"首个思考片段"早、"首个正文片段"晚,说明时间花在思考上。
  • 间隔大但期间有 ping,说明连接还在,只是模型慢。Anthropic Python SDK 解析事件流时直接跳过 ping1。
  • 间隔大且期间什么都没有,数据被某一层攒住了,或网络在抖。
  • 脚本正常、业务代码异常,问题在你的框架、反向代理或客户端配置。

这些判断是根据事件流结构做的推断,不是平台文档的结论。

三、再查:从近到远

3.1 反向代理在缓冲,输出攒着一次涌出

直连时逐字输出,经过你的 Nginx 后变成停一会儿再出一大段,通常是缓冲。Nginx 的 proxy_buffering 默认开启,响应先存进缓冲区;关闭后,收到多少就转发多少4。

代码语言:nginx
复制
location /v1/ {
    proxy_pass https://你的中转站域名;
    proxy_ssl_server_name on;   # 默认 off;上游是 HTTPS 时,让 TLS 握手带上服务器名
    proxy_http_version 1.1;
    proxy_buffering off;        # 默认 on:响应先攒进缓冲区再发给客户端
    proxy_read_timeout 600s;    # 默认 60s:两次读取之间超过这个时间没数据就断开
}

proxy_read_timeout 计的是两次读取之间的间隔,不是整个响应的时长,默认 60 秒4。模型思考超过 60 秒没有输出,默认配置会把连接关掉。这段配置只对照官方文档写成,上线前先在测试环境验证。

3.2 每一层都有空闲超时,代理出口也会打断长连接

流式连接在模型思考阶段可能长时间没有数据,任何一层的空闲超时都会把它切断。AWS 的 ALB 默认空闲超时是 60 秒,文档建议在超时前发送数据,并把超时调大5。其他负载均衡、API 网关和自己写的转发服务也有类似设置,查各自文档。

WHATWG 的 SSE 规范建议:旧式代理可能在短时间后断开连接,可以每 15 秒左右发送一行以冒号开头的注释行保活2。中转站是否这样做,我没查到;自己写转发服务时可以参照。

开着 VPN 或系统代理、出口 IP 在轮换时,长连接也可能被打断。这是排查经验,没有找到官方文档。验证方法是同一台机器关掉代理和开着代理各跑一遍探针,差异大,问题就在代理。

3.3 非流式长请求:504、524 和客户端超时

非流式请求要等整个回答生成完才返回第一个字节,回答越长,越容易撞上某一层的超时:

  • 网关层。RFC 9110 规定,504 表示作为网关或代理的服务器没有及时收到上游的响应3。链路前有 Cloudflare 时,524 表示它连上了源站,但源站在默认 125 秒内没有返回 HTTP 响应,企业版可调到最多 6000 秒6。流式请求很快就返回响应头并持续输出,通常不会撞上这类超时(这是我的推断)。
  • 客户端层。Anthropic Python SDK 默认超时 10 分钟。非流式的 messages.create 在没有显式设置超时时,按"3600 秒 × max_tokens ÷ 128000"估算耗时,超过 600 秒就抛出 ValueError,要求改用流式,对应 max_tokens 超过约 21,3331(main 分支源码,以你安装的版本为准)。

长任务一律用流式,并把客户端超时设得比任务时长长。用 Claude Code 跑长会话时,可以通过环境变量 API_TIMEOUT_MS(毫秒)调大超时,中转站接入教程里常见的示例值是 3000000(50 分钟)。

3.4 流里出现 error 事件,或回答被截断

流式响应一旦开始,HTTP 状态码已经发出,后面再出错只能通过事件流告诉你。 Anthropic Python SDK 遇到 error 事件会抛出异常1。自己解析 SSE 必须处理 event: error,否则会把失败当成"正常结束但内容不全"。

判断一次流式调用是否完整,要同时看是否收到 message_stop,以及 message_delta 里的 stop_reason。stop_reason 为 max_tokens 是正常结束但被长度上限截断,需要调大 max_tokens 或让模型继续。

四、断流之后:重试和对账

  1. 只重试该重试的错误。 Anthropic Python SDK 默认最多重试 2 次,退避从 0.5 秒起、上限 8 秒,对连接错误、超时和状态码 408、409、429、5xx 重试1。400、401 这类请求本身有问题的错误,重试没有意义。SDK 的重试发生在请求阶段,流已经开始后中途断开要自己处理,我在源码中没有看到自动续传。
  2. 整条重试最简单可靠。 长输出想省成本,可以把已收到的内容作为上下文让模型接着写,但衔接处可能重复,要自己验证(这是我的建议,不是官方做法)。
  3. 记录并单独标出断流请求。 每次请求至少记录请求 ID、开始时间、已收到的字符数、断开时间、异常类型。中转站调用 Claude 时断流请求如何计费,我没查到文档,对账时与平台明细逐条核对。
  4. 用平台明细补齐用量。 断流时客户端往往收不到靠后的 message_delta 事件,里面带着最终的输出 Token 数1,客户端日志里这次请求的用量就缺失了。灵眸 AI 的用量导出接口 GET /api/v1/usage 返回 request_id、input_tokens、output_tokens、first_token_ms、duration_ms 等字段(来源:其官方文档《导出 Usage 使用明细》),可以按请求 ID 补齐用量,后两项还能和探针测到的首字时间、总耗时对照,两边差得多,说明时间花在链路上。

五、常见问题

首字等 5 秒正常吗?

没有通用答案,取决于模型、输入长度和是否开启思考。先在同一台机器上直连测 10 次取中位数作基线,业务代码里的首字时间明显高于基线,问题多半在自己的链路。

经过自己的 Nginx 后,输出一次出一大段,怎么办?

关闭 proxy_buffering,并调大 proxy_read_timeout4。

流式请求没报错,但回答只有一半,怎么判断?

先看有没有 message_stop。没有 message_stop 也没有错误事件,是连接被提前关闭;stop_reason 为 max_tokens,是长度上限截断1。

非流式请求报 504 或 524,怎么办?

改用流式,并设置足够长的客户端超时3。

参考资料

以下资料均于 2026-11-02 读取。Anthropic 官方文档站在写作环境因地区限制打不开,事件类型和 SDK 行为以 GitHub 上的 SDK 源码为准。

平台功能、默认值和 SDK 行为会调整,落地前以各自官方文档和你安装的版本为准。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、速查表
  • 二、先量:探针脚本
    • 2.1 脚本
    • 2.2 怎么读结果
  • 三、再查:从近到远
    • 3.1 反向代理在缓冲,输出攒着一次涌出
    • 3.2 每一层都有空闲超时,代理出口也会打断长连接
    • 3.3 非流式长请求:504、524 和客户端超时
    • 3.4 流里出现 error 事件,或回答被截断
  • 四、断流之后:重试和对账
  • 五、常见问题
    • 首字等 5 秒正常吗?
    • 经过自己的 Nginx 后,输出一次出一大段,怎么办?
    • 流式请求没报错,但回答只有一半,怎么判断?
    • 非流式请求报 504 或 524,怎么办?
  • 参考资料
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档