能打开的引用并不等于证据。设想一个研究助手被问到:在海平面上,水是否在 50 摄氏度时沸腾。它给出一段自信的回答和一个链接。链接可以打开,页面上写的却是 100 度。URL 是真实的,但这条引用并不支持该论断。

这正是只检查生成答案的幻觉检测所遗漏的缺口。一句话可以流畅、合理,并附带一个可用的 URL,而被引用的来源说的却是另一回事。链接只是一个指针。验证它,意味着获取指针背后的内容,将其与论断进行比对,并保留做出判定所依据的证据。

本文中的服务将这一比对过程自动化。你提供一个论断以及模型引用的 URL,它会用 Crawling API 获取每个页面,保留与论断最相关的段落,请判定模型给出裁决,检查判定模型的证据引文是否确实出现在页面上,再把每条引用的结果合并为一个标签:supported, contradicted, not_found 或 unreachable.

要点速览
  • HTTP 200 不是证据。页面可以正常加载,却仍然是机器人拦截页、空壳页面、软 404 或站点首页。
  • 只向判定模型发送三个相关段落,而不是整个页面。噪声更少,成本更低,裁决更准确。
  • 永远不要轻信判定模型给出的引文。只有当引文确实是所获取页面中的子串时,才接受 supported 或 contradicted 裁决。
  • 用规则而不是另一个模型来汇总:一处矛盾的分量超过所有支持性引用。
  • 存储每一次获取的结果,以便在页面变化后仍能重放裁决。
五个阶段,其中只有一个是语言模型。 获取、检索、引文校验和汇总都是确定性的。判定模型负责解读证据;代码决定这种解读是否可以被采纳。

代码位于 ScraperHub/llm-citation-verification-service-with-crawling-api,每个阶段在 steps/ 下各有一个检查点,完整的包位于 final/citation_verifier/。除非另有标注,下文的代码片段均引自 final/。你需要一个 Crawlbase 账户;请将其令牌保存在环境变量中,切勿放进代码仓库。

获取被引用的页面,而不只是拿到一个 HTTP 200

为什么普通的 GET 不够

请求成功并不意味着你拿到了引用所指向的内容。单页应用可能返回 200,附带一个空的 <div id="root">。机器人拦截页可能在返回 200 的同时展示验证挑战。软 404 会返回 200,却展示“此页面已不存在”的模板;深层链接也可能重定向到首页。获取阶段要确认的不只是可达性:它会保留原始状态码,识别重定向,并返回适合作为证据使用的文本。

Markdown 加可读性处理

由 Crawling API 负责获取。验证器请求 format=md,它会以 GitHub Flavored Markdown 格式返回页面;同时请求 md_readability=true,它会在转换前剥离导航、侧边栏、页脚和广告,让检索直接从正文开始。它还设置了 store=true,以便日后审计每一次获取。

python
# final/citation_verifier/fetch.py
def crawl_options(readability: bool) -> dict[str, str]:
    """Query parameters for one Crawling API call.

    ``md_readability`` is omitted on the fallback request. The docs define it
    only as a switch on ``format=md``, not as a guarantee about thin pages.
    """
    options = {
        "format": "md",
        "store": "true",
        "custom_success_codes": "404,410",
    }
    if readability:
        options["md_readability"] = "true"
    return options

这里有一个参数其实不起作用。custom_success_codes 用于处理 API 原本会视为失败的源站状态码,但 404 和 410 本身就算作成功的抓取:它们返回的 cb_status 为 200,并附带真实的 original_status,而且不会被重试。删掉它不会改变任何行为。

在内容很短或结构特殊的页面上,可读性处理可能剥离过多内容,因此验证器会把少于 200 个非空白字符的结果视为处理失败,并重新获取同一 URL,但这次不带 md_readability。200 个字符的阈值是应用层规则,而非 Crawlbase 的限制。两次调用都会计费,并且在使用 store=true 时两个页面都会被存储,所以只对确实需要的页面保留这次重试。

死链、软 404 与首页重定向

大多数有问题的引用在任何模型运行之前就会被拦下。 死链、首页重定向和 PDF 都会在获取阶段被打上标签,只有包含可用文本的页面才会进入检索。

每个响应都附带两个状态码。original_status 是被引用站点给出的应答;cb_status 是 Crawlbase 对这次抓取给出的结果。凡是站点应答 404 或 410 的引用,验证器都会标记为 unreachable,并附上原因,例如 http_404,且该引用永远不会到达判定模型。这次抓取仍然计费,因为 Crawlbase 确实获取到了应答:404 是对一个不存在页面的成功抓取。

