Skip to content

필사 모드: LLM structured output 的稳定化 — 消灭解析失败的四层防御

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 解析只成功 99% 也算失败

一旦把 LLM 输出当成流水线的中间环节来用,比自然语言质量先成为问题的是解析成功率。摘要写得平淡一点还能忍,但每天十万条里有 1% 解析失败,那就是一千条异常要处理。

这个问题棘手,在于失败每次都换一副面孔。昨天是被 markdown 围栏裹着回来的,今天后面跟了一句体贴的说明,明天从中间断掉。每次都往上加一段字符串处理,最后就攒出一个二十行正则的解析器,而它照样会漏。

解法是把防御一层层叠起来,同时清楚每一层挡得住什么、挡不住什么。本文先对失败类型分类,然后按顺序摆出四层防御。

先给失败类型分类

打开日志,只收集 100 条解析失败的案例做分类,应对的优先级立刻就定了。大体分成六类。

失败类型症状原因最便宜的防御
被代码围栏包住用三个反引号加 json 标记裹着回来训练数据里的 markdown 习惯解析前先剥掉围栏
前后带说明文字“以下是您要的结果”之类的开场白对话式对齐的副作用预填充、停止序列
截断对象没闭合就结束了触到 token 上限确认结束原因、重算上限
转义出错字符串内引号与换行处理错误很长的自由文本字段拆分字段、约束解码
幻觉字段冒出 schema 里没有的键缺少 schema 约束严格 schema、拒绝未知键
类型不匹配数字变成字符串、日期格式五花八门类型没写清楚在字段名和描述里写明格式

这张表里重要的是最后一列每行都不一样。想用一招挡住全部,就会变成过度设计。看实际分布,只处理排前面的两三类,失败率通常就掉到个位数以下了。

前两类尤其挡得便宜。

import json, re

FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)

def extract_json(text):
    """1) 剥掉围栏 2) 还不行就只截取第一个对象区间再试"""
    cleaned = FENCE.sub("", text).strip()
    try:
        return json.loads(cleaned)
    except json.JSONDecodeError:
        pass
    start, end = cleaned.find("{"), cleaned.rfind("}")
    if start != -1 and end > start:
        return json.loads(cleaned[start:end + 1])
    raise ValueError("JSON을 찾을 수 없음")

这是临时处方。根本对策在下面几层里讲,不过这十行今晚就能把告警按下去,这也是事实。

四层防御 — 指令、示例、schema、约束

把防御手段按强度排开是这样:越往上越强,越往下越便宜、越到处能用。

第一层是提示指令。“只输出 JSON,不要附加任何其他文本”出奇地有效。再加两样效果会更好:指定停止序列,把对象闭合之后接上的说明切掉;用预填充,把助手回复以一个左花括号提前起头。预填充让开场白在物理上不可能出现。

第二层是示例。给出一两个期望形态所带来的格式遵守效果,比几行指令要大得多。尤其是枚举值、日期格式、空值如何表示这类用文字讲会啰嗦的规则。

第三层是函数调用或工具 schema。与其用自由文本索要 JSON,不如定义一个带参数 schema 的工具,让模型去调用它。schema 由供应商那一侧强制执行,前面列的失败类型大多会消失。如果您在用 API,从这一层起步才对。

第四层是约束解码。把 schema 编译成有限状态机或文法,每一步都把该状态下不被允许的 token 的概率压成零。输出违反文法在原理上变得不可能。

# 如果自己部署开源模型,约束解码在服务端选项里打开
vllm serve <모델경로> --guided-decoding-backend xgrammar

# 从请求侧传入 schema (例如 OpenAI 兼容端点)
#   "guided_json": { ...JSON Schema... }
#   "guided_choice": ["正面", "负面", "中性"]
#   "guided_regex": "\\d{4}-\\d{2}-\\d{2}"

在分类这种输出只是若干标签之一的任务上,guided_choice 尤其强大。输出空间是有限的,解析这个概念本身就消失了。

约束解码保证什么,不保证什么

这里要做本文最重要的一个区分。约束解码保证语法上的有效性。它并不保证语义上的正确性

