iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
AI Engineering

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

Day 19 - 為什麼 Pipeline 不夠好:單向流程的天花板

  • 分享至 

  • xImage
  •  

昨天的 Pipeline 收在一個 for 迴圈:區塊進去,結果出來,寫一行 JSONL,下一塊。

我想用一個真實案例說明這個迴圈的問題在哪。不用假設,Day 14 就有現成的。

一個 Pipeline 會怎麼處理 page 61

如果 Day 14 那天我手上已經有 Day 18 的 Pipeline,但沒有 Docling、也沒做切割,只是單純「一頁一請求」,page 61 會留下這樣一行紀錄。數字全部出自 experiments/mops/step2_mops_results.jsonl:

{"page_idx": 61, "label": "labor_violation", "http_status": 200,
 "elapsed_sec": 58.191, "prompt_tokens": 292, "completion_tokens": 3000,
 "finish_reason": "length", "completion_tok_per_sec": 51.55}

HTTP 200。每秒 51.55 個 token,比平常還快一點。

實際內容是:開頭一段「民國一十四年及前一十三年比較,對於業務量與利潤之變化…」,原頁根本沒有這段話;接著陷入複讀;然後 700 多行 | | | 一路吐到 token 上限。三個公告字號、三筆罰鍰,一個都沒有。

Pipeline 看到的只有那行 JSON。它會把這頁寫進輸出檔,然後開始跑 page 62。

另外三頁也一樣:page 5、9、44 全部 finish_reason: length,各花 58 到 60 秒。四頁加起來 237 秒,拿到四份不能用的輸出。

那加一條規則不就好了?

這是我第一個反應。finish_reason == "length" 就重跑嘛,一行 if 的事。

那重跑要怎麼跑?

同樣的圖再送一次?temperature 是 0,會得到一模一樣的垃圾。

把 max_tokens 拉高?Day 8 對 page_19 開的處方就是這個,對那頁可能有用。但對 page 61 沒用,因為它不是輸出太長被切斷,是看不清楚在亂編,給它更多 token 只是讓它編更久。

切半?Day 14 真的試了。結果放在 step4_split_results.jsonl:

半頁 completion tokens finish_reason 耗時
page 5 左 3000 length 58.014s
page 5 右 3000 length 57.986s
page 9 左 441 stop 8.828s
page 9 右 3000 length 58.095s
page 44 左 1626 stop 31.603s
page 44 右 510 stop 10.150s
page 61 左 3000 length 58.354s
page 61 右 296 stop 5.998s

八個半頁有四個還是撞上限。而且我最在意的 page 61 左半,就是裁罰段落所在的那一半,切完之後還是 0/6。

最後真正有效的是 Day 15 的緊裁加 400 DPI:5.561 秒、277 個 token、stop、6/6。

所以「撞上限就重跑」這條規則,正確的動作取決於「為什麼撞」。page_19 是輸出太長,page 61 是輸入太密。同一個 finish_reason,兩種病,兩種藥。

一條靜態的 if 寫不出這個判斷。你需要看更多東西:這塊區域有多少字(文字層可以告訴你)、輸出裡有沒有大段重複、輸出的內容跟文字層有沒有交集。然後還需要一個地方記住「這塊已經切過一次了,下次要換別的方法」。

更麻煩的是安靜的失敗

length 其實算好抓的。它至少會在紀錄裡留下一個不正常的值。

Day 2 那次三頁合併的壓力測試才真的讓我不舒服:

{"test": "a6_combined_3pages", "http_status": 200, "elapsed_sec": 30.148,
 "prompt_tokens": 339, "completion_tokens": 1464,
 "finish_reason": "stop", "completion_tok_per_sec": 48.56}

stop。48.56 tok/s。每個欄位都漂亮。

但事後拿文字層比對,覆蓋率 0.9124,diff 塊 35。「股東權益總計」和「流動比率」兩整列不見,「營業外收入及支出」一列不見,(1,678) 變成 1,678,一年內到期長期負債 4,455 變成 短期負債 4,555。