重定向需要单独检查。一个落到 / 的深层链接会返回一个完全正常的页面,但它对论断只字未提,而通用的首页文本可能看起来像是支持。如果请求的路径深度超过 / 而最终路径却是 /,该引用就会变为 not_found,原因为 redirected_to_homepage,并跳过判定模型。

python
# final/citation_verifier/fetch.py
def _normalized_path(url: str) -> str:
    path = urlsplit(url).path or "/"
    if len(path) > 1 and path.endswith("/"):
        path = path[:-1]
    return path or "/"


def is_homepage_redirect(requested: str, final: str | None) -> bool:
    """True when a deep link landed on ``/``.

    The Crawling API returns the post-redirect URL in the ``url`` header.
    A citation that survives only as the site homepage did not survive.
    """
    if not final:
        return False
    return _normalized_path(requested) != "/" and _normalized_path(final) == "/"

响应中的 url 头携带的是 Crawlbase 在普通请求中跟随重定向之后的最终 URL。在 JavaScript 渲染的请求中,浏览器自行执行的重定向可能使 url 仍然是你所请求的 URL,因此应把首页检查视为普通路径上的强信号,以及渲染路径上的尽力而为判断。

软 404 另行处理:一个 200 页面只有在其可读文本是一段少于 800 个非空白字符、内容为“页面不存在”的简短模板时,才会被视为软 404,因此一篇只是提到 404 的长文章不会被丢弃。

何时改用 JavaScript 令牌

渲染的成本更高,因此验证器对每条引用都先使用普通令牌,只有当第一次尝试表明页面需要浏览器时才升级。JavaScript 请求的计费积分是普通请求的两倍。你可以像代码仓库那样,用 JavaScript 令牌创建第二个客户端来升级;也可以只保留一个使用普通令牌的客户端,并在重试时添加 javascript=true;无论哪种方式,该请求都按 JavaScript 请求计费。

代码仓库中的升级检查位于 fetch.py,当 cb_status 的值为 520 或 525 时触发。实际上这两个信号都不会出现。520 根本不是一个 cb_status 值:它是抓取失败时 Crawling API 返回的 HTTP 状态码,而固定使用的 crawlbase 1.0.0 SDK 返回此类响应时,响应体为空且没有任何响应头,因此根本没有 cb_status 可读。服务器应答 200 但内容为空的页面,返回的 cb_status 为 207,而 525 是一个与反机器人挑战无关的内部代码。请根据 API 实际发送的信号来升级:

python
# Production shape, not from the repository: when to retry on the JavaScript token.
def needs_javascript(response: dict, markdown: str, min_chars: int) -> bool:
    if response.get("status_code") == 520:
        return True  # the crawl failed; failed requests are not billed
    headers = response.get("headers") or {}
    if str(headers.get("pc_status")) == "207":
        return True  # the origin answered 200 with an empty page
    return len("".join(markdown.split())) < min_chars  # still thin after the readability retry

代码仓库固定的 SDK 版本使用旧名称暴露该状态码,即 pc_status;API 同时也会以 cb_status 的名称发送它,新的集成应读取 cb_status。完成 JavaScript 尝试后,再次运行同样的解析逻辑(包括可读性回退),让渲染后的页面经历与静态页面完全相同的检查。

在询问判定模型之前先检索证据

把整个页面发给判定模型既昂贵又嘈杂。一篇 2,000 词的文章可能只有两句相关的话,其余内容只会给模型更多机会去抓住导航、免责声明或无关文本。因此,每个页面会先被切分成段落:按空行拆分,把短段落合并打包到最多 200 词,并在词边界处拆开较长的段落。200 词的预算使用以下方式计数:split(),因此无需分词器就能让段落长度大致均衡。

python
# final/citation_verifier/passages.py (excerpt)
def split_passages(markdown: str, max_words: int = DEFAULT_MAX_WORDS) -> list[str]:
    """Split on blank lines, then pack up to ``max_words`` words.

    A paragraph longer than the cap is cut on word boundaries. Shorter
    paragraphs share a passage until the next one would overflow. Blank
    blocks are dropped.
    """
    if max_words < 1:
        raise ValueError("max_words must be at least 1")
    text = markdown.replace("\r\n", "\n").replace("\r", "\n")
    pieces: list[list[str]] = []
    for paragraph in re.split(r"\n\s*\n", text):
        words = paragraph.split()
        if not words:
            continue
        if len(words) > max_words:
            for start in range(0, len(words), max_words):
                pieces.append(words[start : start + max_words])
        else:
            pieces.append(words)

