iT邦幫忙

2026 iThome 鐵人賽

0
AI Engineering

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

Day 32 特別篇(下)|Langfuse vs. LangSmith:AI Observability 要怎麼選、怎麼整合?

  • 分享至 

  • xImage
  •  

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

結論先說:把 Lab 跑成的 trace、決策表與 ADR 攤開來看,才是把「選 Langfuse 還是 LangSmith」從一場感覺題,變成一次有 owner、有退場條件的工程決策的最後一哩路。

承接上文:(上)先劃清責任邊界、列出選型該問的六個問題;(中)建立了中立的 AI telemetry schema,接上 OpenTelemetry、Collector 與 attribute allowlist,讓兩個平台能看到同一份被妥善遮罩過的 trace。這一篇接著把 Lab 升級成接近 production 的樣子,並把觀察結果收斂成可以被反駁、可以被追蹤的決策紀錄。

Step 12:把 direct OTLP exporter 限在 Lab

direct exporter 的好處是設定少,適合先確認 schema 和 UI。缺點也直接:應用程式要自己承擔 exporter queue、網路暫時失敗、retry 行為與目的地切換。對短命令列程式或本機 fixture,這沒問題;對 API request path,通常不是想要的耦合。

若 reader 想先跑最小 Lab,可在自己的虛擬環境安裝相符版本的套件,並查閱官方相容性矩陣:

uv add opentelemetry-api opentelemetry-sdk \
  opentelemetry-exporter-otlp-proto-http

接著只建立一次 provider。SimpleSpanProcessor 會讓每個 span 結束時直接 export,便於快速除錯;它不適合當成 production 預設。production 應優先使用 BatchSpanProcessor,再把批次、重試與 routing 放到 Collector,而不是在 request handler 裡同步送資料。

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor


def configure_lab_tracing() -> TracerProvider:
    provider = TracerProvider(
        resource=Resource.create(
            {
                "service.name": "answer-api",
                "service.version": "2026.09.22",
                "deployment.environment.name": "lab",
            }
        )
    )
    exporter = OTLPSpanExporter(
        endpoint=(
            f"{os.environ['LANGFUSE_BASE_URL']}"
            "/api/public/otel/v1/traces"
        ),
        headers={
            "Authorization": f"Basic {auth}",
            "x-langfuse-ingestion-version": "4",
        },
    )
    provider.add_span_processor(BatchSpanProcessor(exporter))
    return provider

這段有兩個故意保留給讀者確認的邊界。第一,auth 必須從 deployment secret 注入,不可硬編碼。第二,先檢查目前 Langfuse 官方文件的 endpoint 和 ingestion header,再採用本文範例;服務升版後,舊設定可能不再適用。

這一步驗證的重點其實不是「能不能連上 Langfuse」,而是「先讓自己親手感受一次 direct exporter 的限制在哪裡」。 很多團隊會直接跳過這個 Lab 階段,一步到位就上 Collector 架構,理由是「反正 production 本來就要用 Collector」。但先用最陽春的 direct exporter 跑一次,好處是能清楚看到:一旦 LANGFUSE_BASE_URL 打不通、或 Langfuse 那端短暫回應變慢,這個 exporter 的行為會直接影響你的 API request——因為它就跑在同一個 process 裡,沒有任何緩衝層。親手感受過這種耦合的痛點,才會真心理解 Step 13 要引入 Collector 的理由,而不只是照抄一份架構圖。

這裡最常見的坑,是把 SimpleSpanProcessor 直接帶進 production 程式碼裡,因為它在 Lab 階段「看起來運作得很好」。 它運作得好,正是因為 Lab 環境流量小、延遲容忍度高;一旦流量上升,每個 request 都要同步等待 export 完成,會直接拖慢 API 的回應時間——這也是為什麼程式碼註解特別點出 SimpleSpanProcessor 不適合當 production 預設。

第二個常見的坑,是前面提過「public_key:secret_key 中間那個冒號很容易被忘記」,但光靠提醒很容易在真的動手時又忘記一次。 與其每次都手動組字串,不如寫一個小函式把編碼過程和驗證包在一起,出錯時能立刻看出問題在哪,而不是等到 Langfuse 回傳一個含糊的認證錯誤才回頭猜:

import base64


def build_langfuse_auth_header(public_key: str, secret_key: str) -> str:
    if not public_key or not secret_key:
        raise ValueError("LANGFUSE_PUBLIC_KEY 或 LANGFUSE_SECRET_KEY 未設定")
    if ":" in public_key or ":" in secret_key:
        raise ValueError("public_key/secret_key 不應包含冒號,請檢查是否貼錯欄位")
    raw = f"{public_key}:{secret_key}"
    return base64.b64encode(raw.encode()).decode()

這個函式本身沒有解決根本問題——它一樣需要開發者記得呼叫它,而不是繼續手動拼字串——但它至少讓「忘記冒號」這類低階錯誤,從一個要花時間排查的認證失敗,變成一個在函式呼叫當下就會立刻噴出、訊息清楚的例外。把這種容易犯錯、卻很難從錯誤訊息反推原因的操作包成一個有驗證的小函式,是 Lab 階段值得養成的習慣,成本很低,省下的除錯時間通常划算得多。

Step 13:以 Collector 隔離應用程式與平台目的地

Collector 的價值不是「多一個容器」。它把接收、處理與輸出拆開,讓同一份 application instrumentation 可以送到不同 backend,也能把 retry policy 留在 platform layer。以下是 teaching configuration,讀者需要依自己的 Collector distribution、network、TLS 與 secret injection 改寫:

receivers:
  otlp:
    protocols:
      http:
      grpc:

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 256
  batch:
    timeout: 5s
    send_batch_size: 512