schema 要求 price 字段是整数,那就一定会出来一个整数。但那个整数是不是文档里写的价格,没有任何人保证。情况甚至可能微妙地变糟。没有约束时,模型至少还能违反格式地告诉您“文档里找不到价格”;一旦加上约束,这条逃生通道被堵死,它必须给出某个数字。格式错误变成了取值错误,而取值错误要难发现得多。

所以打开约束解码时,有一件事必须一并做:在 schema 里留出表达“不知道”的位置。

# 糟糕: 就算没有值也必须给出一个数字
{"price": {"type": "integer"}}

# 良好: 可以表达“没有”
{"price": {"type": ["integer", "null"]},
 "price_found": {"type": "boolean"}}

第二个注意点是文法与模型自然生成路径冲突的时候。约束是在裁剪概率分布,而不是改变模型的理解,所以当模型想走的路径全被堵住,就会在剩下的里面挑一个没那么糟的 token。schema 越复杂越别扭,这个现象越明显。如果打开约束解码之后格式错误消失了、精度却下降了,那么先把 schema 简化才是正事。

第三是性能。每一步都要计算允许 token 的掩码,因此有开销。近期的实现会预先编译并缓存文法,把这部分成本抵消掉大半,但如果 schema 每次请求都不一样,编译成本就会原样暴露出来。schema 数量有限的话,请让它们可以被复用。

Schema 设计就是提示设计

模型并不只是把 schema 当作约束接收,它还会把它当文本来读。所以字段名和描述具有与指令同等的分量。在这里能拿到的精度提升,往往比打磨提示得到的更大。

from pydantic import BaseModel, Field
from typing import Literal, Optional

# 糟糕的 schema
class BadTicket(BaseModel):
    type: str            # 什么都可能进来
    date: str            # 格式五花八门
    info: dict           # 没有结构

# 良好的 schema
class Ticket(BaseModel):
    reasoning: str = Field(description="분류 근거를 두 문장 이내로")
    category: Literal["결제", "배송", "환불", "계정", "기타"]
    urgency: Literal["낮음", "보통", "높음"]
    order_id: Optional[str] = Field(None, description="주문번호. 본문에 없으면 null")
    due_date_iso: Optional[str] = Field(None, description="YYYY-MM-DD 형식")

有四处变化。

自由字符串换成了枚举。这会同时提升精度和解析稳定性,下游那段把同一标签的两种写法映射成同一个值的代码也随之消失。

把格式写进了字段名。due_date_iso 是比 date 强得多的指令。名字本身就传达了格式,描述里要花的 token 也省下来了。

把可能不存在的值改成了可空。若留成必填字符串,模型就会造出空字符串或一个像模像样的假订单号。

而效果最大的一处改动,是把 reasoning 放到了最前面。生成是从左往右推进的,所以靠前的字段会成为后面字段的条件。让模型先写依据,分类结果就会在那段依据的条件下被生成。把同一个字段放到最后,它就变成了对既定答案的事后解释,精度改善的效果随之消失。JSON 对象的键顺序在语义上无关紧要,但生成顺序绝非无关紧要

还有一点。请把嵌套保持得浅一些。嵌套三层以上的对象,格式错误率和取值错误率会一起上升,约束解码的文法也会变大。当嵌套看起来必要时,通常把调用拆成两次更好。

把校验与重试循环实现对

防御叠满了,失败还是会剩下一些。剩下这些怎么处理,很大程度上决定了成功率。

关键在于重试时改的是什么。用同一段提示原样再调一次,只有在温度不为 0 时才有意义,而且即便如此,重复同一种失败的概率也很高。把错误信息回灌进对话,成功率会明显不同。

import json
from pydantic import ValidationError

def generate_structured(client, messages, model_cls, max_attempts=3, max_tokens=1024):
    history = list(messages)
    last_error = None

    for attempt in range(max_attempts):
        resp = client.create(messages=history, max_tokens=max_tokens)
        text = resp.content
        finish = resp.finish_reason

        if finish == "length":
            # 截断靠重新生成解决不了。要么提高上限,要么把任务拆开。
            max_tokens = min(max_tokens * 2, 8192)
            history.append({"role": "user",
                            "content": "출력이 잘렸습니다. 더 짧게, 설명 없이 다시 출력하십시오."})
            continue

        try:
            return model_cls.model_validate(extract_json(text))
        except (ValueError, json.JSONDecodeError, ValidationError) as e:
            last_error = e
            # 把失败的输出和具体错误一起回灌
            history.append({"role": "assistant", "content": text})
            history.append({"role": "user", "content":
                f"위 출력이 스키마 검증에 실패했습니다.\n오류: {e}\n"
                f"오류만 수정한 JSON을 다른 텍스트 없이 출력하십시오."})

    raise RuntimeError(f"{max_attempts}회 시도 후 실패: {last_error}")

