iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
AI Engineering

Harness Engineering × Pi Agent 實戰:打造可觀測、可評估的 AI Coding Agent系列 第 19 篇

Day19:Pi 的 Trace 契約已經完整,為什麼執行時卻沒有 Span?

  • 分享至 

  • xImage
  •  

從原始碼開始找

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 設計上非常有牙齒——後面會看到它怎麼變成一條條硬性規則。

用 callback 管理生命週期

大部分 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 上都能用。

對 adapter 的六條硬性規則

任何人要把這套契約接到自己的後端(OpenTelemetry、Sentry、log),都必須遵守這幾條:

  1. 同步呼叫 callback,剛好一次;
  2. 原封不動回傳 callback 的值;同步丟出的例外要轉成被 reject 的 promise;
  3. callback 回傳 promise 時,span 要一直開著直到它 settle;
  4. 正常結束算 ok、丟例外算 error,除非呼叫端明確設過狀態;
  5. 記錄方法必須是同步、被動、不會丟例外的,而且 span 結束後再呼叫要變成沒作用;
  6. 記錄失敗要被完整吞掉,而且業務 callback 還是要剛好執行一次。

第 6 條就是「觀測不能改變被觀測的東西」的具體化:telemetry 壞掉時,agent 必須照跑不誤。

而且 Pi 還附了一套一致性測試(@earendil-works/pi-telemetry/testing),讓你驗證自己寫的 adapter 有沒有遵守這些規則。明天我就會用它來驗我自己寫的東西——先破個梗:我第一次寫的版本沒通過。

Pi 用這套契約描述自己

光有契約還不夠,還要有詞彙。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 還是個骨架。

那這篇的意義是什麼?

我本來可以跳過這天,但我覺得這件事反而更值得寫,理由有三個:

  1. 這是開源專案的真實樣貌。 你讀到一個漂亮的設計,不代表它已經能用。先確認「有沒有人在呼叫它」,再決定要不要依賴它——這個習慣能省下很多時間。我就是因為先跑了一次 prompt(),才在寫完一整篇「如何啟用 Pi 的 trace」之前發現真相。
  2. 這個契約本身就值得學。 「沒有 end()」「記錄失敗要吞掉」「不用隱式 context」「附一致性測試」——這四個決定,任何要做 harness 的人都可以直接抄。
  3. 它預告了 Pi 的方向。 pi.harness.step 是「可重試的嘗試」、pi.harness.sleep 是「重試等待」——從這些名字就看得出來,Pi 正在把「重試」「崩潰恢復」做進 harness 核心。這正好是系列後面要談的題目。

明天

既然 Pi 還沒接上,那就自己接。Day20 我會寫一個符合這份契約的 OpenTelemetry adapter,用 Pi 官方的一致性測試證明它是對的,然後——因為 Pi 內部不發 span——改從外面把 --mode json 的事件串流接起來,組出同一套 pi.* 詞彙的 span 樹。

結果會直接推翻 Day18 的一個結論。


上一篇
Day18:Agent 為什麼跑這麼久?Session 時間戳看不見的執行開銷
系列文
Harness Engineering × Pi Agent 實戰:打造可觀測、可評估的 AI Coding Agent 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言