# 模型访问重试机制 — 第一性原理分析 ## 一、问题本质 ### 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 层重试**。理由: 1. cancel_token 在 AgentLoop,重试与取消协作自然(Provider 内部重试无法响应取消) 2. memory_maintenance 已采用"调用方重试"模式,保持一致 3. 不修改 LLMProvider trait,零侵入 **承认的既有债务**:AgentLoop 的 `is_recoverable_llm_error()` 字符串匹配本身就是解耦缺陷——业务层不该知道 "504" 是 HTTP 错误。根因是 trait 返回 `Box` 丢失类型信息。彻底解耦需给 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` 跟踪 emit 状态,首次 delta 后置 true,true 时不再重试。 这覆盖了最常见的瞬态场景:建连失败、首次响应超时。已开始流式传输后的失败通常是网络中断,重试意义不大且会重复。 ## 三、实施计划 ### 后端(4 个文件) **1. `src/config/mod.rs`** - `ProviderConfig` 增 `max_retries: u32`(`#[serde(default = "default_max_retries")]`,默认 3) - `LLMProviderConfig` 增 `max_retries: u32` - `resolve_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: u32` - `From` 传递该字段 **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` 跟踪 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` ## 四、关键设计约束 1. **向后兼容**:`#[serde(default)]` 保证旧 config.json 无需修改 2. **取消优先**:重试 sleep 期间 `select!` 监听 cancel_signal,立即响应 3. **max_retries=0**:不重试,行为与现状完全一致 4. **不修改 LLMProvider trait**:零侵入,不影响 channels/subagents 调用链 5. **memory_maintenance 不受影响**:它有独立重试逻辑,不经过 AgentLoop ## 五、验证清单 - [ ] `cargo build` 通过 - [ ] `cargo test --lib` 全绿(含新增重试测试) - [ ] 前端 `npm run build` 类型检查通过 - [ ] max_retries=0 时行为与现状一致 - [ ] max_retries=3 时可恢复错误重试 3 次后失败 - [ ] 不可恢复错误(如 401 模拟)立即失败不重试 - [ ] 重试 sleep 期间触发取消立即生效