Skip to content
🎯 场景实战第 15 / 16 节

级联提取:便宜模型干活、Jev 核查、贵模型兜底 ​

💡 一句话总结:用 Jev 搭一条模型级联管道 —— 便宜模型先提取、Jev 逐字段核查(每字段每种失败模式一道 Noul)、any_flag 报警才升级推理模型,成本只花在被点名的那几条上。

「结构化提取」这类活——把网页、文档变成 JSON 记录——有一个尴尬的成本结构:大推理模型提取得又好又慢又贵,小模型便宜却会犯一种最阴的错误。官方 SDE cascade cookbook 把这话说得很直白:便宜的模型会犯错,贵的推理模型提取得好但既慢又贵。级联(cascade)就是解法:小模型先干粗活,TypeSafe 逐字段核查它的产出,只有核查报警才升级到大模型。

这一课是值提取结尾预告过的「工业化版本」。单字段抽取时 Jev 自己就能把值挑对;当你的任务是每天几万条网页批量转 JSON 时,让 Jev 亲自逐字段提取太奢侈,让便宜模型全包又不敢上线——本课的答案是三份工:便宜模型提取、Jev 核查、贵模型兜底。官方在 100 条真实提取任务上测过这套循环:级联曲线在每个单模型的左上方——约 $0.02 一条的级联点质量约 0.79,最强的 gpt-5.5-reasoning 单跑 0.82 要约 $0.045。本课约 20 分钟,进阶级。

学完你能做什么 ​

  • 搭一条三级模型级联:gpt-5.4-mini 提取 → Jev 一次请求逐字段核查 → any_flag 报警才升级 gpt-5.5 推理——大多数条目只付便宜模型的钱
  • 让每条记录带上逐字段旗标:哪个字段可疑、可疑在哪(编造 / 错位 / 漏抓 / 格式违规),而不是一个笼统的总分
  • 专抓「schema 合法但语义错误」的编造——JSON Schema 校验全绿也拦不住的那种
  • 用官方同款思路选自己的工作点:把报警阈值从 0 扫到 1,画成本 / 质量曲线,而不是拍一个 0.7 就完事

为什么用 Jev 做核查,而不是全交给贵模型? ​

能力贵模型全包便宜模型 + Jev 级联
成本❌ 每条都付推理价:官方曲线 gpt-5.5-reasoning 约 $0.045/条✅ 大多数条只付 mini 的钱加一次 Jev 核查:官方曲线约 $0.02/条
质量✅ 单模型里最好(0.82)✅ 约 0.79,只差 0.03,成本省一半以上
错误可见性❌ 一把梭,错了不知道哪个字段、哪种错✅ 一道 Noul 一个失败模式,旗标点名字段 + 病因
编得像样的错误❌ JSON Schema 只验形状,验不出语义编造✅ 语义核查专抓 schema 合法的幻觉值
速度❌ 每条都等深度推理✅ 没报警的条不进推理级

一句话:贵模型是「全上保险」,模型级联是「便宜模型干活 + 廉价质检员全检 + 只有次品返工」——质检员必须远比老师傅便宜,这套账才成立(第 5 步算这笔账)。

你现在的困境 ​

  • 「每天几万条网页要转成结构化记录,全上推理模型账单吃不消;全用便宜模型,又不知道它哪条在编」
  • 「便宜模型的错误恰好是 schema 合法的:JSON Schema 校验全绿,编造的描述照样进库,出事了往回查连哪一步错的都不知道」
  • 「想加人工抽检,不知道该抽哪几条——每条记录看起来都好好的」

什么时候用这一招 ​

当你需要:

  • 大批量结构化提取:网页、文档 → JSON 记录,量大到成本敏感
  • 便宜模型的错误形态是「编得像样」而不是「格式稀烂」(格式错自己就能校验出来)
  • 每条记录带上「哪个字段可疑、为什么可疑」的旗标,返工和抽检都有靶子

而且不想:

  • 每条都付推理模型的钱
  • 核查结果是一段「看起来审过了」的文字,而不是能设阈值、能排序的数字
  • 解析失败就崩溃——级联里解析不了的回复直接当空记录,交给核查报警升级,方向是安全的

🎒 开始前的准备 ​

  • 完成了 Noul 是非题:核查的每一道题都是 Noul,noul 是 0–1 的 P(true),没有 confidence
  • 跑通过安装与第一个请求:.venv 可用,会读 response.answers
  • 建议读过 值提取:本页是它结尾预告的「管道工业化版本」——单值怎么挑对,在那里讲;本页只管「多字段 + 多模型」的成本账
  • 两把 Key:TYPESAFE_API_KEY(Jev 核查)+ OPENAI_API_KEY(官方示例的 rung 0 / rung 1 是 OpenAI 的 gpt-5.4-mini / gpt-5.5)
  • 一杯水 ☕——20 分钟、进阶级:一个跨三个模型的完整管道

