- config: ProviderConfig 增加 max_retries 字段(serde default=3,向后兼容) - config: LLMProviderConfig 透传 max_retries,不进 ProviderRuntimeConfig(保持 provider 构造包纯净) - agent: AgentRuntimeConfig 增加 max_retries,归属 agent 行为层 - agent_loop: 流式 + summary 两个调用点实现重试循环 - 指数退避 1s/2s/4s,仅对 429/502/503/504/timeout/connection reset 重试 - 流式仅在未 emit delta 时重试(AtomicBool 跟踪),避免重复输出 - 退避 sleep 期间响应 cancel_signal,取消优先 - 前端: ProviderConfig 类型和表单增加 max_retries 字段 - 测试: 7 个单元测试(3 判定 + 4 行为),540 个 lib 测试全绿
6.3 KiB
模型访问重试机制 — 第一性原理分析
一、问题本质
1.1 为什么需要重试?
LLM 请求存在客观的瞬态失败:网络抖动、服务端 502/503/504、限流 429、连接重置。这些故障在秒级内可自愈,但当前代码遇到即终止整个用户回合,用户必须手动重发。这是体验断裂点。
1.2 重试的本质权衡
重试 = 用 资源(额外请求/计费)+ 延迟(等待+退避) 换取 成功概率提升。
边界条件:
- 可恢复错误重试有意义:timeout、502/503/504、429、connection reset
- 不可恢复错误重试是纯浪费:401/403(认证)、400(参数)、404(模型不存在)、内容审查拒绝、token 超限
- 用户取消必须立即生效,重试不能凌驾于取消之上
1.3 重试的副作用
| 副作用 | 严重性 | 对策 |
|---|---|---|
| 计费翻倍 | 中 | 限制次数,仅对瞬态错误 |
| 请求放大加剧服务过载 | 低(单机场景) | 退避等待 |
| 流式已 emit 内容后重试→重复输出 | 高 | 流式仅在建连阶段重试 |
| 延迟累积(N×请求+退避) | 中 | 退避不宜过长 |
二、架构决策的第一性原理
2.1 重试决策权归属
原则:决策权给最了解错误语义的层,同时不破坏既有解耦边界。
决策:AgentLoop 层重试。理由:
- cancel_token 在 AgentLoop,重试与取消协作自然(Provider 内部重试无法响应取消)
- memory_maintenance 已采用"调用方重试"模式,保持一致
- 不修改 LLMProvider trait,零侵入
承认的既有债务:AgentLoop 的 is_recoverable_llm_error() 字符串匹配本身就是解耦缺陷——业务层不该知道 "504" 是 HTTP 错误。根因是 trait 返回 Box<dyn Error> 丢失类型信息。彻底解耦需给 trait 加类型化错误 enum,超出本次范围,沿用既有字符串匹配作为增量改进。
2.2 配置层级归属(保留解耦边界)
关键约束:ProviderRuntimeConfig 的语义是"构造 provider 实例的最小参数包"——其每个字段都被 create_provider() 消费。max_retries 不参与 provider 构造,塞进去会破坏该语义。
决策:
- 用户配置层:
ProviderConfig.max_retries(与llm_timeout_secs同级,符合"provider 级网络参数"分组) - 聚合配置层:
LLMProviderConfig.max_retries(透传) - 不进
ProviderRuntimeConfig(保持 provider 构造包纯净) - 改放进
AgentRuntimeConfig.max_retries(该结构本就含max_tool_iterations等 agent 行为参数,"agent 对 provider 瞬态失败的容忍策略"归属 agent 行为层合理)
config.json ProviderConfig.max_retries (用户配置)
↓
LLMProviderConfig.max_retries (聚合配置)
↓
AgentRuntimeConfig.max_retries (agent 行为参数)
↓
AgentLoop.runtime_config.max_retries (业务层读取)
↓
chat_with_retry() (AgentLoop 内部循环)
ProviderRuntimeConfig 保持不变。
2.3 退避策略
原则:退避长度应匹配故障恢复时间尺度。
LLM 服务瞬态故障通常秒级恢复。指数退避 1s/2s/4s 总等待 7s,对单机本地代理场景已足够。
决策:硬编码指数退避 [1000, 2000, 4000] ms,不暴露给用户。 理由:
- 单机场景无需 jitter(jitter 解决分布式客户端同步重试,单机不存在)
- 退避细节是实现策略,非用户可调参数(YAGNI)
- 用户只关心"重试几次",不关心"等多久"
2.4 流式重试的边界
核心矛盾:chat_with_streaming 一旦 emit delta,重试会重复输出。
决策:流式调用仅在"未 emit 任何 delta"时重试。 用 Arc<AtomicBool> 跟踪 emit 状态,首次 delta 后置 true,true 时不再重试。
这覆盖了最常见的瞬态场景:建连失败、首次响应超时。已开始流式传输后的失败通常是网络中断,重试意义不大且会重复。
三、实施计划
后端(4 个文件)
1. src/config/mod.rs
ProviderConfig增max_retries: u32(#[serde(default = "default_max_retries")],默认 3)LLMProviderConfig增max_retries: u32resolve_provider_config()和override_provider_model()传递该字段- 新增
fn default_max_retries() -> u32 { 3 } - 不改
ProviderRuntimeConfig
2. src/providers/traits.rs
- 保持不变(
ProviderRuntimeConfig不增字段,保持 provider 构造包纯净)
3. src/agent/runtime_config.rs
AgentRuntimeConfig增max_retries: u32From<LLMProviderConfig>传递该字段
4. src/agent/agent_loop.rs
- 新增
RETRY_DELAYS_MS: &[u64] = &[1000, 2000, 4000] - 新增
chat_with_retry():包装provider.chat(),循环max_retries+1次 - 新增
chat_with_streaming_with_retry():包装chat_with_streaming(),用Arc<AtomicBool>跟踪 emit 状态 - 两处重试循环均
tokio::select!监听 cancel_signal - 替换
:1068和:1424两处直接调用 - tracing 日志:
warn!(attempt, retry_in_ms, error, "LLM request failed, retrying") - 单元测试:可恢复错误重试成功、不可恢复错误立即失败、重试中取消生效、流式已 emit 不重试
前端(2 个文件)
5. web/src/components/Settings/types.ts
ProviderConfig增max_retries: number
6. web/src/components/Settings/ConfigPage.tsx
- provider 表单增 "最大重试次数" 输入框(与 "LLM 超时" 同组)
- 新增 provider 默认值
max_retries: 3
四、关键设计约束
- 向后兼容:
#[serde(default)]保证旧 config.json 无需修改 - 取消优先:重试 sleep 期间
select!监听 cancel_signal,立即响应 - max_retries=0:不重试,行为与现状完全一致
- 不修改 LLMProvider trait:零侵入,不影响 channels/subagents 调用链
- memory_maintenance 不受影响:它有独立重试逻辑,不经过 AgentLoop
五、验证清单
cargo build通过cargo test --lib全绿(含新增重试测试)- 前端
npm run build类型检查通过 - max_retries=0 时行为与现状一致
- max_retries=3 时可恢复错误重试 3 次后失败
- 不可恢复错误(如 401 模拟)立即失败不重试
- 重试 sleep 期间触发取消立即生效