iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 37 篇

Day 23(上)|LLM Observability:一條 Trace 要能還原工作流

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:LLM trace 的目標不是蒐集 prompt,而是用最少必要資料還原「一個 request 經過哪些決策與依賴」,並安全地診斷延遲、成本、品質訊號與失敗位置。本篇(上)先把 trace 的資料模型定下來——span tree 該長什麼樣、欄位該放哪、outcome 該怎麼拆——下篇(下)再進實作與驗收。

一句話版本:如果一條 trace 無法讓另一位工程師重建這次 workflow 的判斷順序,它只是一串時間戳,無法支援判斷。

Day 22 把 dashboard 分成使用者、服務與資源三層。

那套分層能告訴 on-call「哪裡紅了」。

但當 /ask 的成功率下降、延遲變長,或使用者回報「答案看起來很像真的,卻剛好是錯的」,dashboard 不會自動把答案塞進你腦裡。

你需要沿著一次請求往下看。

它用了哪版 prompt?

retriever 找到幾份文件?

模型被路由到哪個 provider?

tool 有沒有重試?

validator 是拒絕了,還是根本沒跑?

這就是 trace 的工作。

回到 Day 1 那句話:LLM Trace 是 AI Workflow 的 Distributed Trace。

差別只在於,AI workflow 的 span 多了 prompt、retrieval、model、tool 與評估這些決策節點。

① 先把 trace 放回 SRE 的位置

Trace 不是 metrics 的豪華版。

也不是把 log 換成階層圖。

三種訊號各自回答不同問題。

Metrics
  「現在有多大範圍受影響?」

Logs
  「這個 component 在那個時間點留下了什麼細節?」

Trace
  「這一次 request 如何穿過 service、dependency 與 workflow?」

以 /ask 為例。

Prometheus 可以顯示 good_outcome_ratio 在五分鐘內從 0.98 降到 0.83。

Loki 可以用 request_id 找到 parser 報錯或 policy 拒絕的紀錄。

Trace 則讓你看見:慢的是 retrieve、model 還是 validate;失敗是在外部 tool、資料源、模型回覆,還是你的流程控制。

所以 Day 22 的三層 dashboard 與今天的 trace 不是競爭關係。

使用者層 dashboard
  ↓ SLO / latency / good outcome 變差
服務層 dashboard
  ↓ /ask、retrieval、tool 哪一段異常
單一 trace
  ↓ 同一個 request 的 span tree 與版本資訊
logs / deployment / provider 狀態
  ↓ 找到能修的證據

這條鑽取路徑很重要。

如果第一步就把人丟進 trace explorer,on-call 得先猜要找哪一條 request。

如果最後只有 dashboard,大家只會知道「壞了」,不知道怎麼修。

常見誤解:trace 是 metrics 的放大版

新手團隊常犯的第一個錯,是把三種訊號當成同一件事的不同解析度——metrics 是縮圖,trace 是原圖。這個直覺是錯的:兩者其實是互斥的資料保留策略,一種犧牲細節換彙總,一種犧牲彙總換細節。

Metrics 把幾千萬筆 request 壓縮成幾個數字,讓你一眼判斷「範圍有多大」,代價是資料一旦進了 histogram 或 counter,就永遠拿不回是哪一筆 request 造成了那個離群值。Trace 相反:它保留單筆 request 的完整因果鏈,卻不擅長回答「這個問題影響了多少人」——你能看到這次 retrieval.search 花了 800 ms,卻不知道現在有幾成流量也一樣慢。

把 trace 當成「metrics 但更詳細」,通常會讓人在事故現場同時做不好兩件事:想彙總時 trace explorer 給不出全域比例,想追單筆時 metrics 又已經把身分資訊丟了。這也是為什麼 Day 22 堅持三層 dashboard 要分開設計,而不是做一個誰都不知道該先查什麼的「萬用查詢框」。

把 trace 當 metrics 放大版(誤)
  metrics 壞了 → 直接開 trace explorer 找「有問題的那幾條」
  問題:沒有 index、沒有範圍界定,等於在幾十萬條 trace 裡撈針

正確的鑽取順序
  metrics 壞了 → 先看是哪個 route / model / outcome 維度異常
             → 用該維度縮小 trace 查詢範圍
             → 打開具體幾條 trace 看因果鏈

為什麼不能乾脆全量保留每一條 trace

