iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
AI Engineering

從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent系列 第 22 篇

Day 22 - 四重驗證:Checksum、Logprob、Tesseract、MOPS 領域語意 Verifier

  • 分享至 

  • xImage
  •  

昨天把 Subagent 的邊界畫完,MOPS Domain Verifier 有了自己的位置,跟 Field Checker、Text Verifier 並排。可是 Day 8 規劃的是四層:Checksum、Logprob、Tesseract、MOPS。另外三層還沒有座位,而且昨天最後那個問題還懸著:便宜的已經 FLAG 了,貴的還要不要跑?

四個都跑,順序怎麼排?

每一層都跑全量,一頁要多等好幾秒。只跑其中一層,又回到 Day 10 那句話:任何單一指標都有它系統性看不到的失敗模式。


先回到那個讓我很不舒服的例子

Day 6 的 page_24,模型把「阻燃」讀成「防燃」。

這個錯我到現在還覺得很陰。兩個都是合法的詞,句子一樣通順,finish_reason 是 stop,沒有任何欄位在喊救命。同一頁還有「綠→線」「載具→製具」「補強→補償」,全部是同一種味道:形近字被語言模型的先驗「修」成一個更常見的詞。

Day 5 叫它同源盲點。你叫同一顆模型再看一次,它會很有共識地錯第二次。

所以想四重驗證的時候,我腦中先浮出來的是一張「哪一層抓得到哪一種錯」的對照表,檢查項目清單反而是後來才整理的。把前面幾天真的踩過的錯放進去:

實際錯誤(出處) Checksum Logprob Tesseract MOPS 語意
輸出開頭多一句「這張圖片中的文字內容如下」(Day 2 的 page_01、Day 15 的緊裁輸出都有) 抓得到 抓不到 抓不到 抓不到
(1,678) 變成 1,678,負號不見(Day 2) 有合計列時抓得到 看運氣 可能 有 XBRL 對應科目時抓得到
阻燃 → 防燃(Day 6 page_24) 抓不到 部分 抓得到 抓不到
一年內到期長期負債 → 短期負債(Day 5) 抓不到 部分 看字級 抓得到
page_19 撞 max_tokens 後重複退化(Day 6) 抓得到(輸出不是合法 JSON) 抓不到 抓得到 抓不到

這張表我畫了三次。第一次把 Logprob 那欄幾乎全填「抓得到」,後來才改掉,原因後面講。

橫著看,沒有一列四格全空,這讓我安心一點。直著看,也沒有一欄是全滿的。


第一層:Checksum,其實是三件事綁在一起

規劃書寫 Checksum,我一開始也以為就是「驗 JSON 格式」。寫下去才發現它應該包三件事:

  • 輸出長得對不對(schema)
  • 數字自己跟自己對不對得起來(明細加總等於合計)
  • 這份輸出是不是真的屬於這張圖(圖片雜湊)

第三件是 Day 15 裁 page_61 時踩出來的:座標抓錯,隔壁欄的文字被一起裁進去。那次是人發現的。Harness 一次派出幾十個區塊,結果配錯了靠人眼是看不出來的,所以送出去時就算好 sha256,回來要對上。

Day 10 說過「格式越簡單,Pydantic 越好驗」。所以表格區輸出壓成最扁:一列一個標籤、一個原始值,不要 Markdown 框線。純文字區則只收一個 text 欄位。這也是 Day 18 留給今天的那個尾巴:prompt_for(kind) 要吐的格式,就定在這裡。

from __future__ import annotations

import hashlib
import re
from decimal import Decimal, InvalidOperation

from pydantic import (
    BaseModel, ConfigDict, Field, ValidationError,
    field_validator, model_validator,
)

_AMOUNT_RE = re.compile(r"^\(?-?\d{1,3}(,\d{3})*\)?$|^\(?-?\d+\)?$")


def parse_amount(raw: str) -> Decimal:
    """財報金額:括號代表負數。只在驗證時解析,原字串不動。"""
    s = raw.strip()
    negative = s.startswith("(") and s.endswith(")")
    s = s.strip("()").replace(",", "")
    try:
        value = Decimal(s)
    except InvalidOperation as exc:
        raise ValueError(f"金額無法解析:{raw!r}") from exc
    return -value if negative else value


class TableRow(BaseModel):
    model_config = ConfigDict(extra="forbid")

    label: str = Field(min_length=1)
    raw_value: str  # 保留模型原樣輸出,例如 "(1,678)"

    @field_validator("raw_value")
    @classmethod
    def looks_like_amount(cls, v: str) -> str:
        if not _AMOUNT_RE.match(v.strip()):
            raise ValueError(f"不像金額格式:{v!r}")
        return v


