這篇要解決一個很具體的問題:企業要把 LLM 接進工作流,但客戶資料不能上雲、員工資料要脫敏後才能上雲、純技術問題可以直接上雲——誰來判斷哪條 prompt 屬於哪一級,以及這套判斷可不可信 。本文記錄了從 v1 到 v8 兩天 8 個 commit 的完整驗證過程:做一個本地 LLM 驗證 harness,12 條 prompt 跑 routing + sanitize + worker 三階段,驗到 routing 12/12、worker reasoning 9/12,順手抓到兩個沒人警告過的漏洞——ccbot 反客為主、以及本地 LLM 在 response 裡 verbatim 複述原 PII / API key 的二次洩漏。
重點摘要
做什麼 :本地 LLM 驗證 harness,把 prompt 分 ABC 三級(A 客戶/PII → 本地、B 內部代號 → 脫敏後上雲、C 純技術 → 直接上雲),12 條 prompt 跑完整 pipeline 驗證
怎麼做 :三階段 pipeline——judge 用本地 LLM 分級 → sanitize regex 替換敏感詞 → worker 真做事;每條 prompt 加 expected_keywords,response 比對 ≥30% hit 算過關
為什麼 :routing 是 defense in depth 第一層門禁,沒人擋的話客戶名直接被當技術問題上雲;本地 judge 必要,因為 A 級資料連「分類」這個動作都不能上雲
Prompt vs 本地 model :15 顆 model × 12 prompt 跑出來——size 不是 axis,prompt-stability 才是;thinking model + Ollama JSON 架構級不相容,全 0/12;-nothink 後綴騙人;qwen3-nothink + qwen2.5:7b 兩顆滿分
ccbot 意外 :在 ccbot Telegram session 內叫 CC 跑驗證,子 claude -p 寫的 PostgreSQL 健檢稿漏進父 ccbot 視窗,反客為主蓋掉用戶的方法論討論。修法是雙保險:stdio 隔離 + 環境偵測 short-circuit
v8 補洞 + 新發現 :4 個 hole 全修(routing 11/12 → 12/12、cross_team CLI baseline 建立、judge 改 qwen2.5:7b 跟 worker 交叉、forbidden_keywords 抓反向洩漏);新發現「routing 對 ≠ worker 不洩漏」——qwen3-nothink 本地 worker 會在 response 裡 verbatim 寫回原 PII / API key,留 v9 用「output 也跑 sanitize」對稱性原則修
一、在做什麼:給 LLM 工作流加一道「資料分級」前門
企業導入 LLM 第一個踩到的雷是資料治理 。同樣是「請幫忙處理一下」,客戶投訴不能跟 OpenAI 講、員工 review 可以脫敏後問,但純技術問題(Kafka rebalance 怎麼解)直接打雲端 API 最快。沒有分級機制,要嘛全本地(成本爆炸 + 質量差)、要嘛全雲端(資料外洩 + 法遵爆炸)。
所以這套 harness 的工作目標只有一個:每條 prompt 進來自動分級,並驗證這個分級正確、後續處理也對 。三層定義:
級別
特徵
處理方式
範例
A
真實客戶名 / PII / credentials
本地 LLM 處理,連分類都不上雲
「客戶 A123456789 反映…」
B
內部代號 / 員工名
sanitize 替換成 placeholder 再上雲
「[employee_alice] 寫的 5 個模組…」
C
純技術 / 公開知識
直接上雲,可派 Kiro / Claude Code 並行
「Kafka consumer rebalance 怎麼解?」
驗證集 12 條 prompt(prompts.py:PROMPTS_V7_ABC):7 條 happy path 覆蓋 A/B/C × team/cross 笛卡兒角落,5 條 adversarial 壓邊界(PII override、ambiguous team、camouflage api key、隱式 cross_tool、嵌套客戶名)。
二、怎麼做:三階段 pipeline + keyword eval
2.1 三階段 pipeline
prompt → [Stage 1: Judge] 分 ABC 級 + need_team + cross_tool
→ [Stage 2: Sanitize] B 級替換內部代號為 placeholder
→ [Stage 3: Worker] 按級別分派
A → worker_local_real (Ollama 本地推理)
B → kiro CLI (sanitize 後)
C → kiro CLI 直接打
C+team → ThreadPoolExecutor 並行
C+team+cross → Kiro × N + Claude × M 混編
2.2 Judge 用本地 LLM(Ollama)
Judge 是整個 harness 最關鍵的一層 ——它判斷一條 prompt 屬於哪一級,只要它判錯,defense 整個垮。所以 judge 必須:
本地跑 :不能把 prompt 送雲端去問「這條 prompt 含 PII 嗎」——因為光送過去就洩了
強制 JSON 輸出 :Ollama format=json,規範回傳 {"level": "A", "need_team": false, "cross_tool": false}
System prompt 含 few-shot :純規則對小模型沒用,要附 4 個 input/output 對偶範例(覆蓋 A/B/C × team/cross 角落),模型才會把規則當回事
2.3 Sanitize 用 regex(6 類 pattern)
# sanitize.py 簡化示意
PATTERNS = [
(r'\[client_\w+\]', '[CLIENT_REDACTED]'), # 客戶代號
(r'\[employee_\w+\]', '[EMPLOYEE_REDACTED]'), # 員工名
(r'\[internal_\w+\]', '[INTERNAL_REDACTED]'), # 內部代號
(r'sk-[A-Za-z0-9_-]+', '[api_key]'), # API key
(r'\b\d{3}-\d{3,4}-\d{4}\b', '[phone]'), # 台灣手機
(r'\b[A-Z]\d{9}\b', '[tw_id]'), # 台灣身分證
]
sanitize 是 B 級的本分,但也是 A 級的補強 ——judge 萬一漏判 A 級成 C 級,sanitize 還能擋一刀(token 不會流出去)。defense in depth 兩層獨立。
2.4 Worker 真做事 + keyword eval
v3-v6 的 worker 全是 stub:worker_local_skip() 回字面字串「[A 級本地處理] 會 spawn…」、worker_kiro() 回「OK 收到」。意思是滿分等於通過 routing 考卷,不等於這個系統會幹活 。v7 把 worker 改真:
worker_local_real:HTTP 打 http://localhost:11434/api/chat,用 qwen3-nothink 真推理
worker_kiro:subprocess kiro-cli chat,抓最後 3000 字當 response
worker_cross_team:ThreadPoolExecutor 真並行,3 facet 派 Kiro + 1 facet 派 Claude,結果合併
每條 prompt 加 expected_keywords 列表,response 比對 ≥30% hit 才算 reasoning_passed。all_correct = routing_correct AND reasoning_passed——兩條軸都對才算這條 prompt 真的成功。
三、為什麼用這個方法
四個設計選擇,每個都有對應的失敗情境:
設計
替代方案
為什麼選這個
本地 LLM 當 judge
雲端 LLM 判定 + 留 audit log
A 級資料連「請判斷這條算什麼級」這個動作都不能傳出去——光問就洩
judge + sanitize 兩層
只用 LLM judge,信任它分對
defense in depth:judge 失誤時 sanitize 兜底,兩層獨立失誤率相乘
expected_keywords ≥30% hit
人工標 ground-truth + 拿 LLM 評分
v3-v6 沒有自動評分,worker 全是 stub 也驗不出來;30% 拍腦袋,但有比沒有強
12 條 prompt(7 + 5 adv)
100 條 ground-truth 大集
驗證集大不一定強——關鍵是覆蓋角落 case + 30% adversarial。沒 adversarial 的 benchmark 會給你錯覺,gemma2:2b 看 happy path 5/5 完美,加 adversarial 立刻崩到 0/7
四、Prompt 跟本地模型的測試情況
這節是整篇技術重點——15 顆本地 model × 12 prompt 跑出來的對照,直接決定 production 配置。
4.1 完整對照表
Tier
Model
Size
All correct
Avg latency
用途
1 滿分
qwen3-nothink:latest
2.5GB
12/12
7.4s
PRIMARY
1 滿分
qwen2.5:7b
4.7GB
12/12
11.8s
FALLBACK
2
qwen2.5:3b
1.9GB
9/12
7.4s
LATENCY
3
qwen2.5:0.5b
397MB
7/12
4.9s ⚡
EXTREME
4 跨家族
phi3.5、llama3.2:3b、gemma2:2b
1.6-3.8GB
6/12
5.7-9.6s
marginal
5 全死
qwen3:4b/14b、qwen3.5:4b/9b、qwen35-9b-nothink、gemma4:e4b
2.5-9.6GB
0/12
14-104s
REJECT
5 OOM
llama3.3:latest
42GB
0/12
HTTP 500
REJECT
4.2 四條歸納
Size 不是 axis,prompt-stability 才是 。0.5b → 3b → 7b 一條乾淨單調曲線(7→9→12),但 7b vs 14b thinking 完全反向(12 vs 0)。size 跟 accuracy 沒有單調關係,真正分水嶺是「對 prompt 變動穩不穩」。
Thinking model + Ollama JSON 架構級不相容 。6 顆 thinking model 加 few-shot 仍然 0/12 → 不是調 prompt 能救,是模型走 reasoning chain 時把 num_predict budget 燒在 <think> tag,還沒輸出 JSON 就被截斷。
-nothink 後綴騙人 。qwen35-9b-nothink:latest 仍然 0/12,跟其他 thinking model 同表現,後綴只是 Ollama tag 名稱不是真正關了 thinking。新 model 必須跑 30 秒 smoke test 才知道。
VRAM 跌出 → 災難 。size > ~7GB 在 16GB RAM 機器上會丟出 GPU,qwen3:14b 41s/call、gemma4:e4b 104s/call。可用上限約等於「VRAM – 1.5GB」。
4.3 Few-shot 是怎麼救活跨家族 small instruct 的
v3 一開始觀察到 phi3.5 / llama3.2:3b / gemma2:2b 全死在 level——3 個不同家族同時死在同一個地方,本能歸因到「small instruct safety bias」。後來重新驗,把 system prompt 從純規則改成「規則 + 4 個範例」(few-shot in system prompt),結果:
Model
純規則
+ few-shot
Δ
qwen3-nothink:latest 10/12 12/12 +2
qwen2.5:7b 5/12 12/12 +7
phi3.5:latest 1/12 6/12 +5
llama3.2:3b 2/12 6/12 +4
gemma2:2b 5/12 6/12 +1
真實結論:純規則對小模型是可忽略的 boilerplate;規則 + 範例才會被當成必須對齊的 anchor 。所以 size 不是 axis 這件事的另一半是:prompt 工程裡「有沒有 grounding example」才是真 axis。
4.4 新模型來時怎麼判斷能不能用
不要每顆都跑全套 30 分鐘。把 trait 抽出來變 5 步驟 checklist(tools/check_new_model.py):
Stage 0 30 秒 smoke :輸出 {"ok":true} → 失敗直接淘汰,不跑下去
Stage 1 看 model card :base/pretrained 跳過,要 instruct/chat 標籤
Stage 2 12-prompt full suite :< 7/12 reject、7-11 marginal、12/12 production candidate
Stage 3 n=3 一致性 :同一條 prompt 跑三次 level 都一致才算穩
Stage 4 PII adversarial :5 條藏 PII 進技術句,要 100% 抓 A 級
五、結論被推翻三次:差異在哪
整個工作從 v1 到 v8 兩天 8 commits 推翻三次結論又補了一輪洞。差異:
版本
當時主張
後來被翻成
v3 (overnight benchmark)
「只有 qwen3-nothink 唯一可用,7B+ qwen2.5 危險會洩客戶資料,size 不是 axis」
v4 翻盤:7B+ qwen 都行,危險是 prompt 沒範例造成,fallback 三層全有
v4 (few-shot breakthrough)
「qwen3-nothink 12/12 滿分,prompt-stability 是真 axis」
v5 戳破:12/12 是 routing 滿分,worker 一次都沒真做事
v7 (end-to-end + ccbot fix)
「routing 對不等於 worker 對等,worker reasoning 9/12 才是真實水準」
v8 部分修正:routing 12/12 完成,但又翻出新軸——worker output 自己會 echo PII
v8 (holes fixed + PII echo)
「sanitize 前置 + judge 交叉 + forbidden_keywords + cross_team baseline 4 個 hole 補完」
新發現:routing 對不等於不洩漏——本地 LLM 自己會 verbatim 複述 PII,留 v9 補 worker output sanitize
四次推翻的共同 pattern :結論被翻不是因為跑得不夠,是因為跑的東西不夠多軸。v3 只看 routing,v4 只看 routing+prompt 變動,v7 把 worker reasoning 拉進來,v8 加 forbidden_keywords 才看到 worker 自己會洩漏。每多一個軸就翻一次,翻到沒得翻為止 。
六、ccbot 反客為主意外
6.1 症狀
用戶在 ccbot Telegram session 跟 CC(Claude Code)討論 v7 方法論,中途叫 CC 跑驗證。下一秒 ccbot 視窗開始印一篇完整的 PostgreSQL 健檢文 :pg_dump --schema-only、SchemaSpy、postgres_autodoc、obj_description(attrelid, attnum)、pg_settings WHERE source <> 'default'、pgbackrest info + patronictl list、SchemaSpy + dbdocs.io + Atlas…
用戶看了打字框問:「明明在討論方法論,結果你突然 PRINT 一篇 PGSQL,反客為主?」
6.2 追根因
對照前一次 v7 跑(run_v7_20260504_123811)的 02_pipeline_v7.json,prompt 07 cross_team 的 documentation facet 輸出**字面跟用戶 ccbot 看到的內容一字不差**。所以那段 PGSQL 不是父 CC 自己生成,是子 claude -p 為驗證集 prompt 07 documentation facet 寫的稿——但它怎麼漏到父 ccbot TG 訊息流?
v7 既有 workers.py 的 tmpfile + start_new_session + stdin=DEVNULL fix 註解寫:「avoids deadlocking parent session’s stdin/stdout」。但這只擋了「子進程跟父 CC 之間的 stdio 競爭」(deadlock 來源),沒擋住:
子 claude -p 寫的 PG 稿 → tmpfile
父 orchestrator 讀 tmpfile,塞進 worker_cross_team result 的 response 欄位
父 orchestrator 把整個 result 印到 stdout / 回給呼叫端
父 CC 看到 stdout,覺得「我跑完了,把結果報告給用戶」→ 印到 ccbot TG
用戶眼睛裡:剛剛還在討論方法論,下一秒視窗變成 PG 健檢手冊
L1 防線(stdio 隔離)解的是 stdio 競爭,沒解 output 內容被 relay。要加 L2 防線。
6.3 修法雙保險
先找 ccbot session 的可靠 marker:
$ env | grep -i ccbot
MEMORY_PRESSURE_WATCH=/sys/fs/cgroup/user.slice/user-1000.slice/
[email protected] /app.slice/ccbot.service/memory.pressure
ccbot.service systemd cgroup 會 set MEMORY_PRESSURE_WATCH,任何子進程都繼承——包括 ccbot fork 出來的 CC、CC fork 出來的 orchestrator、orchestrator fork 出來的 claude -p。完美 marker。修法:
def _running_inside_ccbot() -> bool:
"""Override:
WALSIN_FORCE_CC_WORKER=1 -> force enable
WALSIN_FORCE_CC_WORKER=0 -> force disable
"""
override = os.environ.get("WALSIN_FORCE_CC_WORKER")
if override == "1": return False
if override == "0": return True
return "ccbot" in os.environ.get("MEMORY_PRESSURE_WATCH", "")
def worker_claude(prompt, timeout=120):
if _running_inside_ccbot():
return {"tool": "claude",
"response": "[SKIPPED: running inside ccbot — "
"`claude -p` output would leak into parent TG. "
"Set WALSIN_FORCE_CC_WORKER=1 to override.]",
"latency_ms": 0, "exit_code": 0, "skipped": True}
# ... 原本的 Popen + tmpfile 邏輯
6.4 驗證:fix 有用且沒破壞 cross_team 可驗證性
修完跑 prompt 07 cross_team 的 4 個 facet:
facet
tool
skipped
latency
證據
security kiro False 12911ms 真 PG security 答案
testing kiro False 8466ms 真 PG testing 答案
performance claude True 0ms [SKIPPED: ...]
documentation claude True 0ms [SKIPPED: ...]
沒漏 PG 內容。reasoning_passed=True(62.5% hit)——kiro 兩個 facet 已經自然涵蓋足夠 PostgreSQL 關鍵字(pg_stat、replica、backup、WAL、index),L2 防線 short-circuit 沒破壞 cross_team 可驗證性。
注意這個 trade-off:L2 防線只在 ccbot 內生效 。獨立 CLI 跑 WALSIN_FORCE_CC_WORKER=1 python3 orchestrator_v7.py 會放行 claude -p,完整 4 facet 都實跑——這才是「真要驗 cross_team 跨工具質量」的 baseline。本次跑因為在 ccbot 內,只算「ccbot 安全模式驗證」,完整 cross_team 留 v8 在獨立環境補。
七、v8 補洞 + 抓到 v7 看不到的新漏洞
v7 結尾留了 5 個洞,本輪一次解 4 個,順手抓到一個 v7 完全看不到的新類別漏洞。
7.1 4 個 hole 修法
#
Hole
修法
驗證
1 🔴 #12 API key routing fail sanitize.has_a_level_pii() 前置 gate,regex 命中強制 level=Arouting 11/12 → 12/12 ✅
2 🔴 cross_team CLI baseline 沒驗 WALSIN_FORCE_CC_WORKER=1 讓 claude facet 真 fork(L1 stdio fix 還在兜底)#07 reasoning 62% → 88% ✅
3 🟡 judge / PRIMARY 同一顆 qwen3-nothink JUDGE_MODEL = "qwen2.5:7b",跟 worker 兩個世代交叉獨立性建立 ✅
4 🟡 30% 閾值沒抓反向洩漏 每條 prompt 加 forbidden_keywords + per-prompt pass_threshold 立刻紅了 #11 + #12 ⚠️
7.2 v7 vs v8 跑分對比
指標
v7
v8
Routing correct 11/12 12/12 ✅
Reasoning passed 9/12 9/12
ALL correct 9/12 9/12
ALL 沒變的原因:routing 多修對 1 條(#12)、reasoning 多失敗 1 條(#11 被 forbidden_keywords 抓到 PII leak)→ 互相抵消。但這個抵消是好事 :v8 多抓的那個 fail 是真實 production 問題,v7 的「pass」是因為沒檢查所以沒看到。
7.3 新發現:worker PII echo(routing 對 ≠ 不洩漏)
加 forbidden_keywords 後,#11 + #12 立刻紅:
[11_adv_tw_pii] level=A -> local_real (111691ms) reasoning=0.375
hits=['log', 'session', '排查']
LEAK=['A123456789', '0912-345-678'] x
[12_adv_api_key] level=A -> local_real (115094ms) reasoning=0.125
hits=['401']
LEAK=['sk-test-abc123def456ghi789jkl'] x
兩條都是 routing 對(level=A,本地處理),worker 也走對,但 worker 寫的 200 字 response 整段把原 PII / API key verbatim 重複出來 。qwen3-nothink 在排查方向裡寫了類似「客戶 A123456789 反映…」「token sk-test-… 看起來像…」這種句子。
意義 :routing 的 A 級保護是「prompt 不上雲」,但 worker 寫出來的 response 還是會被印 log、塞 ccbot relay、走 webhook 給下游 → 從第二條路洩出去 。Defense in depth 的第三層(worker 自我審查)還沒做。
v7 為什麼看不到 :v7 reasoning eval 只看正向 expected_keywords(該寫什麼),沒有反向 forbidden_keywords(絕對不能寫什麼)。回應只要寫對技術方向就 pass,模型有沒有複述 PII 完全不檢查。
修法路徑 (留 v9):defense in depth 第三層——對稱性原則,input 過 sanitize,output 也要過 sanitize。實作:worker_local_real() 在 return 前把 response 也送進 sanitize(),有 PII pattern 命中就替換成 placeholder。即使 LLM 複述 PII,輸出層也會擋。
7.4 v9 還沒補完的 5 個洞(誠實清單)
🔴 worker PII echo (本輪新發現,留 v9 用「output sanitize」對稱性原則修)
🟡 #04 5 模組 review reasoning fail (kiro 沒程式碼可看就拒答,是 prompt 設計問題不是 worker)
🟡 30% threshold 仍未個別 calibrate (per-prompt 機制已支援,但實際每條的 threshold 沒個別調過)
🟢 跨家族 12/12 樣本不足 (mistral-7b / yi-1.5 沒下載)
🟢 judge p99 latency (qwen2.5:7b 平均 8s,#01 偶發 56s,看是不是 cold start)
給跟著做的人三條提醒
Routing 滿分不要爽到忘了驗 worker 。v3-v6 routing 滿分但 worker 全是 stub,直到 v7 加 expected_keywords 才看到 9/12 真實水準。reasoning eval 不必很完美(30% 閾值就有用),但有比沒有強得多 。
在 LLM agent 內 fork 同類 LLM,環境隔離不能只靠 stdio 。要 env-marker double-gate(L1 stdio + L2 環境偵測 short-circuit),否則子寫的稿會回流到父的對話視窗。任何「Claude Code fork Claude Code」「ChatGPT plugin call ChatGPT」這種設計都要警惕。
-nothink 後綴騙人,size 不是 axis 。qwen35-9b-nothink:latest 跟其他 thinking model 同樣 0/12。新 model 來請跑 tools/check_new_model.py 30 秒 smoke + 12-prompt full,不要看 model card 標籤就決定收進候選池。
對稱性原則:input 過 sanitize,output 也要過 sanitize 。即使 routing 100% 對、prompt 沒上雲,本地 LLM 自己會 verbatim 複述 PII / API key 在 response 裡——response 一旦被印 log、寄 ticket、走 webhook 就二次洩漏。Reasoning eval 必須加 forbidden_keywords 反向檢查,worker return 前也要再過一次 sanitize。
原始素材
更新時間:2026-05-04 14:30(整合 v1 → v7 兩天 7 commits 重新編排)