exporters:
  otlphttp/langfuse:
    endpoint: ${env:LANGFUSE_BASE_URL}/api/public/otel
    headers:
      Authorization: Basic ${env:LANGFUSE_OTLP_AUTH}
      x-langfuse-ingestion-version: "4"

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/langfuse]

它回答的是「怎麼讓 trace 到 Langfuse」,還沒有回答「Collector 壞掉時怎麼辦」。production 還要討論 Collector 的 availability、queue、disk buffer、authentication、egress policy、rate limit、monitoring 與容量。不要把 batch 看到就推論為一定不遺失資料;重啟、記憶體壓力和目的地長時間不可達時,行為取決於完整設定與版本。

這裡值得多想一層:Collector、SDK instrumentation、甚至任一 observability agent,都是額外裝進系統裡、常駐執行的元件——它們不是「看不見的旁觀者」,而是實實在在的新故障來源,會有自己的版本、自己的設定、自己的升級節奏。2024 年 7 月的 CrowdStrike 全球當機事故是這個道理被推到極端的版本:CrowdStrike 對其 Falcon Sensor(一款跑在 Windows kernel 層、用於端點偵測防護的常駐 agent)推送了一次設定更新(Channel File 291),內容錯誤觸發記憶體越界讀取,讓全球約 850 萬台 Windows 裝置同時進入開機迴圈;事後調查指出的關鍵缺陷之一,是這款 agent 完全沒有提供客戶延遲套用內容更新的機制——任何一次內容發布,都是對全體客戶同時、立即生效,沒有分階段(canary/staged rollout)的空間(Wikipedia 條目彙整的官方調查摘要)。OTel Collector 當然和 kernel-level 防護 agent 的風險等級不在同一個量級,但原則相通:只要它常駐在你的服務或 host 上,它的設定變更、版本升級、資源用量異常,就有機會變成你系統可用性的一部分。至少要能回答:Collector 的設定變更有沒有分階段推出?升級前有沒有在 staging 驗證過?exporter queue 塞滿時,會不會反過來拖慢甚至阻塞應用程式本身?如果答案是「還沒想過」,這就是上 production 前該補的項目,而不是等下一次事故才發現。

應用程式指向 Collector 時,endpoint 也要改成內部 OTLP receiver,而不是繼續直送 Langfuse:

export OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector:4318"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"

這裡再次強調:同一批 spans 不應同時透過 direct exporter 和 Collector exporter 發送。要比較兩種路徑,可以在不同實驗 run 或不同 environment 比較,不要在同一次 request 裡製造兩份看似相同的 evidence。

這一步是整個 DIY 裡,Step 12 種下的痛點正式被解決的地方。 Step 12 讓你親身體會「應用程式直連平台」的耦合有多脆弱;這裡把 receiver、processor、exporter 拆成三個獨立階段,驗證的正是③段落那句「誰維運…upgrade」的可操作版本:當 Langfuse 那端短暫不可達,batch processor 能不能把 span 先暫存、稍後重試,而不是讓應用程式的 API 回應也跟著卡住?當你想同時觀察 Tempo 和 Langfuse 兩邊的 trace,是不是只要在 exporters 底下多加一段設定,完全不必碰應用程式的一行程式碼?跑起來後,可以刻意把 otlphttp/langfuse 的 endpoint 改成一個打不通的位址,觀察應用程式的 API 回應時間有沒有受影響——如果完全沒受影響,代表 Collector 真的做到了它該做的隔離;如果應用程式跟著卡住,代表某個環節還是同步耦合在一起,值得回頭檢查。

前面提到的 CrowdStrike 案例,在這裡有一個更貼身的延伸提問:Collector 本身也是需要升版、需要改設定的常駐元件,它的升級流程有沒有分階段?如果答案是「還沒想過」,這正是這個 Lab 步驟該留給讀者去補的功課,而不是等到 Collector 真的在生產環境出過一次狀況才處理。

batch processor 到底保證了什麼、沒保證什麼

上面提醒過「不要把 batch 看到就推論為一定不遺失資料」,這句話值得拆成更具體的層次來理解,否則團隊很容易誤以為「有 batching」就等於「有可靠傳輸」。

應用程式 ──► Collector receiver ──► batch processor(累積到門檻或逾時才送)──► exporter ──► Langfuse
                                          │
                                          ├─ Collector process 在累積期間被重啟/OOM Killed
                                          │       → 記憶體裡還沒送出的那批 span 直接消失,沒有任何錯誤訊息
                                          │
                                          └─ exporter 目的地長時間不可達
                                                  → 沒有設定 disk-based queue 時,記憶體佇列滿了就開始丟資料
                                                  → 有設定 disk-based queue 時,佇列本身也有容量上限

batch processor 保證的只是「把多個 span 打包成一次網路呼叫,減少 request 數量」,它從來沒有承諾「這批資料在送出之前,即使 process 重啟也不會遺失」——這後者是完全不同的保證,通常需要額外設定 persistent queue(把還沒送出的資料先寫進磁碟,而不是只留在記憶體裡)才能拿到。這也是為什麼③段落把「誰維運…upgrade」列為 SRE Lab 該問的問題之一:Collector 的可靠性設定(disk buffer 大小、queue 容量、重試次數)跟它「看起來有沒有在運作」是兩件事,前者需要有人主動設定與測試,不會因為 YAML 檔案裡出現 batch 這個關鍵字就自動擁有。

