Architecture - 2026-09-25 - 6 分鐘閱讀
你的紀錄六週後答不出來的那些事
欄位齊全的稽核帳本,仍然可能答不出它當初就是為了回答的那個問題:六週後,是誰決定的、依哪一版規則。三個第一手案例說明欄位為什麼不夠,外加兩個可以自己跑的測試:不靠重建答不答得出來,以及紀錄沒人寫了的時候,有沒有東西會發現。
有位讀者在我企業導入系列第 1 篇底下留言。他在 production 跑一個小型自動化,前面沒擺 gateway。他抱怨的不是成本。六週後他在追一次沒有告警的失敗,查不出那一通呼叫花了多少、是誰發的。
這個缺口值得先講清楚是什麼形狀。一份紀錄裡,能讓你事後答出「這件事是誰決定的、在什麼條件下被允許」的那部分,我叫它 decision provenance,決策留痕。成本這個問題大家馬上就認,因為數字會出現在帳單上。歸屬不一樣,它要隔很久才會找上門;等它找上門,當初記得來龍去脈的人多半已經不在了。
系列第 5 篇回答的是稽核帳本要有哪些欄位:誰動的、對什麼動的、什麼時候、誰核准、依哪一版的哪一條規則。那篇結尾我寫過,欄位就位之後,要問哪條規則允許了某個動作,就只是一次查詢,不再是一次重建。這篇是來收回那句話的一半。欄位是必要的,但不夠;它們失效的三種方式,沒有一種靠加欄位能補。
「沒記到」與「等於預設值」是兩件事
去年八月,我拿同一支探針、同一顆模型、同一份 binary 跑了三次,想看讀數穩不穩。一跑過了 67% 的案例,另外兩跑各只過 8.3%。可是報表檔頭的欄位,三跑一模一樣:provider、模型、endpoint、治理設定、agent 版本、run id。
報表少的不是某一條事實。它答不出這三跑量的是不是同一件事——也就是說,我手上到底是一個結果加兩個離群值,還是兩個結果加一次意外,它分不出來。能分辨的線索全都落在記錄範圍之外,其中三項還是我的程式管不到、別人改得動的。取樣參數根本不是我的程式送出去的,實際生效的值來自模型自己的設定檔;同一個 tag 重拉一次,就可能被換掉。模型那一欄記的是 tag,不是 digest,所以兩次跑在不同權重上,讀回來會長得一模一樣。生效的 context 長度沒設也沒記。server 版本沒記。
那次離群跑的暫存目錄已經被清掉,上面那四件事永遠查不回來了。這點值得照實講:這種事情裡有用的部分從來不是補救,因為根本補救不了。當下還能決定的只有一件事,就是下一次要怎麼記。
後來我補了三條規則,第一條管的是「缺席」。provenance 欄位讀不到值的時候,報表就記 null,絕不填推測值。一份沒有 digest 的舊報表,不代表它跑的就是我現在手上這顆權重;把今天的 digest 補進那一格,等於把一個空缺改寫成一句假話。這條有測試守著:這次改動之前寫出來的報表,讀回來必須是「沒有記錄」,不能是一個空區塊——空區塊會被讀成一次乾淨的跑。
記生效值,不記請求值
第二條規則是,紀錄要記系統實際做了什麼,不是記它被要求做什麼。放到 context 長度上,就是記 runtime 實際給的值,不記我們送出去的值。兩者對不上的時候,對不上本身就是發現。
這條規則後來教了我一件沒預期到的事。我刻意換了幾種機器狀態,把同一支探針重跑四次,其中一次是冷啟動,十個檔頭欄位讀回來零漂移。檔頭確實可重現。但它之所以過關,理由跟我原本假設的不一樣。生效的 context 長度四跑相同,不是記憶體協商剛好重現,而是 server 端有個環境變數把它釘死了。證據是三個模型、兩個不同的宣告值(40960 與 262144),最後全讀出同一個生效值——協商不會有這種結果。
所以那個欄位記得出數值,記不出這個數值是從哪來的。把同一份設定搬到另一台機器,或是有人動了那個變數,報表照樣完整、照樣自洽,但它看起來在說的那件事已經不成立了。我只撞到這一次,而且只在一個欄位上,所以推廣版本只能算一個值得驗的假設,不是定律:一個欄位可以存在、可以正確,卻依然失效,只要決定它的值的東西活在紀錄之外。這件事的檢查成本很低。把紀錄裡每個欄位點名一次,說出是什麼決定了它的值,再回頭查那個東西有沒有被記在任何地方。過不了這關的欄位,就是將來讀起來很正當、其實是錯的那些。
紀錄停掉的時候,要有東西發現
第二個判準跟欄位無關。一份已經沒人在寫的紀錄,看起來跟一個本來就沒事可報的系統一模一樣。
第 5 篇講過旁邊那一半:一個宣告了、卻沒有任何程式去讀的欄位,那是備註,不是控制。這裡講的是另外一半,而且更難看見。那邊的值還在,只是沒人用,清點欄位就抓得到;這邊是寫入端停了,留下來的東西讀起來像一段風平浪靜的日子。
我自己在這裡栽的跟頭不是工程問題。我替自己的發佈維護一張週指標表,欄位早就定好,今年夏天卻有七週沒人寫。那張表不是裝飾品。我的策略文件裡有一條停止條件直接讀它:連續四週沒有任何詢價或對話,就停下來重審定位,不是再多發幾篇。誰負責示警也寫清楚了,寫在處理指標那支 agent 的 prompt 裡,註明要主動提,不必等人問。
沒有人跑它。表停了,那條停止條件就永遠不會被觸發;本來該踩到它的那幾週,就這樣安靜地過去,沒有任何東西被提出來。這個缺口是我 9 月 22 日人工翻稽核帳本才翻出來的。人工翻,正好是唯一一條不會隨規模成長的路。
回填那張表的時候,那七週我填的是「無紀錄」,不是估算值。估算值很好生,而且會讓那張表看起來很健康——那才是更糟的下場,因為一個讀起來像資料的數字會被拿去用,沒有人會回頭查它從哪裡來。
在我真的有把這件事做起來的那個系統上,設計上的答案是讓缺席出現在產出物裡,不是留在誰的記憶裡。取樣參數沒設的時候,報表檔頭不會把那一行省略掉,而是印出:這些值繼承自後端、不受 repo 控制、也不可重現。所以「忘了設定」這件事,在之後每一次跑的輸出上都看得到。
provenance 是身分,不是讀數
第三條規則是一道限制,也是最容易被跳過的一條:provenance 不進任何比率的分母。它的用途是判定兩份紀錄能不能拿來比,它自己不產生分數。守這條的測試很簡單——把兩份報表的 provenance 區塊清掉再比對,要求逐位元相同。
要把它寫成規則,理由是只要某個 provenance 欄位會牽動到一個有人被考核的數字,那份紀錄就會開始被「經營」,不再是被「寫」。這條限制的存在,是為了讓紀錄待在所有人的誘因之外。
多數決策紀錄少掉的那一欄
這套論證放到企業規模,還缺一欄。它比第 5 篇要求的策略版本更低一層。
我自己日常在用的 control plane——也就是我月初寫過的變更意圖治理層的開源參考實作——一筆策略決策紀錄除了狀態,還一定要記它是在哪一種模式下被評估的(advisory 或 enforced),以及一個布林值,說明這個操作實際上有沒有被擋下來。狀態本身有四個值,不是兩個:pass、fail、unknown、conflict。
事發六週之後,「這件事違反了規則」跟「這件事違反了規則,但那一週規則掛在 advisory,所以沒有東西擋它」是兩個不同的發現。一筆只記狀態的紀錄分不出來,於是你讀到的是前者,實際該照後者處理。unknown 跟 conflict 各佔一個位置,理由一樣。「沒有規則涵蓋這個情況」跟「兩條規則互相矛盾」都不等於拒絕;一個無法表達它們的引擎,會替你把它們壓成其中一個,壓成哪一個不是你選的,事後也看不出來。在我這邊,優先順序明寫在程式裡:conflict 最先,再來 unknown,最後 fail。
我沒有做到的,以及兩個你可以自己跑的測試
這個題目特別需要把邊界講準,因為它很容易讓人把偏好講成已經做好的實作。上面那個 control plane 做的是評估設定、寫下決策。它明文排除了 provider 憑證、secret manager 的解析,以及 agent 執行,所以一個正在跑的 session 拿著手上已經握有的 token 在做什麼,它管不到,也沒有發言權。開頭那位留言的讀者接著問的是,一個憑證在長時間執行的 session 中途被輪替掉會怎麼樣。那個問題正好落在被排除的那一塊,我沒有已經出貨的答案。
從這裡可以推出兩個測試。這兩個我都沒在自己以外的系統上跑過,所以它們是提案,不是驗證過的程序;但兩個都夠便宜,這禮拜拿現有的東西就能試。
停寫測試。在測試環境裡把負責寫紀錄的那支程式關掉,量一下要多久才會有東西通知你。如果誠實的答案是「等到下次有人去翻才會浮出來」,那份紀錄就只是記帳,不是控制,不管它有哪些欄位。
切分測試。把一個多步驟的 run 中斷,讓它重試,再去讀事後的紀錄。同一件任務應該還是讀起來像一件事。如果重試開出了第二筆紀錄,那麼最該被追問的那一通呼叫剛好就失去了歸屬,帳本反而會顯得比它描述的那段歷史更整齊。
兩個都過了,再問重建問題:挑一個六週前的決定,回答它是誰下的、依哪一條規則的哪個版本,而且不准去問當時在場的人。這件事值得挑一個什麼都沒出錯的日子來做。等事故當中才做,它只會告訴你已經失去了什麼。