iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
AI Engineering

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

Day 23 - 訊息協定與錯誤重試:角色之間怎麼溝通

  • 分享至 

  • xImage
  •  

昨天留下的那個問題,我今天一早就去翻 Day 20 的規則表找答案。

結果不太妙。

Checksum 回 FAIL 以後,to_task_result 那種轉換根本還不存在;就算存在,VERIFY_TEXT 任務的 FAILED 也沒有任何一條規則會接。R1、R2 看的是 OCR 任務的 finish_reason,R4 要 OK,R5 要 NEEDS_ATTENTION。一個 FAIL 掉進去,Commander 只會寫下一行 no_rule,然後這個區塊就從輸出裡消失了。

角色之間各講各的話,失敗就會掉進縫裡。今天要補的就是這些縫:一份所有角色都認得的訊息,還有 R1 到 R5 沒蓋到的錯該怎麼重試。


我其實手動重試過一次

先把 Day 14 到 Day 15 那段翻出來,因為它是一次活生生的重試,只是當時我自己當 Commander。

page_61 那三筆勞檢裁罰公告,我總共打了三輪:

輪次 輸入方式 finish_reason 耗時 關鍵事實命中
第一輪(Day 14) 整頁 length(撞 3000 token) 58.191s 0/6
第二輪(Day 14) 左半頁 length(撞 3000 token) 58.354s 0/6
第三輪(Day 15) 只裁公告那一欄,400dpi stop(277 token) 5.561s 6/6

數字都在 experiments/mops/ 的三個 results.jsonl 裡。

第一輪失敗的時候,最直覺的反應是再打一次。但我用的是 temperature=0,Day 5 寫過:同一顆模型、同樣溫度,跑三次會得到一模一樣的輸出。再打一次就是再燒 58 秒,換一份一模一樣的複讀。

第二輪換了問法,但換錯方向。切半把總字數腰斬,page_61 左半還有 1238 個字,密度沒降下來。

這一課 Day 20 已經收進規則表了:R1 看到「撞上限而且文字層字數超過 400」,直接派 RECROP 依版面切小,不走切半,也不原樣重送。R2 處理字不多卻撞上限的情況,拉高 DPI。

所以截斷這種錯,Day 20 有答案。今天要處理的是它沒答案的那幾種。


規則表的洞

我把 R0 到 R5 攤開,拿「會發生的錯」一個一個去對:

會發生的錯 訊號 Day 20 誰接 結果
撞上限、字多 finish_reason=length、text_layer_chars > 400 R1 切小
撞上限、字少 同上但字數 ≤ 400 R2 400 DPI 重送
輸出大段重複 repeat_ratio > 0.3 R3 送人工
驗證回報疑點 NEEDS_ATTENTION R5 送人工
試太多次 attempt 超過上限 R0 送人工
連線逾時、HTTP 5xx FAILED,沒有 finish_reason 沒有 no_rule,區塊消失
Checksum 不過(Day 22) VERIFY_TEXT 的 FAILED 沒有 no_rule,區塊消失
驗證方根本不在 Day 20 目前讓它回 NEEDS_ATTENTION R5 送人工,但原因被混在「有疑點」裡

後三列就是洞。

連線錯誤那一列最讓我冒冷汗。GB10 上的 vLLM 偶爾重啟一下,一批區塊同時拿到 503,全部掉進 no_rule。log 裡有紀錄,但最後的輸出少了一塊,而且沒有任何東西會提醒你少了。

最後一列不算洞,比較像是訊號被混淆了。Day 20 讓還沒接上的 verifier 回 NEEDS_ATTENTION,結果是送人工,這個行為沒錯。可是 Day 21 把 FAIL 定義成「角色沒完成工作」,一個不存在的 verifier 顯然沒完成工作。把它寫成「有疑點」,事後統計時就分不出「驗過發現問題」和「根本沒驗」。


訊息長什麼樣子

補規則之前,先讓大家講同一種話。

Day 20 的 Task 和 Day 21 的 SubagentResult 已經把「要做什麼」和「做出什麼」定義好了,我不想再發明第三套。訊息協定只做一件事:在它們外面包一層信封,補上「誰寄給誰、回應哪一則、屬於哪條鏈」。

from __future__ import annotations

import random
import uuid
from datetime import datetime, timezone
from typing import Annotated, Literal, Union

from pydantic import BaseModel, ConfigDict, Field

from commander import RULES, Rule, Task, TaskKind, TaskResult, TaskStatus, _child   # Day 20
from subagents import Outcome, SubagentResult                                        # Day 21


class TaskMsg(BaseModel):
    type: Literal["task"] = "task"
    task: Task


class ResultMsg(BaseModel):
    type: Literal["result"] = "result"
    result: SubagentResult


class Envelope(BaseModel):
    model_config = ConfigDict(frozen=True, extra="forbid")

    msg_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
    trace_id: str                    # 根任務的 task_id,整條衍生鏈共用
    reply_to: str | None = None      # 這則是回應哪一則訊息
    sender: str                      # "commander" 或 Subagent.role
    recipient: str
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
    body: Annotated[Union[TaskMsg, ResultMsg], Field(discriminator="type")]

