错误、重试与数据一致性

对应源码

主题源码
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 和配置写入的耐久性;
  • 按“错误发生在哪一层”定位问题。

前置知识

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_retry
  • error_status_code
  • error_code
  • error_retry_after_s
  • retry_after
  • HTTP 429 等可识别状态

明确的配额、余额或账单错误不会因为等待而自然恢复,所以不应无限重试。未知 429 在当前实现中默认按可重试处理。

标准模式使用有限延迟序列;persistent 模式可以继续等待,但仍会:

  • 限制最大等待时间;
  • 在连续相同暂时性错误达到上限时停止;
  • 通过 on_retry_wait 把等待状态发布给用户。

实际常量和判断以以下位置为准:

  • nanobot/providers/base.py::LLMProvider._run_with_retry
  • LLMProvider._is_transient_response
  • LLMProvider._is_retryable_429_response
  • LLMProvider._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():

  1. 完整写入 .jsonl.tmp;
  2. 可选 flush() + os.fsync();
  3. 用 os.replace() 原子替换目标;
  4. fsync=True 时尝试同步父目录;
  5. 失败时删除临时文件。

普通保存默认不做 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_history
  • nanobot/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. 实用定位顺序

当一次请求失败时:

  1. 确认错误是在入口、Provider、Runner、Tool、持久化还是响应发送阶段;
  2. 查 finish_reason、错误状态码和 retry metadata;
  3. 查是否已经产生流式 delta 或工具副作用;
  4. 查 Session checkpoint 和最终 JSONL;
  5. 查是否触发 fallback 或 finalization retry;
  6. 最后才考虑手工重放请求。

手工重放前必须判断操作是否幂等。

9. 源码与测试

源码:

  • nanobot/providers/base.py
  • nanobot/providers/fallback_provider.py
  • nanobot/agent/runner.py::_request_model
  • nanobot/agent/runner.py::_run_tool
  • nanobot/agent/loop.py::_dispatch
  • nanobot/session/manager.py::save
  • nanobot/cron/service.py::_atomic_write
  • nanobot/agent/memory.py
  • nanobot/config/loader.py::save_config

测试:

  • tests/providers/test_provider_retry.py
  • tests/providers/test_provider_retry_after_hints.py
  • tests/providers/test_provider_error_metadata.py
  • tests/agent/test_runner_errors.py
  • tests/agent/test_runner_fallback.py
  • tests/agent/test_runner_core.py
  • tests/agent/test_runner_persistence.py
  • tests/session/test_session_fsync.py
  • tests/agent/test_memory_store.py

自检

  1. 为什么已经输出一个流式 delta 后,重试风险会上升?
  2. Tool 返回错误字符串和 Tool 抛出 CancelledError 有何不同?
  3. os.replace 与 fsync 分别解决什么问题?
  4. 为什么不能从 Session 原子写推出配置也原子写?

下一篇:日志、RuntimeEvent 与测试策略