大多数关于调用语言模型的教程在 JSON.parse(response.content) 这一行结束。这一行在你前十个测试用例上能工作。当你发布后,大约在第四百次请求时,模型可能会返回它自己编造的日期,或者在 schema 只允许五个元素时返回八个数组项,或者返回一个看似完全有效但其实少了一个字段的 JSON 对象。
我在构建 Temploracraft 时遇到了这个问题,Temploracraft 是一个简历工具,它接受上传的文档并将其转换为应用程序可以编辑的结构化数据。
输入确实不可预测:双栏 PDF、其实不是表格的表格,以及大约十四种不同的日期格式。输出必须严格,因为每个提取的字段都会填入一个人会查看的表单中。当模型在日期上出错时,用户大约两秒钟就能发现。
本文讨论的是位于“模型返回了一些文本”和“我的应用拥有可信数据”之间的那一层。它介绍了三种约束输出的机制,解释了为什么先设计 schema 比写更长的 prompt 更有效,如何构建不会浪费 token 的重试循环,以及针对任何重试都无法解决的失败该怎么处理。
为了轻松跟随,你需要准备好以下几项:
对 TypeScript 有工作知识: 示例会轻微使用类型推断和泛型,你应该能够不停顿地读取类型注解。
你至少曾经调用过一次语言模型 API: 你不需要是专家,但应该知道什么是系统提示词,以及大致什么是标记。
熟悉 JSON Schema 有帮助但不是必需: 我会在需要时解释重要的部分。
Node.js 18 或更高版本 如果你想运行示例,因为它们使用原生的 fetch 和异步迭代器。
代码示例使用 Zod 进行模式定义,并使用 Anthropic SDK 进行模型调用,但这里的每种技术都可以直接应用于其他验证库和其他提供者。理念比具体的包装更重要。
当你需要结构化数据时,第一反应是礼貌地请求。你会写类似 "请返回与此形状匹配的有效 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 描述函数的参数,然后强制模型去调用它。模型返回的是符合该 schema 的参数,而不是自由文本。支持范围广泛,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 字段是关键部分。没有它,模型会自行决定是否调用工具,有时它会直接用散文形式回答。强制使用特定工具可以完全消除这一分支。
受限解码 是最强的选项,也是最少被广泛使用的。与其让模型遵循一个 schema,不如在运行时每一步都对 token 采样器进行掩码,使得会产生无效输出的 token 永远不会被选中。无效输出变得不可能,而不仅仅是不太可能。
OpenAI 通过严格的结构化输出提供了此功能的一个版本;如果你在本地运行模型,可以在 llama.cpp 中使用 GBNF 语法,或使用诸如 Outlines 之类的库。
权衡在于,受限解码可能会把模型推入尴尬的境地。如果 schema 要求一个文档实际上不存在的字段,模型无法拒绝,只能用某种内容填充该位置。你用幻觉换取了原本可能的解析失败,而幻觉更难被发现。出于这个原因,我会把必需字段设为可空,稍后我会再回来说明。
我的默认做法是使用带有大量可空字段的 schema 进行工具调用,并在其上添加验证。这种组合能够获得大部分好处,同时规避这些问题。
当输出质量不佳时,人们的本能是编写更长的提示。增加示例、加强语气、多用大写字母。这虽然有一点帮助,但难以扩展,因为提示是散文,而散文无法强制执行。
更好的做法是将 schema 视为主要产出,让其他所有内容均由此导出。只需定义一次,然后利用这一定义生成 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;
其中有三个细节在发挥实际作用。
YearMonth 正则比 z.string() 更窄,这一点正是关键。一个未加限制的字符串字段会诱使模型返回诸如 "January 2019"、"2019 - Present" 或 "01/2019",所有这些都能通过验证。在 schema 层面限制格式可以让不匹配立即显现,而不必深入日期处理代码的三层之中。
endDate 字段是可空的,而不是可选的。这是我提取质量提升最显著的单一更改。可选字段会让模型悄悄省略它,此时你无法分辨是文档未给出该信息还是模型忘记了。可空字段迫使做出明确决定,null 是一个有意义的答案,表示该职位仍在任。
在项目符号上使用 max(5) 能直接把产品限制写入契约,而非事后裁剪数组。如果模型超过了这个限制,你需要知晓,因为这通常表示模型是在进行填充而非提取。
要将 schema 发送到 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 格式的字符串。它却无法判断结束日期是否早于开始日期,日期是否在未来,或者标记为当前的职位是否也有结束日期。这些情况虽然符合 schema,却都是错误的。
因此需要进行两次验证。第一次是结构验证,来源于 schema;第二次是一个普通函数,用来编码你对领域的了解:
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;
}
这些代码并不巧妙,恰恰这就是重点。它只是普通的业务逻辑,只是恰好在检查模型的工作而不是用户的。随着你发现失败而编写它,并将每一次新的检查视为对模型曾经犯错的永久回归测试。
这里存在一种有用的不对称性。语义问题通常比重试更好地直接呈现给用户,因为模型在已有信息下往往无法做得更好。如果一份文档确实列出了两个当前角色,那就是文档所说的内容,再次询问模型也不会改变这一点。
当验证失败时,天真的做法是使用相同的提示再次调用模型,希望得到不同的样本。这种做法在一定程度上看起来合理,但在账单上也会明显浪费,因为你为一个已经基本成功的请求支付了完整的输入成本。
两个改动能够带来显著差异。
第一是在花费任何资源之前先尝试进行局部修复。相当一部分失败只是表面问题,你可以通过字符串处理来修复它们:
function salvage(raw: string): string {
let text = raw.trim();
// Strip a Markdown fence the model added despite instructions.
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
if (fenced) text = fenced[1];
// Drop any prose before the first brace or after the last one.
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 {
const issues = error.issues
.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
.join("\n");
const response = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 4096,
messages: [
{ role: "user", content: originalPrompt },
{ role: "assistant", content: badOutput },
{
role: "user",
content:
`That output failed validation with these problems:\n${issues}\n\n` +
`Return the corrected JSON only. Keep everything that was already correct.`,
},
],
});
return response.content[0].type === "text" ? response.content[0].text : "";
}
综上所述,完整流程会区分失败类别,并对支出设置上限:
type Outcome =
| { ok: true; value: T; attempts: number }
| { ok: false; error: string; attempts: number };
async function extract(
schema: z.ZodType,
prompt: string,
maxAttempts = 3,
): Promise> {
let lastRaw = "";
let lastError: z.ZodError | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
lastRaw =
attempt === 1
? await callModel(prompt)
: await requestRepair(prompt, lastRaw, lastError!);
let candidate: unknown;
try {
candidate = JSON.parse(salvage(lastRaw));
} catch {
continue; // Syntax failure. Try again without a schema error to report.
}
const result = schema.safeParse(candidate);
if (result.success) {
return { ok: true, value: result.data, attempts: attempt };
}
lastError = result.error;
}
return {
ok: false,
error: lastError?.message ?? "Output was never parseable",
attempts: maxAttempts,
};
}
这个循环有两点是刻意设计的。它会返回尝试次数,你应该记录下来,因为每次成功的尝试次数是提取管道最有用的健康指标。并且它将尝试次数上限设为三次。如果三次尝试仍未产生有效输出,第四次尝试通常无济于事,此时你最好优雅降级,而不是继续付出代价。
还有一点值得注意:速率限制错误和模式验证错误不是同一种故障,不应共用重试策略。速率限制需要指数退避,因为问题在于时机。模式故障需要立即发起修复请求,因为问题在于内容,等待不会改变任何情况。
流式输出和结构化输出相互制约。流式输出的存在是为了让用户在响应完成前看到进度,但必须等到闭合大括号到达才能解析 JSON 对象。如果你的抽取过程需要十二秒,最好的做法是显示一个持续十二秒的加载器,或者找到一种方式来流式输出有意义的内容。
有两种可行的方法。
第一种是部分 JSON 解析器,它接受一个不完整的字符串,并返回能够推断出的最大有效结构,闭合未闭合的大括号并丢弃尾部的不完整值。诸如 best-effort-json-parser 之类的库可以实现这一点。这种方法有效,并且在输出确实是一个大对象时是正确的选择。其代价是中间状态可能具有误导性,因为一个字段可能出现截断但看起来完整的值。
第二种方法,我在输出为列表时更倾向于采用——即改变输出格式,使流式输出变得自然。不是请求一个对象数组,而是请求每行一个对象。每行都可以独立解析,因此你可以在它们完成时立即进行验证并输出:
const stream = client.messages.stream({
model: "claude-sonnet-5",
max_tokens: 4096,
messages: [{ role: "user", content: prompt }],
});
let buffer = "";
for await (const event of stream) {
if (event.type !== "content_block_delta") continue;
buffer += event.delta.text ?? "";
const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; // Keep the incomplete tail for the next chunk.
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = Role.safeParse(JSON.parse(line));
if (parsed.success) onRole(parsed.data);
} catch {
// A malformed line is dropped rather than failing the whole stream.
}
}
}
缓冲区处理是人们常犯错的环节。网络数据块不对齐行边界,因而一个块经常在对象中间截断。将最后一个元素弹回缓冲区并继续前送,这才能让循环正确。
这种模式可免费实现逐项校验,相比解析单一大对象,这是真正的优势。单个错误条目不会导致整体提取失败。
到目前为止的讨论都假设只要提问正确,模型就能给出正确答案。但有些失败不符合这种假设,把它们当作可重试的情况不仅浪费金钱,还会产生看似正确却实际错误的数据。
其中最重要的一种是尝试从源中不存在的信息中提取。如果简历本来没有邮箱,而 schema 要求必须提供邮箱,模型会输出一个看似合理的值。
重试只会得到另一个看似合理的值。这是受限解码让情况变得更糟而不是更好的失败模式——语法会在本应为空的槽位中保证输出格式正确的值。解决办法在于 schema 层面,因而我在提取 schema 中几乎所有字段都设为可空。
源数据中的歧义也是类似情况。当文档在两个不同职位旁边列出一个日期范围时,没有正确的提取结果,只有猜测。重试只会得到另一个置信度相同的猜测。这类情况需要在 schema 中加入置信度信号,并在界面中加入复核步骤,而不是再发一次 API 请求。
还有沉默截断。当响应在对象中间触达输出 token 上限时,会得到语法错误的 JSON;天真的重试循环会把它当作临时解析失败,用同样的限制再试一次,结果每次都一样失败。检查响应的 stop reason。如果模型因 token 用尽而停止,在不提高上限或拆分输入的情况下重试必定会再次失败。
总体原则是,在决定是否重复调用可能有帮助之前,你的重试策略应先判断这是哪种失败。
上面每一层都有成本,值得去测量而不是假设。
需要跟踪的主要指标是 每次成功提取的尝试次数。在每个请求上记录它,关注 p95 而非均值,因为均值会掩盖花费所在的尾部。当 schema 更改导致质量回退时,这个数字会比其他指标先发生变化。
在此场景下,提示缓存的重要性超过大多数工作负载。提取提示异乎寻常地适合缓存,因为系统提示、schema 以及任何 few-shot 示例在每次请求中都是字节相同的,只有文档会变化。
在一个管道中,schema 和指令达到了几千个 token,将该块移动到缓存前缀可以大幅降低输入成本,并且随着每次重试,节省会累积,因为修复会重新发送相同的前缀。
修复请求比全新尝试更便宜,这一点值得理解。输入会增长,因为你现在发送的是原始提示加上失败的输出以及错误列表。但输出会大幅减少,因为模型只是纠正少数几个字段,而不是重新生成整个文档。在大多数提供商那里,输出 token 是昂贵的方向,因此这种权衡通常是有利的。
从一开始就值得监测的另外两件事是:验证错误路径的分布,它能准确告诉你哪些 schema 字段在造成问题,且比总的失败率更具可操作性;以及你的本地修复命中率,因为如果 salvage() 修复了大量响应,说明你有一个提示问题,只需解决一次,而无需反复付费。
这种机制在特定情况下才有其用武之地,而在其他情况下则确实是过度设计。
如果模型的输出直接交给人类阅读为散文,那么根本不需要结构化输出。聊天界面、摘要或草拟邮辑都有人担任验证者,而在其前面添加 schema 只会无益地限制模型。
如果你只需要提取一两个字段而不是整个文档,使用一个精准的提示、轻量的 safeParse 以及一次重试就足够了。重试循环、语义验证层和流式解析器是针对规模和 schema 复杂度出现的问题的解决方案。
如果你的数据量很低且人工会逐条审核结果,那么验证层只是在重复别人已经做的工作。直接输出原始结果,让审核者进行修正,并将工程时间用在其他地方。
如果你仍在探索该功能应该是什么样子,请抵制过早构建。一旦提取代码、验证规则和存储数据都依赖于它,schema 就是最昂贵的修改对象。先松散地原型设计,了解你实际需要哪些字段,随后再收紧。
最安全的做法是先使用 schema 和 safeParse,仅在真实故障证明需要时才逐步添加后续层。本文中的每种技术都源自具体的生产事件,而非设计文档。
工作演示与可靠功能之间的差距几乎完全体现在这一层。模型本身不再是难点,提示词通常也不是难点。难点在于决定你能接受什么,检测到未得到预期结果时,并做出合理的响应。
以下三个观点承担了主要的工作:
模式即契约,一切皆源于此。 一个定义同时产出你的类型、API 模式和验证器,意味着这三者永远不会偏离。
区分语法错误、模式错误和语义错误。 它们的原因和解决办法各不相同,若在重试循环中一视同仁,只会在重复调用无法解决的问题上浪费金钱。
让字段可为 null,而非可选。 强制模型说出“此字段未出现”,而不是允许它悄悄省略该字段,可以把一类静默幻觉转化为可显式检查的值。
只要把这些做对,剩下的就是普通的工程工作。你在编写验证代码和重试逻辑,而开发者们已经面对不可靠输入做了几十年同样的事。语言模型只不过是一种新型的不可靠输入,它对同样的纪律反应良好。
Zod: 这些示例中使用的 schema 库。
zod-to-json-schema: 将 Zod schema 转换为 API 所期望的 JSON Schema。
Anthropic tool use documentation: 通过工具定义强制使用结构化参数。
OpenAI structured outputs: 通过受限解码实现严格的 schema 符合。
Outlines: 面向本地托管模型的语法约束生成。
best-effort-json-parser: 在响应仍在流式传输时解析不完整的 JSON。
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。