既然 trace 這麼有用,一個自然的念頭是「那就每一條都留著,反正儲存空間便宜」。但全量保留至少撞上兩個現實限制:一是效能開銷——下一節會提到 Dapper 論文定下的紅線,追蹤系統對應用效能的影響必須低於 1%,過度密集的 instrumentation 會讓觀測系統自己變成延遲的一部分;二是資料治理成本——第⑪節會展開,trace 裡經常帶著 prompt、retrieved document 這類高敏感度內容,全量保留等於無限期累積一份未經治理的敏感資料庫。「該留多少」從來不是單純的技術問題,而是效能、成本與風險三方拉鋸出來的工程判斷,這也是為什麼 sampling 值得獨立成一整節來談。

② 一條 trace 到底在描述什麼

先把幾個名詞講清楚。

trace_id 代表一個端到端工作單位。

在這篇裡,它通常對應一次使用者呼叫 /ask。

span_id 代表這段工作中的一個操作。

例如一次檢索、一次模型呼叫或一次 validator 執行。

parent span 與 child span 的關係,才讓「時間點」變成「因果順序」。

trace_id = 4c1b...

POST /ask                         1,240 ms
├─ prompt.build                      4 ms
├─ retrieval.search                 91 ms
├─ model.chat                      992 ms
│  └─ provider.retry                88 ms
├─ tool.policy_check                 2 ms
├─ response.parse                    1 ms
└─ answer.validate                 150 ms

這張樹不是 UI 裝飾。

它讓你做兩個很具體的判斷。

第一,model.chat 變慢時,不要先去調 vector index。

第二,answer.validate 沒有出現時,不要把 HTTP 200 誤當成語意成功。

OpenTelemetry 將跨服務狀態放在 Context 中,並用 propagator 在 HTTP 等 transport 注入與抽取 trace context。

因此 API service 呼叫 retriever、retriever 再呼叫另一個 service 時,不必各自生一條斷掉的 trace。

同一個 trace_id 能穿過邊界。

官方文件也把 Collector 定位為可集中做 enrichment、sampling 與敏感資訊清理的地方;應用程式不該各自硬編一套不一致的遮罩規則。

這套設計不是憑空出現的。

Google 在 2010 年發表的 Dapper 論文,描述了一套每天要追蹤數十億次請求的內部基礎設施,並訂下後來幾乎所有分散式追蹤系統都在遵守的三個前提:追蹤本身的效能開銷要低於 1%、應用程式不必為了被追蹤而改寫商業邏輯(instrumentation 躲在 RPC、HTTP client 這類共用函式庫裡)、系統要能從幾個服務長到數千個服務而不垮掉。

OpenTelemetry 今天把 API 與 SDK 分開、把 trace context 包成單一物件在 process 內外傳遞,直接繼承自這三個前提。

實際跨服務傳遞時,context 通常會被編碼成一個 HTTP 標頭字串。W3C Trace Context 規範定義的 traceparent 長這樣。

traceparent: 00-4c1b2c3d4e5f60718293a4b5c6d7e8f9-00f067aa0ba902b7-01
             │  └────────── trace_id ──────────┘ └── span_id ──┘  │
          version                                          trace_flags

第一段是規範版本,中間一長串是這次 request 的 trace_id,接著是呼叫方目前所在的 span_id,最後的 01 表示這條 trace 被取樣。

這裡藏著一個常被忽略的細節:trace_id 是 128 位元、span_id 只有 64 位元,長度不同不是排版巧合——trace_id 要在極大流量下保持全域唯一,span_id 只需在同一條 trace 內唯一,64 位元已經綽綽有餘。這也是為什麼絕大多數情況下應該讓 SDK 自動產生這兩個 id,而不是自己手刻,以免手動搞混導致高流量下碰撞機率升高。

API service 呼叫 retriever 時,只要把這個標頭原封不動往下轉發,retriever 產生的新 span 就能把自己的 parent 指回這個值,不必知道上游是誰、用什麼語言寫。

這也是本篇開頭那句「無法還原判斷順序的時間戳」真正會發生的地方——只要有一個服務邊界忘了轉發這個標頭,後面所有 span 就會各自開一條新 trace,樹就斷了,而且斷點通常不會有任何錯誤訊息提醒你。

span kind:同一個字,不同角色

OpenTelemetry 規範裡還有一個經常被忽略的欄位叫 SpanKind,它標記一個 span 在這次呼叫裡扮演的角色,而不是它做了什麼事。常見的五種是 INTERNAL(單一 process 內部的工作單位,例如 prompt.build)、SERVER(接收一次外部請求,例如 FastAPI 收到的 POST /ask)、CLIENT(發出一次外部呼叫,例如呼叫模型 provider 的那一段)、PRODUCER 與 CONSUMER(用於訊息佇列的兩端)。

