工具系统与上下文治理
这一章讲两件事:模型「手脚」从哪来(工具怎么被发现、注册、校验、执行),以及发给模型的「上下文」在最后一刻经历了什么修整(
ContextGovernor)。两者都贯穿一个原则:对模型宽容,对存盘严格。
3.1 工具的生命:发现 → 注册 → 校验 → 执行
它要解决的小问题
大模型只会「说」要调哪个工具、传什么参数。框架得:① 知道有哪些工具;② 把模型说的参数安全地变成真实的函数调用;③ 把结果变回模型能读的文本。这三步分别由 ToolLoader、ToolRegistry/Tool、和执行路径负责。
自动发现:不用手写注册表
加一个工具,你只要在 nanobot/agent/tools/ 下放一个 Tool 子类,不用改任何注册代码。ToolLoader.discover(tools/loader.py:30-60)用 pkgutil.iter_modules 扫描该包的每个模块,挑出所有「是 Tool 子类、非抽象、_plugin_discoverable=True」的类。外部插件则通过 entry point 组 nanobot.tools 发现(loader.py:62-84)。
注册时还做了冲突保护:内置工具优先,同名插件会被跳过并告警(loader.py:99-110)。
Tool 基类:四个抽象 + 并发标记
一个工具最少要给四样东西(tools/base.py:131-197):name、description、parameters(JSON Schema)、execute。另外三个属性决定它能不能并发:
| 属性 | 含义 | 默认 |
|---|---|---|
read_only | 无副作用、可安全并行 | False |
exclusive | 必须独占运行 | False |
concurrency_safe | read_only and not exclusive | 推导 |
concurrency_safe 正是 01 章里 Runner 分批并发的依据。
参数校验:模型传错了也能纠
模型给的参数经常不规整(字符串包了个 JSON、整数传成了字符串、外面套了一层 arguments)。ToolRegistry.prepare_call(registry.py:92-120)是统一的「解析 + 转型 + 校验」入口:
- 解析
_coerce_params:若参数是"{...}"字符串就json.loads;若是{"arguments": ...}这种多套的一层就拆开(registry.py:122-155)。 - 转型
Tool.cast_params:按 schema 把"3"→3、"true"→True等做安全转换(base.py:214-257)。 - 校验
validate_params→Schema.validate_json_schema_value:一个自带的迷你 JSON Schema 校验器,查类型、enum、min/max、required 等(base.py:48-108)。
找不到工具时还会做模糊提示:把名字归一化(去非字母数字、转小写)后若能唯一匹配,就提示「你是不是指 X?」——但绝不拿模糊名去执行(registry.py:34-50、92-104)。
执行与错误约定
执行走 _run_tool(runner.py:1162-1311)。一个关键约定:工具返回以 "Error" 开头的字符串 = 失败,会被加一句「分析错误,换个思路」的提示再喂回模型。真正的安全边界(SSRF/越界)走另一条路,见 05 章。
工具排序为了 prompt 缓存
get_definitions 把工具定义稳定排序:内置工具按名排在前,mcp_ 前缀的 MCP 工具排在后,结果缓存到下次增删(registry.py:67-90)。稳定的工具顺序 = 稳定的 prompt 前缀 = 更高的 provider prompt-cache 命中率。
3.2 上下文治理:发给模型前的「最后一道修整」
它要解决的小问题
持久化的会话历史可能带病:上一版代码遗留的畸形工具调用、孤儿工具结果、超长的工具输出、或者上下文已经超了模型窗口。如果直接发给模型,轻则浪费 token,重则被上游 API 整条拒绝、把会话永久卡死。
核心设计:两份消息,只修副本
ContextGovernor.prepare_for_model(context_governance.py:75-89)在每轮调模型前跑一条修复+压缩流水线——但它操作的是 messages 的副本,持久化的真实历史一字不动。这是整个项目最关键的不变量之一(01 章 runner.py:374-383 注释明确强调了这点)。
流水线顺序(怎么读:从上到下依次施加在模型副本上):
strip_placeholder_assistant_messages # 删掉「[上条助手消息已省略]」这类占位
|
v
strip_malformed_tool_calls # 丢弃 name 缺失/非字符串的工具调用
|
v
drop_orphan_tool_results # 删掉对不上 call 的孤儿工具结果
|
v
backfill_missing_tool_results # 给缺结果的 call 补占位,保证配对
|
v
apply_tool_result_budget # 给工具结果套字符预算
|
v
compact_inflight_overflow # 本轮内若超窗,挑最大的工具结果就地压缩
|
v
snip_history # 还超就裁老历史(保留合法尾部)
|
v
drop_orphan + backfill (再来一遍) # 裁剪后再次保证 call/result 配对
自愈:被脏数据卡死的会话能自己好
strip_malformed_tool_calls 的注释把这个机制讲得很清楚(context_governance.py:176-191):一条 name=None 的工具调用若混进存盘历史,每轮重放都会让上游 API 报 tool_use.name: Input should be a valid string,永久卡死会话。这里在模型副本里把坏调用删掉,下游的孤儿结果清理顺手把它那条悬空结果也删掉——于是下一轮这条会话自己就修好了,无需人工清库。
工具结果离场(offload)
normalize_tool_result(context_governance.py:109-136)在工具结果写进消息前,会先 maybe_persist_tool_result:超长结果落盘到工作区、消息里只留引用/截断,再对仍超 max_tool_result_chars 的做截断。少数工具(TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS)豁免。这让一次大输出(比如读了个大文件)不会瞬间撑爆上下文。
3.3 系统提示是怎么拼的
ContextBuilder.build_system_prompt(context.py:66-117)把系统提示按段拼起来,用 --- 分隔:身份(平台/工作区路径/运行时)→ bootstrap 文件(AGENTS.md/SOUL.md/USER.md)→ 工具契约 → 记忆 → 常驻技能 → 技能目录摘要 → 近期历史 → 归档摘要。
一个细节:用户没改过的模板内容不会被塞进提示(_is_template_content,context.py:179-185)——避免把默认占位文当成「用户记忆」浪费 token、误导模型。
运行时元数据(当前时间、渠道、chat id)被包进一个显式的 [Runtime Context — metadata only, not instructions] 块附加在用户内容之后(context.py:134-150、230-233),既防止把元数据当指令(prompt 注入面),又把多变的时间放在末尾、保住前缀给 prompt 缓存。
4. 巧妙之处
- 对模型宽容、对存盘严格。 解析/转型/校验/治理全在「进模型」这一侧做,持久层保持干净可回放。
- 自愈会话。 畸形数据靠「删坏调用 → 清孤儿结果」的组合在下一轮自动清除(
context_governance.py:176-191)。 - 工具结果离场。 大结果落盘、消息留引用,把上下文压力挡在窗口之外(
normalize_tool_result)。 - 模板内容识别。 没被用户定制的模板不进提示,省 token(
context.py:179-185)。
5. 边界与局限
- 迷你 Schema 校验器不是完整 JSON Schema。
validate_json_schema_value覆盖常见关键字(type/enum/min/max/required/items),但不实现$ref、oneOf等高级特性(base.py:48-108)。 - 压缩是启发式的。
snip_history/compact_inflight_overflow用「保尾部、压最大结果」这类规则,不是语义最优;极端长会话仍可能丢掉中段细节。
6. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 工具发现 | nanobot/agent/tools/loader.py | ToolLoader.discover、_discover_plugins、load |
| 工具基类 | nanobot/agent/tools/base.py | Tool、tool_parameters、Schema.validate_json_schema_value |
| 注册/解析/校验 | nanobot/agent/tools/registry.py | ToolRegistry.prepare_call、_coerce_params、get_definitions |
| 上下文治理流水线 | nanobot/agent/context_governance.py | ContextGovernor.prepare_for_model |
| 自愈/修复步骤 | nanobot/agent/context_governance.py | strip_malformed_tool_calls、drop_orphan_tool_results、backfill_missing_tool_results |
| 结果离场/截断 | nanobot/agent/context_governance.py | normalize_tool_result |
| 系统提示拼装 | nanobot/agent/context.py | ContextBuilder.build_system_prompt、_build_runtime_context |