模組三|AI 生圖與生片產線(Day 10–19)
昨天講風格與角色解耦:畫風走圖片、角色走文字。今天講那些「角色文字」實際放在哪裡。
這篇是純工程,沒有 AI 的部分。但它決定了整條產線好不好改。
第一版的生圖腳本長這樣(真的是這樣):
CHARACTERS = [
("char-01",
"front-line assault unit, crimson short hair, heavy combat exosuit with exposed "
"chip-blade forearm, confident aggressive pose holding a glowing energy cleaver, "
"red circuitry glow, battle-worn"),
("char-02",
"long-range sniper unit, silver long hair in low ponytail, sleek prosthetic arm, "
"cold calm expression, kneeling with an oversized rail sniper rifle, cyan scope glow"),
...
]
一個 Python list,20 個 tuple,每個裝一大段英文描述。
這在只有三個角色的時候完全沒問題。到 20 個角色之後,它有三個很實際的毛病。
毛病一:改設定變成改程式。
我想調整某個角色的服裝描述(一個純內容的決定),但我要打開 .py、找到那個 tuple、在一堆引號和逗號之間編輯多行字串。改錯一個引號整支腳本壞掉。
內容和程式碼綁在一起,就等於每次改內容都要承擔改壞程式的風險。
毛病二:那段描述沒有地方放脈絡。
角色的設定不只有 prompt。還有 canon 摘要、為什麼這樣設計、哪些地方不能改、參考了哪幾章。
這些東西在 Python list 裡沒有位置。寫成註解會很難看,而且註解跟字串是分開的,改一邊很容易忘記另一邊。
毛病三:一個角色一個 prompt 不夠。
我後來需要針對不同模型寫不同版本的 prompt。同一個角色,送給不同引擎的寫法不一樣。
在 list 結構裡這代表 tuple 要變成 3-tuple、4-tuple……每加一種模型就要改所有 20 筆的結構。
現在的結構:
docs/character-prompts/
01-焰刃.md 02-霜瞳.md 03-綠語.md ... 13-季讀.md
主律.md 斷香者.md 焙九.md 秤兒.md 縷生.md 調律者.md 靛煙.md
index.md
20 個角色檔 + 一個索引。
每個檔案的結構固定:
# 秤兒
## Canon 摘要
(這個角色的設定重點、不能改的地方)
## 三視圖 Prompt
```plaintext
(風格段 — 已棄用,見下)
(角色描述 — 腳本讀這裡)
(不要出現什麼)
text ...
text ...
腳本這樣抽:
```bash
# 取第一個 text 區塊的第 2 段起(角色描述,跳過舊版風格開頭段)
desc=$(awk '/^```text$/{n++; inblk=1; next} /^```$/{inblk=0} inblk && n==1' "$f" \
| awk -v RS='' 'NR>=2{print; print ""}')
# 第二個 text 區塊 = negative prompt
neg=$(awk '/^```text$/{n++; inblk=1; next} /^```$/{inblk=0} inblk && n==2' "$f")
第一個 awk 抓「第 n 個 code fence 之間的內容」,第二個 awk 用 RS=''(空行分段)取第二段以後。
約定就兩條:第 1 個 text 圍欄是三視圖 prompt,第 2 個是 negative。
這是這篇我最想講的決定。
JSON 或 YAML 更適合機器讀,這沒有爭議。但這些檔案的主要讀者是我:我改設定、我查 canon、我比對兩個角色的差異,都是用眼睛在讀。
Markdown 讓同一個檔案同時滿足兩邊:
| Markdown + 圍欄 | JSON | |
|---|---|---|
| 人讀 | 有標題、有分節、可以放說明段落 | 一堆跳脫字元的長字串 |
| 機器讀 | 需要一個 awk 約定 | 直接 parse |
| 放脈絡 | 隨便放,## 備註 想寫多少寫多少 |
要另外設欄位,而且不能換行 |
| 改一段長英文 | 就是一段文字 | 要處理引號跟 \n |
代價是機器讀的那端要寫五行 awk。 我認為這筆交易很划算:那五行 awk 寫一次就不用再碰,而我讀這些檔案是每週的事。
判準是:這份資料的主要讀者是人還是機器? 主要讀者是人的,選人類友善的格式,然後補一個小 parser。
現在的流程是:打開 秤兒.md,改 ## 三視圖 Prompt 裡的文字,存檔,重跑腳本。
gen_turnarounds.sh 從頭到尾不知道有幾個角色、叫什麼名字。它只做一件事:
for f in docs/character-prompts/*.md; do
新增一個角色 = 新增一個檔案。 腳本零改動。

OUT="$out" python3 - <<'PY' || echo "FAILED $name (continuing)"
某個角色生成失敗(API 錯誤、內容審核擋下、逾時),印一行 FAILED 然後繼續跑下一個。
這在批次任務裡很重要。20 個角色跑到第 7 個掛掉,如果整支腳本結束,前面 6 個的成果還在但你要手動判斷從哪裡接。讓它跑完,最後看有幾行 FAILED,重跑那幾個就好。
(不過這裡有個陷阱:重跑的前提是 skip 邏輯正確。我的 skip 邏輯是壞的,Day 16 會講。)

講完好的部分,講一個我今天才發現的問題。
那個目錄裡除了 20 個 .md,還有兩個檔案:
prompts.json
prompts.yaml
打開來看,它們裝的是同一批內容:
欄位: ['id', 'file', 'name', 'code', 'role', 'sources',
'canon_summary', 'prompt_base', 'negative_prompt',
'prompt_flux', 'prompt_midjourney']
prompt_base、negative_prompt、prompt_flux、prompt_midjourney:.md 裡那四個圍欄的內容,在 JSON 裡全部又有一份。YAML 裡再一份。
我實測了同步狀況:
.md 角色檔: 20 prompts.json 筆數: 20
md 有但 json 沒有: 無
json 有但 md 沒有: 無
canon_summary 與 md 不一致: 0/20
prompts.yaml '- id:' 筆數: 20
目前完全同步。 但這不是好消息,這是還沒出事而已。
因為:
.md
.md 改動時更新 json / yaml這跟 Day 9 那張落後 180 回的狀態表,是一模一樣的結構。 一份沒人讀、沒人維護、看起來很正常的衍生資料,安靜地等著過期。
差別只在於狀態表已經爛了,這兩個檔案還沒。
正確的做法應該是:json / yaml 由 .md 生成,不是手寫維護。 寫一支 20 行的腳本,跑一次產出,需要時重跑。
我還沒做。而且照 Day 9 的結論,我大概不會做,因為它不在我的工作路徑上。
代價二:四個圍欄只用了兩個。
每個檔案有 Flux 和 Midjourney 專用 prompt,但現行產線一個都沒用到。它們是之前試別的模型時留下的。
沒有壞處,但它們也在等著過期:設定改了,那兩個圍欄不會有人記得改。
代價三:awk 的約定是隱性契約,而它已經被違反了。
「第 1 個 text 圍欄是三視圖 prompt、第 2 個是 negative」這件事,只寫在腳本裡。
我原本要在這裡寫「如果哪天有人多加一個圍欄就會抽錯」當成假設的風險。然後我去查了一下,發現它已經發生了。
其中一個角色檔中間多了一節:
## 三視圖 Prompt
```text ... ``` ← 圍欄 1
## 三視圖 Prompt v2(新風格 · 風格參考工作流)
```text ... ``` ← 圍欄 2 ← 腳本把這個當成 negative prompt
## Negative Prompt
```text ... ``` ← 圍欄 3 ← 真正的 negative,從來沒被送出去過
而圍欄 2 的內容是一段完整的正面 prompt:風格指示、版面規格、配色、角色描述、渲染要求全都在裡面。
所以這個角色每次生成,送出去的指令實際上是:
請避開:白色亮面裝甲、深紅短鮑伯頭、緋紅晶刃、高細節畫風⋯⋯
它在叫模型避開這個角色自己的設計。
20 個檔案裡只有這一個中招(其餘 19 個都是 4 個圍欄)。而它沒有報錯、沒有異常、輸出看起來完全正常——跟 Day 16 要講的那個 bug 是同一種形狀。
修法是不要用序號定位。 改成用標題:
fence_under() { # $1=檔案 $2=完整的 H2 標題行
awk -v want="$2" '
/^## / { insec = ($0 == want); next }
!insec { next }
/^```text$/ { inblk=1; next }
/^```$/ { if (inblk) exit }
inblk { print }
' "$1"
}
desc=$(fence_under "$f" '## 三視圖 Prompt' | awk -v RS='' 'NR>=2{print; print ""}')
neg=$(fence_under "$f" '## Negative Prompt')
再加兩行空值防呆:抽不到內容就 skip 並印出原因,不要送空字串出去。
序號是位置,標題是語意。 位置會因為任何插入而失效,語意不會。
一、內容和程式碼要分開,判準是「改這個東西的人在想什麼」。
如果修改時你在做的是內容決定(這個角色該是什麼樣子、這段文案怎麼寫、這個閾值該多少),那它就該在資料檔裡,不該在程式裡。
在程式裡的代價不是不能改,是每次改都要繞過程式的語法,而且改壞的風險跟改內容的風險混在一起。
二、資料格式看主要讀者是誰。
寫 parser 的成本是一次性的,讀不舒服的格式的成本是每次的。
三、用語意定位,不要用序號定位。
「第 2 個圍欄」「第 3 欄」「倒數第二個元素」,這類基於位置的約定,會被任何一次插入無聲地破壞。
而且破壞的方式最惡劣:它不會報錯,它會拿到另一個合法但錯誤的東西。
改用有語意的錨點(標題、鍵名、標記),插入就不再是威脅。
我這次的教訓是:我在寫文章時把它當成「未來可能的風險」,結果一查發現它已經在 repo 裡活了一段時間。你認為「將來可能會發生」的隱性契約問題,值得現在就去 grep 一次。
四、任何「同一份內容存在兩個地方」的結構,都要問一句:誰負責同步?
答案只有三種:
我的 .md / json / yaml 是第 3 種。它現在 0/20 不一致,看起來很健康——但健康的是今天的狀態,不是這個結構。
判斷一個重複是不是問題,不要看它現在同不同步,要看它不同步的時候誰會知道。沒有人會知道的,就是遲早的事。
明天 Day 13,講一個完全不同類型的問題:同一個 prompt 連續 4 次被判 NSFW,改寫措辭完全無效。我會講排查的順序、最後怎麼繞過去,以及一個結論——有時候換模型比改 prompt 快得多。