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 中斷,讓它重試,然後去讀事後的紀錄。一件任務應該還是讀起來像一個單位的工作。如果重試開出了第二筆紀錄,那麼恰恰是最該被問的那一通呼叫失去了歸屬,而帳本會看起來比它所描述的那段歷史更整齊。
兩個都過了的話,下一個要問的是重建問題:挑一個六週前的決定,回答它是誰下的、依哪一條規則的哪一個版本,不准去問當時在場的人。這件事值得在什麼都沒出錯的時候做。在事故當中做同一件事,只會告訴你已經失去了什麼。