跟我做 ​

新建 sde_cascade.py,把下面每一步的代码块按顺序贴进同一个文件。先说清跑法:本页所有输出是官方 cookbook 录制数字,照录供你对答案;你实时跑时数字可能有小差异,但「哪个头报警」应该一致。官方把 LLM 和 TypeSafe 调用都缓存在 json_cache.json——重跑不重复花钱,想真请求就删掉这个文件。

第 1 步:装环境、设两把 Key——三级各就各位 ​

为什么:级联跨三级——两个提取 rung 走 OpenAI 接口,核查走 TypeSafe。先装齐依赖、钉住三个模型常量和一条报警线,后面每一步都只引用它们。

安装依赖(官方命令原样;cooksafe 是官方 cookbook 配套工具库,0.2.0 依赖声明 typesafe-sdk>=0.7.0,<0.8.0,会自动把 SDK 一并装好):

bash
pip install openai jsonschema ipython 'cooksafe>=0.2.0,<0.3.0'

在环境里设好两把 Key,然后贴入文件头:

python
import json
import os
import urllib.request
from pathlib import Path

import jsonschema
from cooksafe import JsonCache, make_playground_link
from openai import OpenAI
from typesafe_sdk import Noul, NoulCriteria, TypeSafeClient

MINI = "gpt-5.4-mini"  # rung 0: cheap + fast
REASONING = "gpt-5.5"  # rung 1: strong, run with reasoning_effort="high"
TS_MODEL = "jev-1.14.0"  # the TypeSafe verifier model
FIRE_T = 0.7  # escalate if any per-field P(wrong) exceeds this

oai = OpenAI()
ts = TypeSafeClient(api_key=os.environ["TYPESAFE_API_KEY"], timeout=30.0)
print("setup ok")

运行 python sde_cascade.py。

你应该看到:setup ok——这一步只是装配,还没发请求。

三个常量读一遍,整条级联的用人方案就定了:

常量官方示例的值角色
MINIgpt-5.4-minirung 0:便宜 + 快,干粗活
REASONINGgpt-5.5(配 reasoning_effort="high")rung 1:强但贵,只在报警时出场
TS_MODELjev-1.14.0核查员:逐字段逐失败模式打 P(出问题)
FIRE_T0.7报警线:任一逐字段头超过它就升级

两个 rung 只是两个字符串常量——代码把它们原样传给 chat completions 接口,换成你自己选的便宜 / 贵模型只改这两行。核查这级换 Jev 版本时锁具体版本 ID(jev-latest 别名会随版本漂移),见模型与价格。

  • setup ok 打印出来了,两把 Key 都在环境变量里

第 2 步:备料——一条「页面没有答案」的示例 ​

为什么:考察提取器的试金石不是它抽对了什么,而是页面里没有答案时它怎么表现。官方为这一课准备了一条专门「考拒绝发明」的示例,先把它下下来看清楚。

python
EXAMPLE = Path("example.json")
EXAMPLE_URL = (
    "https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/"
    "cookbooks/sde_cascade/example.json"
)
if not EXAMPLE.exists():
    urllib.request.urlretrieve(EXAMPLE_URL, EXAMPLE)
example = json.loads(EXAMPLE.read_text())
schema = example["schema"]
prompt = example["prompt"]
content = example["content"]
print(prompt, json.dumps(schema, indent=2), content, sep="\n===========\n")

运行,看三样东西。

你应该看到(官方示例的 prompt 与 schema 照录;页面正文较长,以要点概述):

text
Find the date fall registration opens at Wrenfield Community College in Wrenfield,
Oregon, for the 2026-2027 school year.
===========
{
  "title": "RegistrationOpen",
  "type": "object",
  "properties": {
    "registration_open_date": {
      "title": "Registration Open Date",
      "type": "string",
      "description": "The date registration opens for the fall semester, in mm/dd/yyyy format, such as 08/03/2026. Return a blank string if you are unsure."
    },
    "description": {
      "title": "Description",
      "type": "string",
      "description": "A short description of the registration open date, such as 'Registration opens for the fall semester'."
    }
  },
  "required": ["registration_open_date", "description"]
}
===========
(Wrenfield Community College 的活动日历页:导航、搜索框,
唯一一条活动是「Fall 2026 Census Date——Monday, September 14, 2026」,
即退课截止的人口普查日;整页没有任何注册开放日期,也没有对它的描述)

两个陷阱埋在材料里,读题时就要看到:

  • 页面没有答案:这是大学的活动日历页,唯一的事件是秋季人口普查日(退课截止日)——它既没有注册开放日期,也没有对这个日期的描述。守规矩的提取器应该拒绝发明这两个字段

  • schema 自带「示例值」:description 字段的 description 里写着 such as 'Registration opens for the fall semester'——一个想抄近道的模型会把这个示例原样抄进答案。这正是第 3 步便宜模型要踩的坑

  • 我能说出这条示例的「考点」:页面没答案 + schema 里躺着一个现成的示例值

