加载中...
  • 工具系统:统一执行入口为什么比工具函数更重要 loading

    吃透 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 和读取用户密钥文件,风险完全不同。

    权限通常至少看四个维度:

    1. 工具能力。 只读、写入、执行命令还是访问网络。
    2. 资源范围。 当前工作区、受信目录还是任意绝对路径。
    3. 参数内容。 命令中是否包含危险操作,URL 是否指向内网。
    4. 当前模式。 只读规划阶段不应暴露写工具。

    文件路径还要处理符号链接。字符串看起来位于工作区内,解析真实路径后可能跳到外部。安全判断应基于规范化后的真实路径,并兼容 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 工具,要求:

    1. 只允许读取当前工作目录内的 .txt 文件。
    2. 文件超过 20KB 时只返回前 2KB,并附上原始大小。
    3. 记录开始、成功和失败三个事件。
    4. ../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 可以声明 cancellableeffect,但这些字段必须由实现提供,不能由模型选择。

    用户取消时,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。统一管线不是一张架构图,而是旁路真的走不通。

    本文目录
    本文目录