class TableRegionOutput(BaseModel):
    model_config = ConfigDict(extra="forbid")  # 多一個欄位就算錯

    region_id: str
    source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
    unit: str                      # 千元、元……不讓模型省略
    rows: list[TableRow] = Field(min_length=1)
    total_label: str | None = None
    total_raw: str | None = None

    @model_validator(mode="after")
    def detail_sums_to_total(self) -> "TableRegionOutput":
        if self.total_raw is None:
            return self
        detail = sum(parse_amount(r.raw_value) for r in self.rows)
        total = parse_amount(self.total_raw)
        if detail != total:
            raise ValueError(f"明細加總 {detail} 與合計 {total} 不符")
        return self


class TextRegionOutput(BaseModel):
    model_config = ConfigDict(extra="forbid")

    region_id: str
    source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
    text: str = Field(min_length=1)

    @field_validator("text")
    @classmethod
    def no_preamble(cls, v: str) -> str:
        if v.lstrip().startswith(("這張圖片", "以下是")):
            raise ValueError("輸出開頭是模型自己加的說明文字")
        return v


def checksum_layer(kind: str, raw_json: str, crop_bytes: bytes):
    """kind 是 Day 18 Region.kind。回傳 (解析後物件或 None, 問題清單)。"""
    schema = TableRegionOutput if kind == "table" else TextRegionOutput
    try:
        out = schema.model_validate_json(raw_json)
    except ValidationError as exc:
        return None, [err["msg"] for err in exc.errors()]
    if out.source_sha256 != hashlib.sha256(crop_bytes).hexdigest():
        return None, ["輸出對應的圖片雜湊不符,可能配錯區塊"]
    return out, []

我拿一個手做的小例子試過:兩列明細 3,000 和 (1,678),合計 1,322。原樣丟進去是空清單,通過。把 (1,678) 改成 1,678,也就是 Day 2 那個負號消失的錯,回來的是:

['Value error, 明細加總 4678 與合計 1322 不符']

再多塞一個 "note" 欄位進去,extra="forbid" 直接擋下:Extra inputs are not permitted。模型想在 JSON 裡偷偷補一句「註:圖片右下角另有數字」(Day 6 錯誤分類表裡 page_09 那筆 hallucination 就長這樣),沒有地方放。

例子裡的數字是我編的,只用來確認驗證器的行為。

Tip:加總檢查只在區塊裡同時有明細和合計時才有意義。Day 2 那個負號錯誤,所在的表要是有合計列就抓得到;換成沒有合計列的附註表,這條規則就是空的。我寧願讓 total_raw 可以是 None,也不要讓模型為了過關自己「算」一個合計出來,那等於把答案卷交給考生寫。

這層便宜、結果固定,格式錯就是錯。但它只管長相和內部算術,「阻燃」寫成「防燃」,JSON 還是完美的。


第二層:Logprob,我最不信任的一層

先說一個有點丟臉的事實。

我回頭翻了 experiments/ 底下所有 raw response,每一份都寫著 "logprobs": null。不是端點不給,endpoint_probe/models.response.json 裡 gemma-4-26b 的 permission 明明寫著 "allow_logprobs": true。是我從 Day 2 開始打請求就沒要過。

所以這一層目前沒有任何實測數字,我手上有的只有設計。

要拿到它很簡單,vLLM 的 OpenAI 相容介面在 chat.completions.create 多帶 logprobs=True, top_logprobs=5,回應的 choices[0].logprobs.content 就是每個 token 一筆。

每一筆有 token、logprob 和前 5 名候選。我除了看機率高低,也看第一名跟第二名差多少。Day 8 寫過「形近字誤判時,兩個候選的機率常常很接近」,那是推論,沒量過,函式就照這個想法寫:

import math


def suspicious_spans(content, min_prob: float = 0.80, min_margin: float = 0.25):
    """找出模型自己也猶豫的位置。

    content: resp.choices[0].logprobs.content 轉成 dict 後的 list
    min_prob / min_margin: 起始值,是我先抓的,不是量出來的
    """
    spans = []
    pos = 0
    for tok in content:
        text = tok["token"]
        p1 = math.exp(tok["logprob"])
        alts = sorted(
            (t for t in tok.get("top_logprobs", []) if t["token"] != text),
            key=lambda t: t["logprob"],
            reverse=True,
        )
        p2 = math.exp(alts[0]["logprob"]) if alts else 0.0
        if p1 < min_prob or (p1 - p2) < min_margin:
            spans.append({
                "start": pos,
                "end": pos + len(text),
                "token": text,
                "p1": round(p1, 3),
                "runner_up": alts[0]["token"] if alts else None,
                "p2": round(p2, 3),
            })
        pos += len(text)
    return spans

