Day18 的結論是:靠 session 記錄的時間戳推算,只能算出「等模型 vs 跑工具」的粗略比例,再往下就沒有解析度了。要看清楚,需要的是 span。
翻 Pi 的原始碼時,我原本只是想確認「有沒有可以掛勾的地方」,結果發現它內建了一整套 telemetry 體系——而且設計得比我預期的講究很多。
今天先把這套設計講清楚,因為它本身就是一份很好的 harness engineering 教材。至於為什麼標題說「沒有任何一行程式在用它」,留到後半段。
Pi 把 telemetry 拆成一個獨立套件 @earendil-works/pi-telemetry,而這個套件裡沒有任何 exporter、沒有全域狀態、不依賴任何後端。它只定義概念:
| 概念 | 白話 |
|---|---|
| Span | 一次操作的計時紀錄,有開始有結束 |
| 父子 span | 操作裡面包含更小的操作,形成一棵樹 |
| Attribute | 掛在 span 上的事實,例如 pi.ai.model = "gpt-5.6-luna" |
| Event | span 進行中的某個瞬間,例如「重試被排程」 |
| Status | 這次操作成功還是失敗 |
| Context | 一個把柄,用來決定新的 span 掛在樹的哪裡 |
它的文件裡有一句話定調了整個設計:
A span is diagnostic data, not business state. Recording it must not change whether the account load runs, succeeds, fails, or is persisted.
觀測不能改變被觀測的東西。 這句話聽起來像廢話,但它在 API 設計上非常有牙齒——後面會看到它怎麼變成一條條硬性規則。
大部分 telemetry 函式庫長這樣:span = start(); ... ; span.end()。Pi 不是,它只給你一個 startSpan(options, callback):
return telemetryContext.startSpan(
{ name: "example.account.load", attributes: { "example.account.id": accountId } },
async (span) => {
const account = await readAccount(accountId);
span.setAttributes({ "example.account.found": account !== undefined });
return account;
},
);
沒有公開的 end()。 span 什麼時候結束,由 callback 回傳的值(或 promise)決定。
這個選擇解決了 trace 最常見的 bug:忘記結束 span、或在例外路徑上漏掉 end()。你沒辦法忘記,因為根本沒有那個方法。
父子關係也一樣是顯式的:callback 收到的 span 本身就是子 span 的 context,要建子 span 就從它身上長出來。Pi 刻意不用 AsyncLocalStorage 這類隱式的「當前 span」機制——它在文件裡明說,這是為了能在 Node、Bun、瀏覽器、worker 上都能用。
任何人要把這套契約接到自己的後端(OpenTelemetry、Sentry、log),都必須遵守這幾條:
ok、丟例外算 error,除非呼叫端明確設過狀態;第 6 條就是「觀測不能改變被觀測的東西」的具體化:telemetry 壞掉時,agent 必須照跑不誤。
而且 Pi 還附了一套一致性測試(@earendil-works/pi-telemetry/testing),讓你驗證自己寫的 adapter 有沒有遵守這些規則。明天我就會用它來驗我自己寫的東西——先破個梗:我第一次寫的版本沒通過。
光有契約還不夠,還要有詞彙。Pi 在 pi-agent-core 裡定義了自己的 span 名稱與屬性,全部帶 pi. 前綴:
pi.harness.run 一次執行(樹根)
├─ pi.harness.turn 一輪:一則回應加上它那批工具
│ ├─ pi.harness.step 一次「可重試的嘗試」
│ │ ├─ pi.ai.request 一次模型請求
│ │ └─ pi.harness.sleep 重試前的等待
│ └─ pi.harness.tool 每一個工具呼叫,各自一條
└─ pi.harness.checkpoint
另外還有幾條是獨立的樹根:pi.harness.compaction(compaction 不是掛在 run 底下)、pi.harness.navigation、pi.session.write、pi.harness.hook、pi.harness.event_handler。
pi.ai.request 身上掛的屬性,正好是 Day18 想要卻拿不到的東西:
pi.ai.provider / pi.ai.model / pi.ai.api
pi.ai.stream.time_to_first_chunk_ms ← 等第一個字花多久
pi.ai.stream.chunk_count
pi.ai.usage.input_tokens / output_tokens / cache_read_tokens / reasoning_tokens / cost
pi.ai.response.stop_reason / pi.ai.http.status_code / pi.ai.error.type
更講究的是,這些不是散落的字串常數,而是用 defineTelemetrySchema() 定義的帶版本的結構:每個 span 宣告自己允許哪些屬性、哪些是必填、哪些只在結束時才知道、可以掛哪些 event、以及允許的父節點是誰。TypeScript 會在編譯期擋掉拼錯的名稱和不該出現的屬性。
看到這裡我很興奮:接上 OpenTelemetry,Day18 的四個問題就全部有解了。
於是我去找開關。結果是:
一、CLI 沒有任何 telemetry 參數。 pi --help 裡沒有,環境變數 PI_TELEMETRY 指的是「要不要回報安裝統計」,跟 span 無關。
二、高階 SDK 也沒有。 createAgentSession() 的選項有 model、tools、resourceLoader…就是沒有 telemetry context。
三、底層有,但那層是空殼。 AgentHarness 的建構選項確實收 context?: TelemetryContext。我照著組了一個 harness、把 adapter 傳進去、呼叫 prompt(),得到的是:
HarnessNotImplemented: AgentHarness.prompt is not implemented yet
不只 prompt。skill、compact、navigateTree、resume、steer、lane——我數了一下,二十個操作全部都是「尚未實作」。
四、最關鍵的:整個套件裡沒有任何一行程式呼叫 startHarnessSpan 或 startAiSpan。 也就是說,就算你成功把 context 傳進去,也不會有任何 span 被發出來。
在 0.84.3 這個版本,這套 telemetry 是已經定義好、但還沒有被接上的藍圖:契約寫完了、詞彙定完了、型別安全做完了、連一致性測試都附上了,但發出 span 的那些程式還沒寫,而要承載它們的 AgentHarness 還是個骨架。
我本來可以跳過這天,但我覺得這件事反而更值得寫,理由有三個:
prompt(),才在寫完一整篇「如何啟用 Pi 的 trace」之前發現真相。pi.harness.step 是「可重試的嘗試」、pi.harness.sleep 是「重試等待」——從這些名字就看得出來,Pi 正在把「重試」「崩潰恢復」做進 harness 核心。這正好是系列後面要談的題目。既然 Pi 還沒接上,那就自己接。Day20 我會寫一個符合這份契約的 OpenTelemetry adapter,用 Pi 官方的一致性測試證明它是對的,然後——因為 Pi 內部不發 span——改從外面把 --mode json 的事件串流接起來,組出同一套 pi.* 詞彙的 span 樹。
結果會直接推翻 Day18 的一個結論。