随后,使用 all-MiniLM-L6-v2 嵌入和余弦相似度,按与论断的相关度对段落排序,并保留排名前三的段落。模型在首次检索调用时才加载,因此导入这个包永远不会触发下载。

python
# final/citation_verifier/retrieval.py
def top_k_passages(
    claim: str,
    passages: list[str],
    embedder: Embedder,
    k: int = DEFAULT_TOP_K,
) -> list[str]:
    """Return up to ``k`` passages, highest cosine similarity first."""
    if k < 1 or not passages:
        return []
    vectors = embedder.embed([claim, *passages])
    query = vectors[0]
    ranked = [
        (cosine(query, vectors[index + 1]), index, passage)
        for index, passage in enumerate(passages)
    ]
    ranked.sort(key=lambda item: (-item[0], item[1]))
    return [passage for _, _, passage in ranked[:k]]

三是一个刻意设定的上限。只取一个段落时,一旦最佳证据排在第二位,检索就会失效;取十个则会把检索本应去除的噪声大部分带回来。落在前三之外的证据,最终结果为 not_found,对于验证器从未展示给判定模型的证据,这是诚实的答案。

让判定模型返回证据,而不只是裁决

判定模型负责解读证据;它不是事实来源。模型可能会返回一个自信的 supported,附带的引文却从未在页面上出现过;严格的 JSON 能让输出可预测,却不能让它诚实。因此,提示词把判定模型限制在三种标签之内,并要求每个裁决都附带一段从段落中原样复制的连续引文,唯一的例外是 not_found. unreachable 则永远不会到达模型;它由获取阶段指定。

python
# final/citation_verifier/judge.py
SYSTEM_PROMPT = """You verify one claim against the passages in the user message. The passages are the only evidence you may use.

Return JSON with these fields:
- label: supported, contradicted, or not_found
- evidence_quote: a contiguous quote copied from the passages, or null when the label is not_found
- confidence: a number from 0 to 1

supported means the passages state the claim. contradicted means the passages state the opposite. not_found means the passages do not settle the claim. If you cannot copy a quote that appears in the passages, return not_found and null. Do not add words, numbers, or sources that are not in the passages."""


class RawJudgement(BaseModel):
    label: Label
    evidence_quote: str | None = None
    confidence: float = Field(ge=0, le=1)

OpenAI 是默认的判定模型,通过 httpx 调用,严格的 JSON schema 写在 response_format; JUDGE_PROVIDER=anthropic 和 JUDGE_PROVIDER=ollama 则在其他提供方上运行同一个提示词。提供方改变的是裁决的产生方式,而不是决定裁决能否被采纳的规则。

用页面校验引文

引文校验这一步把判定模型从神谕变成了解读者。它会把弯引号替换为直引号、合并空白字符,并要求引文作为精确子串出现在检索到的段落中。任何 supported 或 contradicted 裁决,只要缺少匹配的引文,就会被降级为 not_found,置信度设为 0,原因为 evidence_quote_not_in_source.

只有附带页面中确实存在的引文,裁决才能保留。 判定模型可能对论断判断正确,却仍然捏造证据;子串检查会在裁决生效之前发现这一点。
python
# final/citation_verifier/judge.py
def guard_judgement(
    raw: RawJudgement,
    passages: list[str],
) -> tuple[Label, str | None, float, str | None]:
    """Drop a quote the passages do not contain.

    Supported and contradicted both need a real quote. A quote that fails the
    check downgrades the citation to not_found with confidence 0.
    """
    quote = raw.evidence_quote
    if raw.label in {Label.supported, Label.contradicted}:
        if quote and quote_in_passages(quote, passages):
            return raw.label, quote, raw.confidence, None
        return Label.not_found, None, 0.0, REASON_QUOTE
    if quote and not quote_in_passages(quote, passages):
        return Label.not_found, None, 0.0, REASON_QUOTE
    return Label.not_found, None, raw.confidence, None

在沸点的例子中,论断说的是 50 度,而页面写的是 100 度,因此 contradicted 裁决得以保留:它的引文就在页面上。只有当证据通过检查时,模型的置信度才会被保留;汇总环节会忽略置信度,只依据标签进行。

将其发布为 /verify API

完成后的服务有两个路由:GET /health 和 POST /verify。请求包含一个论断以及一个或多个被引用的 URL;响应包含论断级别的标签,以及每条引用的裁决,其中包括引文、置信度、最终 URL、original_status、存储 rid,以及验证器给出的任何原因。

python
# final/citation_verifier/schema.py
class UrlVerdict(BaseModel):
    url: str
    label: Label
    evidence_quote: str | None = None
    confidence: float = Field(ge=0, le=1)
    final_url: str | None = None
    original_status: int | None = None
    rid: str | None = None
    reason: str | None = None


