Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

模型: openai/gpt-5.4
生成日期: 2026-04-01
书名: Claude Code VS OpenCode:架构、设计与未来
章节: 第4章 — 工具系统设计
Token用量: 约 13,500 input + 2,200 output

4.1 工具定义范式

工具系统本质上是 Agent 的“外设总线”。模型本身只会生成 token,真正让它接触文件系统、Shell、网络与子任务的,是 tool abstraction。三套系统都承认这一点,但其定义范式并不相同。

OpenCode 采用 Tool.define() 工厂函数,核心位于 packages/opencode/src/tool/tool.ts。它要求每个工具显式声明 iddescriptionparametersexecute,其中 parameters 基于 Zod。Zod 可以理解为一类 TypeScript runtime schema validation library:TypeScript 只在编译期检查类型,运行时类型会被擦除,而 Zod 则把“类型描述”保留下来,在程序真正执行时继续验证参数是否合法。因此,OpenCode 的工具不是“相信模型会传对参数”,而是“先定义结构,再在运行时验收结构”。这是一种典型的 schema-first tool design。

Claude Code 的核心是 buildTool(),位于 src/Tool.ts。它的思路更偏产品化:先定义 ToolDef 接口,再由 buildTool() 补齐默认能力,例如 isReadOnlyisDestructivecheckPermissionsuserFacingName。在 schema 层,它既支持 Zod,也支持 JSON Schema。JSON Schema 是 IETF 标准化的数据结构描述规范,可以把它理解为“面向跨语言交换的类型契约”。如果说 Zod 更适合 TypeScript 内部开发体验,那么 JSON Schema 更适合跨进程、跨服务、跨协议场景,尤其适合 Claude Code 这样要兼容 MCP、远程工具与更大生态的系统。