這個分類看起來像是裝飾用的 metadata,但它決定了 trace 後端要不要把兩個 span 當成同一次「網路呼叫」的兩端來配對。很多後端會用 SERVER span 的 duration 當成該服務對外的真正延遲基準;若所有 span 都標成 INTERNAL,後端就無法分辨「這是我自己算的時間」還是「網路那頭真正花的時間」,Service dashboard 上的延遲數字就會跟實際感受對不上。

# 標示 SpanKind 讓後端能正確配對 CLIENT / SERVER 的兩端
from opentelemetry.trace import SpanKind

with tracer.start_as_current_span(
    "model.chat", kind=SpanKind.CLIENT
) as span:
    span.set_attribute("model.provider", "internal-gateway")
    # ... 呼叫外部 model provider
POST /ask                    SpanKind.SERVER   ← API 收到外部請求
├─ prompt.build               SpanKind.INTERNAL ← 純 process 內運算
├─ retrieval.search           SpanKind.CLIENT   ← 呼叫外部 retriever service
├─ model.chat                 SpanKind.CLIENT   ← 呼叫外部 model provider
└─ answer.validate            SpanKind.INTERNAL ← 純 process 內運算

API 與 SDK 分離:為什麼你的程式碼不必認識 exporter

OpenTelemetry 把「你在程式碼裡呼叫的介面」(API)與「真正把資料送出去的實作」(SDK)拆成兩個獨立套件,這個設計選擇直接來自 Dapper 論文的第二個前提——instrumentation 不該綁死商業邏輯。

具體來說,app/workflow.py 裡呼叫的 tracer.start_as_current_span(...) 屬於 API 層;真正決定「span 最後被印到 console,還是送到 OTLP endpoint」的 TracerProvider 與 SpanProcessor 屬於 SDK 層,只在 app/telemetry.py 的 configure_tracing() 裡設定一次。這代表 workflow.py 完全不需要知道也不需要 import 任何 exporter 相關的類別。

這個分離帶來一個實務上很重要的好處:換後端不必碰商業邏輯。想從 ConsoleSpanExporter 換成正式的 OTLP exporter(Day 32 的主題),你只要改 configure_tracing() 這一個函式;retrieve()、call_model()、validate() 一行都不用動。如果反過來把 exporter 的細節散落在每個函式裡,換一次後端就要動整個程式碼庫。

③ LLM workflow 的 span tree 不該只有 llm.call

最常見的第一版 instrumentation 長這樣。

POST /ask
└─ llm.call

它比完全沒有 trace 好。

但它無法回答「模型為什麼拿到這個 prompt」或「答案為什麼被拒絕」。

RAG 或 agent workflow 的問題,常常發生在模型前面或後面。

建議先用能對應產品行為的 span 名稱。

POST /ask
├─ prompt.build
├─ retrieval.search
├─ retrieval.rerank
├─ model.chat
├─ tool.policy_check
├─ tool.execute.<tool_name>
├─ response.parse
├─ answer.validate
└─ response.serialize

不是每個 workflow 都有這九個節點。

例如沒有 reranker,就不要為了 dashboard 對稱硬塞空 span。

反過來說,只要某個步驟可以改變回覆內容、成本、延遲或安全結果,它通常值得有自己的 span。

以下是一條 Policy Q&A workflow 的合理最小樹。

POST /ask
├─ prompt.build
│  └─ prompt.version = policy-qa-v3
├─ retrieval.search
│  ├─ retrieval.index_version = handbook-2026-09-18
│  └─ retrieval.result_count = 3
├─ model.chat
│  ├─ model.provider = internal-gateway
│  ├─ model.name = model-route-a
│  └─ model.route = primary
├─ answer.validate
│  ├─ quality.grounded = true
│  └─ safety.status = pass
└─ response.serialize

注意 policy-qa-v3 與 handbook-2026-09-18。

這些不是方便搜尋的裝飾字串。

它們是你在下週發現答案退步時,能否把問題縮到某次 prompt 或 index 更新的分水嶺。

Agent workflow:span tree 要能長成一棵真正的樹,不是一條線

上面的 Policy Q&A 例子是線性 pipeline,每個 span 只有一個 parent、只執行一次。但只要 workflow 裡加入 agent 迴圈——模型自己決定要不要呼叫某個 tool、要不要再檢索一次——span tree 就不再是一條直線,而是會分叉、會重複、甚至會遞迴的樹狀結構。

