工具即契约 —— Agent 的双手

Agent; 工具系统; Tool Use; Function Calling; schema 2901 字 15 min read

上一篇讲对话循环时,Act 阶段一笔带过:“执行工具请求”。这一篇把这一步展开。因为工具是 Agent 唯一能真正改变世界的地方——模型本身只会生成 token,是工具让它能读文件、跑命令、发消息。工具设计得好不好,直接决定 Agent 靠不靠得住。

先看三个真实场景,它们都不是模型的错,而是工具系统的错:

  • 你给 Agent 一个 shell 工具,模型往错误的路径写了 rm -rf。没有权限门、没有沙箱、没有让你在执行前检查命令的机会。Agent 做的完全是你让它做的事
  • 你给 Agent 一个发邮件工具。一次网络抖动让第一次调用超时,循环重试,客户收到了两封邮件。因为”发送”不是幂等的。
  • 你给 Agent 一个部署工具,它很快返回 "ok"。模型以为成功了,继续往下走。三小时后你发现部署根本没到集群——API 静默丢弃了请求,而工具返回了它乐观的默认值。

这三个问题的共同点

,它们就都能被防住。这一篇讲的就是这纸契约的四个部分——元数据、验证、错误、结果。

一、schema 是模型看到的,契约是循环需要的

一个工具定义里,模型能看到三样东西(这部分在上一专题和本专题第 1 篇都提过,这里快速回顾):

  • 名字
  • 描述
    ”什么时候用、什么时候不用、返回什么”。这是模型唯一的指引。含糊的描述(“获取天气”)会让模型在错误的时机调用;精确的描述(“返回单个城市的当前天气;不要用于历史数据”)能减少错误。
  • 输入 schema
    JSON Schema——名字、类型、是否必填、逐字段描述。

但一个生产级工具还携带一组模型看不到、只有循环读的元数据:

{
  name: "edit_file",
  description: "替换 workspace 中单个文件的内容。",
  input_schema: { /* 模型的视角 */ },

  // 循环的视角
  read_only:        false,
  destructive:      true,    // 触发权限门 + 审批
  concurrency_safe: false,   // 不能和同批工具并行
  idempotent:       true,    // 瞬时失败时可安全重试
  open_world:       false    // 给定参数结果确定
}

每个 flag 启用一种循环行为:

  • read_only
    Agent(比如一个只能探索、不能改状态的 explore Agent)。
  • destructive
  • concurrency_safe
  • idempotent
    ,无需显式幂等键。
  • open_world
    (网页抓取、时间、随机数),harness 不能像缓存 read_file 那样缓存或去重它。

OpenCode 在它的 Tool.Def 接口上编码了等价物,Hermes Agent 在注册时附加类似 flag,OpenClaw 和 Paperclip 都按副作用类别给工具分类以驱动审批和重试策略。名字各有不同,但**“schema 给模型看、元数据给循环读”**这个思路是通用的。

二、工具也是模型思考的方式

有一个不那么显然的点

,也是模型的词汇

一个叫 edit_file(path, new_content) 的工具,教会模型用”编辑”来推理。一个叫 run_shell(command) 的工具,教会它用 bash 推理。一个叫 book_meeting(participants, when) 的工具,教会它用”排期”推理。

所以设计工具不只是接口决策,更是提示词决策。每个工具名和 schema 每一轮都待在系统提示里(下一篇讲这为什么对缓存重要),模型读它们、内化它们、伸手去够它们。少而精、命名清晰的一小组工具,比一大堆通用工具能产出更锐利的推理。

一个反直觉的结论

,而是靠给它对的工具。 OpenCode 把这一点做得很具体——explore Agent 只有只读工具(搜索、读取、glob),build Agent 才加上写入,专家 Agent 拿到进一步裁剪的集合。

这也和上一专题(Claude Code)的 Explore Agent 设计完全呼应

disallowedTools 从物理层面禁掉写入工具,是”双重锁”里的硬约束那一层。

三、验证管线
,顺序有讲究

每个工具调用在碰到真实副作用之前,要过五道关:

flowchart LR
    M["模型工具请求"] --> K{"已知工具?"}
    K -- 否 --> F1["fatal: 未知工具"]
    K -- 是 --> T{"参数能解析?"}
    T -- 否 --> R1["recoverable: schema 错误"]
    T -- 是 --> S{"语义安全?"}
    S -- 否 --> R2["recoverable: 输入非法"]
    S -- 是 --> P{"权限允许?"}
    P -- 否 --> F2["fatal: 拒绝"]
    P -- 是 --> H["运行 handler"]
    H --> E{"handler 成功?"}
    E -- 否 --> R3["recoverable: 工具错误"]
    E -- 是 --> C["裁剪 + 封装"]
    C --> O["tool_result 回到循环"]

顺序很重要。便宜的检查先跑——已知先于类型,类型先于语义,语义先于权限,权限先于执行。在解析完一个巨大的 JSON blob 之后才做权限拒绝,是在浪费 token;在 handler 已经打开文件之后才做语义检查(路径是否在 workspace 内),就太晚了。参考系统里的每一个都收敛到大致这个顺序,即使它们对各阶段的叫法不同。

