首页 / 文章 / 如何从LLM中获取可靠的结构化数据
← 返回
IT技术

如何从LLM中获取可靠的结构化数据

✍️ zhirenhun 📅 2026/8/28 👁 121 阅读 ⏱ 38 分钟
如何从LLM中获取可靠的结构化数据

大多数关于调用语言模型的教程在 JSON.parse(response.content) 这一行结束。这一行在你前十个测试用例上能工作。当你发布后,大约在第四百次请求时,模型可能会返回它自己编造的日期,或者在 schema 只允许五个元素时返回八个数组项,或者返回一个看似完全有效但其实少了一个字段的 JSON 对象。

我在构建 Temploracraft 时遇到了这个问题,Temploracraft 是一个简历工具,它接受上传的文档并将其转换为应用程序可以编辑的结构化数据。

输入确实不可预测:双栏 PDF、其实不是表格的表格,以及大约十四种不同的日期格式。输出必须严格,因为每个提取的字段都会填入一个人会查看的表单中。当模型在日期上出错时,用户大约两秒钟就能发现。

本文讨论的是位于“模型返回了一些文本”和“我的应用拥有可信数据”之间的那一层。它介绍了三种约束输出的机制,解释了为什么先设计 schema 比写更长的 prompt 更有效,如何构建不会浪费 token 的重试循环,以及针对任何重试都无法解决的失败该怎么处理。

目录

先决条件

为了轻松跟随,你需要准备好以下几项:

代码示例使用 Zod 进行模式定义,并使用 Anthropic SDK 进行模型调用,但这里的每种技术都可以直接应用于其他验证库和其他提供者。理念比具体的包装更重要。

为什么仅仅提示 JSON 不够

当你需要结构化数据时,第一反应是礼貌地请求。你会写类似 "请返回与此形状匹配的有效 JSON,并且不要包含任何其他文本",然后粘贴一个示例,它就能工作。在开发过程中它一直有效。在你的演示中也有效。

问题在于语言模型基于概率逐个生成 token,而你的指令只是众多影响因素之一。它与模型的训练、输入文档的形状以及模型在前几百个 token 中生成的内容竞争。大多数时候你的指令会起作用,但偶尔也会失效。

以下是我从生产提取管道中实际收集的失败案例,全部来自被明确告知返回严格 JSON 的模型:

请注意,这些并不是同一种失败。代码围栏和截断是语法问题,你通常可以在不再次调用模型的情况下局部修复它们。合并的日期字符串和额外的项目符号是模式问题,此时 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 开始,而非 Prompt

当输出质量不佳时,人们的本能是编写更长的提示。增加示例、加强语气、多用大写字母。这虽然有一点帮助,但难以扩展,因为提示是散文,而散文无法强制执行。

更好的做法是将 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,仅在真实故障证明需要时才逐步添加后续层。本文中的每种技术都源自具体的生产事件,而非设计文档。

总结

工作演示与可靠功能之间的差距几乎完全体现在这一层。模型本身不再是难点,提示词通常也不是难点。难点在于决定你能接受什么,检测到未得到预期结果时,并做出合理的响应。

以下三个观点承担了主要的工作:

  1. 模式即契约,一切皆源于此。 一个定义同时产出你的类型、API 模式和验证器,意味着这三者永远不会偏离。

  2. 区分语法错误、模式错误和语义错误。 它们的原因和解决办法各不相同,若在重试循环中一视同仁,只会在重复调用无法解决的问题上浪费金钱。

  3. 让字段可为 null,而非可选。 强制模型说出“此字段未出现”,而不是允许它悄悄省略该字段,可以把一类静默幻觉转化为可显式检查的值。

只要把这些做对,剩下的就是普通的工程工作。你在编写验证代码和重试逻辑,而开发者们已经面对不可靠输入做了几十年同样的事。语言模型只不过是一种新型的不可靠输入,它对同样的纪律反应良好。

参考文献

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

← 上一篇
多数团队过早转向专用推理
下一篇 →
如何自行基准测试LLM推理:值得信赖的数字设计标准

📌 相关推荐

GraphRAG 是推理问题,而非数据库问题
2026/8/30
构建市场时光机:使用 Python 和 WebSocket 重放交易会话
2026/8/30
如何自行基准测试LLM推理:值得信赖的数字设计标准
2026/8/30
← 返回文章列表