用假資料跑,假設「阻」只有 0.52、「防」有 0.41,函式會回一筆 {"token": "阻", "p1": 0.52, "runner_up": "防", "p2": 0.41, ...}。機率是我填的,只證明函式邏輯通。

然後說說為什麼它是我最不信任的一層。

logprob 是模型對自己的看法,而同源盲點講的正是這種看法不可靠。模型的先驗強到「確信」這裡是「防燃」的話,機率會很高,這層就安靜放行。我第一次把對照表的 Logprob 欄填滿,就是忘了這件事。它抓得到模型「自己也覺得怪」的錯,抓不到模型「覺得很對」的錯,偏偏後者最危險。

實作上還有個坑:token 不等於字。好幾個字可能黏成一個 token,罕用字則可能被 tokenizer 拆成 byte 片段,這時 len() 算出來的位置就歪了。回傳實際長什麼樣子我還沒驗證,列為待查。

所以 Logprob 在我的設計裡只做一件事:當觸發器。它標出來的位置,交給第三層去看。它自己不判任何人死刑。

Tip:0.80 和 0.25 拜託不要直接抄,那是我拍腦袋的起始值。要等 Day 25 的飛輪跑起來,拿「人工確認是錯的 token」的機率分布回頭調。沒有資料撐著的門檻,寫得越精確越假。


第三層:Tesseract,一個比較笨但比較誠實的對照組

Day 8 我寫過一句我很喜歡的話:它的錯誤是吵的,不是安靜的。

Tesseract 沒有「這樣寫比較通順」的偏好,看到「阻」就輸出「阻」,看不清楚就吐亂碼或空白。整體準確率它比不上 VLM,這我承認。但我要的不是一個比較準的第二意見,是一個錯法不一樣的第二意見。

兩個錯法不一樣的系統,意見分歧的地方,就是該看的地方。

但 Day 8 也寫了擔心:Tesseract 中文小字本來就差,page_24 那種密度它可能自己噴一堆錯,變成假陽性工廠。所以程式碼的重點全在「怎麼少比一點」:

import difflib
import unicodedata

import pytesseract
from PIL import Image

# Day 7 的比較鍵:只折明確等價的異體字,結果不寫回原文
VARIANT_FOLD = str.maketrans({"臺": "台", "裏": "裡", "爲": "為", "綫": "線"})


def fold(s: str) -> str:
    s = unicodedata.normalize("NFC", s)
    return "".join(s.split()).translate(VARIANT_FOLD)


def tesseract_words(crop: Image.Image, min_conf: float = 60.0) -> str:
    """只收 Tesseract 自己有把握的字,低信心的直接丟掉不比。"""
    data = pytesseract.image_to_data(
        crop,
        lang="chi_tra",
        config="--psm 6",
        output_type=pytesseract.Output.DICT,
    )
    kept = [
        text for text, conf in zip(data["text"], data["conf"])
        if text.strip() and float(conf) >= min_conf
    ]
    return "".join(kept)


def cross_check_digits(vlm_text: str, crop: Image.Image):
    """表格區:只比數字與符號。"""
    keep = set("0123456789(),.-")
    tess = "".join(c for c in fold(tesseract_words(crop)) if c in keep)
    vlm = "".join(c for c in fold(vlm_text) if c in keep)
    sm = difflib.SequenceMatcher(a=vlm, b=tess, autojunk=False)
    return [
        {"tag": tag, "vlm": vlm[i1:i2], "tesseract": tess[j1:j2]}
        for tag, i1, i2, j1, j2 in sm.get_opcodes()
        if tag != "equal"
    ]


def cross_check_spans(vlm_text: str, spans: list[dict], crop: Image.Image, ctx: int = 2):
    """純文字區:logprob 標出的字連同前後 ctx 個字,Tesseract 那邊找不找得到。"""
    tess = fold(tesseract_words(crop))
    misses = []
    for sp in spans:
        piece = fold(vlm_text[max(0, sp["start"] - ctx): sp["end"] + ctx])
        if piece and piece not in tess:
            misses.append({"tag": "span", "vlm": piece, "tesseract": None})
    return misses