POST /ask
├─ prompt.build
├─ agent.loop
│  ├─ agent.turn (n=1)
│  │  ├─ model.chat
│  │  │  └─ event: finish_reason=tool_calls
│  │  ├─ tool.policy_check
│  │  └─ tool.execute.search_handbook
│  ├─ agent.turn (n=2)
│  │  ├─ model.chat
│  │  │  └─ event: finish_reason=tool_calls
│  │  ├─ tool.policy_check
│  │  └─ tool.execute.lookup_employee_id
│  └─ agent.turn (n=3)
│     ├─ model.chat
│     │  └─ event: finish_reason=stop
│     └─ answer.validate
└─ response.serialize

這裡有兩個容易被忽略的設計重點。第一,每個 agent.turn 都應該是獨立的 span,而不是把整個迴圈塞進一個巨大的 agent.loop span 裡只記總耗時——否則你會知道整個 agent 花了 4.2 秒,卻不知道是第幾輪開始變慢、第幾輪開始重複呼叫同一個 tool。第二,model.chat 的 finish_reason 屬性在多輪場景裡特別關鍵:tool_calls 代表模型主動要求再做一輪,stop 代表模型認為任務完成,length 代表輸出被 token 上限截斷後強制結束。如果一個 agent 卡在迴圈裡不斷產生 tool_calls,光看 POST /ask 這個根 span 的 status,你只會看到「還在跑」或者「跑了很久但沒有 error」——不會知道它其實已經偏離了原本的任務。

常見誤解:span 數量越多,可觀測性越好

把每一行程式碼都包成一個 span,聽起來像是「更細的顆粒度」,實際上通常會製造兩個反效果:event storm 讓你在 trace explorer 裡滑很久才找到真正決定成敗的那幾個 span;每個 span 都要序列化、傳輸、儲存,過度細分本身就會吃掉 Dapper 論文要求的「低於 1% overhead」預算。

判斷一個步驟值不值得有自己的 span,可以問:「如果這一步變慢或失敗,我需要獨立診斷它嗎?」答案是否定的,就該合併進上一層 span 的 attribute。例如 prompt.build 裡的字串拼接不需要拆成十個 span,但 retrieval.search 需要獨立,因為它有自己的延遲分佈與失敗模式。這也解釋了為什麼 agent 迴圈的每一輪要拆成獨立 span、但輪次內部的 token-by-token 生成不需要——單一 token 的生成快慢屬於模型本身的效能特性,不是這層 workflow 該管的診斷單位。

④ resource、span attribute、event:不要把所有資料塞同一格

資料模型一開始亂,半年後通常只能靠搜尋框祈禱。

先分清三個層級。

放置位置 適合放什麼 不適合放什麼
Resource service.name、service.version、deployment、環境 每次 request 的 task id
Span attribute prompt version、index version、model route、outcome 整段 prompt 或完整文件
Span event retry、fallback、validation failure 的發生時點 每個 token 或高頻 debug noise

Resource 用來描述「誰送出了這筆 telemetry」。

它在同一個 process 或 deployment 裡通常不會每個 request 都變。

service.name=ai-api、service.version=2026.09.23、deployment.environment=staging 都屬於這層。

Span attribute 描述「這一步做了什麼」。

prompt.version=policy-qa-v3、retrieval.result_count=3、workflow.outcome=degraded 都屬於這層。

Span event 描述「過程中某件值得追查的事發生了」。

例如 provider 第一次回 429,第二次成功。

model.chat
├─ event: provider.retry
│  ├─ attempt = 1
│  └─ error.type = rate_limit
└─ event: provider.retry_succeeded
   └─ attempt = 2

如果把 retry 次數只留在最後的 retry_count=1,你仍然知道它重試過。

但你失去了「為什麼重試」與「重試花了多少時間」的時間關係。

如果每個 token 都記成 event,trace 又會變成昂貴的逐字錄音。

這正是 schema contract 要幫你做的取捨。

為什麼不能把 event 直接當成另一種 span

初次接觸 OTel 的人常把 event 想成「一個沒有 duration 的 mini span」,於是把所有時間點資訊都改成子 span。這個做法在小規模 demo 裡看不出問題,規模一大就會出現兩個副作用:span 數量隨 retry 次數線性成長,而 retry 次數往往是系統壓力的訊號——壓力越大、trace 樹越長越大,形成「越是事故現場、trace 越難讀」的反直覺現象;而且多數後端對 span 的計費與儲存成本遠高於 event,因為每個 span 都要單獨帶一份完整的 context 與 attribute 集合,event 卻能共享所在 span 的這些欄位。

