@freeCodeCamp: 获取你的应用程序真正能信任的结构化数据可能很棘手。在这篇教程中,Vineeth解释了如何设计模式…

X AI KOLs Timeline 新闻

摘要

这篇来自freeCodeCamp的教程解释了如何设计模式、验证输出,并处理故障,以从LLMs中可靠地提取结构化数据,涵盖了约束输出、重试循环和流式处理等技术。

获取你的应用程序真正能信任的结构化数据可能很棘手。 在这篇教程中,Vineeth解释了如何设计模式、验证输出,并分别处理语法、模式和语义故障。 你还将学习工具调用、约束输出、更智能的重试、流式处理,以及何时根本不重试。 https://freecodecamp.org/news/how-to-get-reliable-structured-data-out-of-an-llm/…
查看原文
查看缓存全文

缓存时间: 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、并非真正表格的表格,以及大约以十四种不同格式书写的日期。输出必须是严格的,因为提取出的每个字段都将进入一个供人查看的表单。当模型搞错一个日期时,用户大约两秒钟内就会注意到。

本文讨论的是介于“模型返回了一些文本”和“我的应用程序拥有了它能信赖的数据”之间的那一层。它涵盖了三种约束输出的方法,为什么先设计模式比写更长的提示更好,如何构建一个不会烧钱的重试循环,以及对于那些无论重试多少次都无法解决的故障该怎么办。

目录

前提条件

为了顺畅地学习,你需要准备好以下条件:

  • 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");
  // ... 构建包含错误详情的修复提示并调用模型 ...
}

相似文章