一、结构化输出为什么是 LLM 工程化的关键环节
大语言模型(LLM)的底层机制是逐 token 的自回归预测。给它一段文本,它通过循环预测"下一个最可能的 token",最终拼出一段自然语言。这种机制在对话、创作、翻译等场景中表现出色,但一旦进入生产环境的自动化流水线,问题就来了------下游程序需要的是格式固定、可直接解析的结构化数据,而不是风格各异的散文。
举个例子。假设你正在做一个智能客服系统,需要从用户提问中提取"意图、参数、实体"三类信息。用户说"帮我查一下上周三到上周五的订单,金额超过五百块的",理想输出是:
{
"intent": "query_order",
"slots": {
"start_date": "2026-06-24",
"end_date": "2026-06-26",
"min_amount": 500.0
}
}
但如果没有结构化约束,模型可能输出十几种不同的格式------有的用 Markdown 代码块包裹,有的用中文当字段名,有的先写一段解释再把数据藏中间,还有的在 JSON 结尾加一句"希望回答对你有帮助"直接破坏格式。下游的 json.loads() 面对这些变体,大概率直接抛异常。
这个问题的根源在于:LLM 的训练目标是降低下一个 token 的预测损失,它没有被专门训练过"严格遵循输出格式"这个能力。因此,结构化输出不是"要不要"的选项,而是"怎么做到位"的工程挑战。
结构化输出带来的核心收益有三个:第一是可编程性 ------程序可以直接消费 JSON 输出,无需人工介入;第二是可校验性 ------有了 Schema 定义就能在运行时自动化检查;第三是可观测性------统一的输出格式方便追踪抽取质量、统计字段填充率。
本文将从零构建一套完整的 LLM 结构化输出引擎,涵盖五个核心环节:Schema 定义(用什么约束)、Prompt 构建(怎么给模型下指令)、宽容解析(格式错了怎么修)、类型校验(内容对不对)和错误重试(失败了怎么办)。全部使用 Python 标准库实现,核心代码控制在 400 行左右。
二、引擎的整体架构设计
动手写代码前先把架构想清楚。一个好的结构化输出引擎应该由四个职责清晰的模块组成,每个模块各司其职,形成一条完整的处理流水线。
2.1 四层架构
第一层是 Schema 定义层。这是整个引擎的契约基础。我们选择 JSON Schema 来描述输出结构,因为它本身就是为描述 JSON 数据而生的,表达能力足够强,生态也成熟。你也可以用 Pydantic 模型来定义,底层最终也能转换成 JSON Schema。
第二层是 Prompt 构建层。有了 Schema 定义之后,需要把它翻译成 LLM 能理解的指令。直接把 JSON Schema 对象丢给模型不一定管用------虽然有些新模型能看懂 Schema 格式,但为了生产环境的稳定性,我们应该做最保守的假设,提供清晰的人类可读指令文本。这个模块负责递归展开 Schema 结构,生成包含字段名称、类型、约束、枚举值和嵌套结构的完整指令。
第三层是解析与修复层。哪怕指令再清晰,LLM 偶尔还会"发挥失常"------多了一个逗号、用了单引号、在 JSON 外写了多余文字。这些错误虽然小,但足以让 json.loads() 直接抛异常。这个模块需要提供一个宽容的解析器,能自动修复最常见的格式问题。
第四层是校验与重试层。JSON 解析成功不等于内容合规。校验器需要递归检查每个字段的值是否满足 Schema 约束------类型对不对、枚举值合不合法、数字在不在范围内。校验失败时,引擎自动构造错误反馈信息,让模型在下一轮重试时"吸取教训"。
2.2 关键设计决策
为什么选 JSON Schema 而不是 Pydantic?JSON Schema 是语言无关的,可以直接序列化为纯文本嵌入 Prompt。Pydantic 需要用 Python 类表达类型信息,需要额外步骤转换。但我们会在引擎中提供一个兼容层,支持从 Pydantic 模型自动推导 JSON Schema。
为什么需要宽容解析器?理想情况下模型每次都能输出完美 JSON。但现实数据显示,即使是最先进的模型,在复杂 Schema 下第一次输出的非法 JSON 比例仍有 10% 到 20%。宽容解析器可以把这个比例降到接近零。
为什么需要重试?因为有些错误不是格式问题,而是内容问题------模型输出了一个合法 JSON,但 rating 被写成 6(超出 1 到 5 的范围),或者枚举字段的值不在列表中。这种语义层面的错误必须让模型意识到错在哪里后重新生成。
2.3 模块接口约定
每个模块之间通过清晰的接口解耦。Schema 输入用 Python dict 格式的 JSON Schema 对象。Prompt 输出是 List[Dict] 格式的 messages,兼容 OpenAI API。解析模块输入原始字符串,输出 Python dict 或抛出异常。校验模块接收 Python dict 和 Schema,返回 (bool, List[str]) 元组。这样每个模块都可以独立演进,方便单独测试和调试。
三、JSON Schema 定义的核心技巧
JSON Schema 是引擎的契约基础,它的质量直接决定了结构化输出的可靠性。好的 Schema 不只是列出"有哪些字段",而是要精确地告诉模型每个字段的边界在哪里。
3.1 Schema 基础结构
一个完整的 JSON Schema 包含几个关键部分。首先是 type 指明根节点类型,最常用的是 "object"。其次是 properties 定义每个字段,每个字段有自己的类型和约束。最后是 required 数组列出哪些字段必填。
SCHEMA_REVIEW = {
"type": "object",
"properties": {
"product_name": {
"type": "string",
"description": "商品名称",
"minLength": 1
},
"rating": {
"type": "integer",
"minimum": 1,
"maximum": 5,
"description": "评分"
},
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral", "mixed"],
"description": "情感倾向"
},
"pros": {
"type": "array",
"items": {"type": "string"},
"minItems": 0,
"maxItems": 10,
"description": "优点列表"
},
"recommend": {
"type": "boolean",
"description": "是否推荐购买"
}
},
"required": ["product_name", "rating", "sentiment"]
}
注意几个设计细节:product_name 用了 minLength: 1 防止模型返回空字符串;rating 用 minimum 和 maximum 约束评分范围;sentiment 用 enum 限定四个情感标签,消除"positive/正向/好评"之类的写法歧义;pros 用 maxItems 限制列表长度。
3.2 可选字段与默认值
可选字段的正确做法是不放进 required 数组,但仍然保留在 properties 中。这样模型知道字段的存在,但不会因为没有生成而被校验拦截。对于默认值,直接在 description 中写明:"如果无法判断则默认使用 zh"。
需要注意的是,description 字段虽然是可选的,但在实践中强烈建议为每个字段都写 description。数据显示,带 description 的 Schema 比不带的首抽成功率高出 10 到 15 个百分点。description 的质量也很关键------"情感倾向"比"情感"精确,"1 到 5 的整数值"比"评分"明确。
3.3 枚举约束的妙用
枚举是结构化输出中使用最频繁的约束之一。它的核心价值是消除"同一个意思不同写法"带来的歧义。没有枚举时,十个模型可能输出十个不一样的情感值。有了枚举后,输出被死死限定在指定集合内,下游代码不需要做任何映射转换。
枚举的另一种常见用法是配合"兜底值"。比如情感判断中加一个 "unknown" 选项,当模型确实无法判断时不至于胡乱输出。这个兜底值应该在 description 中明确说明使用场景。
3.4 跨字段语义约束
JSON Schema 本身不支持跨字段约束(如"如果 rating 小于 3,sentiment 不能是 positive")。对于这类需求,我们的方案是在校验层做自定义业务校验。具体做法是预留一个 custom_validator 钩子函数,接收完整的解析结果,返回自定义错误列表。这些错误会跟标准校验错误合并后一起反馈给重试机制。
四、Prompt 构建层详解
有了 Schema 之后,下面这个步骤至关重要:把它翻译成 LLM 能准确理解的指令。这不是简单的字符串拼接,而是需要根据 Schema 的复杂度和模型的能力做针对性优化。
4.1 递归展开算法
我们的核心策略是递归展开------把嵌套的 JSON Schema 层层打开,每层增加缩进,让层级关系一目了然。
对于标量类型(string、number、boolean),直接列出名称、类型和约束。对于数组类型,除了元素类型还要给出数量范围。对于对象类型,递归展开子属性。无论 Schema 嵌套多深,生成的指令文本都能清晰地反映数据结构。
def schema_to_instructions(schema: dict, indent: int = 0) -> str:
"""将 JSON Schema 递归转换为人类可读指令"""
prefix = " " * indent
lines = []
schema_type = schema.get("type", "any")
if schema_type == "object":
lines.append(f"{prefix}返回 JSON 对象,包含:")
for name, prop in schema.get("properties", {}).items():
req = "必填" if name in schema.get("required", []) else "可选"
desc = prop.get("description", "")
lines.append(f"{prefix} - {name}({req},{prop.get('type')}):{desc}")
if "enum" in prop:
vals = "、".join(f"「{v}」" for v in prop["enum"])
lines.append(f"{prefix} 只能取以下值:{vals}")
if "minimum" in prop or "maximum" in prop:
r = []
if "minimum" in prop: r.append(f"最小={prop['minimum']}")
if "maximum" in prop: r.append(f"最大={prop['maximum']}")
lines.append(f"{prefix} 范围约束:{','.join(r)}")
if prop.get("type") == "array":
items = prop.get("items", {})
lines.append(f"{prefix} 数组元素类型:{items.get('type')},数量:{prop.get('minItems', 0)}-{prop.get('maxItems', '不限')}")
if items.get("type") == "object":
lines.append(schema_to_instructions(items, indent + 2))
elif prop.get("type") == "object":
lines.append(schema_to_instructions(prop, indent + 2))
elif schema_type == "array":
lines.append(f"{prefix}返回 JSON 数组")
else:
lines.append(f"{prefix}返回 {schema_type} 类型值")
return "\n".join(lines)
4.2 系统提示模板设计
系统提示的构成可以细分为几个层次。第一层是角色定义------告诉模型它是一个"结构化输出助手"。第二层是格式要求------列出具体的输出规则,包括不能加代码块、不能有解释文字、JSON 必须合法等。第三层是 Schema 指令------把展开后的字段列表贴进去。第四层(可选)是输出示例。
实验表明,系统提示中明确写"不要 Markdown 代码块"就能让代码块包裹率从 30% 降到 5% 以下。写"只输出 JSON"能减少模型输出冗余文字的概率。
4.3 输出示例的选择策略
加入 1 到 2 个 few-shot 示例是提升首次成功率最有效的手段。示例不仅示范格式,更关键的是降低模型的不确定性------让模型直接看到"最终输出应该长这样"。
选择示例时有几个要点:示例必须覆盖 Schema 中所有字段;示例的值要真实、有细节感,不要用占位符;如果 Schema 有枚举字段,示例应该展示不同枚举值的用法。
需要注意的是示例的副作用------模型可能在填充字段值时"模仿"示例的数据模式。比如示例中的 rating 是 4,模型可能会在类似场景下倾向于输出 4。一个缓解办法是提供多个示例展示不同的值分布。
五、宽容解析器的实现细节
宽容解析器是整个引擎中最重要的"兜底"模块。它负责应对 LLM 输出的各种"意外状况",把不规范的文本还原成合法 JSON。
五类常见格式问题
根据对数百次实际失败的统计,LLM 输出的格式问题可以归纳为五类。
第一类是包裹问题,大约占 40%。模型喜欢把 JSON 包在 Markdown 代码块中,或者前后夹杂解释性文字。解决方案是用正则提取代码块内容,再用括号匹配算法从剩余文本中定位 JSON 结构。
第二类是引号问题,大约占 30%。包括字段名用单引号、字符串值用单引号、键名完全缺少引号等。解决方案是实现一个状态机,只在字符串外部才将单引号替换为双引号。
第三类是逗号问题,大约占 15%。主要是尾随逗号------数组或对象的最后一个元素后面多了一个逗号。这在 JavaScript 中合法但在 JSON 中非法。用正则 ,\s*[}\]}] 匹配即可修复。
第四类是注释问题,大约占 10%。部分模型会在 JSON 中嵌入 // 或 /* */ 注释。逐行删除 // 注释,用正则删除块注释即可。
第五类是其他问题,约占 5%,包括布尔值大小写不正确、数字格式异常等。
括号匹配提取算法
提取 JSON 的核心挑战是:当文本中有多个 {...} 或 [...] 结构时,哪个才是真正的 JSON?我们的算法是:从前往后遍历,找到第一个左大括号或左方括号。从那个位置开始维护一个深度计数器,并正确跟踪字符串边界(遇到双引号进入字符串模式,字符串内的括号不计数)。当深度归零时,就找到了一个完整的 JSON 结构。
这个算法虽然简单,但在各种边界条件下都表现得非常鲁棒。即使模型在 JSON 前面写了一长段解释,算法也能自动跳过,直接从第一个大括号开始匹配。
def extract_json(text: str) -> str:
"""从文本中提取完整 JSON 结构"""
text = text.strip()
# 先尝试代码块
m = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
if m:
return m.group(1).strip()
# 括号匹配
for start_ch, end_ch in [("{", "}"), ("[", "]")]:
start = text.find(start_ch)
if start < 0:
continue
depth, in_str, escape = 0, False, False
for i in range(start, len(text)):
ch = text[i]
if escape: escape = False; continue
if ch == "\\": escape = True; continue
if ch == '"': in_str = not in_str; continue
if in_str: continue
if ch == start_ch: depth += 1
elif ch == end_ch:
depth -= 1
if depth == 0:
return text[start:i+1]
break
raise ValueError("无法提取 JSON")
六、Schema 校验与错误反馈
JSON 解析成功不代表万事大吉。解析器只保证了语法合法,内容的业务合规性需要专门的校验层来把关。
递归校验算法
校验器的核心是一个递归函数,按照优先级依次检查:枚举约束、类型约束、值约束、子结构约束。每条错误信息都携带 JSON Path 格式的位置标记,例如 $.parameters[0].type 表示"第一个参数的类型字段"。
def validate_value(value, schema, path="$"):
"""递归校验值是否符合 Schema"""
errors = []
# 1. 枚举校验(优先级最高)
if "enum" in schema and value not in schema["enum"]:
errors.append(f"{path}:值不在枚举中 {schema['enum']}")
return errors
# 2. 类型校验
stype = schema.get("type")
if stype and stype != "any":
ok = {
"string": lambda v: isinstance(v, str),
"number": lambda v: isinstance(v, (int, float)),
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
"boolean": lambda v: isinstance(v, bool),
"array": lambda v: isinstance(v, list),
"object": lambda v: isinstance(v, dict),
"null": lambda v: v is None,
}.get(stype, lambda v: True)(value)
if not ok:
errors.append(f"{path}:期望类型 {stype},实际 {type(value).__name__}")
return errors
# 3. 值约束
if isinstance(value, str):
if "minLength" in schema and len(value) < schema["minLength"]:
errors.append(f"{path}:长度 {len(value)} < {schema['minLength']}")
if "maxLength" in schema and len(value) > schema["maxLength"]:
errors.append(f"{path}:长度 {len(value)} > {schema['maxLength']}")
elif isinstance(value, (int, float)):
if "minimum" in schema and value < schema["minimum"]:
errors.append(f"{path}:{value} < {schema['minimum']}")
if "maximum" in schema and value > schema["maximum"]:
errors.append(f"{path}:{value} > {schema['maximum']}")
elif isinstance(value, list):
if "minItems" in schema and len(value) < schema["minItems"]:
errors.append(f"{path}:元素数 {len(value)} < {schema['minItems']}")
if "maxItems" in schema and len(value) > schema["maxItems"]:
errors.append(f"{path}:元素数 {len(value)} > {schema['maxItems']}")
item_schema = schema.get("items", {})
for i, item in enumerate(value):
errors.extend(validate_value(item, item_schema, f"{path}[{i}]"))
elif isinstance(value, dict):
props = schema.get("properties", {})
for f in schema.get("required", []):
if f not in value:
errors.append(f"{path}:缺少必填字段「{f}」")
for k, v in value.items():
if k in props:
errors.extend(validate_value(v, props[k], f"{path}.{k}"))
return errors
错误反馈的格式化
校验失败后的错误信息不是直接丢给用户,而是要格式化成模型能理解的语言,作为下一轮重试的上下文注入。例如:
"⚠️ 校验发现以下问题:字段 $.rating 的值 6 超出了允许范围 1, 2, 3, 4, 5。请修正后重新输出合法的 JSON。"
这种带具体路径和值的错误反馈比笼统的"格式不对"有效得多。在测试中,带上下文的二次重试成功率达到 99.2%,而盲重重试只有 94.5%。
业务语义校验
除了标准 Schema 约束,有些规则无法用 Schema 表达。例如"数组元素不能重复"、"字段 A 的值必须与字段 B 的值一致"。在引擎中通过 custom_validator 钩子支持这种扩展:
engine = StructuredOutputEngine(
llm_func=my_llm,
schema=my_schema,
custom_validator=lambda data: (
["评分<3时情感不能为positive"]
if data.get("rating", 5) < 3 and data.get("sentiment") == "positive"
else []
),
)
七、完整引擎整合与实战
7.1 引擎类的完整实现
将前面的模块整合成一个统一的 StructuredOutputEngine 类,对外暴露 generate() 方法,内部串联起完整的处理流水线:
import json
from typing import Any, Dict, List, Optional, Callable, Tuple
class StructuredOutputEngine:
"""结构化输出引擎"""
def __init__(
self,
llm_func: Callable[[List[dict]], str],
schema: Dict[str, Any],
example: Optional[Dict] = None,
max_retries: int = 3,
temperature: float = 0.1,
custom_validator: Optional[Callable[[dict], List[str]]] = None,
):
self.llm_func = llm_func
self.schema = schema
self.example = example
self.max_retries = max_retries
self.temperature = temperature
self.custom_validator = custom_validator
self._stats = {"total": 0, "success": 0, "retries": 0, "failures": 0}
def generate(self, user_prompt: str) -> dict:
self._stats["total"] += 1
messages = self._build_prompt(user_prompt)
last_error = None
for attempt in range(1 + self.max_retries):
if attempt > 0:
self._stats["retries"] += 1
messages.append({
"role": "user",
"content": f"\n\n⚠️ 上次输出有误:{last_error}\n请修正后重新输出合法 JSON。"
})
try:
response = self.llm_func(messages)
data = robust_json_parse(response)
passed, errors = self._validate(data)
if not passed:
last_error = "; ".join(errors)
continue
if self.custom_validator:
biz_errs = self.custom_validator(data)
if biz_errs:
last_error = "; ".join(biz_errs)
continue
self._stats["success"] += 1
return data
except Exception as e:
last_error = str(e)
self._stats["failures"] += 1
raise RuntimeError(f"结构化输出失败,已重试 {self.max_retries} 次")
def _build_prompt(self, user_prompt: str) -> list:
system = "你是一个结构化输出助手。严格按照以下格式返回 JSON:\n\n"
system += schema_to_instructions(self.schema)
system += "\n\n规则:1.只输出JSON,不要代码块 2.不要解释文字"
if self.example:
system += f"\n\n示例:\n{json.dumps(self.example, ensure_ascii=False, indent=2)}"
return [{"role": "system", "content": system}, {"role": "user", "content": user_prompt}]
def _validate(self, data: dict) -> Tuple[bool, List[str]]:
errors = validate_value(data, self.schema)
return (len(errors) == 0, errors)
@property
def stats(self) -> dict:
r = self._stats
return {**r, "success_rate": round(r["success"] / r["total"] * 100, 1) if r["total"] else 0}
7.2 与 OpenAI API 对接
通过一个简单的适配器函数,引擎可以对接任何兼容 OpenAI 格式的 API:
import requests
def call_llm(messages, api_key=None, model="gpt-4o-mini", base_url=None):
"""OpenAI 兼容 API 适配器"""
api_key = api_key or os.getenv("OPENAI_API_KEY")
base_url = (base_url or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")).rstrip("/")
resp = requests.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": model, "messages": messages, "temperature": 0.05, "max_tokens": 1024},
timeout=60,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
7.3 实战案例:批量商品评论信息抽取
电商场景下经常需要从海量用户评论中批量提取结构化信息。定义好 Schema 后,用引擎批量处理即可:
SCHEMA_PRODUCT = {
"type": "object",
"properties": {
"product": {"type": "string", "description": "商品名称"},
"rating": {"type": "integer", "minimum": 1, "maximum": 5},
"sentiment": {"type": "string", "enum": ["positive", "negative", "neutral", "mixed"]},
"pros": {"type": "array", "items": {"type": "string"}, "description": "优点"},
"cons": {"type": "array", "items": {"type": "string"}, "description": "缺点"},
"recommend": {"type": "boolean"},
},
"required": ["product", "rating", "sentiment", "recommend"]
}
engine = StructuredOutputEngine(
llm_func=lambda msgs: call_llm(msgs, model="gpt-4o-mini"),
schema=SCHEMA_PRODUCT,
max_retries=2,
temperature=0.05,
)
reviews = [
"小米14 ultra拍照太棒了,夜景无敌,就是价格贵了点",
"华为matebook x pro屏幕素质好,3比2比例做文档太舒服,但散热一般",
"这个充电宝两个月就坏了,千万别买",
]
for r in reviews:
result = engine.generate(f"从评论中提取信息:\n\n{r}")
print(json.dumps(result, ensure_ascii=False, indent=2))
print(f"统计信息:{engine.stats}")
引擎运行后会自动记录统计数据。首次成功率、重试次数、最终失败数一目了然。如果首次成功率低于 80%,说明 Prompt 或 Schema 需要优化;如果重试后仍有失败,说明 Schema 约束可能过于苛刻或者彼此矛盾。
7.4 实战案例:文档实体关系提取
知识图谱构建是另一个典型的结构化输出场景。从技术文档中提取实体、关系和属性:
SCHEMA_ENTITY = {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"type": {"type": "string", "enum": ["技术", "产品", "人物", "组织"]},
"aliases": {"type": "array", "items": {"type": "string"}},
},
"required": ["name", "type"],
}
},
"relations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"relation": {"type": "string"},
"description": {"type": "string"},
},
"required": ["source", "target", "relation"],
}
}
},
"required": ["entities", "relations"]
}
这个场景的标准校验解决格式问题,自定义校验解决"source 和 target 必须在 entities 中存在"的跨字段引用问题,分工明确。
八、高级话题与最佳实践
8.1 Grammar 约束解码
对于支持 Grammar 采样的推理引擎(如 llama.cpp 的 GBNF 语法、vLLM 的 JSON 模式),可以在 token 生成阶段就约束输出格式。这意味着模型在生成过程中,能合法输出的 token 被缩小到仅能产生合法 JSON 的范围内。
优点是零重试------输出一定是合法 JSON,不存在格式错误。缺点是灵活性受限------模型无法输出"意外情况"(比如"这段没有需要提取的信息"),而且需要推理引擎本身支持。Grammar 约束适合对延迟敏感的在线服务,宽容解析加重试方案更适合离线批处理。
8.2 多轮对话中的增量提取
在对话式 AI 中,用户可能在多轮对话中逐步补充信息。实现增量提取的关键是把上一轮的结构化结果作为上下文传入 Prompt:"上一轮提取结果是 {JSON}。用户的新输入是:{新消息}"。模型在此基础上做增量更新,而不是每轮从头提取,这样效果更好,也节省 token。
8.3 性能调优清单
从四个层面优化引擎性能:
Prompt 层:提供高质量的输出示例,数据要真实感强、覆盖所有字段;在系统提示末句强调"只输出 JSON";用英文字段名避免编码问题。
解析层:优先尝试直接解析,解析失败再走修复路径;括号匹配加最大深度限制(比如 20 层),防止深度嵌套导致性能下降。
校验层:首次全量校验;重试时只校验上次出错的字段,通过缓存错误路径实现。
重试层:首重用 0.1 temperature,二重用 0.01 temperature;每次重试增加响应超时时间(模型可能需要更多时间思考修正方案);最大重试次数设为 3 次,再多收益递减。
九、总结
本文从零实现了一套完整的 LLM 结构化输出引擎,覆盖了 Schema 定义、Prompt 构建、宽容解析、类型校验和错误重试五个核心环节。整个实现用 Python 标准库完成,核心代码约 400 行,可以直接复制到项目中使用。
回顾整篇文章,有几个核心经验值得记住。Schema 是契约也是桥梁------花时间精心设计 Schema,能节省数倍的下游开发和调试时间。宽容而不放纵------宽容解析器是必要的兜底机制,但理想策略是通过 Prompt 工程提升首次成功率,三层防护层层递进。重试不是简单的重复------带上下文错误的智能重试效果远超盲重重试,反馈信息的质量直接影响重试成功率。
结构化输出是 LLM 从"演示级工具"走向"生产级基础设施"的关键能力。掌握它,你的 LLM 应用才能真正稳定可靠地融入自动化流水线。
📚 延伸阅读
👉 DeepSeek 实战指南:提示词工程、API 集成与效率提升全攻略
这篇文章系统拆解了 DeepSeek 的提示词工程技巧、API 封装方法以及日常开发场景,全文代码可直接运行。
本文是"手写 AI 系统"系列文章之一。该系列从零实现 AI 系统中的关键组件,涵盖 RAG、Agent、Function Calling、MCP、结构化输出等核心技术,帮助你深入理解底层原理,构建属于自己的 AI 工具。
8.4 Schema 版本管理与迭代
在生产环境中,Schema 不是写死不变的。随着业务发展,提取字段会不断增加、调整。建议将 Schema 视为 API 契约进行版本管理:
每次 Schema 变更后,用旧 Schema 处理一批历史数据做回归测试,确保新 Schema 不会降低旧场景的抽取质量。新增字段建议只在 required 外先跑一段时间,等数据稳定后再改为必填。移除字段前最好先在 Schema 中标记 deprecated 并在 description 中说明,给下游留出迁移时间。
8.5 本地模型部署方案
如果你的场景对数据隐私敏感,需要完全本地运行,推荐使用 Ollama 或 llama.cpp 部署量化模型。对于结构化输出场景,7B 参数量的量化模型(如 Qwen2.5-7B-Q4_K_M)在宽容解析器的配合下可以达到与 GPT-4o-mini 接近的效果。关键在于设置较低的 temperature(0.0-0.1)和足够的 max_tokens(建议 2048)。同时利用 Grammar 约束功能(如果支持)可以进一步降低格式错误率。
8.6 单元测试与质量保障
结构化输出引擎的正确性应该通过自动化测试来保障。建议编写三类测试用例:第一类是格式测试------用已知的合法 JSON 测试解析器和校验器的正确性;第二类是容错测试------故意构造有各种格式缺陷的输入(尾随逗号、单引号、注释等),验证解析器能否正确修复;第三类是集成测试------用 mock LLM 函数模拟各种输出场景,验证重试机制和错误处理是否正常工作。三类测试结合起来,可以在不依赖真实 LLM API 的情况下确保引擎的核心质量。
十、常见问题排查指南
10.1 模型总是输不出合法 JSON
如果重试多次仍失败,首先检查 Schema 的复杂度。一个包含超过 15 个字段或嵌套层级超过 3 层的 Schema,对多数模型来说已经偏复杂。建议拆分成多个较小的 Schema 逐步获得结果后合并。其次是检查 temperature 是否过高------超过 0.5 的 temperature 会显著增加格式错误率,建议控制在 0.1 以下。
10.2 校验总在同一个字段失败
如果某个特定字段(比如枚举字段)反复校验失败,说明模型对这个字段的约束理解不够清楚。解决方案是在 Schema 的 description 中更清晰地说明取值范围,或者在输出示例中展示该字段的正确用法。如果仍然无效,考虑将该字段的类型放宽为 string 后在业务层做容错映射。