iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
AI Engineering

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

Day 24 - Commander-Subagent-Verifier Harness 完整組裝

  • 分享至 

  • xImage
  •  

零件一個一個做的時候都很開心,要接起來的前一晚我就開始焦慮,因為接起來才知道介面對不對得上。Day 18 的 route() 回的是 Route,Day 20 的 Commander 吃的是 Task;Day 21 的角色吐 SubagentResult,Commander 的規則看的卻是 TaskResult;Day 22 的 run_four_layers 回一個 Outcome 加一串 LayerReport,誰來把它交給 Commander?

每一條線都是一個可能斷掉的地方。今天就是那一晚。


先看全貌

 PDF
  │
  ▼
 plan_tasks()  ── 包裝一:Day 18 detect_regions / extract_text_layer / char_density / route
  │               照 Day 20 拆解表產生第一批 Task
  ▼
 Commander(dispatch, log_path, rules=RULES_V2 + [R9])      ◄── Day 20 原樣,規則 R0–R9
  │  submit() / run()
  │
  │  dispatch(task) ──► HarnessDispatch  ── 包裝四:Task → SubagentRequest → call() → TaskResult
  │                        │
  │                        ├─ RECROP            → Extractor          (包裝二:Day 18 split_region)
  │                        ├─ OCR_REGION        → Reader             (Day 21 角色,gemma-4-26b)
  │                        ├─ VERIFY_TEXT       → FourLayerVerifier  (包裝三:Day 22 run_four_layers)
  │                        ├─ VERIFY_FIELDS     → Field Checker      (Day 21,Day 16 規則)
  │                        ├─ VERIFY_XBRL       → MopsDomainVerifier (Day 21,Day 17)
  │                        ├─ EXTRACT_CITATIONS → Citation Extractor (Day 21,Day 15)
  │                        └─ HUMAN_REVIEW      → Human Auditor      (Day 21,人工佇列)
  │                        │
  │   TaskResult ◄─────────┘   (Day 23 to_task_result)
  │
  ├─► commander.jsonl        決策紀錄(Day 20 _record)
  └─► Envelope ─► Recorder   訊息紀錄(Day 23 信封,Day 26 落地)

Day 19 講的那件事終於有形狀了。Pipeline 是一條往下的線;這裡的 Commander 把結果丟回規則表,規則表再生出新任務丟回佇列,那個迴圈才是 Harness。

圖上標了四個「包裝」,是今天唯一新寫的東西,其他全是前幾天定義好的名稱,原樣呼叫。


包裝一:plan_tasks,Day 20 那張拆解表的程式版

Day 20 用一張表講「什麼區塊產生什麼任務」,沒有寫成函式。這裡把它寫出來,底下呼叫的全是 Day 18 的函式:

from __future__ import annotations

import io
import json
import re
import time
from collections import Counter
from pathlib import Path

import pymupdf
from PIL import Image

from pipeline import (Region, Route, char_density, detect_regions,            # Day 18
                      extract_text_layer, render_region, route, split_region)
from commander import Commander, Rule, Task, TaskKind, TaskResult, TaskStatus, _child  # Day 20
from subagents import (Evidence, MopsDomainVerifier, Outcome, Subagent,         # Day 21
                       SubagentRequest, SubagentResult, call)
from four_layers import run_four_layers                                          # Day 22
from harness_msgs import Envelope, ResultMsg, TaskMsg, RULES_V2, to_task_result   # Day 23
from recorder import Recorder                                                    # Day 26

CITATION_RE = re.compile(r"字\s*第\s*(\d{4,12})\s*號")   # 跟 Day 15、Day 20 同一條


def _inside(b, box, pad=2):
    return (b[0] >= box[0] - pad and b[1] >= box[1] - pad
            and b[2] <= box[2] + pad and b[3] <= box[3] + pad)


def _params(region: Region, n_chars: int, **extra) -> dict:
    return {"region_id": region.region_id, "page_index": region.page_index,
            "kind": region.kind, "bbox": list(region.bbox),
            "text_layer_chars": n_chars, **extra}


