本文面向:想让 LLM 输出可靠结构化 JSON 的开发者。
预计阅读时间:10 分钟
最终效果:掌握 Zod Schema + generateObject 的结构化输出方案,理解 Schema 与 System Prompt 协同设计的方法。
LLM 天生擅长生成自然语言文本,但当我们需要把它的输出接入程序逻辑时,自由文本就变成了障碍。你需要的是一个可以 JSON.parse() 的结构化对象,而不是一段散文。
本文以 ChatCrystal 的两个真实场景为例,介绍两种让 LLM 输出结构化 JSON 的方案。
为什么需要结构化输出
假设你让 LLM "总结这段对话",它可能返回:
arduino
这段对话讨论了如何配置 Ollama。用户遇到了模型加载的问题,
最终通过修改 config.json 解决了。建议以后注意端口冲突。
这段文字对人来说很好理解,但程序无法直接从中提取标题、标签、代码片段等字段。你需要的是:
json
{
"title": "Ollama 模型加载配置问题",
"tags": ["ollama", "config", "debugging"],
"key_conclusions": ["端口冲突会导致模型加载失败"]
}
这就是结构化输出要解决的问题。下面介绍两种方案。
方案一:Prompt 约束 + 后处理
最朴素的做法是在 prompt 中明确告诉 LLM "请以 JSON 格式返回",然后在代码里用正则或 JSON 解析提取结果。
这种方法的优点是兼容任何模型(包括不支持 function calling 的小模型),缺点是 LLM 不一定严格遵守格式,需要额外的容错逻辑。
ChatCrystal 早期版本的摘要生成就采用过这种方式。在 prompt 中指定 JSON 字段,然后从响应中提取 JSON 块:
ini
// 从 LLM 响应中提取 JSON
const jsonMatch = rawResponse.match(/{[\s\S]*}/);
if (jsonMatch) {
const result = JSON.parse(jsonMatch[0]);
}
这种方式的问题很明显:
- LLM 可能在 JSON 前后加上解释文字
- 可能输出格式有误(尾部逗号、注释等)
- 需要写大量的 try-catch 和正则兜底
方案二:AI SDK 的 generateObject
Vercel AI SDK 提供了 generateObject 函数,它利用模型的 structured output 能力,在 API 层面保证返回合法的 JSON。你只需要定义一个 Zod schema,SDK 会负责 prompt 注入、输出解析和验证。
ChatCrystal 当前版本的摘要生成和关系发现都使用了这个方案。
基本用法
php
import { generateObject } from 'ai';
import { z } from 'zod';
// 1. 定义 schema
const MySchema = z.object({
title: z.string(),
score: z.number().min(0).max(10),
});
// 2. 调用 generateObject
const { object } = await generateObject({
model: getLanguageModel(),
schema: MySchema,
system: '你是一个评分助手',
prompt: '请对以下内容打分:...',
maxRetries: 2,
});
// 3. 直接使用 --- object 的类型已经由 Zod 推断
console.log(object.title); // string
console.log(object.score); // number
generateObject 背后做了这些事:
- 将 Zod schema 转换为 JSON Schema
- 通过模型的 structured output / function calling 接口传递 schema
- 模型返回的 JSON 自动通过 Zod 验证
- 如果验证失败,自动重试(最多
maxRetries次)
Zod Schema 定义输出格式
Zod 是 TypeScript 生态中最流行的运行时验证库。用它定义 schema,既能在编译期提供类型推断,又能在运行时做数据验证。
ChatCrystal 摘要生成的 schema 定义如下:
css
const SummarizeSchema = z.object({
title: z.string().describe('简洁的标题,概括对话主题,20字以内'),
summary: z.string().describe('2-4 段 markdown 摘要,涵盖决策上下文和可复用知识'),
key_conclusions: z.array(z.string()).describe('3-5 个关键结论或决策'),
code_snippets: z.array(
z.object({
language: z.string(),
code: z.string(),
description: z.string(),
})
).describe('0-3 个最关键的代码片段'),
tags: z.array(z.string()).describe('3-6 个小写英文标签'),
});
注意 .describe() 方法。它不是 Zod 原生的功能,而是 AI SDK 扩展的------这些描述会被自动注入到发给模型的 schema 中,帮助模型理解每个字段的含义和约束。这对输出质量有直接影响。
枚举和数值约束
关系发现的 schema 展示了更多技巧:
css
const RelationElementSchema = z.object({
target_note_id: z.number().describe('目标笔记的 ID'),
relation_type: z.enum([
'CAUSED_BY', 'LEADS_TO', 'RESOLVED_BY', 'SIMILAR_TO',
'CONTRADICTS', 'DEPENDS_ON', 'EXTENDS', 'REFERENCES',
]).describe('关系类型'),
confidence: z.number().min(0).max(1).describe('置信度,0.0-1.0'),
description: z.string().describe('简短说明关系,20字以内'),
});
z.enum([...])限定模型只能从给定的枚举值中选择,杜绝了拼写错误或自造类型.min(0).max(1)对数值做范围约束,模型返回 1.5 这种越界值时会被 SDK 拒绝并触发重试
System Prompt 设计技巧
Schema 约束了"输出什么格式",而 system prompt 约束了"输出什么内容"。好的 system prompt 能显著提升结构化输出的质量。
角色定义
ini
const SYSTEM_PROMPT = `你是一个技术对话分析专家,擅长从 AI 编程助手的对话中提炼结构化知识。`;
一句话定义角色和能力边界。不需要长篇大论,模型会根据角色调整用语风格和分析深度。
每个字段的详细说明
shell
const SYSTEM_PROMPT = `...
### 标题
用一句话概括对话的核心主题。使用与对话相同的语言。
### 摘要
写 2-4 段 markdown 格式的摘要,需要涵盖:
- 决策上下文:遇到了什么问题,考虑了哪些方案
- 实施要点:具体做了什么改动
- 可复用知识:可以提炼出什么通用经验
### 关键结论
提取 3-5 个最重要的结论或决策,每条应当独立可理解。
### 标签
3-6 个小写英文标签,涵盖:技术栈、问题类型、领域。`;
虽然 schema 的 .describe() 已经提供了字段说明,但在 system prompt 中用自然语言展开描述,效果更好。这相当于给了模型"写作指南",而不仅仅是"格式规范"。
约束和注意事项
ini
const SYSTEM_PROMPT = `...
## 注意事项
- 使用与对话相同的语言撰写总结
- 如果对话记录标注了"中间省略了 N 条消息",基于可见内容总结
- 技术术语、函数名、包名保留原文,不翻译`;
负面约束("不要做什么")和边界条件处理同样重要。这些细节决定了输出在边缘情况下是否仍然可用。
maxRetries 重试机制
即使用了 structured output,模型偶尔也会返回不符合 schema 的结果。maxRetries 参数让 SDK 自动重试失败的请求:
php
const { object } = await generateObject({
model: getLanguageModel(),
schema: SummarizeSchema,
system: SYSTEM_PROMPT,
prompt: transcript,
maxOutputTokens: 4096,
maxRetries: 3, // 最多重试 3 次
});
ChatCrystal 的摘要生成设置为 maxRetries: 3,关系发现设置为 maxRetries: 2。为什么不同?摘要是一次性操作,重试成本低;关系发现是自动触发的,需要更快失败以避免阻塞队列。
重试时 SDK 会将验证失败的原因反馈给模型,让它在下一次尝试中修正。这比你手动写重试循环要优雅得多。
输出验证和错误处理
generateObject 通过 Zod 验证保证了数据格式正确,但格式正确不等于语义正确。ChatCrystal 在拿到结构化结果后还会做二次过滤。
关系发现的代码展示了这种"SDK 验证 + 业务验证"的双重保障:
php
const { object: rawRelations } = await generateObject({
model: getLanguageModel(),
output: 'array',
schema: RelationElementSchema,
system: RELATION_SYSTEM_PROMPT,
prompt,
maxRetries: 2,
});
// 业务层二次过滤
const filteredRelations = rawRelations
.filter(
(rel) => candidateIdSet.has(rel.target_note_id)
&& rel.confidence >= MIN_CONFIDENCE,
)
.slice(0, MAX_RELATIONS);
即使 Zod 验证通过(target_note_id 是数字,confidence 在 0-1 之间),业务逻辑仍需检查:
target_note_id是否在候选列表中(模型可能幻觉出不存在的 ID)confidence是否达到最低阈值(0.3 虽然合法,但没有实际意义)- 返回数量是否超过上限
实际案例:摘要生成
完整的摘要生成流程:
php
const SummarizeSchema = z.object({
title: z.string().describe('简洁的标题,概括对话主题,20字以内'),
summary: z.string().describe('2-4 段 markdown 摘要,涵盖决策上下文和可复用知识'),
key_conclusions: z.array(z.string()).describe('3-5 个关键结论或决策'),
code_snippets: z.array(z.object({
language: z.string(),
code: z.string(),
description: z.string(),
})).describe('0-3 个最关键的代码片段'),
tags: z.array(z.string()).describe('3-6 个小写英文标签'),
});
async function summarizeConversation(conversationId: string, transcript: string) {
const { object } = await generateObject({
model: getLanguageModel(),
schema: SummarizeSchema,
system: SYSTEM_PROMPT,
prompt: transcript,
maxOutputTokens: 4096,
maxRetries: 3,
});
// 后处理:统一标签为小写
return {
...object,
tags: object.tags.map((t) => t.toLowerCase()),
raw_response: JSON.stringify(object),
};
}
注意最后的 .map(t => t.toLowerCase())。虽然 system prompt 里写了"小写英文标签",但模型偶尔仍会返回大写。schema 和 prompt 的约束是"尽量遵守",代码层面的归一化才是确定性保障。
实际案例:关系发现
关系发现使用 output: 'array' 模式,让模型返回一个数组而非单个对象:
php
const { object: rawRelations } = await generateObject({
model: getLanguageModel(),
output: 'array',
schema: RelationElementSchema,
system: RELATION_SYSTEM_PROMPT,
prompt, // 包含新笔记和候选笔记的上下文
maxOutputTokens: 1024,
maxRetries: 2,
});
output: 'array' 告诉 SDK 期望的顶层结构是数组,schema 定义的是每个元素的格式。这比让模型返回 { relations: [...] } 再取 .relations 更直接。
prompt 的构造也很有讲究------不是让模型自由发挥,而是给出了明确的输入格式:
makefile
新笔记:
标题: xxx
摘要: xxx
标签: xxx
关键结论: xxx
已有笔记:
[id=1] "标题" - 摘要 [标签]
[id=2] "标题" - 摘要 [标签]
结构化的输入引导结构化的输出。当 prompt 本身格式清晰时,模型更容易遵循 schema 约束。
Prompt 工程最佳实践
- Schema 和 Prompt 协同设计。schema 定义格式约束,prompt 定义内容约束,两者缺一不可。只靠 schema,模型可能返回格式正确但内容空洞的结果;只靠 prompt,输出格式可能不稳定。
- 善用
.describe()。Zod schema 的.describe()会直接出现在模型的 system message 中,相当于给模型的"字段注释"。写得越具体,输出质量越高。 - 枚举优于自由文本 。
z.enum(['A', 'B', 'C'])比z.string()安全得多。只要你的场景能穷举选项,就用枚举。 - 数值约束要明确 。
z.number().min(0).max(1)比z.number()好。模型有时会返回不合常理的数值,范围约束能触发重试。 - 不要完全信任模型输出。SDK 的 Zod 验证保证了格式正确,但语义正确性需要业务层把关。幻觉 ID、无意义的置信度、不合语境的标签------这些都是可能发生的。
- 设置合理的 maxRetries。重试有成本(时间 + token),一般 2-3 次足够。如果连续失败,可能是 schema 定义过于复杂,考虑简化。
- 后处理不可省略。标签统一小写、数组去空值、字符串 trim------这些确定性的归一化操作,不应该依赖模型遵守。
下一步
- LLM 和 Embedding 不能混用 --- 理解不同 AI 模型的分工
- Ollama vs OpenAI vs Claude 摘要横评 --- 不同模型的结构化输出能力对比
- Vercel AI SDK 文档: generateObject --- 官方 API 参考
项目地址:github.com/ZengLiangYi...
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。