指出三点。

错误信息必须具体。不能只说“校验失败”,要说到“urgency 字段收到的值不在允许的取值范围内”,模型才能改。把 pydantic 或 jsonschema 的错误字符串原样传过去,通常就够了。

失败的输出本身要放进对话。模型得看到自己写了什么,才改得动。

截断是另一类失败。用同样的方式重试,会在同一个地方再断一次。必须确认结束原因再分支处理。

顺便把重试的成本也算出来会更好。

def retry_math(p_fail, max_attempts):
    expected_calls = sum(p_fail ** i for i in range(max_attempts))
    residual = p_fail ** max_attempts
    return expected_calls, residual

for p in (0.04, 0.15):
    calls, res = retry_math(p, 3)
    print(f"1회 실패율 {p:.0%} → 평균 호출 {calls:.3f}회 (비용 +{calls - 1:.1%}), 최종 실패율 {res:.4%}")
# 1회 실패율 4% → 평균 호출 1.042회 (비용 +4.2%), 최종 실패율 0.0064%
# 1회 실패율 15% → 평균 호출 1.172회 (비용 +17.2%), 최종 실패율 0.3375%

这里假设了各次尝试相互独立,这一点必须点明。真实的失败是相关的。如果某个输入是因为难而失败,那么第二次尝试很可能因为同样的原因再失败。所以上面的最终失败率是一个乐观的估计,真实值要从日志里测。把重试次数加到 3 次以上几乎没有帮助,原因也正是这种相关性。

输出上限与截断的应对

截断是一种沉默的失败。表现为解析错误还算走运,最糟的是数组只少了最后一项、却仍然是合法 JSON。校验通过、告警不响,可数据缺了。

def estimate_output_tokens(schema_fields, avg_value_tokens, list_len=1):
    """结构开销 + 取值。真实数值还是用分词器测更准确。"""
    per_item = sum(len(name) // 3 + 4 + avg_value_tokens for name in schema_fields)
    return int(per_item * list_len * 1.25)   # 留 25% 余量

fields = ["reasoning", "category", "urgency", "order_id", "due_date_iso"]
print(estimate_output_tokens(fields, avg_value_tokens=12, list_len=1))   # 116
print(estimate_output_tokens(fields, avg_value_tokens=12, list_len=20))  # 2325

在抽取列表的任务里,如果条目数不可预测,上限的估算根本无从谈起。这时有三种办法可用。

在 schema 里写明条目数上限,把超出的部分做成翻页交给下一次调用。或者把 JSON 数组换成一行一个对象的输出方式,这样即使截断,扔掉最后一行就行。最后一种,是把统计数组长度的字段放在靠前的位置,与实际数组长度比对,事后校验是否发生了截断。

无论走哪条路,确认结束原因都是必须的。不检查这个值的流水线,绝对发现不了截断。

结语 — 格式去强制,语义去校验

归纳起来就一句话:格式在生成阶段强制,语义在生成之后校验。

格式的强制靠工具 schema 和约束解码在原理上就解决了,没有理由在这上面耗太久。反过来,取值是否正确、有没有把不存在的值编出来、枚举值是否对应真实情形,schema 绝对帮不上忙。约束解码上线之后解析失败率归零的那一刻之所以危险,原因就在这里:仪表盘上的红灯灭了,错的值照样往下流。

所以请把取值准确率指标摆在解析成功率旁边。而动 schema 的时候,先看字段名和顺序。把一个字段往前挪一位,往往强过改十遍提示。

현재 단락 (1/112)

一旦把 LLM 输出当成流水线的中间环节来用,比自然语言质量先成为问题的是解析成功率。摘要写得平淡一点还能忍,但每天十万条里有 1% 解析失败,那就是一千条异常要处理。

작성 글자: 0원문 글자: 5,918작성 단락: 0/112