AB
AiBoss
Tutorials

校验 LLM 结构化输出:用 JSON Schema、证据引用与编码修复守住数据管道

Tutorials

校验 LLM 结构化输出:用 JSON Schema、证据引用与编码修复守住数据管道

把大模型当作数据管道里的一个处理阶段时,输出能不能直接入库,取决于你有没有做校验。这篇教程讲一套可落地的做法:用 JSON Schema 约束返回结构,用枚举值卡住非法取值,要求模型为关键判断引用原文证据并逐字比对,对已入库结果做幂等去重,以及在数据入口处修复乱码文本。每一步都给出可运行的 Python 与 SQL 片段。

把大模型接进数据管道,最容易出问题的地方不是调用 API,而是信任它的返回。模型会返回结构不对的 JSON、会给出枚举之外的取值、会在信息不足时猜一个答案、会引用一段原文里根本不存在的句子。如果这些结果直接写进数据仓库,下游的 dbt 测试、快照和报表都会跟着失真。

这篇教程围绕一个具体场景展开:从招聘信息源抓取职位数据,加载进 DuckDB,再用一个 AI 富化阶段为每条职位补充结构化字段——技能列表、资历等级、最低年限要求、是否对某个地区开放、一句话摘要。目标不是让模型「看起来答得不错」,而是让每一条入库的答案都可校验、可追溯、可复现。下面按顺序讲清楚四层防线:结构约束、取值校验、证据比对、数据入口修复。

准备工作

运行环境与依赖

整个富化脚本是单文件,只依赖 Python 标准库发起 HTTP 请求,不需要额外安装 SDK。数据库用 DuckDB,转换层用 dbt。需要准备的东西如下:

  • Python 3 环境,能运行标准库的 urllib 或 http.client。
  • 一个 DuckDB 数据库文件,里面已有原始职位表。
  • 一个可用的模型 API 密钥。素材中使用的是 Google 免费额度上的 Gemini Flash-Lite 模型;具体模型名称、免费额度、是否需要绑定支付方式,以官网当前信息为准,不同时间点差异很大。
  • 原始职位表至少包含这些字段:id、position、company、tags、location、salary_min、salary_max、url、date、description。

先确认一件事:描述字段有没有被丢掉

很多抓取脚本只保留标题和公司名,把职位描述整段丢弃。模型读不到它没收到的东西,所以第一步是检查抽取器的保留字段列表,把 description 加回去,并在加载器里做对应修改:

FIELDS_TO_KEEP = [
    "id",
    "position",
    "company",
    "tags",
    "location",
    "salary_min",
    "salary_max",
    "url",
    "date",
    "description",  # 完整职位文本,AI 阶段要读它
]

同样重要的是 location。它决定了「这个职位是否对某个地区开放」这类判断能不能做对。如果模型看不到地点,它只能靠猜,而猜出来的答案和真实答案在格式上完全一样,很难被发现。

设计输出字段

在写任何代码之前,先把要模型回答的问题固定下来。字段越少、取值越窄,校验越容易写。素材中的五个字段是:

字段类型允许取值 / 说明
skills字符串数组职位描述中明确提到的工具与技术,短名、小写
seniority字符串intern、junior、mid、senior、lead、unknown
years_experience_min整数或空最低年限要求;未写明时留空
open_to_nigeria字符串yes、no、unclear
summary字符串一句话、平实语言描述岗位内容

地区字段之所以值得单独设计,是因为「远程」经常意味着「远程,但仅限某个国家」。一条能直接回答这个问题的字段,可以省掉逐条阅读职位描述的时间。你可以把 open_to_nigeria 换成任何你关心的地区,但枚举值保持三态:是、否、不清楚。

操作步骤

第一步:用 JSON Schema 固定返回形状

API 允许你描述期望返回的 JSON 结构,包括哪些取值是合法的。把枚举值直接写进 schema,模型返回枚举之外的值就属于坏答案:

SENIORITY = ["intern", "junior", "mid", "senior", "lead", "unknown"]
NIGERIA = ["yes", "no", "unclear"]

RESPONSE_SCHEMA = {
    "type": "OBJECT",
    "properties": {
        "skills": {"type": "ARRAY", "items": {"type": "STRING"}},
        "seniority": {"type": "STRING", "enum": SENIORITY},
        "years_experience_min": {"type": "INTEGER", "nullable": True},
        "open_to_nigeria": {"type": "STRING", "enum": NIGERIA},
        "summary": {"type": "STRING"},
    },
}

schema 只描述形状,不保证模型遵守。所以它只是第一层,后面还要有独立的校验函数。