class VerifyRequest(BaseModel):
    claim: str = Field(min_length=1)
    urls: list[str] = Field(min_length=1)

汇总是一条固定规则,而不是又一次模型调用。只要有一条 contradicted 引用,就压过所有 supported 引用;在没有矛盾的情况下,只要存在支持,论断就成立;论断被判为 unreachable 仅当每条引用都是如此;其余情况一律为 not_found.

python
# final/citation_verifier/aggregate.py
def aggregate(verdicts: list[UrlVerdict]) -> Label:
    """One contradiction outweighs every supporting citation.

    Otherwise any supported citation carries the claim. The claim is
    unreachable only when every citation is unreachable. Every remaining mix,
    including an empty list, is not_found.
    """
    labels = [verdict.label for verdict in verdicts]
    if any(label == Label.contradicted for label in labels):
        return Label.contradicted
    if any(label == Label.supported for label in labels):
        return Label.supported
    if labels and all(label == Label.unreachable for label in labels):
        return Label.unreachable
    return Label.not_found

HTTP 层保持精简:POST /verify 把请求交给验证器,并在缺少必需的令牌时返回 503,同时指明对应的环境变量名称,但不暴露其值。代码仓库的试运行 python steps/step_07_api/main.py --dry-run,会生成如下响应。其中的页面是测试夹具,托管于 example.com,两次 Crawlbase 请求是真实发出的,而由于没有设置 OpenAI 密钥,判定模型使用的是一句夹具文本,因此请把它当作夹具来读,而不是模型的真实运行轨迹:

json
{
  "claim": "Water boils at 100 degrees Celsius at sea level.",
  "overall": "supported",
  "citations": [
    {
      "url": "https://example.com/articles/water-boiling-point",
      "label": "supported",
      "evidence_quote": "Water boils at 100 degrees Celsius at sea level.",
      "confidence": 0.96,
      "final_url": "https://example.com/articles/water-boiling-point",
      "original_status": 200,
      "rid": "rid-boil",
      "reason": null
    },
    {
      "url": "https://example.com/old-report",
      "label": "unreachable",
      "evidence_quote": null,
      "confidence": 1.0,
      "final_url": "https://example.com/old-report",
      "original_status": 404,
      "rid": "rid-404",
      "reason": "http_404"
    }
  ]
}

论断的结果是 supported:一条引用支持它,另一条则已经不存在。这一区别正是保留四种标签的意义所在。失效的来源并不构成反对论断的证据;内容相反的有效来源才是。

Crawlbase Crawling API

以干净的 Markdown 获取引用所指向的页面,在需要浏览器时进行渲染,并同时附带站点的真实状态码。失败的请求不计费。从最多 5,000 次免费请求开始,无需信用卡。

对一批引用评估引用精确率

单个经过验证的论断可以演示整个流水线,却无法告诉你模型引用的可靠程度。为此,验证器会对一批数据评分:一个 JSONL 文件,其中每条记录包含一个 id、一个论断及其引用的 URL,每一对论断与 URL 计为一条引用。引用精确率等于被支持的引用数除以全部引用数。仅可达精确率使用相同的分子,但从分母中去掉无法访问的引用,从而区分“模型引用了死链”和“模型引用了有效页面,但页面并未说出它所声称的内容”。

python
# final/citation_verifier/batch.py
@dataclass(frozen=True)
class Precision:
    """Citation precision over claim-URL pairs.

    citation precision is supported citations divided by every citation.
    Reachable-only precision uses the same numerator and drops unreachable
    citations from the denominator. It is None when nothing was reachable.
    """

    supported: int
    total: int
    reachable: int

运行的并发数由 asyncio.Semaphore (BATCH_CONCURRENCY,默认值为 4) 加以限制;由于 Crawlbase SDK 调用是同步的,它会在工作线程中运行,而不会阻塞事件循环。在示例夹具上,试运行报告了九条引用,其中两条被支持,三条无法访问:

text
citation_precision=0.2222 (2/9)
reachable_precision=0.3333 (2/6)

设置好令牌和判定模型密钥后,用 python -m citation_verifier.batch your_outputs.jsonl --out citation_report.csv 针对你自己的输出运行它。先从几十个论断开始,并首先阅读 contradicted 行:一处经过验证的矛盾,就可能暴露出聚合准确率分数所掩盖的问题。

用 Cloud Storage 保留证据

