流式调用中途断连别直接报错:瞬态故障识别、退避重试与中文根因诊断的搭建方法

流式调用中途断连别直接报错:瞬态故障识别、退避重试与中文根因诊断的搭建方法

学习目标

读完这篇,你能给任意一个 LLM 流式调用加上四件套:可重试判断、退避重试、中文根因诊断、结构化失败日志。用户再遇到网关断连,看到的不再是一句裸英文报错,而是"已自动重试 N 次"或一句能看懂的中文原因。

开场:最气人的报错,是那句看不懂的英文

流式对话最气人的时刻,不是模型答得不好,而是答到一半断了,界面上甩出一句英文:Failed to parse JSON: missing field 'type'。

这句话是真的,但说了等于没说。用户不知道是网络问题、服务端问题,还是自己的问题;也不知道该重试、该换模型,还是该等一会儿。更糟的是,有一类断连其实完全可以自动恢复------网关抖了一下,连接在输出任何内容之前就断了,重建一次流就能好。直接把错误甩给用户,等于把本可以自动修好的故障变成了用户的麻烦。

看完我可以负责任地告诉你三件事:第一,流式故障要先分类,能自动恢复的重试、不能的才报错;第二,分类不能看错误的表象,要看错误体里的标记;第三,重试耗尽之后,必须给用户一句中文诊断------告诉他发生了什么、该怎么办,而不是把原始报错原文照单全收。

1. 前置知识:三个概念先说清

翻译成人话:瞬态故障就是"抖一下就好"的故障。 网关断连、连接重置、5xx 服务端错误,这类故障的特点是:不是你的请求有问题,是链路或服务端那一刻出了问题。换一个时刻重发,大概率就好了。跟它相对的是"重试也没用"的故障:密钥错了(401)、配额用完了(429 insufficient_quota)、请求格式错了------重发一百次还是同样的错。

为什么只在"输出前"重试。 流式输出一旦开始,内容已经一段一段发给用户了。这时候断连再重建流,模型会从头重新生成,用户会看到重复内容,或者两段内容拼接错位。所以重试的安全边界是:在输出任何内容之前发生的故障,才可以重试。输出开始之后的故障,只能保留已生成的内容、告诉用户断在哪里。

错误表象 vs 错误根因。 有的 SDK 会把底层错误"展平"成字符串,原始的错误链丢了。比如 rig 把火山网关的错误体当成 SSE 事件解析,对外表现为 Failed to parse JSON: missing field------看表象像解析错误,看错误体里的 code: InternalError 和 peer closed connection 才知道是网关断连。分类必须按根因标记来,不能按表象来。

2. 分步骤实操:五步搭一套瞬态故障处理

第 1 步:写一个分类函数,用标记识别瞬态故障

不要写一堆 if-else 散落在各处。集中一个函数,输入错误字符串,输出是不是瞬态故障:

rust 复制代码
fn is_transient_stream_error(msg: &str) -> bool {
let m = msg.to_lowercase();
const MARKERS: &[&str] = &[
"peer closed connection",
"incomplete chunked read",
"connection reset",
"broken pipe",
"connection closed before message completed",
"incompletemessage",
"internalerror",
"internal server error",
"bad gateway",
"service unavailable",
"gateway timeout",
"error decoding response body",
];
if MARKERS.iter().any(|k| m.contains(k)) {
return true;
}
["status code 500", "status code 502", "status code 503", "status code 504"]
.iter()
.any(|k| m.contains(k))
}

注意两点:先 to_lowercase 再匹配,大小写就不用操心了;标记里既有连接层面的(connection reset、broken pipe),也有网关层面的(bad gateway、internalerror),还有状态码层面的(status code 502)。三层都覆盖,因为不同网关、不同 SDK 暴露出来的形态不一样。

关键反例要写进测试:没有瞬态标记的纯解析错误不重试 。Failed to parse JSON: missing field 'type' 单独出现时,可能是服务端 schema 变了,重试没有意义。只有当错误体里同时带了 InternalError / peer closed connection 标记时,才认定是网关断连:

rust 复制代码
#[test]
fn volcano_gateway_internalerror_is_transient() {
let msg = "CompletionError: ResponseError: Failed to parse JSON: \
missing field `type` (Data: {\"code\":\"InternalError\",\
\"message\":\"peer closed connection without sending \
complete message body (incomplete chunked read)\"})";
assert!(is_transient_stream_error(msg));
}

#[test]
fn quota_and_auth_errors_are_not_transient() {
// 配额、认证错误:重试一百次还是同样的错。
assert!(!is_transient_stream_error(
"Invalid status code 429 with message: insufficient_quota"
));
assert!(!is_transient_stream_error(
"Invalid status code 401 Unauthorized with message: Invalid API key"
));
// 无瞬态标记的纯解析错误:不重试。
assert!(!is_transient_stream_error(
"Failed to parse JSON: missing field `type`"
));
}