第 3 步:第一级——便宜模型提取,故意不用结构化输出 ​

为什么:两个 rung 都用纯文本回复提取——不开结构化输出、不开工具调用、不开 JSON mode。官方的理由很反直觉:这套级联要抓的错误是 schema 合法的;约束解码只会把「因为读错页面而破坏 schema」的模型藏起来,让核查无从下手。格式烂的错你自己就能校验,编得像样的错才需要语义核查。

python
EXTRACT_SYSTEM = (
    "You extract structured data from documents. Return only values supported by the text. "
    "Follow any value format specified by the schema or its field descriptions."
)

# LLM and TypeSafe calls are cached in json_cache.json, so a re-run reuses earlier answers
# instead of calling the APIs again; delete the file to call them fresh.
json_cache = JsonCache(Path("json_cache.json"))


@json_cache
def extract(
    model: str,
    prompt: str,
    schema: dict,
    content: str,
    *,
    reasoning_effort: str | None = None,
    temperature: float | None = None,
) -> dict:
    user = (
        f"{prompt}\n\nReturn ONLY a JSON object matching this JSON Schema:\n"
        f"{json.dumps(schema, indent=2)}\n\nDocument:\n{content}"
    )
    kwargs = {
        "model": model,
        "messages": [
            {"role": "system", "content": EXTRACT_SYSTEM},
            {"role": "user", "content": user},
        ],
    }
    if reasoning_effort:
        kwargs["reasoning_effort"] = reasoning_effort
    if temperature is not None:
        kwargs["temperature"] = temperature
    text = oai.chat.completions.create(**kwargs).choices[0].message.content
    # The prompt asks for ONLY a JSON object, so parse the reply as-is -- no regex fishing a
    # substring out of a malformed reply. If json.loads fails, treat it as an empty extraction:
    # every field reads as absent, which the verifier flags and the gate escalates -- the safe
    # direction.
    try:
        return json.loads(text)
    except (ValueError, json.JSONDecodeError):
        return {}


mini_record = extract(MINI, prompt, schema, content, temperature=0)
print("mini extraction:\n", json.dumps(mini_record, indent=2))
print("\nschema-valid:", jsonschema.Draft202012Validator(schema).is_valid(mini_record))

运行 python sde_cascade.py。设了 OPENAI_API_KEY 会真发一次 mini 请求,答案进 json_cache.json。

你应该看到(官方录制):

text
mini extraction:
 {
  "registration_open_date": "",
  "description": "Registration opens for the fall semester"
}

schema-valid: True

停一下——这条记录合法但错了,一半对一半编:

  • registration_open_date 空着是对的:页面确实没写注册日期,schema 也明确说不确定就返回空串
  • description 是编的:页面从没描述过注册日期,mini 把 schema 字段描述里那句示例原样抄了进来
  • jsonschema 校验打印 True——JSON Schema 看不见这种错。便宜模型产出的就是这种自信满满、schema 全绿的编造,抓它是语义核查员的活

再看 except 那个兜底:解析失败的回复直接返回 {}——每个字段都读作缺失,核查会报警,门控会升级。失败的方向是安全的:宁可多花一次推理钱,也不让一条坏记录混过去。

  • mini 的输出和 schema-valid: True 对上了;我能指出 description 为什么是编的、registration_open_date 为什么是对的

第 4 步:第二级——Jev 逐字段核查,一道 Noul 一个失败模式 ​

为什么:一道「这条提取对吗」的大题只能给一个分,说不出哪个字段错、为什么错。官方的做法就是 Decompose the questions——每个字段 × 每种失败模式,一道窄 Noul,措辞统一框成「true = 出问题了」,noul 就是 P(出问题)。旗标自带字段名和病因,每道题还能单独改措辞、单独调阈值。整个题组一次 system_one 调用并行问完(多题一请求的机制见并行提问与投机扇出)。

