@freeCodeCamp: 获取你的应用程序真正能信任的结构化数据可能很棘手。在这篇教程中,Vineeth解释了如何设计模式…
摘要
这篇来自freeCodeCamp的教程解释了如何设计模式、验证输出,并处理故障,以从LLMs中可靠地提取结构化数据,涵盖了约束输出、重试循环和流式处理等技术。
查看缓存全文
缓存时间: 2026/08/29 01:58
获取你的应用真正信赖的结构化数据可能颇具挑战。在本教程中,Vineeth 将讲解如何设计模式、验证输出,以及分别处理语法、模式和语义错误。你还将了解工具调用、约束输出、智能重试、流式处理,以及何时不应重试。
https://freecodecamp.org/news/how-to-get-reliable-structured-data-out-of-an-llm/…
如何从大语言模型获取可靠的结构化数据
来源:https://www.freecodecamp.org/news/how-to-get-reliable-structured-data-out-of-an-llm/
大多数关于调用语言模型的教程都在 JSON.parse(response.content) 这一步戛然而止。这行代码在你最初的十个测试用例中都能正常工作。然后你发布了产品,大约在第四百个请求时,模型返回了一个它自己编造的日期,或者返回了八个数组项而你的模式只允许五个,又或者返回了一个完全合法的 JSON 对象却悄悄少了一个字段。
我在构建 Temploracraft 时遇到了这个问题。这是一个简历工具,它能将上传的文档转换成应用程序可以编辑的结构化数据。输入确实不可预测:双栏 PDF、并非真正表格的表格,以及大约以十四种不同格式书写的日期。输出必须是严格的,因为提取出的每个字段都将进入一个供人查看的表单。当模型搞错一个日期时,用户大约两秒钟内就会注意到。
本文讨论的是介于“模型返回了一些文本”和“我的应用程序拥有了它能信赖的数据”之间的那一层。它涵盖了三种约束输出的方法,为什么先设计模式比写更长的提示更好,如何构建一个不会烧钱的重试循环,以及对于那些无论重试多少次都无法解决的故障该怎么办。
目录
- 前提条件
- 为什么仅靠提示获取 JSON 是不够的
- 约束输出的三种方式
- 从模式开始,而非提示
- 验证是两项工作,而非一项
- 构建不消耗 Token 的重试循环
- 流式结构化输出
- 无法通过重试解决的故障
- 这实际需要多少成本
- 当你不需要这些时
- 总结
- 参考资料
前提条件
为了顺畅地学习,你需要准备好以下条件:
- TypeScript 的工作知识: 示例中会轻度使用类型推断和泛型,你应该能够毫不费力地读懂类型注解。
- 至少调用过一次语言模型 API: 你不必是专家,但你应该知道什么是系统提示,以及 Token 大概是什么。
- 熟悉 JSON Schema 会有帮助,但不是必需: 我会在相关部分解释重要的内容。
- Node.js 18 或更高版本: 如果你想运行示例,因为它们使用了原生的
fetch和异步迭代器。
代码示例使用 Zod (https://zod.dev/) 进行模式定义,使用 Anthropic SDK 进行模型调用,但本文介绍的每种技术都可以直接适用于其他验证库和其他提供商。理念比具体软件包更重要。
为什么仅靠提示获取 JSON 是不够的
当你需要结构化数据时,第一反应是礼貌地要求它。你会写类似这样的话:“请回复符合此结构的有效 JSON,并且不要包含任何其他文本。”你粘贴一个示例,它成功了。它在整个开发过程中都持续有效。在你的演示中也有效。
问题在于,语言模型是基于概率逐个 Token 生成的,而你的指令只是众多影响因素中的一个。它需要与模型的训练、输入文档的形状,以及模型在前几百个 Token 中生成的内容相竞争。大多数情况下你的指令会胜出。但偶尔也会失败。
以下是我实际从生产环境提取管道中收集到的所有故障,所有这些都来自一个被明确要求返回严格 JSON 的模型:
- 尽管被告知两次不要这样做,模型仍将响应包裹在 Markdown 代码围栏中。
- 它返回了
"2019 - Present"作为一个字符串,而模式定义了单独的startDate和endDate字段。 - 它为一个文档明确标记为“当前”的角色发明了一个
endDate:“2024-12-31”。 - 它返回了字符串
"null"而不是实际的null。 - 它为一个角色输出了七个要点,而模式设置了最大五个。
- 它在对象中间截断了,因为响应达到了输出 Token 限制。
注意,这些并非同一类故障。代码围栏和截断是语法问题,你通常可以局部修复而无需再次调用模型。合并的日期字符串和额外的要点是模式问题,JSON 解析正常但不符合你需要的结构。发明的结束日期是语义问题,输出既是有效的 JSON 也符合模式,但关于源文档的事实是错误的。这三类需要三种不同的响应,将它们混为一谈是我在提取代码中看到的最常见的架构错误。本文其余部分主要就是关于如何区分它们。
约束输出的三种方式
在编写任何验证代码之前,值得了解 API 本身能为你强制执行什么。有三种机制,它们提供了显著不同的保证。
JSON 模式是三者中最弱的。你设置一个标志,如 response_format: { type: "json_object" },提供商保证响应将是语法有效的 JSON。这确实很有用,因为它一举消除了代码围栏和截断问题。它不能做的是保证任何关于结构的东西。你可能会得到具有错误键、错误类型,或与你要求完全不同的结构的有效 JSON。
工具调用是我最常用的机制。你用 JSON Schema 描述一个函数的参数,然后强制模型调用它。模型返回匹配该模式的参数,而不是自由文本。支持广泛,模式随请求一起传输,因此你不必在提示中重复它,提供商往往比普通 JSON 模式更严格地约束输出。以下是使用 Anthropic SDK 的示例:
const response = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 4096,
tools: [
{
name: "emit_resume",
description: "Return the parsed resume as structured data.",
input_schema: jsonSchema,
},
],
tool_choice: { type: "tool", name: "emit_resume" },
messages: [{ role: "user", content: resumeText }],
});
const block = response.content.find((b) => b.type === "tool_use");
const candidate = block?.input;
tool_choice 字段是关键部分。没有它,模型会自行决定是否调用工具,有时它可能会直接用散文回答。强制指定特定工具可以完全消除这个分支。
受约束解码是最强大的选项,但也是最不广泛可用的。与其要求模型遵循模式,不如在每一步遮蔽 Token 采样器,使得那些会产生无效输出的 Token 根本无法被选中。无效输出变得不可能,而非不太可能。OpenAI 通过严格结构化输出提供了此功能的一个版本,如果你在本地运行模型,可以在 llama.cpp 中使用 GBNF 语法或使用 Outlines 等库。
其权衡在于,受约束解码可能将模型逼入尴尬的角落。如果模式要求一个文档确实不包含的字段,模型无法拒绝,因此它会用某个内容填充这个位置。你将一个解析失败换成了一个幻觉,这更难检测。我正是出于这个原因将必需字段设为可空,我稍后会回到这一点。我的默认设置是使用工具调用配合一个重度可空的模式,再加上验证。这种组合在没有上述问题的情况下提供了大部分好处。
从模式开始,而非提示
当输出质量不佳时,本能反应是写更长的提示。更多的示例、更多的强调、更多的大写字母。这有一点帮助,但扩展性很差,因为提示是散文,而散文是不可执行的。更好的方法是将模式作为主要产物,让其他一切都从中派生。定义一次,然后使用这个单一定义来生成 TypeScript 类型、发送到 API 的 JSON Schema 以及运行时验证器。当结构变化时,三者同时变化,它们之间不可能产生偏差。
使用 Zod 看起来像这样:
import { z } from "zod";
const YearMonth = z
.string()
.regex(/^\d{4}-\d{2}$/, "Expected a YYYY-MM date");
const Role = z.object({
company: z.string().min(1),
title: z.string().min(1),
startDate: YearMonth,
endDate: YearMonth.nullable(),
bullets: z.array(z.string().min(1)).min(1).max(5),
});
const Resume = z.object({
name: z.string().min(1),
email: z.string().email().nullable(),
roles: z.array(Role),
});
export type Resume = z.infer<typeof Resume>;
其中有三个细节在发挥真正作用。YearMonth 正则表达式比 z.string() 更窄,而这种狭窄正是关键所在。一个裸字符串字段会诱使模型返回 "2019年1月"、"2019 - Present" 或 "01/2019",而所有这些都会通过验证。在模式层面约束格式意味着不匹配会立即暴露出来,而不是在你的日期处理代码中更深层次才出现。
endDate 字段是可空的,而不是可选的。这是我发现的唯一一个最能提高提取质量的改动。可选字段允许模型悄悄地省略它,你无法区分“文档没说”和“模型忘记了”。可空字段强制做出明确决定,而 null 是一个有意义的答案,表示该角色是当前的。
bullets 上的 max(5) 将一个产品约束直接编码到契约中,而不是事后裁剪数组。如果模型超出限制,你会想知道,因为这通常意味着模型在填充而不是提取。
要将模式发送到 API,从同一个定义派生 JSON Schema:
import { zodToJsonSchema } from "zod-to-json-schema";
const jsonSchema = zodToJsonSchema(Resume, {
target: "openApi3",
});
一个值得养成的习惯:为任何仅凭名称就含糊不清的字段编写 .describe()。这些描述会出现在 JSON Schema 中,这意味着它们作为工具定义的一部分到达模型,因此它们起到针对所关心字段的、结构化的提示指令作用。
const Role = z.object({
company: z.string().min(1),
title: z.string().min(1).describe("The person's job title, not the team name"),
startDate: YearMonth,
endDate: YearMonth.nullable().describe("null if this role is current"),
bullets: z
.array(z.string().min(1))
.min(1)
.max(5)
.describe("Verbatim from the document. Do not rewrite or summarise."),
});
最后一个描述为我消除了一整类错误,即模型会“乐于助人地”改进个人自己的要点措辞。
验证是两项工作,而非一项
一旦响应返回,很容易就运行 schema.parse() 然后认为工作完成了。Zod 会告诉你结构是否正确,如果正确,你就继续前进。但结构验证和语义验证是不同的工作,而且只有第一个是免费的。Zod 可以告诉你 endDate 是一个匹配 YYYY-MM 的字符串。它无法告诉你结束日期是否早于开始日期,日期是否在未来,或者一个标记为当前的角色是否也有结束日期。这些都符合模式但都是错误的。
所以进行两轮验证。第一轮是结构的,来自模式。第二轮是一个普通函数,编码了你对领域的了解:
function findSemanticProblems(resume: Resume): string[] {
const problems: string[] = [];
const currentMonth = new Date().toISOString().slice(0, 7);
for (const role of resume.roles) {
if (role.startDate > currentMonth) {
problems.push(`${role.company}: start date is in the future`);
}
if (role.endDate && role.endDate < role.startDate) {
problems.push(`${role.company}: end date precedes start date`);
}
}
const currentRoles = resume.roles.filter((r) => r.endDate === null);
if (currentRoles.length > 1) {
problems.push("More than one role is marked as current");
}
return problems;
}
这些都不是聪明的代码,而这恰恰是重点。它是普通的业务逻辑,只是恰好在检查模型的工作,而不是用户的工作。在你发现错误时编写它,并将每个新的检查视为模型曾经犯过的错误的永久回归测试。
这里有一个有用的不对称性:语义问题通常比重试更适合向用户展示,因为模型通常无法用可用信息做得更好。如果文档确实列出了两个当前角色,那就是文档所说的内容,再次请求模型也不会改变。
构建不消耗 Token 的重试循环
当验证失败时,天真地修复方法是使用相同的提示再次调用模型,并期望得到不同的样本。这种情况足够经常发生,让人觉得合理,但也足够浪费,让你在账单上注意到,因为你为一个基本成功的请求支付了完整的输入成本。
两个改变能带来巨大差异。第一个是在花费任何东西之前尝试本地修复。相当一部分故障是表面性的,你可以用字符串处理修复它们:
function salvage(raw: string): string {
let text = raw.trim();
// 剥离模型添加的 Markdown 围栏,尽管有指令。
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
if (fenced) text = fenced[1];
// 丢弃第一个花括号之前或最后一个花括号之后的任何散文。
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start !== -1 && end > start) text = text.slice(start, end + 1);
return text;
}
第二个是当你确实再次调用模型时,应该发送一个修复请求,而不是一个全新的尝试。包括原始提示、失败的输出和具体的验证错误。模型已经完成了困难的提取工作,你要求它修复少量已命名的问题,而不是从头开始。修复收敛更快,产生的输出更短,这意味着成本更低。
async function requestRepair(
originalPrompt: string,
badOutput: string,
error: z.ZodError,
): Promise<string> {
const issues = error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join("\n");
// ... 构建包含错误详情的修复提示并调用模型 ...
}
相似文章
使用LLM实现结构化输出的可靠性——三个月生产环境经验总结
本文分享了在生产环境中可靠地从LLM生成结构化JSON输出的经验,涵盖JSON模式、模式验证和重试循环等方法,实现了99.5%的有效性。
@freeCodeCamp: 生产级ETL管道需要保持可靠运行,即使数据混乱或API失败。在本手册中,Brookly…
这本freeCodeCamp手册教读者如何使用真实洪水数据在Python中构建生产级ETL管道,涵盖增量加载、类型强制转换、去重和幂等性。
一个实现可靠结构化输出的廉价技巧:将验证错误反馈给重试
一种实用技术,通过将验证错误反馈到重试提示中,改进语言模型的结构化输出生成,使模型能够自我修正而非盲目重试。该方法以模型易理解的方式描述错误,并提供先前输出供其编辑。
@freeCodeCamp:编写整洁代码有助于构建可扩展且可维护的软件应用。在这本手册中,@shahancd 解释了…
一本解释整洁代码原则和模式的手册,用于构建可扩展的软件,包含 JavaScript 示例。
@freeCodeCamp:AI生成的代码可能看起来正确,但仍然会在边界情况、安全性或可靠性上失败。在本指南中,@manishmshiva……
本指南介绍了如何使用测试、黄金数据集、可靠性检查和人工审查来评估AI生成代码的质量。它提供了一个实用工作流,帮助捕获回归问题,并更有信心地交付AI辅助代码。