iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0

蓋完一層之後
最誠實的動作
是把它還不是什麼
一格一格數出來

昨天把 CEL(Context Enrichment Layer,情境豐富層)的三個職責跟溯源攤開來講,也貼了一份決策級遙測該長什麼樣的 JSON,並且說明那個物件現在沒有任何一支程式會吐出來。今天把帳算清楚:這一階段實際蓋出來的東西,對照那四項,逐一打勾或留白,然後把留白的那幾格該長什麼樣寫下來。

會這樣收尾是因為概念日很容易變成一種漂亮的空話。講完 enrichment、correlation、projection、grounding,讀者很自然會以為這個 repo 就是照這樣做的。它不是。

因為今天盤點的是整個第二階段,程式碼放的是完整一份服務原始碼快照,在範例 repo OTel_AIOps_Agentironman-2026/day18/,Signal Plane 那八個模組在 aiops-agent/service/app/signals/。底下每一條 grep、每一張對帳圖都可以自己重跑一次。

今天沒有寫新的程式碼,做的是三件事:

  1. 攤一張對照表:把第二階段實際蓋出來的東西,對照 CEL 四項職責逐格打勾,看哪一格做滿、哪一格是空的。
  2. 分清楚每一格為什麼空:是想清楚了不做(取捨),還是還沒輪到(欠著)。這兩種混在一起講,讀者會以為缺的都是取捨。
  3. 把欠著的那幾格畫出形狀:baseline、trajectory、change context、correlation、grounding 缺的那半,各自該長什麼樣、是誰的工作。

產出是一張對照表加一份補強的設計草稿,不是一個功能。

先看蓋了什麼

第二階段的程式碼在 app/signals/,八個模組(加一個 __init__.py)1545 行,六份對應的測試檔 69 條(這是寫這篇那天數的,後面幾天還會再長)。設計稿把它切成四個階段,代號 s1 到 s4:

https://ithelp.ithome.com.tw/upload/images/20260906/20104930AoaptjF5N2.png

(圖裡那個 SLI 是 Service Level Indicator,服務水準指標,也就是「用哪一句查詢判斷這個服務好不好」;RCA 是 root cause analysis,根因分析,agent 被叫去做的那件事。)

四個階段全部唯讀,這是設計稿一開始就定下的鐵律:整個第二階段不能有任何副作用。這個限制回頭看是對的,它讓每一段都可以單獨上線、單獨驗證,而且錯了也只是 context 難看,不會壞掉任何東西。

逐項對照

先說清楚要對照的是哪四項:昨天講的 CEL 三職責 enrichmentcorrelationprojection,加上書裡跟三職責放同一層、份量講得還更重的 grounding。這篇的帳就是對著這四項算的。

下面這張表把 enrichment 那一項再拆成四格來看(baseline、趨勢、拓撲位置、變更情境),所以表裡有七列。

CEL 的職責 這一階段做到的 判定
enrichment:baseline 只有 attribution 那條邊有(s4.2 拿 current 比 offset 前) 部分
enrichment:trajectory 沒有。signals/ 裡沒有任何算趨勢、斜率的東西
enrichment:topology context s1 全做到,而且是宣告加對帳兩層
enrichment:change context 沒有。git_version 只是一個被搬運的欄位
correlation 沒有。三段文字各自生成、各自注入
projection 沒有,一行都沒有
grounding 權威查詢可以重跑、log 帶著 trace ID;但注入的那段話本身沒有任何識別碼

這四項沒有一項是完整的:enrichment 底下四格只有拓撲那一格做滿,grounding 一半,correlation 跟 projection 整項空白。加起來大概一項半。下面把每一項的證據攤開。

enrichment:只有一條邊有 baseline

補基準線這件事,signals/ 裡只有一個地方在做:

$ grep -rn "baseline\|trajectory\|trend\|slope\|forecast" app/signals/*.py | cut -d: -f1 | sort -u
app/signals/health.py

而且 health.py 裡那個 baseline 不是給 SLI 用的,是給 attribution 那條邊用的。也就是說「order-service 歸因到 payment 的失敗量比三十分鐘前漲了沒」有基準線,但「payment 的拒絕率 55% 算不算異常」沒有。後者靠的是契約裡宣告的一個固定目標值 declined_rate < 1%

固定目標值跟基準線不是同一件事。目標值回答「這個數字合不合格」,基準線回答「這個數字對這個服務、這個時段來說正不正常」。一個半夜流量只有白天十分之一的服務,用同一個目標值判斷,白天漏報、半夜誤報。

trajectory 更乾脆,完全沒有。系統現在有辦法說「payment 的拒絕率是 55%」,沒有辦法說「它從 2% 一路爬上來,爬了二十分鐘」。而後面那句話對值班的人來說資訊量大得多,它同時回答了「什麼時候開始的」跟「還在惡化嗎」。

change context 那格是最尷尬的一格,因為它看起來像有做。topology.yaml 每個節點都帶著 git_version,看起來就是變更情境。但那個欄位從來沒有被讀過,它只是被 compile.py 從各服務的宣告搬進來又搬出去。要讓它變成真的變更情境,得補一條部署事件流,後面「這幾格打算怎麼補」那節會講形狀。

correlation:三段文字,各自為政

前面那個模組關係圖已經畫出結論了:context.pyhealth.pydq.py 三個各自生一段文字,各自注入。沒有任何一個地方問過「這三段講的是不是同一件事」。

這不是抽象的缺點,它已經咬過兩次。一次是同一條邊在呼叫方跟被呼叫方各出現一個 ⚠,另一次是標題說 100% 而底下掛著兩個警告。那兩個 bug 我當時是各自修掉的,但它們的成因是同一個:沒有一層負責把散落的判斷收成一個一致的說法。

projection:一行都沒有

沒什麼好講的,這個系列從一開始就沒有打算做。理由是推估要有意義,得先有校準。一個沒有被驗證過準確度的預測,比沒有預測更危險,因為它會讓人採取行動。校準機制是另一個層次的東西,不是這個階段收得完的。

grounding:一半

這一項比想像中好,但也只有一半。

好的那半是契約帶來的。注入給 agent 的每一條 SLI 都附上權威的 PromQL,log 也附上權威的 LogQL。這代表 agent 講出來的任何結論,都可以有人把那句查詢複製出來重跑一次,看看數字對不對。這是很實用的一種溯源,而且成本幾乎是零。

Loki 那邊更完整。隨手撈一筆 payment.declined,它自己就帶著 trace 的識別碼。這裡要注意它在 Loki 裡的形狀跟服務 stdout 印出來的那份 JSON 不一樣:走 OTLP 進來之後,log 的本體只剩一句話,其他全部變成 structured metadata:

$ curl -sG localhost:3100/loki/api/v1/query_range --data-urlencode \
    'query={service_name="payment-service"} | event="payment.declined"' ...

body: "declined by new validator"
  event:        payment.declined
  reason:       new_validator_odd_cents
  order_id:     o-25779
  git_version:  v2.5.0
  otelTraceID:  e18757726d5af93fe9fabd3d9d82adee
  span_id:      6a6aeadc9c010352

(那個欄位叫 otelTraceID 不叫 trace_id,是 OTLP 進 Loki 之後被改名的,寫查詢的時候會踩到。)從這一行可以直接跳到那一條 trace。這是三種訊號裡唯一一種本來就走得回去的。

缺的那半是:注入的那段話本身沒有身分。 它是一段散文,裡面的每一個數字都沒有標記它是什麼時候、用哪一句查詢、在哪個視窗算出來的。agent 讀完之後如果說「payment 拒絕率 55%」,沒有任何機制可以從那句話走回產生它的那次查詢。前面那個兩個 replica 疊成一條 series 的坑會沒有人發現,根本原因就在這裡。

這幾格打算怎麼補

對照表上「缺」的那幾格性質不一樣,得先分開。projection 是想清楚了不做(前面講過,沒有校準的推估比沒有推估更危險)。其他每一格都是欠著的,下面把它們該長什麼形狀、以及是誰的工作寫下來。共同的那條線是:這幾格卡住的多半不是實作難度,是「誰來決定正常的樣子」——這是平台工程的問題,不是寫幾行程式的問題。(trajectory 是唯一的例外,它只讀 SLI 自己的樣本,不需要任何人先定義「正常」,純粹是還沒排到。)

baseline(部分 → 有)。 現在 SLI 只靠一個固定目標值 declined_rate < 1%。要補的是「這個服務、這個時段的正常值」,而它不能是產品團隊在 signal.yaml 裡多填一個常數,那會退化成每個服務各憑感覺一套。做法是平台這一側自動算:對每一條 SLI,拿它過去幾週同一時段的分佈當基準,排程重算,落成一個衍生 artifact。產品團隊只宣告例外(「這條指標開盤會漲十倍,別報」)。基準本身也要 grounded,附上它是用哪個視窗、哪個分位算出來的。

trajectory(缺 → 有)。 這一格最便宜,而且不牽扯擁有權。它是對 SLI 自己最近 N 分鐘的樣本做一次線性擬合,回報斜率、還有它穿過基準線的那個時間點。位置就在 health.py 現在那個 attribution 基準線旁邊。沒做的原因只有一個,時間。

change context(缺 → 有)。 現在 git_version 是個被 compile.py 從各服務宣告搬進搬出的死欄位,沒有人讀它。要讓它變成情境,需要的不是「宣告的版本對不對」,是「事故視窗裡有沒有一次部署、是哪個服務」。這要一條部署事件流,從 CI 來,或是盯著遙測上 git_version label 的變化。CEL 拿它替每一條訊號標上「最近一次部署:X 服務,在異常起漲前 N 分鐘」。(宣告的版本跟實際跑的版本會不會對不上,那是另一個問題,屬於 ARE 講的 靜默腐化(silent decay),得靠對帳,不是靠 enrichment。)

correlation(缺 → 有)。 這是最大的一塊,也就是昨天講的「散在各處 → 一個物件」。context.pyhealth.pydq.py 現在各生一段散文,要改成三個帶著共同鍵(服務加視窗)的物件,再由一支組裝器合併:同一條邊出現兩次的去重、互相矛盾的判定對齊、最後吐一句一致的話。它天生只能跑在看得到全部訊號的那一層,所以是平台團隊的工作。這是一次不小的重構,會動到注入格式,得配著 agent 那一側一起改。

grounding 缺的那半(半 → 有)。 注入的那段散文要有身分:裡面每一個數字都標上它是用哪句查詢、哪個視窗、什麼時候算出來的,也就是昨天那份 JSON 裡的 grounding 區塊,只是要讓每一段情境都真的帶著它。

為什麼要在平台這一層一次補齊,而不是讓每個消費者自己補?這跟前面講過的那條判準是同一件事:一個機制的成本會不會隨消費者數量線性成長。把基準線、一致的說法做進注入的資料裡,成本付一次;讓每個消費者自己補,值班的人靠經驗、dashboard 作者手動加一條線、agent 什麼都不知道就照單全收,同一份判斷被做三次、品質參差、沒有一次被記下來。而新來的那個消費者通常就是 agent,也就是最沒有能力補的那一個。

這份清單看起來像下一階段的施工圖,但它不是。真正的下一步是先回頭確認一件更基本的事:現在已經在注入的那些情境,到底是在幫 agent 還是在害它。 在補更多 enrichment 之前,得先知道現有這層有沒有用,甚至有沒有反效果。

小結

總結來說,今天交出來的是一張對照表,跟一份把每一格空白該怎麼補寫下來的草稿。四項職責做到一項半:拓撲那一格是滿的,grounding 一半,baseline 只夠一條邊用,trajectory、correlation、change context 是空的。

這些空格我不打算粉飾。projection 是想清楚了不做,其他幾格是還沒輪到,這兩種性質不一樣,混在一起講會讓讀者以為缺的都是取捨。

接下來要換一個角度,從「資料準備好了沒」轉到「拿到這些資料的那個 agent 到底怎麼做決定」,順便就把上面那個問題一起看了:現在這層情境對它到底有沒有用。


上一篇
Day17:訊號跟情境的差別,僅是 JSON 欄位的差別?
下一篇
Day19:從一問一答,到一隻會自己查東西的 agent
系列文
AIOps with OpenTelemetry:從可觀測到可信任19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言