AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-04-18

1. 实战目标:一条类型一以贯之的 AI Chat 链路

专栏最后一篇,我们把前面 13 篇学过的东西整合进一个完整可部署的 AI Chat demo

这个 demo 要跑通的链路是这样的:

text
01
前端表单
02
│ z.input<ChatRequestSchema>
03
04
Hono RPC Client($post)
05
06
07
Hono API 入口
08
│ validate('json', ChatRequestSchema)
09
10
业务层:调 LLM
11
│ Structured Output schema
12
13
LLM 原始输出
14
│ LLMJsonSchema(AIReplySchema).parse
15
16
响应出口
17
│ ChatResponseSchema.parse
18
19
前端收到响应
20
│ await res.json() 类型精确

整条链路上,Zod schema 是唯一的真相源。 一处改动,其他地方全部同步。

目录结构我们按 monorepo 组织:

text
01
packages/
02
├── shared/src/schemas/
03
│ ├── chat.ts ← 本篇重点
04
│ ├── analysis.ts
05
│ └── common.ts
06
├── server/
07
│ ├── lib/validator.ts
08
│ ├── lib/llm.ts
09
│ └── routes/chat.ts
10
└── web/
11
├── api.ts
12
└── chat-form.tsx

下面我们一块一块搭起来。

2. 第一步:定义共享 schema

所有 schema 放在 packages/shared/src/schemas/,前后端共用。这是整个项目的契约层

packages/shared/src/schemas/common.ts
01
import { z } from 'zod'
02
03
export const ApiErrorSchema = z.object({
04
ok: z.literal(false),
05
code: z.string(),
06
message: z.string().optional(),
07
errors: z.array(z.object({
08
field: z.string(),
09
message: z.string(),
10
})).optional(),
11
})
12
13
export const ApiOkSchema = <T extends z.ZodTypeAny>(data: T) =>
14
z.object({
15
ok: z.literal(true),
16
data,
17
})
packages/shared/src/schemas/chat.ts
01
import { z } from 'zod'
02
import { ApiOkSchema } from './common'
03
04
// ========== 消息 ==========
05
export const MessageSchema = z.object({
06
role: z.enum(['system', 'user', 'assistant']),
07
content: z.string().min(1).max(8000),
08
})
09
10
// ========== 请求 ==========
11
export const ChatRequestSchema = z.object({
12
model: z.enum(['claude-opus-4-6', 'claude-haiku-4-5']).default('claude-opus-4-6'),
13
temperature: z.number().min(0).max(2).default(0.7),
14
maxTokens: z.coerce.number().int().positive().max(8000).default(2048),
15
messages: z.array(MessageSchema).min(1).max(30),
16
}).refine(
17
data => {
18
const first = data.messages[0].role
19
return first === 'system' || first === 'user'
20
},
21
{ message: '对话必须以 system 或 user 消息开头', path: ['messages', 0] }
22
)
23
24
export type ChatRequest = z.infer<typeof ChatRequestSchema>
25
export type ChatRequestInput = z.input<typeof ChatRequestSchema>
26
27
// ========== LLM 结构化输出的骨架 ==========
28
export const AIReplySchema = z.object({
29
content: z.string().min(1).describe('要回复用户的正文'),
30
topics: z.array(z.string().min(1).max(30)).max(5).describe('本次回答涉及的 1~5 个主题'),
31
sentiment: z.enum(['warm', 'neutral', 'cautious']).describe('回答的语气倾向'),
32
})
33
export type AIReply = z.infer<typeof AIReplySchema>
34
35
// ========== 响应 ==========
36
export const ChatResponseSchema = ApiOkSchema(z.object({
37
id: z.string(),
38
model: z.string(),
39
reply: AIReplySchema,
40
usage: z.object({
41
inputTokens: z.number().int().nonnegative(),
42
outputTokens: z.number().int().nonnegative(),
43
}),
44
}))
45
export type ChatResponse = z.infer<typeof ChatResponseSchema>

这里一份 schema 吃的角色就很多了:

  • ChatRequestSchema — 前端表单校验 + 后端入口校验 + Hono RPC 客户端入参类型
  • AIReplySchema — 喂给 LLM 的 Structured Output 指令 + 解析 LLM 输出
  • ChatResponseSchema — 后端出口校验 + 前端收到响应后再校验

