昨天的 Pipeline 收在一個 for 迴圈:區塊進去,結果出來,寫一行 JSONL,下一塊。
我想用一個真實案例說明這個迴圈的問題在哪。不用假設,Day 14 就有現成的。
如果 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。