commander、subagents 是我把 Day 20、Day 21 的程式碼各自存成的模組名稱,裡面的類別和函式都沒改。

序列化再讀回來我在本機試過:Envelope.model_validate_json(e.model_dump_json()),body 會照 type 正確還原成 TaskMsg。這是 Pydantic v2 discriminated union 的好處,讀的一方不用猜。

幾個欄位的來由:

trace_id 用根任務的 task_id。Day 20 的 Task 已經有 parent_task_id,可以一代一代往上爬;trace_id 是捷徑,讓「這個區塊從頭到尾發生過什麼」變成一次查詢,不用遞迴。

reply_to 跟 parent_task_id 不一樣。parent_task_id 是任務之間的衍生關係(R1 從 OCR 任務生出 RECROP 任務),reply_to 是訊息之間的問答關係(這份結果是回哪一次派工)。兩條線都要有,事後才重建得出來。

frozen=True:訊息寄出就不能改。這跟 Day 21 那條「上游證據一字不能動」是同一個想法,只是從證據延伸到訊息。

然後是 Day 21 的 Outcome 怎麼翻成 Day 20 看得懂的 TaskStatus:

OUTCOME_TO_STATUS = {
    Outcome.PASS: TaskStatus.OK,
    Outcome.NOT_APPLICABLE: TaskStatus.OK,       # 沒檢查,但也沒壞;outcome 原值留在 signals
    Outcome.FLAG: TaskStatus.NEEDS_ATTENTION,
    Outcome.FAIL: TaskStatus.FAILED,
}


def to_task_result(res: SubagentResult) -> TaskResult:
    return TaskResult(
        task_id=res.task_id,
        status=OUTCOME_TO_STATUS[res.outcome],
        payload={"produced": [e.model_dump() for e in res.produced],
                 "issues": [i.model_dump() for i in res.issues]},
        signals={**res.signals, "outcome": res.outcome.value, "role": res.role},
        model_id=res.model_id,
    )

N/A 翻成 OK 是我想了一下才決定的。對 Commander 來說,「不在我的範圍」不需要任何動作,不該觸發重試也不該送人工。但原始的 N/A 要留在 signals["outcome"],不然 Day 26 做統計時,XBRL 的覆蓋率就算不出來了。

signals 這個 dict 是角色跟 Commander 之間真正的通訊管道,規則全靠它判斷。所以哪些 key 代表什麼,要寫死:

key 誰填 意思
finish_reason Reader 模型回應原樣,R1、R2 用
text_layer_chars Reader(從任務參數帶過來) Day 18 char_density 算的字數
repeat_ratio Reader R3 用
error_kind 任何角色 transport、verifier_unavailable,只在 FAIL 時填
http_status Reader、Text Verifier 連線錯誤時的狀態碼
failed_layer Day 22 的四層驗證 第一個不過的層,例如 checksum

Tip:error_kind 我原本分了九種,後來砍到兩種。分類的標準只有一個:兩種錯如果處理方式一樣,它們就是同一種。「連線逾時」和「HTTP 503」看起來不一樣,但都是等一下重送,所以合併成 transport。分類是為了決定動作,不是為了寫報告好看。


補三條規則

def _transport(r: TaskResult) -> bool:
    return r.status == TaskStatus.FAILED and r.signals.get("error_kind") == "transport"


def _checksum_failed(t: Task, r: TaskResult) -> bool:
    return (t.kind == TaskKind.VERIFY_TEXT and r.status == TaskStatus.FAILED
            and r.signals.get("failed_layer") == "checksum")


def _repair_or_escalate(t: Task, r: TaskResult) -> list[Task]:
    if "repair_hint" in t.params:            # 已經修補重問過一次
        return [_child(t, TaskKind.HUMAN_REVIEW, "R7: 修補重問後格式仍不合,交人工")]
    return [_child(t, TaskKind.OCR_REGION, "R7: 輸出格式不合,附上錯誤訊息重問一次",
                   repair_hint=r.payload["issues"][0]["concern"])]


EXTRA_RULES: list[Rule] = [
    Rule("R6",
         lambda t, r: _transport(r),
         lambda t, r: [_child(t, t.kind, f"R6: 連線錯誤 {r.signals.get('http_status')},退避後重送",
                              backoff_sec=min(30.0, 2.0 ** t.attempt) * random.uniform(0.5, 1.5))],
         "唯一允許原樣重送的錯"),
    Rule("R7", _checksum_failed, _repair_or_escalate, "修補重問只給一次"),
    Rule("R8",
         lambda t, r: r.status == TaskStatus.FAILED
                      and r.signals.get("error_kind") == "verifier_unavailable",
         lambda t, r: [_child(t, TaskKind.HUMAN_REVIEW, "R8: 驗證方不在,標成待驗證,不當作通過")],
         "沒驗過不能寫成驗過"),
]

RULES_V2 = RULES + EXTRA_RULES