def plan_tasks(doc_id: str, pdf_path: str) -> list[Task]:
    doc = pymupdf.open(pdf_path)
    layers: dict[int, tuple] = {}
    tasks: list[Task] = []
    for region in detect_regions(pdf_path):
        if region.page_index not in layers:
            layers[region.page_index] = extract_text_layer(doc, region.page_index)
        blocks, quality = layers[region.page_index]
        n_chars, _ = char_density(region, blocks)
        decision = route(region, quality, n_chars)
        if decision == Route.SKIP:
            continue

        text = "".join(b.text for b in blocks if _inside(b.bbox, region.bbox))
        common = dict(doc_id=doc_id, region_id=region.region_id,
                      location=f"page_{region.page_index:02d} / {region.kind} / {region.region_id}")
        if decision == Route.TEXT_LAYER:
            tasks.append(Task(kind=TaskKind.VERIFY_FIELDS, reason="plan: 文字層可信",
                              params=_params(region, n_chars, text=text), **common))
        elif decision == Route.SPLIT:
            tasks.append(Task(kind=TaskKind.RECROP, reason=f"plan: 字數 {n_chars} 超過上限",
                              params=_params(region, n_chars), **common))
        else:  # VLM、VLM_UNVERIFIABLE
            tasks.append(Task(kind=TaskKind.OCR_REGION, reason=f"plan: {decision.value}",
                              params=_params(region, n_chars, dpi=200), **common))

        if CITATION_RE.search(text):
            tasks.append(Task(kind=TaskKind.EXTRACT_CITATIONS, reason="plan: 文字層掃到字第…號",
                              params=_params(region, n_chars, text=text), **common))
    return tasks

Day 20 把抽文字層、偵測版面寫成 EXTRACT_LAYER、DETECT_LAYOUT 任務,組裝時我先直接呼叫 Day 18 的函式。這兩步不呼叫模型,失敗就是程式錯誤,丟進規則表也沒有規則救得了。


包裝二、三:把 Day 18、Day 22 的函式變成 Day 21 的角色

Day 21 的 Extractor 負責依版面切小,但沒寫類別;Day 22 的 run_four_layers 只是函式。dispatch 只認得 Subagent,所以各包一層:

class Extractor:
    """包裝二:Day 21 的 Extractor。RECROP 任務 = Day 18 的 split_region。"""
    role, uses_model, model_name = "extractor", False, None

    def __init__(self, pdf_path: str):
        self.doc = pymupdf.open(pdf_path)

    def run(self, req: SubagentRequest) -> SubagentResult:
        p = req.params
        region = Region(p["region_id"], p["page_index"], p["kind"], tuple(p["bbox"]))
        blocks, _ = extract_text_layer(self.doc, p["page_index"])
        pieces = split_region(region, blocks)
        return SubagentResult(
            task_id=req.task_id, role=self.role, outcome=Outcome.PASS,
            produced=[Evidence(location=f"{req.location} / {r.region_id}", source="pymupdf",
                               data={"region_id": r.region_id, "bbox": list(r.bbox),
                                     "text_layer_chars": char_density(r, blocks)[0]})
                      for r in pieces],
            impl_version="split_region@day18",
        )


class FourLayerVerifier:
    """包裝三:Day 22 的 run_four_layers,接 VERIFY_TEXT 任務。"""
    role, uses_model, model_name = "four_layer_verifier", False, None

    def __init__(self, pdf_path: str, domain_agents: list[Subagent]):
        self.doc = pymupdf.open(pdf_path)
        self.domain_agents = domain_agents

    def run(self, req: SubagentRequest) -> SubagentResult:
        p = req.params
        region = Region(p["region_id"], p["page_index"], p["kind"], tuple(p["bbox"]))
        png = render_region(self.doc, region, p.get("dpi", 200))   # 跟 Reader 看到的同一張
        reader_out = req.inputs[0]
        domain_req = req if region.kind == "table" else None
        outcome, reports = run_four_layers(
            region, reader_out.text or "", png, Image.open(io.BytesIO(png)),
            reader_out.data.get("logprobs"), self.domain_agents, domain_req)
        first_bad = next((r.layer for r in reports
                          if r.outcome in (Outcome.FAIL, Outcome.FLAG)), None)
        return SubagentResult(
            task_id=req.task_id, role=self.role, outcome=outcome,
            issues=[i for r in reports for i in r.issues],
            signals={"failed_layer": first_bad},
            impl_version="run_four_layers@day22",
        )

FourLayerVerifier 用 render_region 重畫一次圖。Checksum 要比 sha256,重畫的圖必須跟 Reader 當時拿到的一位元不差;同樣的 bbox 和 dpi 我預期輸出固定,但還沒驗證。

Day 20 的拆解表說表格區 OCR 完還要派 VERIFY_FIELDS 和 VERIFY_XBRL。我沒另外派,而是讓 FourLayerVerifier 在表格區用 call() 叫這兩個角色當第四層。分成獨立任務的話 Commander 會同時派出去,Day 22「Checksum 不過,第四層不跑」的順序就守不住。文字層路線的區塊還是走 VERIFY_FIELDS。


接起來才看到的洞:Day 20 那份 trace 少了一行

我回去重看 Day 20 走 page 61 的那份手推 trace,在 t02 recrop 回 ok 跟 t03 ocr_region 之間,沒有 decide 那一行。

拆解表寫著 RECROP「完成後每一片各一個 OCR_REGION(400 DPI)」,但 R0 到 R5 沒有一條規則做這件事。真的跑起來,RECROP 回 OK 只會記一筆 no_rule,切好的片段永遠不會被讀。

