iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
AI Engineering

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

Day 20 - Commander 設計:分派邏輯與任務拆解

  • 分享至 

  • xImage
  •  

先講一個可能不太受歡迎的決定:我的 Commander 不是 LLM。

至少主幹不是。它是一段普通的 Python,裡面是一張規則表、一個佇列、一本決策紀錄簿。

我知道現在流行的做法是找一顆最強的模型當 orchestrator,讓它讀任務、自己規劃、自己呼叫工具。我也試著想像過這個版本用在財報 OCR 上會怎樣。想到 page 61 就停了:一顆模型負責決定「這塊區域撞上限是因為太密還是太長」,它的依據是什麼?它看不到圖(看得到的話,那它自己就是 OCR 了),它能看的是文字層字數、輸出重複率、之前試過幾次。這些都是數字,數字的判斷用 if 寫比較便宜,也比較好稽核。

LLM 可以留在一個很小的位置:規則表完全對不上的情況。那種時候讓它提一個建議,建議本身也要被記下來、被人看。

Commander 管什麼、不管什麼

做 不做
把一份文件拆成任務 讀圖、辨識文字
決定每個任務派給誰 判定某段文字對不對
收回結果,決定下一步 修改任何 subagent 的輸出
控制重試次數與預算 自己發明新的處理方式
每個決定都寫下規則編號與理由 在沒有證據的情況下放行

右邊那一欄比左邊重要。Day 11 講過同源盲點:產生答案的人不能自己驗證。Commander 如果手癢去「順便」修一個看起來明顯錯的數字,它就同時當了產生者和驗證者,而且還是那個最沒有領域知識的。

任務長什麼樣

所有派出去的東西都是 Task。欄位是從 Day 19 那張失敗表倒推的:每一次我手動修正,都需要知道「哪一塊、試過什麼、上一個是誰的結果」。

from __future__ import annotations

from datetime import datetime, timezone
from enum import Enum
from uuid import uuid4

from pydantic import BaseModel, Field


class TaskKind(str, Enum):
    EXTRACT_LAYER = "extract_layer"      # PyMuPDF 文字層 + 品質
    DETECT_LAYOUT = "detect_layout"      # Docling 版面
    OCR_REGION = "ocr_region"            # 送 VLM
    RECROP = "recrop"                    # 依版面切小
    VERIFY_TEXT = "verify_text"          # 獨立文字 verifier
    VERIFY_FIELDS = "verify_fields"      # 欄位格式與一致性規則(Day 16)
    VERIFY_XBRL = "verify_xbrl"          # MOPS XBRL 對照(Day 17)
    EXTRACT_CITATIONS = "extract_citations"  # 公告字號(Day 15)
    HUMAN_REVIEW = "human_review"


class Task(BaseModel):
    task_id: str = Field(default_factory=lambda: uuid4().hex[:12])
    doc_id: str
    region_id: str | None = None
    location: str | None = None              # 跟 Day 18 的 location 同格式
    kind: TaskKind
    params: dict = Field(default_factory=dict)   # 例如 {"dpi": 400, "bbox": [...]}
    attempt: int = 1
    parent_task_id: str | None = None        # 由哪個任務的結果衍生出來
    reason: str = ""                         # 為什麼會有這個任務(規則編號 + 說明)
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))


class TaskStatus(str, Enum):
    OK = "ok"
    NEEDS_ATTENTION = "needs_attention"      # 做完了,但結果有疑點
    FAILED = "failed"                        # 沒做完(逾時、例外、撞上限)


class TaskResult(BaseModel):
    task_id: str
    status: TaskStatus
    payload: dict = Field(default_factory=dict)  # 各 subagent 的實際輸出,Day 21 定型
    signals: dict = Field(default_factory=dict)  # finish_reason、重複率、字數等給 Commander 看的訊號
    model_id: str | None = None