Oh-My-OpenCode(OMO)没有重新发明底层工具协议,而是包装 OpenCode SDK 的工具定义能力,在 src/plugin/tool-registry.tssrc/create-tools.tssrc/tools/* 中注册自己的工具集合。它的关键不是换掉 definition primitive,而是在 execute 前后插入一层 orchestration wrapper:工具创建时仍沿用 OpenCode 风格,但在执行链路上增加 hooks、metadata store、background task 管理、skill/MCP 注入等机制。换言之,OMO 的创新点不在“工具是什么”,而在“工具怎样被编排、拦截、约束和放大”。

这里可以引入 Anthropic 提出的 ACI(Agent-Computer Interface) 原则。传统 HCI(Human-Computer Interface)研究的是人机界面,而 ACI 研究的是“模型如何最小歧义地调用计算机能力”。它强调几个方向:第一,工具接口要清晰、强约束、低歧义;第二,参数名与描述要让模型容易推断;第三,返回结果要结构化,便于后续推理;第四,工具数量不宜无限膨胀,否则会提高模型的选择成本。OpenCode 的 Tool.define()、Claude Code 的 buildTool(),以及 OMO 的包装层,本质上都是 ACI 的工程化体现。

三者因此形成了三个层次。OpenCode 代表“开源基座式范式”:用 Tool.define() + Zod 建立简洁、统一、强验证的工具定义语言。Claude Code 代表“产品平台式范式”:用 buildTool() + JSON Schema/Zod + 默认权限语义,把工具定义扩展成一套可治理的产品接口。OMO 则代表“编排增强式范式”:在不破坏 OpenCode 基座的前提下,为工具定义附加多智能体、技能、会话与钩子系统。

因此,第4章讨论工具系统时,不能只把工具看作函数调用。更准确地说,工具定义范式决定了 Agent 能力边界的表达方式:它既是调用协议,也是安全边界,还是推理成本控制器。谁把这个层设计得更清晰,谁就更可能做出稳定、可扩展、可治理的编码智能体。

深度解析:Zod Schema 的全链路渗透

模型: openai/gpt-5.4
Token用量: 当前本地追加操作未暴露精确统计

如果上一节回答的是“为什么工具需要 schema”,那么这一节要回答的是另一个更深的问题:为什么 schema 在 Agent 系统里会变得如此关键,甚至像毛细血管一样渗透全链路?

先从一个高中生也能立刻理解的比喻开始:麦当劳点餐

假设一家麦当劳完全没有结构化点单系统。顾客说:“我要那个鸡肉的,来个大一点的,再配个冷的,不要那个绿色的东西。” 收银员凭感觉理解,厨房再凭感觉理解,打包员再凭感觉理解。有人把“鸡肉的”理解成麦香鸡,有人理解成鸡腿堡;“大一点”到底是套餐变大,还是薯条变大;“冷的”到底是可乐、雪碧还是冰淇淋;“绿色的东西”到底是生菜、酸黄瓜,还是某种酱料。最后做出来的东西,可能每个人都觉得自己“理解得差不多”,但最终成品却和顾客真正想要的完全不同。

这就是没有 schema 的系统:每一层都在猜。

再看另一种情况。麦当劳点单界面要求必须填写这些字段:主食是什么是否套餐饮料是什么规格大小去掉哪些配料是否加购。如果顾客表达模糊,收银员就不能直接下单,必须先把模糊之处问清楚。之后,厨房看到的是结构化订单,饮料台看到的是结构化饮料字段,打印小票也复用同一份结构化数据。注意,这里的关键不是“收银员更聪明”,而是整个链路不再靠猜测,而是靠同一份被验证过的结构化订单工作

软件里的 schema,本质上就是这张“结构化订单”。


1. Zod 到底是什么?

Zod 是一个 TypeScript 生态里非常常见的 runtime validation 工具。这里有几个非标准术语需要单独解释,因为它们不是很多传统 CS 教科书中的常见条目。

  • schema:可以理解成“数据形状说明书”或者“数据结构契约”。它描述一个数据对象应该有哪些字段、字段是什么类型、哪些字段必填、哪些值合法。
  • runtime validation:运行时校验。TypeScript 的类型检查主要发生在编译期,代码一旦编译成 JavaScript,类型信息大多就消失了;而 runtime validation 指的是程序真正运行时,仍然去检查传进来的数据是否符合预期。
  • discriminated union:可判别联合。它指“一个值可以是多种结构中的一种,但每种结构都带一个明确的标签字段”,比如 type: "success"type: "error"。这样后续代码就不用猜它到底属于哪一类,而是看标签直接分流。

为什么 Zod 重要?因为它不只是“写一个类型定义”,而是把“类型约束”保留到运行时继续使用。也就是说,TypeScript 说“理论上这里应该是 number”,而 Zod 则负责在程序运行时问一句:“你现在传进来的,真的就是 number 吗?”

看一个非常简单的例子:

import { z } from "zod"

const ToolInput = z.object({
  filePath: z.string().min(1),
  lineNumber: z.number().int().positive(),
  includeContext: z.boolean().default(false),
})

const rawInput = {
  filePath: "/project/src/app.ts",
  lineNumber: "18",
  includeContext: true,
}

const result = ToolInput.safeParse(rawInput)

if (!result.success) {
  console.error(result.error.issues)
} else {
  const input = result.data
  console.log(input.lineNumber)
}

上面这段代码里,ToolInput 就是 schema。它规定:

  • filePath 必须是字符串;
  • lineNumber 必须是正整数;
  • includeContext 必须是布尔值,默认是 false

然后 safeParse 会在运行时检查 rawInput。因为例子里 lineNumber 传入的是字符串 "18",而不是数字 18,所以校验会失败,并返回结构化错误信息。

这里的重点是:Zod 不是“写给人看”的注释,而是“程序会真的执行”的边界检查器。


2. 为什么 AI Agent 特别需要它?

普通程序里,函数参数往往来自另一个程序员写的代码;而在 Agent 系统里,工具参数往往来自 LLM 的输出。问题在于:LLM 很强,但它天生是 non-deterministic 的。

所谓 non-deterministic,可以简单理解为:同一个输入、同一个目标,不同轮次的输出可能略有不同。对自然语言写作来说,这不是问题;对工具执行来说,这就很危险。因为自然语言可以模糊一点,系统边界却不能模糊。

常见问题包括:

  • 本来要求 absolute path,模型却给了 relative path,比如 src/main.ts
  • 本来要求 lineNumber: 42,模型却给了 lineNumber: "42"
  • 本来 session_id 是必填字段,模型忘了填
  • 本来字段允许值是 fast | safe,模型却自创了一个 quick
  • 本来某个结果结构是 A 或 B 两种格式之一,模型却拼出一个 A、B 混杂的中间态

这些错误看起来都不大,但在工具系统里,小错会迅速放大。因为工具不是聊天回复,它是真实地操作文件、状态、数据库、消息历史、网络接口的。

所以 Agent 系统里必须坚持一个设计哲学:

LLMs can be non-deterministic, but system boundaries must be deterministic.

也就是:LLM 可以非确定,但系统边界必须确定。


3. 没有 schema 时,错误如何“静默传播”

最危险的不是“立刻报错”,而是看似没报错,但错误悄悄往后传播

比如模型调用一个读取文件的工具,本来要求绝对路径,但它给了相对路径 src/index.ts。如果没有 schema 检查,工具实现可能会默认用当前工作目录去拼接。结果呢?

  • 也许拼到的是错误的文件;
  • 也许碰巧读到了另一个同名文件;
  • 也许在不同 session、不同工作目录下,行为还不一致。

接下来,错误内容进入 tool result;tool result 又进入消息历史;后续推理再基于错误上下文继续操作;最后 SQLite 持久化保存的也是错的;UI、事件总线、HTTP API 看到的也是错的。系统可能一直没有抛出“致命异常”,但错误已经像污染物一样扩散开了。

这个流程可以表示成:

LLM 生成模糊参数
  -> 工具没有严格校验,直接接受
  -> 内部代码自行脑补默认含义
  -> 读错文件 / 写错状态 / 产出错结果
  -> 错误结果进入 message history
  -> 后续步骤基于污染上下文继续推理
  -> 错误静默扩散

这就是 silent failure propagation:静默失败传播。

相反,如果引入 schema,链路会变成:

LLM 生成模糊参数
  -> Zod 在边界处校验
  -> 立即发现具体错误
  -> 返回精确反馈给模型
  -> 模型修正参数后重试
  -> 工具在可信数据上执行
  -> 正确结果继续向后流动

这就是“精确反馈”的价值。它不是单纯地“更严格”,而是把错误拦在最小作用域里。


4. Zod 在 OpenCode 里的全链路渗透

很多初学者容易把 schema 理解成“工具执行前的一次参数检查”。这理解太浅了。更准确地说,像 OpenCode 这样的系统里,schema 会一路渗透整个链路,每经过一个边界,就像过一次海关。

graph LR
    A["系统提示词"] -->|"Zod ✓"| B["LLM 推理"]
    B -->|"Zod ✓"| C["工具参数"]
    C -->|"Zod ✓"| D["工具执行"]
    D -->|"Zod ✓"| E["工具结果"]
    E -->|"Zod ✓"| F["消息历史"]
    F -->|"Zod ✓"| G["SQLite 存储"]
    G -->|"Zod ✓"| H["事件总线"]
    H -->|"Zod ✓"| I["HTTP API"]
    
    style A fill:#4a9eff,color:#fff
    style I fill:#51cf66,color:#fff

可以把这条全链路拆开看:

  1. system prompt assembly
    工具名、字段名、参数说明会被拼进系统提示词。也就是说,schema 虽然还没执行,但它已经先影响了模型对工具的理解方式。

  2. LLM inference
    模型根据提示词决定是否调工具、调哪个工具、参数怎么填。这一步最不稳定,也最像“语言生成”。

  3. tool call parameters
    模型生成的参数对象真正到达运行时,此时 Zod 开始做 runtime validation,确认形状是否匹配。

  4. tool execution
    只有通过校验的数据才能进入真实 execute()。这意味着工具实现可以在更可靠的前提下工作,而不是每个工具作者都要手写一遍 if typeof ...

  5. tool result
    返回结果往往也需要结构约束,比如成功、失败、是否截断、输出路径、metadata 等。这里也常常会用到 schema 或 discriminated union。

  6. message history(MessageV2 parts)
    工具结果会变成结构化消息片段,供下一轮推理继续读取。消息结构一旦不稳定,后续推理就会读歪。

  7. SQLite storage
    Session 持久化依赖稳定数据结构。没有 schema,旧会话恢复时就会陷入“这个字段当年到底是什么格式”的猜谜游戏。

  8. event bus payload
    UI 更新、通知、订阅器、observer 都要依赖事件对象。如果 payload 结构飘忽不定,事件消费端就会脆弱。

  9. HTTP API response
    对外 API 更需要确定契约。否则外部调用者会把你的系统当成“今天这样、明天那样”的不可靠黑盒。

所以可以把整个系统想成一连串边界:

Every boundary has a customs checkpoint.

也就是:每一个边界都应该像海关一样检查“你带来的数据形状是否合法”。这就是所谓的 全链路渗透


5. 机场安检比喻:为什么要把“聪明”和“安全”分开

再换一个比喻:机场安检

  • LLM 像乘客:大多数时候没问题,但有时粗心、含糊、表达不标准,也可能出现不可预测行为。
  • tools 像登机口:一旦让错误的人或错误的物品通过,后果会迅速放大。
  • schema 像安检机与证件核验:它不去“猜”乘客大概是什么意思,而是按照明确规则检查当下这份输入是否合规。

很多工程事故,本质上都是把“模型很聪明”误当成“边界可以放松”。这和机场安检说“这个乘客看起来像好人,就不用过机器了”一样危险。“大概率没问题”不是系统设计原则。 系统设计原则必须是:规则明确、可重复执行、可检查、可追踪。

Zod 做的就是软件世界里的“安检机”。


6. 用图看设计哲学:非确定核心 + 确定性外壳

Agent 最理想的结构,不是要求 LLM 变成一个完全确定的逻辑机,而是承认它会有随机性,然后在外围包上一层严格的确定性壳层。

可以用一个 ASCII 图表示:

+--------------------------------------------------------------+
| 确定性的系统外壳                                              |
|                                                              |
| Prompt 组装 -> Schema 校验 -> Tool 执行 -> Storage 持久化     |
|      |               |              |              |         |
|      v               v              v              v         |
|                  [ 非确定性的 LLM Core ]                     |
|                                                              |
| Event Bus -> HTTP API -> Session 恢复 -> UI 渲染             |
|                                                              |
+--------------------------------------------------------------+

也可以压缩成一句话:

确定性边界 -> 概率性生成 -> 确定性校验 -> 确定性执行 -> 确定性持久化

这张图表达的就是本章最核心的设计思想:

LLM 可以是模糊的,但边界不可以。


7. 四个直接后果

如果一个 Agent 系统把 schema 做成真正的“全链路渗透”,会带来至少四个非常直接的后果。

第一,错误会被限制在最小范围内

参数不对,就在参数边界报错;结果结构不对,就在结果边界报错;事件 payload 不对,就在事件分发前报错。这样错误不会轻易越过边界去污染下一层。

第二,错误信息会非常精确

不是笼统地说“工具失败了”,而是明确说:

  • filePath 必须是 absolute path
  • lineNumber 必须是 integer
  • 缺少必填字段 session_id
  • mode 只能是 fastsafe

这种精确反馈对人类工程师有用,对 LLM 也同样有用,因为模型更容易据此修正下一次调用。

第三,系统更容易 self-heal

所谓 self-heal,不是系统 magically 什么都能修,而是说:当错误暴露得足够早、足够清楚时,系统可以自动重试、自动修正、自动缩小问题范围。schema 失败通常是最适合重试的一类失败,因为它告诉模型“结构错了,按这个格式重来”。

第四,子系统可以安全协作

Prompt builder、tool runner、message history、SQLite、event bus、HTTP API、UI renderer,都是不同子系统。没有共享 schema 时,它们之间依赖大量“默认共识”和“口口相传”的隐性知识;有了 schema,它们共享的是明确契约。这样每个子系统都可以更独立地演进,同时仍然安全协作。


8. 为什么这不是“额外的严格”,而是 Agent 可用性的基础

很多人第一次接触 schema-first 设计,会觉得它只是“多了一道麻烦的校验”。但对 Agent 来说,这不是锦上添花,而是基础设施。

因为 Agent 最大的张力就在这里:

  • 它的“脑”是概率性的;
  • 它的“手脚”却连接着确定性的真实世界。

如果你不在两者之间放一个严格的契约层,模型的小偏差就会直接变成系统的大事故。相反,如果你把 schema 做成一道完整的边界层,那么 LLM 的创造性、模糊性、探索性就能被安全地约束在一个可治理的框架里。

所以,“Zod Schema 的全链路渗透”真正想表达的是:

  1. schema 不是局部工具,而是系统级控制机制;
  2. 它不只校验输入,还稳定全链路中的意义传递;
  3. 它让错误从“隐性污染”变成“显性反馈”;
  4. 它让一个非确定性的 LLM,能够被包裹进一个确定性的工程系统。

最后用一句最值得反复记住的话收束:

LLMs can be non-deterministic, but system boundaries must be deterministic.

而 Zod,正是把这句话从理念落成工程现实的关键工具之一。