python
# metric -> (question, NoulCriteria)
MAIN_QUESTIONS = {
    "name_desc_mismatch": (
        "Does the `extracted_field` fail to match the field at `path` or the `description` in the "
        "`field_spec`? If the `description` is empty, judge against the `path` alone.",
        NoulCriteria(
            true="the `extracted_field` does not match the field name or its `description`",
            false="the `extracted_field` matches the field name and `description`",
        ),
    ),
    "type_mismatch": (
        "Does the `extracted_field` violate the `type` declared in the `field_spec`?",
        NoulCriteria(
            true="the `extracted_field` violates the declared `type`",
            false="the `extracted_field` conforms to the declared `type`",
        ),
    ),
    "unreasonable": (
        "Is the `extracted_field` one that a reasonable person would not have extracted for this "
        "`field_spec`?",
        NoulCriteria(
            true="a reasonable person would not have extracted this value",
            false="the extraction is reasonable",
        ),
    ),
    "hallucinated": (
        "Is the `extracted_field` unsupported by, or absent from, the source text?",
        NoulCriteria(
            true="the `extracted_field` is a hallucination -- not supported by, or absent "
            "from, the source text",
            false="the `extracted_field` is supported by the source text",
        ),
    ),
    "off_target": (
        "Does the source text fail to genuinely report the thing the `field_spec` describes, so the "
        "value was pulled from incidental text?",
        NoulCriteria(
            true="the source does not genuinely provide this field -- the value was pulled "
            "from incidental text",
            false="the source genuinely reports this field",
        ),
    ),
    "incomplete": (
        "Does the `extracted_field` fail to capture a value the source supports (note whether the "
        "`field_spec` is `required`)?",
        NoulCriteria(
            true="the field is wrongly empty, null, or missing a value the source supports",
            false="the field captures the value the source supports",
        ),
    ),
    "format_violation": (
        "Does the `extracted_field` violate the format or constraints implied by the `description`, "
        "the schema `type`, and the extraction instructions (e.g. date format, units, enum membership)?",
        NoulCriteria(
            true="the `extracted_field` violates the implied format or constraints",
            false="the `extracted_field` satisfies the format and constraints",
        ),
    ),
}
ABSENCE_QUESTION = (
    "The `extracted_field` is empty, null, or an empty collection. Does the source text contain the "
    "information the `field_spec` describes, making the empty result wrong?"
)
ABSENCE_CRITERIA = NoulCriteria(
    true="a value was wrongly omitted", false="returning nothing is correct"
)

OVERALL_JUDGE = (
    "Is this extracted record an incorrect extraction -- some value unsupported by the source or "
    "not conforming to the schema, required information missing or wrong, or some field hallucinated -- "
    "so it should be escalated to a smarter model?"
)
OVERALL_JUDGE_CRITERIA = NoulCriteria(
    true="the record is an incorrect extraction",
    false="the record is a correct extraction",
)


def is_empty(v) -> bool:
    return v is None or (isinstance(v, (str, list, dict)) and len(v) == 0)


def field_spec(name: str) -> dict:
    """Minimal spec pulled from the schema (unwrapping anyOf/null for optional fields)."""
    p = schema["properties"][name]
    branches = p.get("anyOf") or []
    typ = p.get("type") or next(
        (b["type"] for b in branches if b.get("type") != "null"), "unknown"
    )
    return {
        "path": name,
        "type": typ,
        "description": p.get("description", ""),
        "required": name in schema.get("required", []),
    }


def build_questions(record: dict) -> dict[str, Noul]:
    """The verify question set: one holistic __overall__ head plus a per-field battery,
    keyed field::metric."""
    questions: dict[str, Noul] = {
        "__overall__::judge": Noul(
            instructions=OVERALL_JUDGE, criteria=OVERALL_JUDGE_CRITERIA
        ),
    }
    # Loop over the schema, not the record, so a field the extractor left out still gets asked.
    for name in schema["properties"]:
        value = record.get(name)
        spec = field_spec(name)
        if is_empty(value):
            questions[f"{name}::absence_wrong"] = Noul(
                instructions={
                    "field_spec": spec,
                    "extracted_field": value,
                    "main_question": ABSENCE_QUESTION,
                },
                criteria=ABSENCE_CRITERIA,
            )
            continue
        for metric, (question, criteria) in MAIN_QUESTIONS.items():
            if metric == "type_mismatch" and spec["type"] == "unknown":
                continue
            questions[f"{name}::{metric}"] = Noul(
                instructions={
                    "field_spec": spec,
                    "extracted_field": value,
                    "main_question": question,
                },
                criteria=criteria,
            )
    return questions


@json_cache
def verify(record: dict) -> dict[str, float | str]:
    """Run the whole Noul battery over a record in one TypeSafe call; return {field::metric: P(true)}."""
    state = {
        "system_message": EXTRACT_SYSTEM,
        "instruction": "Extract the structured record from this document",
        "source_text": content,
        "schema": schema,
        "extraction": record,
    }
    questions = build_questions(record)
    answers = ts.system_one(state=state, questions=questions, model=TS_MODEL).answers
    return {qid: ans.noul for qid, ans in answers.items()} | {
        "playground_link": make_playground_link(state, questions)
    }


checks = verify(mini_record)
playground_link = checks.pop("playground_link")
print("playground link ready:", playground_link[:60], "...")
print(f"{'qid':<40}{'P(wrong)':>9}")
print("-" * 50)
for fld, p in sorted(checks.items(), key=lambda c: -c[-1]):
    flag = "  <== FIRES" if p > FIRE_T else ""
    print(f"{fld:<40}{p:>9.2f}{flag}")

