模組三|AI 生圖與生片產線(Day 10–19)
模組三的圖產線收尾。前六天講怎麼把產線建起來,今天講它壞掉的地方:一個只有帳單會告訴你的 bug。
而且我要先說:這個 bug 我在 2026-07-23 就記下來了,標註「重跑陷阱(仍在)」。今天是 08-06,它還在。這篇的最後會講為什麼。
三視圖生成腳本要跑 20 個角色,每個角色一次 API 呼叫。
跑到一半失敗是常態,API 逾時、內容審核擋下、網路斷都會。所以腳本必須能重跑,而重跑的時候不該把已經生好的再生一次。
標準做法就是那一行:
for f in docs/character-prompts/*.md; do
name=$(basename "$f" .md)
[ "$name" = "index" ] && continue
out="assets/turnarounds/${name}.png"
if [ -f "$out" ]; then echo "skip $name (exists)"; continue; fi
...
檔案已存在就跳過。這行看起來完全沒問題,而且它在寫下來的當天確實是對的。
第二版產線定稿之後,成品檔名做了一次統一:從中文改成英文。
assets/turnarounds_old/ 01-焰刃.png 02-霜瞳.png ... ← 舊,中文
assets/turnarounds/ char-01-turnaround.png
char-02-turnaround.png ... ← 新,英文
改名的理由很正當。中文檔名在某些工具鏈上會出問題,統一成英文比較安全。
但那行 skip 檢查沒有跟著改。它算出來的路徑是:
out = "assets/turnarounds/" + name + ".png"
而 name 來自 prompt 檔的檔名,也就是 01-焰刃、秤兒、調律者 這些中文。
於是它每次都在找 assets/turnarounds/01-焰刃.png,而那個檔案在改名之後就不存在了。
我為了寫這篇,把 skip 邏輯抽出來做了一次 dry-run(只判斷不呼叫 API):
[WOULD REGENERATE] 01-焰刃 -> 找不到 assets/turnarounds/01-焰刃.png
[WOULD REGENERATE] 02-霜瞳 -> 找不到 assets/turnarounds/02-霜瞳.png
...
[WOULD REGENERATE] 調律者 -> 找不到 assets/turnarounds/調律者.png
[WOULD REGENERATE] 靛煙 -> 找不到 assets/turnarounds/靛煙.png
會重生: 20 角色 | 會 skip: 0 角色
20/20 重生,0 skip。
不是部分失效,也不是偶爾漏掉。這個機制是完全死的,而它看起來還活著。

因為它沒有任何症狀。
跑下去會發生什麼:
gen 01-焰刃 ...
saved assets/turnarounds/01-焰刃.png
gen 02-霜瞳 ...
saved assets/turnarounds/02-霜瞳.png
...
ALL DONE
每一行都是成功訊息。沒有 error、沒有 warning、exit code 0。
腳本第 4 行有 set -e,那是好習慣,但 set -e 只在指令失敗時中止。這裡沒有任何指令失敗。每一次 API 呼叫都成功,每一個檔案都成功寫出。
它是成功地做了不該做的事。
而且更麻煩的是:它產出的檔案是新的中文檔名,跟現有的英文檔名並存。所以跑完之後目錄裡會有 40 個檔案,兩套命名各 20 個,你甚至不會馬上發現多出來一組。
唯一的症狀是帳單,而帳單是月結的。
我把這類 bug 的特徵整理成三條,它們同時成立的時候最危險:
一般的 bug 你跑一次就知道。這種 bug 你跑一次會覺得很順利。
拆到最底層,這個 bug 的結構是這樣:
| 來源 | |
|---|---|
| skip 檢查預期的產物路徑 | 從 prompt 檔名推導(中文) |
| 實際的產物路徑 | 人工改名決定(英文) |
同一個事實(產物叫什麼名字)有兩個來源,而沒有任何機制讓它們保持一致。
寫到這裡我發現,這正是這個專案反覆出現的同一種問題:
| 出現在 | 兩個來源 | 現況 |
|---|---|---|
| Day 9 · 連載狀態表 | 手寫的狀態表 vs 實際章回檔案 | 已爛,落後 180 回 |
| Day 12 · prompt 索引 | .md vs prompts.json / .yaml |
目前同步,無機制保證 |
| Day 15 · 封面規格 | 日誌寫的「定稿」vs 實際檔案尺寸 | 已爛,日誌寫 2528×1696,實際 38/40 是 1536×1024 |
| Day 16 · skip 檢查 | 推導的路徑 vs 實際檔名 | 已爛,100% 失效 |
四次。四種形式。同一個根因。
只要同一件事實有兩個獨立維護的來源,它就會不同步,差別只在什麼時候,以及你會不會發現。
前三個的代價是資訊錯誤,第四個的代價是錢。
三個選項,記在 開發足跡.md 裡:
選項一:在腳本裡建映射表,中文 prompt 檔名 → 英文成品檔名,out 改查表。
→ 缺點:又多一個要手動同步的來源。這是用同一個病治病。
選項二:prompt 檔改名為英文,跟成品檔名同源。
→ 一次性解決比對問題。要同步改 index.md 和任何引用處。
→ 這個才是真的消除雙來源。
選項三:加 --dry-run 旗標,預設只印「會生成哪些」,確認無誤才加 --run 真的跑。
→ 不解決根因,但它讓根因再也不會偷偷花錢。
我現在的想法是二 + 三:二消除雙來源,三防止下一個我還沒想到的類似錯誤。
因為選項三保護的不只是這個 bug。任何會花錢的批次腳本,預設就不該直接執行。

這才是這篇真正的重點。
開發足跡.md 的 2026-07-23 條目,最後一行寫著:
⚠️ 重跑陷阱(仍在):腳本的「已存在即 skip」比對的是中文檔名,成品全已改名,直接重跑會把所有角色全部重生(燒 API 費)。
我當天就知道了。我甚至寫下了修法方向。
然後我沒有修。今天是 08-06,16 天後,它一模一樣。
為什麼?因為那 20 張圖已經生好了,我沒有再跑那支腳本的需求。沒有痛感,就沒有動力。
而這正是它危險的地方。等到我真的需要補生一個角色的那一天,我很可能已經忘記這件事,然後直接 bash gen_turnarounds.sh。
我做的唯一有效防護,是在日誌裡加了一行:
未修前禁止事項:不得直接執行
bash gen_turnarounds.sh,包含為了取得成本數字而試跑。
一行禁令,代替一個修好的機制。這是很弱的防護,因為它依賴我記得去讀日誌。
正確的做法是選項三,把禁令變成程式碼。寫在文件裡的規則會被忘記,寫在程式裡的不會。
一、冪等性檢查的「預期」必須跟實際產物同源。
檢查一支腳本的 skip / cache / 去重邏輯時,問這一句:
我拿來比對的那個東西,跟實際產出的那個東西,是不是同一個地方決定的?
不是的話,它們之間有一條沒人看守的縫。今天對,是因為還沒有人動過任何一邊。
二、會花錢的腳本,預設不該執行。
任何一支會呼叫付費 API、寄信、寫入外部系統的批次腳本,都該長成這樣:
預設 印出「會做哪些事」,不做
加 --run 才真的執行
成本是十行程式。它保護的不是這個 bug,是所有你還沒想到的同類 bug。
我這次是為了寫文章才做 dry-run,然後才發現失效。如果 dry-run 是內建的預設行為,我在 16 天前就會看到那 20 行 WOULD REGENERATE。
三、「不會報錯」是危險訊號。
我們習慣把「沒有 error」當成沒事。但有一整類 bug 的定義就是成功地做了不該做的事:
判斷方式:這支程式如果做了兩倍的工作,我看得出來嗎? 看不出來的地方,就要主動加一個計數或摘要輸出。
一行 echo "generated: $count / skipped: $skipped" 就能讓這個 bug 在第一次跑的時候現形。
四、把禁令寫進程式,不要寫進文件。
我在日誌裡寫「未修前不得執行這支腳本」。這是誠實的,但它是最弱的一種防護,它保護不了忘記讀日誌的未來的我。
同樣一件事,寫成 --run 旗標就是一個不可能忘記的守門員。
判斷句:這條規則如果被忘記,會發生什麼?後果嚴重的話,它就不該只是一行文件。
模組三的圖產線到這裡結束。七天講的是:產線怎麼分工(Day 10)、一致性怎麼鎖(Day 11)、prompt 怎麼變成資料(Day 12)、被擋住怎麼繞(Day 13)、圖怎麼傳(Day 14)、規格怎麼統一(Day 15)、以及它哪裡會靜靜地燒錢(Day 16)。
明天 Day 17 講成本結算:這一整條產線,實際花了多少。