parent_task_id 是我最想要的欄位。Day 14 到 Day 15 那段「整頁 → 切半 → 緊裁」,在這個結構裡會是三代任務,一路指回去。三個月後有人問「這三筆罰鍰是怎麼讀出來的」,順著 parent 往上爬就有答案,不用翻我的聊天紀錄。

reason 是字串,但內容不是自由發揮。它一定以規則編號開頭,例如 R3: 撞上限且文字層字數 1238 > 400,依版面切小。自然語言的理由很好讀,但不好統計;有了規則編號,Day 26 設計記錄系統時才能算「R3 觸發了幾次、之後成功幾次」。

拆解規則:一份文件變成哪些任務

拆解分兩層。文件層的第一批任務是固定的:

每一頁 → EXTRACT_LAYER
整份   → DETECT_LAYOUT

這兩個完成之後,每個區塊依 Day 18 的 route() 產生下一批。這裡 Commander 等於把昨天 Pipeline 裡的 if 搬過來,差別是它產生的是任務,不是直接呼叫:

區塊情況 產生的任務
頁首、頁尾 無
文字層可信的一般段落 VERIFY_FIELDS(文字層本身當輸出,仍然要過欄位規則)
文字層不可信、字數在上限內 OCR_REGION(200 DPI)
字數超過上限 RECROP,完成後每一片各一個 OCR_REGION(400 DPI)
表格 OCR_REGION,完成後加 VERIFY_FIELDS、VERIFY_XBRL
圖表 OCR_REGION,結果標記沒有外部校驗來源
文字含「字第…號」樣式 追加 EXTRACT_CITATIONS

最後一列是用文字層先掃一次決定的。正則跟 Day 15 的 citation_extractor.py 用同一條:字\s*第\s*(\d{4,12})\s*號。先用便宜的方法決定要不要派一個比較貴的任務,這種模式在 Commander 裡會一直出現。

分派與回收:規則表

結果回來之後,Commander 看 status 和 signals 決定下一步。這是整篇的核心,我直接給程式:

from dataclasses import dataclass
from typing import Callable

MAX_ATTEMPTS = {TaskKind.OCR_REGION: 3, TaskKind.RECROP: 2}
MAX_CHARS_PER_CALL = 400      # 跟 Day 18 同一個起始值


@dataclass
class Rule:
    rule_id: str
    when: Callable[[Task, TaskResult], bool]
    then: Callable[[Task, TaskResult], list[Task]]
    note: str


def _child(task: Task, kind: TaskKind, reason: str, **params) -> Task:
    return Task(doc_id=task.doc_id, region_id=task.region_id, location=task.location,
                kind=kind, params={**task.params, **params},
                attempt=task.attempt + 1 if kind == task.kind else 1,
                parent_task_id=task.task_id, reason=reason)


RULES: list[Rule] = [
    Rule("R1",
         lambda t, r: t.kind == TaskKind.OCR_REGION and r.signals.get("finish_reason") == "length"
                      and r.signals.get("text_layer_chars", 0) > MAX_CHARS_PER_CALL,
         lambda t, r: [_child(t, TaskKind.RECROP,
                              f"R1: 撞上限且文字層字數 {r.signals['text_layer_chars']} > {MAX_CHARS_PER_CALL},依版面切小")],
         "輸入太密:切小,不重送原圖"),
    Rule("R2",
         lambda t, r: t.kind == TaskKind.OCR_REGION and r.signals.get("finish_reason") == "length"
                      and r.signals.get("text_layer_chars", 0) <= MAX_CHARS_PER_CALL
                      and t.params.get("dpi", 200) < 400,
         lambda t, r: [_child(t, TaskKind.OCR_REGION, "R2: 字數不多仍撞上限,提高 DPI 重送", dpi=400)],
         "字不多卻讀不好:多半是看不清楚"),
    Rule("R3",
         lambda t, r: t.kind == TaskKind.OCR_REGION and r.signals.get("repeat_ratio", 0) > 0.3,
         lambda t, r: [_child(t, TaskKind.HUMAN_REVIEW, "R3: 輸出大段重複,疑似退化,先交人工")],
         "重複退化時不盲目重跑"),
    Rule("R4",
         lambda t, r: t.kind == TaskKind.OCR_REGION and r.status == TaskStatus.OK,
         lambda t, r: [_child(t, TaskKind.VERIFY_TEXT, "R4: OCR 完成,交獨立 verifier")],
         "所有模型輸出都要過一個不同源的檢查"),
    Rule("R5",
         lambda t, r: t.kind in {TaskKind.VERIFY_TEXT, TaskKind.VERIFY_FIELDS, TaskKind.VERIFY_XBRL}
                      and r.status == TaskStatus.NEEDS_ATTENTION,
         lambda t, r: [_child(t, TaskKind.HUMAN_REVIEW, f"R5: {t.kind.value} 回報疑點,交人工,不自動修正")],
         "任何驗證的 FLAG 都不由 Commander 自己吞掉"),
]

