吃透 AI Agent 开发 · 第 10 篇 · 第三章 · Tool System
工具一多,超时、权限、Hook、审计和结果裁剪不能靠每个函数各写一套。注册表是副作用的统一边界。
第一次给 Agent 加工具很容易:写一个函数,配一份 JSON Schema,交给模型。第二个、第三个也不难。等工具增加到二十个,问题开始出现:有的自己处理超时,有的会打印密钥,有的写完文件却没有审计记录,还有一个绕过了审批直接执行命令。
工具系统真正难的不是“让模型会调用函数”,而是把所有真实副作用收进一条统一管线。
Function Calling 只表达意图
模型返回下面的数据,并不代表函数已经执行:
{
"name": "read_file",
"arguments": { "path": "package.json" }
}
这只是模型表达:“我希望调用 read_file,参数是这些。”参数是否合法、路径能不能读、超时多久、结果怎么回填,都由宿主程序决定。
因此要把两件事分开:
- 工具描述面向模型,帮助它选择工具并生成参数。
- 工具执行面向系统,负责权限、副作用和结果协议。
Schema 写得再严格,也不能代替运行时权限。模型传入一个格式正确的绝对路径,不等于它有权读取那个文件。
统一入口像公司工具间
员工借电钻,不是直接翻窗进入仓库,而是在工具间登记。管理员检查权限、记录借出时间,坏了还能追查。
Agent 工具的统一入口也做类似的事:
tool call
-> 参数解析
-> 权限与审批
-> pre-hook
-> 超时和并发控制
-> 真正执行
-> 结果裁剪与脱敏
-> post-hook
-> 审计和进度事件
-> tool result message
项目可以把它叫 Tool Registry、Tool Runtime 或 Tool Manager。名字不重要,关键是没有工具绕过去。
一个最小 Tool Registry
interface ToolDefinition<Input, Output> {
name: string
description: string
readOnly: boolean
parse(input: unknown): Input
execute(input: Input, context: ToolContext): Promise<Output>
}
class ToolRegistry {
private tools = new Map<string, ToolDefinition<unknown, unknown>>()
register(tool: ToolDefinition<unknown, unknown>) {
if (this.tools.has(tool.name)) throw new Error(`Duplicate tool: ${tool.name}`)
this.tools.set(tool.name, tool)
}
async execute(call: ToolCall, context: ToolContext) {
const tool = this.tools.get(call.name)
if (!tool) throw new Error(`Unknown tool: ${call.name}`)
const input = tool.parse(call.arguments)
await context.policy.authorize(tool, input)
await context.hooks.before(tool, input)
const output = await tool.execute(input, context)
const safeOutput = context.results.sanitize(output)
await context.hooks.after(tool, input, safeOutput)
return safeOutput
}
}
真实项目还会加入 AbortSignal、超时、进度回调和审计,但职责已经清楚:工具只实现领域动作,共同策略由 Registry 包装。
权限不能只看工具名
“read_file 是只读工具,所以安全”并不成立。读取当前项目的 README 和读取用户密钥文件,风险完全不同。
权限通常至少看四个维度:
- 工具能力。 只读、写入、执行命令还是访问网络。
- 资源范围。 当前工作区、受信目录还是任意绝对路径。
- 参数内容。 命令中是否包含危险操作,URL 是否指向内网。
- 当前模式。 只读规划阶段不应暴露写工具。
文件路径还要处理符号链接。字符串看起来位于工作区内,解析真实路径后可能跳到外部。安全判断应基于规范化后的真实路径,并兼容 Windows 盘符大小写和分隔符。
Hook 是策略,不是工具本身
Hook 可以在执行前后观察或干预:
continue:正常执行。warn:提示风险后继续或等待确认。block:拒绝执行。modify:在受控范围内修改参数或结果。
例如组织规定所有部署命令必须带环境名称,这属于执行策略,适合放在 pre-hook;真正调用部署 API 的逻辑仍属于工具。
Hook 失败时也要定义行为。安全 Hook 无响应,通常应该保守阻断;一个可选的统计 Hook 失败,则可以记录警告后继续。
超时、后台任务与大输出
Shell、爬虫和外部 API 不一定在一次短调用内完成。工具协议可以支持:
- 前台执行并设置总超时。
- 转入后台,立即返回 job ID。
- 用状态工具查询进度。
- 把超大输出写入 artifact,只返回预览。
不要让 Agent Loop 自己猜一个 Promise 是否“太久”。工具层最了解操作类型,应明确声明超时、后台能力和取消方式。
工具越多,模型未必越强
一次暴露一百个相似工具,模型可能选错,也会消耗大量上下文。可以按任务阶段动态选择工具,或采用延迟加载:先告诉模型有哪些工具组,真正需要时再展开详细 Schema。
但动态工具集要稳定可解释。两个完全相同的请求不应因为随机排序看到不同工具;工具被隐藏时,也要有清晰原因。
最危险的捷径:直接调用函数
某段业务代码为了方便,直接调用 writeFileTool.execute()。文件确实写成功了,但它绕过了 pre-hook、审批、写前快照、审计和 TUI 进度。功能测试可能全部通过,生产保障却同时消失。
解决办法不是提醒开发者“记得别绕过”,而是缩小裸执行函数的可见范围,只导出 Registry 包装后的入口。
用 Audit Result 做个小练习
实现一个只读的 read_text 工具,要求:
- 只允许读取当前工作目录内的
.txt文件。 - 文件超过 20KB 时只返回前 2KB,并附上原始大小。
- 记录开始、成功和失败三个事件。
- 用
../secret.txt和指向外部目录的符号链接测试路径边界。
如果只写 happy path,这个练习很短;把边界补齐后,你会直观看到工具函数和工具系统的区别。
Tool Definition 到 Audit Result 的小结
Function Calling 让模型表达工具意图,统一执行入口把意图变成受控动作。参数校验、权限、Hook、超时、脱敏、审计和进度都应该围绕这个入口展开。
下一篇讨论另一类风险:用户说“先计划一下”,Agent 却把计划当成开工信号。如何把自然语言意图变成可靠的规划状态机?
给一次 read_file 调用做“验尸”
当 read_file 返回失败,不要只看异常文本。沿调用记录依次确认:模型给了什么参数,schema 是否接受,路径解析到了哪里,Hook 是否修改或阻止,真正执行函数是否启动,结果又以什么状态回到 Loop。每一步都需要自己的时间戳和决定者。
flowchart LR
Call[tool call] --> Schema[参数校验]
Schema --> Path[真实路径]
Path --> Hook[策略与 Hook]
Hook --> Execute[执行]
Execute --> Result[标准结果]
Result --> Audit[审计事件]
如果某个工具入口绕过注册表直接调用函数,它可能仍然返回正确文件,但 Hook、权限和审计都消失了。统一入口的价值就是让“能运行”和“允许运行”不可分离。
五个源码切面
| 检查位 | 路径 | 具体观察 |
|---|---|---|
| 1. 注册表 | src/tools/registry.ts |
schema、execute 和包装层如何绑定 |
| 2. 文件工具 | src/tools/file-tools.ts |
文件读取怎样返回标准结果 |
| 3. Shell 工具 | src/tools/shell-tools.ts |
超时、cwd 和输出 spill 有何差异 |
| 4. Hook 执行 | src/hooks/runner.ts |
allow、warn、block、modify 如何落地 |
| 5. 审计日志 | src/observability/audit.ts |
工具输入输出如何脱敏记录 |
失败反例:某个入口偷偷直调函数
某个入口绕过注册表直接调用函数,工具还能跑,但权限、审计和进度一起消失。以后给注册表补路径策略时,这条旁路不会获得保护;生产事故发生后,日志又无法证明调用来自哪里。
修复时先搜索所有 execute 或底层函数引用,把真正执行能力收拢到唯一边界。其他模块只能构造意图和消费标准结果,不持有底层函数。用一个被 Hook 阻止的工具调用做回归,确认 CLI、后台 Agent 和自定义命令都得到相同拒绝。
练习:让同一个工具经历四种结局
为一个只读工具准备成功、参数错误、权限拒绝和超时四个案例。断言每种结果都有 call id、状态、用户可读提示和审计事件;失败结果不能泄露完整路径或原始输出。最后从另一个入口调用它,确认行为完全一致。
Registry 返回的是结果信封,不是任意对象
每个工具随手返回自己的对象,Loop 就会充满特判:文件工具看 content,Shell 看 stdout,MCP 看 isError,后台任务又只给 jobId。统一入口可以保留领域 payload,同时用一层稳定信封描述共同状态。
interface ToolResultEnvelope<T = unknown> {
callId: string
tool: string
status: 'ok' | 'rejected' | 'failed' | 'cancelled' | 'unknown'
summary: string
data?: T
artifact?: { file: string; chars: number; sha256: string }
retry?: { allowed: boolean; afterMs?: number; requiresInspection?: boolean }
timing: { startedAt: string; elapsedMs: number }
}
summary 供模型和界面快速判断,data 保留小型结构化结果,artifact 指向长正文。拒绝与失败分开:拒绝说明系统没有开始执行,失败说明执行尝试已经发生;unknown 又说明系统不能确认副作用终态。Loop 根据这些状态决定是否让模型修正、请求用户、查询状态或结束。
信封不能成为大杂烩。文件 diff、HTTP headers、数据库行等领域信息仍放在 data 的工具专属类型里,共同层只保存跨工具真正一致的字段。
Hook 修改参数时要重新校验
pre-hook 支持 modify 很方便,例如组织统一给部署命令补 --environment staging。但修改后的参数不能直接进入执行器。Hook 输出和模型输入一样不可信,必须重新经过 schema、路径和授权校验。
还要限制 Hook 能扩大什么。一个当前只允许读取的任务,Hook 不应把 read_file 改成 write_file;Hook 可以收窄路径、补默认超时,却不能绕过工具白名单。安全边界依然由 Registry 持有。
post-hook 修改结果也有风险。脱敏 Hook 可以把 token 替换为 [REDACTED],但原始结果是否已经写入审计、是否已经发给模型,需要固定顺序。通常先在受控执行层拿到原始值,立即做脱敏和大小限制,再把安全结果交给审计、UI 和模型;只有最小诊断元数据能接触原始内容。
注册冲突要报告来源,而不是看加载顺序
内置、用户级、项目级和 MCP 都可能提供同名工具。静默“后加载覆盖前加载”会让两台机器行为不同,也可能被恶意项目用同名 read_file 劫持。
Registry 应保存来源和优先级,启动摘要显示最终解析:
read_file -> builtin (active)
review_api -> project:.q-code/tools/review_api (active)
review_api -> user:~/.q-code/tools/review_api (shadowed)
jira_get_issue -> mcp:jira-prod (active)
允许覆盖时也要显式遵守既定优先级,工具 schema 校验失败不能让一个半注册对象进入工作集。项目工具可覆盖用户工具,不代表它自动获得更大路径或网络权限;执行时仍走相同策略。
不同工具需要不同的取消合同
读文件通常可以快速停止,Shell 需要向进程树发信号,HTTP 可能支持 AbortSignal,某些远程 API 则请求发出后无法撤销。ToolDefinition 可以声明 cancellable 和 effect,但这些字段必须由实现提供,不能由模型选择。
用户取消时,Registry 先停止接受本轮新调用,再向活动调用广播信号,并等待有限的收尾时间。到期仍未确认的调用进入 unknown,返回查询句柄。界面显示“已发送取消请求”和“已取消”应是两个状态。
后台任务则返回 jobId,由专门的 status/tail/kill 工具控制。主 Loop 结束不等于后台进程自动结束,生命周期要在任务创建时写清楚。
观测工具系统的三个饱和信号
工具成功率不是唯一指标。还可以观察参数拒绝率、策略拒绝率和结果 offload 率。参数拒绝突然升高,可能是 schema 描述或模型适配变化;策略拒绝升高,可能是当前模式与可见工具不匹配;offload 率升高,可能是某个命令开始输出大量无关日志。
按 toolName 聚合 p50/p95 延迟能找出慢服务,但不要把完整参数作为指标标签,否则会造成高基数和泄密。runId/callId 留在 trace 中,指标只保留工具类别、状态和粗粒度环境。
统一入口的反向测试
除了测试正常调用,还应该刻意证明旁路不存在:为 Registry 配一个永远 block 的测试 Hook,从 CLI、SubAgent、用户命令和 MCP 适配入口分别调用同名测试工具。只要某一处真正执行了底层副作用,就说明还有裸入口。
静态搜索可以帮助找直接引用,但运行测试更可靠。底层 execute 可以只在模块内部导出,所有外部调用都拿 Registry 产生的受控 handler。统一管线不是一张架构图,而是旁路真的走不通。