错误、重试与数据一致性
对应源码
| 主题 | 源码 |
|---|---|
| Provider retry 和错误元数据 | nanobot/providers/base.py、nanobot/providers/fallback_provider.py |
| Runner 请求与 Tool 错误 | nanobot/agent/runner.py::_request_model、_run_tool |
| Turn 外层收敛 | nanobot/agent/loop.py::_dispatch |
| Session 原子保存 | nanobot/session/manager.py::SessionManager.save |
| Cron 原子写入 | nanobot/cron/service.py::_atomic_write |
| Memory 和配置写入 | nanobot/agent/memory.py、nanobot/config/loader.py::save_config |
学习目标
读完后,你应当能够:
- 区分 Provider 重试、模型循环重试、Tool 错误和入口层错误;
- 解释流式输出为什么限制了安全重试;
- 判断 Session、Cron、Memory 和配置写入的耐久性;
- 按“错误发生在哪一层”定位问题。
前置知识
- 理解 AgentRunner 的模型—工具循环;
- 理解 Session 文件与回放边界;
- 知道幂等性:同一操作重复执行时,不应产生额外副作用。
1. “重试”不是一个机制
nanobot 中不同重试有不同目标:
| 层级 | 触发原因 | 行为 |
|---|---|---|
| Provider 请求 | 429、暂时性网络或服务错误 | LLMProvider._run_with_retry() 等待后重试 |
| Provider fallback | 当前 Provider/Preset 不可用 | fallback wrapper 切换后端 |
| Runner 空回复 | 模型没有内容和 Tool Call | 在有限次数内再次请求 |
| Runner finalization | 达到工具/预算边界但没有可用最终答复 | 用禁止工具的补充提示再请求 |
| Tool 循环 | 工具返回可恢复错误文本 | 把结果交给模型,由模型换一种做法 |
| Gateway/Channel | 连接或发送失败 | 由对应 Channel/服务自己的策略处理 |
因此,“系统会自动重试”不是足够精确的说明。调试时要问:谁在重试、重试什么、是否会重复副作用、停止条件是什么?
2. Provider 重试决策
LLMProvider.chat_with_retry() 与 chat_stream_with_retry() 最终进入 _run_with_retry()。它根据结构化错误字段和内容判断是否暂时性:
LLMResponse.error_should_retryerror_status_codeerror_codeerror_retry_after_sretry_after- HTTP 429 等可识别状态
明确的配额、余额或账单错误不会因为等待而自然恢复,所以不应无限重试。未知 429 在当前实现中默认按可重试处理。
标准模式使用有限延迟序列;persistent 模式可以继续等待,但仍会:
- 限制最大等待时间;
- 在连续相同暂时性错误达到上限时停止;
- 通过
on_retry_wait把等待状态发布给用户。
实际常量和判断以以下位置为准:
nanobot/providers/base.py::LLMProvider._run_with_retryLLMProvider._is_transient_responseLLMProvider._is_retryable_429_responseLLMProvider._extract_retry_after_from_response
3. 流式响应为什么更难重试
如果 Provider 在还未输出任何 delta 前失败,可以像普通请求一样重试。已经向用户输出内容后再重放同一个请求,可能出现:
- 用户看到重复文本;
- 第二次模型输出与第一次前半段不一致;
- 工具调用或计费被重复触发。
当前 chat_stream_with_retry() 用 has_streamed_content 作为 guard。代码会根据流分段能力决定是否开启新 segment 或抑制重复 delta;不能安全处理时会跳过重试。
一旦外部可见,就要重新考虑幂等性
这条原则也适用于发送消息、写文件、调用支付接口和创建工单。Agent 的“思考重试”与真实世界副作用的“操作重试”不是一回事。
4. Tool 错误如何进入模型上下文
ToolRegistry.execute() 和 AgentRunner._run_tool() 会把许多失败转成 Tool result,而不是直接让整个进程崩溃。Runner 还会附上类似“分析错误并换一种方式”的提示。
但安全边界错误会被标记为不可重试的明确说明。例如 workspace 或 SSRF guard 会告诉模型不要用 shell trick、编码 IP 或替代工具绕过策略。
这是一种重要的 Agent 设计:
flowchart LR A["Tool 执行"] --> B{"结果类型"} B -->|"正常"| C["加入 tool message"] B -->|"可恢复错误"| D["加入错误与换方案提示"] B -->|"策略拒绝"| E["加入不可绕过的安全说明"] B -->|"取消 / fatal"| F["向上抛出并终止"] C --> G["下一次模型调用"] D --> G E --> G
把普通错误变成模型可理解的观察,让 Agent 有机会自我修正;把策略错误写成硬边界,则避免模型在同一策略上反复试探。
5. 外层异常兜底
AgentLoop._dispatch() 捕获普通异常时会:
- 记录 exception 日志;
- 发布通用错误回复;
- 发布 Turn 完成事件;
- 通知 Cron coordinator 此次执行失败。
这是最后一道用户体验兜底,不是根因修复。看到 “Sorry, I encountered an error.” 时,应继续查看其前面的异常日志和 State trace,而不是只在 Channel 层排查。
6. 写入一致性逐项判断
Session
SessionManager.save():
- 完整写入
.jsonl.tmp; - 可选
flush()+os.fsync(); - 用
os.replace()原子替换目标; fsync=True时尝试同步父目录;- 失败时删除临时文件。
普通保存默认不做 fsync;Gateway 正常关闭时 flush_all() 对缓存 Session 使用 fsync=True。
Cron
CronService._atomic_write() 总是执行临时文件、文件 fsync、os.replace 和父目录同步。注释明确说明这是为了避免容器关闭时把 jobs.json 留成截断文件。
Memory history
MemoryStore 对追加历史和重写历史采用不同路径:
- 追加由文件锁协调;
- rewrite 使用临时文件、
fsync和 replace; - cursor 与历史格式有自己的恢复测试。
具体实现:
nanobot/agent/memory.py::append_historynanobot/agent/memory.py::_write_entries
配置
nanobot/config/loader.py::save_config() 当前直接以写模式输出 JSON,没有使用临时文件和 os.replace。因此不能把 Session 的原子写保证推广到 config.json。
已确认的当前实现
这里不是建议配置一定会损坏,而是准确说明:从当前
save_config()源码无法证明它拥有与 Session 相同的崩溃耐久性。
7. 一致性不是“所有文件同步更新”
一次 Turn 可能触及:
- Session JSONL;
- Memory history;
- goal metadata;
- Cron store;
- WebUI transcript/usage index;
- 外部 Tool 的真实副作用。
当前代码没有把这些资源放进一个跨文件、跨服务事务。设计重点是:
- 每个核心存储尽量可恢复;
- 用 Session metadata 记录运行 checkpoint;
- 重启后从可用事实继续;
- 不假设外部副作用可回滚。
8. 实用定位顺序
当一次请求失败时:
- 确认错误是在入口、Provider、Runner、Tool、持久化还是响应发送阶段;
- 查
finish_reason、错误状态码和 retry metadata; - 查是否已经产生流式 delta 或工具副作用;
- 查 Session checkpoint 和最终 JSONL;
- 查是否触发 fallback 或 finalization retry;
- 最后才考虑手工重放请求。
手工重放前必须判断操作是否幂等。
9. 源码与测试
源码:
nanobot/providers/base.pynanobot/providers/fallback_provider.pynanobot/agent/runner.py::_request_modelnanobot/agent/runner.py::_run_toolnanobot/agent/loop.py::_dispatchnanobot/session/manager.py::savenanobot/cron/service.py::_atomic_writenanobot/agent/memory.pynanobot/config/loader.py::save_config
测试:
tests/providers/test_provider_retry.pytests/providers/test_provider_retry_after_hints.pytests/providers/test_provider_error_metadata.pytests/agent/test_runner_errors.pytests/agent/test_runner_fallback.pytests/agent/test_runner_core.pytests/agent/test_runner_persistence.pytests/session/test_session_fsync.pytests/agent/test_memory_store.py
自检
- 为什么已经输出一个流式 delta 后,重试风险会上升?
- Tool 返回错误字符串和 Tool 抛出
CancelledError有何不同? os.replace与fsync分别解决什么问题?- 为什么不能从 Session 原子写推出配置也原子写?