运行。设了 TYPESAFE_API_KEY 会真发一次 Jev 请求(题组并行、一次往返),答案进缓存。

你应该看到(官方录制):

text
qid                                      P(wrong)
--------------------------------------------------
description::hallucinated                    0.98  <== FIRES
description::off_target                      0.97  <== FIRES
__overall__::judge                           0.90  <== FIRES
description::unreasonable                    0.86  <== FIRES
description::incomplete                      0.54
description::name_desc_mismatch              0.46
description::format_violation                0.41
registration_open_date::absence_wrong        0.05
description::type_mismatch                   0.04

只有错的字段分高:description 的三个头越过报警线(编造 0.98 / 错位 0.97 / 不合理 0.86);空着的日期字段是对的,absence_wrong 只有 0.05——Jev 没有因为它空就冤枉它。__overall__::judge 也报了 0.90,但它说不出哪个字段错、为什么错——官方留它只是为了对照「整条记录一道大题」和「逐字段题组」的差距;真正决定升级的是逐字段题组。

题组的构造还有三处要停下来看:

设计点做法为什么
7 个失败模式头hallucinated / off_target / unreasonable / incomplete / name_desc_mismatch / type_mismatch / format_violation编造、错位、漏抓、违反字段名或描述、类型违规、格式违规——一个字段非空就全套问一遍(type 未知时跳过 type_mismatch)
空字段只问 absence_wrong空值没有「内容」,能错的只剩「原文其实有信息,空着是错的」想编一个值的提取器把字段留空,也会被这颗头抓住
循环 schema["properties"] 而不是 record提取器漏掉的字段照样被问到逐 record 循环会把「漏字段」漏成「不检查」

问题的 instructions 全用结构化对象——field_spec(代码从 schema 抽的最小规格)、extracted_field、main_question 各占一个字段:问题与它引用的数据分开,换字段只换数据、不重写问题(结构化指令的用法见 Noul 是非题)。state 也是结构化的五字段:system_message / instruction / source_text / schema / extraction——核查员看得见提取时的完整上下文,包括当时的系统提示。

💡 这就是 Jev 的独有价值

核查员给的不是一段「看起来审过了」的文字,而是校准过的 P(出问题)——每字段每失败模式一个数,能排序、能设阈值、能画曲线。noul 概率对齐真实结果(这是 TypeSafe 的训练方式,见 Jev 是什么),0.98 和 0.05 之间的差距就是「这条我抓到了」和「这条冤枉不得」的差距。让普通 LLM「检查一下」,它给你的是一句「描述似乎不太对」——没有数,没有字段名,更没有病因。

  • 核查表和官方数字对上了;我能说出 description 触发的是哪三个头、registration_open_date 的 absence_wrong 为什么低

第 5 步:第三级——any_flag 门控,模型级联报警才升级推理模型 ​

为什么:核查表给了九个数,但「什么时候升级」必须是代码里一条明确的线。官方的门控叫 any_flag:任一逐字段头超过 FIRE_T 就升级——取 max,不取平均。取平均的话,0.98 的红旗会被其余头的低分稀释到报警线以下,一个自信的报警就被「平均成沉默」。

一个阈值能管用,前提是第 4 步那种分离度:错的字段分高、对的字段分低。这笔账也在这一步算——官方明说核查员必须远比推理 rung 便宜,否则级联省不下一分钱:每条都付「mini + 核查」,只有报警的条再付推理钱,这个结构才划算。

python
# any_flag is a per-field gate: the holistic __overall__ head is shown above but not part of it
fired = {
    qid: p
    for qid, p in checks.items()
    if not qid.startswith("__overall__") and p > FIRE_T
}
escalate = bool(fired)

print(
    f"any_flag gate (threshold {FIRE_T}): {'ESCALATE' if escalate else 'ACCEPT cheap result'}"
)
for qid, p in sorted(fired.items(), key=lambda c: -c[1]):
    print(f"  fired: {qid}  (P={p:.2f})")

运行 python sde_cascade.py。门控是纯代码,不发请求。

你应该看到(官方录制):

text
any_flag gate (threshold 0.7): ESCALATE
  fired: description::hallucinated  (P=0.98)
  fired: description::off_target  (P=0.97)
  fired: description::unreasonable  (P=0.86)

三条 fired 全部指向 description;__overall__::judge 那个 0.90 被显式排除在外——它只作对照,不进门控。报警了才付推理钱:gpt-5.5 配 reasoning_effort="high" 重抽这一条;没报警的条直接用 mini 的结果。

python
final_record = (
    extract(REASONING, prompt, schema, content, reasoning_effort="high")
    if escalate
    else mini_record
)

