流式调用出问题,量三个数:首字等了多久、中间最长停了多久、有没有收到结束事件。 这三个数分别对应三种故障:首字慢、卡住、断流。
本文用一段探针脚本取这三个数,脚本以灵眸 AI 的 Claude 接口(Anthropic 协议)为例,换其他中转站只需改 BASE_URL。 取到数以后,从离你最近的一层往外查:客户端、自己的反向代理或网关、本地网络和代理、中转站、上游。多数问题出在自己的反向代理和超时设置上。
TOC
现象 | 探针里看到 | 多半是 | 详见 |
|---|---|---|---|
首字慢 | 响应头很快到,首个正文片段很晚 | 模型在思考、输入太长、排队 | 2.2 |
输出攒着一次涌出 | 直连间隔小,经过自己的代理间隔大 | 反向代理在缓冲 | 3.1 |
停住一会儿又继续 | 间隔大,期间有 | 模型在思考;或某一层攒数据、网络抖动 | 3.2 |
输出到一半结束 | 没有 | 读取超时、空闲超时、客户端超时、代理换了出口 IP | 3.1、3.2 |
流里报错 | 收到 | 上游在流开始后出错 | 3.4 |
非流式请求 504 或 524 | 等不到第一个字节 | 网关或源站超时 | 3.3 |
下面的脚本绕开业务代码,向 /v1/messages 发一次流式请求,输出响应头到达时间、首个事件、首个思考片段、首个正文片段的时间,事件间最大间隔,各类事件数量,是否收到 message_stop,以及 error 事件、连接异常和请求 ID。
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 运行过。
ping,说明连接还在,只是模型慢。Anthropic Python SDK 解析事件流时直接跳过 ping1。这些判断是根据事件流结构做的推断,不是平台文档的结论。
直连时逐字输出,经过你的 Nginx 后变成停一会儿再出一大段,通常是缓冲。Nginx 的 proxy_buffering 默认开启,响应先存进缓冲区;关闭后,收到多少就转发多少4。
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 秒没有输出,默认配置会把连接关掉。这段配置只对照官方文档写成,上线前先在测试环境验证。
流式连接在模型思考阶段可能长时间没有数据,任何一层的空闲超时都会把它切断。AWS 的 ALB 默认空闲超时是 60 秒,文档建议在超时前发送数据,并把超时调大5。其他负载均衡、API 网关和自己写的转发服务也有类似设置,查各自文档。
WHATWG 的 SSE 规范建议:旧式代理可能在短时间后断开连接,可以每 15 秒左右发送一行以冒号开头的注释行保活2。中转站是否这样做,我没查到;自己写转发服务时可以参照。
开着 VPN 或系统代理、出口 IP 在轮换时,长连接也可能被打断。这是排查经验,没有找到官方文档。验证方法是同一台机器关掉代理和开着代理各跑一遍探针,差异大,问题就在代理。
非流式请求要等整个回答生成完才返回第一个字节,回答越长,越容易撞上某一层的超时:
messages.create 在没有显式设置超时时,按"3600 秒 × max_tokens ÷ 128000"估算耗时,超过 600 秒就抛出 ValueError,要求改用流式,对应 max_tokens 超过约 21,3331(main 分支源码,以你安装的版本为准)。长任务一律用流式,并把客户端超时设得比任务时长长。用 Claude Code 跑长会话时,可以通过环境变量 API_TIMEOUT_MS(毫秒)调大超时,中转站接入教程里常见的示例值是 3000000(50 分钟)。
流式响应一旦开始,HTTP 状态码已经发出,后面再出错只能通过事件流告诉你。 Anthropic Python SDK 遇到 error 事件会抛出异常1。自己解析 SSE 必须处理 event: error,否则会把失败当成"正常结束但内容不全"。
判断一次流式调用是否完整,要同时看是否收到 message_stop,以及 message_delta 里的 stop_reason。stop_reason 为 max_tokens 是正常结束但被长度上限截断,需要调大 max_tokens 或让模型继续。
message_delta 事件,里面带着最终的输出 Token 数1,客户端日志里这次请求的用量就缺失了。灵眸 AI 的用量导出接口 GET /api/v1/usage 返回 request_id、input_tokens、output_tokens、first_token_ms、duration_ms 等字段(来源:其官方文档《导出 Usage 使用明细》),可以按请求 ID 补齐用量,后两项还能和探针测到的首字时间、总耗时对照,两边差得多,说明时间花在链路上。没有通用答案,取决于模型、输入长度和是否开启思考。先在同一台机器上直连测 10 次取中位数作基线,业务代码里的首字时间明显高于基线,问题多半在自己的链路。
关闭 proxy_buffering,并调大 proxy_read_timeout4。
先看有没有 message_stop。没有 message_stop 也没有错误事件,是连接被提前关闭;stop_reason 为 max_tokens,是长度上限截断1。
改用流式,并设置足够长的客户端超时3。
以下资料均于 2026-11-02 读取。Anthropic 官方文档站在写作环境因地区限制打不开,事件类型和 SDK 行为以 GitHub 上的 SDK 源码为准。
_streaming.py 的 ping 与 error 处理,_base_client.py 的非流式超时与重试,_constants.py 的默认超时和重试次数):https://github.com/anthropics/anthropic-sdk-python平台功能、默认值和 SDK 行为会调整,落地前以各自官方文档和你安装的版本为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。