“工具先列目录,schema 用到再展开。”
Harness 层:控制每一轮向模型暴露哪些工具契约。
flowchart LR
R["ToolRegistry<br/>schema + handler 单一来源"] --> I["Immediate schemas<br/>启动时完整可见"]
R --> D["Deferred directory<br/>名称 + 一句话用途"]
D --> S["ToolSearch<br/>精确查找 / 关键词检索"]
S --> O["ToolSearchResult<br/>命中、未命中、稳定顺序"]
O --> C["Session loaded schemas<br/>会话级缓存"]
C --> E["DeferExecuteTool<br/>仅执行已加载工具"]
R --> E
E --> X["Tool result"]
S02 解决的是“工具定义和执行不能分家”:schema、handler 和 dispatch 由同一个注册表管理。S03 没有再造第二套注册表,而是在同一份工具定义之上增加了可见性策略:
- 高频、短 schema 工具从第一轮开始完整可见;
- 低频、长 schema 工具启动时只出现在紧凑目录;
ToolSearch命中后,完整 schema 才进入本次会话;DeferExecuteTool只允许执行已经被发现并加载的延迟工具。
这是一种 clean-room 教学实现。它说明 deferred tools 的必要数据流,不依赖某个产品的私有字段、固定工具数量或内部索引实现。
标准 tool calling 通常会把每个工具的 name、description、input_schema 一起发给模型。工具少时,这是最简单可靠的方案;工具目录变大后,全量 schema 会持续占用上下文,而且多数工具在当前任务里根本不会被调用。
因此 S03 把“知道有这个能力”和“知道怎么调用这个能力”拆开:
| 阶段 | 模型看到什么 | 能做什么 |
|---|---|---|
| 启动 | 即时工具完整 schema;延迟工具名称与简述 | 判断需要搜索哪类能力 |
| 发现 | ToolSearch 返回的少量完整 schema |
理解命中工具的参数契约 |
| 执行 | DeferExecuteTool 通用入口 |
按已加载契约提交参数 |
目录不是 schema 的替代品。它只负责召回候选工具;真正执行前,模型仍然必须看到完整 input_schema。
registry.register(
"image_gen",
schema=IMAGE_GEN_SCHEMA,
handler=mock_image_gen,
defer=True,
)ToolEntry 同时持有目录描述、完整 schema、handler 和加载策略。搜索结果与执行入口都从这份定义解析,避免“目录说有工具,但执行映射没有”或“schema 已更新,handler 仍按旧参数运行”。
注册时还会拒绝:
- 空名称;
- 重复名称;
- 注册名称与 schema 名称不一致;
- 非 object 类型的输入 schema。
这些问题在启动阶段暴露,比运行到模型调用时才失败更容易定位。
self._loaded_schemas: dict[str, dict[str, Any]] = {}注册表描述“系统有什么工具”,loaded cache 描述“当前会话已经向模型展示过什么 schema”。二者不能混为一谈。第一次搜索返回 load_state=loaded,同一会话再次搜索返回 load_state=cached,但不会创建第二份工具定义。
ToolSearchResult 同时记录:
matches:命中的名称、分数、完整 schema、加载状态;missing:精确名称未命中或查询无结果;- 稳定顺序:先按分数降序,再按名称升序打破平分。
显式的次级排序很重要。若平分结果依赖字典或注册顺序,同一个输入可能在不同构建中加载不同工具,回归 trace 也会产生无意义抖动。
本章的关键词评分器故意保持简短、可读:精确名称最高,名称 token 次之,描述 token 再次之。生产系统可以换成 BM25、向量检索或混合召回,但仍应保留相同的结果契约和稳定排序。
output = handle_defer_execute(
registry,
toolName="image_gen",
params={"prompt": "a cat sitting on a desk"},
)如果 image_gen 尚未进入 loaded cache,执行器返回可观察错误并提示先调用 ToolSearch。它也不会接受即时工具:即时工具应该走 S02 的直接 dispatch,不能借通用延迟入口绕过原有边界。
这里的加载只表示“schema 已进入会话上下文”,并不表示下载代码、安装插件或绕过权限检查。
Model: ToolSearch(tool_names=["image_gen"])
-> ToolRegistry.load_by_name()
-> 确认它是 deferred tool
-> schema 写入 session loaded cache
-> ToolSearchResult(matches=[...], missing=[])
-> 完整 input_schema 返回给模型
精确列表会保留调用者顺序,同时折叠同一次请求中的重复名称,避免重复 schema 进入同一个 tool result。
Model: ToolSearch(queries=["image generation"], top_k=3)
-> 规范化查询词
-> 只评分 deferred tools
-> (-score, name) 稳定排序
-> 只加载 top_k 命中 schema
-> 返回可解释的 ToolSearchResult
未命中不会抛异常,也不会改变 loaded cache;结果会显式告诉模型哪个名称或查询没有对应延迟工具。
Model: DeferExecuteTool(toolName="image_gen", params={...})
-> 工具是否存在?
-> 是否为 deferred tool?
-> schema 是否已在本会话加载?
-> 从同一 ToolEntry 取得 handler
-> 执行并把成功/失败写成 tool result
handler 异常会被转换为模型可见的观察结果,避免一个工具失败直接拆掉 agent loop。参数的完整 JSON Schema 校验已经在 S02 讲解,本章聚焦可见性与加载状态,不重复扩大验证器实现。
生产 harness 常见两种渐进式方案:
- 搜索后,在下一轮直接把命中工具加入 provider 的
tools列表; - 搜索后,通过一个通用执行入口调用命中工具。
本章选择第二种,因为它能在一个离线、单文件 demo 里直接观察完整状态机,同时不依赖特定 provider 是否支持运行中修改工具集合。两种方案共享核心原则:模型执行前必须获得准确 schema,harness 只暴露当前任务需要的少量 schema。
code.py 用 len(json) // 4 做教学估算,运行时会同时输出:
- 全量加载全部 schema 的估算成本;
- 启动时即时 schema + 延迟目录的估算成本;
- 本会话经
ToolSearch实际加载的增量; - 最终相对全量加载的估算差值。
这些数字只用于比较本 demo 的两种装载策略,不是 tokenizer 实测,也不应外推成任何真实产品的固定节省比例。真实收益取决于 provider 序列化方式、工具数量、schema 长度、缓存策略和单会话实际命中数。
| 场景 | Harness 行为 | 设计原因 |
|---|---|---|
| 精确名称不存在 | 写入 missing,不激活任何工具 |
让模型可以改写查询 |
| 关键词无命中 | 返回明确空结果 | 空结果是数据,不是异常 |
| 重复注册名称 | 启动时抛 ValueError |
防止目录和执行歧义 |
| 平分结果 | 按名称稳定排序 | 保持测试与 trace 可复现 |
| 未搜索直接执行 | 拒绝并提示先搜索 | 保证模型先看参数契约 |
| 延迟入口调用即时工具 | 拒绝并提示直接调用 | 保留 S02 dispatch 边界 |
| handler 抛异常 | 转成工具错误文本 | 保住 agent loop |
python3 s03_deferred_loading/code.py不需要 API key。默认 mock 对话会依次执行:
ToolSearch(tool_names=["image_gen"]);- 返回并缓存
image_gen完整 schema; DeferExecuteTool(toolName="image_gen", params=...);- mock handler 返回图片路径;
- 输出本次会话的 schema 成本统计。
也可以进入交互模式:
python3 s03_deferred_loading/code.py --interactivetools
search image
schema image_gen
run image_gen {"prompt":"a cat at a desk","size":"1024x1024"}
q
python3 -m pytest -q tests/test_s03_deferred_loading.py行为测试固定了本章最重要的五条契约:
- 重复名称不能注册;
- 未命中不会污染 loaded cache;
- 平分搜索结果顺序稳定;
- 延迟工具必须先发现再执行;
- 即时工具不能通过延迟执行器绕行。
- 给 scorer 增加同义词映射,但保持相同分数下按名称稳定排序。
- 给
ToolSearchResult增加query_id,观察多轮 trace 中一次发现如何对应后续执行。 - 将 session loaded cache 移到独立
ToolVisibility对象,比较“全局注册表 + 多会话状态”的生命周期。 - 把关键词评分器替换为离线 BM25,同时让现有五个行为测试继续通过。
上一课:s02 Tool Dispatch — 一个注册表统一 schema、验证和执行
下一课:s04 Permission & Hooks — 工具可调用之后,先做权限与生命周期边界