1. 实战目标:一条类型一以贯之的 AI Chat 链路
专栏最后一篇,我们把前面 13 篇学过的东西整合进一个完整可部署的 AI Chat demo。
这个 demo 要跑通的链路是这样的:
01前端表单02│ z.input<ChatRequestSchema>03▼04Hono RPC Client($post)05│06▼07Hono API 入口08│ validate('json', ChatRequestSchema)09▼10业务层:调 LLM11│ Structured Output schema12▼13LLM 原始输出14│ LLMJsonSchema(AIReplySchema).parse15▼16响应出口17│ ChatResponseSchema.parse18▼19前端收到响应20│ await res.json() 类型精确
整条链路上,Zod schema 是唯一的真相源。 一处改动,其他地方全部同步。
目录结构我们按 monorepo 组织:
01packages/02├── shared/src/schemas/03│ ├── chat.ts ← 本篇重点04│ ├── analysis.ts05│ └── common.ts06├── server/07│ ├── lib/validator.ts08│ ├── lib/llm.ts09│ └── routes/chat.ts10└── web/11├── api.ts12└── chat-form.tsx
下面我们一块一块搭起来。
2. 第一步:定义共享 schema
所有 schema 放在 packages/shared/src/schemas/,前后端共用。这是整个项目的契约层。
01import { z } from 'zod'0203export const ApiErrorSchema = z.object({04ok: z.literal(false),05code: z.string(),06message: z.string().optional(),07errors: z.array(z.object({08field: z.string(),09message: z.string(),10})).optional(),11})1213export const ApiOkSchema = <T extends z.ZodTypeAny>(data: T) =>14z.object({15ok: z.literal(true),16data,17})
01import { z } from 'zod'02import { ApiOkSchema } from './common'0304// ========== 消息 ==========05export const MessageSchema = z.object({06role: z.enum(['system', 'user', 'assistant']),07content: z.string().min(1).max(8000),08})0910// ========== 请求 ==========11export const ChatRequestSchema = z.object({12model: z.enum(['claude-opus-4-6', 'claude-haiku-4-5']).default('claude-opus-4-6'),13temperature: z.number().min(0).max(2).default(0.7),14maxTokens: z.coerce.number().int().positive().max(8000).default(2048),15messages: z.array(MessageSchema).min(1).max(30),16}).refine(17data => {18const first = data.messages[0].role19return first === 'system' || first === 'user'20},21{ message: '对话必须以 system 或 user 消息开头', path: ['messages', 0] }22)2324export type ChatRequest = z.infer<typeof ChatRequestSchema>25export type ChatRequestInput = z.input<typeof ChatRequestSchema>2627// ========== LLM 结构化输出的骨架 ==========28export const AIReplySchema = z.object({29content: z.string().min(1).describe('要回复用户的正文'),30topics: z.array(z.string().min(1).max(30)).max(5).describe('本次回答涉及的 1~5 个主题'),31sentiment: z.enum(['warm', 'neutral', 'cautious']).describe('回答的语气倾向'),32})33export type AIReply = z.infer<typeof AIReplySchema>3435// ========== 响应 ==========36export const ChatResponseSchema = ApiOkSchema(z.object({37id: z.string(),38model: z.string(),39reply: AIReplySchema,40usage: z.object({41inputTokens: z.number().int().nonnegative(),42outputTokens: z.number().int().nonnegative(),43}),44}))45export type ChatResponse = z.infer<typeof ChatResponseSchema>
这里一份 schema 吃的角色就很多了:
ChatRequestSchema— 前端表单校验 + 后端入口校验 + Hono RPC 客户端入参类型AIReplySchema— 喂给 LLM 的 Structured Output 指令 + 解析 LLM 输出ChatResponseSchema— 后端出口校验 + 前端收到响应后再校验
3. 第二步:Hono 的通用工具
校验封装 + LLM 工具抽出来,避免每个路由重写一遍。
01import { zValidator as zv } from '@hono/zod-validator'02import type { ZodSchema } from 'zod'03import type { ValidationTargets } from 'hono'0405export const validate = <T extends ZodSchema>(06target: keyof ValidationTargets,07schema: T,08) => zv(target, schema, (result, c) => {09if (!result.success) {10return c.json({11ok: false as const,12code: 'VALIDATION_ERROR',13errors: result.error.issues.map(i => ({14field: i.path.join('.'),15message: i.message,16})),17}, 400)18}19})
01import { z } from 'zod'0203// 第 13 篇讲过的脏输出防御层04const stripFence = (s: string) =>05s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim()0607const extractJson = (s: string) => {08const cleaned = stripFence(s)09const first = cleaned.indexOf('{')10const last = cleaned.lastIndexOf('}')11if (first === -1 || last === -1) throw new Error('no JSON object found')12return cleaned.slice(first, last + 1)13}1415export const LLMJsonSchema = <T extends z.ZodTypeAny>(shape: T) =>16z.string()17.transform(extractJson)18.transform(s => JSON.parse(s))19.pipe(shape)
4. 第三步:Hono 路由(核心业务)
这是整个链路的心脏。每一次数据跨边界,我们都让它过一次 schema。
01import { Hono } from 'hono'02import Anthropic from '@anthropic-ai/sdk'03import { zodToJsonSchema } from 'zod-to-json-schema'04import { validate } from '../lib/validator'05import { LLMJsonSchema } from '../lib/llm'06import {07ChatRequestSchema,08ChatResponseSchema,09AIReplySchema,10} from '@shared/schemas/chat'1112const client = new Anthropic()1314export const chat = new Hono()15.post('/chat',16validate('json', ChatRequestSchema),17async (c) => {18const req = c.req.valid('json')19// 到这一行,req 的类型是 ChatRequest(output),字段都已经填好默认值2021// 构造一个「告诉模型按结构化 JSON 返回」的 system 提示22const schemaHint = JSON.stringify(zodToJsonSchema(AIReplySchema), null, 2)23const systemHint = `你必须以下面这个 JSON Schema 格式返回,不要说任何其他话:\n${schemaHint}`2425const msg = await client.messages.create({26model: req.model,27max_tokens: req.maxTokens,28temperature: req.temperature,29system: systemHint,30messages: req.messages.map(m => ({31role: m.role === 'system' ? 'user' : m.role,32content: m.content,33})),34})3536const raw = msg.content[0]?.type === 'text' ? msg.content[0].text : ''3738// 用「脏输出防御层 + 严格 schema」解析模型输出39const parseResult = LLMJsonSchema(AIReplySchema).safeParse(raw)40if (!parseResult.success) {41console.error('[llm] invalid output', {42raw,43issues: parseResult.error.issues,44model: req.model,45})46return c.json({47ok: false as const,48code: 'LLM_OUTPUT_INVALID',49message: '模型返回格式不符合预期',50}, 502)51}5253// 构造最终响应,再走一次出口校验54return c.json(ChatResponseSchema.parse({55ok: true,56data: {57id: msg.id,58model: msg.model,59reply: parseResult.data,60usage: {61inputTokens: msg.usage.input_tokens,62outputTokens: msg.usage.output_tokens,63},64},65}))66}67)
细数一下这段代码里 Zod 做了多少事:
validate('json', ChatRequestSchema)— 入口校验zodToJsonSchema(AIReplySchema)— 把同一份 schema 转成 prompt 里的指令LLMJsonSchema(AIReplySchema).safeParse(raw)— 脏输出防御 + 严格 schemaChatResponseSchema.parse(...)— 出口校验,保证给前端的响应合法
整个业务函数里没有任何手写的 if (x == null) 或 as any——所有「守门员」工作都被 Zod 接走了。
5. 第四步:导出 AppType 给前端
前端要拿到类型,必须能导入整个 app 的类型。
1import { Hono } from 'hono'2import { chat } from './routes/chat'34const app = new Hono()5.route('/api', chat)67// 关键:把 app 的类型导出,供前端 hc<AppType> 用8export type AppType = typeof app9export default app
monorepo 里建议建一个单独的包 packages/server-types,只 re-export 这个类型,避免前端把 server 整个依赖拉进 bundle:
1export type { AppType } from '@server/app'
6. 第五步:前端 API 客户端 + 表单
前端有两种用到 schema 的地方:调接口 和 校验表单。
6.1 RPC 客户端
01import { hc } from 'hono/client'02import type { AppType } from '@server-types'03import { ChatResponseSchema } from '@shared/schemas/chat'04import type { ChatRequestInput } from '@shared/schemas/chat'0506const client = hc<AppType>(import.meta.env.VITE_API_URL)0708export async function chat(req: ChatRequestInput) {09const res = await client.api.chat.$post({ json: req })10const raw = await res.json()11// 响应也走一次 schema——防御后端偷改字段12return ChatResponseSchema.parse(raw)13}
这里有两个关键点:
req: ChatRequestInput— 用z.input,前端可以省略默认字段(model/temperature/maxTokens)ChatResponseSchema.parse(raw)— 前端也做一次校验,任何一方偷改 schema 都会在开发期就被抓到
6.2 React 表单
01import { useForm } from 'react-hook-form'02import { zodResolver } from '@hookform/resolvers/zod'03import { ChatRequestSchema, type ChatRequestInput } from '@shared/schemas/chat'04import { chat } from './api'0506export function ChatForm() {07const form = useForm<ChatRequestInput>({08resolver: zodResolver(ChatRequestSchema),09defaultValues: {10messages: [{ role: 'user', content: '' }],11},12})1314const onSubmit = async (data: ChatRequestInput) => {15const res = await chat(data)16if (res.ok) {17console.log('AI reply:', res.data.reply.content)18}19}2021return (22<form onSubmit={form.handleSubmit(onSubmit)}>23<textarea {...form.register('messages.0.content')} />24<button type="submit">发送</button>25{form.formState.errors.messages?.[0]?.content && (26<p>{form.formState.errors.messages[0].content?.message}</p>27)}28</form>29)30}
前后端的校验规则一字不差——因为它们共用 ChatRequestSchema。你要调整 content 长度上限?改 ChatRequestSchema 一个地方,前端表单提示 + 后端校验 + OpenAPI 文档同步生效。
7. 改动一处,全链路跟上
给你一个让老板也能看懂的演示,感受一下 SSOT 的威力——假设产品经理说:
「把 maxTokens 的上限从 8000 改成 4000。」
你唯一要改的地方是 packages/shared/src/schemas/chat.ts 里一行:
1maxTokens: z.coerce.number().int().positive().max(4000).default(2048),2// ^^^^ 改这里
保存之后发生的事:
- 前端
chat-form.tsx里传大于 4000 的值 →zodResolver立刻报错 - 前端调
chat(req)传大于 4000 → TypeScript 层不会报错(因为类型是number),但提交后会被后端 400 拒绝 - 后端
validate('json', ChatRequestSchema)→ 返回 400 VALIDATION_ERROR,带精确字段路径 - OpenAPI 文档(如果用
@hono/zod-openapi) → 自动同步上限为 4000 - 所有调用
api.chat.$post的前端代码 → 没有触发类型错误,但运行时被保护
代码仓库里没有一处遗漏。 这就是专栏从第 2 篇讲到第 14 篇、重复最多次的一条原则——单一真实来源(SSOT)——在真实代码里完整落地的样子。
8. 总结
整个专栏到这里结束。这一篇不是「又学一个新能力」,而是把前 13 篇学到的所有能力放进一条真实可跑的链路,验证它们加在一起到底能产生多大的工程价值。
一张收官全景图:
| 层 | Zod 做的事 | 引用的章节 |
|---|---|---|
| 共享 schema 模块 | 定义真相源、导出 input/output | 第 10、11 篇 |
| 前端表单 | zodResolver 校验用户输入 | 第 12 篇 |
| 前端 API 客户端 | 导入 XxxInput 类型 + 响应再校验 | 第 10、12 篇 |
| Hono 路由入口 | validate('json'/'query'/'param', ...) | 第 12 篇 |
| 业务层 | 不重复校验,信任入口 | — |
| LLM 工具参数 | zodToJsonSchema + schema.describe | 第 13 篇 |
| LLM 输出解析 | LLMJsonSchema(shape).safeParse | 第 9、13 篇 |
| Hono 路由出口 | XxxResponseSchema.parse | 第 11、12 篇 |
| 错误处理 | validate 统一格式 + app.onError 兼 ZodError | 第 12 篇 |
| 文档生成 | zodToJsonSchema / @hono/zod-openapi | 第 2、13 篇 |
如果要用一句话总结整个 Zod 章节:
Zod 的价值不在任何一个 API,而在于它让你用一份 schema,贯穿表单、请求、响应、LLM、文档——让一个真实项目的所有类型契约,收敛成一个文件夹里的几个 .ts。
你从第 1 篇开始读到这里,现在已经完整掌握:
- ✅ 写任意复杂度的业务 schema
- ✅ 跨字段校验和数据变换
- ✅ 从 schema 设计到类型推导
- ✅ 派生一整套相关 schema
- ✅ 在真实 Hono API 里全链路使用
- ✅ 驯服 LLM 的脏输出
- ✅ 打通前后端类型链路
祝你在你自己的 AI 项目里用得顺手。