一個實用的判斷準則:這件事本身有值得獨立量測的 duration(例如一次 retry 呼叫花了多久),它應該是子 span;只是「在某個時間點發生了一件事」(例如判斷要不要重試的那個決策點),它應該是 event。前面 provider.retry 用 event 而不是 span,正是因為真正花時間的是包在外面那次 model.chat 呼叫本身。

Resource 屬性不會憑空自動填上

Resource.create({...}) 常被誤會成 OpenTelemetry SDK 會自動幫你偵測。實際上 service.name、service.version、deployment.environment.name 全部要由你自己在啟動時顯式提供,SDK 只負責把這些值附掛到每個從這個 process 送出去的 span 上,不會替你猜。

這在多服務架構裡特別重要:service.name 沒設定好,trace 後端在做服務地圖分析時會把好幾個不同服務全部歸成同一個「unknown_service」節點;service.version 每次部署都正確更新,則能直接在延遲分佈圖上看出「延遲升高是不是伴隨新版本上線」,不必另外翻部署紀錄比對時間。

一個真實常見的錯誤放置:把 request 層資訊塞進 Resource

下面這組對照,是很多團隊第一次寫 instrumentation 時會犯的錯誤。

# 錯誤:把每次 request 才有意義的資訊塞進 Resource
resource = Resource.create({
    "service.name": "ai-policy-api",
    "request.id": request_id,          # ✗ 每個 request 都不同,不該放這裡
    "user.question_hash": question_hash,  # ✗ 同上
})
# 正確:Resource 只放「這個 process/deployment 不會變」的資訊
resource = Resource.create({
    "service.name": "ai-policy-api",
    "service.version": "2026.09.23",
    "deployment.environment.name": "staging",
})

# request 層的資訊放在 span attribute,隨每次呼叫變化
with tracer.start_as_current_span("POST /ask") as root:
    root.set_attribute("request.id", request_id)

把 request.id 錯放進 Resource,最直接的後果是:這個 TracerProvider 產生的所有後續 span,都會被永久打上同一個 request.id,因為 Resource 在建立 TracerProvider 時就固定下來,不會隨每次呼叫更新。等於你花力氣記錄了一個看似有意義的欄位,實際上它從第二個 request 開始就是錯的。

⑤ 先定義 outcome,否則所有紅色都叫 failure

Day 7 已經拆過 technical success 與 semantic success。

Day 23 的 trace 要把這個區別帶進欄位,而不是留在工程師腦中。

一個真實的生產事故提供了警示。一組多 agent 系統裡,兩個 agent 陷入互相要求對方澄清的迴圈:Agent A 回頭問 Agent B 要更多資訊,Agent B 又反過來問 Agent A,如此循環了十一天。這不是幻覺,也不是任何一次呼叫出錯——每一次呼叫本身都「正常」:API 回傳 HTTP 200、沒有拋出例外、延遲也在合理範圍,因為兩個 agent 完全依照設計在運作。但整個 workflow 從未真正完成任何任務,第一週的成本還只是 $127,等帳單寄達時,每週 API 花費已經燒到 $47,000。問題不是任何一次呼叫故障,而是 workflow 層級的語意目標(完成任務)早就失敗,卻被逐次都合法的技術回應掩蓋。如果 trace 無法區分「系統正常運作」與「系統從未真正完成任務」,on-call 永遠無法可靠地診斷發生了什麼。

一個可用的最小 outcome schema 如下。

technical.status = ok | error
workflow.status  = completed | degraded | aborted
quality.status   = pass | fail | not_evaluated
safety.status    = pass | refused | needs_review
task.status      = completed | incomplete | failed

這些值不能互相推導。

technical.status=ok 表示程式沒有因未處理例外中斷。

它不表示模型說的話正確。

quality.status=not_evaluated 也不等於 pass。

它只表示這次沒有足夠證據判斷品質。

看看三個情境。

情境 technical workflow quality safety task
找到來源並正確回答 ok completed pass pass completed
找不到來源,明確說不知道 ok completed pass pass completed
HTTP 200 但答案沒有根據 ok completed fail pass failed
provider timeout,fallback 成功 ok degraded not_evaluated pass completed
使用者要求危險操作,被拒絕 ok completed not_evaluated refused completed

第二列特別容易被做錯。

「不知道」不是系統失敗。

在有來源邊界的 Q&A 中,編造才是。

同樣地,拒絕不一定是品質差。

若 policy 要求拒絕,safety.status=refused 可以是正確結果。

quality.status=fail 背後,其實是兩種不同的病