幾個我自己很在意的決定:

min_conf=60 把 Tesseract 自己都沒把握的字丟掉(image_to_data 每個詞有信心值,非文字框是 -1),等於讓它棄權。第三層會因此漏一些東西,但我寧可漏,也不要每頁噴二十個假分歧把複核的人搞到麻木。人一麻木,真的紅旗也會被按掉。

表格區只比數字。字元集小,照理說比中文好認(我的預期,沒量過),而財報上最貴的錯就是數字錯。負號那個案例,Tesseract 只要看得到括號,(1,678) 和 1,678 在 diff 裡就分得開。

純文字區不做全文 diff,只檢查 logprob 標出來的字(連同前後兩個字)在 Tesseract 的結果裡找不找得到。第二層跟第三層就是在這裡串起來的。

比較前兩邊都過 Day 7 的 fold,「臺」和「台」不算分歧,原文也不動。

lang="chi_tra" 要另外裝繁中的 traineddata,--psm 6 是「當成一整塊文字」的模式。這組參數和門檻 60 都還沒在 page_24 上實際比較過,是起始值。

Tip:交叉比對最怕抓到太多「錯」。第一天就讓分歧全進人工佇列,一週後大家就會開始批次按通過。先只開數字模式,確認人工消化得了,再慢慢放寬。


第四層:MOPS 領域語意,直接用 Day 21 包好的角色

這層不用重寫。Day 21 已經把 Day 16 的規則包成 Field Checker、把 Day 17 的 verifier 包成 MopsDomainVerifier,都吃 SubagentRequest、吐 SubagentResult。第四層只要透過 Day 21 的 call() 叫它們,邊界檢查照樣生效。

這層只回 PASS、FLAG 或 N/A,不回 FAIL:領域規則只能說「這裡怪」,不能動手改。NT$1,23元 被標出千分位問題,原文留在 original_text,沒人會替它猜成 1,230。

限制要講清楚:Day 17 沒拿到真實的 MOPS XBRL instance,那支 verifier 只跑過合成 fixture,這層在真實文件上能擋多少錯,現在沒有數字。


觸發順序:便宜的先跑,會喊停的先跑

順序 層 成本量級 抓的是什麼 結果
1 Checksum 毫秒級,純 CPU 格式、內部算術、配錯區塊 不過就 FAIL,後面不跑
2 Logprob 幾乎零,回應裡本來就有 模型自己猶豫的位置 不判,只產生「要看的位置」
3 Tesseract 每個區塊秒級,CPU 兩套系統的分歧 PASS / FLAG / 沒得比就 N/A
4 MOPS 語意 毫秒級,只對表格區 領域規則、外部事實 PASS / FLAG / N/A

成本那欄是推估的量級,沒實際計時。排序只有一條道理:會讓整份輸出作廢的檢查放最前面。Checksum 過不了,後面拿它去比 Tesseract、對 XBRL 全是浪費,還會產生一堆沒意義的 FLAG 污染紀錄。

Checksum 不過給 FAIL 不給 FLAG,是照 Day 21 的定義:FAIL 是「沒做完」。格式都不對的輸出,等於 Reader 那一步沒完成,該重來,不是送人工看疑點。

主函式的輸入全是前幾天就有的東西:Day 18 的 Region、Reader 的輸出、裁切圖、Day 21 的角色:

from dataclasses import dataclass, field

from pipeline import Region                                   # Day 18
from subagents import Outcome, Subagent, SubagentRequest, call  # Day 21
from verifier_protocol import VerifierIssue                   # Day 11


@dataclass
class LayerReport:
    layer: str
    outcome: Outcome | None          # None:這層只產生位置,不下判定(Logprob)
    issues: list[VerifierIssue] = field(default_factory=list)
    note: str | None = None


def _issues(region: Region, diffs: list[dict]) -> list[VerifierIssue]:
    return [VerifierIssue(location=region.region_id, original_text=d["vlm"],
                          concern=f"Tesseract 讀到 {d['tesseract']!r}", confidence=0.6)
            for d in diffs]