3. 第二步:Hono 的通用工具

校验封装 + LLM 工具抽出来,避免每个路由重写一遍。

packages/server/lib/validator.ts
01
import { zValidator as zv } from '@hono/zod-validator'
02
import type { ZodSchema } from 'zod'
03
import type { ValidationTargets } from 'hono'
04
05
export const validate = <T extends ZodSchema>(
06
target: keyof ValidationTargets,
07
schema: T,
08
) => zv(target, schema, (result, c) => {
09
if (!result.success) {
10
return c.json({
11
ok: false as const,
12
code: 'VALIDATION_ERROR',
13
errors: result.error.issues.map(i => ({
14
field: i.path.join('.'),
15
message: i.message,
16
})),
17
}, 400)
18
}
19
})
packages/server/lib/llm.ts
01
import { z } from 'zod'
02
03
// 第 13 篇讲过的脏输出防御层
04
const stripFence = (s: string) =>
05
s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim()
06
07
const extractJson = (s: string) => {
08
const cleaned = stripFence(s)
09
const first = cleaned.indexOf('{')
10
const last = cleaned.lastIndexOf('}')
11
if (first === -1 || last === -1) throw new Error('no JSON object found')
12
return cleaned.slice(first, last + 1)
13
}
14
15
export const LLMJsonSchema = <T extends z.ZodTypeAny>(shape: T) =>
16
z.string()
17
.transform(extractJson)
18
.transform(s => JSON.parse(s))
19
.pipe(shape)

4. 第三步:Hono 路由(核心业务)

这是整个链路的心脏。每一次数据跨边界,我们都让它过一次 schema

packages/server/routes/chat.ts
01
import { Hono } from 'hono'
02
import Anthropic from '@anthropic-ai/sdk'
03
import { zodToJsonSchema } from 'zod-to-json-schema'
04
import { validate } from '../lib/validator'
05
import { LLMJsonSchema } from '../lib/llm'
06
import {
07
ChatRequestSchema,
08
ChatResponseSchema,
09
AIReplySchema,
10
} from '@shared/schemas/chat'
11
12
const client = new Anthropic()
13
14
export const chat = new Hono()
15
.post('/chat',
16
validate('json', ChatRequestSchema),
17
async (c) => {
18
const req = c.req.valid('json')
19
// 到这一行,req 的类型是 ChatRequest(output),字段都已经填好默认值
20
21
// 构造一个「告诉模型按结构化 JSON 返回」的 system 提示
22
const schemaHint = JSON.stringify(zodToJsonSchema(AIReplySchema), null, 2)
23
const systemHint = `你必须以下面这个 JSON Schema 格式返回,不要说任何其他话:\n${schemaHint}`
24
25
const msg = await client.messages.create({
26
model: req.model,
27
max_tokens: req.maxTokens,
28
temperature: req.temperature,
29
system: systemHint,
30
messages: req.messages.map(m => ({
31
role: m.role === 'system' ? 'user' : m.role,
32
content: m.content,
33
})),
34
})
35
36
const raw = msg.content[0]?.type === 'text' ? msg.content[0].text : ''
37
38
// 用「脏输出防御层 + 严格 schema」解析模型输出
39
const parseResult = LLMJsonSchema(AIReplySchema).safeParse(raw)
40
if (!parseResult.success) {
41
console.error('[llm] invalid output', {
42
raw,
43
issues: parseResult.error.issues,
44
model: req.model,
45
})
46
return c.json({
47
ok: false as const,
48
code: 'LLM_OUTPUT_INVALID',
49
message: '模型返回格式不符合预期',
50
}, 502)
51
}
52
53
// 构造最终响应,再走一次出口校验
54
return c.json(ChatResponseSchema.parse({
55
ok: true,
56
data: {
57
id: msg.id,
58
model: msg.model,
59
reply: parseResult.data,
60
usage: {
61
inputTokens: msg.usage.input_tokens,
62
outputTokens: msg.usage.output_tokens,
63
},
64
},
65
}))
66
}
67
)

细数一下这段代码里 Zod 做了多少事:

  1. validate('json', ChatRequestSchema) — 入口校验
  2. zodToJsonSchema(AIReplySchema) — 把同一份 schema 转成 prompt 里的指令
  3. LLMJsonSchema(AIReplySchema).safeParse(raw) — 脏输出防御 + 严格 schema
  4. ChatResponseSchema.parse(...) — 出口校验,保证给前端的响应合法

