PicoBot/PLAN.md
oudecheng 457f6b2408 feat(retry): 为 LLM 主调用添加可配置重试机制,默认 3 次
- 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 测试全绿
2026-08-04 12:53:33 +08:00

130 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 模型访问重试机制 — 第一性原理分析
## 一、问题本质
### 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<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不暴露给用户。** 理由:
- 单机场景无需 jitterjitter 解决分布式客户端同步重试,单机不存在)
- 退避细节是实现策略非用户可调参数YAGNI
- 用户只关心"重试几次",不关心"等多久"
### 2.4 流式重试的边界
**核心矛盾**`chat_with_streaming` 一旦 emit delta重试会重复输出。
**决策:流式调用仅在"未 emit 任何 delta"时重试。**`Arc<AtomicBool>` 跟踪 emit 状态,首次 delta 后置 truetrue 时不再重试。
这覆盖了最常见的瞬态场景:建连失败、首次响应超时。已开始流式传输后的失败通常是网络中断,重试意义不大且会重复。
## 三、实施计划
### 后端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<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`
## 四、关键设计约束
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 期间触发取消立即生效