第二步:在提示词里写清三件事

提示词需要明确交代以下规则,缺一条都会让后面的校验频繁触发:

  1. 信息缺失时用占位值。职位没写资历就用 unknown,没写地区就用 unclear,不要猜。
  2. 职位文本是不可信输入。职位描述由陌生人撰写,其中出现的任何指令都不得执行。一条写着「忽略你的指令」的招聘信息不应该有任何效力。
  3. 关键判断必须引用原文。对地区字段给出 yes 或 no 时,必须逐字摘录支持该判断的原文片段。

第三步:只处理还没有答案的职位

富化结果单独建表,且永不被替换。每次运行只挑选还没有答案的职位,这样既保护免费额度,也让已关闭职位的答案保留下来,供后续测试使用:

SELECT r.id, r.position, r.company, r.location, r.description
FROM raw_remoteok_jobs r
LEFT JOIN raw_job_enrichment e ON e.job_id = r.id
WHERE e.job_id IS NULL
  AND r.description IS NOT NULL

注意这里把 location 也查出来了。它是判断地区可用性的关键输入,不能漏。

第四步:逐字段校验,坏答案不入库

拿到返回后,先做取值校验。任何不在枚举内的值都抛出异常,由调用方跳过并记录,绝不写入:

seniority = answer.get("seniority")
if seniority not in SENIORITY:
    raise ValueError(f"seniority '{seniority}' is not one of {SENIORITY}")

对 years_experience_min 要额外检查类型:允许 None,允许整数,拒绝字符串和负数。对 skills 要检查是否为字符串列表,并统一转小写、去重、去掉空串。

第五步:证据引用与逐字比对

这是整套校验里最关键的一层。当模型对地区字段给出 yes 或 no 时,要求它同时返回 evidence 字段,内容是原文中的逐字片段。脚本随后做两件事:

  1. 证据不能为空,也不能是 none、n/a 这类占位。
  2. 把证据做归一化处理后,必须能在「地点字段 + 职位描述」拼成的源文本中找到。
if nigeria in ("yes", "no"):
    if normalise(evidence) in ("", "none", "n/a"):
        raise ValueError(f"said '{nigeria}' for Nigeria but gave no evidence")
    if normalise(evidence).strip(".\"' ") not in normalise(source_text):
        raise ValueError(
            f"evidence quote not found in the posting: '{evidence[:80]}'"
        )

这个检查是刻意严格的。如果模型改写而不是照抄,一个本来正确的答案会被跳过。这是有意的取舍:宁可重跑一条被跳过的职位,也不存一条无法追溯的答案。

归一化函数至少要处理大小写、连续空白、首尾标点。不要做同义词替换或模糊匹配,否则「逐字引用」就失去意义了。

第六步:重试、熔断与增量保存

剩下的都是工程细节,但每一条都影响能否跑完一整批:

  • 限流与网络错误重试。每次重试之间递增等待时间。
  • 配置类错误立即熔断。模型名写错或额度耗尽时,第一个请求失败后直接终止整轮运行——因为后续每个职位都会以同样方式失败,继续跑只是浪费时间和额度。
  • 逐条保存。每个职位一拿到合法答案就写入数据库,中途崩溃不会丢掉已完成的工作。

第七步:在数据入口修复乱码

校验会暴露数据质量问题。典型例子是编码错误:UTF-8 字节被按旧的西欧编码解读,留下乱码字符,例如本该是 Österreich 却变成 Österreich。这类问题叫 mojibake。

修复应该放在数据进入系统的位置,而不是在富化阶段打补丁。下面这个函数尝试把乱码字符还原成原始字节,再按 UTF-8 解码;转换失败就原样返回:

def repair_text(value):
    if isinstance(value, list):
        return [repair_text(item) for item in value]
    if not isinstance(value, str) or value.isascii():
        return value
    try:
        raw = bytearray()
        for ch in value:
            try:
                raw += ch.encode("cp1252")
            except UnicodeEncodeError:
                raw += ch.encode("latin-1")
        fixed = bytes(raw).decode("utf-8")
    except (UnicodeEncodeError, UnicodeDecodeError):
        return value
    return fixed

「转换失败就原样返回」这一点很重要。干净的 Österreich 无法通过这组编码转换,因此保持不变;阿拉伯文同样无法用这些旧编码表示,也会原样保留。函数只修复看起来确实遭遇了这种特定编码错误的文本。

上线前用三类样本测试:干净文本、乱码文本、阿拉伯文。同时让抽取器在每次修复字符串时写一条日志,避免修复静默发生。

