外观
第 12 章 输出工程与格式控制
让模型稳定输出一份代码能直接解析的 JSON,是 LLM 集成工作里最琐碎也最难的一段。这一章从“模型为什么总在 JSON 里加注释”的困境讲起,沿着从软到硬的路线,依次给出 Prefilling、JSON Mode 与约束解码、流式输出下的增量解析、Pydantic 校验加自动修复,以及最后的兜底策略,再用一个财报数据提取的实战把所有防线串成完整的输出工程体系。读完这一章,面对任何格式要求严格的输出场景,都能搭出一套从提示到代码的多层防御,让模型的自由生成和工程要求的确定性各归其位。
12.1 为什么模型总在 JSON 里加注释
先从一个不少工程师都遇到过的场景说起。
某个功能需要和前端紧密协作,前端要一个 API,这个 API 会调用 LLM,从一段非结构化的用户评论中提取出关键信息,并以严格的 JSON 格式返回。双方约定好了 JSON 的 Schema,精确到了每一个字段名和数据类型。
Prompt 可能长这样: “请从以下文本中提取‘产品名称’、‘评分’和‘优点’,并以 JSON 格式输出。”
先在本地满怀信心地测上一轮。第一次,模型返回了:
json
{
"product_name": "降噪豆 Pro",
"rating": 4,
"pros": ["音质好", "降噪效果明显"]
}第一次就成功,看起来这活儿很简单。
再试一次,模型返回了:
好的,这是您要的 JSON 数据:
```json
{
"product_name": "降噪豆 Pro",
"rating": 4,
"pros": ["音质好", "降噪效果明显"]
}
```
```
希望对您有帮助!这次多了前后两句废话,还有那个 Markdown 的代码块标记,Python 代码 json.loads(response) 当场就抛出了一个 JSONDecodeError。于是决定加一个正则表达式,把 json 和 中间的内容提取出来。
加上正则,再试一次。这次,模型返回了:
json
{
"product_name": "降噪豆 Pro", // 这是从文本中提取的产品名
"rating": 4,
"pros": [
"音质好",
"降噪效果明显"
], // 这是一个优点列表
}json.loads 再次崩溃。这次是因为模型在 JSON 里加入了注释,以及最后一个数组元素后面多了一个尾随逗号(trailing comma)。于是再写一段更复杂的预处理代码,试图在解析前,用正则去掉所有的注释和尾随逗号。
眼看准备上线,做最后一次测试,模型却返回了: “抱歉,我分析了一下,这段文本里好像没有明确提到评分,所以我无法为您生成完整的 JSON。” 它直接返回了一段自然语言,而不是一个包含 null 值的 JSON 对象!
到这一步,问题已经很清楚。Prompt 里清清楚楚写着“以 JSON 格式输出”,为什么模型还是不断输出不合规的内容?
这个困境,正是把 LLM 集成到自动化工作流时,最普遍、也最严重的障碍之一,也就是结构化输出的稳定性问题。
原因还得回到 LLM 的“第一性原理”。它的训练目标,是生成流畅、自然、有帮助的人类语言。在它的训练数据里,代码块前后通常都会有解释性的文字;在 JSON 或代码里加上注释,是一种“好”的编程习惯;当遇到无法完成的任务时,解释原因,也是训练数据里常见的回复方式。
让它输出一个纯净的、不带任何附加文字的 JSON,与它从训练数据里学到的模式并不一致。它经常在输出里附加解释和注释,让内容更接近训练数据里常见的自然回复。
而工程师需要的是机器可读性(Machine Readability),而不是人类可读性(Human Readability),需要的是 100% 的确定性和稳定性。这种根本性的矛盾,就是结构化输出问题的根源。
那么,该怎么解决这个问题?指望模型自觉遵守是不行的,必须用一套从“软”到“硬”的约束手段,逐层收紧对输出格式的控制。
12.2 预先写好输出的开头
在祭出那些更“硬核”的技术之前,先来学一个最简单、也常常无人在意的技巧,提示引导(Prefilling),或者叫前缀填充。
这个技巧的原理很简单,既然担心模型会说废话,那就不给它说废话的机会,直接替它把开头说出来。
不过先说一句,这招有明确的适用边界。截至 2026 年 8 月,Claude Opus 4.6 和 Sonnet 4.6 及之后的模型已经不再支持预填 assistant 消息,这类请求会直接报 400 错误。缘由和应对放在本节末尾的时效提醒里,动手之前,先查一眼手头模型的最新文档。
大部分模型的 Chat API,都支持一种多轮对话的格式,通常是 [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}] 这样的列表。
通常的做法,是把 Prompt 放在 user 的 content 里,然后让模型来续写 assistant 的 content。
比如:
python
messages = [
{"role": "user", "content": "请把‘你好’翻译成英文,并以 JSON 格式输出,格式为 {\"translation\": \"...\"}。"}
]
# 然后把 messages 发给 API这种情况下,模型就有很大的“自由度”去决定它的开场白。它可能会先说“好的,没问题”,然后再输出 JSON。
而 Prefilling 技巧,则改变了这个 API 的用法,在 messages 列表的最后,手动地追加一个 assistant 角色的消息,但这个消息的 content 是不完整的。
看这个例子:
python
messages = [
{"role": "user", "content": "请把‘你好’翻译成英文,并以 JSON 格式输出,格式为 {\"translation\": \"...\"}。"},
{"role": "assistant", "content": "{"} # 注意看这里!
]
# 然后把这个包含了“半句话”的 messages 发给 API这里做的事情,是手动地在 assistant 的回复里,填上了 JSON 的起始符号 {。
这时候,模型拿到这个上下文,接下来要生成的内容,只能衔接在 assistant 消息已有的 { 后面。
它再说“好的,没问题”这类开场白的可能性,已经压到极低。因为它续写的内容,必须是跟在 { 后面的。任何不符合 JSON 语法的开头,在这个上文下概率都极低。
换句话说,Prefilling 改变的不是模型本身,而是模型续写时的起始上文。起始符号一旦给定,后面所有内容的概率分布,都围绕它重新展开。
Prefilling 有几种常用的开头写法。对于 JSON 输出,用 { 或者 [ 作为开头;对于 Markdown 代码块,用 ```json 作为开头;对于思维链,用 “好的,让我们一步步来思考:\n第一步:” 作为开头,直接引导它进入思考模式;对于 Few-shot,则是在最后一个示例的 输出: 后面,直接把新任务答案的开头写出来,比如,期望的答案是“正面”,可以直接写一个“正”,让模型来补完那个“面”字。
Prefilling 的优点和局限都很鲜明。它的优点是极其简单,只需要在 API 调用时,多构造一个不完整的 assistant 消息;成本极低,几乎不增加任何额外的 Token 消耗;而且效果显著,解决“在输出前加废话”这类问题立竿见影。
局限也很明显。它治标不治本,只能解决“开头”的问题,但无法阻止模型在 JSON 的“中间”或“结尾”部分犯错(比如加注释、用错数据类型)。它也并非 100% 可靠,对于一些能力较弱或者没有经过专门对齐训练的模型,依然有可能忽略引导,输出别的内容。
所以,务实的第一步,是把 Prefilling 作为格式控制的第一道、也是最基础的一道防线。它不能解决所有问题,但能以最低的成本,过滤掉绝大多数低级的“开场白废话”。
开头提到的那句适用边界,这里展开说说。Prefilling 曾经是各家官方文档专门推荐的特性,但它的适用范围正在收缩。截至 2026 年 8 月,Anthropic 官方文档已声明 Claude Opus 4.6 和 Sonnet 4.6 及之后的模型不再支持预填 assistant 消息,这类请求会直接返回 400 错误,官方给出的替代方案,是把格式指令写进 System Prompt,或者改用本章后面会讲到的结构化输出。所以这一节真正值得带走的,是“用上下文锁住输出开头”这个原理。具体到手头的模型还支不支持,动手之前,查一眼它的最新文档。
12.3 在解码阶段限制可选 Token
Prefilling 这种“软约束”还不够,有时候模型还是会跳出预期的格式。这就需要更硬、也无法绕开的“硬约束”了。
这就引出了本节的主角,也是目前业界解决结构化输出问题最彻底的手段,约束解码(Constrained Decoding)。
这个词听起来非常技术化,但它的原理其实很直观。LLM 生成文本的过程,本质上是在每一步,从一个巨大的词汇表(Vocabulary)中,根据概率分布,选择一个 Token。
正常的、无约束的生成过程里,模型在每一步,理论上可以从词汇表里选择任何一个 Token,只是不同 Token 的概率有高有低。
而约束解码,是在生成的每一步,把不符合语法要求的 Token 的概率直接置零,模型只能从剩下的合法 Token 里选择。
这个约束是怎么定义的呢?通常有两种方式,一种是 JSON Mode,另一种是基于语法的解码(Grammar-based Decoding)。
JSON Mode:最简单易用的官方约束选项
随着业界对结构化输出的需求越来越强烈,各大模型厂商(如 OpenAI、Anthropic、Google)都开始在 API 里内置一个开箱即用的约束解码功能。OpenAI 一开始把它叫做 JSON Mode,后来在 2024 年 8 月的 gpt-4o-2024-08-06 模型中引入了更严格的 Structured Outputs,直接让开发者传一份 JSON Schema,模型不仅保证输出是合法 JSON,还要 100% 符合给定的字段名、类型和嵌套结构。
JSON Mode 的用法最简单,只需要在调用 API 时多加一个参数,比如 response_format={"type": "json_object"}。这个开关并不是 OpenAI 独有,DeepSeek 和通义千问 Qwen(阿里云百炼)的 API 也支持同样的参数。
开启 JSON Mode 之后,情况就变了。模型在生成文本时,一个内置的语法检查器会实时监控它的输出。在每一步,这个检查器都会“过滤”掉所有不可能出现在一个合法 JSON 中的 Token。比如,在生成了一个键(key)"name" 和一个冒号 : 之后,下一个 Token 必须是一个值(比如一个字符串的开头 ",一个数字,或者 {、[),模型如果想在这里生成一个“好”字,这个 Token 的概率就强制归零,根本轮不到它。再比如,当模型生成完一个完整的 JSON 对象,打上了最后一个 } 之后,这个检查器就会强制停止它,让它无法再在后面添加任何“希望对您有帮助”之类的废话。
JSON Mode 的本质,就是模型厂商替开发者做了一个“语法层”的、高效的约束解码实现。 但它只保证语法合法,不保证字段结构。Structured Outputs 才是更进一步的“语义层”约束,把 Pydantic 模型或 Zod Schema 喂给 SDK,服务端会把 Schema 编译成一个上下文无关文法(context-free grammar),再让模型在解码时严格遵守。说白了,JSON Mode 是“必须写得像 JSON”,Structured Outputs 是“必须就是这份 JSON”。
时效备注:JSON Mode 和 Structured Outputs 都是 OpenAI 的官方特性,按官方文档,2024 年 8 月之后的 GPT-4o 全系列以及 GPT-5 家族都默认支持 Structured Outputs 的
strict: true模式;Anthropic 的 Tool Use 走的是另一条路,通过工具的input_schema字段实现类似约束;国内的 DeepSeek、通义千问 Qwen 也都提供了 JSON 输出模式,Qwen 还支持基于 Schema 的结构化输出。模型和接口迭代很快,请以调用时各家官方文档为准。
JSON Mode 的优点很实在。它一键开启,极其方便,不需要任何复杂的 Prompt 设计,只需要加一个 API 参数;可靠性极高,因为它是在解码的最底层进行约束,所以能基本保证输出的是一段语法合法的 JSON,再也不会有注释、尾随逗号,或者前后有废话的问题了;效率也很高,因为是在模型内部实现的,通常比在外部用代码做校验和修复,要高效得多。
缺点也有。它不够灵活,只能保证输出的是一个“语法合法”的 JSON,但不能保证这个 JSON 的“结构”是想要的,比如,想要一个包含 name 和 age 字段的对象,它可能只返回一个包含 name 字段的对象,这同样是一个合法的 JSON。而且它并非所有模型都支持,到 2026 年,主流商业 API 基本都已支持,但一些小众模型,或者自托管的开源模型服务,可能没有现成的开关,接入前要查一下官方文档。
所以,对于绝大多数需要输出 JSON 的场景,JSON Mode 都是首选。它把语法层面的保证交给了 API,开发者不需要再自己做解析前的预处理。
Grammar-based Decoding:用自定义语法规则精确控制输出
但如果 JSON Mode 的控制粒度还不够,不仅想保证输出是 JSON,还想精确地控制 JSON 的每一个字段、每一种数据类型,就需要更强的手段了,也就是基于语法的解码(Grammar-based Decoding)。
这个技术允许自定义一套“语法规则”,然后强制模型在生成时,严格地、一步不差地遵循这套规则。
这套“语法规则”通常是用一种叫 BNF(巴科斯-诺尔范式) 的形式化语言来定义的。这个名字听着唬人,其实就是一套用来描述语言结构的简单规则。
来看一个例子。假设期望模型输出的 JSON,必须、也只能包含一个字符串类型的 name 字段和一个数字类型的 age 字段。
可以定义这样一套 BNF 语法:
bnf
root ::= "{" ws "\"name\"" ws ":" ws string "," ws "\"age\"" ws ":" ws number "}"
string ::= "\"" ([^"]*) "\""
number ::= "-"? ([0-9]+) ("." [0-9]+)?
ws ::= ([ \t\n]*)把这段语法定义翻译成白话。root ::= ... 定义了整个 JSON 的结构,必须以 { 开头,然后是 "name",然后是冒号,然后是一个 string,然后是逗号,然后是 "age",然后是冒号,然后是一个 number,最后以 } 结尾,ws 代表空白符;string ::= ... 定义了字符串的格式;number ::= ... 定义了数字的格式。
然后,把这段 BNF 语法,通过一些支持这个功能的开源库(比如 outlines、guidance、lm-format-enforcer,或自行接入 vLLM / llama.cpp 这类带 grammar 模式的后端),传递给模型。
这条技术路线背后有扎实的研究支撑。上面提到的 outlines 库,出自 Willard 和 Louf 在 2023 年提出的引导式生成方法(Efficient Guided Generation,arXiv:2307.09702);把语法规则编译成高效的 Token 掩码、让约束解码真正能在生产环境里跑得动的代表性工作,则是 Dong 等人 2024 年提出的 XGrammar(arXiv:2411.15100,MLSys 2025)。
接下来,模型在生成时,语法规则把可选范围完全限定住。它生成完 { 和 "name": 之后,解码器会根据 BNF 规则,把下一步可选的 Token 限定为构成字符串的那些字符(比如引号和字母),数字此时不在可选集合里。等它生成完 name 的值和逗号,解码器又会把下一步限定为必须生成 "age" 这几个字,一个都不能错。
基于语法的解码,是对模型生成过程最底层的、最强硬的、也最精确的控制。它能 100% 保证,模型输出的不仅是一个语法合法的 JSON,更是一个结构完全符合预定义 Schema 的 JSON。
它的优点是终极的控制力,可以定义任何想要的输出格式,不只是 JSON,可以是 XML、CSV,甚至可以是自己发明的某种特定格式的日志;还有极高的结构保证,在语法规则覆盖到的字段和类型上,基本可以告别“缺字段”、“类型错误”这些常见问题。
缺点在于实现复杂,需要学习 BNF 语法,并且需要使用特定的开源库来配合,不是所有模型 API 都原生支持;而且可能限制模型的灵活性,规则定得太死,模型就没有变通空间。比如,如果文本里真的没有提到 age,一个没有约束的模型可能会输出 null,但一个 BNF 严格约束的模型,可能会卡住或者报错,因为规则强制它必须生成一个 number。
那么,该如何选择约束解码的策略?如果模型 API 支持,并且只需要一个语法合法的 JSON,那就直接用 JSON Mode,它能解决绝大多数问题;如果需要输出的结构极其严格,或者格式不是 JSON,并且不怕折腾,那就研究一下 Grammar-based Decoding,它能把输出格式控制到字段级别。
12.4 边生成边解析增量数据
前面讲的做法,都是在等模型把完整的 JSON 都生成完了,再去解析。但有时候,模型的生成过程可能会很慢,尤其是当 JSON 很大的时候,可能需要十几秒甚至几十秒。难道只能让用户盯着一个空白的屏幕或者一个不停转圈的加载动画,干等吗?
当然不是。现代的 LLM API 大都支持一种非常重要的特性,流式输出(Streaming)。
它的原理是分块返回,收到一部分就处理一部分,不等全部生成完毕。
具体来说,API 不会等到所有 Token 都生成完了,才一次性返回结果。而是每生成一个或几个 Token,就立刻把这个“增量(Delta)”推送过来。
这对用户体验的提升是明显的。用户可以看到文字逐个出现,能感知到“AI 正在生成”,而不是面对一个长时间无变化的页面。
但是,当流式输出和想要的 JSON 格式结合在一起时,一个新的、非常棘手的工程问题就出现了。手头拿到的是一堆 JSON 的“碎片”,该如何处理一个不完整的 JSON 呢?
比如,API 可能先推送 {"name":,然后是 "Jo",然后是 "hn",然后是 ", "a"…… 如果在拿到第一个碎片时,就直接去 json.loads,那肯定会报错。
处理流式 JSON,需要在客户端或后端,实现一个“增量解析器(Incremental Parser)”。
一个简化的处理逻辑可能是这样的。先初始化一个空字符串缓冲区 buffer = "",然后监听 API 推送的每一个数据块(Delta),将每一个 Delta 追加到缓冲区,也就是 buffer += delta,并在每次追加后,尝试进行一次“试探性”的解析:
python
try:
partial_json = json.loads(buffer)
# 如果成功了,说明到目前为止,我们已经有了一个或多个完整的键值对
# 你可以在这里,就开始更新你的前端 UI 了
update_ui(partial_json)
except json.JSONDecodeError:
# 如果解析失败,说明 JSON 还不完整,这很正常
# 什么都不做,继续等待下一个 Delta
pass这个逻辑看起来简单,但实际上充满了各种边界情况。比如,一个字符串的值可能会拆分在多个 Delta 里。
因此,在生产环境中,强烈建议不要自己从零开始写这个解析器,而是使用一些专门为处理流式 JSON 而设计的、成熟的开源库(比如 Python 生态里的 ijson、partial-json)。这些库通常实现了一个更健壮的、基于状态机的解析器,能够正确处理各种不完整的 JSON 状态。
流式处理的真正价值,不仅仅在于提升前端的用户体验。对于后端来说,它也带来了一个巨大的好处,可以提前对已经接收到的、完整的部分数据进行处理。
比如,模型正在生成一个包含 100 个元素的 JSON 数组。当流式地接收到第一个完整的元素时,就可以立刻把它扔到后台的消息队列里,让下游的服务去处理了,而不需要等到全部 100 个元素都生成完毕。
这种流水线式(Pipelining) 的处理方式,可以极大地降低整个系统的端到端延迟,提升吞吐量。
12.5 用代码校验并修复模型输出
到这里,各种 Prompt 技巧和解码约束都已经用上,试图让模型输出一个完美的 JSON。但必须面对一个现实,百密必有一疏。
总有那么千分之一、万分之一的可能性,模型还是会输出一个格式有问题的 JSON。可能是多了一个逗号,可能是字符串没闭合,可能是数据类型不对……
当这种情况发生时,json.loads 就会无情地抛出异常。这时候该怎么办?直接给用户返回一个“500 服务器内部错误”吗?这显然是无法接受的。
一个鲁棒的系统,必须有兜底处理的能力。这就是解析器协同(Parser Collaboration)思想发挥作用的地方。
它的核心思想很直接,不要把解析和业务逻辑混在一起。用一个专门的、健壮的解析器,隔离在模型输出和业务代码之间,负责格式和类型的校验与转换。
在 Python 生态里,首选 Pydantic 这个库。在 JavaScript/TypeScript 生态里,Zod 是不二之选。
以 Pydantic 为例。Pydantic 允许用非常简洁的 Python 类,来定义期望的数据结构(Schema)。
python
from pydantic import BaseModel, Field
from typing import List
class ExtractedInfo(BaseModel):
product_name: str = Field(description="从文本中提取的产品名")
rating: int = Field(description="用户评分,必须是 1-5 之间的整数", ge=1, le=5)
pros: List[str] = Field(description="优点列表")现在,当代码从 LLM API 接收到一段可能是 JSON 的字符串 response_text 后,不要直接用 json.loads。正确的做法是:
python
import json
from pydantic import ValidationError
try:
# 1. 先用标准的 JSON 解析器尝试解析
data = json.loads(response_text)
# 2. 用 Pydantic 模型进行严格的结构和类型校验
parsed_info = ExtractedInfo.model_validate(data)
# 如果上面两步都成功了,你就可以放心地使用 parsed_info 了
# 它的每一个字段、每一种类型,都得到了保证
print(f"产品名: {parsed_info.product_name}")
except json.JSONDecodeError as e:
print(f"JSON 语法错误: {e}")
# 在这里,触发我们的自动修复逻辑
handle_json_syntax_error(response_text)
except ValidationError as e:
print(f"JSON 结构或类型错误: {e}")
# 在这里,触发我们的自动修复逻辑
handle_pydantic_validation_error(response_text, e)这个 try-except 结构建立起了两道防线。
更进一步,当 except 触发时,不是简单地记个日志就完事了,还可以实现一个自动修复(Auto-Fixing) 的循环。
当 Pydantic 抛出 ValidationError 时,它的异常对象 e 里,包含了极其丰富的、结构化的错误信息。它会明确指出,是哪个字段错了,错误的原因是什么(比如“类型应该是整数,但你给的是字符串”)。
可以把这个原始的、错误的 LLM 输出,连同 Pydantic 提供的精确的错误信息,一起打包,再重新发给 LLM,并给它一个“修复 Prompt”:
markdown
# 角色
你是一个 JSON 格式修复助手。
# 任务
你之前生成了一个 JSON,但它未能通过 Pydantic 的数据校验。请根据提供的错误信息,修复这个 JSON,并返回一个严格符合要求的、纯净的 JSON 对象。
# 原始的、错误的 JSON
{
"product_name": "降噪豆 Pro",
"rating": "4.5", // 错误在这里
"pros": ["音质好"]
}
# Pydantic 校验错误信息
`"Validation Error: rating - Input should be a valid integer"`
# 修复要求
请修复上述 JSON 中的错误,并只输出修正后的 JSON。模型收到这个 Prompt 后,通常会按错误信息修正字段类型,输出一个修正后的、正确的 JSON。
这个“尝试解析 -> 捕获错误 -> 调用修复 Prompt -> 再次尝试解析”的循环,就是解析器协同的精髓。
它让系统能够自动处理解析失败。它承认“LLM 总是可能犯错”这个基本事实,然后用一个工程化的、闭环的流程处理这些错误,而不是把问题抛给用户。
12.6 当一切都失败时该怎么办
到这里,层层防线已经构建起来,Prefilling、JSON Mode、Pydantic 校验、自动修复循环……
但作为一个有经验的工程师,必须时刻追问一个问题:“如果,连兜底之前的所有修复手段,都失败了呢?”
如果那个自动修复循环,尝试了两次、三次,模型还是返回一个错误的 JSON,该怎么办?让程序陷入死循环吗?
绝对不行。一个生产级的系统,必须有最终的兜底策略(Fallback Strategy)。
兜底策略,是系统的最后一道防线。所有主动的修复手段都失败后,靠它保证系统不崩溃,不给用户返回一个无法处理的错误。
兜底策略可以有几种不同的做法。
一种做法是正则抽取,不求完美、但求可用。如果目标只是从那段乱七八糟的、无法解析为 JSON 的字符串里,抢救出几个关键的值,那么,正则表达式正是处理这个问题的可靠工具。
比如,即使模型返回的是 “哦,这个产品的评分好像是 4 星”,用一个简单的正则 r'评分.*?(\d)',大概率能把那个数字 4 给“抠”出来。
这是一种“优雅降级”。结构化信息可能丢了一些,但最核心的数据保住了。这对于一些数据展示类的场景,可能已经足够了。
另一种做法是返回固定错误信息,诚实地承认失败。如果系统对数据的完整性和结构性要求极高,任何不完整的、通过正则“抢救”出来的数据都是不可接受的,那么,最负责任的做法,就是诚实地向调用方(或者用户)承认失败。
在 try-except 块的最后,加上一个 finally 或者一个重试次数耗尽的判断,然后返回一个预先定义好的、标准的错误 JSON。
json
{
"error": {
"code": "LLM_OUTPUT_PARSING_FAILED",
"message": "抱歉,AI助手返回的内容暂时无法解析,我们正在紧急处理。请您稍后再试。"
}
}这比直接抛出一个 500 错误,或者返回一个空内容,要好得多。它给了前端或调用方一个明确的、可处理的信号,让它们可以据此向用户展示一个友好的提示信息。
还有一种做法是触发人工干预,把问题交给人类。在一些高价值的、不容有失的场景(比如处理一笔重要的订单,或者生成一份法律文件),如果自动化流程彻底失败,最好的兜底策略,可能就是触发人工干预。
代码可以在捕获到最终的、无法修复的解析错误后,自动地将这个有问题的请求,连同模型的原始输出,一起存入数据库,并标记为“待处理”,同时通过钉钉、企业微信、飞书或者邮件,向一个专门负责处理异常的人工运营团队,发送一条报警信息。
这样,虽然这个特定的请求失败了,但系统不会把问题“吞掉”,它最终会得到一位人类专家的妥善处理。
选择哪种兜底策略,取决于业务对“可用性”和“一致性”的权衡。但无论如何,必须有一个兜底策略。一个没有 Fallback 的 AI 系统,无论其他部分多完善,都存在整体不可用的风险。
12.7 从杂乱财务报表提取标准 JSON 数据
理论和工具都讲完了。最后把这一章的所有手段都用上,解决一个极其常见、但也极其棘手的实战问题,从一段格式混乱的、非结构化的财务报表文本中,提取出标准的 JSON 数据。
先看痛点场景。设想一家公司的财务部门,每个月都会收到来自不同子公司的各种财报。这些财报可能是 PDF、Word,甚至是邮件正文。它们的格式五花八门,有的用中文,有的用英文,有的带单位,有的不带……财务人员需要手动地把里面的关键数字(比如“营业收入”、“净利润”、“毛利率”)一个个地抠出来,填到 Excel 表里,过程痛苦不堪,还容易出错。
摆在面前的任务,就是开发一个 AI 工具,把这个过程自动化。
原始的、混乱的输入文本可能长这样:
“2023 年第四季度业绩快报:本季度,在宏观经济面临挑战的背景下,本公司实现营业收入共计 1,550.5 百万元人民币,较去年同期增长 15.2%。归属于母公司股东的净利润为 210.8 百万元,毛利率稳定在 55.3 个百分点。研发投入持续加大,占营收比 12%。”
期望的、干净的输出 JSON 长这样:
json
{
"revenue": 1550500000,
"net_profit": 210800000,
"gross_margin": 0.553,
"rd_expense_ratio": 0.12
}注意看,这里面有几个难点。“百万元”需要转换成实际的数字,“55.3 个百分点”需要转换成小数 0.553,字段名需要统一成标准的英文(revenue、net_profit...)。
现在,来设计一套完整的 Prompt 和处理流程。
第一步,设计一个带有“终极约束”的 Prompt:
markdown
# 角色
你是一个顶级金融数据分析师,同时也是一个对 JSON 格式有强迫症的 Python 程序员。
# 核心任务
从提供的财务报表文本中,提取出关键的财务指标,并严格按照指定的 JSON Schema 输出。
# JSON Schema 与转换规则
你必须输出一个严格符合以下结构和规则的 JSON 对象。
{
"revenue": "number",
// 营业收入。必须是数字类型。注意:你需要将文本中的“百万元”、“亿元”等单位,换算成实际的数字。例如,“15.5 亿元”应转换为 1550000000。
"net_profit": "number",
// 净利润。必须是数字类型。同样需要进行单位换算。
"gross_margin": "number",
// 毛利率。必须是 0 到 1 之间的小数。注意:你需要将文本中的百分比(如“55.3%”或“55.3 个百分点”)转换为小数(如 0.553)。
"rd_expense_ratio": "number"
// 研发费用率。必须是 0 到 1 之间的小数。同样需要进行百分比到小数的转换。
}
# 兜底规则
- 如果文本中没有明确提及某个指标,该字段的值必须为 `null`,严禁猜测或估算。
# 输出约束
- 你的输出必须是一个纯净的、语法合法的 JSON 对象。
- 严禁包含任何注释、Markdown 标记或解释性文字。第二步,在代码中实现一个健壮的调用与解析流程:
python
from pydantic import BaseModel, Field, ValidationError
import json
import re
# 1. 定义 Pydantic 模型,作为最终的“真理标准”
class FinancialData(BaseModel):
revenue: float | None
net_profit: float | None
gross_margin: float | None = Field(ge=0, le=1)
rd_expense_ratio: float | None = Field(ge=0, le=1)
# 2. 你的主处理函数
def extract_financial_data(report_text: str, max_retries: int = 2) -> FinancialData | None:
prompt = build_extraction_prompt(report_text) # 这是你根据上一节模板构建 Prompt 的函数
for attempt in range(max_retries):
try:
# 3. 调用 LLM API,强烈建议开启 JSON Mode
raw_response = call_llm_api(
prompt,
response_format={"type": "json_object"} # 开启 JSON Mode!
)
# 4. 使用 Pydantic 进行严格校验
data = FinancialData.model_validate_json(raw_response)
return data # 成功!返回结构化数据对象
except (json.JSONDecodeError, ValidationError) as e:
print(f"第 {attempt + 1} 次尝试失败: {e}")
# 5. 如果失败,进入自动修复逻辑:用错误信息重写 Prompt,把原始坏数据 + 错误原因一起发给模型,让它改正
if attempt < max_retries - 1:
prompt = build_fix_prompt(report_text, raw_response, str(e))
# 下一轮循环会用新的 prompt 重新调用 LLM
continue
else:
# 6. 多次尝试后依然失败,触发最终的兜底策略
print("自动修复失败,启动兜底策略...")
return fallback_strategy(raw_response)
return None
# 7. 兜底策略函数
def fallback_strategy(raw_text: str) -> FinancialData | None:
# 尝试用正则表达式做最后的“抢救”
try:
revenue_match = re.search(r'营业收入.*?([\d,.]+)\s*(百万元|亿元)', raw_text)
# ... 此处省略复杂的正则逻辑 ...
# 如果能抢救出部分数据,就返回一个不完整的 Pydantic 对象
# 如果连正则都失败了,就返回 None 或记录错误
# 下一行的两个 ... 是省略号示意,代表字段值由上面省略的正则逻辑给出,不是可直接运行的代码
return FinancialData(revenue=..., net_profit=...)
except:
return None这一整套解决方案,就是一个多层次的纵深防御体系。第一层是 Prompt 层,用清晰的伪 Schema和转换规则,对模型进行了“软约束”;第二层是 API 层,开启了 JSON Mode,在语法层对输出做硬约束;第三层是代码校验层,用 Pydantic 作为最终的校验标准,对模型的输出进行严格的、基于业务逻辑的校验;第四层是修复与兜底层,设计了自动修复的循环和正则抽取的最后防线,保证了系统在最坏的情况下,也能平稳地降级,而不是直接崩溃。
只有构建了这样一套从 Prompt 到代码的、完整的、端到端的“输出工程”体系,在面对那些充满了不确定性的、混乱的真实世界数据时,才能把解析失败的概率压到足够低,让系统保持可用。
本章总结 把不可控的输出变成可靠的接口
这一章深入到了 LLM 应用开发中最琐碎、最费力,但也最能体现一个工程师价值的部分,输出工程与格式控制。
从“为什么模型总是在 JSON 里加注释”这个问题开始,这一章把背后的根本矛盾讲透了,模型从训练数据里学到的自然语言习惯,与工程师追求的“机器可读性”,本来就是对立的两端。
为了解决这个矛盾,这一章装备了一整套从“软”到“硬”的约束手段。用提示引导(Prefilling) 预先写好输出的开头,堵住模型说废话的路;用约束解码(Constrained Decoding),无论是一键开启的 JSON Mode,还是拥有极致控制力的基于语法的解码(Grammar-based Decoding),都在解码的最底层限制模型只能生成符合语法的内容。在流式输出(Streaming) 的场景下,用增量解析的思路,边生成边解析已经完整的部分。再以 Pydantic / Zod 这类工具,构建起解析器协同与自动修复的闭环,让系统能自动处理解析失败。最后,兜底策略(Fallback) 保证在最坏的情况下,系统也能平稳地降级,而不是直接崩溃。
学完这一章,处理模型输出的方式,会发生一次转变。
在最开始,让模型输出稳定的结构化数据,感觉像是能力与可控性只能二选一,用它的自然语言能力,就得忍受输出格式不可预测。但现在,手头有了从提示到解码再到代码校验的一整套手段,不再是被动忍受,而是给模型的输出立下了明确的格式要求,以及不达标时的处理办法。
分工的边界也很清楚,模型决定生成什么内容,工程决定输出成什么格式,格式不合格就有修复和兜底来处理。
这种把不稳定的文本输出约束成可靠接口的工程能力,正是工程师在 AI 应用开发中最核心、也最不可替代的价值之一。
参考资料
- OpenAI(2024-08-06). Introducing Structured Outputs in the API. https://openai.com/index/introducing-structured-outputs-in-the-api/
- OpenAI. Structured Outputs(官方文档). https://platform.openai.com/docs/guides/structured-outputs
- DeepSeek. JSON Output(官方文档). https://api-docs.deepseek.com/guides/json_mode/
- 阿里云百炼. 如何让通义千问生成JSON字符串(官方文档). https://help.aliyun.com/zh/model-studio/json-mode
- noamgat. lm-format-enforcer(GitHub 仓库). https://github.com/noamgat/lm-format-enforcer
- vLLM. Structured Outputs(官方文档). https://docs.vllm.ai/en/v0.6.6.post1/usage/structured_outputs.html
- ggml-org. llama.cpp grammars/README,GBNF(GitHub 仓库). https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md
- Anthropic. Migrating to Claude 4.6(官方迁移指南,预填 assistant 消息返回 400). https://platform.claude.com/docs/en/about-claude/models/migration-guide
- Willard, B. T. & Louf, R. (2023). Efficient Guided Generation for Large Language Models. arXiv:2307.09702(开源库 Outlines 的论文). https://arxiv.org/abs/2307.09702
- Dong, Y. et al. (2024). XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models. MLSys 2025. arXiv:2411.15100. https://arxiv.org/abs/2411.15100