昨天牆立起來之後,我開始想知道容器裡面到底發生了什麼。一場審查跑四十分鐘,時間跟錢到底花去哪了?主 agent 在忙什麼?那一群 subagent 各自佔了多少?
其實 OpenTelemetry 這個詞我知道很久了。它是可觀測性的開放框架,替 traces、metrics、logs 提供一致的資料模型與匯出管道,Claude Code 也原生支援,設幾個環境變數它就會把執行過程吐出來。但我一直不知道這個功能可以為我帶來什麼。接個監控儀表板嗎?一個我自己用的審查工具,監控要給誰看?
直到費用優化這個題目出現,我才想清楚我要的是什麼:各個階段的時間、主 agent 跟 subagents 各自在忙什麼、每個環節的花費。講白一點,我想做的事情叫 profiling。就像抓程式效能會掛 profiler、看火焰圖找熱點一樣,只是這次被剖析的對象是一條 review 流程,單位從 function 換成了 agent。
Monitoring 與 profiling 回答的是不同問題。Monitoring 持續觀察系統是否正常、何時偏離;profiling 則往下追,回答時間與資源究竟花在哪裡。兩者並不以「是否長期執行」區分,profiling 也可以持續進行。
嚴格來說,這次我沒有使用 OpenTelemetry 尚在發展中的 Profiles signal,而是把一場 review 的 trace 當成流程層級的 profiler:帶著具體問題開始量,找到瓶頸、做完決定,就可以關掉。這一天我的問題很具體,所以接下來的每一步都有方向。
後面先記住三個層級:Claude Code 每收到一次 user prompt,會開一筆以 claude_code.interaction 為根的 trace;裡面的 LLM request、tool call 與 subagent 生命週期是 span;skill.version、experiment 這類 resource attributes,則是黏在觀測資料上的標籤。同一個 session 如果有多次互動,可能跨越多筆 trace,之後再靠 session.id 聚合。
Claude Code 的 telemetry 是原生的,環境變數開起來就有:
本文對應的設定與報表程式都放在公開 repo 的 opentelemetry/ 目錄。
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOG_TOOL_DETAILS=1 # 沒有這個,tool 呼叫的參數與輸入不會進 telemetry
# 方式一:直接印在 console(快速確認用;注意它寫 stdout,會跟 claude -p 的結果混在一起)
export OTEL_METRICS_EXPORTER=console
export OTEL_LOGS_EXPORTER=console
# 方式二:traces 走 OTLP 送給 Jaeger(本文主用)。trace 還掛在 beta 旗標後面,
# 2.1.222 實測:不開下面第一行,Jaeger 連一筆都收不到
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1
export OTEL_TRACES_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
# Claude Code 直接跑在 host 時用 localhost;本文的 dev container 會由 run script
# 自動改注入 http://jaeger:4317,不必在容器裡照抄 localhost
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_LOG_TOOL_DETAILS=1 會把路徑、URL、Bash 指令與工具參數送進觀測後端;這些欄位可能含有機敏資訊,開啟前要先決定誰能讀、要保存多久,以及是否需要遮罩。
Jaeger 用一個 docker compose 就起得來。v2 以單一 binary/image 發布,再由設定決定要扮演 all-in-one、collector、query 等角色;本文這份 config 把收集、儲存與查詢 UI 放在同一個容器裡,也就是官方所稱的 all-in-one。OTLP gRPC 走 4317 收進來,UI 直接看每場 run 的時間瀑布。位置上它跟 Day 18 的 GitLab 代理掛在同一張 docker network,dev container 本來就會接上那張網,容器內用 jaeger:4317 就直達;run script 偵測到 Jaeger 容器在跑才注入這些環境變數,沒跑就完全不碰,平常零負擔;就算配置好了,進容器的啟動選單還會再問一次要不要送:跟昨天網路能力那題同一個道理,要不要被記錄,是人每一場自己做的選擇。
有一個細節值得講:昨天那道牆不用為它新增對外出口。防火牆原本就放行容器接上的直連網段,Jaeger 也在同一個網段裡,telemetry 的封包不必離開這個環境。代價是 Jaeger 會多保存一份觀測資料。本文這個示例沒有再替 Jaeger 加上登入、保存期限或欄位遮罩;若要帶進多人或長期運作的環境,這些會是下一層需要補上的治理。
跑完一場,它就在這裡等你:
點進去就是一次 interaction 的時間瀑布:由這次 prompt 觸發的 tool 呼叫、LLM 請求與 subagent 生命週期,會攤在同一條時間軸上。這就是 profiler 的視覺語言:
先講清楚一件事,免得跟前面幾天對不上:以下的數字來自我原本在用的那套,不是我們前面一起建的這份。那一套多了一條掃 git 歷史憑證的 gitleaks 軌道,也多了兩個後面會被我砍掉的角色,公開的這版都沒有。
我的審查 skill 從三月底就開始派 subagent 平行做事,linter、trivy、gitleaks、opengrep、品質盲檢,到六月已經十一個派遣點。接上 telemetry 之後,第一件想知道的事就是每個角色各花多少。結果 agent.name 這個維度上,自訂 agent 一律叫 "custom"。派了十一種角色,報表說他們是同一個人。
當時(Claude Code 2.1.193)實測下來的行為,跟文件讀起來的預期差很多(agent.name 那條現在文件上有寫了):
description,在任何 metric 跟 event 裡完全看不到agent.name 與 query_source 對自訂 agent 塌成 custom,內建 agent 反而保留原名claude_code.subagent_completed 這個事件的 agent_type 欄位,自訂名保留原樣,同一筆還帶 total_tokens、duration_ms、model
第 3 點是關鍵。照文件讀會以為「自訂 agent 一律塌成 custom」,實測才發現只有 metric 那兩個維度塌,agent_type 不塌,這條後來成了整套歸因的主路徑。(寫稿前我在 2.1.222 重驗過一輪:2、3、4 仍然成立;1 軟化了,開 OTEL_LOG_TOOL_DETAILS 後 description 會出現在 tool_result 的參數 JSON 裡,但仍然當不了 group by 的維度。)
所以要分出誰是誰,只能靠被量測物自己身上的記號。這時候該把 Day 7 埋的梗挖出來了:當時規劃 subagent 派遣,我寫了兩條看起來像碎念的規格:「subagent_type 會是 ncr-* 的格式」、「Prompt 第一句加上 ncr-* Tag,未來執行分析時可以直接 grep 這樣的 Pattern 來定位」。那個「未來」,就是今天。
第一個記號,每個派遣 prompt 的第一行固定放一個可以 grep 的 tag,自成一行:
[ncr-scan-lint]
subagent 讀到會直接忽略這行,但它會跟著 prompt 一起進 telemetry(前提是有開 OTEL_LOG_TOOL_DETAILS),事後一個 grep 就能撈出所有派遣。
第二個記號,幫每個角色建一個具名的自訂 agent 檔,派遣時指定 subagent_type:
---
name: ncr-scan-lint
model: sonnet
---
Follow your dispatch prompt.
歸因需要的最小形狀就這樣,一句話:讓 subagent_completed.agent_type 帶著角色名回來的只有那個 name(repo 裡的正式版本後來各自長成了完整 runbook,那是為了別的目的)。有了它,group by 就有每角色的 token 跟時間。
兩個記號互相印證,但扮演的角色不同。正常情況下,報表沿 span 的父子鏈找到派遣 span,再用 subagent_type 做結構化歸因;如果環境裡沒有 agent 檔、派遣退回 general-purpose,prompt tag 至少還能讓我人工反查角色。老實說,寫下「未來可以 grep」的時候,我並不知道那個未來長什麼樣;接上遙測這天,它真的成了一條復原線索。
記號真的進了 telemetry。Jaeger 瀑布裡點開一個派遣 span,subagent_type、skill.version、experiment、session.id 一次到齊(這顆就是一場真實審查裡 ncr-quality-check 的派遣,7 分多鐘):
只量一次是看熱鬧,要回答「改了有沒有效」需要對照組。OTel 的 resource attributes 就是做這件事的:啟動時設一個環境變數,該 session 的所有資料都會黏上這些標籤。我讓 run script 自動組:
OTEL_RESOURCE_ATTRIBUTES="skill.version=${SKILL_VER},experiment=${NCR_EXPERIMENT:-none},..."
skill.version 從 skill 檔案自動抓,experiment 由環境變數指定。之後要做前後比較,姿勢就是:
NCR_EXPERIMENT=before-slimgen ./run-ncr-dev-container.sh # 改之前跑
# ……改 skill……
NCR_EXPERIMENT=after-render ./run-ncr-dev-container.sh # 改之後跑
事後按 experiment 這個維度切開來比。這層是設定當下花五分鐘、之後每一次優化都在用的地基。開發當下改了什麼變因,就記進什麼標籤,資料收下來,未來才有東西可以分析。
這其實就是實驗設計,我在醫檢那行練過的老規矩:操縱變因是 skill 版本,應變變因是時間跟錢,其他能固定的(同一個 MR、同一台機器、同一個模型版本)通通是要釘住的控制變因。變因沒有先講清楚,量出來的差異就不知道該歸給誰。
experiment標籤做的事,就是把「這一場動了什麼變因」寫死在資料上,不必靠事後回憶。
儀器接好當天,第一個熱點就浮出來了。
當時的報告流程是這樣:主 agent 寫完完整版報告(Markdown,落地存檔),接著為了貼到 MR comment,派一個 subagent 把它投影成精簡版。這個 subagent 的 runbook 白紙黑字寫著它是 formatter 不是 reviewer,審查發現要逐字照抄、不准重新判斷。抄完之後,因為怕它抄漏,再派第二個 subagent 拿完整版跟精簡版逐條比對,回 PASS 或 FAIL。
你看出問題了嗎?我付錢請一個被禁止思考的 LLM 把整份報告重打一遍,然後再付錢請另一個 LLM 檢查它有沒有打錯。而且驗證的那位跑在 opus 長上下文模式,比被驗證的還貴。
當天時間瀑布一攤開,投影那一步就孤零零掛在整場的最尾端。隔天早上我補了一組嚴謹的對照:同一個內部 MR(6 個檔案、190 行變更),新舊兩版 skill 各完整審一次。數字攤開:
| 角色 | 改之前 | 改之後 |
|---|---|---|
| ncr-scan-opengrep | 941s/$2.52 | 490s/$1.40 |
| ncr-quality-check | 433s/$1.81 | 424s/$2.25 |
| ncr-slim-gen(投影) | 509s/$1.12 | 不存在了 |
| ncr-slim-gate(投影驗證) | 294s/$1.35 | 不存在了 |
| ncr-scan-trivy | 147s/$0.38 | 180s/$0.44 |
| ncr-scan-lint | 117s/$0.45 | 103s/$0.34 |
| ncr-scan-gitleaks | 50s/$0.17 | 34s/$0.13 |
| 整場 wall-clock | 42 分 28 秒 | 24 分 39 秒 |
| subagent 總花費 | $7.80 | $4.56 |
(錢都是 token 乘牌價的估算上界,遙測記的是 token 不是帳單。絕對秒數是在 macOS 的 Docker Desktop 裡量的:這台 Mac 用了五年,再疊一層虛擬化,同一場跑起來大概比原生 Linux 慢上一倍,所以看相對變化就好,絕對值沒有參考意義。)
同一份數字畫開來看(這張是從救回的 A/B raw trace 直接算出來的,計算走的就是 repo 裡 opentelemetry/telemetry-report.py 的角色歸因邏輯):
把時間軸攤開,情況更難看。四條掃描軌道是平行跑的,品質盲檢也早就結束,但投影與驗證近乎依序排在最尾端:改之前那場的最後 799 秒,快 13 分半、大約整場三分之一的 wall-clock,全部花在「投影、再驗證投影」這兩步上(兩步耗時合計 803 秒,頭尾交疊了 4 秒,所以尾段跨度是 799)。我就坐在那裡等一台影印機。
兩件事要老實講。第一,這是一場對一場的比較,n=1,而且 opengrep 那軌在兩場之間自己就差了 450 秒(它在跟 semgrep 規則檔的相容性搏鬥,跟這次優化無關),主 agent 的花費甚至是上升的($7.51 → $9.80,部分工作移回主 agent 做了)。所以「42 分變 25 分」我不會拿來當主張,站得住的主張是:被砍掉的那兩步本身,803 秒與 $2.47,變成 0。第二個數字對我來說更重要:兩場的主要審查結論一致,都是 Approved with Comments、Critical 0、Suggestion 2;新版另外多抓了 3 個 Nit。這條管線消失,在這一組對照裡,主要審查結論沒有跟著消失。
解法本身不新奇:報告本體從 Markdown 改成 JSON,每個欄位有 schema 定義,精簡版變成一支 Python 對同一份資料套另一組視圖規則、渲染出 Markdown。renderer 近乎即時,也不再產生模型 token 成本。更關鍵的是第二個 LLM 也不需要存在了:內容對應不再交給 LLM 自由生成,而是成為可由 schema、deterministic renderer 與測試查驗的契約,不用再花一個 LLM 去驗另一個 LLM。
時序我自己回看也覺得有戲:早上 10:38 儀器接上,晚上 18:54 熱點砍掉,18:57 連那兩個角色的 agent 檔一起刪了。開著 profiler 量、量到、改完、把該收的收乾淨,一天。
有一題到今天在 OTel 管道內都沒有解:每個角色「精確」花了多少錢。cost 相關的記錄只帶那個塌掉的 agent.name,所以每角色的錢只能按 token 占比去估,上面表格的 $ 全是這種估算。
當時我的替代路線是 transcript:Claude Code 的對話記錄(~/.claude/projects/ 下的 JSONL)每筆請求都有精確的 usage,ccusage 這類工具就是解析它在算錢的。六月那時我只讀主對話那一份 JSONL,算得出 session 總額,切不出角色。
後來才知道這件事早就補齊了,是我沒去找:每個 session 目錄下就有 subagents/ 子目錄,每個 subagent 一份獨立 transcript 加一份 meta,meta 裡直接寫著身分(我自己是 2.1.222 重驗時才看到,2.1.251 仍是這個形狀):
{"agentType": "ncr-scan-lint", "toolUseId": "toolu_01...", "spawnDepth": 1}
拿 meta 辨識角色、取得各檔案的精確 token 用量,再依當時牌價換算,就能得到每個角色的估算成本。這條路我後來直接寫成了 repo 裡的 opentelemetry/cost-report.py:
uv run opentelemetry/cost-report.py # 目前專案最新的 session
uv run opentelemetry/cost-report.py <session.jsonl 或其目錄> # 指定某一場
我也實測驗過 ccusage 對新格式的行為:subagents/ 有被算進去(跟我自己逐行加總的結果完全一致);去重是對每則訊息取最終值,因為 streaming 期間同一則訊息會寫進多行、output 數一路遞增,取第一行會少算好幾倍。
至於六月那兩場對照的 transcript?被預設 30 天的清理機制吃掉了,所以那兩場的錢永遠停在估算上界。遙測那份倒是從 Jaeger 的儲存底層撈回來了,撈的過程夠寫另一篇,先不展開。
跑出來長這樣,每個角色的 model、token 四項與估算成本一行一個,總額與 ccusage 的計算結果一致:
另一支 opentelemetry/session-report.py(跟 cost-report 一樣就在 repo 裡)則是把一場審查的三個資料源收進同一份互動 HTML:產出是一頁網頁,甘特可以縮放、span 可以 hover。下面拿另一場真實審查當例子,不是上面 A/B 那兩場:
uv run opentelemetry/session-report.py 17c7d838 --open # session id 前綴即可
最上面三張事實卡,結論來自審查報告的 JSON、時間來自 trace、錢來自 transcript,各源各管各的(內部 MR 標題已打碼):
中段是甘特時間軸。這場審查完等了 35 分鐘才回來下發佈指令,這段空窗用 ⫽ 斷軸壓掉,不讓它把有事發生的部分擠成一條線:
hover 到任何一個 span,能看到那一次請求的輸出 token 跟快取命中:
最下面是每角色合表。這裡的「快取命中」按輸入 token 計算,不是按 request 次數:cache read ÷ (input + cache read + cache write)。這一欄值得多看一眼:主 agent 97.4%,每個 subagent 都在八成以上,這是這場長對話的成本沒有隨 context 等比例上升的重要原因之一:
接 OpenTelemetry 這件事,我學到的:
subagent_type 是結構化歸因的主路徑,prompt 首行 tag 則保留一條人工復原線索。不過話說回來,這一整套數字有一個共同的前提:它們全部是 Claude Code 自己申報的,它願意記什麼,我就只看得到什麼。明天來做 Day 20 結尾預告過的那件事:把容器裡跑出去的流量,直接攤開來看。