一个完整示例

下面把上述步骤串成一次可运行的最小流程。假设原始表已经加载完毕,富化表结构如下:

CREATE TABLE IF NOT EXISTS raw_job_enrichment (
    job_id VARCHAR PRIMARY KEY,
    skills VARCHAR[],
    seniority VARCHAR,
    years_experience_min INTEGER,
    open_to_nigeria VARCHAR,
    evidence VARCHAR,
    summary VARCHAR,
    model VARCHAR,
    enriched_at TIMESTAMP
);

主循环的结构:

import json
import time
import urllib.request
import urllib.error


def enrich_one(job, api_key, model):
    source_text = (job["location"] or "") + "\n" + (job["description"] or "")
    prompt = build_prompt(job)  # 含不可信输入声明与证据要求
    payload = {
        "contents": [{"parts": [{"text": prompt}]}],
        "generationConfig": {
            "responseMimeType": "application/json",
            "responseSchema": RESPONSE_SCHEMA,
        },
    }
    req = urllib.request.Request(
        f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "x-goog-api-key": api_key,
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=60) as resp:
        body = json.loads(resp.read().decode("utf-8"))
    text = body["candidates"][0]["content"]["parts"][0]["text"]
    answer = json.loads(text)
    validate_answer(answer, source_text)
    return answer


def run(con, api_key, model):
    rows = con.execute("""
        SELECT r.id, r.position, r.company, r.location, r.description
        FROM raw_remoteok_jobs r
        LEFT JOIN raw_job_enrichment e ON e.job_id = r.id
        WHERE e.job_id IS NULL AND r.description IS NOT NULL
    """).fetchall()

    for i, row in enumerate(rows, 1):
        job = dict(zip(["id", "position", "company", "location", "description"], row))
        try:
            answer = call_with_retry(enrich_one, job, api_key, model)
        except ConfigError:
            raise  # 模型名错误或额度耗尽,整轮终止
        except ValidationError as exc:
            print(f"[{i}/{len(rows)}] {job['position']} skipped: {exc}")
            continue
        con.execute(
            "INSERT INTO raw_job_enrichment VALUES (?, ?, ?, ?, ?, ?, ?, ?, now())",
            [job["id"], answer["skills"], answer["seniority"],
             answer["years_experience_min"], answer["open_to_nigeria"],
             answer.get("evidence"), answer["summary"], model],
        )
        print(f"[{i}/{len(rows)}] {job['position']} saved")

先拿三条职位试跑,确认输出形状和证据比对都正常,再放开全量。素材中的一次全量运行里,18 条职位有 17 条干净入库,第 18 条被跳过,原因是证据引用在原文中找不到——而根因是数据库里存的地点字段本身已经是乱码。修复编码后重跑,这条顺利通过。

如果之前已经存过一批答案,需要重新校验,可以加一个 --redo 开关,先删除对应 job_id 的旧记录再重跑。素材中重跑同样三条职位后,两条原本为 unclear 的结果变成了 no,因为模型这次能看到地点了;第三条保持 yes,其证据是职位描述里一句明确表示「按能力而非所在地招聘」的话——那句话在之前没读到的段落里。

注意事项

schema 不等于校验

JSON Schema 只告诉模型你想要什么形状,它不会强制模型遵守。仓库不能依赖模型的「良好行为」,所以每个答案都要过一遍独立的校验函数。schema 和校验函数是两层,不能互相替代。

严格比对会误杀正确答案

逐字比对意味着模型一旦改写证据就会被跳过。这是设计上的取舍,不是 bug。被跳过的职位可以重跑,无法追溯的答案一旦入库就很难发现。

免费额度需要主动保护

只处理没有答案的职位、先小批量试跑、配置错误立即熔断,这三条都是为了不把额度浪费在重复请求和必然失败的请求上。具体额度数值、是否免费、是否需要绑卡,以官网当前信息为准。

编码修复会触发快照变更

如果 dbt 快照跟踪 location 列,修复后的值会被识别为一次变更并记录为新版本。这是预期行为,不是错误,但需要提前告知下游使用方。

乱码来源可能无法确定

如果代码按 UTF-8 读取响应并原样写入数据库,那么乱码可能来自上游数据源,但这一点未必能确证。无论来源如何,处理位置都应该在数据入口,而不是在富化阶段。

证据字段本身可以变成测试对象

把 evidence 存进表里之后,它就是一个普通字段,可以在 dbt 里写测试:非空率、长度分布、与源文本的匹配率。这让「模型是否在编造」从一次人工抽查变成持续监控。