把 quality.status 設成單一布林值,很容易把兩種成因不同的失敗混在一起。業界對生產環境幻覺偵測的整理把它們拆成兩類:intrinsic hallucination 是模型輸出違反了自己拿到的 instruction 或 retrieved context(文件寫著要主管核准,模型卻說不需要);extrinsic hallucination 是模型編造了完全無法從既有資料驗證的事實(例如引用一份不存在的內部規章編號)。intrinsic 通常可以靠比對 retrieved context 與輸出做自動化引文驗證,extrinsic 則需要領域特定的事實查核,往往只能靠人工或另一個 judge 模型抓到。

這個區分直接影響 citation.valid 這個欄位該怎麼設計。trace 若只留籠統的 quality.status=fail,事後就無法分辨這次失敗是「模型沒讀懂文件」還是「模型憑空編造」——前者多半要調整 prompt 或 retrieval 排序,後者往往要收緊 grounding 要求。業界共識是 citation-valid ratio 門檻依場景不同:合規類工作建議 ≥ 0.99,一般問答可放寬到 ≥ 0.95;但單一樣本掉到門檻以下不該直接觸發告警,而要看滾動窗口下的比例是否趨勢性下滑,這點會在第⑪節的 sampling 策略接著展開。

這些語意要留在 trace,才能讓 Day 24 的 dashboard 顯示真正的 good outcome,而不是把所有 200 混成同一桶。

⑥ trace 能回答什麼,不能回答什麼

Trace 很擅長回答一次 workflow 的歷程。

這次為何慢?
  → model.chat 佔了 992 ms,還是 retrieval.search 佔了 800 ms?

這次用了什麼?
  → prompt、index、model route、service version 分別是哪一版?

這次在哪裡偏離?
  → fallback、retry、tool denial、parser failure、validator failure 是哪一個?

這次可否和其他訊號對上?
  → trace_id 能否找到相同 request 的 log、deployment 與 audit event?

生產經驗顯示這個區別很重要。當一個多輪 Agent 在第 7 輪開始產出不相關的答案時,若 trace 只記錄「route 與 token count」,你知道用了同一個 model,卻無法追蹤「從哪一輪開始 context 被誤解」。真正有用的 trace 要記錄每一輪的 retrieval result 筆數、model 的 finish_reason、validator 在哪一輪開始失敗。

這不是空泛的擔心。一份針對生產環境 LLM 故障的整理指出,多輪 agent 系統進行到第十輪之後,準確率常見下滑 15% 到 30%——不是因為某一輪突然報錯,而是每一輪各自看都「技術上正常」,context 卻在多輪之間逐步偏離原本任務。同一份整理歸納出五種會被傳統監控放過的失敗樣態:HTTP 200 底下的靜默品質衰退、只能靠事後人工或 judge 模型抓到的幻覺內容、多輪 context drift、沒有 circuit breaker 保護讓成本失控的重試迴圈,以及被截斷輸出打壞的 tool call schema。

這五種樣態有一個共同點:全部發生在「技術成功」的外殼底下,靠 latency、error rate、throughput 三個傳統指標一個都看不出來,前面十一天燒掉 $47,000 的迴圈就是最極端的例子。這正是為什麼 model.chat 的欄位要包含 finish_reason——區分模型是自然結束、被 token 上限截斷還是被 stop sequence 打斷,決定了該在第幾輪開始懷疑 context 已跑偏,不必把整段對話重讀一次才知道問題出在哪。

但 trace 不會自動判定真相。

它不會因為你記錄了完整 model output,就知道答案是否有根據。

它也不會因為你有一個 score=0.9,就取代 Day 19 到 Day 21 的 dataset、evaluator 與人工 review。

可以把責任邊界畫成這樣。

Trace
  還原「系統做了什麼」

Evaluation
  檢查「結果是否符合定義的品質條件」

Human review
  處理 evaluator 無法可靠裁決,或風險較高的案例

因此 trace 應保留 evaluator 的結果與 case id,而不是假裝自己就是 evaluator。

這個界線說起來簡單,執行起來卻常被進度壓力打破——沒有現成 evaluator 時,工程師往往先在 answer.validate 裡寫一個「看起來還算合理」的簡易規則頂著用,然後因為一直沒出大問題,就從沒被換成 Day 20 那套正式的 evaluator。半年後追根究底時才發現,quality.status 這個欄位從頭到尾只是一個沒人審查過的 if 判斷式。

evaluation.case_id = policy-remote-work-014
evaluation.run_id = eval-2026-09-23-a
quality.status = fail