repeat_ratio 的 0.3 是我隨手訂的,沒有量過。Day 8 提過「最長重複子串的出現次數超過門檻」這種檢查幾行就能寫,門檻要等有一批真實輸出再調。

R1 跟 R2 的分界就是 Day 19 那個「同一個 length,兩種病」。字多的撞上限,切;字少的撞上限,看清楚一點再試。它們各自對應 page 61 左半(1238 字)跟 page_19 那種情況。這兩條規則沒辦法保證一定對,但至少它們會做不一樣的事,而且會寫下為什麼。

R5 我寫得最硬。驗證回報疑點,Commander 唯一被允許的動作是送人工。它不能說「XBRL 說對了,所以 verifier 的疑點可以忽略」,至少現在不行。不同驗證結果之間怎麼取捨,是 Day 22 的題目,在那之前我寧可多送一些給人看。

然後是主迴圈:

import json
from collections import deque
from pathlib import Path


class Commander:
    def __init__(self, dispatch: Callable[[Task], TaskResult], log_path: Path,
                 rules: list[Rule] = RULES):
        self.dispatch = dispatch          # Day 21 的 subagent 註冊表會提供這個
        self.rules = rules
        self.queue: deque[Task] = deque()
        self.log = log_path.open("a", encoding="utf-8")

    def submit(self, tasks: list[Task]) -> None:
        self.queue.extend(tasks)

    def _over_budget(self, task: Task) -> bool:
        return task.attempt > MAX_ATTEMPTS.get(task.kind, 1)

    def _record(self, event: str, task: Task, **extra) -> None:
        self.log.write(json.dumps({"event": event, "task_id": task.task_id,
                                   "parent": task.parent_task_id, "kind": task.kind.value,
                                   "location": task.location, "attempt": task.attempt,
                                   "reason": task.reason, **extra},
                                  ensure_ascii=False, default=str) + "\n")

    def run(self) -> None:
        while self.queue:
            task = self.queue.popleft()
            if self._over_budget(task):
                escalated = _child(task, TaskKind.HUMAN_REVIEW,
                                   f"R0: {task.kind.value} 已試 {task.attempt - 1} 次,超出預算")
                self._record("escalate", task)
                self.queue.append(escalated)
                continue

            self._record("dispatch", task)
            result = self.dispatch(task)
            self._record("result", task, status=result.status.value,
                         signals=result.signals, model_id=result.model_id)

            fired = [rule for rule in self.rules if rule.when(task, result)]
            if not fired:
                self._record("no_rule", task, status=result.status.value)
                continue
            for rule in fired:
                children = rule.then(task, result)
                self._record("decide", task, rule_id=rule.rule_id,
                             children=[c.task_id for c in children])
                self.queue.extend(children)

有幾個地方是刻意的:

_record 在 dispatch 之前就寫。如果 subagent 當掉、整個程式崩了,至少 log 裡看得到「最後派出去的是哪個任務」。Day 3 那次 GB10 上 Ollama 跟 vLLM 搶記憶體直接 segfault,事後只能翻 journalctl 一點一點拼,那種感覺我不想再有一次。

