Mastra 开发(03):端到端类型安全

QuibblerAgentQuibblerAgent 2026-11-11 约 20 分钟 4 次阅读

上一篇把第一个 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),多个工具按图编排,类型继续全程护航。

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.2k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
Multi-Agent 详解:多智能体协作架构与实践
精选Agent

Multi-Agent 详解:多智能体协作架构与实践

一个智能体配上一套工具,已经能完成相当多任务。但让它同时“当产品经理、写后端、跑测试、做代码评审”,很快就会露馅:上下文里塞满了互相干扰的指令,十几件工具的清单来回切换,任务做到一半忘了开头。解法看起来很自然——像人类团队一样分工:让多个智能体各司其职,由一个协调者调度,这就是 Multi-Agent(多智能体)系统。它也是 2026 年智能体工程里讨论度最高、误用率也最高的架构。本文按“架构模式…

122