前面幾天造的東西有一個共同點:它們都在限制 AI —— CLAUDE.md 限制它怎麼做(Day 6),測試限制它做得對不對(Day 9),settings.json 限制它做得到什麼(Day 10),hook 擋在它寫入之前(Day 17)。
今天換一個方向。不是限制它,是換一個更便宜的它 —— 一台 Mac mini、一個 35B 的地端模型、一支自己包的 CLI,把寫單元測試整批交出去。
然後用一個比「跑得過」更硬的裁判,驗它交回來的東西。同一個模型、同一段程式碼,我給了兩種交代法:一份寫死預期值的清單,和一句話。各跑三次。
心法先講:先問誰當裁判,再問誰來做。
「把工作外包給地端模型」這個做法的宣稱是:量大、機械性、規格明確的工作,不需要用最貴的模型做。
聽起來合理,但它預設了一件事 —— 省下的生成成本,大於驗證與返工的成本:
外包的划算程度 = 省下的生成成本 − 驗證與返工的成本
如果驗證與返工要花的力氣比自己寫還多,外包就是負收益。所以判準不是「這件事難不難」,是「規格能不能先寫死,結果有沒有一個便宜的裁判」。
| 工作 | 有沒有便宜的裁判 | 能不能外包 |
|---|---|---|
| 寫單元測試 | ✓ 跑測試 + 突變檢查 | 可以 |
| 寫實作 | ✓ 前提是先有測試 | 有條件 |
| 機械性重構 | ✓ 既有測試 | 可以 |
| 架構設計 / 介面定義 | ✗ | 只外包候選方案,不外包決定 |
第一列的裁判寫的是「跑測試 + 突變檢查」,後半截不能省。下一節說為什麼。
一個測試可以完全不測任何東西:
it('借 14 天會算出到期時刻', () => {
expect(dueAt('2026-09-04T02:00:00Z', 14)).toBeDefined() // ← 只要不是 undefined 就過
})
這個測試永遠是綠的。把 dueAt 的算式改成加 13 天、加 0 天,它還是綠的。它有測試該有的一切外觀:有名字、有斷言、跑得動、進得了 CI、算得進覆蓋率 —— 判別力是零。
而這種測試特別容易從「量產」的路徑長出來,因為寫出這種測試最快,而且看起來最像是完成了工作。
覆蓋率抓不到它。覆蓋率量的是測試有沒有執行到那一行,不是那一行寫錯了測試會不會紅。上面那個例子覆蓋率可以是 100%。
突變檢查(mutation testing)量的是後者:
故意把 code 改壞,看測試會不會抓到。改壞了測試還是綠的 —— 那個突變「活下來」了,代表這段 code 沒有被真的測到。
指標叫殺掉率(mutation score):被抓到的突變數 ÷ 有效的突變數(編譯不過、或讓測試根本跑不起來的突變不算進分母;這次 56 個全部有效)。Day 9 手動改過四種寫法打 overdue.js;今天交給 Stryker 自動產生,一次幾十個。
硬體:Mac mini M4 Pro,20-core GPU,64 GB 統一記憶體。
模型:qwen3.6-35b-a3b-mlx(MLX 4-bit 量化,約 20.4 GB)。為什麼不用更強的 qwen3.8-27b,下一節有數字。
工具:pil —— 用開源的 Pi coding agent 接上 LM Studio 裡的地端模型,它會實際改檔案,所以跑之前 git 必須是乾淨的,不然分不清哪些改動是它做的。這次只給它讀寫檔的工具、不給 bash,而且用 --only "tests/local/**" 鎖死它只能寫測試資料夾。
標的:圖書館借閱 repo 的兩支純函式 —— book-state.js(書的狀態機:available ⇄ on_loan,非法轉移丟 IllegalTransitionError,未知輸入丟 TypeError)和 due-at.js(借出時算到期時刻)。Day 9 打的是 overdue.js,這兩支沒碰過。在實驗用的 clone 裡,原本那份測試先刪掉,不讓它抄;repo 裡的原檔留著當對照組。
兩種交代法。差別不只是有沒有清單 —— A 還多了四條規則 —— 所以比的是「把判斷先寫死再交出去」和「一句話交出去」;模型、起點 commit、工具權限、取樣參數都相同:
A. 精準清單 —— 我寫一份 19 條的 tests/PLAN.md,每一條寫死輸入和預期值,臨界點一個一個列出來(節錄三條,完整版在 repo):
| B2 | 最小借期 1 天 | dueAt('2026-09-04T02:00:00Z', 1) | '2026-09-05T02:00:00.000Z' |
| B6 | now 非法 | now 各用 '昨天'、''、undefined、null、1757988000000(數字)、
new Date('2026-09-04T02:00:00Z'),loanDays 用 14 | 每一個都丟 TypeError |
| B7 | loanDays 非法 | 0、-1、1.5、NaN、Infinity、'14'、undefined、null | 每一個都丟 TypeError |
給 pil 的指令附上四條規則:照清單不增減、不准讀實作推測預期值、一個 it 只驗一件事、只寫這兩個檔。
B. 一句話 —— 不給清單,實作隨它讀:
幫 src/domain/book-state.js 和 src/domain/due-at.js 寫單元測試,
放在 tests/local/book-state.test.js 與 tests/local/due-at.test.js,用 vitest。
對照組:repo 裡原本那份測試(開發時先紅後綠寫的,22 個),同一把 Stryker 打一次。
分工(這一條是實驗成立的前提):
需要判斷的東西 —— 架構、介面、測試規格 —— 留在雲端模型和我手上;需要耐力的東西 —— 把規格變成測試碼 —— 外包給地端;判定先交給機器;機器判不了的,才輪到人。
要記的數字:交回來的測試有幾個、原樣跑有幾個紅(返工)、耗時,以及最後的殺掉率。每一輪都從同一個 commit 開新分支、同一段指令、同一組工具;模型取樣用 Pi 設定檔裡的 temperature 1.0、top_p 0.95、top_k 20,reasoning 開著。紅的我只做最小修正 —— 格式、算錯的預期值照規格改對 —— 修到全綠再打突變。
磁碟上有兩個 Qwen:3.6-35b-a3b(MoE,35B 總參數、每個 token 只動 3B)和 3.8-27b(dense,每個 token 動全部 27B)。3.8 是 8 月中才出的,各家評測都說它強很多,理論上該用它。
8 月我拿同一份考卷打過兩個。考卷不是「測試跑不跑得過」—— 那種題目兩個都滿分 —— 是往一支分級計費函式裡注入 9 個各自獨立的 bug,看模型寫出來的測試抓到幾個:
| 模型 | 抓到 | 寫了幾條測試 | 耗時 |
|---|---|---|---|
qwen3.6-35b-a3b(MoE,4-bit) |
6 / 9 | 19 | 191 秒 |
qwen3.8-27b(dense,8-bit) |
7 / 9 | 18 | 2,776 秒 |
在這份 9 個注入 bug 的小考卷、每個模型各跑一次的條件下,27B 多抓到一個(四捨五入的位數),代價是 14.5 倍時間,46 分鐘。所以選 3.6 不是因為 3.8 不好,是多的那一分不值 14.5 倍時間 —— 而且那一分只要在測試清單裡多寫一句「驗四捨五入到 2 位」就補得回來。
這句話後面會再出現一次:多的那一分,清單補得回來。
三個坑有一個共同點:都是安靜地失敗,或是錯誤訊息指錯方向。它們決定了後面的數字能不能信。
第一,context 對不上,它會假裝跑完(8 月踩的)。Pi 不會去驗證 server 實際載入的 context 長度,它只信自己設定檔裡的那個數字(32,768)。當時 LM Studio 如果用預設值載入模型 —— 可能只有 8,192 —— Pi 就會照 32k 的假設送出超長的請求。症狀不是報錯,是空的 log、exit 0、沒有產出任何檔案,看起來就像沒執行。所以我在 pil 裡加了一道檢查:跑之前先問 server 實際載入的 context,比設定檔小就用 -c 卸載重載:
lms load qwen3.6-35b-a3b-mlx -c 32768 --gpu max -y
今天想重現這個坑,重現不出來 —— 但發現了另一件事。在現在的 LM Studio(0.4.23)上,-c 8192、-c 32768、--context-length 8192、不帶旗標讓它第一個請求時自動載入,全部載入成模型的上限 262,144;換一個 gemma 模型帶 -c 4096,也是載成它的上限 131,072。旗標被收下了、沒有報錯、載入成功,但實際 context 沒有照指定值走 —— LM Studio 的文件把 --context-length 寫成載入時設定 context 的參數,我在 0.4.23 實測到的行為不是這樣。
坑換了方向:以前是 context 太小、Pi 安靜地失敗;現在是 -c 寫了等於沒寫,而 pil 那道檢查只驗「不小於設定值」,所以照樣通過。這次沒有影響到結果 —— 至少看得到的只是多吃記憶體 —— 但它說明了一件事:一道檢查是對著某個版本的行為寫的,版本一換,它可能還是綠的,只是已經不在檢查你以為的那件事。
第二,「模型有沒有載入」問錯了 API 會得到錯的答案(8 月踩的,今天重測一樣)。/v1/models 列的是已下載的模型,不是在記憶體裡的;今天它列 5 個,lms ps 只有 1 個真的載入了。問前者會以為一切就緒,然後 LM Studio 在第一個請求時才自動載入 —— 8 月那時就是用預設 context 載入,回到第一個坑。
第三,檢查說「沒回應」,其實是「不讓你進」(今天踩的)。pil --check 一開頭就紅:
✗ LM Studio 沒回應 127.0.0.1:1234 — 試試 lms server start
照它說的啟動 server,成功了,再檢查 —— 還是「沒回應」。直接 curl 才看到真相:401。Day 15 那次我把 LM Studio 的 Require Authentication 重新打開了,而 Pi 設定檔裡的 key 還是佔位字串。檢查只看「有沒有成功」,把 401 和「server 沒開」混成同一句話 —— 它給的修法方向是錯的。
三個坑加起來,跟 Day 13 那個誤報、Day 17 那個 hook 是同一句話:一個會安靜地過、或把原因講錯的檢查,比沒有檢查更糟。 外包之前,先確認「它真的在跑」這件事本身有裁判。
還有一條原則跟成本直接相關:地端的輸出一律寫進檔案,不要讓它直接回到 Claude 的 context。 批次產測試檔,如果讓結果印在終端被雲端模型讀走,等於把整批測試碼塞進最貴的那個 context —— 外包省下的錢就這樣還回去了。
交回來的時候:
| 交代法 | 輪 | 耗時 | 交回幾個測試 | 原樣跑,紅幾個 |
|---|---|---|---|---|
| A 精準清單 | 1 | 115 秒 | 45 | 0 |
| A 精準清單 | 2 | 48 秒 | 20 | 整個檔案載不進來(import 漏了 afterEach) |
| A 精準清單 | 3 | 59 秒 | 46 | 0 |
| B 一句話 | 1 | 258 秒 | 21 | 3 |
| B 一句話 | 2 | 114 秒 | 26 | 3 |
| B 一句話 | 3 | 1,563 秒(卡住,手動停) | 44 | 14 |
A 的測試數跳動(45 / 20 / 46)只是有沒有用 it.each 把一條清單展開成多個 it,涵蓋的案例一樣。第 2 輪那個漏字,清單第一行其實寫了 import 要帶什麼 —— 補一個字就好。
B 的第 3 輪,兩個測試檔在第 1 分鐘就寫完了,之後它一直沒有結束 —— 測試檔沒再變過,只在十幾分鐘後多建了一個空的設定檔。它在想什麼我看不到(log 只在結束時才寫),26 分鐘後我手動停掉。同樣沒有 bash 的 A,三輪都在兩分鐘內自己收工。
返工的類型(B 三輪合計,比總數更有用):
| 返工原因 | 次數 | 是誰的問題 |
|---|---|---|
預期值少了毫秒(...00Z 應為 ...00.000Z) |
13 | 格式只寫在實作的註解裡,它讀得到,但沒照著寫 |
把 toThrow() 的回傳值當成錯誤物件用 |
4 | 把 Vitest 的 API 語意用錯了 |
| 日期算錯(時區換算、3650 天) | 2 | 算錯了 |
測了不存在的行為(以為沒帶 Z 的時間字串會解析失敗) |
1 | 把 JavaScript Date.parse 的行為判斷錯了 |
A 那邊,這四類一次都沒出現 —— 預期值是清單寫死的,它不用算、也不用猜格式。
修到全綠之後,突變檢查的結果(Stryker 對兩支檔案產生 56 個突變):
| 測試來源 | 殺掉率 | 存活 |
|---|---|---|
| A 精準清單(三輪) | 89.3%、89.3%、89.3% | 6、6、6 |
| B 一句話(三輪) | 83.9%、87.5%、85.7% | 9、7、8 |
| 對照:repo 原本那份 | 87.5% | 7 |
A 三輪的存活突變是同一組 6 個,一個不差。
總分只是一個數字,要看的是活下來的是哪幾個:
| 存活的突變 | A | B | 對照 | 意義 |
|---|---|---|---|---|
拿掉 TRANSITIONS.get(status)?.get(event) 的 ?. |
活 | 活 | 活 | 等價突變,要扣掉 |
TypeError 的訊息字串換成空字串(5 個) |
活 | 活 | 活 | 規格沒規定訊息內容 |
this.name = 'IllegalTransitionError' 換成空字串 |
殺 | 活 | 活 | 清單 A9 寫了要驗 e.name |
IllegalTransitionError 的訊息換成空字串 |
殺 | 一輪活 | 殺 | B 第 1 輪沒驗訊息內容 |
typeof now !== 'string' 整條檢查拿掉 |
殺 | 兩輪活 | 殺 | 行為漏洞 |
第一列是等價突變。 拿掉 ?. 之後程式行為真的沒變 —— status 在前一行已經驗過白名單,get(status) 不可能是 undefined。這種突變殺不掉是正常的,把它算進失敗率,會讓這個指標開始說謊,然後你就會開始不信任它。跟 Day 13 講誤報要當 bug 修是同一件事:一個會冤枉你的檢查,終究會被忽略。扣掉它之後,A 是 50 / 55 = 90.9% —— 這是我人工扣除後的數字,Stryker 自己報的是 89.3%。
第二列不是等價突變 —— 訊息換成空字串,行為確實變了 —— 是規格沒要求的行為。 規格只說「丟 TypeError」,沒說訊息寫什麼。三組都沒驗訊息,這是對的 —— 硬要驗,就是把實作的措辭釘成規格。
第五列才是真的漏。 把這一行拿掉:
if (typeof now !== 'string') throw new TypeError(`now 必須是 ISO-8601 字串:${now}`)
B 有兩輪的測試照樣全綠。因為它們測了 null、空字串、亂寫的字串 —— 這些下一行 Date.parse 會回 NaN,照樣丟錯。只有一種輸入會穿過去:Date 物件。Date.parse(new Date(...)) 會先把 Date 物件轉成字串,再解析那個字串,得到一個正常的數字,函式就默默接受了。
清單 B6 把 Date 物件列成一個案例,所以 A 三輪都抓得到。這件事我不是今天才學到的 —— 8 月那份考卷,沒殺掉的兩個突變,當時打過的每個模型都一樣:
M1 一級邊界 <= 改成 < charge(100) 少算 三個都沒抓到
M2 二級邊界 <= 改成 < charge(500) 算錯級 三個都沒抓到
規格白紙黑字寫著 1–100、101–500、501 以上,每個模型都測了一般值,沒有一個去碰 100 和 500 本身。從那之後我的清單都把臨界點一個一個列出來。臨界點是出題的人的責任,不能指望它自己想到。
同一個模型、同一段程式碼,兩種交代法的差別:
e.name」。這不是地端比雲端強,是多的那一分,清單補得回來 —— 跟 27B 那一分是同一件事。所以回到開頭那個等式。這篇量的是「穩不穩」,不是「省多少」—— 寫清單、跑突變、修返工的時間我沒有逐項計時,所以不能說它一定比較便宜。能說的是:寫清單花的力氣也算在「驗證與返工的成本」裡,它換來的是交回來少讀、少修,突變分數也可預期。要外包的不是「寫測試」這件事,是「把寫好的清單翻成測試碼」這件事。 前者要判斷,後者只要耐力。
A 第 2 輪跑完,我的彙整腳本印出這一行:
Tests 13 passed (13)
全綠。我差點就記成「零返工」。重跑一次、看完整輸出才發現上面還有一行:
FAIL tests/local/due-at.test.js
ReferenceError: afterEach is not defined
due-at 那個檔案整個沒有載入,13 個 passed 全部來自另一個檔案。我的腳本只抓了「Tests」那一行 —— 我替地端設了裁判,卻沒替自己的裁判設裁判。
判定要看 exit code 和「跑了幾個檔案」,不能只看 passed 那個數字。這條本來就寫在我的外包筆記裡(Xcode 的 Executed 0 tests + TEST SUCCEEDED),換一個工具就又踩了一次。
外包這件事最容易被誤解的地方,是把它當成「省錢」。
它其實是分類:把工作按「有沒有裁判」分開。有裁判的,才有資格談誰來做、做多便宜 —— 誰做還是會影響返工多少;沒裁判的,再便宜都不能交出去。而裁判本身也要分層:
| 層 | 裁判 | 它證明什麼 |
|---|---|---|
| 測試跑得過 | Vitest | 測試能跑 |
| 測試殺得掉突變 | Stryker | 測試真的在測東西 |
| 突變分數可信 | 等價突變扣掉、臨界點寫進清單 | 裁判不把等價突變算成漏測,也抓得到清單列出的臨界點 |
少了第二層,你只知道測試跑得動,不知道它有沒有用 —— 而一個沒有判別力的測試,比沒有測試更糟,因為它給你綠燈。
而同一個地端模型,給它一句話,它交回來的測試要修、分數在飄、有一輪停不下來;給它一份寫死預期值的清單,三輪結果一樣。差別不在模型,在清單。
這一篇留下的心法:
先問誰當裁判,再問誰來做:「跑得過」只證明能跑,測試的裁判是突變檢查 —— 而等價突變要扣掉,裁判才不會冤枉人。能外包的不是「寫測試」,是「把清單翻成測試碼」;預期值和臨界點寫死在清單裡,殺掉率才可預期;臨界點是出題的人的責任。
明天:換一個有歷史的專案 —— 一份當年手工做掉的需求,讓 OpenSpec 重拆一次,看它逼我回答哪些我當年沒回答的問題。
src/domain/book-state.js、due-at.js,起點 ee08920):github.com/n913239/library-loan-lab
8b03d53):github.com/n913239/library-loan-lab/tree/main/devlog/raw/exp-day19-local-mutation
qwen3.6-35b-a3b-mlx、Pi 0.84.2(pil 無 bash、--only "tests/local/**")、Vitest 3.2.7、StrykerJS 10.0.0,2026-09-26;兩種交代法各 3 輪lms(lms load -c 與 lms ps):lmstudio.ai/docs/cli