diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 2b9527b..0000000 --- a/PLAN.md +++ /dev/null @@ -1,129 +0,0 @@ -# 模型访问重试机制 — 第一性原理分析 - -## 一、问题本质 - -### 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 期间触发取消立即生效