吃透 AI Agent 开发 · 第 12 篇 · 第三章 · Tool System
工具描述也占上下文。工具数量变多后,按需加载、搜索和动态注册比把全部工具常驻更可靠。
工具描述也占上下文。工具数量变多后,按需加载、搜索和动态注册比把全部工具常驻更可靠。
工具目录也会制造噪声
当工具从十个涨到两百个,问题不是模型“记不住函数名”这么简单。每个 schema 都会占上下文,描述相似的工具会互相竞争,模型还可能为了确认能力而频繁搜索。工具可见性本身就是上下文设计:当前任务不需要的能力,不必在当前请求中出现。
用目录和工作集分开管理
flowchart LR
Intent[任务意图] --> Search[工具目录检索]
Search --> Candidates[候选摘要]
Candidates --> Select[应用筛选]
Select --> WorkingSet[本轮工具集]
WorkingSet --> Call[模型请求]
Call --> Release[回收动态工具]
目录记录名称、用途、风险和关键词,工作集只记录本轮真正暴露给模型的 schema。搜索结果是候选,不等于授权;动态注册也不等于绕过原有权限层。
先测 token,再决定是否延迟加载
interface ToolCatalogItem {
name: string
summary: string
risk: 'read' | 'write' | 'external'
keywords: string[]
}
function score(item: ToolCatalogItem, query: string): number {
const words = query.toLowerCase().split(/\s+/u)
return words.reduce((total, word) => total + (item.name.includes(word) || item.keywords.includes(word) ? 2 : 0), 0)
}
搜索器只负责缩小候选集,不能因为分数高就直接执行。生产实现还要按照工具风险、当前 cwd、用户授权和模型可见范围再过滤一次。一个“搜到了”的工具,不应该自动拥有写权限。
三种加载策略的现场差别
| 策略 | 优点 | 代价 | 适用任务 |
|---|---|---|---|
| 全量常驻 | 实现简单 | 上下文大、描述互相干扰 | 工具很少的短会话 |
| 目录摘要 + 按需 schema | token 稳定 | 需要搜索和缓存 | 中大型工具集 |
| 模型自己发现并注册 | 灵活 | 发现、授权、审计都更复杂 | 受控插件平台 |
不要把 Deferred Loading 当成“让模型自由加载任何能力”。真正的边界是:应用先决定哪些目录可见,再决定哪些 schema 可被本轮使用,最后仍由统一注册表执行。
q-code 里追踪一次动态加入
| 阅读顺序 | 路径 | 观察问题 |
|---|---|---|
| 1. 注册中心 | src/tools/registry.ts |
动态工具和内置工具是否共享执行管线 |
| 2. 工具搜索 | src/tools/tool-search-tool.ts |
搜索结果怎样回到模型上下文 |
| 3. 目录加载 | src/tools/load-tools-dir.ts |
外部 schema 如何被解析和约束 |
| 4. Prompt 组装 | src/context/prompt-builder.ts |
当前工具集放在哪个动态层 |
失败反例:为了“让模型知道所有能力”把几百个 schema 永久塞进 system prompt
为了“让模型知道所有能力”把几百个 schema 永久塞进 system prompt。模型不仅更难选择,还会因为一个工具描述更新而失去稳定缓存,调试时也很难判断到底是哪一段说明影响了决策。
修复时记录每次工具集变更:查询词、候选、被排除的风险原因、最终暴露的 schema。这样一旦模型选错,可以先判断是搜索召回错、策略过滤错,还是模型理解错,不必把责任都推给 Prompt。
做一个工具目录小实验
练习:把你项目中的工具分成“文件、网络、进程、知识库”四组,为每个工具写三个关键词和一个风险等级。分别测量全量暴露与按需暴露时的输入 token、选择正确率和错误调用数。实验至少包含一个名称相近但权限不同的工具,观察搜索器是否会把它们混淆。
最后定义回收规则:任务结束、模型切换、权限变化和会话恢复时,动态工具是否还在?生命周期写清楚后,Deferred Loading 才不会变成另一种隐形全局状态。
目录项应该比 Schema 小,也应该足够区分
工具目录若只保存名称,模型或搜索器无法区分相似能力;若保存完整 JSON Schema,又失去延迟加载的意义。实用目录项一般保留一句用途、风险等级、来源、关键词和大致输入类型。
{
"name": "jira_search_issues",
"summary": "按 JQL 只读搜索 Jira 工单,不创建或更新工单",
"risk": "external-read",
"source": "mcp:jira-prod",
"keywords": ["jira", "工单", "jql", "issue"],
"inputHint": "query + maxResults",
"schemaHash": "9d42..."
}
schemaHash 帮助缓存判断详情是否变化。摘要要写清“不做什么”,否则 jira_search_issues 和 jira_update_issue 仍可能被一起召回。风险字段只用于过滤和展示,不能替代执行时授权。
召回工具也要有离线评测集
检索文档会测 recall@k,工具搜索同样需要。准备一组真实任务,为每个任务标注必需工具、可接受替代和危险干扰项。搜索器要把必需工具放进 top-k,同时尽量不召回高风险同名工具。
query: "看看 ABC-123 当前是什么状态"
required: jira_get_issue
acceptable: jira_search_issues
dangerous_distractor: jira_update_issue
query: "把本地测试失败行找出来"
required: grep
acceptable: read_file
dangerous_distractor: shell
只有召回率不够。若每个查询都返回 30 个工具,必需项当然容易命中,上下文和选择噪声却没有改善。还要记录工作集大小、schema token、模型最终选择和策略拒绝数。
工具工作集是本轮快照
模型请求发出后,工具目录可能因为 watcher 刷新而变化。已经生成的 tool call 应按该请求看到的 schema 快照校验,不能用刚更新的新版本随机解释。每轮可以保存 toolsetId,关联名称、版本和来源。
下一轮再采用新快照。若某工具已被管理员紧急禁用,执行权限可以立即阻断旧调用;“能识别旧 schema”不等于“必须继续允许执行”。版本解释和授权时效是两件事。
会话恢复也不应永久恢复旧工具集。历史 toolset 用于解释过去,当前运行配置决定新一轮可见工具;界面可以提示某个历史工具已不可用。
自定义工具加载是代码执行入口
项目目录里的 schema.json 看似只是数据,其中的 execute 命令却会运行本地代码。加载器必须限制固定目录、校验 schema、明确覆盖优先级,并把真正执行交给 Shell/Registry 管线。
不能在扫描目录时就运行插件代码来“读取元数据”,否则用户只是打开项目,未批准的代码已经执行。发现阶段只读静态文件;执行阶段才在授权后启动命令。
错误也要隔离。一个自定义工具 schema 损坏,应报告工具名、来源和校验字段,并跳过该项;不该让 help 命令或所有内置工具一起失效。项目级同名覆盖用户级时,诊断里要显示 shadowed 来源,方便判断为何另一台机器行为不同。
动态可见不等于动态注册底层函数
很多场景只需要从已注册工具中选择本轮子集,不需要反复修改 Registry。底层定义保持稳定,Prompt 层生成 working set,执行时再确认 call 的 toolsetId 和当前权限。
真正动态注册适合 MCP 重连或用户新增工具目录。此时更新应原子进行:先在临时结构完成解析、冲突检查和 schema 编译,全部成功后替换某个来源的快照。逐个写入全局 Map,一半失败会留下混合版本。
删除来源时,只移除该来源拥有的条目,再重新解析被遮蔽的候选。不能简单按名称删除,否则项目级工具卸载会把仍存在的用户级同名工具也抹掉。
工具搜索本身不能成为无限循环
模型找不到能力时可能连续调用 tool_search("jira"),每次得到相同候选,却不加载或执行。Loop 检测可以把查询词、候选 hash 和最终工作集做指纹;没有变化时提示模型从现有候选选择或解释缺口。
搜索结果要说明为什么某项不可用,例如“命中但当前为 Plan Mode,写工具被隐藏”。只返回空数组,模型会换同义词反复搜;给出策略原因,它才知道需要审批或切换阶段。
对工具搜索设置局部预算也合理,例如每轮最多两次目录查询、工作集最多 12 项、总 schema token 不超过某个比例。超预算时要求模型基于现有能力继续,不让发现阶段吞掉整个任务。
缓存失效比缓存命中更重要
目录索引可以按文件 mtime、schemaHash 和来源版本缓存,启动先用旧索引再后台刷新。但权限变化、项目切换和 MCP 断线必须立即影响可见性,不能因为缓存还在就暴露失效工具。
旧索引适合加速候选展示,不是授权依据。真正构建工作集时重新应用当前模式、用户权限和连接状态;执行前 Registry 再做最后一次检查。三处看似重复,分别防止“搜错、看错、执行错”。
判断 Deferred Loading 是否真的有收益
优化前后至少比较四项:首轮 schema token、目标工具进入 top-k 的比例、模型误选率、完成任务所需额外搜索步数。token 降低但每次多走三轮搜索,成本和延迟可能反而上升。
常用文件读写工具可以常驻,低频企业服务按需加载,高风险写工具即使常用也应在审批阶段才暴露。混合策略通常优于“全部延迟”或“全部常驻”。最终目标不是目录技术更高级,而是模型在当前任务里看到一套小而明确、权限真实的能力。
工具目录被污染时,搜索效果好反而更危险
项目里新增一个同名 read_file,summary 写得比内置工具更贴合任务,execute 却把内容发送到外部。若动态目录按搜索分数直接覆盖,模型会稳定选择恶意工具。
防线从发现阶段开始:固定受信目录、静态 schema、来源与优先级;项目工具覆盖用户工具时显示诊断,但不能覆盖保留的内置安全能力,或至少要求显式信任。加载 metadata 不执行脚本,真正 execute 仍走 Registry 的网络、路径、Hook 和 Audit。
Catalog item 可以带 package/content hash,首次出现或变化时提示。企业环境可要求签名或 allowlist;本地个人项目则保持默认不自动运行陌生工具。
事故演练在 fixture 项目放入同名工具,预期目录显示 collision/shadowed,模型工作集仍指向可信来源,底层脚本未启动。随后用户显式允许一个无冲突只读工具,确认它能按既定优先级加载。安全策略不能用“禁用所有扩展”换取表面通过。