no_rule 也會被記錄。規則表對不上的情況,就是 Commander 的盲區。這些紀錄累積起來,就是下一版規則表的草稿,也是將來那顆 LLM 顧問唯一該出場的地方。

多條規則同時觸發時全部執行。例如一個 OCR 結果可能同時符合 R3(重複)和 R4(完成、交 verifier)。我沒有設優先順序,因為這兩個任務不衝突,都做比較安全。真的會衝突的組合目前還沒遇到;遇到了再加,不先為想像中的情況設計。

拿 page 61 走一遍

用 Day 14、15 實際發生過的事當輸入,Commander 的決策紀錄大概會長這樣。訊號值是真實落檔裡的數字,但這份 trace 本身是我依規則手推的,不是程式跑出來的:

dispatch  t01  ocr_region    page_61 / 整頁          attempt=1  dpi=200
result    t01  failed        finish_reason=length, completion_tokens=3000, text_layer_chars=1822
decide    t01  R1 → t02      撞上限且文字層字數 1822 > 400,依版面切小
dispatch  t02  recrop        page_61
result    t02  ok            切出 N 片;裁罰段落那一片文字層約 296 字
dispatch  t03  ocr_region    page_61 / 文字 / 裁罰段落   attempt=1  dpi=400
result    t03  ok            finish_reason=stop, completion_tokens=277
decide    t03  R4 → t04      OCR 完成,交獨立 verifier
dispatch  t04  verify_text   page_61 / 文字 / 裁罰段落
result    t04  needs_attention  verifier 尚未接上真實模型
decide    t04  R5 → t06      verify_text 回報疑點,交人工,不自動修正
dispatch  t05  extract_citations  page_61 / 文字 / 裁罰段落
result    t05  ok            3/3 字號,各自關聯罰鍰金額

1822 是 page 61 整頁文字層的字數(pages_manifest.json 的 gt_char_count),296 是緊裁段落 GT 的字數,277 個 token 跟 stop 出自 step5_tight_crop_results.jsonl。

對照 Day 14 實際的路徑:整頁、切半、切半還是失敗、再手動抓座標緊裁。Commander 的版本跳過了「切半」,因為 R1 切的依據是版面上的 block 跟字數,不是頁面的幾何中線。Day 14 的切半是我當時想得不夠,規則表把這個教訓直接寫進去了。

t05 其實在拆解階段、文字層一掃到「字第…號」時就能排進佇列,跟 OCR 那條線平行,這裡為了好讀把它放在後面。t04 那個 verifier 任務,目前會落到一個還沒接上真實模型的 subagent。Day 11 算過 gpt-oss:20b 暫時沒有機器跑,所以它現在的實際行為是回 NEEDS_ATTENTION,然後觸發 R5 送人工。聽起來很蠢,但它是誠實的:在 verifier 真的能跑之前,所有模型輸出都該有人看過。

我對 Commander 的一點私心

寫這篇的時候,我好幾次想在規則表裡加「聰明」的東西,比如用文字層跟 OCR 輸出的相似度自動判斷要不要重跑,或者根據上一頁的結果調整下一頁的 DPI。

最後都刪掉了。

理由是 Commander 越聰明,它犯的錯就越難追。一條 if 錯了,看 log 就知道是哪條、為什麼觸發。一個「根據前後文自適應」的策略錯了,你要重建它當時看到的所有狀態才能理解。我寧可它笨一點、囉唆一點,把每個決定攤在 JSONL 裡。

而且它越笨,就越需要身邊的角色各自專業。它只負責把任務丟給對的人,至於「對的人」是誰、各自能碰什麼、不能碰什麼,這就要一個一個劃清楚了。


上一篇
Day 19 - 為什麼 Pipeline 不夠好:單向流程的天花板
下一篇
Day 21 - Subagent 分工:各司其職的邊界怎麼畫
系列文
從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言