整个业务函数里没有任何手写的 if (x == null)as any——所有「守门员」工作都被 Zod 接走了。

5. 第四步:导出 AppType 给前端

前端要拿到类型,必须能导入整个 app 的类型。

packages/server/app.ts
1
import { Hono } from 'hono'
2
import { chat } from './routes/chat'
3
4
const app = new Hono()
5
.route('/api', chat)
6
7
// 关键:把 app 的类型导出,供前端 hc<AppType> 用
8
export type AppType = typeof app
9
export default app

monorepo 里建议建一个单独的包 packages/server-types,只 re-export 这个类型,避免前端把 server 整个依赖拉进 bundle:

packages/server-types/index.ts
1
export type { AppType } from '@server/app'

6. 第五步:前端 API 客户端 + 表单

前端有两种用到 schema 的地方:调接口校验表单

6.1 RPC 客户端

packages/web/api.ts
01
import { hc } from 'hono/client'
02
import type { AppType } from '@server-types'
03
import { ChatResponseSchema } from '@shared/schemas/chat'
04
import type { ChatRequestInput } from '@shared/schemas/chat'
05
06
const client = hc<AppType>(import.meta.env.VITE_API_URL)
07
08
export async function chat(req: ChatRequestInput) {
09
const res = await client.api.chat.$post({ json: req })
10
const raw = await res.json()
11
// 响应也走一次 schema——防御后端偷改字段
12
return ChatResponseSchema.parse(raw)
13
}

这里有两个关键点:

  1. req: ChatRequestInput — 用 z.input,前端可以省略默认字段(model / temperature / maxTokens
  2. ChatResponseSchema.parse(raw) — 前端也做一次校验,任何一方偷改 schema 都会在开发期就被抓到

6.2 React 表单

packages/web/chat-form.tsx
01
import { useForm } from 'react-hook-form'
02
import { zodResolver } from '@hookform/resolvers/zod'
03
import { ChatRequestSchema, type ChatRequestInput } from '@shared/schemas/chat'
04
import { chat } from './api'
05
06
export function ChatForm() {
07
const form = useForm<ChatRequestInput>({
08
resolver: zodResolver(ChatRequestSchema),
09
defaultValues: {
10
messages: [{ role: 'user', content: '' }],
11
},
12
})
13
14
const onSubmit = async (data: ChatRequestInput) => {
15
const res = await chat(data)
16
if (res.ok) {
17
console.log('AI reply:', res.data.reply.content)
18
}
19
}
20
21
return (
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 的威力——假设产品经理说:

NOTE

「把 maxTokens 的上限从 8000 改成 4000。」

你唯一要改的地方是 packages/shared/src/schemas/chat.ts 里一行:

index.ts
1
maxTokens: z.coerce.number().int().positive().max(4000).default(2048),
2
// ^^^^ 改这里

保存之后发生的事:

  1. 前端 chat-form.tsx 里传大于 4000 的值zodResolver 立刻报错
  2. 前端调 chat(req) 传大于 4000 → TypeScript 层不会报错(因为类型是 number),但提交后会被后端 400 拒绝
  3. 后端 validate('json', ChatRequestSchema) → 返回 400 VALIDATION_ERROR,带精确字段路径
  4. OpenAPI 文档(如果用 @hono/zod-openapi → 自动同步上限为 4000
  5. 所有调用 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.onErrorZodError第 12 篇
文档生成zodToJsonSchema / @hono/zod-openapi第 2、13 篇

如果要用一句话总结整个 Zod 章节:

NOTE

Zod 的价值不在任何一个 API,而在于它让你用一份 schema,贯穿表单、请求、响应、LLM、文档——让一个真实项目的所有类型契约,收敛成一个文件夹里的几个 .ts

你从第 1 篇开始读到这里,现在已经完整掌握:

  • ✅ 写任意复杂度的业务 schema
  • ✅ 跨字段校验和数据变换
  • ✅ 从 schema 设计到类型推导
  • ✅ 派生一整套相关 schema
  • ✅ 在真实 Hono API 里全链路使用
  • ✅ 驯服 LLM 的脏输出
  • ✅ 打通前后端类型链路

祝你在你自己的 AI 项目里用得顺手。