[{"data":1,"prerenderedAt":1402},["ShallowReactive",2],{"blog-preview":3},[4,538,1137],{"id":5,"title":6,"body":7,"date":526,"description":527,"extension":528,"meta":529,"navigation":530,"path":531,"seo":532,"stem":533,"tags":534,"__hash__":537},"blog\u002Fblog\u002Fstt-postedit-leverage.md","我測了兩天，證明「換更好的引擎」降了一個錯誤",{"type":8,"value":9,"toc":512},"minimark",[10,18,21,24,27,30,34,37,40,43,46,90,93,95,99,102,105,108,111,114,117,120,122,126,129,132,135,138,145,147,151,154,157,160,163,166,169,171,175,178,181,252,255,258,261,264,267,270,275,277,281,284,287,290,335,338,341,344,347,350,411,414,417,420,422,425,428,431,437,440,442,446,449,452,455,458,464,467,470,472,475,478,481,484,487,490,492,495,498,501],[11,12,13],"p",{},[14,15],"img",{"alt":16,"src":17},"一個人專心轉動一個巨大的旋鈕，旁邊有個不起眼的小開關才是真正接上電路的那個","\u002Fblog\u002Fimages\u002Fstt-postedit-leverage-hero.png",[11,19,20],{},"準備錄實驗素材那天，我打開對話視窗跟他說：等一下每段話講兩次，第一次按鍵盤右下角那個麥克風。",[11,22,23],{},"他回我：iPhone 有那個東西？",[11,25,26],{},"那一刻我的實驗死了。我設計了三組對照，其中一組叫「現況組」——而現況組從來不存在。我查了程式碼，確認過那個功能該怎麼運作；我沒查的是，這個人根本沒在用它。",[28,29],"hr",{},[31,32,33],"h2",{"id":33},"我原本要測什麼",[11,35,36],{},"我在幫自己的知識庫系統做語音輸入。走路的時候按住講一段話，鬆手，它應該變成當天的紀錄。",[11,38,39],{},"聽不聽得懂從來不是重點。真正卡住的是專有名詞——人名、專案代號、自己發明的縮寫——這些字在通用語音模型的訓練資料裡幾乎不存在，它只會挑一個發音接近的常見詞給你。我紀錄裡有個團隊代號，十次有九次被聽成一個完全無關的詞，每次都要人工回去改。",[11,41,42],{},"摩擦沒有被消除，只是被搬到後面去了。",[11,44,45],{},"所以我打算換一顆更好的引擎。手上有一台自架的推論機器，跑著比手機內建大得多的模型，端點打得通。剩下的就是證明它比較好。",[47,48,49,62],"table",{},[50,51,52],"thead",{},[53,54,55,59],"tr",{},[56,57,58],"th",{},"組",[56,60,61],{},"內容",[63,64,65,74,82],"tbody",{},[53,66,67,71],{},[68,69,70],"td",{},"A",[68,72,73],{},"手機內建聽寫（現況）",[53,75,76,79],{},[68,77,78],{},"B",[68,80,81],{},"自架引擎的原始輸出",[53,83,84,87],{},[68,85,86],{},"C",[68,88,89],{},"B 再加一層「帶知識庫上下文的語意後修」",[11,91,92],{},"有基準、有單一變數推進、有最終方案。這個設計是錯的，而且錯得很不明顯——我要繞很久才會發現。",[28,94],{},[31,96,98],{"id":97},"第一次翻車我查了程式碼沒查人","第一次翻車：我查了程式碼，沒查人",[11,100,101],{},"上面那個對話發生在正式開錄前十分鐘。",[11,103,104],{},"我對 A 組的認知是「使用者現在就是這樣輸入的」。這個認知從哪來？我 grep 了 bot 的原始碼，確認它怎麼處理進來的訊息——這一步做對了。",[11,106,107],{},"我沒做的是問一句「你平常是怎麼傳的」。",[11,109,110],{},"他從來沒用過鍵盤聽寫，一直是長壓輸入欄那個麥克風傳語音訊息。而 bot 的 handler 只認文字、照片、文件三種——語音訊息進來，沒有任何一段程式碼接得住。它們被靜默丟棄，而且不回任何訊息。",[11,112,113],{},"所以他過去每一次「用講的記一下」，都以為記進去了，實際上一句話都沒留下。這個洞比實驗本身嚴重，我當天先把它補起來：不轉錄，先存檔，至少不再遺失。",[11,115,116],{},"至於實驗，A 組不再是「現況」了。它變成一個從沒被試過的候選方案：如果手機內建聽寫夠準，最省事的解法是使用者改變操作習慣，一行程式碼都不用寫。",[11,118,119],{},"這個角色轉換，後來變成整件事的關鍵。",[28,121],{},[31,123,125],{"id":124},"第二次翻車我的系統污染了我自己的對照組","第二次翻車：我的系統污染了我自己的對照組",[11,127,128],{},"錄第一段的時候。",[11,130,131],{},"A 組的文字要進到知識庫裡才好比對，所以我讓他直接傳進去。傳進去之後我打開檔案一看——專有名詞已經被自動改對了，句子還被重新整理成條列。",[11,133,134],{},"原因很蠢，但值得記：我的知識庫本身就掛著一層路由，任何進來的文字都會被 LLM 順過一次，順便修正專有名詞、整理成結構。這是我自己設計的，每天都在用，好用到我完全忘了它存在。",[11,136,137],{},"等於 A 組在進場之前，就先被 C 組處理過一次了。原文不可復原，那一段只能重念。我把 A 組改走一條專用旁路——訊息開頭帶特定前綴就原文逐字落檔，禁止校正、禁止結構化。",[11,139,140,144],{},[141,142,143],"strong",{},"基礎設施會自己動手，它從來不是一根安靜的管子。"," 還有一層更麻煩的含意：這條路由平常就在自動校正我所有的紀錄，意味著知識庫裡既有的文字都不是原始輸入。以後拿它們當語料的分析，都得先記得這件事。",[28,146],{},[31,148,150],{"id":149},"第三次翻車差點把-kill-線設計掉","第三次翻車：差點把 kill 線設計掉",[11,152,153],{},"C 組的後修要由誰來做？最順手的當然是我自己——直到我想起朗讀腳本是我寫的，每一段的標準答案我都知道。我來做後修，那叫開卷考。",[11,155,156],{},"分數虛高還算小事。真正致命的是誤修數會恆為 0。",[11,158,159],{},"先解釋一下誤修。後修層是一個 LLM，它讀轉錄出來的文字，用知識庫的上下文判斷哪些詞被聽錯了。它會修對，也會把本來對的改成錯的——冷門正確詞被改成常見詞，這是這類方案最真實的風險。而且誤修比漏修危險：漏修的錯誤讀起來很怪，一眼看得出來；誤修的錯誤讀起來很通順，只是它是假的。",[11,161,162],{},"我的 kill 線有兩條，第二條就是「誤修數超過修對數的三分之一就收掉」。",[11,164,165],{},"如果由知道答案的人來做後修，這條線永遠不會被觸發。我等於在實驗開始前，先把自己最想量的那個風險刪掉了。",[11,167,168],{},"改由一個沒看過腳本的 agent 執行，只給它知識庫的專有名詞表——上線後它拿得到的東西——和轉錄的原始輸出。",[28,170],{},[31,172,174],{"id":173},"分數出來了然後兩條線同時成立","分數出來了，然後兩條線同時成立",[11,176,177],{},"15 段素材，每段 15 到 40 秒，在走路的時候錄的。刻意不在安靜房間念稿，那會同時高估所有組別，測不出差距。",[11,179,180],{},"評分不用 WER。中文的 WER 對這個問題沒有鑑別度——斷詞、同音字、贅字全部會污染分數。我改成數三種錯誤：專有名詞錯幾個、數字錯幾個、語意被破壞幾處。",[47,182,183,201],{},[50,184,185],{},[53,186,187,189,192,195,198],{},[56,188,58],{},[56,190,191],{},"專名",[56,193,194],{},"數字",[56,196,197],{},"語意",[56,199,200],{},"合計",[63,202,203,220,237],{},[53,204,205,208,211,214,217],{},[68,206,207],{},"A（手機內建）",[68,209,210],{},"31",[68,212,213],{},"3",[68,215,216],{},"7",[68,218,219],{},"41",[53,221,222,225,228,231,234],{},[68,223,224],{},"B（自架引擎）",[68,226,227],{},"24",[68,229,230],{},"1",[68,232,233],{},"4",[68,235,236],{},"29",[53,238,239,242,245,247,249],{},[68,240,241],{},"C（B + 後修）",[68,243,244],{},"0",[68,246,230],{},[68,248,230],{},[68,250,251],{},"2",[11,253,254],{},"C 組修對 27 處，誤修 0 處。",[11,256,257],{},"然後我盯著這張表看了很久，因為它同時觸發了兩件相反的事。",[11,259,260],{},"成功指標達成：我事前寫的成功線是「C 的專名錯誤 ≤ A 的一半，且誤修 = 0」。0 ≤ 15.5，誤修 0，過了。",[11,262,263],{},"kill 線也觸發了：另一條 kill 線是「B 的專名錯誤 ≥ A 的 70%」。A 是 31，70% 是 21.7，B 是 24。觸發。",[11,265,266],{},"兩條線在問不同的問題，所以它們可以同時成立。",[11,268,269],{},"kill 線問的是「換引擎有沒有用」——幾乎沒有。B 比 A 只降 23%，落在我開工前就寫死的噪音區間裡：兩組音檔不同源，只有兩倍以上的量級差距才算有效信號。成功線問的是「加後修有沒有用」——41 降到 2。",[11,271,272],{},[141,273,274],{},"價值來源是後修層，不是引擎。",[28,276],{},[31,278,280],{"id":279},"但我還是不能歸因因為第四格從來沒被測過","但我還是不能歸因，因為第四格從來沒被測過",[11,282,283],{},"上面那個結論其實還站不太住。",[11,285,286],{},"因為 C 相對 A 同時改了兩件事：換了引擎，又加了後修。C 大勝，我沒辦法把功勞分開。B 比 A 只好一點點是強烈的暗示——但只是暗示。",[11,288,289],{},"畫成 2×2 就很清楚了：",[47,291,292,304],{},[50,293,294],{},[53,295,296,298,301],{},[56,297],{},[56,299,300],{},"不加後修",[56,302,303],{},"加後修",[63,305,306,322],{},[53,307,308,313,316],{},[68,309,310],{},[141,311,312],{},"不換引擎",[68,314,315],{},"A ✅",[68,317,318,319],{},"❌ ",[141,320,321],{},"從沒測過",[53,323,324,329,332],{},[68,325,326],{},[141,327,328],{},"換引擎",[68,330,331],{},"B ✅",[68,333,334],{},"C ✅",[11,336,337],{},"我測了三格。缺的那格是「不換引擎、只加後修」——也就是最便宜的那一格。",[11,339,340],{},"遞增疊加的設計為什麼會騙人？因為相鄰兩臂之間確實只差一個變數：A 到 B 只換引擎，B 到 C 只加後修。它感覺起來像對照實驗。但整體看，它只是三個點在測一條線，永遠算不出「不換引擎的條件下，後修自己值多少」。",[11,342,343],{},"而如果後修自己就夠了，換引擎那一整條的工程成本全是浪費——外部服務依賴、音檔上傳管線，還有簡繁轉換（那顆引擎會偶發吐簡體字，15 段裡有兩段）。",[11,345,346],{},"補這一格幾乎免費：A 組的輸出還在，後修流程做 C 組時已經建好，套上去跑一次就好。",[11,348,349],{},"我跑了。",[47,351,352,365],{},[50,353,354],{},[53,355,356,358,360,362],{},[56,357],{},[56,359,300],{},[56,361,303],{},[56,363,364],{},"換引擎效果",[63,366,367,381,395],{},[53,368,369,374,376,378],{},[68,370,371],{},[141,372,373],{},"手機內建",[68,375,219],{},[68,377,213],{},[68,379,380],{},"−38",[53,382,383,388,390,392],{},[68,384,385],{},[141,386,387],{},"自架引擎",[68,389,236],{},[68,391,251],{},[68,393,394],{},"−27",[53,396,397,401,404,409],{},[68,398,399],{},[141,400,364],{},[68,402,403],{},"−12",[68,405,406],{},[141,407,408],{},"−1",[68,410],{},[11,412,413],{},"後修層降 27 到 38 個錯誤。換引擎在沒有後修時降 12 個，在有後修的條件下降 1 個。",[11,415,416],{},"一個。",[11,418,419],{},"我為了那個「1」，原本準備蓋一整條管線。",[28,421],{},[31,423,424],{"id":424},"為什麼後修層贏這麼多",[11,426,427],{},"因為它做的是純語音模型結構上做不到的事。",[11,429,430],{},"那個 agent 修對的四處裡，有這些：一個不存在的英文詞被還原成某專案的代號、一個發音相近的詞被改成「優化」、一串被聽成「R P 一八」的東西被還原成健身術語的「RPE 8」。",[11,432,433,434],{},"這些靠的全是語意推斷。它讀知識庫裡的上下文，判斷「在這個人的紀錄裡，這個位置該出現的是什麼」。",[141,435,436],{},"任何純語音引擎都做不到，因為它們沒有你的知識庫。",[11,438,439],{},"還有一個更窄的判準：如果你要換的那顆引擎沒有提供餵入領域詞彙的介面，那換誰都差不多。我測的那顆就是這樣，端點只吃音檔和語言參數，沒地方塞人名。",[28,441],{},[31,443,445],{"id":444},"最後一次翻車四格量的都是同一個指標","最後一次翻車：四格量的都是同一個指標",[11,447,448],{},"我根據 2×2 做了裁決：走最便宜的那格，手機內建聽寫加後修層。外部服務不用碰了，音檔管線省下來，連簡繁轉換這個坑都直接不存在。",[11,450,451],{},"然後拿去給使用者複核，被推翻了。",[11,453,454],{},"理由一句話就說完：手機內建聽寫這條路，前提是他要改變操作習慣。而整場實驗從頭到尾，A 組之所以存在，就是因為他從來沒用過那個功能。",[11,456,457],{},"語音訊息是按住、講、放開。鍵盤聽寫要開輸入框、點麥克風、盯著文字跑完、按送出——走路的時候還得看螢幕。我算的是一次性的工程成本，沒算每天都要付的摩擦成本。",[11,459,460,463],{},[141,461,462],{},"2×2 拆對了變數，但四格量的都是同一個指標：錯誤數。"," 當候選方案真正的差異在別的維度上，只比主指標就會選錯。",[11,465,466],{},"實驗的結論本身沒變——後修是槓桿，引擎不影響準度。改的是推論：既然引擎不影響準度，這一維就該由摩擦決定，而摩擦這維是自架引擎贏。零行為改變，音檔本來就在存，邊際成本只剩一個 HTTP call。",[11,468,469],{},"最後上線的是「語音訊息 → 自架引擎 → 後修層」。隔天用同樣那 15 段素材對生產環境做了一次盲評：誤修 0，三類錯誤合計 3。實驗量到的東西在生產上重現了。",[28,471],{},[31,473,474],{"id":474},"如果你也在調同一顆旋鈕",[11,476,477],{},"我從這兩天帶走三條，都是給下一次的我。",[11,479,480],{},"第一條寫成規則：任何「換掉 X，而且加上 Y」的假設，開工前先畫 2×2，確認四格都會被測到。真有一格不打算測，那就在筆記上明寫「不測哪格、因此哪個主效應無法歸因」——讓它是個判斷，不是疏忽。",[11,482,483],{},"四格全量同一個指標，等於沒量到候選方案真正的差異。這是我這次栽得最重的地方——沒被量到的那一維，通常就是最後推翻你的那一維。",[11,485,486],{},"還有一條沒那麼漂亮。查程式碼只告訴我系統能做什麼，沒告訴我人實際上在做什麼，我把前者做對了，後者整個漏掉。",[11,488,489],{},"有件事值得帶出這個故事之外：大部分「模型不夠聰明」的狀況，其實是模型不知道你的業務在講什麼。它不知道你的產品叫什麼、你的客戶怎麼稱呼那個流程、內部那個三個字的縮寫是什麼意思。換一顆更大的模型救不了這件事。你得把自己的上下文餵給它。",[28,491],{},[31,493,494],{"id":494},"如果你正在評估這件事",[11,496,497],{},"如果你手上有一個 AI 功能準確率卡住，而現在的討論是「要不要換更貴的模型」，那大概值得先停一下。是它聽不清楚，還是它不知道你在講什麼？這兩件事的解法完全不一樣，成本差一個量級。",[11,499,500],{},"我做技術諮詢，這類「先確認問題在哪」的對話通常十分鐘就有結論。沒有簡報，不賣套裝方案，如果你的狀況根本不用動 AI 我也會直說。",[11,502,503,504,511],{},"到 ",[505,506,510],"a",{"href":507,"rel":508},"https:\u002F\u002Fmattchang.dev",[509],"nofollow","mattchang.dev"," 加我的 LINE，直接描述你的狀況就好。那個帳號本身也是我自己蓋的——你跟它對話的體驗，就是你正在評估的那個東西的 live demo。",{"title":513,"searchDepth":514,"depth":514,"links":515},"",2,[516,517,518,519,520,521,522,523,524,525],{"id":33,"depth":514,"text":33},{"id":97,"depth":514,"text":98},{"id":124,"depth":514,"text":125},{"id":149,"depth":514,"text":150},{"id":173,"depth":514,"text":174},{"id":279,"depth":514,"text":280},{"id":424,"depth":514,"text":424},{"id":444,"depth":514,"text":445},{"id":474,"depth":514,"text":474},{"id":494,"depth":514,"text":494},"2026-08-05","AI 功能不夠準，第一個念頭通常是換模型。我為此設計了一場對照實驗，結果換引擎只降 1 個錯誤，真正把 41 降到 2 的是我原本當配角的那一層。這是實驗設計怎麼騙我、以及我怎麼發現的完整過程。","md",{},true,"\u002Fblog\u002Fstt-postedit-leverage",{"title":6,"description":527},"blog\u002Fstt-postedit-leverage",[535,536],"claude-code","experiment-design","zIpkLaGim0pcHQJvstOwSdcsdWMODNqfAUQnL3wBDtY",{"id":539,"title":540,"body":541,"date":1128,"description":1129,"extension":528,"meta":1130,"navigation":530,"path":1131,"seo":1132,"stem":1133,"tags":1134,"__hash__":1136},"blog\u002Fblog\u002Fgreen-light-is-not-verification.md","綠燈不等於驗證：同一個專案裡，同一種失敗形狀重複了四次",{"type":8,"value":542,"toc":1113},[543,549,557,559,563,566,572,574,578,587,606,612,615,618,623,625,629,632,642,657,667,682,685,688,695,713,716,718,722,729,740,743,750,771,784,787,790,800,804,807,820,830,837,847,854,861,863,867,881,884,903,910,916,919,926,940,944,955,965,968,970,973,976,1033,1039,1042,1048,1054,1060,1067,1069,1073,1076,1079,1086,1094,1096,1100,1103,1106],[11,544,545],{},[14,546],{"alt":547,"src":548},"四個打勾的 checkbox，後面的路徑都斷在半空中","\u002Fblog\u002Fimages\u002Fgreen-light-is-not-verification-hero.png",[11,550,551,552,556],{},"172 個測試全綠。",[553,554,555],"code",{},"send_message"," 的 checkbox 打了勾。專案筆記上寫著「DC-4 done」。實際上這個功能從來沒有真的送出過一則訊息——三個 bug 疊在一起，全被 mock adapter 遮住。而這只是同一個專案裡的第一次。接下來兩天內，同一種失敗形狀又出現了三次，每次穿不同的偽裝。",[28,558],{},[31,560,562],{"id":561},"專案背景一句話","專案背景：一句話",[11,564,565],{},"我把自己在用的 LINE TUI 工具泛化成一套聊天基礎建設，叫 chatmux——一個 daemon 管多個平台 adapter，資料落地成 SQLite + JSONL，對外用 MCP 暴露給 Claude Code 和 Neovim。現在是三個 public repo，1637 則訊息、143 個對話在裡面跑著。",[11,567,568,569],{},"寫程式的部分不是這篇的重點。",[141,570,571],{},"這篇是關於我四次以為自己驗證完了。",[28,573],{},[31,575,577],{"id":576},"第一次mock-綠燈","第一次：mock 綠燈",[11,579,580,582,583,586],{},[553,581,555],{}," 這條路徑跨三層：MCP tool 層組 request、core 轉發給 adapter、adapter 呼叫 linejs 的 ",[553,584,585],{},"sendCompactMessage","。",[11,588,589,590,593,594,597,598,601,602,605],{},"三個 bug 剛好一層一個。tool 層送出的 payload 少了 ",[553,591,592],{},"content"," wrapper，直接丟 ",[553,595,596],{},"{ chat_id, text }","；adapter 把 ",[553,599,600],{},"sendCompactMessage(to, text)"," 當兩個參數傳，但 linejs 要的是一個物件；chat_id 的 ",[553,603,604],{},"line:"," prefix 沒被剝掉。",[11,607,608,609,611],{},"三個都不會被測到。因為每一層的測試對面都是 mock：mock adapter 不檢查 payload 有沒有 wrapper，mock 的 ",[553,610,585],{}," 不在意你傳幾個參數，測試裡的 chat_id 本來就沒帶 prefix。",[11,613,614],{},"三層都綠。整條鏈是斷的。",[11,616,617],{},"後來是為了另一件事跑端對端 spike，才發現這個功能從沒送出過任何東西。補的方式是一個 env-gated 的 live integration test，走真實 linejs、真的發一則訊息給自己。然後做 mutation check：把三個 bug 其中一個還原回去，確認測試會紅，再還原回來確認回綠。",[11,619,620],{},[141,621,622],{},"測試要證明自己有牙，不能只證明自己會綠。",[28,624],{},[31,626,628],{"id":627},"第二次文件寫得很合理","第二次：文件寫得很合理",[11,630,631],{},"chatmux 的招牌是「MCP-native」，旗艦 demo 是「Claude Code 直接當 client 讀你的聊天記錄」。v0.1 的驗收清單上，這條打了勾。",[11,633,634,635,638,639],{},"問題是 MCP server 只 ",[553,636,637],{},"listen(socketPath)","——綁死在 unix socket 上。而 MCP spec 只定義兩種標準 transport：stdio 和 streamable HTTP。",[141,640,641],{},"Claude Code 根本沒有一個欄位可以填 unix socket。",[11,643,644,645,648,649,652,653,656],{},"那 README 和 ",[553,646,647],{},"docs\u002Fmcp-interface.md"," 裡教人填的那段 ",[553,650,651],{},"\"socketPath\": \"...\""," config 是什麼？是一段讀起來完全合理、但從來沒有任何人跑過的設定。當初驗收是用 Bun 的 ",[553,654,655],{},"fetch({ unix })"," 自己打 unix socket 打通的——那是 Bun 專有能力，Claude Code 沒有。",[11,658,659,660,663,664,586],{},"補的方式是加一個 TCP listener（只綁 ",[553,661,662],{},"127.0.0.1","），unix socket 保留給 Neovim 側的 sidecar。這次先寫失敗測試，而",[141,665,666],{},"紅燈的形狀本身給了我一個沒預期到的資訊",[11,668,669,670,673,674,677,678,681],{},"舊的 ",[553,671,672],{},"startMcpServer"," signature 收一個 string，我把新的 options 物件直接丟進 ",[553,675,676],{},"httpServer.listen()","，Node 竟然接受了——結果綁在 ",[553,679,680],{},"*:47717","，所有網路介面。七個測試裡四個「意外通過」，三個紅。",[11,683,684],{},"想清楚這意味著什麼：如果我的測試只斷言「TCP 連得上」，這個把全部聊天記錄暴露到區網的 bind 會全綠通過。",[11,686,687],{},"所以那次之後我寫斷言會多列一條：這個東西不該綁到哪裡去。",[11,689,690,691,694],{},"完成判定我這次沒有用測試綠燈，而是真的跑 ",[553,692,693],{},"claude mcp add --transport http","，開一個 headless Claude Code session、刻意只給 read-only tool，讓它對真實資料查詢。",[11,696,697,698,701,702,705,706,705,709,712],{},"差點又栽在這裡。第一輪 ",[553,699,700],{},"search_messages"," 查「會議」回 0 hits。0 hits 可能是真陰性——語料裡就是沒這個詞；也可能是 CJK 全文搜尋壞掉，而那個形狀跟前面兩次一模一樣。我用語料中已知存在的詞複驗：",[553,703,704],{},"stale_ratio"," 93 hits、",[553,707,708],{},"過高",[553,710,711],{},"報價"," 139 hits。CJK 正常，「會議」是真陰性。",[11,714,715],{},"CJK 搜尋是好的，「會議」就是真的沒出現過——但我沒查第二次，就不會知道是哪一個。",[28,717],{},[31,719,721],{"id":720},"第三次宣告-optional","第三次：宣告 optional",[11,723,724,725,728],{},"寫第二個 adapter 的時候，我刻意不看第一個 adapter 的原始碼，只照 ",[553,726,727],{},"adapter-protocol.md"," 蓋。換語言、換 repo、換平台語義。",[11,730,731,732,735,736,739],{},"這招很有效——逼出 21 條 LINE 特有假設，其中 5 條是「文件沒寫但行為存在」的隱式假設。協定升到 v0.2，最招牌的一條裁決是把 ",[553,733,734],{},"get_message_boxes","（一個 LINE 專有 API）改成 optional：adapter 不支援就回 ",[553,737,738],{},"-32601","，core 跳過。",[11,741,742],{},"看起來很泛化。三個問題疊在一起。",[11,744,745,746,749],{},"第一個是小的：偵測用字串比對錯誤訊息，沒有讀 JSON-RPC 的 ",[553,747,748],{},"error.code","。第三方 adapter 換個措辭就會被當成硬失敗。",[11,751,752,753,756,757,760,761,763,764,767,768,770],{},"更麻煩的是，",[141,754,755],{},"文件承諾的 fallback 寫不通","。文件說 core 會「直接用 ",[553,758,759],{},"get_chats"," 結果做 backfill 排序」，但 ",[553,762,759],{}," 的 response schema 裡沒有任何時間欄位，而 backfill 就是 ",[553,765,766],{},"ORDER BY last_message_at DESC","。誠實回 ",[553,769,738],{}," 的 adapter 會拿到全部 chat 的排序欄位為 NULL，冷啟動的 500 則預算花在隨機對話上。",[11,772,773,774,776,777,780,781,783],{},"最意外的一條：Telegram adapter 也實作了 ",[553,775,734],{},"。內容是 ",[553,778,779],{},"get_dialogs()"," 的第二份包裝，跟它自己的 ",[553,782,759],{}," 同源。所以那條 fallback 路徑一次都沒被執行過，零測試守衛。",[11,785,786],{},"而表面證據看起來像泛化成功——兩個平台的 adapter 都跑得起來。",[11,788,789],{},"判準其實很簡單：要問的是「沒支援的那一邊會怎樣」，不是「有沒有人支援」。第二個實作如果是第一個的複製，泛化程度是零。",[11,791,792,793,795,796,799],{},"閉環的做法是把 Telegram 那份刪掉，讓 fallback 第一次真的被走過，同時給 ",[553,794,759],{}," 加 optional 的 ",[553,797,798],{},"last_message_at","，協定升 v0.3。",[801,802,803],"h3",{"id":803},"舊資料會替你演出已被刪除的元件",[11,805,806],{},"這條線最險的一步在驗證方法，不在程式碼。",[11,808,809,810,812,813,816,817,819],{},"我的 DB 裡 Telegram 那些 chat 的 ",[553,811,798],{}," ",[141,814,815],{},"已經有舊值","——過去 ",[553,818,734],{}," 寫進去的。刪掉 handler 之後，在現有 data dir 上跑，一切正常。排序完全看不出退化。",[11,821,822,823,826,827,829],{},"要換一個乾淨的 ",[553,824,825],{},"CHATMUX_DATA_DIR"," 冷啟動，才看得到真相。結果是 30 個 chat、30 筆 non-null、0 筆假值，回填順序與 ",[553,828,766],{}," 的前五名逐位相同。",[11,831,832,833,836],{},"推廣出去：",[141,834,835],{},"任何「移除資料來源」的改動，都必須在沒有該來源歷史殘留的環境驗證。"," 刪 cache 層、換 migration、移除 denormalized 欄位——舊資料都會扮演那個已經被你刪掉的元件。",[11,838,839,840,843,844,846],{},"而同一條線上，我還差點自己製造一個一樣的掩護。為了保護既有值不被 null 覆寫，我寫了 ",[553,841,842],{},"MAX(COALESCE(a,0), COALESCE(b,0))","。意圖是對的。副作用是：兩者皆 null 時它會寫入 epoch 0，而我的驗證判準正是「",[553,845,798],{}," 有值」。",[11,848,849,850,853],{},"也就是說，防呆機制差點讓驗證判準自己失效。外層包一個 ",[553,851,852],{},"NULLIF(..., 0)","，讓「沒有訊號」保持 NULL、讓退化看得見。",[11,855,856,857,860],{},"抓到這個不是因為更小心。是因為問了一句：",[141,858,859],{},"這個判準在功能完全壞掉的時候，會不會仍然通過？"," 會通過的判準不是判準。",[28,862],{},[31,864,866],{"id":865},"第四次掃描零命中","第四次：掃描零命中",[11,868,869,870,873,874,873,877,880],{},"三個 repo 要轉 public。開工前的 pre-flight 掃描報告寫得很乾淨：憑證檔案（",[553,871,872],{},".env","、",[553,875,876],{},"*.session",[553,878,879],{},"data\u002F","）全歷史從未被 commit、無真實個資、不需要重寫歷史。",[11,882,883],{},"實際上有三處洩漏。",[11,885,886,887,890,891,894,895,898,899,902],{},"第一處是真實的 Telegram ",[553,888,889],{},"API_ID"," \u002F ",[553,892,893],{},"API_HASH","，硬寫成常數放在一個 integration 測試檔裡。不是 placeholder，是真值。",[553,896,897],{},"git log -S"," 查出來是某個 commit 引入的，",[141,900,901],{},"至今仍在 HEAD","——十三個 commit 裡最後七個都含它。改 HEAD 不夠，public 之後任何人都能從 history 撈出來。",[11,904,905,906,909],{},"第二處是真實的 Telegram user ID，寫在 ",[553,907,908],{},"docs\u002Ftesting.md"," 的範例裡。第三處是真實的 LINE user ID，寫在另一個 repo 的 spike 註解裡。",[11,911,912,913],{},"為什麼掃描報「乾淨」？",[141,914,915],{},"因為它掃的是憑證「檔案」和「姓名／電話／email」，inline 憑證常數和平台帳號 ID 不在字典裡。",[11,917,918],{},"零命中只證明你搜的那些東西不在。",[11,920,921,922,925],{},"這次的運氣是時間差。憑證是在 push 之前十分鐘發現的。Telegram 的 ",[553,923,924],{},"api_hash"," 沒有 rotate 機制，推出去就是永久的；而當時 repo 還沒有 remote、沒有任何下游 clone，所以重寫歷史的成本是零。晚十分鐘發現，就會變成「已公開的憑證 + 無法撤銷的金鑰」。",[11,927,928,929,932,933],{},"順手記一條在 repo 文件裡的告誡：",[553,930,931],{},"describe.skipIf"," 不保護任何東西。",[141,934,935,936,939],{},"被 skip 的測試一樣會被 commit、push，一樣會被 ",[553,937,938],{},"git log -p"," 讀到。",[801,941,943],{"id":942},"驗證乾淨的方法有兩層而且都不看本機","驗證乾淨的方法有兩層，而且都不看本機",[11,945,946,947,950,951,954],{},"第一層：不看本機工作區，從 GitHub ",[141,948,949],{},"重新 clone","，再對四個敏感字串跑 ",[553,952,953],{},"git log --all -S","。三個 repo 全部 0 命中。",[11,956,957,958,961,962,586],{},"但這只證明「現在可達的歷史是乾淨的」。GitHub 會保留曾經 push 過的 unreachable object——舊 SHA 的 URL 照樣打得開。所以第二層：用 ",[553,959,960],{},"gh api repos\u002F\u003Cowner>\u002F\u003Crepo>\u002Fcommits\u002F\u003Csha>"," 去打那四個重寫前的 SHA，全部回 ",[141,963,964],{},"422 No commit found",[11,966,967],{},"這才證明含憑證的舊 commit 從來沒抵達過 GitHub。",[28,969],{},[31,971,972],{"id":972},"四次的共同結構",[11,974,975],{},"機械檢查通過（測試綠／文件合理／掃描零命中）→ 宣稱完成（checkbox 打勾）→ 真實路徑從沒被執行過 → 事後覆核才發現。",[47,977,978,991],{},[50,979,980],{},[53,981,982,985,988],{},[56,983,984],{},"次",[56,986,987],{},"偽裝",[56,989,990],{},"實際上沒被驗證的是",[63,992,993,1003,1013,1023],{},[53,994,995,997,1000],{},[68,996,230],{},[68,998,999],{},"172 test 全綠",[68,1001,1002],{},"三層都對著 mock，整條鏈沒跑過",[53,1004,1005,1007,1010],{},[68,1006,251],{},[68,1008,1009],{},"文件的 config 讀起來合理",[68,1011,1012],{},"沒有任何 client 用那個欄位連過",[53,1014,1015,1017,1020],{},[68,1016,213],{},[68,1018,1019],{},"標了 optional、兩個 adapter 都跑得起來",[68,1021,1022],{},"fallback 路徑一次都沒走過",[53,1024,1025,1027,1030],{},[68,1026,233],{},[68,1028,1029],{},"pre-flight 掃描零命中",[68,1031,1032],{},"掃描字典裡沒有那三種東西",[11,1034,1035,1036],{},"四次的偽裝完全不同。共同結構只有一句：",[141,1037,1038],{},"我驗證的是「我想到的東西」，不是「實際會發生的事」。",[11,1040,1041],{},"三條可以直接拿去用的判準——",[11,1043,1044,1047],{},[141,1045,1046],{},"一、這個判準在功能完全壞掉時，會不會仍然通過？"," 會通過的就不是判準。epoch 0 冒充「有值」，mock 冒充「送出去了」，都是這個問題沒問。",[11,1049,1050,1053],{},[141,1051,1052],{},"二、退路拿不拿得到它需要的東西？"," 標 optional、寫 fallback、留 degraded mode——只寫清楚主路徑不算，要問「不做那件事的人，用什麼替代」。答不出來的 optional 是把責任丟給別人，還讓文件看起來很泛化。",[11,1055,1056,1059],{},[141,1057,1058],{},"三、零命中只證明你搜的東西不在。"," 掃描、grep、空結果，全部適用。要嘛擴充搜尋維度，要嘛用已知存在的東西複驗一次。",[11,1061,1062,1063,1066],{},"順帶一個規模參考：這條線從 172 個測試走到 235 個，",[553,1064,1065],{},"tsc --noEmit"," 的 19 個既有 error 從頭到尾沒有增加，協定從 v0.1 走到 v0.3。四次修正沒有一次是靠「更小心」，全部是靠換一個會露出真相的驗證環境。",[28,1068],{},[31,1070,1072],{"id":1071},"誰抓到的這件事得講清楚","誰抓到的，這件事得講清楚",[11,1074,1075],{},"四次都是在宣稱完成之後才被抓到的，但抓到的人不是同一個。",[11,1077,1078],{},"前三次是我在督導 session 裡覆核時發現的——讀 AI 寫的完成報告，看到「checkbox 全勾、測試全綠」，然後問下一句：這條路徑真的被執行過嗎。第四次不是我，是執行 session 自己在做別的事（把範例配置裡的絕對路徑去個人化）時順手掃到憑證的。",[11,1080,1081,1082,1085],{},"我不想把這寫成「人類監督 AI」的漂亮故事。真實的分工比較尷尬：",[141,1083,1084],{},"AI 很擅長讓機械檢查通過，也很擅長把「檢查通過」寫成「完成」。而我很擅長在它交報告時偷懶不追問。"," 四次裡有三次，是因為我剛好在覆核而不是剛好在趕進度。",[11,1087,1088,1089],{},"專案在這裡，包括那四次的完整施工紀錄都在 repo 的追蹤文件裡：",[505,1090,1093],{"href":1091,"rel":1092},"https:\u002F\u002Fgithub.com\u002Fechoedinvoker\u002Fchatmux",[509],"github.com\u002Fechoedinvoker\u002Fchatmux",[28,1095],{},[31,1097,1099],{"id":1098},"如果你的團隊也在用-ai-寫程式","如果你的團隊也在用 AI 寫程式",[11,1101,1102],{},"那你大概已經遇過這個問題了：AI 交回來的東西測試全綠、報告寫得很漂亮，但你不確定它到底有沒有真的跑過那條路徑。而你沒有時間逐條追問。",[11,1104,1105],{},"如果你正在把 AI coding 導進團隊流程，卡在「怎麼定完成判定」這一關，可以找我聊——沒有簡報，也不賣套裝方案，先確認你的問題長什麼形狀再談解法。加 LINE 直接描述你的狀況就好，十分鐘大概就能知道值不值得繼續談。",[11,1107,1108,1109,1112],{},"我的顧問 LINE 本身就是我自己做的：",[505,1110,510],{"href":507,"rel":1111},[509],"。你跟它的對話，就是你正在評估的那個東西的 live demo。",{"title":513,"searchDepth":514,"depth":514,"links":1114},[1115,1116,1117,1118,1122,1125,1126,1127],{"id":561,"depth":514,"text":562},{"id":576,"depth":514,"text":577},{"id":627,"depth":514,"text":628},{"id":720,"depth":514,"text":721,"children":1119},[1120],{"id":803,"depth":1121,"text":803},3,{"id":865,"depth":514,"text":866,"children":1123},[1124],{"id":942,"depth":1121,"text":943},{"id":972,"depth":514,"text":972},{"id":1071,"depth":514,"text":1072},{"id":1098,"depth":514,"text":1099},"2026-07-25","測試全綠、checkbox 打勾、文件寫好——然後發現那條路徑從來沒被執行過。我在同一個開源專案裡踩了四次，每次偽裝不同。這是四次的拆解和三條可以直接套用的驗證判準。",{},"\u002Fblog\u002Fgreen-light-is-not-verification",{"title":540,"description":1129},"blog\u002Fgreen-light-is-not-verification",[1135,535],"testing","AQYTq3yLEjhzx1qrTXoxIPQwj7ex1RmzrEqKrbEDLVk",{"id":1138,"title":1139,"body":1140,"date":1392,"description":1393,"extension":528,"meta":1394,"navigation":530,"path":1395,"seo":1396,"stem":1397,"tags":1398,"__hash__":1401},"blog\u002Fblog\u002Fdgx-spark-self-hosted-llm.md","我把部門唯一的 AI 主機搞掛了：自建 LLM 推理四個月實錄",{"type":8,"value":1141,"toc":1384},[1142,1148,1151,1154,1156,1159,1162,1165,1168,1170,1174,1180,1183,1232,1235,1242,1249,1252,1255,1258,1260,1263,1270,1273,1297,1300,1302,1306,1312,1315,1318,1320,1324,1327,1337,1340,1349,1352,1354,1357,1360,1374,1377],[11,1143,1144],{},[14,1145],{"alt":1146,"src":1147},"深夜機房裡，記憶體量表爆表的 AI 主機，與走向電源鍵的人","\u002Fblog\u002Fimages\u002Fdgx-spark-self-hosted-llm-hero.png",[11,1149,1150],{},"五月的一個下午，我把部門唯一一台 AI 主機搞到完全沒反應。SSH 連不上，跑在上面的服務全部陪葬，最後是請 IT 走進機房、按下電源鍵，才把它救回來。",[11,1152,1153],{},"起因是我在啟動指令裡加了一個看起來很合理的 flag。",[28,1155],{},[31,1157,1158],{"id":1158},"這台機器是怎麼來的",[11,1160,1161],{},"三月中，部門進了一台 NVIDIA DGX Spark——128GB 統一記憶體的小型 AI 主機。我們拿它跑幾個內部服務：文字向量化的 embedding 模型、一個 31B 的語言模型、還有會議語音轉錄。我是兩個管理員的其中一個。",[11,1163,1164],{},"自建的理由很實際：內部文件不想丟給外部 API，批次任務跑雲端模型又太貴。一台機器放機房，內網打 API，聽起來很美好。",[11,1166,1167],{},"前兩個月也確實很美好。",[28,1169],{},[31,1171,1173],{"id":1172},"一個-flag一台屍體","一個 flag，一台屍體",[11,1175,1176,1177,586],{},"五月中，我要在這台機器上評估一個新的本地推理引擎，模型檔 Q2 量化後 ",[141,1178,1179],{},"81GB",[11,1181,1182],{},"機器的記憶體當時長這樣：",[47,1184,1185,1195],{},[50,1186,1187],{},[53,1188,1189,1192],{},[56,1190,1191],{},"佔用",[56,1193,1194],{},"大小",[63,1196,1197,1205,1213,1221],{},[53,1198,1199,1202],{},[68,1200,1201],{},"31B 語言模型",[68,1203,1204],{},"~67GB",[53,1206,1207,1210],{},[68,1208,1209],{},"Embedding 模型",[68,1211,1212],{},"~20GB",[53,1214,1215,1218],{},[68,1216,1217],{},"語音轉錄",[68,1219,1220],{},"~4GB",[53,1222,1223,1227],{},[68,1224,1225],{},[141,1226,200],{},[68,1228,1229],{},[141,1230,1231],{},"~91GB \u002F 128GB",[11,1233,1234],{},"81GB 塞不進剩下的空間，所以我先停掉 67GB 的那隻騰位置。到這裡都照規矩來。",[11,1236,1237,1238,1241],{},"然後我在啟動指令加了 ",[553,1239,1240],{},"--warm-weights","。這個 flag 的意思是：啟動時先把整份權重讀進記憶體「暖機」，第一次推理就不用等。聽起來很貼心，在獨立顯卡的機器上也確實是。",[11,1243,1244,1245,1248],{},"但這台機器是統一記憶體架構（UMA）：CPU 和 GPU 共用同一塊 ",[141,1246,1247],{},"128GB","。81GB 的權重一次灌進來，跟作業系統自己要用的記憶體搶同一塊實體空間，page fault 風暴直接把整個系統壓死。我手上沒有完整的驗屍報告，事後推測是連 SSH daemon 要的那一點記憶體都擠不出來——總之機器沒有變慢，它是直接失聯，連進去救的機會都沒有。",[11,1250,1251],{},"機房裡的機器，死得很安靜。",[11,1253,1254],{},"沒有畫面、沒有警報，你只會發現所有東西都 timeout。最後的解法很土：請 IT 走過去按電源鍵。",[11,1256,1257],{},"修正方式反而很不刺激：拿掉那個 flag，改用 lazy load。第一次推理慢一點，但機器不會死。那天我學到的一行字：在共用記憶體的機器上，任何「一次載入全部」的操作都是在賭命。",[28,1259],{},[31,1261,1262],{"id":1262},"同一天的第二個坑",[11,1264,1265,1266,1269],{},"機器活過來之後，我要把先前停掉的服務開回去，才發現第二件事：之前有服務是用 ",[553,1267,1268],{},"docker stop"," 停的，重開機後容器直接消失。它是 systemd 管的服務但沒設開機自啟，docker 層面看起來就是「不見了」。",[11,1271,1272],{},"那天之後，部門在 Slack 開了一個 channel 訂規矩，四條：",[1274,1275,1276,1284,1287,1290],"ol",{},[1277,1278,1279,1280,1283],"li",{},"動手前先 ",[553,1281,1282],{},"free -h"," 看記憶體",[1277,1285,1286],{},"要載入模型，先估算記憶體用量",[1277,1288,1289],{},"要關別人的服務，先在 channel 喊一聲",[1277,1291,1292,1293,1296],{},"操作服務一律走 ",[553,1294,1295],{},"systemctl","，不碰 docker 指令",[11,1298,1299],{},"四條全是翻車換來的。沒有一條是裝機那天想得到的。",[28,1301],{},[31,1303,1305],{"id":1304},"它又躺了一次這次不是我的錯","它又躺了一次，這次不是我的錯",[11,1307,1308,1311],{},[553,1309,1310],{},"nvidia-smi"," 說找不到 GPU，三個服務全掛——這是本週某天早上這台機器給我的見面禮。前一天只做了一件事：例行的系統 kernel 升級。",[11,1313,1314],{},"查下去才發現：新 kernel 對應的 GPU driver 模組沒被裝上，因為套件庫索引太舊，安裝時默默 404。更麻煩的是裝完不能熱載入，driver 的 firmware 要在開機流程初始化，所以必須再重開一次機。中間那包 221MB 的 driver，從公司網路抓只有 77~256 kB\u002Fs，光下載就等了 15 分鐘。",[11,1316,1317],{},"服務掛了一個上午。這種故障不會寫在任何採購簡報裡。",[28,1319],{},[31,1321,1323],{"id":1322},"數字現實它到底能跑什麼","數字現實：它到底能跑什麼",[11,1325,1326],{},"四個月下來，最值錢的其實是幾個數字。",[11,1328,1329,1330,1333,1334,586],{},"我實測那顆 81GB 的 Q2 模型，生成速度 ",[141,1331,1332],{},"13.75 token\u002Fs","。常駐的 31B 模型更慢：按規格換算大約 7 token\u002Fs，這不是我跑分跑出來的數字，但跟日常體感一致。用 7 token\u002Fs 算，一個 300 token 的回覆要等 ",[141,1335,1336],{},"40 秒上下",[11,1338,1339],{},"所以這台機器的能與不能，界線很清楚：",[1341,1342,1343,1346],"ul",{},[1277,1344,1345],{},"❌ 任何要給使用者即時回覆的場景。40 秒的等待，什麼產品都撐不住",[1277,1347,1348],{},"✅ 不趕時間的批次任務：離線跑評測、內部文件處理、embedding 向量化",[11,1350,1351],{},"我們後來的分工就是這樣：embedding 留在這台機器上跑，需要即時回覆的路徑走雲端 API。自建不是替代雲端，是跟雲端分工。這個結論值四個月。",[28,1353],{},[31,1355,1356],{"id":1356},"如果你的公司也在考慮這件事",[11,1358,1359],{},"買機器的錢是整件事裡最小的成本。真正的成本是這些問題有沒有人接：",[1341,1361,1362,1365,1368,1371],{},[1277,1363,1364],{},"誰懂統一記憶體的行為？（不懂的話，一個 flag 就能讓它躺平）",[1277,1366,1367],{},"記憶體預算誰管？多人共用時誰協調？",[1277,1369,1370],{},"kernel 升級、driver 掛掉，誰半夜爬起來修？",[1277,1372,1373],{},"你要跑的工作負載，它的推理速度真的撐得起嗎？",[11,1375,1376],{},"這四題沒有一題寫在規格表上，而我們是用四個月的翻車換來的答案。",[11,1378,1379,1380,1383],{},"如果你的公司正在評估自建 AI 主機或本地 LLM 推理，不管是為了資料不出門，還是想省 API 費用，都一樣：先別急著下單。把你的使用場景丟給我——",[505,1381,510],{"href":507,"rel":1382},[509]," 上有我的 LINE，不用準備簡報，描述你想拿它做什麼就好。十分鐘，你就會知道該買、該租、還是該繼續用 API。",{"title":513,"searchDepth":514,"depth":514,"links":1385},[1386,1387,1388,1389,1390,1391],{"id":1158,"depth":514,"text":1158},{"id":1172,"depth":514,"text":1173},{"id":1262,"depth":514,"text":1262},{"id":1304,"depth":514,"text":1305},{"id":1322,"depth":514,"text":1323},{"id":1356,"depth":514,"text":1356},"2026-07-13","一台 128GB 統一記憶體的 AI 主機進了部門。四個月裡我把它搞到 SSH 無回應、容器消失、GPU driver 全掛。這是自建 LLM 推理的真實成本清單。",{},"\u002Fblog\u002Fdgx-spark-self-hosted-llm",{"title":1139,"description":1393},"blog\u002Fdgx-spark-self-hosted-llm",[1399,1400],"ai-infra","llm","VjEP5pxzq2jyb7_-sTEDPiuOt-wFVsikM_ZM_WiASnE",1785903592117]