跑這一步的 Lab 時,值得刻意做一次「粗暴」的測試:啟動 Collector、送出幾筆 request 讓 span 進到 batch 累積佇列裡,然後在它送出之前直接 kill -9 掉 Collector process,觀察 Langfuse 那邊究竟收到了多少筆——如果收到的筆數比預期少,這就是「batch 沒有搭配 persistent queue」這件事最直觀的證據,遠比讀設定文件更有說服力。

Step 14:讓 FastAPI instrumentation 管好 HTTP parent span

FastAPI handler 自己建立 root span,最常見的後果是 server span、workflow span 和 downstream span 變成三條斷線。先讓 framework instrumentation 建立 server span;業務程式只從 current context 開 child span。

uv add opentelemetry-instrumentation-fastapi \
  opentelemetry-instrumentation-httpx
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)

接著在 endpoint 呼叫既有的 answer_workflow()。workflow 函式不需接收人工組出的 parent ID;只要它在 request context 內呼叫,start_as_current_span() 就會以 server span 為 parent。若服務會從 queue worker、cron job 或 webhook 開始,請把每一種 entry point 畫出來。沒有 HTTP parent 的 worker 可以有自己的 root span,但仍要記錄 task ID 和 producer 的 correlation。

透過 HTTP client 呼叫下游服務時,也要啟用對應 instrumentation。實驗時可刻意檢查下游收到的 traceparent;若 header 被 proxy、CORS、custom client 或安全中介層移除,兩端各自都有合理的 trace,卻永遠拼不起同一條 request。

這一步驗證的是 Step 10 資料流圖裡那句容易被忽略的話:「當上游帶來合法的 traceparent,OpenTelemetry 會以它建立 child span;沒有或不合法時才建立新的 root span」。 這句話聽起來像實作細節,實際上決定了事故發生時,能不能把「使用者在 Browser 按下按鈕」和「AI workflow 內部某個 span 失敗」串成同一條可追溯的證據鏈。最推薦的驗證方式,是拿一個支援自訂 header 的工具(例如 curl -H)故意送一個格式錯誤的 traceparent,觀察 FastAPI instrumentation 是選擇忽略它另建 root span,還是直接讓 request 失敗——這個行為在不同版本、不同 instrumentation library 之間可能不一致,親手跑過一次,勝過憑印象假設。

這裡最容易被忽略、卻最常在真實 incident 裡發作的坑,是 header 在跨越某個中介層時被悄悄剝除。 Nginx、API Gateway、CORS 設定、或是公司自己寫的安全中介層,都可能因為預設的 header allowlist 沒有包含 traceparent,把它濾掉而不留下任何錯誤訊息。發生這種狀況時,兩端各自都有一條「看起來正常」的 trace,卻永遠對不上同一個 trace_id——這種斷裂在平常開發時幾乎不會被發現,只有在事故發生、真的需要跨服務追蹤時,才會突然變成調查的死路。跑這個 Lab 步驟時,值得刻意在自己的 proxy 或 gateway 設定裡確認一次 header allowlist,而不是假設它預設就會放行。

Step 15:把 AI workflow 拆成可回答問題的 span tree

只寫一個很大的 answer-workflow span,日後只能回答「慢」。下列切法讓 trace 至少能回答「慢在哪一段、失敗前做了什麼、哪個版本參與」:

POST /ask
└─ ai.workflow
   ├─ prompt.build
   ├─ retrieval.search
   ├─ model.generate
   ├─ tool.policy_check
   ├─ tool.execute
   ├─ response.parse
   └─ response.validate

對每個 span 問兩個問題:它的輸入輸出是否能安全記錄?它對 failure classification 有沒有幫助?若答案都是否定,就不要只是為了畫出漂亮瀑布圖而增加 span。

def answer_workflow(question: str, request_id: str) -> dict:
    with tracer.start_as_current_span("ai.workflow") as workflow:
        set_safe_attributes(
            workflow,
            {
                "ai.workflow.name": "policy-qa",
                "ai.workflow.version": "v2",
                "deployment.id": "candidate-17",
            },
        )

        with tracer.start_as_current_span("retrieval.search") as retrieval:
            documents = retrieve(question)
            set_safe_attributes(
                retrieval,
                {
                    "retrieval.index_version": "policy-v5",
                    "retrieval.result_count": len(documents),
                },
            )

        with tracer.start_as_current_span("model.generate") as generation:
            answer = call_model(question, documents)
            set_safe_attributes(generation, {"model.route": "primary"})

        with tracer.start_as_current_span("response.validate") as validation:
            result = validate(answer, documents)
            set_safe_attributes(
                validation,
                {
                    "ai.quality_status": result.status,
                    "ai.task_status": result.task_status,
                },
            )

        return {"answer": answer, "quality_status": result.status}

如果 call_model() 發生 timeout,span 要記錄 error status 和受控的 failure class,而不是把整個 exception text、request body 或 provider response 原封不動放進 attribute。完整診斷仍可留在受存取控制的 structured log,並以 request_id、trace_id 查找。

為什麼要切成七個 span,而不是三個或十五個? 這是這一步最容易被問到、也最值得說清楚的設計決策。切分粒度沒有一個放諸四海皆準的正確數字,但有一個可操作的判斷準則:每多切一個 span,都要能同時回答「它的輸入輸出能不能安全記錄」與「它對故障分類有沒有幫助」。以 prompt.build 為例,它幾乎不花時間、也很少單獨失敗,切出來的價值在於:一旦發現某次 request 的延遲異常集中在這裡,能立刻排除是 retrieval 或 model 的問題,把 debug 範圍縮小一半。反過來,如果把 response.parse 和 response.validate 硬要再切成五個更細的 span,卻沒有一個能單獨對應到一種可能的故障模式,那就只是在製造更多要維護的程式碼,卻沒換來更多能回答的問題。

