- 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 测试全绿
130 lines
6.3 KiB
Markdown
130 lines
6.3 KiB
Markdown
# 模型访问重试机制 — 第一性原理分析
|
||
|
||
## 一、问题本质
|
||
|
||
### 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,不暴露给用户。** 理由:
|
||
- 单机场景无需 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: 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 期间触发取消立即生效
|