每个阶段决定失败是可恢复的(模型能读到错误再试一次——schema 错误、坏路径、文件未找到)还是致命的(循环应该停止或升级——未知工具、权限拒绝、凭证过期)。这个可恢复/致命的划分,正是上一篇里循环的停止逻辑要读的东西。

验证不只是”JSON 能解析”

Schema 验证是必要的,但不充分。模型能发出干净解析、却依然错误的 JSON:

  • 一个 ../../etc/passwd 路径,字符串前缀匹配了 workspace,但解析时逃逸出去了。
  • 一个指向 localhost:25 的 URL,你的 URL 白名单会拒绝它。
  • 一个 limit: 100000,解析成合法正整数,但会撑爆上下文窗口。
  • 一个 user_id: "self",模型从训练数据里编出来的,不是从你的领域里来的。

规律是:语义检查和 schema 检查放在一起,并且在 handler 之前跑。最典型的例子是路径安全——永远不要靠字符串前缀判断路径在 workspace 内,也永远不要只信任文本解析。path.resolve 是纯词法的

workspace/innocent_link 是一个指向 /etc/passwd 的符号链接。要用 realpath、逐段 O_NOFOLLOW 或平台等价物先解析符号链接,再做结构性比较。

sanitize 在前,escape 在后

在 schema 解析器看到模型参数之前,有几步便宜的清洗值得做。模型能发出技术上合法、操作上危险的字节

null 字节、截断流里的孤立代理对、从工具结果里粘进来的 ANSI 转义序列、BOM、混乱的行尾。

规律:进来时 sanitize,出去时 escape,顺序绝不能反。进来时,你在保护流水线的其余部分不受怪字节影响;出去时——把字符串传给子进程、shell、SQL 驱动、模板引擎——你在保护世界不受模型刚发出的东西影响,不管它看起来多干净。

四、错误是消息,不是异常

坏输入和运行时错误都不应该让对话崩溃。每个生产系统都收敛到同一个模式:

  • 执行前用 schema 验证参数。
  • 验证失败,把错误作为 tool_result 返回,不要 throw
  • handler 运行时失败,把那个也作为 tool_result 返回——用一条对模型有用的消息,而不是一个堆栈跟踪。

模型对它能读到的错误恢复能力惊人。它无法从杀死进程的错误里恢复。把异常包装成工具结果,就是”优雅重试的 Agent”和”任务中途静默停止的 Agent”之间的区别。

五、分发契约

元数据是循环从工具读的。反方向也有一份契约

。当你的 handler 被调用时,分发器已经做完了验证管线的 1-4 阶段,handler 可以依赖这些。工具收到一个 ToolContext,携带:

  • workspace 根和配置的沙箱路径——已经解析好。
  • 调用 Agent 的身份(工具知道自己跑在 explore 还是 build 下,可以据此调整行为)。
  • 循环正在遵守的中止令牌——长时间运行的工具应定期检查它。
  • 预配置了当前步骤、工具名、调用 id 的 logger 和 tracer,让工具的每条日志都能追溯到 trace。
  • harness 强制的逐工具预算(每会话最大调用数、最大返回字节、每次调用最大墙钟时间)。

工具依赖这些,它不重新检查权限、不重新解析路径、不发明自己的日志文件。这个分离——分发器负责边界,工具负责工作——是让两边都可独立测试的关键。

一个检验边界是否干净的好办法

send_message({to, body}, ctx),不用启动整个循环?如果能,你的契约形状良好;如果不能,工具越过分发器去够了本该作为 ctx 一部分收到的东西——你有一个迟早要还的泄漏。

六、大结果不该内联

关于结果,有两件不到发布那天不会显现的事。

id 往返是强制的。每个 tool_use 块有一个 id,你的 tool_result 必须引用同一个 id。丢了这个关联,模型就无法把结果匹配到请求,对话会以令人困惑的方式崩掉。这是机械性的、容易漏、值得写单元测试的。

大结果不属于内联。一个返回 50 KB 的 grep,或一个返回 2 MB 的文件读取,会撑爆你的上下文窗口、杀死你的 prompt 缓存、拖慢之后每一轮。生产里的模式

,给模型一个片段加一个指针,把完整的东西存到模型需要时能问的地方。OpenCode 把这包成一个专门的截断服务;Hermes Agent 强制逐工具的结果大小上限。

小结

工具的契约有四个部分:

部分给谁看解决什么
schema(名字/描述/输入)模型模型知道有什么工具、怎么调
元数据(read_only/destructive/…)循环循环知道能不能并行、要不要审批、能不能重试
验证管线(五阶段)分发器在碰真实副作用前拦住坏调用
结果信封(id 往返 + 裁剪)循环失败变成一轮对话而非崩溃,大结果不撑爆上下文

把这四样都做对,工具就从”一个模型能调的函数”变成了”一个 Agent 能被信任的函数”。下一篇讲一个和工具紧密相关、却常被忽视的话题

,才能让缓存真正省钱。


本文主要整合自

Ch.01/Ch.03(工具即契约、验证管线、元数据),agent开发系列《工具与技能 Skill》《代码智能与编辑工具》,以及 OpenCode / Hermes Agent 的工具注册设计。属基于公开教程与开源项目的二次整理。