Pipeline 對這筆紀錄唯一能做的事,就是照單全收。它的每一個步驟都成功了。

把過去幾天的失敗排在一起

我把 Day 2 到 Day 15 遇到、而且真的有紀錄的失敗全部列出來,問同一個問題:當時是誰發現的?

失敗 出處 Pipeline 看得到的訊號 實際上誰發現的 修正動作
整頁撞上限、幻覺加複讀 Day 14,page 5/9/44/61 finish_reason: length 我讀了輸出 切半,然後緊裁
切半後仍 0/6 Day 14,page 61 左 length(有時) 我逐字 grep 關鍵事實 改成緊裁單欄 + 400 DPI
歸因錯誤:以為模型讀不懂中文財報 Day 14 沒有 獨立 verifier 對照 prompt_tokens 恆為 292 推出固定視覺 token 預算 改寫結論方向
裁切混入隔壁欄 Day 15 沒有 我打開 GT 檔看到「展望未來…」 把 x 範圍縮到單欄
三頁合併漏列、負號遺失 Day 2 沒有,stop 事後拿文字層做 diff 拆成單頁跑
答案卷本身漏抽 Day 6,page_02、page_19 沒有 看圖比對 另找 ground truth(Day 17 的 XBRL)

第三欄那一排「沒有」,就是單向流程的天花板。

第四欄就更尷尬了。過去兩個禮拜,這條流程裡負責「發現不對、想原因、決定下一步」的角色,一直是我本人。半夜盯著 gt.txt 看的那個人,就是 Harness。只是他不能擴充、會累、沒有留下決策紀錄,而且 Day 14 那次如果不是 verifier 攔下來,他差一點把錯的結論寫進文章。

用程式重演一次

我把「Pipeline 會怎麼處理」寫成一段可以對著真實落檔跑的程式。它只讀 experiments/ 底下已經存在的 JSONL,不發任何請求:

import json
from pathlib import Path

MOPS = Path(r"D:\iron-people\experiments\mops")


def one_way_pipeline(records: list[dict]) -> list[dict]:
    """單向流程:每筆結果直接往下游交,唯一的判斷是 HTTP 有沒有成功。"""
    accepted = []
    for r in records:
        if r["http_status"] == 200:
            accepted.append({"page": r["page_idx"], "side": r.get("side", "full"),
                             "status": "done"})
    return accepted


def what_should_have_happened(r: dict) -> str:
    """同一筆紀錄,如果有人願意看第二眼。"""
    if r["finish_reason"] == "length":
        return "不能交出去:撞上限。下一步要看是輸出太長還是輸入太密"
    return "可以往下,但還需要內容層的驗證才知道對不對"


for name in ("step2_mops_results.jsonl", "step4_split_results.jsonl"):
    rows = [json.loads(line) for line in (MOPS / name).read_text(encoding="utf-8").splitlines()]
    done = one_way_pipeline(rows)
    blocked = [r for r in rows if r["finish_reason"] == "length"]
    wasted = sum(r["elapsed_sec"] for r in blocked)
    print(f"{name}: Pipeline 標記完成 {len(done)}/{len(rows)};"
          f"其中撞上限 {len(blocked)} 筆,花掉 {wasted:.1f} 秒")
    for r in rows:
        print("  ", r["page_idx"], r.get("side", "full"), "→", what_should_have_happened(r))

依落檔的值算,第一個檔會是「完成 4/4,撞上限 4 筆,約 237.3 秒」,第二個檔是「完成 8/8,撞上限 4 筆,約 232.4 秒」。十二筆紀錄 Pipeline 全部標成完成,其中八筆是不能用的。

what_should_have_happened 那個函式故意寫得很弱。它只會說「不能交出去」,說不出「接下來該做什麼」。要說出下一步,得知道這塊區域之前試過什麼、文字層說它有多少字、預算還剩多少。這些資訊 Pipeline 的單一步驟都沒有,它們散在上游、下游、還有我的腦袋裡。

「加分支就好」為什麼不夠

寫到這裡,我自己先跳出來反駁自己一次。