補一條:

R9 = Rule(
    "R9",
    lambda t, r: t.kind == TaskKind.RECROP and r.status == TaskStatus.OK,
    lambda t, r: [
        _child(t, TaskKind.OCR_REGION,
               f"R9: 切片 {e['data']['region_id']} 以 400 DPI 送讀", dpi=400,
               region_id=e["data"]["region_id"], bbox=e["data"]["bbox"],
               text_layer_chars=e["data"]["text_layer_chars"])
        for e in r.payload["produced"]
    ],
    "RECROP 完成後,每一片各派一個 OCR",
)

text_layer_chars 一定要換成切片自己的字數。第一版我忘了,片段繼承父任務的大數字,一片撞上限時 R1 又派 RECROP,而 _child 換種類時 attempt 歸零,R0 攔不到。跟昨天 R7 是同一種病。

還有一個洞沒補:切片後字數已在 400 以內、DPI 也是 400,卻還撞上限。R1、R2 都不觸發,它會落進 no_rule。我沒有第三招,就不假裝有,讓報告把它算成「沒做完」。


包裝四:HarnessDispatch,還有主流程

Day 20 的 Commander 要一個 dispatch: Callable[[Task], TaskResult]。這個包裝把 Task 變成 Day 21 的 SubagentRequest,經 call() 送給角色,再用 Day 23 的 to_task_result 翻回來,順便寫紀錄:

class HarnessDispatch:
    def __init__(self, agents: dict[TaskKind, Subagent], recorder: Recorder):
        self.agents = agents
        self.recorder = recorder
        self.trace_of: dict[str, str] = {}
        self.evidence: dict[str, list[Evidence]] = {}

    def _inputs(self, task: Task) -> list[Evidence]:
        if task.parent_task_id in self.evidence:        # 上一代的產出,唯讀
            return self.evidence[task.parent_task_id]
        if "text" in task.params:                        # 文字層路線,plan 時就帶著
            return [Evidence(location=task.location or "", source="pymupdf",
                             text=task.params["text"])]
        return []

    def __call__(self, task: Task) -> TaskResult:
        time.sleep(task.params.get("backoff_sec", 0))    # Day 23 R6 的退避在這裡等
        trace = self.trace_of.setdefault(
            task.task_id, self.trace_of.get(task.parent_task_id, task.task_id))
        agent = self.agents[task.kind]

        sent = Envelope(trace_id=trace, sender="commander", recipient=agent.role,
                        body=TaskMsg(task=task))
        self.recorder.write(sent)                         # 先記再做

        req = SubagentRequest(task_id=task.task_id, doc_id=task.doc_id,
                              location=task.location or "", inputs=self._inputs(task),
                              params=task.params)
        res = call(agent, req)                            # Day 21 的邊界檢查在這裡
        self.evidence[task.task_id] = res.produced or req.inputs

        self.recorder.write(Envelope(trace_id=trace, reply_to=sent.msg_id,
                                     sender=agent.role, recipient="commander",
                                     body=ResultMsg(result=res)))
        return to_task_result(res)


def run_harness(doc_id: str, pdf_path: str, facts, reader: Subagent,
                field_checker: Subagent, citation_extractor: Subagent,
                human_queue: Subagent, out_dir: Path) -> Path:
    recorder = Recorder(out_dir)
    mops = MopsDomainVerifier(facts)
    agents = {
        TaskKind.RECROP: Extractor(pdf_path),
        TaskKind.OCR_REGION: reader,
        TaskKind.VERIFY_TEXT: FourLayerVerifier(pdf_path, [field_checker, mops]),
        TaskKind.VERIFY_FIELDS: field_checker,
        TaskKind.VERIFY_XBRL: mops,
        TaskKind.EXTRACT_CITATIONS: citation_extractor,
        TaskKind.HUMAN_REVIEW: human_queue,
    }
    commander = Commander(dispatch=HarnessDispatch(agents, recorder),
                          log_path=out_dir / "commander.jsonl",
                          rules=RULES_V2 + [R9])
    commander.submit(plan_tasks(doc_id, pdf_path))
    commander.run()
    return out_dir / "commander.jsonl"

reader、field_checker、citation_extractor、human_queue 是 Day 21 角色表的 Reader、Field Checker、Citation Extractor、Human Auditor,照同一個 Subagent 介面實作,用參數傳進來。Reader 要自己接住連線錯誤,回 FAIL 並填 error_kind="transport",不然 R6 等不到它。

call() 丟出 PermissionError 時我讓程式直接停,那是程式錯誤,不該被規則表吞掉。Day 21 的 check_independence 還沒呼叫,Text Verifier 接上後要放在 run_harness 第一行。