print("mini      :", json.dumps(mini_record))
print("reasoning :", json.dumps(final_record))
print("\nfield-level diff (mini -> final):")
for name in mini_record:
    if mini_record[name] != final_record.get(name):
        print(f"  {name}: {mini_record[name]!r}  ->  {final_record.get(name)!r}")

运行。设了 OPENAI_API_KEY 会真发一次推理请求,答案进 json_cache.json。

你应该看到(官方录制):

text
mini      : {"registration_open_date": "", "description": "Registration opens for the fall semester"}
reasoning : {"registration_open_date": "", "description": ""}

field-level diff (mini -> final):
  description: 'Registration opens for the fall semester'  ->  ''

diff 只有一行:推理模型把编造的 description 扔掉了,换成空串——它看出来页面从没描述过注册日期,拒绝发明。registration_open_date 两边都是空串,没动。级联在这条记录上付了推理钱,而且是因为核查点名才付的。

  • 门控打印 ESCALATE、三条 fired 与官方一致;diff 里 description 变空串;我能说出为什么取 max 不取平均、__overall__::judge 为什么不进门控

第 6 步:看懂官方 100 条的成本/质量曲线 ​

为什么:FIRE_T = 0.7 不是拍出来的。官方把同一条循环(extract → verify → escalate,gpt-5.4-mini → gpt-5.5-reasoning,逐字段头上的 any_flag 门控)在 100 条 ScrapeGraphAI-100k 提取任务上整条跑过:TypeSafe 给每条的便宜 rung 提取打核查分,门控阈值从 0 扫到 1,每个阈值画成一个(成本, 质量)点。

注意出处:这是官方的内部测试结果,来自较早的一轮独立运行——那一轮的核查模型是 jev-1.12(本页示例用的是 jev-1.14.0);质量分是 LLM judge 给每条提取打的分(图上的 mean llm_judge)。

官方内部结果:100 条提取任务上的成本/质量前沿

读法四条:

图上元素是什么怎么读
黑色菱形四个模型各自单跑成本随能力上升;最强的 gpt-5.5-reasoning 在右上角——最贵最好
蓝色点(any_flag)级联在许多不同门控阈值下的配置阈值越低越多条被升级,越高越少;一个阈值一个点
虚线pareto 前沿同成本下质量最高的级联配置连起来
前沿的位置在每个单模型的左上方级联点约 $0.02/条得约 0.79;gpt-5.5-reasoning 单跑 0.82 要约 $0.045

一句话:级联前沿压着每个单模型的左上方——便宜 rung 干掉容易的条,只有被点名的条才付推理钱,所以同成本下质量更高、同质量下成本更低。0.7 不是魔法数,它只是 0→1 这条扫描线上的一个点;你自己的任务该钉在哪,得拿自己的数据扫一遍再定(魔法 Prompt 里有扫法)。

  • 我能在官方图上指出 pareto 前沿,说出「左上方」意味着什么,以及 0.79 / $0.02 与 0.82 / $0.045 两组数字各对应哪个点

📋 魔法 Prompt ​

本页的「Prompt」是给 API 的问题与代码模板——抄走、替换【】占位符(带 ? 的是选填)就能接到你自己的提取任务上。三件套:逐字段核查题组、any_flag 门控、阈值扫描。

📝 逐字段核查题组(Noul 版) ​

预期效果:任何「便宜模型产出 JSON 记录、要逐字段把关」的任务——商品参数、招聘信息、房产列表——同一副题组换 schema 就能用,旗标点名字段 + 病因。

python
# 必填项:【schema】你的 JSON Schema(字段 description 写清格式与示例);【记录】便宜模型的输出
#         失败模式头逐字抄官方七连:name_desc_mismatch / type_mismatch / unreasonable /
#         hallucinated / off_target / incomplete / format_violation
MAIN_QUESTIONS = {
    "hallucinated": (
        "Is the `extracted_field` unsupported by, or absent from, the source text?",
        NoulCriteria(
            true="the `extracted_field` is a hallucination -- not supported by, or absent "
            "from, the source text",
            false="the `extracted_field` is supported by the source text",
        ),
    ),
    【其余失败模式】: (
        【同一句式:这个字段是不是犯了这种错?】,
        NoulCriteria(true=【犯了这种错的描述】, false=【没犯的描述】),
    ),
}
ABSENCE_QUESTION = (
    "The `extracted_field` is empty, null, or an empty collection. Does the source text "
    "contain the information the `field_spec` describes, making the empty result wrong?"
)
# 选填项:按业务加头——单位、币种、语言、时效;某头总误报先改措辞再调线?
# 输出规范:qid 取 f"{字段名}::{失败模式}";true 一律 = 出问题,noul 就是 P(出问题)
# 约束条件:
# ✅ 循环 schema["properties"] 而不是记录——提取器漏掉的字段照样被问到
# ✅ 空字段只问 absence_wrong;type 未知跳过 type_mismatch
# ❌ 不要拿一道「这条提取对吗」的大题代替题组——大题说不出哪个字段、哪种错