精确率分数的价值,取决于你日后检查它的能力,而被引用的页面可能在你检查后的第二天就发生变化。如果启用 store=true,Crawlbase 会把它返回的 Markdown 保存在 Cloud Storage 中,保留 30 天,响应中还会附带一个 storage_url,其查询字符串中包含该页面的 rid。验证器只保存 rid,因为完整 URL 中包含令牌;同时它还维护一个小型 SQLite 审计索引,记录 rid、论断、URL、裁决、置信度和时间。存储会让每个页面的抓取额外增加半个积分;读取已存储的页面是免费的。

python
# final/citation_verifier/audit.py (docstring omitted)
def replay_rid(rid: str, token: str | None = None) -> str:
    if not rid:
        raise ValueError("rid is required")
    settings = get_settings()
    used = token if token else settings.crawlbase_token
    if not used:
        raise MissingTokenError(
            "CRAWLBASE_TOKEN is not set. Replay needs the token that stored the page."
        )
    client = StorageAPI({"token": used})
    response = _storage_get(client, rid)

已存储的页面属于你的账户,而不属于抓取它们的那个令牌,因此任一令牌都可以重放任何 rid;代码仓库中的注释说法与此相反,你可以忽略其中的这一限制。在固定使用的 crawlbase 1.0.0 SDK 中,StorageAPI.get 以位置参数的形式接收 rid。从未生成存储页面的引用(例如被跳过的 PDF)仍然会获得一条审计记录,其中以下字段为空:rid,因此即使没有可重放的正文,判定也会被记录下来。

局限:付费墙、PDF 与多来源论断

付费墙。 付费墙后的页面可能返回 200,内容却只有订阅提示。如果检索找到的就是这些内容,正确的裁决是 not_found:可公开访问的页面并不支持该论断。这比“该论断为假”的范围更窄,也比那些会在仅仅提到订阅的文章上误判的付费墙启发式规则更诚实。

PDF。 以 .pdf 结尾的 URL 会被标记为 not_found,原因为 pdf_unsupported。Crawlbase 的 pdf=true 是把网页渲染成 PDF;它并不能从本身就是 PDF 的 URL 中提取文本,因此验证器会选择拒绝,而不是去判定它无法担保的文本。

多来源论断。 每个 URL 都单独判定。只有把两份文档结合起来才能推出的论断,会保持为 not_found,因为子串校验可以证明引文来自某个页面,却无法验证跨页面的推理。验证器并不确定一个论断是否为真;它确定的是,在检查之时,被引用且可访问的来源是否支持该论断。

结论

引用验证应当作为数据校验步骤放进流水线,而不是在模型之上再叠加一个提示词。获取被引用的来源,把它精简为相关证据,让模型解读这些证据,然后在任何结果生效之前应用确定性检查。这样,每一种失败都有一个名称和一个原因,你可以对其进行测试和审计。

先从试运行的 /verify 流程开始,代码位于 ScraperHub/llm-citation-verification-service-with-crawling-api,然后 创建一个免费的 Crawlbase 账户,设置 CRAWLBASE_TOKEN, CRAWLBASE_JS_TOKEN 和 OPENAI_API_KEY,然后针对你自己输出中的引用运行它。

常见问题(FAQ)

验证器如何检查 LLM 的来源?

它使用 Crawling API 以 Markdown 形式获取每个被引用的 URL,保留与论断最相关的三个段落,然后请判定模型给出一个标签和一段支持性引文。随后,一个确定性的校验步骤只在引文是所获取段落的精确子串时才接受该裁决。

被引用的页面已不存在时会怎样?

站点应答 404 或 410 的引用会被标记为 unreachable,并且永远不会到达判定模型。重定向到首页的深层链接则被标记为 not_found,因为它原本指向的页面在该地址上已不复存在。

为什么要以 Markdown 形式获取页面?

Markdown 保留了页面的结构但去掉了标记,因此能被干净地切分为段落,且消耗的词元远少于 HTML。如果启用 md_readability=true,导航、侧边栏、页脚和广告会在转换前被移除,因此检索直接从内容本身开始。

引用什么时候需要 JavaScript 令牌?

当使用普通令牌的抓取失败,或者返回的是空壳页面时。不经渲染时,在浏览器中构建的页面看起来就是这样。请使用 JavaScript 令牌重试这些页面,或者在普通令牌上使用 javascript=true 进行重试。渲染请求按两倍积分计费,这正是它作为回退方案而非默认选项的原因。

它能验证 LLM 提出的任何论断吗?

它验证的是:一个可访问的被引用页面是否支持或反驳该论断。它无法验证需要结合多份文档的论断,无法从 PDF URL 中提取文本,也无法看到付费墙后的内容。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量