跑這段程式碼最容易產生的誤解,是以為切出很多 span 等於「做了很細的 observability」。實際上,一個沒有意義切分的深瀑布圖,反而會讓事故現場的 on-call 花更多時間爬過一堆對故障分類毫無幫助的節點——這也是為什麼③段落一路強調的「敏感資料要遮罩」在這裡有第二層意義:多一個 span,不只是多一個記錄點,也是多一個可能不小心夾帶敏感資料的地方。切分粒度和資料治理,其實是同一個設計決策的兩面。

把「每個 span 要能對應到哪一種故障模式」講清楚,比空泛地說「切得夠細」更容易在 code review 時被檢驗:

Span 這一段慢/壞,通常代表 值班工程師該往哪查
prompt.build 模板渲染邏輯本身有問題,或依賴的設定/字典載入異常 檢查最近一次 prompt 模板或設定變更
retrieval.search index 版本問題、向量資料庫延遲、查詢條件錯誤 對照 retrieval.index_version,查最近一次 index 重建紀錄
model.generate provider 延遲或降級、model route 切換、token 超限 查 model.route、provider 的 status page
tool.policy_check policy engine 規則變更、規則衝突 查最近一次 policy 規則變更紀錄
tool.execute 外部工具或 API 本身故障、權限被拒 查外部依賴的 health check、audit log
response.parse 模型輸出格式漂移,parser 邏輯跟不上 比對最近幾筆失敗案例的原始輸出格式
response.validate evaluator 邏輯或門檻設定本身有問題 查 evaluator 版本、Step 16 的 rule/judge 分歧紀錄

這張表格的價值不在於窮舉所有可能——真實世界的故障永遠比任何表格列得更多——而在於它逼著設計 span tree 的人,在動手切之前先想清楚「這一段的失敗模式,跟其他段是不是真的不一樣」。如果兩個相鄰的 span 在這張表的「值班工程師該往哪查」欄位裡填的答案幾乎一樣,這通常就是一個訊號:它們或許該合併成一個 span,而不是繼續維持不必要的切分。

這張對照表也回過頭來解釋了一件事:為什麼①段落一直強調「觀測責任邊界」要先劃清楚。同一個 span 樹裡,retrieval.search 出問題通常是資料團隊的責任範圍,tool.execute 出問題通常是外部依賴或整合團隊的責任範圍,response.validate 出問題則多半回到寫 prompt/evaluator 的團隊手上。如果 Day32.md 選的平台無法讓每個團隊只看到自己需要負責的那幾種 span、卻要求他們爬完整條 trace 才能定位問題,這其實是在用工具設計去稀釋原本已經講清楚的責任邊界。

Step 16 之後:把 evaluator 的分歧率也變成一條可以告警的指標

reconcile_evaluators() 回傳的 disagreement 狀態,如果只是寫進待複核佇列就結束,很容易變成一個沒人回頭看的死信匣。比較務實的做法,是把「分歧率」本身也做成一個 Prometheus 指標,讓它跟②段落提到的技術成功率一樣,可以設定告警門檻:

from prometheus_client import Counter

EVALUATOR_AGREEMENT = Counter(
    "ai_evaluator_agreement_total",
    "rule-based 與 LLM-as-judge 的一致/分歧次數",
    ["agreement_status"],  # agreed_pass / agreed_fail / disagreement
)

def record_evaluation(reconciled: dict) -> None:
    if reconciled["status"] == "disagreement":
        EVALUATOR_AGREEMENT.labels(agreement_status="disagreement").inc()
    else:
        status = "agreed_pass" if reconciled["pass"] else "agreed_fail"
        EVALUATOR_AGREEMENT.labels(agreement_status=status).inc()

當 disagreement 占比忽然從平常的 3% 跳到 20%,這通常不是「evaluator 壞了」這麼單純——常見的根本原因,包括模型升級後輸出風格改變導致 rule-based 的正則比對失效,或是最近調整過 judge 的 prompt 卻沒有重新校準門檻。把分歧率變成一條可觀測的時間序列,才有機會在「兩邊評分開始對不上」的當下就發現,而不是等到有人抽查才注意到 evaluator 本身已經悄悄跟系統行為脫節。

Step 16:同時檢查技術成功與語意失敗

AI workflow 最容易被 HTTP 200 騙過。以下兩個 fixture 都可能回傳 200,卻不該得到相同 release decision:

案例 技術結果 語意/任務結果 trace 應顯示什麼
policy-correct parser 成功、response 送出 必要事實和來源都存在 quality=pass、task=completed
policy-missing-approval parser 成功、response 送出 漏掉主管核准這個必要條件 quality=fail、task=failed
provider-timeout timeout 或 fallback 使用者可能拿到降級結果 technical=degraded、model route、fallback
tool-denied policy engine 正常拒絕 高風險 action 未執行 safety=pass、task=requires_review

先把 fixture 寫在你的 dataset 或 test data。對正常案例跑成功一次不代表 evaluator 有用;一定要有預期失敗的案例,否則你只是在測試「它會不會把所有答案判為好」。

一個簡單的 rule-based evaluator 可以先把 release gate 中不能討價還價的條件寫清楚:

def evaluate_required_source(output: dict, reference: dict) -> dict:
    citations = set(output.get("citations", []))
    required = set(reference["required_sources"])
    passed = required.issubset(citations)
    return {
        "key": "required_source_present",
        "score": 1 if passed else 0,
        "comment": "all required sources present" if passed else "missing source",
    }

LLM-as-judge 可補上「理由是否貼近問題」這類語意判斷,但它也有 model、prompt、溫度與版本。當 rule evaluator 和 LLM judge 互相矛盾時,這是應被人工審查的資料點,不是把較高的分數挑出來。

