上一篇把第一个 Agent 跑了起来:一个无参的时间工具、一句 instructions,模型自己决定了何时调用。工具跑通之后,值得停下来把"工具"这件事讲透——在 Mastra 里,Zod schema 是整个工具体系的类型货币:入参靠它声明,出参靠它约束,TS 类型靠它推导,发给模型的参数说明书也从它生成。这一篇就沿着这条线,把工具从"能跑"讲到"类型安全"。
本篇按"价值链 → createTool 解剖 → 输入校验 → 结构化输出 → 类型复用 → 错误处理 → 多工具实战"组织,最后照例留练习。Python 侧的对照仍指向 Agent 系列:工具设计模式在第 22 篇、Pydantic 用法在第 5 篇展开过,同样的概念换到 TS 侧,成色并不相同。
1、类型安全的价值链
先回答"为什么是 Zod"。一份 Zod schema 在 Mastra 里同时扮演三个角色:运行时校验器,模型传来的参数先过校验再进业务代码;静态类型来源,z.infer 从 schema 推导出 TS 类型,execute 内外全程自动补全;模型侧的参数说明,框架把 schema 转成 JSON Schema 随工具定义一并发给模型。一份声明,三处生效,这就是"端到端类型安全"的具体含义。
与 Python 侧的 Pydantic 对照,概念几乎一一对应,差异在结构方式与覆盖半径:
| 维度 | Pydantic(Python) | Zod(TypeScript) |
|---|---|---|
| 定义方式 | 类继承加类型注解 | 构建器链式声明 |
| 运行时校验 | 有 | 有 |
| 类型事实来源 | 手写注解,校验器围绕注解展开 | schema 推导类型,无第二份事实 |
| 静态检查 | mypy、pyright 进程内可查 | tsc 全程在岗,贯穿到调用点 |
| 跨端复用 | Python 进程内,跨语言需 codegen | 前后端同语言直接共享 |
结构性差别在第三行:Pydantic 模型里类型是手写注解,Zod 里压根没有独立注解——类型是 z.infer 从 schema 推导出来的,schema 是唯一事实,改一处全链路更新。更实际的差别在最后一行:Agent 系列第 5 篇里 Pydantic 的 schema 止步于 Python 进程,TS 前端还得再手写一遍接口类型;而 TS 栈里同一份 Zod schema 可以同时服务表单校验、API handler 和 Agent 工具。第 1 篇说"Zod 是 TS 栈最大差异化",落点就在这里。
2、createTool 解剖
把上一篇用过的 createTool 完整摊开,一个带注释的骨架如下:
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
export const skeletonTool = createTool({
id: 'demo-skeleton',
description: '给模型看的说明书:何时调用、传什么',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ answer: z.string() }),
execute: async ({ context }) => ({ answer: context.query }),
})
五个字段逐个过。id 是工具的稳定标识,习惯用 kebab-case,注册名、trace 面板、日志检索都以它为准;上线后不要再改,改了等于换了工具。description 是模型决定"是否调用、怎么传参"的唯一依据,好的写法动词开头,说清适用场景与每个参数的含义。
inputSchema 声明入参,两条硬规矩:顶层必须是 z.object({...}),直接给 z.string() 会类型报错;可以用任何实现 Standard Schema 规范的库,Zod、Valibot、ArkType 都行,Zod v3 与 v4 均在支持范围。outputSchema 声明出参,可选但强烈建议写上:它让 execute 的返回值在编译期受约束,结构不符直接标红。
execute 是业务本体,第一个参数解构出 context——校验后的输入,类型自动推导;第二个参数是执行上下文(中断信号、请求信息等),需要时再取。isDynamic 是高级开关,标记 schema 要到运行时才解析的工具(对接 MCP 远端工具一类场景),日常手写工具保持默认即可。除这五个之外还有 requireApproval、toModelOutput 等字段,完整清单见 createTool 参考。
3、输入校验实战
真实工具的入参很少像骨架那样只有一个 string。以"查未来几天天气"为例,枚举、范围、可选、默认值四种约束一次写齐:
const weatherInput = z.object({
city: z.enum(['北京', '上海', '广州', '深圳']),
days: z.number().int().min(1).max(7).default(3),
unit: z.enum(['celsius', 'fahrenheit']).optional(),
note: z.string().max(50).optional(),
})
逐行解读:z.enum 是白名单,模型传"杭州"会在进 execute 之前就被拦下;.int().min(1).max(7) 把天数锁死在合法区间;.default(3) 与 .optional() 是两回事——前者不传时框架补默认值,业务代码拿到的一定是 number,后者解析为 string | undefined,业务代码要自己处理缺席。另外让 description 与约束保持一致(写明"days 取 1 到 7"),模型第一轮就能传对参数,省一轮试错。
校验失败时框架的行为值得记住:execute 不会执行,校验错误被包装成工具结果回传给模型,大意如下:
工具调用未执行(示意):days —— Number must be less than or equal to 7
模型读到错误后会在下一轮自我修正、重新调用。校验错误是 agent loop 内的自动纠偏信号,而不是需要人介入的故障——脏参数永远打不进业务代码,这与 Agent 系列里"让错误尽早暴露给模型"的原则一脉相承。
工程上的经验是把约束尽量前移:schema 是硬约束,参数不合法就拦截;description 是软引导,帮模型第一轮就传对。两层配合下来,多轮纠偏在多数任务里只是偶发路径而非常态。
4、结构化输出的妙用
outputSchema 的价值常被低估。工具返回不必是拼好的字符串,数组、嵌套对象、格式约束都能声明。以一个搜索工具为例:
outputSchema: z.object({
total: z.number().int(),
results: z.array(z.object({
title: z.string(),
url: z.string().url(),
score: z.number().min(0).max(1),
})),
})
解读:模型拿到结构化结果后的引用准确性是最大收益。字段名成为模型可引用的锚点——"第二条结果相关度 0.83"这类转述直接读字段,不再靠从一段自然语言长文本里做二次阅读理解,转述出错的空间被大幅压缩。数组长度、字段存在性、URL 格式都有契约保证。
另一层保险在框架侧:outputValidation 默认 strict,execute 返回不合 schema 的结果时,框架不会把脏数据喂给模型,而是替换成校验错误对象。宁可让模型知道"这次工具坏了",也不让它基于幻觉出来的字段编答案。确有需要透传原始响应的工具,可以显式改为 warn,框架只记警告不拦截;但对生产环境的多数工具,默认的 strict 是更稳妥的选择。
5、工具类型复用实践
工具不该凭空手写 schema。TS 生态的主流做法是让领域 schema 成为唯一事实:配了 Drizzle 的项目用 drizzle-zod 从表定义直接生成 Zod schema,一句话概括就是数据库结构当事实来源。没有 ORM 的场景,手工等价做法是"schema 优先":
export const orderStatusSchema = z.enum(['pending', 'paid', 'shipped'])
export const orderSchema = z.object({
orderId: z.string().uuid(),
status: orderStatusSchema,
amount: z.number().positive(),
})
export type Order = z.infer<typeof orderSchema>
解读:先写 schema,类型用 z.infer 推导导出,Order 是产物而非另一份需要维护的事实。API handler、工具定义、前端表单 import 同一份,杜绝"interface 与 schema 两处手写、改一漏一"的经典事故。注意枚举 schema 单独导出——下一节的判别联合与第 7 节的实战都会组合它。
6、工具错误处理
失败分两种,走两条路。意外失败(下游超时、依赖挂了)允许 execute 直接抛异常:框架捕获后把异常信息作为工具结果回传,模型知道这次调用没成。可预期失败(查无此单、参数组合不存在)则不要抛——抛错会诱导模型重试,而这类失败重试一万次也不会成功。正确做法是把失败写进 outputSchema,用判别联合声明两种合法返回:
const orderFound = z.object({
ok: z.literal(true),
order: orderSchema,
})
const orderNotFound = z.object({
ok: z.literal(false),
code: z.literal('ORDER_NOT_FOUND'),
message: z.string(),
})
const outputSchema = z.discriminatedUnion('ok', [orderFound, orderNotFound])
解读:z.literal 锁定判别字段的取值,z.discriminatedUnion 以 ok 字段判别两分支。消费端 if (!result.ok) 之后,TS 自动把类型收窄到 not-found 分支,result.code、result.message 直接可用;另一分支里 result.order 保证存在。Agent 系列 22 篇在 Python 侧用 Pydantic 实现过同一模式,TS 侧的额外红利是收窄发生在编译期——写错分支字段名直接标红,前端消费同一类型同样受益。语义上,返回 ok: false 对模型是一次诚实的阴性结果,转述给用户即可,不浪费轮次。一句话判定两类失败:重试有意义的(网络抖动、限流),抛错交给框架;重试必然原样失败的(订单不存在、参数组合非法),返回 ok: false 交给模型如实转述。
7、多工具实战
把前六节串成一个订单客服小助手:查询、算折扣、生成摘要三个工具,共享第 5 节的领域类型。schema 放 src/mastra/domain/order.ts,工具照旧在 src/mastra/tools/index.ts。
7.1、查询工具
查询是唯一的 IO 工具,负责从库里取订单,失败模式就是上一节的"查无此单":
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
import { orderSchema } from '../domain/order.ts'
import { findOrder } from '../../lib/repo.ts'
export const queryOrderTool = createTool({
id: 'query-order',
description: '按订单号查询订单',
inputSchema: z.object({ orderId: z.string().uuid() }),
outputSchema: z.discriminatedUnion('ok', [
z.object({ ok: z.literal(true), order: orderSchema }),
z.object({ ok: z.literal(false), code: z.literal('NOT_FOUND') }),
]),
execute: async ({ context }) => {
const order = await findOrder(context.orderId)
return order ? { ok: true, order } : { ok: false, code: 'NOT_FOUND' }
},
})
解读:第 5 节的 orderSchema 直接嵌进 outputSchema,findOrder 返回的正是同源的 Order 类型,return 处零类型断言——这就是 schema 优先的报酬。
7.2、计算与格式化工具
剩下两个是纯函数工具,一个算钱、一个组织语言:
export const discountTool = createTool({
id: 'calc-discount',
description: '按百分比计算折后金额',
inputSchema: z.object({
amount: z.number().positive(),
percent: z.number().min(0).max(100),
}),
outputSchema: z.object({ final: z.number() }),
execute: async ({ context }) => ({
final: Math.round(context.amount * (1 - context.percent / 100) * 100) / 100,
}),
})
export const summaryTool = createTool({
id: 'order-summary',
description: '把订单整理成一句中文摘要',
inputSchema: z.object({ order: orderSchema }),
outputSchema: z.object({ summary: z.string() }),
execute: async ({ context }) => ({
summary: `订单 ${context.order.orderId} 状态 ${context.order.status},金额 ¥${context.order.amount}`,
}),
})
解读:注意三个共享点。orderSchema 既当输出契约又当输入参数——summaryTool 的入参直接是 Order,模型把上一步查到的订单原样传进来即可;percent 的范围约束让模型传 150 这种值在进函数前就被拦;context.order 的类型来自嵌套 schema,编辑器里链式补全一路点到底。
7.3、组装与验证
组装沿用上一篇的模式,多工具就是 tools 对象多几项:
import { Agent } from '@mastra/core/agent'
import { queryOrderTool, discountTool, summaryTool } from '../tools/index.ts'
export const orderAgent = new Agent({
id: 'order-agent',
name: '订单助手',
instructions: '你是订单客服:查单用 query-order,算折扣用 calc-discount,转述订单用 order-summary。',
model: 'anthropic/claude-sonnet-5-5',
tools: { queryOrderTool, discountTool, summaryTool },
})
解读:instructions 里点名工具分工,配合各自清晰的 description,模型的路由基本不会错。在 Studio 里问一句"查一下订单 550e8400 打八折多少钱",trace 面板能看到查询、计算两次调用按序发生,中间传递的正是那份类型化的 Order——三个工具,一条类型链。
8、总结
回顾全文:Zod schema 是 Mastra 的类型货币,一份声明同时供给运行时校验、静态类型与模型侧 JSON Schema,这是 TS 栈对 Python 栈最实的差异化;createTool 五个字段各司其职;输入校验失败会变成回传模型的自动纠偏信号;outputSchema 换来结构化引用与 strict 兜底;schema 优先加 z.infer 让领域类型 DRY;判别联合把可预期失败写进类型系统;最后用三个共享类型的工具把全篇串了起来。
留一个练习:回到上一篇的 timeTool,给它加 timeZone 参数(用 z.enum 限定 Asia/Shanghai、UTC、America/New_York 三个时区),并把输出升级为结构化的 { iso, readable, weekday }——readable 保持 sv-SE 格式,weekday 输出中文星期。做完在 Studio 里问"纽约现在几点、星期几",观察模型如何路由时区参数、trace 里的结构化输出长什么样。对于准备在生产里铺开工具的团队,建议把"schema 优先"立成规矩:先写 schema,再写工具,最后写类型消费方。下一篇进入工作流(Workflow),多个工具按图编排,类型继续全程护航。