Pipeline 又不是只能直線。Airflow、Prefect 這類工具都有條件分支,撞上限就走 B 路線,不就解決了?

我試著把 page 61 的處理寫成有分支的版本:

def branching_pipeline(region, call_vision, recrop):
    r = call_vision(region, dpi=200)
    if r["finish_reason"] == "length":
        pieces = recrop(region)                 # 分支 B:切小
        return [call_vision(p, dpi=400) for p in pieces]
    return [r]

看起來很合理。問題有三個,一個比一個麻煩。

第一,分支 B 如果又失敗了呢?再加一層 if?page 61 從整頁到緊裁,中間經過了「切半」這個失敗的嘗試。真實世界的修正路徑長度不固定,寫死的分支深度永遠不夠,或者寫得太深讓每一塊都白跑好幾輪。

第二,recrop 需要文字層的 block 座標,那是 Extractor 那一段的產物。分支發生在 OCR 這一段,它得回頭跟上游要東西。在 Pipeline 裡這通常代表把整包上游資料一路往下傳,每一段都背著所有人的行李。

第三個最根本:這段程式對 Day 2 那筆 stop 的三頁合併完全無效。它會走 return [r],高高興興地交出一份漏了兩整列的表。分支只能對「看得到的訊號」反應,而最傷的錯誤恰好沒有訊號。要抓它,得有一個角色去做內容層的檢查,檢查完的結果還得能把流程拉回去重做。這已經不是分支,是迴圈加上一個會做決定的人。

所以差別不在有沒有 if。差別在於:失敗之後,系統有沒有一個地方同時知道「這塊之前試過什麼」「上游有什麼資料可以用」「驗證說了什麼」,然後根據這三件事決定下一步。

兩種架構差在哪

Pipeline Harness
資訊流向 往下游 可以回頭
失敗時 記下來,繼續 判斷原因,改變下一步
誰決定重試方式 寫死在步驟裡,或沒人 一個專門負責派工的角色
驗證 可有可無,通常在最後 每一步都可能觸發
決策紀錄 通常沒有 每次派工都要留理由
適合 輸入均質、失敗會大聲的任務 輸入不均質、失敗是安靜的任務

最後一列是我的真心話。

Pipeline 不是壞東西。如果一份文件的文字層乾淨、沒有表格、失敗都會噴錯,昨天那支 run_pipeline 就夠了,硬要套 Harness 只是多寫一堆程式碼。我看過不少「全部都要 agent 化」的設計,最後花在協調上的成本比工作本身還高。

但繁中財報不是這種文件。Day 10 量過同一份文件裡有覆蓋率 0.99 的頁,也有 0.76 的頁;Day 14 看到同一個 finish_reason 背後是兩種病;Day 2 看到漂亮數字底下漏掉兩整列。輸入不均質、失敗安靜,剛好是 Pipeline 最不擅長的兩件事。

「從 LLM 到 Harness」這個系列標題,轉折就在這裡。前十八天做的事,其實都在把單一步驟做好:選模型、測速度、定驗證介面、寫欄位規則、接 XBRL。每一步單獨拿出來都比 Day 2 好很多。但讓它們接成一條直線之後,系統還是不知道自己什麼時候錯了,就算知道了,也不知道該叫誰處理。

所以要加的第一個東西

不是更多驗證,也不是更強的模型。

是一個會看結果、會決定下一步、而且會把理由寫下來的角色。它不用很聰明,甚至最好不要太聰明。page 61 那一連串「整頁 → 切半 → 緊裁」,如果寫成規則,其實就是三條:撞上限時先查文字層字數,字數超過上限就依版面切小,切過一次還不行就提高 DPI 再切。

我花了兩天才試出來的東西,下次應該由程式在一分鐘內做完。

明天來寫這個角色。它叫 Commander,而我對它的第一個要求是:它自己不准做 OCR。


上一篇
Day 18 - Pipeline 組裝:PyMuPDF、Docling、Region Routing
下一篇
Day 20 - Commander 設計:分派邏輯與任務拆解
系列文
從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言