第 2 步:在流循环里划清重试边界------只认"输出前 + 瞬态"

流循环里每收到一个错误,先做两道判断:有没有输出过内容?是不是瞬态故障?两道都通过,才返回"可重试":

rust 复制代码
Err(e) => {
let mut msg = e.to_string();
if!emitted_any && classify_rate_limit_error(&msg) == RateLimitKind::Retriable {
return RunOutcome::RateLimited(msg.clone());
}
if!emitted_any && is_transient_stream_error(&msg) {
return RunOutcome::Transient(msg.clone());
}
// 走到这里的错误都不重试:见第 4 步的兜底处理。
}

这里 emitted_any 是关键变量:收到任何文本、思考增量、工具调用、终态响应,都把它置为 true。注意思考增量也算"已输出"------思考过程虽然不直接给用户看结论,但流已经开始了,重建流会让思考从头再来。

限流(429 可重试)和瞬态故障走两条返回,但共用同一个重试循环(第 3 步)。分开返回的好处是:重试耗尽之后,两者的报错文案可以不一样------限流告诉用户"降频或换模型",瞬态故障告诉用户"网络或服务端中断"。

第 3 步:复用一个退避重试循环,最多 4 次

重试循环不需要为瞬态故障另写一份。限流和瞬态共用同一个循环,指数退避:

rust 复制代码
const MAX_RETRIES: usize = 4;
const BASE_DELAY_SECS: u64 = 5;

match outcome {
RunOutcome::RateLimited(_) | RunOutcome::Transient(_)
if attempt <= MAX_RETRIES =>
{
// 退避:5s、10s、20s、40s,总等待最多 75 秒。
let delay = BASE_DELAY_SECS * (1 << (attempt - 1));
emit_retry_notice(&window, &task_id, attempt as u32, MAX_RETRIES as u32, delay);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
continue;
}
// 重试耗尽:见第 4 步。
}

三个细节值得抄:

  1. 退避用指数增长,不用固定间隔。 网关过载时,所有客户端同时以固定间隔重试,等于给过载的服务端再补一波脉冲。指数退避把重试打散,5、10、20、40 秒逐级拉长。
  2. 每次重试都要告诉用户。 发一个 retry 事件,前端显示"第 N 次重试",用户知道程序在干活,而不是界面卡死了。静默重试是最差的体验------用户不知道是卡了还是在重试,往往会手动再点一次,反而造成重复请求。
  3. 上限要硬编码在循环条件里。 attempt <= MAX_RETRIES 写在匹配臂上,而不是靠循环体里的 break,避免哪天改逻辑时把上限绕过去。

第 4 步:重试耗尽后,给一句中文诊断,不要甩原文

重试耗尽是唯一可以"直接报错"的地方。但报错内容不能是原始英文,而是一句中文诊断 + 原文备查:

rust 复制代码
RunOutcome::Transient(last) => {
let cause = diagnose_stream_error(&last)
.map(|d| format!(":{d}"))
.unwrap_or_default();
emit_event(&window, &task_id, "error", Some(format!(
"网络或服务端连接中断{cause}。已自动重试 {MAX_RETRIES} 次仍失败,\
请稍后重发或更换模型。\n{last}"
)), None);
break;
}

诊断函数按标记翻译成中文,分四个分支:

rust 复制代码
fn diagnose_stream_error(msg: &str) -> Option<&'static str> {
let m = msg.to_lowercase();
if m.contains("peer closed connection")
|| m.contains("incomplete chunked read")
|| m.contains("error decoding response body")
{
return Some(
"服务端在流式传输中途断开了连接(响应体不完整)。常见原因:\
网络波动、代理/VPN 掐断长连接、模型服务或网关过载超时",
);
}
if m.contains("connection reset") || m.contains("broken pipe") {
return Some("TCP 连接被重置。常见原因:网络切换、防火墙/代理干预、服务端过载");
}
if m.contains("internalerror") || m.contains("bad gateway")
|| m.contains("service unavailable") || m.contains("status code 50")
{
return Some("模型服务端/网关内部错误(5xx 类),多为服务端瞬时故障");
}
if m.contains("timed out") || m.contains("timeout") {
return Some("请求超时。可能网络延迟过大,或服务端排队过长");
}
None // 无法归类:配额、认证、纯解析错误都返回 None,只展示原始错误。
}

返回 Option 是有意的:无法归类的错误不要硬编一句诊断。配额、认证、纯解析错误返回 None,前端只展示原始错误------没有诊断,比编一句假诊断强。

第 5 步:没走重试的错误,也要记结构化日志

输出中途断连(emitted_any == true)的错误不重试,但不能白白丢掉。给它附上中断位置(阶段 / 已输出 token / 耗时),落一条结构化日志:

rust 复制代码
let stage_cn = if emitted_any { "流式输出过程中"} else { "请求建立阶段"};
let approx_out = run_output_tokens + usage::estimate_tokens(&output_buf);
let elapsed_secs = run_start.elapsed().as_secs();
tracing::warn!(
category = "agent",
task_id = task_id.as_str(),
detail = serde_json::to_string(&serde_json::json!({
"stage": if emitted_any { "mid_stream"} else { "before_output"},
"elapsedSecs": elapsed_secs,
"outputTokensApprox": approx_out,
"diagnosis": diagnose_stream_error(&msg),
"raw": msg.clone(),
})).unwrap_or_default(),
"LLM 流式中断({stage_cn},已输出约 {approx_out} tokens,{elapsed_secs}s)"
);
if let Some(d) = diagnose_stream_error(&msg) {
msg.push_str(&format!(
"\n\n{d}。\n{stage_cn}(已输出约 {approx_out} tokens,\
耗时 {elapsed_secs} 秒){}。",
if emitted_any { ";已生成的部分内容已保留"} else { ""}
));
msg.push_str("\n可稍后重发继续;频繁出现请检查网络/代理,或更换模型端点。");
}

给用户看的文案里,"已生成的部分内容已保留"这半句很重要:用户最怕的是"断了=全丢了"。明确告诉他前面生成的内容还在,他才敢放心重发。

3. 为什么这样设计

分类先行,动作在后。 所有重试、诊断、日志逻辑,都建立在第 1 步那个分类函数之上。分类函数是单一事实来源:新增一种错误形态,只改标记表一处;诊断函数与分类函数共用同一套标记,两个地方不会各说各话。

重试只给"能好"的故障。 401、配额不足、纯解析错误不重试,不是吝啬重试次数,而是重试本身有成本:每次重试都是一次完整的模型调用,花 token、花时间,还可能把服务端打得更过载。把重试留给真正"抖一下就好"的故障,是一种克制。

诊断是写给人看的,不是给机器看的。 诊断文案里每一句都有"常见原因"和"该怎么办"。peer closed connection 翻译成"代理/VPN 掐断长连接",用户才知道去检查代理;只翻译成"对端关闭连接",用户还是不知道该干什么。诊断的终点是行动,不是翻译。

4. 避坑清单

  1. 别按错误表象分类。 SDK 展平后的 Failed to parse JSON 可能是网关断连的伪装。认错误体里的标记,不认表象。
  2. 别在输出开始后重试。 重建流会让模型从头生成,用户看到重复或错位内容。emitted_any 这道闸必须有,思考增量也算已输出。
  3. 别把 401 / 配额不足当瞬态。 这类错误重试一百次还是同样的错,还浪费 token。测试里要把反例钉死。
  4. 别静默重试。 每次重试都发事件告诉用户"第 N 次重试",否则用户会以为界面卡死而手动重复提交。
  5. 别编诊断。 无法归类的错误返回 None,只展示原始错误。编一句假诊断比没有诊断更害人。
  6. 别让重试间隔固定。 固定间隔重试等于给过载的服务端补脉冲。指数退避(5、10、20、40 秒)把重试打散。

5. 总结:给开发者的建议

给流式调用加瞬态故障处理,按这个顺序来:先写标记分类函数并钉死正反测试 → 在流循环里用 emitted_any 划清重试边界 → 复用一个指数退避循环(最多 4 次,每次都通知用户)→ 重试耗尽后给中文诊断 + 原文备查 → 没走重试的错误也要落结构化日志,带阶段、token、耗时。

记住一句话:用户不需要知道 peer closed connection 是什么,他只需要知道"程序已经自动重试过了,现在该他做什么"。 从这个标准反推,你就知道分类、重试、诊断每一环该写成什么样。

相关推荐
救救孩子把1 小时前
AI面试八股文-21_21-多模态Agent面试题-Vision-Language-Agent
人工智能·面试·职场和发展·llava·多模态agent
今年下半年1 小时前
AI智能体项目实战记录(02):初始化 Python 项目
开发语言·人工智能·python·fastapi
Jlzn88881 小时前
华东电芯分选通道数怎么配才不堵线
java·人工智能·架构·自动化·制造
JXJD20041 小时前
224G PAM4 高速线束:阻抗匹配设计与损耗控制的核心逻辑
大数据·ai
u1301301 小时前
GitHub 热榜项目:日榜(2026-10-17)
人工智能·github
流烟默1 小时前
MCP第 5 篇:用 Spring AI 写 MCP:注解、自动配置、ChatClient 集成
人工智能·spring·mcp封装
康实训2 小时前
老年评估仿真实训平台架构设计与老年人能力评估实训室教学落地实践
大数据·人工智能·算法·实训室·实训室建设
ha_lydms2 小时前
Datetime Functions(日期时间函数)
大数据·hadoop·阿里云·spark·数据处理·dataworks·maxcompute