我用假角色把整台機器跑過一次:兩個假區塊(一個字少、一個 1200 字),假 Reader 第一次一定在 text 開頭補「這張圖片中的文字內容如下」,帶 repair_hint 重問才給乾淨輸出。派工順序跟預期一樣:字少的 R4 → Checksum 不過 → R7 → R4;字多的 RECROP → R9 切四片 → 各走一遍。沒有迴圈,stuck 是 0。

但最後五個區塊全進了人工佇列,都是 R5。原因在 Day 22 的 aggregate:假 Reader 沒回 logprobs,純文字區的 Tesseract 層判 N/A,依規則升成 FLAG。也就是說,logprobs=True 打開之前,這台機器對每個純文字區塊都會說「請人看」。規則照設計在運作,只是結果有點刺眼。這是假資料,只證明線接通了。


一份文件跑完,交出什麼

Day 20 的 Commander 只有 submit 和 run,沒有總結。文件層級的報告就從 commander.jsonl 算:

def document_report(commander_log: Path) -> dict:
    events = [json.loads(line) for line in commander_log.open(encoding="utf-8")]
    human = [e for e in events if e["event"] == "dispatch" and e["kind"] == "human_review"]
    stuck = [e for e in events if e["event"] == "no_rule" and e.get("status") == "failed"]
    return {
        "human_review": len(human),
        "stuck": len(stuck),                                   # 失敗了卻沒有規則接
        "by_rule": dict(Counter(e["reason"].split(":")[0] for e in human)),
        "fully_verified": not human and not stuck,
    }

no_rule 本身不代表出事,PASS 的驗證任務後面本來就沒有規則。要看的是狀態為 failed 的,也就是沒有第三招的那種片段。

fully_verified 沒有「大致可信」的中間狀態。99 個區塊通過、1 個金額被 FLAG,那份文件就是還沒驗完。

by_rule 是給我自己看的:人工案件大多是 R8,就該把 Text Verifier 接上;大多是 R5 而且來自 Tesseract,就回頭看 min_conf。


讓 page_61 走一遍

拿 page_61 推演一次,先講清楚哪些是實測、哪些是推的。第一件事就跟 Day 14 不一樣:plan_tasks 一個區塊一個區塊派工,page_61 根本不會被整頁送出去,Day 14 那兩次 58 秒的請求不會發生。

# 發生什麼 依據 來源
1 公告段落成為一個 Docling 區塊,依字數走 OCR_REGION 或 RECROP Day 18 route() 推演,區塊字數沒量過
2 文字層掃到「字第…號」,同時排一個 EXTRACT_CITATIONS Day 20 拆解表 推演
3 若走 RECROP:R9 讓每片以 400 DPI 送讀 包裝二、R9 推演
4 公告那片的 OCR:stop,277 token,5.561s Day 15 緊裁 實測,但當時是手動裁切,位置不一定相同
5 R4 派 VERIFY_TEXT,Checksum 檢查 Reader 輸出 Day 20、Day 22 見下
6 EXTRACT_CITATIONS:3 個字號、3 筆罰鍰 Day 16 實測(GT 和緊裁 OCR 都 3/3)

第 5 步最有意思。Day 15 緊裁 OCR 的真實輸出,第一行是「這張圖片中的文字內容如下,已保留原始的段落結構:」。當時 prompt 要的是純文字不是 JSON,不能斷定它在 Day 22 的 schema 下一定失敗;但換了 JSON prompt 後模型若還是在 text 開頭補這句,no_preamble 會擋下,接著 R7 重問。

這條路徑沒實際跑過,所以不寫秒數。唯一實測的片段是第 4 步的 5.561 秒;跟 Day 14 整頁的 58.191 秒比,這台機器省下的就是那兩次不會發生的整頁請求。


這台機器現在缺什麼

組完以後看得到的縫,我列在這裡:

  • Text Verifier 沒接上,R8 會把所有需要它的區塊送人工。語意錯但字形正確的錯誤,目前只能靠人工抽樣。
  • Day 20 的 Commander.run() 是同步迴圈,一次一個任務,完全沒用到 vLLM 同時收多個請求的能力(Day 5 的 batch 測試)。我暫時不改 Day 20,先求對再求快。
  • 切片後仍撞上限的區塊沒有第三招,會卡在 stuck。

我比較在意的不是這張清單。想像它跑完一份年報,commander.jsonl 裡躺著幾百行 decide、no_rule,Recorder 那邊還有一樣多的信封。每一個 R5 背後都是某個字、某個數字,兩套系統意見不一樣。可是那一行 log 只說「這裡有疑點」,沒說是哪個字、錯成什麼、後來誰對。那幾百行,現在只是一堆不能拿來算的東西。


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

尚未有邦友留言

立即登入留言