「這個我們上次遇過」
是一個資深值班的人最值錢的一句話
而這套系統一開始
連「上次」是什麼時候都認不出來
前面把五道門攤開之後,有一個東西一直在旁邊沒被處理:這隻 agent 每次調查完,結論就散在一份逐字稿裡,下一次同樣的告警打進來,它從零開始查一次。今天處理這件事,然後把前面幾天散開的每一段串成一次完整的迴圈。
程式碼在範例 repo OTel_AIOps_Agent 的 ironman-2026/day29/。
一開始我以為這件事很單純:拿告警指紋當 key,把結論存起來,下次同一個指紋就撈出來。
指紋是告警名稱 + 服務 + 版本算出來的雜湊。問題就在最後那一項。這套系統的第一個示範事故是一次壞掉的部署,處置方式是回滾,也就是說修好它的那個動作會改變版本。於是同一個事故,修好前後是兩個指紋;重新部署一次,又是第三個。某次快照裡,一個反覆發生的事故被拆成六個指紋,而那正是「上次的結論應該被端出來」的場合。
更麻煩的是那個指紋當時同時兼四份差事:一次調查的識別、告警去重的 key、LangGraph 對話的 thread、以及案例的 key。這四件事需要的粒度完全不同。一次調查是一次;一個告警實例可以有很多次調查;一個事故橫跨很多個告警實例。
所以拆成三把 key,各自對上自己的角色:
| key | 認的是 | 粒度 |
|---|---|---|
run_id |
這一次調查 | 每次執行一個 |
fp(指紋) |
這個告警實例 | 同一個告警重複打進來共用 |
case_key |
這個事故 | 跨版本、跨告警實例 |
case_key 刻意把版本丟掉。它認的是告警名稱 + 服務,因為「這個服務的這種告警」才是人腦裡「同一件事」的定義。
這個拆分在資料表裡的代價很小,在查詢的正確性上差很多。在拆之前,一個判決會蓋到八次不同的調查上,因為它們共用一個 id。
案例表裡有一欄 root_cause,預設是空的。什麼東西可以把它填起來,是這張表最重要的設計。
最容易寫出來的版本是「agent 自己的結論」。這條路的問題跟校準那道門一樣:agent 說自己是對的,不能拿來當下一次的前提。差別是這裡更嚴重,因為案例記憶會被注入到下一次的提示裡,等於一個沒有被驗證的結論會變成之後每一次調查的起點。錯誤在這種結構下會自我加強。
所以填得起來的只有兩種來源:讀過逐字稿的人,以及在已知答案上評分的 grader。欄位裡連帶記下是誰說的,而不只是說了什麼:
root_cause TEXT,
root_cause_source TEXT, -- human / grader / self / NULL
confirmed_run_id TEXT, -- 哪一次調查的結論被採信,可回放
self 這個值會被寫進去,但永遠不會被召回。記下來是為了知道它曾經這樣猜過,不召回是因為那不是證據。
原本的設計是「有 root cause 才算學到」。跑了幾輪之後我把它放寬了,因為出現了一種很常見的狀態:事故被修好了,但沒有人有空去寫下為什麼。
「上次是把那個旗標關回去解決的」這句話對值班的人的價值,其實不輸「上次的原因是快取被關掉」。而且它的證據等級更高:原因是人說的,處置有沒有效是量出來的。所以召回的條件改成有原因或有處置,兩者有一個就值得端出來。
結論一直有人存,走不通的路從來沒有。所以每一次調查都要重新付一次同樣的代價:對著一個不存在的 label 寫 PromQL、對著超過保留期的時間查 trace。
case_ruled_out 這張表記的就是這些:
query PromQL referencing reason disproved_by=tool_result still_valid=0
query PromQL referencing status disproved_by=tool_result still_valid=0
query PromQL referencing http_status_code disproved_by=tool_result still_valid=0
disproved_by 這一欄跟 root_cause_source 是同一個道理。tool_result 表示「工具真的回了空」,model 表示「模型說它排除了」。後者會被存下來但預設不注入,因為一句沒有工具證據的「我排除了」餵回去,只會讓下一次更早停止思考。
still_valid=0 那一整排是重點。這九筆全部被退休了,因為有人按下了「這個案例聲稱知道的東西作廢」。環境會變:Tempo 查不到 trace 通常代表保留期過了,不代表那個 trace 不存在,把這種結論釘死會讓下一次不敢去看它該看的地方。所以死路有兩層時效,一層是寫入時就知道會短命的(給它一個到期時間),一層是所有死路共用的年齡上限。
退休不等於刪除。那九筆還在表上,只是不再被召回,因為「這件事曾經被排除過、後來又被解除」正是事後稽核要看的東西。
有一種紀錄比結論更貴,就是人按下拒絕的那一刻。
在把它記下來之前,系統的行為是這樣的:人拒絕了一個提案,下一次同樣的事故又發生,agent 提出一模一樣的提案。這不是模型不長記性,是那筆拒絕根本沒有被寫在任何它讀得到的地方。
所以拒絕的理由會變成這個案例上的一條死路,而且是以動作 + 對象為單位記的,不是以動作為單位。「不要重啟 payment」不是關於重啟任何東西的一般性宣告,這件事分不清楚的話,一次拒絕會把整個動作在所有服務上都封死。
機制做完了,接下來要證明它會轉。而要挑哪一個事故來跑,本身就是設計的一部分。
這座叢集上的第一個示範事故是一次壞掉的部署,處置是回滾。它很適合當第一個,但它有一個性質讓後面每件事都變簡單:壞的是版本,而回滾這個動作剛好認得版本。
第二個事故不是這樣。它的原因是一個旗標被打開,導致 user-service 的驗證每次都掉到冷路徑。這個事故沒有壞掉的版本可以回滾,rollout_undo 在這裡不只是不對,是根本用不上。
所以要跑通它,得先補兩樣東西。一個是能真的修好它的動作(改 ConfigMap 裡的一個布林值,不重啟 pod),另一個是把「症狀」跟「那個動作」接起來的處置程序。這件事 agent 自己跨不過去:它可以查出旗標被打開了,但「所以要把它關回去」這一跳需要有人事先寫下來。
這也是我後來才想清楚的一件事。runbook 在這套系統裡不是自動化腳本,是把人的處置知識變成 agent 講得出口的動作的那座橋。
這是那次真的寫進案例記憶的執行(不是排練),從提案到驗證通過三分鐘:
15:15:52 proposed action=k8s.configmap_flag_set autonomy=propose
15:15:56 approved trace_id=f7fc9b0285fb036de6a93d92d4ab8576
15:15:56 execute start
15:15:56 precondition ok checked 4
15:15:56 dry_run ok blast_radius: target demo/user-flags,
revision user_session_cache_disabled=True→False
15:15:58 execute success
15:15:58 verify settle 165s
15:18:43 verify pass value 0 ≤ max_value 0.01
執行完不等於迴圈閉合。閉合的定義是下一次會拿到不一樣的東西。那一輪跑完之後,案例上多了這個:
resolution : {"action": "k8s.configmap_flag_set",
"args": {"configmap": "user-flags", "flag": "user_session_cache_disabled", "value": false},
"runbook_id": "session-cache-timeout",
"request_id": "92690e7562a54af8",
"verified": true,
"ts": "2026-08-22T15:18:43Z"}
三件事同時被記下來:做了什麼、根據哪一份處置程序、以及它到底有沒有效。最後那個布林值是整筆紀錄裡唯一不是宣稱、而是量出來的東西。
同一輪還在另外兩本帳上各留了一列。執行帳記的是「這個目標被動過」,給熔斷跟頻率上限用;runbook 的成績單記的是「這一份程序被用過一次,結果 ok」,給那道 runbook 的門用。三本帳、三個不同的讀者。同一件事被記三次不是重複,是因為這三個讀者問的問題不一樣,而把它們合成一張表的那一版我試過,結果是每一邊都要對另外兩邊做過濾。
順帶一提,今天這篇用的是前一天那次執行的紀錄,不是為了寫文章現跑一輪新的。原因是這條路要走到「寫進案例記憶」那一半,就得關掉排練標記,讓那一輪被系統當成真實事故——而那會在帳上留下一列「這裡發生過一個事故」,實際上是我按的。前面才剛花一整天處理「演習不能污染真實紀錄」,為了讓今天的截圖漂亮而去多寫一列假事故,會是最諷刺的一種自打嘴巴。
這是驗收的地方。同一個服務的同一種告警再來一次,注入到提示裡的東西是這三行:
## Past cases for this service (reference — current evidence wins)
- order-cancel-rate-high (×8, last 2026-08-23)
resolved by: k8s.configmap_flag_set (verified)
短得有點失望,但這三行是有代價的:它要有一個跨版本認得出來的事故 key、一個能被驗證的處置結果、以及一條「這是參考,現在的證據優先」的但書。
那句 current evidence wins 是刻意寫給模型看的。案例記憶最危險的失敗模式不是記錯,是記對了但過度相信——這次的症狀其實不一樣,而它照著上次的答案往下走。
而它仍然沒有原因。八次發生、有人修好過、沒有人寫下為什麼。
上面那句「還沒有人寫下為什麼」我寫的時候當成一個誠實的但書,也就是一份待辦清單上的一項。幾天後我去查了一件別的事,順手在叢集上直接呼叫召回那支函式,才知道它的份量完全不是那樣:
_past_incident_context('payment-service', 'payment-decline-rate-high') → ''
_past_incident_context('order-service', 'order-cancel-rate-high') → ''
空字串。 兩半都空:根因那半要求案例有一個可信來源的 root_cause,而四個案例全部是 NULL;死路那半的九筆全部 still_valid=0,也全部不召回。
也就是說,從迴圈「跑完」的那天到我去查的那天,這個系統對每一次調查注入的過往事件是零個字元。機制全部正確,帳上三列都在,而下一次調查拿到的東西跟第一次一樣多。
補起來花了三十秒——把那個八次發生的案例的原因寫進去(source 寫死是 human,這是設計,不是欄位有預設值):
- order-cancel-rate-high (×8, last 2026-08-23, confirmed by human)
root cause: user-service 的 user_session_cache_disabled flag 被設成 true,session cache
關掉之後每一次 auth 檢查都落到慢的 session store…
resolved by: k8s.configmap_flag_set (verified)
寫進去的那一刻,案例自己把前面那次已驗證的處置接上來當 resolution,狀態從 open 變成 resolved。根因(人說的)跟修法(機器跑過的)第一次接在同一列上,而那正是這一整天在講的那個迴圈第一次真的通電。
所以這件事的教訓不在機制,在做完的入口沒有人走。標註頁做好了、案例頁做好了、提案審核介面做好了,然後過期沒人理的提案有十二筆、四個案例有三個原因欄位是空的。一個「還沒填」的欄位,跟一個「填了才會動」的開關,在資料表上長得一模一樣。
還有一件事是那天才看清楚的:真正稀缺的記憶不是「答案是什麼」,是**「這個環境怎麼問」**。同一個事故連跑四輪,成敗的差別不在有沒有召回過往案例,而在那一輪有沒有碰巧用對查詢的形狀——用 | json | event="cache.miss" 的那次查到了,用 level="error" 過濾的那次什麼都沒有(這些服務根本不發 level 這個欄位)。死路那張表本來就是為這件事做的,而它是空的。
案例記憶真正改變的不是 agent,是交接。
一個反覆發生八次的事故,如果每次的結論都只活在當時那個人的腦袋跟一份逐字稿裡,那第九次接手的人拿到的東西跟第一次一樣多。把「上次怎麼修好的」「哪些路已經走不通」「哪個提案被拒絕過、理由是什麼」寫成表,這些東西才會跟著事故走,而不是跟著人走。
但要小心的地方也在這裡。一份會被自動端到眼前的「上次的答案」,會讓人跳過驗證,尤其在半夜。所以召回那段文字的第一行就寫著現在的證據優先,而且案例可以被整份作廢——這句話不是免責聲明,是設計:一份會自動端到眼前的舊答案,必須同時提供一個把它拿掉的方法。
今天做的事情是把「上次遇過」從一句人的口頭禪,變成一張可以查、可以退休、可以作廢的表,然後把散開的每一段接成一次完整的迴圈:告警進來、處置程序把症狀接到一個型別化的動作、五道門決定要不要問人、人按了、執行前四道閘門再擋一次、驗證等回看窗、然後三本帳各記一列。
而迴圈閉不閉合,判準只有一個:下一次會不會拿到不一樣的東西。
我原本以為答案是「會,拿到三行字」。後來去查才知道,在有人把那個原因欄位填起來之前,答案是零個字元——機制全對,證據全空,而這兩件事在報表上長得一模一樣。