Prompt 工程:让 LLM 输出结构化 JSON

本文面向:想让 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 背后做了这些事:

  1. 将 Zod schema 转换为 JSON Schema
  2. 通过模型的 structured output / function calling 接口传递 schema
  3. 模型返回的 JSON 自动通过 Zod 验证
  4. 如果验证失败,自动重试(最多 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 工程最佳实践

  1. Schema 和 Prompt 协同设计。schema 定义格式约束,prompt 定义内容约束,两者缺一不可。只靠 schema,模型可能返回格式正确但内容空洞的结果;只靠 prompt,输出格式可能不稳定。
  2. 善用 .describe() 。Zod schema 的 .describe() 会直接出现在模型的 system message 中,相当于给模型的"字段注释"。写得越具体,输出质量越高。
  3. 枚举优于自由文本z.enum(['A', 'B', 'C'])z.string() 安全得多。只要你的场景能穷举选项,就用枚举。
  4. 数值约束要明确z.number().min(0).max(1)z.number() 好。模型有时会返回不合常理的数值,范围约束能触发重试。
  5. 不要完全信任模型输出。SDK 的 Zod 验证保证了格式正确,但语义正确性需要业务层把关。幻觉 ID、无意义的置信度、不合语境的标签------这些都是可能发生的。
  6. 设置合理的 maxRetries。重试有成本(时间 + token),一般 2-3 次足够。如果连续失败,可能是 schema 定义过于复杂,考虑简化。
  7. 后处理不可省略。标签统一小写、数组去空值、字符串 trim------这些确定性的归一化操作,不应该依赖模型遵守。

下一步


项目地址:github.com/ZengLiangYi...

如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。

相关推荐
Asmewill12 小时前
LangGraph学习笔记四(Node和Edge)
前端
何乐乐12 小时前
【Taro 5.0 技术与实践】 - 高性能 iOS 渲染层与 TaroUI 跨端框架介绍
android·前端·ios
米丘12 小时前
React19.x 一个示例来看 Diff 算法
javascript·react.js
猩球中的木子12 小时前
什么是DNS解析
前端·vue.js·面试
Oo_行者_oO12 小时前
MyBatis-Plus 字段数学计算封装
后端
bandaoyu12 小时前
【AMD】HDP(Host Data Path)是什么
java·后端·spring
Ticnix12 小时前
从零封装 Ollama AI 服务:TypeScript 流式对话工具开发
前端·ollama
zithern_juejin12 小时前
手写instanceof
javascript