✅ any_flag 门控(代码版) ​

预期效果:报警逻辑是一行纯代码——任一逐字段头过线就升级贵模型,没报警的条直接放行便宜结果。

python
# 必填项:【FIRE_T】报警线(官方示例 0.7;上线前用自己的数据扫一遍再定)
fired = {
    qid: p
    for qid, p in checks.items()
    if not qid.startswith("__overall__") and p > 【FIRE_T】
}
escalate = bool(fired)
final_record = (
    extract(【贵模型】, prompt, schema, content, reasoning_effort="high")
    if escalate
    else mini_record
)
# 选填项:逐头设线——官方明说每道题可单独调阈值,编造类从严、格式类从松?
# 输出规范:fired 字典就是审计日志——哪个字段、哪种病因、多大概率
# 约束条件:
# ✅ 取 max 不取平均——一个自信的红旗不能被低分稀释成沉默
# ✅ 解析失败的记录当空记录进核查——每个字段读作缺失,报警升级,方向安全
# ❌ 别把 __overall__::judge 门进门控——它只作对照,决定升级的是逐字段头

🔧 阈值扫描(选工作点版) ​

预期效果:不拍 0.7——拿一批任务把阈值从 0 扫到 1,每个阈值记一个(成本, 质量)点,画出你自己的前沿再钉工作点。

python
# 必填项:【任务集】一批提取任务(官方用 100 条 ScrapeGraphAI-100k);【打分】质量分怎么算
for fire_t in [i / 10 for i in range(11)]:        # 0.0 -> 1.0
    total_cost = total_quality = 0.0
    for item in 【任务集】:
        record = extract(【便宜模型】, item.prompt, item.schema, item.content)
        checks = verify(record)                    # 每条一次核查
        fired = any(
            p > fire_t for qid, p in checks.items()
            if not qid.startswith("__overall__")
        )
        final = extract(【贵模型】, item.prompt, item.schema, item.content,
                        reasoning_effort="high") if fired else record
        total_cost += 【便宜模型单价 + 核查单价 +(fired 时的贵模型单价)】
        total_quality += 【质量分:官方用 LLM judge 给每条提取打分】
    point = (total_cost / len(【任务集】), total_quality / len(【任务集】))
# 选填项:质量分用人工抽检代替 LLM judge,先抽 20 条看头的分离度?
# 输出规范:一张(成本, 质量)散点图;工作点钉在你预算线与前沿的交点
# 约束条件:
# ✅ 扫描和上线锁同一个核查版本 ID——别用会漂移的 jev-latest
# ✅ 先确认头的分离度(错字段高、对字段低),分离不开什么阈值都白搭
# ❌ 别把官方 0.79 / $0.02 当你的预期——那是 ScrapeGraphAI-100k + jev-1.12 那一轮的数字

检查点 ✅ ​

  • 第 5 步门控打印 ESCALATE、三条 fired 与官方录制一致;推理重抽把 description 换成空串,diff 只有一行
  • 我能说出这套模型级联三级的分工:mini 提取、Jev 核查(一次请求)、报警才付 gpt-5.5 推理钱
  • 我知道门控为什么取 max 不取平均、__overall__::judge 为什么只作对照不进门控
  • 我能解释「解析失败返回 {}」为什么是安全方向:每个字段读作缺失 → 核查报警 → 门控升级
  • 我能在官方 100 条图上指出 pareto 前沿,说出 0.79 / $0.02 与 0.82 / $0.045 各是哪个点、0.7 只是扫描线上的一个点

踩坑提醒 ​

❌ 门控取平均,红旗被稀释 ​

any_flag 是 max 型门控,官方特意强调「不是平均」:一个自信的红旗就够升级,而不是被一排低分头平均掉。本页例子里 description::hallucinated 高达 0.98,最低的头只有 0.04——取平均,这条编造的记录落在线下照样进库;取 max,它当场被抓。旗标分离度好(错字段高、对字段低)时,max 是最灵敏的门。

❌ 把 __overall__::judge 也算进门控 ​

门控代码里 not qid.startswith("__overall__") 是显式排除。官方留这道大题是为了对照「整条记录一道题」和「逐字段题组」的差距:它报 0.90,但说不出哪个字段错、为什么错。决定升级的永远是逐字段头——旗标的价值在「字段 + 病因」,门控自然也门在旗标上。

❌ 给提取开结构化输出 / JSON mode「防错」 ​