這讓你可以從 production-like failure 回到 regression dataset。

同時,它也避免把完整 golden answer 塞進每一條線上 trace。

三層責任如何對應到已經走過的 Day 19~21

把責任邊界具體對應到系列已經談過的內容,會更容易記住:

責任層 對應到系列哪一天 trace 該存的東西
Trace Day 23(本篇) 系統做了什麼:span tree、attribute、outcome 欄位
Evaluation Day 19~21(dataset、evaluator、judge) 結果是否符合品質條件:quality.status、evaluation.case_id
Human review Day 21 的人工複核流程 高風險或 evaluator 無法裁決的案例,回溯用的 case id

這張表的重點在於箭頭只能往一個方向流:production trace 可以把某個 case 的 id 送進 regression dataset,讓 Day 19 的 evaluator 拿它當新增測資;反過來,trace 不該試圖自己重新實作一套 evaluator 邏輯塞進 span attribute。每個服務都在 answer.validate 裡各自土法煉鋼判斷品質,很快會得到好幾套彼此不一致、也沒經過 Day 20 系統化驗證的「品質判斷」,還會被 dashboard 誤當成權威數字。

與 APM 的差異:trace 不是換皮的 APM dashboard

熟悉傳統 web service 的工程師,第一次接觸 LLM trace 時常有「這不就是我用慣的 APM 工具嗎」的錯覺。傳統 APM 確實也畫 span tree、也量 latency,但它假設的失敗模型是二元的:這次呼叫成功了,或拋出例外/逾時了;沒有「查詢成功但資料是錯的」這種狀態需要另外判定。

LLM workflow 打破了這個假設。model.chat 可以完美地成功——沒有例外、沒有逾時、HTTP 200——但輸出內容仍然是錯的。這正是 outcome schema 要拆成 technical / workflow / quality / safety / task 五個獨立欄位的根本原因:傳統 APM 的 span status 只有 OK 和 ERROR,天生無法表達「技術上 OK、語意上 ERROR」這種組合。直接套用 APM 心智模型設計 LLM trace,第一個會被犧牲掉的欄位幾乎必然是 quality.status,因為它在傳統世界裡根本沒有對應概念。

傳統 APM 的失敗模型
  span.status = OK | ERROR(二元)

LLM workflow 需要的失敗模型
  technical.status = ok | error         ← 程式碼層級
  quality.status   = pass | fail | not_evaluated   ← 語意層級
     ↑
  兩者互相獨立,APM 的二元模型無法表達這種正交關係

⑦ 一份夠用的 AI trace schema contract

不要一開始就把 vendor SDK 的欄位當成公司資料模型。

先寫一份自己能看懂、能測試、能遷移的 contract。

下面是可以放進 docs/telemetry-contract.md 的範例。

# AI Trace Contract v1

## Root span: POST /ask

必填
- request.id
- workflow.name
- workflow.version
- technical.status
- workflow.status
- task.status

禁止預設寫入
- user.email
- user.phone
- raw.prompt
- raw.response
- raw.retrieved_document

## retrieval.search

必填
- retrieval.index_version
- retrieval.result_count
- outcome

可選
- retrieval.query_hash
- retrieval.top_score_bucket

## model.chat

必填
- model.provider
- model.name
- model.route
- prompt.version
- input_token_count
- output_token_count
- outcome

可選
- ttft_ms
- retry_count
- cost_usd
- finish_reason

禁止預設寫入
- prompt.content
- response.content
- authorization_header

## answer.validate

必填
- quality.status
- safety.status
- citation.valid
- outcome

這份 contract 有三個用途。

第一,它讓開發者知道必填欄位,不必每次從 dashboard 反推缺什麼。

第二,它讓 reviewer 能檢查敏感資料邊界。

第三,它讓測試可以驗證「某個 failure 不會被收成神祕的 request_failed」。

這三個用途有一個共同前提:contract 必須是一份團隊真的會讀、會維護的文件,而不是寫完就進資料夾睡覺的規格書。比較有效的做法,是把它和 pull request review 綁在一起——新增 workflow 步驟時,contract 同步更新成 review checklist 的一部分,而不是等半年後有人發現欄位對不上才回頭補寫文件。

欄位名稱不必和任何產品完全一致。

若使用 OpenTelemetry,請在 mapping 層處理語意慣例與內部欄位的差異。

GenAI semantic conventions 仍在演進,欄位名稱與穩定度必須跟著官方規格確認;不要把實驗性欄位直接寫死成不可變更的公共 API。

