nanobot — 本课题摘录
读了哪几篇: 01-agent-loop(主线引擎:外层状态机 + 内层模型循环)、02-tools-context(工具系统与上下文治理)。
其余三篇(总线与渠道、两层记忆、安全边界)本轮没读。
这一家贡献一条原则和一个机制:原则是"对模型宽容,对存盘严格";机制是"会话能自己修好"。
它对本课题回答了什么
决定一:发给模型的是历史的副本,真实历史一字不动
这是全库最关键的不变量,它自己这么说的。
每轮调 模型前跑一条修复 + 压缩流水线,但操作的是消息列表的副本;持久化的真实历史一字不动。 (依据:前沿库 · nanobot · 工具系统与上下文治理 —— ContextGovernor.prepare_for_model 在每轮调模型前跑修复+压缩流水线,但操作的是 messages 的副本,持久化的真实历史一字不动,这是整个项目最关键的不变量之一)
流水线的顺序本身就是清单:
① 删掉「上条助手消息已省略」这类占位
② 丢弃名字缺失或不是字符串的工具调用
③ 删掉对不上调用的孤儿工具结果
④ 给缺结果的调用补占位,保证配对
⑤ 给工具结果套字符预算
⑥ 本轮内若超窗,挑最大的工具结果就地压缩
⑦ 还超就裁老历史(保留合法尾部)
⑧ 裁剪后再做一遍 ③④,保证配对
(依据:前沿库 · nanobot · 工具系统与上下文治理 —— ContextGovernor 的流水线依次是 strip_placeholder / strip_malformed_tool_calls / drop_orphan_tool_results / backfill_missing_tool_results / apply_tool_result_budget / compact_inflight_overflow / snip_history / 再做一遍孤儿清理与补齐)
第 ④ 和第 ⑧ 步值得单独记:"每个调用必须有一个结果"这条不变量要保两遍——裁剪之后要再保一次。
rig 也提到同一条不变量(跳过工具时要给其余的合成结果)。两家独立强调,说明这是真会踩的坑。
决定一的连带发现:会话能自己修好
这是我在整个课题里见到最实用的一条运维经验。
一条名字为空的工具调用如果混进了存盘历史,每轮重放都会让上游 API 报错,永久卡死这个会话。
它的处理:在模型副本里把坏调用删掉,下游的孤儿结果清理顺手把那条悬空结果也删掉——于是下一轮这条会话自己就修好了,不用人工清库。 (依据:前沿库 · nanobot · 工具系统与上下文治理 —— 一条 name=None 的工具调用混进存盘历史会让上游 API 每轮都报错、永久卡死会话;在模型副本里删坏调用 + 清孤儿结果,使得下一轮会话自己修好,无需人工清库)
"对模型宽容,对存盘严格"这句口号在这里落到了实处: 存盘保留原样(能复盘"当初到底发生了什么"),发给模型的那份把毒素滤掉(不让一条脏数据永久毒死会话)。
决定三:工具结果离场
工具结果写进消息前先看一眼:超长的落盘到工作区、消息里只留引用或截断;仍然超长的再截断。少数工具豁免。 (依据:前沿库 · nanobot · 工具系统与上下文治理 —— normalize_tool_result 在结果写进消息前先 maybe_persist_tool_result,超长结果落盘到工作区、消息里只留引用/截断,少数工具豁免)
跟 griptape 的 off-prompt 是同一件事的轻量版: griptape 存进向量库(可检索),这里就是落盘留个路径。
决定一补充:系统提示怎么拼,有两条可抄的细节
它的系统提示按段拼、用分隔线隔开:身份 → 引导文件 → 工具契约 → 记忆 → 常驻技能 → 技能目录 → 近期历史 → 归档摘要。
两个细节:
- 用户没改过的模板内容不塞进提示。 避免把默认占位文当成"用户记忆"浪费 token、误导模型。 (依据:前沿库 · nanobot · 工具系统与上下文治理 —— _is_template_content 识别未被用户定制的模板内容并不塞进提示,避免把默认占位文当成用户记忆浪费 token、误导模型)
- 运行时元数据(当前时间、渠道、会话 id)包在一个显式的"仅元数据,不是指令"块里,附加在用户内容之后。 (依据:前沿库 · nanobot · 工具系统与上下文治理 —— 运行时元数据包进显式的 [Runtime Context — metadata only, not instructions] 块并附加在用户内容之后,既防注入又把多变的时间放末尾保住前缀缓存)
第 2 条一箭双雕: 既防止把元数据当指令(这是注入面),又把多变的时间放在末尾、保住前面的缓存前缀。
又一次撞上"顺序与缓存"这条线。它已经在 whale、aider、deepagents、这里出现四次了。
决定一(架构):把"面向渠道"和"面向模型"两层分开
它把一轮对话里两种完全不同的关切分开:
| 层 | 管什么 |
|---|---|
| 外层 | 这条消息属于哪个会话?工作区在哪?历史要不要先压缩?是不是命令?跑完怎么存盘、怎么把答复发回去? |
| 内层 | 发给哪个厂商?模型要调工具吗?结果怎么喂回?什么时候算答完了?撞上限怎么收尾? |
它给的理由是调 试导向的: 渠道路由/会话键/存盘的问题去看外层;模型调用/工具/流式/上限的问题去看内层。 (依据:前沿库 · nanobot · 主线引擎(AgentLoop + AgentRunner) —— AgentLoop 管面向渠道的事务、AgentRunner 管面向模型的事务,理由是调试时这条线很有用——渠道/会话/存盘问题去 loop.py,模型/工具/上限问题去 runner.py)
外层是一个七态状态机,转移用一张纯数据表描述,驱动器只是查表——加一个状态等于加一个表项加一个函数,转移逻辑零分支。
决定三补充:并发只给只读工具
工具有三个属性决定能不能并发:只读、必须独占、能否并发(由前两个推导)。 (依据:前沿库 · nanobot · 主线引擎(AgentLoop + AgentRunner) —— 工具的 concurrency_safe 由 read_only and not exclusive 推导,把「能并行」收得很紧,写操作天然串行)
"能并行"被收得很紧:写操作天然串行。
这是并发问题的第三种答案:
- openai-agents-js:按动作类型硬分;
- haystack:按读写集合自动推;
- nanobot:工具自己声明只读,不声明就不并行。 最保守,也最简单。对最小原型来说这条最合适。
它的做法(可以抄的部分)
提前持久化用户消息:在调模型之前就把用户的话存盘,崩溃也不丢输入。
它没回答什么
- 工具调用怎么解析——它假设模型返回结构化调用。
- 不用原生工具调用怎么办——没讲。
- 循环状态怎么整个存下来——有检查点恢复,但不是"整个运行可序列化"那种。
坑与代价
- 迭代上限是硬墙,超了强制收尾。 它自己承认:复杂任务可能在中途被"最终化"截断——这是用可终止性换的。
- 非流式请求有墙钟超时(默认 300 秒),流式改用空闲超时,避免长推理被误杀。
这条细节值得抄:流式和非流式的超时口径必须不同,否则长推理会被误杀。
- "只修副本"的代价是每轮都要跑一遍流水线。 八步里有几步要遍历整个历史——长会话时这本身就是开销。