新增: 弱网韧性: send 网络感知重试与基准面探针
- connect 类失败重试: 10 分钟窗口+基准面感知(IP 直连与 DNS 双测判本机断网), 断网进长等待自适应探测(5s 快探到 30s 封顶省电), 恢复即重试; 单点故障指数短退避 1s→8s - 两协议(anthropic/openai)同构, 隧道级分钟级弱网不再直接吐中断
This commit is contained in:
@@ -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 响应,可能服务不可达或被防火墙拦截)");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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-架构设计/构想审查/
|
||||||
|
|||||||
@@ -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(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 响应,可能服务不可达或被防火墙拦截)");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user