這張表格與這段 evaluator 程式碼合起來,驗證的正是①段落一開頭那個「機器人核准了一個不存在的流程」的具體情境。 表格裡的 policy-missing-approval 案例,技術結果完全正常(parser 成功、response 順利送出),如果團隊只看 Prometheus 的 5xx 比例或 latency,這筆 request 會被算成一次成功的服務——這正是本文反覆強調「HTTP 200 不代表 AI 系統成功」的具體落地案例,而不再只是抽象論述。evaluate_required_source() 這個 rule-based evaluator 的價值,不在於它有多聰明,而在於它把「哪些條件是絕對不能妥協的」寫死成程式碼,不受任何一次 LLM-as-judge 的情緒或溫度影響——這是 release gate 裡最該優先建立的一層,因為它的判斷結果可重現、可解釋、也不會因為 judge model 換了版本而悄悄漂移。

跑這段程式碼時,容易忽略的一個細節是:evaluate_required_source 只回答「必要來源有沒有出現」,它完全沒有能力判斷「這個來源用得對不對」。這正是為什麼要搭配 LLM-as-judge 補上語意層的判斷——但兩者的分數若互相矛盾,不能直接挑分數高的那一個當作最終結果,而是要把這種矛盾本身當成一筆需要人工複核的資料。跳過這一步、直接自動選較高分,等於是把 release gate 的最後把關權力,讓給了一個你連判斷依據都說不清楚的黑盒子。

「不能直接挑分數高的」講起來是原則,實際上要有個地方把矛盾接住,否則這句話也只是停在會議室裡的口號。下面是一個最小可行的做法:兩個 evaluator 都跑完之後,只要結果不一致,就把這筆案例連同兩邊的判斷依據一起寫進一個待複核佇列,而不是讓流程默默地選一邊:

def reconcile_evaluators(
    rule_result: dict, judge_result: dict, sample: dict
) -> dict:
    rule_pass = rule_result["score"] == 1
    judge_pass = judge_result["score"] >= 0.7

    if rule_pass == judge_pass:
        return {"status": "agreed", "pass": rule_pass}

    # 不一致:不自動選任何一邊,寫進待複核佇列
    return {
        "status": "disagreement",
        "pass": None,
        "needs_human_review": True,
        "rule_evaluator": rule_result,
        "llm_judge": judge_result,
        "sample_id": sample["request_id"],
    }

這段程式碼刻意把「不一致」設計成一個獨立的第三種狀態(disagreement),而不是強迫每次都要吐出一個 true/false——這正是在防止一種常見的隱性 bug:如果 reconcile_evaluators 的回傳值型別只能是布林,工程師遲早會為了讓程式碼「能跑」,寫出某種隱藏的 tie-breaker 邏輯(例如「兩個都不通過才算失敗」),而這個 tie-breaker 邏輯一旦寫下去,就會在沒有人真正決定過的情況下,變成事實上的 release gate 規則。把「不一致」當成一等公民的狀態值,才能確保這種決策真的被一個人看過,而不是被程式碼的型別系統意外地做掉。

Step 17:用同一份報告比較,而不是用感覺選平台

Lab report 應該把兩個平台放在相同問題下比較。以下模板可直接放進 repository,但本文不替讀者建立檔案:

# LangSmith vs Langfuse Lab Report

## Scope
- Workflow: policy Q&A RAG
- Dataset: policy_task_dataset_v1
- Build: candidate-17
- Data classification: masked Lab fixture only

## Trace investigation
- Can I find the root trace from request_id?
- Can I identify prompt, index, model route and validator outcome?
- Can I see the semantic-failure fixture without exposing its prompt?

## Evaluation and release
- Which evaluator ran, with which version?
- Does the candidate regress a required fact or safety case?
- Who reviews a disagreement?

## Operations
- Who owns retention, backup, upgrade and incident response?
- What is the estimated ongoing platform work?
- Which system is the primary investigation source during an incident?

## Decision
- Primary platform:
- Evidence and owner:
- Revisit date:

「UI 比較喜歡」可以是決策資料,但不該是唯一資料。真正有用的 report 會在 incident 來時讓下一位 on-call 知道:從哪裡進去、什麼 metadata 能找、哪份 dataset 可重跑、誰能核准 release。

這份模板的四個區塊,其實對應到前面四個不同段落各自留下的懸念。 Scope 對應 Step 2 強調的「先寫清楚 Lab-only boundary」;Trace investigation 對應 Step 6 那六項驗收,把「能不能找到、看得懂、沒外洩」變成一份可以被檢查、可以被追問的清單,而不是驗完就丟的暫時性動作;Evaluation and release 對應 Step 8 的 dataset 比較,逼你把「誰能核准 release」這個常被略過的問題寫清楚 owner;Operations 則是把③④⑤段落反覆強調的維運負擔,從「感覺自架比較累」變成一份具體要填的欄位。四個區塊合起來,剛好就是①段落開頭那張「Infrastructure Reliability/AI Workflow Reliability」責任邊界圖的落地版本——每一區塊都在回答「這條責任邊界具體長什麼樣子」。

為什麼要求這份 report 明確寫出「誰能核准 release」,而不是留給團隊默契決定? 因為選型會議最後常見的失敗,不是選錯平台,而是選完之後沒有人真正被授權在下一次 candidate 出現分歧時做決定,於是回到「感覺哪個比較順眼」的原點。把這個問題寫進模板的固定欄位,等於強迫團隊在還沒有壓力的時候(不是在半夜的 incident 現場)就把責任講清楚。