def run_four_layers(region: Region, raw_json: str, crop_png: bytes, crop_img,
                    logprob_content: list[dict] | None,
                    domain_agents: list[Subagent], domain_req: SubagentRequest | None):
    reports: list[LayerReport] = []

    # 1. Checksum:過不了就停
    parsed, problems = checksum_layer(region.kind, raw_json, crop_png)
    if parsed is None:
        reports.append(LayerReport("checksum", Outcome.FAIL, [
            VerifierIssue(location=region.region_id, original_text=raw_json[:200],
                          concern=p, confidence=1.0) for p in problems]))
        return Outcome.FAIL, reports
    reports.append(LayerReport("checksum", Outcome.PASS))

    # 2. Logprob:只產生位置
    spans = suspicious_spans(logprob_content) if logprob_content else []
    reports.append(LayerReport("logprob", None,
                               note=None if logprob_content else "回應沒有 logprobs"))

    # 3. Tesseract:表格比數字;純文字只查被標出的位置
    if region.kind == "table":
        text = " ".join(f"{r.label} {r.raw_value}" for r in parsed.rows)
        diffs = cross_check_digits(text, crop_img)
        reports.append(LayerReport("tesseract", Outcome.FLAG if diffs else Outcome.PASS,
                                   _issues(region, diffs)))
    elif logprob_content is None:
        reports.append(LayerReport("tesseract", Outcome.NOT_APPLICABLE,
                                   note="沒有 logprob 指路,純文字區等於沒查"))
    else:
        diffs = cross_check_spans(parsed.text, spans, crop_img)
        reports.append(LayerReport("tesseract", Outcome.FLAG if diffs else Outcome.PASS,
                                   _issues(region, diffs)))

    # 4. MOPS 語意:只有表格區才跑,透過 Day 21 的 call() 叫角色
    if region.kind == "table" and domain_req is not None:
        for agent in domain_agents:
            res = call(agent, domain_req)
            reports.append(LayerReport(f"domain:{agent.role}", res.outcome, res.issues))
    else:
        reports.append(LayerReport("domain", Outcome.NOT_APPLICABLE, note="不是表格區"))

    return aggregate(reports), reports


def aggregate(reports: list[LayerReport]) -> Outcome:
    outcomes = [r.outcome for r in reports if r.outcome is not None]
    if Outcome.FAIL in outcomes:
        return Outcome.FAIL
    if Outcome.FLAG in outcomes:
        return Outcome.FLAG
    # 第三層沒得比,不准給 PASS
    if any(r.layer == "tesseract" and r.outcome is Outcome.NOT_APPLICABLE for r in reports):
        return Outcome.FLAG
    return Outcome.PASS

aggregate 最後那個判斷是後來補的。第一版是「沒有 FAIL、沒有 FLAG 就 PASS」,看起來合理,但有個洞:如果回應裡沒有 logprobs(就像我現在所有的舊資料),純文字區的第三層根本沒東西可比,結果只過了 Checksum 就拿到 PASS。

沒被檢查過,跟檢查過沒問題,是兩回事。但第四層的 N/A 我沒升級成 FLAG:XBRL 本來就只蓋一小部分欄位,N/A 都算疑點的話人工佇列會被淹掉,覆蓋率交給 Day 21 的 skipped 計數記。

Day 21 的 Text Verifier(gpt-oss:20b)還是 design-only, not yet wired,所以沒排進來,接上後會放在第三、四層之間。

Tip:別把四層結果平均成一個總分。0.9、0.8、0.95、0.7 平均 0.84 很好看,但那個 0.7 可能就是「金額跟 XBRL 對不起來」。只看最嚴格的那一層。


疊了四層,能說「一定正確」嗎

不能。四層只是把「安靜的錯」變吵的機率拉高。有幾種錯我還想不到哪一層會抓:

  • 整列漏掉,而且那張表剛好沒有合計列、也沒有對應的 XBRL 科目。這時只剩 Tesseract 有機會,前提是它自己有讀到那一列。
  • 敘事段落裡語意錯但字形完全正確的東西,例如模型把「未」漏掉整句意思反過來。這要靠 gpt-oss:20b 或人。

所以這四層吐出來的,比較像一份「哪裡要人看」的清單。

寫完我把 run_four_layers 的回傳值盯了很久。一個 Outcome,一串 LayerReport。Day 20 的 Commander 看得懂的是 TaskResult 的 status 和 signals,它不認識 LayerReport;Checksum 那個 FAIL 傳回去,Commander 的規則表裡有沒有哪一條會接?如果沒有,這個 FAIL 會不會就只是 log 裡安安靜靜的一行 no_rule?


上一篇
Day 21 - Subagent 分工:各司其職的邊界怎麼畫
下一篇
Day 23 - 訊息協定與錯誤重試:角色之間怎麼溝通
系列文
從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言