手写 LLM 结构化输出引擎 —— 从 JSON Schema 约束到类型安全的数据提取

一、结构化输出为什么是 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 防止模型返回空字符串;ratingminimummaximum 约束评分范围;sentimentenum 限定四个情感标签,消除"positive/正向/好评"之类的写法歧义;prosmaxItems 限制列表长度。

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 后在业务层做容错映射。

相关推荐
QYR-分析1 小时前
柔性传感新赛道崛起:织物压力传感器行业发展全景解析
大数据·人工智能
zhiSiBuYu05171 小时前
重排序(Rerank)提升检索准确率实战指南
开发语言·python·算法
Token炼金师1 小时前
架构的岔路:Decoder 一统江湖,MoE 另辟蹊径 —— 主流架构变体的工程权衡
人工智能·encoder-decoder·moe·decoder-only
月疯1 小时前
华为手环的部分功能
算法
2zcode2 小时前
免费开源项目文档:基于HSV颜色空间和卷积神经网络的交通标志识别系统设计与实现
人工智能·深度学习·cnn
郭梧悠2 小时前
算法:有效的括号
python·算法·leetcode
atunet2 小时前
关于算法设计模式的演化与编程范式变迁的技术7
算法·设计模式
Jerry2 小时前
LeetCode 27. 移除元素
算法