Step 18:把 operational overhead 算進成本,而非藏在免費方案後面

Langfuse 的 self-host 路線帶來較多資料控制權,也把一組實際服務交到你手上。至少要盤點 web/ingestion、worker、Postgres、ClickHouse、Redis 或 Valkey,以及 object storage;實際組件和資源需求要以所選版本的官方 deployment 文件為準。

維運問題 不能只回答的話 需要留下的證據
backup 「有 volume」 backup 頻率、還原步驟、最近一次 restore 演練
retention 「之後再清」 哪些資料多久刪、誰核准例外
upgrade 「compose pull」 版本相容性、rollback、maintenance window
access 「只有內網」 project role、SSO/帳號流程、audit event
incident 「DevOps 會看」 on-call、dashboard、runbook、escalation
sampling/schema 演進 「照官方文件設定就好」 gen_ai.* 屬性升版時的相容性測試紀錄、attribute mapping 的最近一次檢查日期

最後一項容易被忽略,卻是自架團隊特有的額外負擔:managed 平台的供應商會自己追蹤 gen_ai.* 規格的演進、自己決定何時跟進新版本;自架團隊如果直接照抄某個時間點的官方範例,卻沒有人排期去追蹤規格更新,遲早會遇到「舊版屬性名稱悄悄被棄用,但沒人注意到」的狀況——這正是②段落建議先做一層 adapter(如 ai_semconv.py)的原因:把「規格會變」這件事,變成一個集中維護的小檔案,而不是散落在幾十個 handler 裡、沒有人記得要一起改的技術債。

local Docker Compose 很適合學資料流與 UI;它沒有自動給你 HA、異地備援、容量管理或還原能力。這不是 Langfuse 的缺點,而是所有 self-hosted stateful platform 的正常帳單。若團隊只想先改善 AI debug 與 evaluation loop,managed platform 可能是較好的起點。

這張表格刻意把「不能只回答的話」和「需要留下的證據」並排放,用意是讓維運責任從一句安慰性的口頭承諾,變成可以被稽核的具體產出物。 這正是把 GitLab 2017 年事故的教訓提前應用在自己系統上的方式——當年那四層備援理論上都存在,實際上沒有一層被驗證過真的能還原,原因就是每個環節都停在「有 volume」「應該有備份」這種口頭層次。跑這一步的具體做法,是真的去找一次 Postgres 或 ClickHouse 的 restore 演練紀錄;如果找不到,這一格暫時先空著,比硬填一個「應該沒問題」更誠實,也更能提醒團隊這是一個真正的缺口,而不是已經解決的問題。

Step 19:設計短期雙寫入時的退場條件

有些團隊確實需要雙寫入,例如從 A 轉往 B、比較合規控制,或評估一個新 trace schema。此時雙寫入必須是有結束日期的實驗,而不是「先都留著」。

開始條件:有比較假設、範圍、資料分類與 owner。
期間:只收固定 workflow、固定 sampling 或去識別 fixture。
成功條件:兩邊都能回答預先列出的 investigation 問題。
失敗條件:成本、延遲、資料外送或 evidence drift 超過門檻。
結束條件:指定 primary source,停止另一條 application export。

不要在 production 全量雙寫入後,才發現兩邊 prompt masking 規則不同。這會讓「同一條 trace」在兩個 UI 看到兩個版本,incident review 只剩下資料對帳工作。

這五個條件清單驗證的是⑥段落那個「為什麼先都留著比選錯更危險」的論點,能不能真的落成一份可執行的實驗設計,而不是永遠停留在「之後再說」的狀態。 開始條件裡的「比較假設」尤其關鍵:如果雙寫入的理由只是「多留一個選項比較安心」,這不算是一個可被驗證的假設,因為它永遠不會有「成功」或「失敗」的判定標準——沒有明確假設的實驗,注定沒有明確的結束日期。反過來,如果假設寫成「驗證 Langfuse 的 OTel-native exporter,能否在不改動應用程式碼的前提下取代目前的 LangSmith SDK 呼叫」,這句話本身就自帶了成功與失敗的判準,雙寫入才有機會真的走到「結束條件」那一步,而不是變成永久掛著的技術債。

Step 20:故障時先用哪條證據鏈?

以下 runbook 適合放在 Lab report 的最後。它刻意不把任何單一平台當成萬能入口:

  1. 使用者回報錯誤時,先以 request_id 在 structured log 找技術 outcome。若 log 裡的 trace 識別碼查不到對應資料,先排除 Step 6 提過的四種可能——格式錯誤、context 洩漏、取樣或匯出失敗、tenant/時間不一致——不要直接假設「這個工具壞了」。
  2. 用同一個 request_id 或 trace link 找 OTel root trace,確認 service、dependency、latency 和 error span。若這條 trace 完全找不到,先確認它是不是本來就沒被抽中保留(⑥段落的取樣數學)——這跟「trace 系統故障」是兩種不同的缺口,前者需要調整取樣規則,後者才需要叫醒平台的 on-call。
  3. 若問題是 prompt、retrieval、model、tool 或 validator 的決策,再進 LangSmith 或 Langfuse 的 AI workflow trace。
  4. 若是已知 quality/safety failure,查 dataset、evaluator 版本與最近一次 candidate 結果。
  5. 若要修正,將遮罩後的代表性 evidence 轉成 regression case;不要只在 UI 裡加一個自由文字備註。

這個順序避免兩種浪費:拿 Grafana 查 prompt 漏掉哪個條件,或拿 AI trace 追一個 DNS timeout。每個工具都被放在它最能回答的問題上。