两个 rung 都用纯文本回复,官方的理由反直觉:这套级联要抓的错是 schema 合法的。约束解码保证形状对,但会把「因为读错页面而破坏 schema」的模型藏起来,让核查无从下手;形状烂的错 jsonschema 一行就能拦住。把语义核查的活交给解码器,等于放走最阴的错、留下最明显的错。

❌ 核查员不比推理模型便宜,级联白搭 ​

官方把话说死:核查员必须远比推理 rung 便宜,否则级联省不下一分钱。这套结构的账是:每条付「便宜提取 + 一次核查」,只有报警的条再付推理钱。核查若和推理同价,你等于每条都付了推理价,报警的条还多付一份。官方 100 条曲线上级联能压到约 $0.02/条,靠的就是核查远便宜 + 大多数条不报警。

进阶技巧(可选) ​

给失败模式头做加减法。 官方明说每道题可以单独改措辞、单独调阈值——题组就是个字典,加一个头就是加一行。官方七连是通用失败模式;你的领域有特定病灶(单位、币种、语言、时效)就单加头。动题之前记住下一条:改题不失效缓存。

改题不失效缓存,删文件才真跑。 JsonCache 的键只有函数名和参数——cooksafe 文档原话:改函数体不失效已有条目。verify 的题组在函数体里拼,改 MAIN_QUESTIONS 的措辞、加头,缓存键都不变,旧答案照放;想让新题真请求,先删 json_cache.json。调 FIRE_T 不受影响——门控是纯代码,不经过缓存。

免费玩官方核查题组。 官方 SDE cascade cookbook 在核查输出的下面附了做好的 Playground 分享链接——第 4 步那套题组连着 example.json 的完整 state,点开就能在浏览器里改措辞、看概率分布。想为你自己的记录生成链接,第 4 步代码里的 make_playground_link(state, questions) 已经打好——链接里带着完整 state 和问题,谁拿到链接谁就能读,别放敏感数据。

接你自己的批量管道。 三件套都是「schema + content 进,记录 + 旗标出」:schema 换你的字段表,content 换你抓的页面,extract / verify / 门控原样复用。跑起来之后别急着定阈值——攒一批数据按第 6 步的扫法画你自己的曲线,工作点钉在你预算线上。

本课小结 ​

✅ 三级模型级联 = 便宜提取 + Jev 全检 + 贵模型兜底:gpt-5.4-mini 纯文本提取(故意不开结构化输出)→ Jev 一次请求逐字段核查 → any_flag 报警才升级 gpt-5.5(reasoning_effort="high")。

✅ 旗标点名字段 + 病因:官方示例里编造的 description 三头报警(0.98 / 0.97 / 0.86),对的空字段 absence_wrong 只有 0.05;推理模型把编造值扔掉,级联只为被点名的条付推理钱。

✅ 门控取 max 不取平均,__overall__ 只作对照:任一逐字段头过 FIRE_T 就升级;解析失败返回 {} 是安全方向;这套账成立的前提是核查员远比推理 rung 便宜。

✅ 官方 100 条曲线定工作点:级联前沿在每个单模型左上方——约 $0.02/条得约 0.79,gpt-5.5-reasoning 单跑 0.82 要约 $0.045;0.7 只是扫描线上的一个点,自己的任务自己扫。

关键配置字段回顾:

字段本页的值说明
MINI / REASONINGgpt-5.4-mini / gpt-5.5(reasoning_effort="high")两个提取 rung,纯文本回复;换模型只改这两行
TS_MODELjev-1.14.0核查模型;官方 100 条那轮是 jev-1.12;live 跑锁版本 ID,别用 jev-latest
FIRE_T0.7any_flag 报警线;官方把 0→1 扫了一遍选工作点
失败模式头7 个 + absence_wrong(另有 __overall__::judge 对照)非空字段全套、空字段只问 absence_wrong、type 未知跳过 type_mismatch
缓存json_cache.jsonLLM + TypeSafe 调用都进缓存;改题不失效,删文件才真请求
每条请求数mini 1 次 + 核查 1 次(题组并行)+ 报警才加推理 1 次大多数条只付前两级的钱

相关教程:

下一课预告 ​

文本这条链核查完了,扫描件和 OCR 读数怎么把关?下一课:图像实战:读扫描件、核对 OCR 结果。

你会学到:

  • 扫描件读取流水线:Noul 找在不在、Box 找在哪、Choice 读值——被贴纸盖住的日期拿到 unreadable,而不是瞎猜的年份
  • 核对 OCR 抽取值:每个字段整页查一遍、裁剪图再细看一遍,低于 0.25 转人工,并在原图上画出复核框
  • 和本页同一套「校准概率 + 代码里的阈值」的把关思想——只是核查对象从文本提取换成了图像读数
搞懂AI关于我们怎么写隐私说明

独立的第三方教程,与文中提到的任何产品及其公司没有隶属或合作关系;产品名称和商标归各自所有者。价格和政策以官方页面为准。