新增: 弱网韧性: send 网络感知重试与基准面探针

- connect 类失败重试: 10 分钟窗口+基准面感知(IP 直连与 DNS 双测判本机断网), 断网进长等待自适应探测(5s 快探到 30s 封顶省电), 恢复即重试; 单点故障指数短退避 1s→8s
- 两协议(anthropic/openai)同构, 隧道级分钟级弱网不再直接吐中断
This commit is contained in:
lxy
2026-10-07 18:33:41 +08:00
parent 76ace953be
commit c2382325cb
4 changed files with 230 additions and 72 deletions
+88 -41
View File
@@ -743,47 +743,94 @@ impl LlmProvider for AnthropicCompatProvider {
// 流式长生成任务会被误砍。改用 tokio::time::timeout // 流式长生成任务会被误砍。改用 tokio::time::timeout
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。 // 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。 // 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
let send_future = self // send 阶段弱网退避: connect 类失败(隧道断网/基站切换/DNS 抖动)请求未发出, 重试零副作用。
.auth_headers(self.client.post(self.messages_url())) // 网络感知分速(net_probe 基准面): 本机断网进长等待按自适应间隔探测恢复(隧道级分钟级波动),
.json(&body) // 单点故障指数短退避 1s→8s; 10 分钟窗口耗尽才放弃。timeout/decode/HTTP 状态错不重试。
.version(reqwest::Version::HTTP_11) // 与 openai_compat 同构。
.send(); let resp = {
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await { let start = std::time::Instant::now();
Ok(Ok(r)) => r, let mut offline_logged = false;
Ok(Err(e)) => { let mut offline_polls = 0usize;
// 谓词结构化分类(对齐 openai 路径,共用 crate::send_error_class)。原 bail 串 let mut attempt = 0usize;
// [timeout={} connect={}=...] 的字面词让一切错误(含 builder 错)侥幸可重试。 loop {
// 类词改由谓词决定置串首;谓词转储缩写为 t/c/b/r 防误触分类词表,source 链 attempt += 1;
// 保留真实传输词(reset/broken pipe 等)作串兜底。 let send_future = self
let class = crate::send_error_class(crate::SendErrPreds::of(&e)); .auth_headers(self.client.post(self.messages_url()))
tracing::error!( .json(&body)
is_timeout = e.is_timeout(), .version(reqwest::Version::HTTP_11)
is_connect = e.is_connect(), .send();
is_body = e.is_body(), let outcome = tokio::time::timeout(Duration::from_secs(60), send_future).await;
is_request = e.is_request(), match outcome {
class = class, Ok(Ok(r)) => break r,
url = %self.messages_url(), Ok(Err(e)) => {
source = ?std::error::Error::source(&e), // 谓词结构化分类(对齐 openai 路径,共用 crate::send_error_class)。原 bail 串
"Anthropic 流式 send 失败" // [timeout={} connect={}=...] 的字面词让一切错误(含 builder 错)侥幸可重试。
); // 类词改由谓词决定置串首;谓词转储缩写为 t/c/b/r 防误触分类词表,source 链
anyhow::bail!( // 保留真实传输词(reset/broken pipe 等)作串兜底。
"send 失败({}): {} [t={} c={} b={} r={} src={:?}]", let preds = crate::SendErrPreds::of(&e);
class, let retryable = preds.is_connect || (preds.is_request && !preds.is_builder);
e, if retryable && start.elapsed() < crate::net_probe::SEND_RETRY_WINDOW {
e.is_timeout(), // 本机断网: 等基准面恢复(自适应间隔), 不消耗退避档位
e.is_connect(), if !crate::net_probe::baseline_alive().await {
e.is_body(), offline_polls += 1;
e.is_request(), let wait = crate::net_probe::offline_probe_wait(offline_polls);
std::error::Error::source(&e) if !offline_logged {
); warn!(
} url = %self.messages_url(),
Err(_elapsed) => { window_secs = crate::net_probe::SEND_RETRY_WINDOW.as_secs(),
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢 "本机网络不可达(疑似隧道/电梯弱网), 按自适应间隔探测恢复, 恢复后立即重试"
tracing::error!( );
url = %self.messages_url(), offline_logged = true;
"Anthropic 流式 send 超时(60s 未返回响应头)" }
); tokio::time::sleep(wait).await;
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)"); continue;
}
offline_polls = 0;
offline_logged = false;
let delay = Duration::from_millis(
1000u64.saturating_mul(1 << (attempt - 1).min(3)).min(8000),
);
warn!(
url = %self.messages_url(),
attempt,
delay_ms = delay.as_millis() as u64,
error = %e,
"流式 send 连接失败, 退避重试"
);
tokio::time::sleep(delay).await;
continue;
}
let class = crate::send_error_class(preds);
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
class = class,
url = %self.messages_url(),
source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败"
);
anyhow::bail!(
"send 失败({}): {} [t={} c={} b={} r={} src={:?}]",
class,
e,
e.is_timeout(),
e.is_connect(),
e.is_body(),
e.is_request(),
std::error::Error::source(&e)
);
}
Err(_elapsed) => {
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
tracing::error!(
url = %self.messages_url(),
"Anthropic 流式 send 超时(60s 未返回响应头)"
);
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
}
}
} }
}; };
+1
View File
@@ -11,6 +11,7 @@ pub mod anthropic_helpers;
pub mod context; pub mod context;
pub mod context_helpers; pub mod context_helpers;
pub mod memory_adapter; pub mod memory_adapter;
pub(crate) mod net_probe;
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。 // 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
pub mod coordinator; pub mod coordinator;
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/ // 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
+64
View File
@@ -0,0 +1,64 @@
//! 本机连通性基准面探测(弱网重试的感知依据)。
//!
//! 场景: 隧道/电梯等移动网络波动可达分钟级, 固定次数重试必然失败。send 失败时探
//! 基准面区分「本机断网」与「单点故障」: 前者进长等待(自适应间隔探测, 恢复即重试),
//! 后者短退避即可。纯 IP TCP 与系统 DNS 解析双测, 任一通过即判在线
//! (DNS 单挂时 IP 直连上游仍可能可用, 双测互为盲区互补)。
//!
//! 演进路线(P2, 需求触发再上): 系统网络事件订阅双通道——事件源治接口级变化
//! (WiFi 断开/切蜂窝)零延迟, 本探针治链路级不通(隧道半断/DNS 故障, 系统事件
//! 不触发的盲区)。跨平台无统一事件 crate(Windows=INetworkListManager COM,
//! macOS=NWPathMonitor, Linux=netlink), 届时以 NetEventSource trait 抽象,
//! 按 devflow 实际部署面(Windows 桌面+Linux 云端)逐平台实现; Linux 服务端
//! 网络稳定, 纯探针已足够。
use std::net::ToSocketAddrs;
use std::time::Duration;
/// 探测超时(单次解析或建连)。弱网下慢响应不代表断网, 2s 内完成才计可用。
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// send 弱网重试总窗口(隧道/电梯级分钟级波动覆盖)。
pub(crate) const SEND_RETRY_WINDOW: Duration = Duration::from_secs(600);
/// 脱网确认后的探针间隔表(秒): 首段 5s 快探保即时性(网络抖动常秒级恢复),
/// 确认持续脱网后间隔拉长到 30s 封顶——蜂窝 radio 休眠省电, 分钟级隧道场景无感差异。
pub(crate) const OFFLINE_PROBE_SCHEDULE_SECS: &[u64] = &[5, 5, 5, 5, 10, 15, 30];
/// 按脱网轮次取探针间隔(越界取表尾封顶值)。
pub(crate) fn offline_probe_wait(poll_count: usize) -> Duration {
let idx = poll_count.saturating_sub(1).min(OFFLINE_PROBE_SCHEDULE_SECS.len() - 1);
Duration::from_secs(OFFLINE_PROBE_SCHEDULE_SECS[idx])
}
/// IP 直连探针目标(公共 DNS 服务商的 443, 不依赖 DNS)。
const PROBE_IPS: &[&str] = &["223.5.5.5:443", "119.29.29.29:443"];
/// DNS 面探针目标(公共域, 走系统 resolver)。
const PROBE_DNS_HOST: &str = "www.aliyun.com:443";
async fn tcp_alive(addr: &str) -> bool {
let target = match addr.to_socket_addrs() {
Ok(mut it) => match it.next() {
Some(a) => a,
None => return false,
},
Err(_) => return false,
};
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect(target))
.await
.is_ok()
}
/// 本机网络基准面是否可用。任一探针通过即在线。
pub(crate) async fn baseline_alive() -> bool {
for ip in PROBE_IPS {
if tcp_alive(ip).await {
return true;
}
}
matches!(
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::lookup_host(PROBE_DNS_HOST)).await,
Ok(Ok(_))
)
}
+77 -31
View File
@@ -593,37 +593,83 @@ impl LlmProvider for OpenAICompatProvider {
// send 阶段需 timeout 防 hang(同 Anthropic 路径)。 // send 阶段需 timeout 防 hang(同 Anthropic 路径)。
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。 // 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
let send_future = self // send 阶段弱网退避: connect 类失败重试零副作用; 网络感知分速(net_probe 基准面):
.client // 本机断网长等待按自适应间隔探测恢复, 单点故障指数短退避 1s→8s, 10 分钟窗口耗尽才放弃。
.post(self.chat_url()) // 与 anthropic_compat 同构。
.header("Authorization", format!("Bearer {}", self.api_key)) let resp = {
.header("Content-Type", "application/json") let start = std::time::Instant::now();
.json(&openai_req) let mut offline_logged = false;
.send(); let mut offline_polls = 0usize;
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await { let mut attempt = 0usize;
Ok(Ok(r)) => r, loop {
Ok(Err(e)) => { attempt += 1;
// 谓词结构化分类:Display 串不含 source 关键词("error sending request for url"), let send_future = self
// 纯串分类必 unknown → Fatal 零重试(陈旧连接首包 RST 就此裸奔)。类词置串首, .client
// source 链兜底真实传输词(reset/broken pipe 等),下游按类词走可重试。 .post(self.chat_url())
let class = crate::send_error_class(crate::SendErrPreds::of(&e)); .header("Authorization", format!("Bearer {}", self.api_key))
tracing::error!( .header("Content-Type", "application/json")
error = %e, .json(&openai_req)
class = class, .send();
url = %self.chat_url(), let outcome = tokio::time::timeout(Duration::from_secs(60), send_future).await;
source = ?std::error::Error::source(&e), match outcome {
"OpenAI 流式 send 失败" Ok(Ok(r)) => break r,
); Ok(Err(e)) => {
anyhow::bail!( // 谓词结构化分类:Display 串不含 source 关键词("error sending request for url"),
"流式 send 失败({}): {} [src={:?}]", // 纯串分类必 unknown → Fatal 零重试(陈旧连接首包 RST 就此裸奔)。类词置串首,
class, // source 链兜底真实传输词(reset/broken pipe 等),下游按类词走可重试。
e, let preds = crate::SendErrPreds::of(&e);
std::error::Error::source(&e) let retryable = preds.is_connect || (preds.is_request && !preds.is_builder);
); if retryable && start.elapsed() < crate::net_probe::SEND_RETRY_WINDOW {
} // 本机断网: 等基准面恢复, 自适应间隔探测(首段 5s 快探, 确认脱网后 30s 封顶省电)
Err(_elapsed) => { if !crate::net_probe::baseline_alive().await {
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)"); offline_polls += 1;
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)"); let wait = crate::net_probe::offline_probe_wait(offline_polls);
if !offline_logged {
warn!(
url = %self.chat_url(),
window_secs = crate::net_probe::SEND_RETRY_WINDOW.as_secs(),
"本机网络不可达(疑似隧道/电梯弱网), 按自适应间隔探测恢复, 恢复后立即重试"
);
offline_logged = true;
}
tokio::time::sleep(wait).await;
continue;
}
offline_polls = 0;
offline_logged = false;
let delay = Duration::from_millis(
1000u64.saturating_mul(1 << (attempt - 1).min(3)).min(8000),
);
warn!(
url = %self.chat_url(),
attempt,
delay_ms = delay.as_millis() as u64,
error = %e,
"流式 send 连接失败, 退避重试"
);
tokio::time::sleep(delay).await;
continue;
}
let class = crate::send_error_class(preds);
tracing::error!(
error = %e,
class = class,
url = %self.chat_url(),
source = ?std::error::Error::source(&e),
"OpenAI 流式 send 失败"
);
anyhow::bail!(
"流式 send 失败({}): {} [src={:?}]",
class,
e,
std::error::Error::source(&e)
);
}
Err(_elapsed) => {
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
}
}
} }
}; };