RULES_V2 直接丟進 Day 20 的 Commander(dispatch, log_path, rules=RULES_V2),Commander 本身一行都不用改。規則表當初設計成資料而不是程式碼,好處就在這裡。

R6 是整張表裡唯一會原樣重送同一個任務的規則。只有連線錯誤適合這樣做,因為那次請求根本沒拿到結果,重送不是在賭運氣。_child 在同種任務時會把 attempt 加一,所以 Day 20 的 MAX_ATTEMPTS(OCR 三次)和 R0 自動就是它的上限。退避時間放在 params["backoff_sec"],Commander 的 run() 是同步迴圈,沒有地方等,所以真正的 sleep 由 dispatch 那一側處理,明天組裝時會看到。

R7 讓 Pydantic 的錯誤訊息回到 Reader 手上。像 輸出開頭是模型自己加的說明文字、明細加總 4678 與合計 1322 不符 這種訊息很具體,附進 prompt 裡,模型有機會自己改對。

R7 我差點寫出一個無窮迴圈。

_child 只有在「同種任務」時才會把 attempt 加一,換種類就歸零。R7 從 VERIFY_TEXT 生出 OCR_REGION,attempt 回到 1;OCR 成功後 R4 又生出 VERIFY_TEXT,attempt 又是 1。如果修補後格式還是錯,R7 會再觸發,再生一個 OCR,再來一次……R0 永遠攔不到,因為每一代的 attempt 都是 1。

解法是那個 repair_hint。_child 會把父任務的 params 整包繼承下去,所以修補過一次的鏈,後代任務身上都帶著 repair_hint。R7 看到它就不再重問,直接送人工。

我拿一個假的 dispatch 跑過這組規則:第一次 OCR 回 503,其後 OCR 都成功,驗證永遠回 checksum 不過。Commander 依序派出的任務是:

ocr_region    attempt=1  plan
ocr_region    attempt=2  R6(503 後退避重送)
verify_text   attempt=1  R4
ocr_region    attempt=1  R7(帶 repair_hint 重問)
verify_text   attempt=1  R4
human_review  attempt=1  R7(修補後仍不合,交人工)

然後停下來。沒有迴圈,也沒有東西掉進 no_rule。這是測試資料,不是真實請求。


為什麼 FLAG 不自動重試

寫 R6 到 R8 的時候,我很想順手再加一條:Day 22 的 Tesseract 在數字上跟 VLM 不一致時,自動派一個 RECROP 再讀一次。

最後沒加。

自動重試很誘人,FLAG 一出現就換方法再跑,跑到 PASS 為止,人工佇列就會很乾淨。但這樣做其實是在挑一個讓 Verifier 滿意的答案。多試幾次,總有一次兩套系統碰巧一致,那個 PASS 是試出來的,沒有比較可信。

Day 20 的 R5 寫得很硬:驗證回報疑點,唯一允許的動作是送人工。我寫到這裡才真的理解那條規則為什麼要那麼硬。


fail-closed,但只關一半

R8 就是現在的真實狀態。Text Verifier(gpt-oss:20b)還沒接上,Tesseract 也可能沒裝 chi_tra。四重驗證裡隨時可能有一兩層缺席。

缺席的時候,有兩條路。

fail-open,驗證不在就放行。流程順,但哪天 verifier 掛了一整晚也沒人發現,因為所有東西都通過了。

fail-closed,驗證不在就整條擋下。安全,但 verifier 常常不在的話,整份文件都跑不完。

我選的是中間:這個區塊送人工、標成待驗證,其他區塊照跑。紀錄上分得出「驗過沒問題」跟「還沒驗」,流程也不會卡死。這跟 Day 22 aggregate 裡「第三層沒得比就不准 PASS」是同一個想法。

更完整的 fail-open 跟 fail-closed 取捨,Day 29 在 Router 那邊會再碰到,那裡還牽涉資料敏感分級。

Tip:R6 的退避時間 min(30, 2**attempt) 再乘上 0.5 到 1.5 的隨機抖動,是我隨手定的。加抖動的理由倒是很具體:一批區塊同時被 503 打回來,如果大家都在同一秒重送,vLLM 剛起來就又被打爆一次。至於 30 秒上限和 OCR 最多三次,我沒有資料撐著,要等 Day 26 的紀錄累積以後,看連線錯誤通常在第幾次重送後恢復,再回來調。


寫到這裡,我桌面上攤著五個檔案:Day 18 的 route 和 split_region、Day 20 的 Commander 和 RULES、Day 21 的 call 和 MopsDomainVerifier、Day 22 的 run_four_layers、今天的 Envelope 和 RULES_V2。每一塊單獨跑都沒問題,但它們之間還沒有一行程式把彼此接起來。像一箱拆好分類的樂高,說明書只存在我腦子裡。


上一篇
Day 22 - 四重驗證:Checksum、Logprob、Tesseract、MOPS 領域語意 Verifier
下一篇
Day 24 - Commander-Subagent-Verifier Harness 完整組裝
系列文
從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言