這份五步 runbook 是這整節 DIY 最後要交付的核心產物:它把①段落一開頭那句「分清楚這兩層責任邊界,決定 on-call 接手事故的第一分鐘該打開哪一個分頁」,變成一份可以真的貼在 wiki 上、事故當下照著走的清單。 值得留意的是,這份 runbook 完全沒有把任何單一平台放在第一步——不是因為 Langfuse 或 LangSmith 不重要,而是因為它們回答的問題(prompt、retrieval、model 的決策為什麼錯)通常是第三層才需要問的問題,前面兩層(技術結果、跨服務因果)如果先確認清楚,往往能大幅縮小往下追查的範圍,避免在還沒確認「這到底是不是 AI workflow 的問題」之前,就一頭栽進 AI trace 的細節裡。

Step 21:在切換 primary platform 前做一次桌上演練

選型完成前,找一個真實但已遮罩的 scenario 做 30 分鐘桌上演練。參與者不必多:一位 application engineer、一位 on-call 或 platform owner,再加一位能決定資料/release 風險的人就夠了。

演練題目可以是:「新版 prompt 讓政策問答的 HTTP 200 維持正常,但 policy-missing-approval 的 required source fail 增加。使用者已送出負評。」要求每個人只根據已記錄的 evidence 回答下列問題:

  1. 第一個告警或使用者訊號從哪裡來?
  2. 如何由 request_id 找到技術 trace 與 AI workflow trace?
  3. 哪個 prompt、index、model route 和 deployment 參與這一筆 request?
  4. 這是 availability incident、semantic regression,還是兩者皆有?
  5. 哪個 evaluator 的結果足以阻擋 rollout?哪個結果只需要人工複核?
  6. 若 trace payload 含有不該存的資料,誰負責停止外送與清理?
  7. rollback 後,要把哪一筆 evidence 加回 regression dataset?
  8. 如果這筆事故的 trace 剛好沒被取樣保留下來,下一步該怎麼查?(提示:這題沒有標準答案,它要測的是團隊有沒有為「trace 找不到」準備一條備援路徑,例如退回 structured log 裡的欄位化 fallback 資訊,而不是卡在原地)

演練最有價值的產物通常不是「選 Langfuse」或「選 LangSmith」。而是找出查不到資料、責任人不明、mask 規則沒測到,或兩個平台結果不同的地方。那些缺口才是上 production 前該修的項目。

為什麼要在切換平台之前做一次演練,而不是等真的出事再說? 因為選型決策一旦定案,接下來幾個月甚至幾年的事故調查流程都會建立在這個決策之上;如果流程裡藏著查不到資料、或責任人不明的缺口,最壞的發現時機就是真正的事故現場——那時候不只要處理眼前的問題,還要一邊摸索「這個資訊到底該去哪裡找」。這份演練的第 6 題(trace payload 含有不該存的資料,誰負責停止外送)尤其值得認真回答,因為它直接對應三星 ChatGPT 事故的教訓:外洩發生之後,「誰有權限、誰知道怎麼立刻停止」往往比「事後檢討」重要得多。花 30 分鐘在會議室裡發現這些缺口,成本遠低於在真實事故裡才發現。

Step 22:為選型結果留下可變更的紀錄

平台決策不是永久宣言。把結果寫成一份有日期的 ADR 或 change record,至少包含下列欄位:

Decision: primary AI observability platform
Scope: environment、workflow、資料分類
Why now: 目前的資料治理與團隊能力
Rejected alternatives: 未選方案及原因
Evidence: Lab report、fixture、evaluation、桌上演練結果
Owners: application、platform、data governance、release
Revisit: 觸發條件與下一次檢查日期

觸發重新評估的事件可以很具體:資料分類改變、retention 要求改變、Collector 改版、平台成本超過預算、incident 無法順利調查,或團隊開始有能力維運 self-hosted data service。保留這份紀錄,半年後才不會有人把當時的權衡誤讀成「大家一直都比較喜歡這個工具」。

為什麼一定要有「Revisit」這個欄位,而不是把 ADR 寫成一次性結案報告? 因為選型當下成立的每一個前提,都會隨時間鬆動。回到前面案例情境裡那次事故:如果團隊在三個月前選 LangSmith 的理由之一是「團隊還沒能力維運自架的資料服務」,而事故發生後花了兩個月訓練出一名熟悉 ClickHouse 的工程師,這個前提就已經不成立了——但如果沒有 Revisit 欄位提醒「團隊維運能力改變時要重新評估」,這份決策可能會被當成永久答案,一路沿用到下一次類似事故發生、卻拿不出更細緻的 retrieval index 追蹤能力時,才被迫重新討論。ClickHouse 在 2026 年收購 Langfuse 這類產業事件,也是典型的觸發點之一:收購案本身不代表舊決策是錯的,但代表 Rejected alternatives 欄位裡「self-hosted 維運負擔過高」這個理由,可能因為母公司資源挹注而有了變化,值得被排進下一次 Revisit 的討論議程,而不是被忽略。

這一步最終驗證的,其實是整節 DIY 的核心主張:選型是一個需要持續維護的工程決策,不是一次性的採購決定。 如果讀者跑完 22 個 Step,最後只留下一句「我們選了 X」,代表這份 Lab 沒有真正被使用——它應該留下的,是一份任何人都能在六個月後打開、看懂「當初為什麼這樣選」、並且知道「現在該不該重新討論」的活文件。