這不是假設性的謹慎。OpenTelemetry 在 2024 年 4 月才成立專責的 GenAI SIG,一開始只限於 LLM client call 的 tracing,之後陸續擴展到 agent orchestration、MCP tool calling、content capture 與 quality evaluation。截至 2026 年 5 月,這組規範仍停留在官方標示的「Development」狀態;社群追蹤紀錄顯示,v1.37 到 v1.41 之間 GenAI 相關屬性名稱與結構幾乎每一版都有變動,不同語言、不同框架的 instrumentation 成熟度也不一致。

規範作者自己也承認一個沒有簡單解法的張力:把敏感的 prompt 與 completion 直接記在 span attribute 上,除錯方便但外洩風險高;完全不記,風險降低但診斷時什麼線索都沒有;折衷方案是把內容搬到外部儲存再參照回 trace,但這又是額外的工程投入。這正是第⑪節要處理的取捨,不是這篇文章想像出來的困境,而是 OpenTelemetry 官方規格本身尚未收斂的核心問題。

這呼應了本節開頭那句話:契約要自己維護,不要把 vendor SDK 的欄位當成公司資料模型。如果團隊把 GenAI semantic conventions 的屬性名稱寫死在 retrieve()、call_model() 內部,規範改了名稱或拆分欄位,商業邏輯就得跟著全部重寫。比較穩妥的做法,是讓內部 contract(例如上面的 AI Trace Contract v1)維持穩定,在獨立的 adapter 層把內部欄位映射到 OpenTelemetry 目前的慣例,規範演進時只需要改這一層 mapping。

# adapter 層:把內部穩定欄位映射到目前版本的 GenAI semantic conventions
# 這一層允許隨官方規格變動而改寫,商業邏輯完全不受影響

def to_otel_attributes(internal: dict) -> dict:
    return {
        "gen_ai.request.model": internal["model.name"],
        "gen_ai.system": internal["model.provider"],
        "gen_ai.usage.input_tokens": internal["input_token_count"],
        "gen_ai.usage.output_tokens": internal["output_token_count"],
        # 下面這行是 v1.41 之後才穩定下來的欄位名稱;
        # 若規範再變動,只需要改這裡,不必動 workflow.py
        "gen_ai.response.finish_reasons": [internal.get("finish_reason", "unknown")],
    }

讓 contract 能被測試守住,而不是只活在文件裡

一份寫在 markdown 裡的 contract,價值只到「大家看過一次」為止;沒有測試守著,六個月後某次重構很可能悄悄違反了它。比較務實的做法,是把必填與禁止欄位轉成一份可被程式讀取、能在 CI 裡跑的規則檔,讓「這次 pull request 有沒有意外洩漏敏感欄位」變成自動化檢查,而不是靠 reviewer 肉眼抓漏。

# scripts/validate_schema.py(概念示範)
FORBIDDEN_ATTRIBUTES = {
    "raw.prompt",
    "raw.response",
    "raw.retrieved_document",
    "authorization_header",
    "user.email",
    "user.phone",
}

REQUIRED_ROOT_ATTRIBUTES = {
    "request.id",
    "workflow.name",
    "workflow.version",
    "technical.status",
    "workflow.status",
    "task.status",
}


def validate_span(span: dict) -> list[str]:
    violations = []
    attribute_keys = set(span.get("attributes", {}).keys())

    leaked = attribute_keys & FORBIDDEN_ATTRIBUTES
    if leaked:
        violations.append(f"發現禁止欄位: {sorted(leaked)}")

    if span.get("name") == "POST /ask":
        missing = REQUIRED_ROOT_ATTRIBUTES - attribute_keys
        if missing:
            violations.append(f"root span 缺少必填欄位: {sorted(missing)}")

    return violations

這份腳本不是用來取代 code review,而是把 review 裡最容易憑印象判斷錯誤的那一部分——「這個 span 是不是不小心多記了敏感欄位」——變成一個幾毫秒就能跑完的機械檢查。把它接進 CI,任何 pull request 只要新增違反 contract 的 attribute,測試就會直接失敗。

schema contract 定下來之後,剩下的問題是:這套設計真的能在程式碼裡跑起來嗎?failure path 記得夠不夠細?trace 能不能跟 logs 對上?敏感資料的遮罩與 sampling 又該怎麼落地?下篇(下)會用一個可重跑的 FastAPI demo,把這些欄位一一實作出來,並附上完整的 DIY 驗收清單。

這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 22(下)|Golden Signals、RED、USE:框架是起點,不是儀表板模板
下一篇
Day 23(下)|LLM Observability:一條 Trace 要能還原工作流
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言