讀者完成後應能驗證

  • 能說明 Langfuse/LangSmith 與 Prometheus/Grafana/Loki/OTel 的責任邊界。
  • 中立 schema 能關聯 request_id、trace_id、task_id 與版本資訊。
  • OpenTelemetry request/workflow/retrieval/validator span tree 可被追查,且只透過一條 primary export path 傳送至 Langfuse。
  • OTLP endpoint、Basic auth、version header、遮罩與 flush/shutdown 的責任邊界都已寫下。
  • downstream HTTP call 能保留 W3C Trace Context;request_id 只作業務 correlation。
  • direct exporter 僅作 Lab;staging/production 的 Collector、batching、retry、routing 與 exporter failure isolation 有設計責任人。
  • 同一正常與 semantic-failure fixture 可在 Lab 中被比較,而非只看漂亮的 happy path。
  • 自架設計已揭露資料、secret、retention、backup、access control、upgrade 與 incident ownership 的責任。
  • 至少一份 dataset 與一種 evaluator 可用於比較 baseline/candidate。
  • 選擇 primary platform 時,有資料治理、維運能力與 workflow 證據,而不是品牌偏好。
  • 能分辨「trace 找不到」的兩種成因——本來就沒被取樣保留,或平台/pipeline 真的故障——並知道兩者該找誰處理。
  • 對 gen_ai.* 這類仍在演進中的 semantic conventions,有一層集中管理的 adapter,而不是把尚未穩定的欄位名稱直接散落在整個 codebase。
  • 本文未建立 Langfuse stack、未執行雙寫入、未呼叫 provider,也未驗證任何 production outcome。

把這份清單再讀一遍,會發現它其實不是「有沒有做完 22 個 Step」的打勾表,而是把①到⑥每一段留下的問題,重新排成可以自我檢查的形式。第一項對應①的責任邊界;第二、三項對應 Step 1 與 Step 15 的 schema 與 span 設計;第四到第六項對應 Step 3、5、12、13 反覆強調的「Lab 與 production 的邊界」;第七項對應 Step 16 的技術成功 vs 語意失敗;第八項對應③、⑤反覆討論的自架維運責任;第九、十項對應整個評估與選型流程;最後一項則是刻意的誠實聲明——這份清單能不能打勾,取決於讀者自己有沒有真的動手做,而不是讀過文章就自動達成。如果讀完這一整節,發現某幾項完全無法勾選,那正是這份 DIY 該發揮作用的地方:先回頭補那個 Step,而不是跳過去看結論。

⑧ 本文結論

LangSmith 很適合快速建立 AI tracing、evaluation 與 release workflow;Langfuse 值得自架、資料主權與 OTel-first 團隊優先評估。兩者不是非黑即白的競爭,而是不同部署與治理策略下的工程選擇。

不論選哪個,可靠性模型都應保持自己的形狀:從使用者旅程、SLI/SLO、metrics、logs、traces,一路連回 prompt、model、retriever、tool、evaluation、safety 與 release decision。工具可以替換,這條證據鏈不能斷。

回頭看這篇文章引用的幾個案例,其實在講同一件事的不同切面。GitLab 2017 年的事故告訴我們,備援機制沒有 owner、沒有被定期驗證,就只是心理安慰;三星 2023 年的 ChatGPT 外洩事故告訴我們,好用的工具會鼓勵人把完整上下文送出邊界,靠自律規範攔不住這件事,只有技術層的強制遮罩才靠得住;CrowdStrike 2024 年的全球當機告訴我們,任何常駐在系統裡的 agent,不論它的初衷是保護還是觀測,本身都是一個需要被納入變更管理的新故障來源;Uber 2015 到 2017 年間的 tracing 架構演進告訴我們,context propagation 的正確性不是「手刻得夠仔細就沒事」,而是並行模型一旦變了(thread pool 換成 async IOLoop),原本湊合能用的假設會整個失效;OneUptime 對 trace sampling 的量化分析告訴我們,「trace 找不到」跟「trace 系統壞了」是兩種完全不同的缺口,分不清楚只會讓 on-call 找錯方向;而 ClickHouse 收購 Langfuse、LangChain 完成 Series B 融資,則從另一個角度證明——「自架換資料主權」與「託管換維運省力」這兩條路線,都不是工程師自己的一廂情願,而是在真實市場裡各自站得住腳的策略。這些案例橫跨了硬體當機、資料外洩、市場資本、乃至於一段十年前的架構演進史,表面上和「選 Langfuse 還是 LangSmith」離得很遠,但拆解到底層,講的都是同一句 SRE 最基本的話:可靠性不是一次性買到的功能,而是持續被驗證、持續有人承擔責任的過程。

這也是為什麼本文從頭到尾沒有給出一個「請直接選這個」的答案。Day 31 把 LangSmith 接成一條完整的 trace → dataset → evaluation → release decision 迴圈,證明了這條迴圈的價值;這篇文章接著問的,不是「這條迴圈該用哪個工具蓋」,而是「你的團隊、你的資料、你的組織能力,適合把這條迴圈的地基蓋在哪裡」。答案因團隊而異,但拿來回答這個問題的方法——先問責任邊界、再問工程條件、再用真實 Lab 驗證、最後留下可被追蹤的決策紀錄——這套方法本身,才是這篇文章真正想留下的東西。

延伸閱讀


這篇是 Learning SRE for the AI Era 系列的一部分。從 Day 31 把 LangSmith 接成 trace → dataset → evaluation → release decision 的迴圈,到這篇比較 Langfuse 與 LangSmith 的治理與維運條件,核心問題始終沒變:你要用什麼證據,決定下一個版本能不能上線。

工具會換、平台會換,但這條從使用者旅程一路連回 prompt、model、retriever、evaluation 與 release decision 的證據鏈,必須自己保持完整。

Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 32 特別篇(中)|Langfuse vs. LangSmith:AI Observability 要怎麼選、怎麼整合?
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 58 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言