生活分享
追蹤、評測與可觀測性:知道流程哪一步出錯
多模型工作流串起幾個步驟後,一步答錯或變慢,只看最後輸出看不出問題在哪,必須每一步都留下可查的紀錄。本篇示範只用 Python 標準函式庫自寫一支 JSONL 追蹤器,記下每次呼叫的輸入雜湊、模型、token 數、延遲與結果,再用離線評測集把新舊兩版流程跑過同一批題目,比較分數與退步清單,也對照 OpenTelemetry 的 GenAI 語意慣例命名,讀完能不接追蹤 SaaS 就看出哪一步出錯、哪一版更好。
閱讀時間約 9 分鐘

多模型工作流只要串起三、四個步驟,任何一步答錯或變慢,光看最後顯示給使用者的那一句話完全找不出問題出在哪一步;解法是替每一步都留下可以查、可以重放的紀錄,再用同一批題目跑過新舊兩個版本比較分數,而不是憑印象覺得這次感覺比較順。
本篇會示範只用 Python 標準函式庫寫一支輕量的追蹤器(tracer),替流程裡每一次呼叫記下輸入雜湊、用了哪個模型、token 數、延遲與結果,一行一筆存成 JSON Lines;接著建立一組固定的離線評測集,把新舊兩版流程跑過同一批題目、逐題比對分數與退步清單。需要 Python 3.11 以上,範例只用 hashlib、json、time、functools 與 pathlib 這些標準函式庫,不必安裝套件、不必連網,也不必先申請任何供應商的 API 金鑰就能整段跑完;文中也會對照 OpenTelemetry 的 GenAI 語意慣例,說明它的屬性命名和目前的穩定性狀態,留著以後要接上正式觀測系統時參考。以下引用的官方文件查證於 2026 年 9 月。
為什麼要追蹤每一步,不能只看最後結果
把一件事拆成好幾個模型接力處理之後,任何一步都可能出錯:路由選錯模型、某一步逾時、回傳的結構化輸出解析失敗,或者前面步驟的結果被後面誤用。使用者只看得到最後顯示出來的那一句話,開發者如果也只盯著最終輸出,遇到問題只能整段重跑、用猜的縮小範圍,沒辦法直接指出是哪一步、哪一次呼叫出的錯。
代理系統維運(AgentOps)是什麼一文,已經談過怎麼把一個任務的完整執行軌跡串起來看,區分回答成功與操作成功;本篇要往下一層,講清楚追蹤裡一步具體該記哪些欄位、格式怎麼設計,並且自己動手寫一支不依賴任何服務的追蹤器,讓小型專案不必先評估、採購一整套觀測平台,也能從今天開始留紀錄。
每一步該記什麼欄位
要能回頭指出問題出在哪,每一次呼叫至少要記六件事:這一步屬於流程裡的哪個階段、輸入的雜湊、實際呼叫了哪個模型、輸入與輸出各自的 token 數、從送出到拿到結果經過的延遲,以及這次呼叫成功還是失敗、失敗時是什麼錯誤類別。整理成清單如下。
- step:這一次呼叫屬於流程裡的哪一段,例如分類、摘要或審查
- input_hash:對輸入內容取 SHA-256 雜湊再截斷,用來比對兩次呼叫是不是同一組輸入
- model:實際呼叫的模型 id,串多個供應商時尤其要記,不能只靠印象猜當時用的是哪一個
- input_tokens_est/output_tokens_est:輸入與輸出各自的 token 數,離線環境先用估計值
- latency_ms:從送出到拿到結果經過的毫秒數
- status:成功時的簡短標記,失敗時記下錯誤類別
用雜湊而不是直接存輸入原文,是因為輸入常常帶著使用者的個人資料或商業內容,沒有必要整份複製一次;雜湊只用來判斷這兩次呼叫的輸入是不是同一份,真的需要回放原始內容除錯時,應該另外設計有存取控制的儲存方式,不能把原文和追蹤紀錄放在同一個檔案裡。token 數在離線、不安裝任何套件的前提下沒有供應商的計數工具可以呼叫,本文用字串長度除以一個固定倍數概估,正式串接後應該改讀供應商回應裡的實際用量欄位。
自己寫一支 JSONL 追蹤器
本文採用的 JSON Lines(JSONL)寫法是一行一個獨立的 JSON 物件,新紀錄用附加的方式接在檔尾,不需要讀出整個檔案、修改後再整份寫回去;一般常見的 JSON 檔案通常是一個大陣列,中途要新增一筆就得整份重寫,追蹤紀錄這種一直往後長、幾乎不回頭修改的資料,用 JSONL 比較合適,之後要匯入試算表或資料庫也能一行一列讀。
下面的裝飾器(decorator)只用 Python 標準函式庫:用 hashlib 對輸入算雜湊、time.perf_counter 量進出這次呼叫經過的時間、functools.wraps 讓包裝過的函式保留原本的名字與說明文字(Python 官方文件說少了這個裝飾器,被包住的函式名字會變成 wrapper、原本的說明文字會遺失),最後把整筆紀錄用 json 轉成一行文字附加到檔案尾端。只要在原本的函式上面加一行 @traced(...),就能替任何一步加上追蹤,不用改函式本體的邏輯。
"""tracer.py -- self-contained JSONL step tracer (standard library only)."""
import functools
import hashlib
import json
import time
from pathlib import Path
TRACE_PATH = Path("trace.jsonl")
def _input_hash(args, kwargs) -> str:
raw = json.dumps({"args": args, "kwargs": kwargs}, ensure_ascii=False, default=str, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _approx_tokens(text: str) -> int:
# Rough offline estimate; swap for the provider's real usage field once online.
return max(1, len(text) // 4)
def traced(step: str, model: str = "claude-sonnet-5"):
"""Decorator that appends one JSON line per call to TRACE_PATH."""
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
status, output = "ok", None
try:
output = fn(*args, **kwargs)
return output
except Exception as exc: # noqa: BLE001
status = f"error:{exc.__class__.__name__}"
raise
finally:
latency_ms = round((time.perf_counter() - start) * 1000, 1)
record = {
"ts": round(time.time(), 3),
"step": step,
"model": model,
"input_hash": _input_hash(args, kwargs),
"input_tokens_est": _approx_tokens(json.dumps([args, kwargs], default=str)),
"output_tokens_est": _approx_tokens(str(output)) if output is not None else 0,
"latency_ms": latency_ms,
"status": status,
}
with TRACE_PATH.open("a", encoding="utf-8") as fp:
fp.write(json.dumps(record, ensure_ascii=False) + "\n")
return wrapper
return decorator
@traced(step="summarize", model="claude-sonnet-5")
def summarize(text: str) -> str:
# Placeholder for a real model call, offline-safe so this file runs standalone.
return text[:40]
if __name__ == "__main__":
summarize("Mokaair 的多模型工作流追蹤示範文字。")
print(TRACE_PATH.read_text(encoding="utf-8"))
如果專案已經在用 Python 標準函式庫的 logging 模組管理一般的應用程式紀錄,也可以把追蹤疊上去:先用 addHandler 掛一個只輸出 JSON 的處理器,再用 info() 的 extra 參數把追蹤欄位塞進每一筆紀錄。官方文件特別提醒,extra 傳入的鍵不能跟 logging 系統本身使用的鍵衝突。本文的範例不依賴 logging,直接開檔案寫入,好處是任何專案都能直接複製貼上,不用先弄懂 Handler 的階層架構。
離線評測集:兩版流程跑過同一批題目
先讀模型與代理評測(Evals)是什麼,裡面已經完整說明怎麼設計案例、選評分器、判斷通過與否;本篇只示範怎麼用程式把這件事自動化:固定一組題目、跑兩個版本的流程、把每一題的結果和整體分數印出來,順便列出從通過變成失敗的題目。
本篇的範例用的是程式評分:判斷輸出字串是否與預期完全相符。Anthropic 的文件把 output == golden_answer 這種完全相符列為程式評分的例子,並形容程式評分最快、最穩定、極易擴大規模,但遇到需要較少規則式判斷的複雜情況就不夠細緻。人工評分與以模型當評審各自的取捨,那一篇已經比較過,本篇不重複;換成自己的任務時,判分規則要照任務的性質選。
OpenAI 的 Evals 文件把一個評測拆成兩個要素:一個是測試資料的欄位格式,另一個是判斷模型輸出是否正確的評分器;本文的離線範例把這兩者簡化成一份 Python 清單和一個相等比較函式,觀念相同,只是不必先把測試資料上傳到平台、不必排隊等待非同步的執行結果,跑完馬上看到分數。如果只想比較 Claude Code 單一流程的兩種做法,Claude Code|比較流程品質、用量與執行時間一文已經示範怎麼用固定案例與原始紀錄比較;本篇的離線評測集比較,示範的是更通用、不限定於單一工具的兩版流程打分方式。
"""eval_compare.py -- run a fixed eval set through two pipeline versions offline."""
import time
EVAL_SET = [
{"id": "ascii-upper", "input": "hello", "expected": "HELLO"},
{"id": "zh-tag", "input": "你好", "expected": "你好[v2]"},
{"id": "empty", "input": "", "expected": ""},
{"id": "digits", "input": "42", "expected": "42"},
]
def pipeline_a(text: str) -> str:
# Version A: upper-cases ASCII input, leaves other scripts untouched.
return text.upper() if text.isascii() else text
def pipeline_b(text: str) -> str:
# Version B: appends a version tag unless the input is empty; regresses the two ASCII cases.
return f"{text}[v2]" if text else text
def run(pipeline, cases):
rows = []
for case in cases:
start = time.perf_counter()
actual = pipeline(case["input"])
latency_ms = round((time.perf_counter() - start) * 1000, 3)
rows.append({"id": case["id"], "passed": actual == case["expected"], "latency_ms": latency_ms})
return rows
def compare(cases, a, b):
rows_a, rows_b = run(a, cases), run(b, cases)
score_a = sum(r["passed"] for r in rows_a) / len(cases)
score_b = sum(r["passed"] for r in rows_b) / len(cases)
regressions = [ra["id"] for ra, rb in zip(rows_a, rows_b) if ra["passed"] and not rb["passed"]]
return {"score_a": score_a, "score_b": score_b, "regressions": regressions}
if __name__ == "__main__":
report = compare(EVAL_SET, pipeline_a, pipeline_b)
print(report) # illustrative only: scores depend entirely on your own eval set
上面的例子只是示意:四題固定案例跑下來,版本 A 通過三題、版本 B 通過兩題。版本 B 多了一個版本標籤,讓原本不會被處理的非 ASCII 那一題從不通過變成通過,卻讓 ascii-upper 與 digits 兩題從通過變成失敗,退步清單列出的就是這兩題。總分只告訴你 B 比較低,看不出它在某一類輸入上其實變好了,所以要逐題把退步的題目列出來、人工看過,才能決定要不要換版本;固定案例只有四題,只夠驗證程式怎麼跑,換成自己的任務時,題目要涵蓋一般情況、缺漏與少見情況,數量也要夠多,結論才站得住。
跟 OpenTelemetry 的 GenAI 語意慣例對一下命名
OpenTelemetry 原本在自己的規格網站上放了一頁 GenAI 語意慣例,現在那一頁的標題是 Moved: Generative AI semantic conventions,內文寫著 GenAI 語意慣例已經搬到 OpenTelemetry 的 GenAI 語意慣例儲存庫、這一頁不再於原本的儲存庫維護,連過去的是 open-telemetry 底下的 semantic-conventions-genai;本文實際讀的就是該儲存庫 main 分支 docs 目錄裡的兩份檔案:屬性登錄檔與 GenAI spans 頁。本文查證當天,屬性登錄檔列出 72 個 gen_ai. 開頭的欄位,包含模型名稱、呼叫的操作類型、輸入與輸出 token 數等等,每一條都在穩定性欄位寫著目前的狀態。
本文查證當天逐列數過那 72 個屬性的穩定性欄位:全部標成 Development,沒有一個標成 Stable。反而是 span 上一起使用、但不屬於 gen_ai 命名空間的 error.type,在 GenAI spans 頁上標的是 Stable,該頁把它連回 OpenTelemetry 通用語意慣例的 error 屬性頁。換句話說,GenAI 這一段命名本身還在調整,本文不會直接拿它當追蹤器的正式欄位,只整理成對照表,方便以後要把資料送進支援 OpenTelemetry 的觀測系統時,知道欄位名稱怎麼換算。
官方文件也定義了 span 命名的慣例:一個 GenAI 呼叫的 span 名稱建議是操作名稱加上請求的模型名稱;操作名稱本身列了一組常用值(well-known values),像是 chat、generate_content、text_completion,登錄檔寫的是其中一個適用時就必須用該值、否則才可以自訂。這一點正好對應到本文追蹤器裡的 step 欄位,只是命名習慣不同。GenAI spans 頁另外寫著 span 應該涵蓋整個操作的時間長度,從操作發動開始、到回應完整收到或因錯誤與取消而終止為止。
| 追蹤器欄位 | 對應的 OpenTelemetry 屬性 | 目前穩定性狀態 |
|---|---|---|
| step | gen_ai.operation.name | development |
| input_hash | 那 72 個屬性裡沒有雜湊欄位;最接近的是記錄輸入對話內容的 gen_ai.input.messages,登錄檔另外警告它很可能含使用者個資 | development |
| model | gen_ai.request.model | development |
| input_tokens_est/output_tokens_est | gen_ai.usage.input_tokens/gen_ai.usage.output_tokens | development |
| latency_ms | 那 72 個屬性裡沒有整體延遲的欄位,只有串流首個區塊的 gen_ai.response.time_to_first_chunk;spans 頁則寫 span 本身要涵蓋整個操作的時間長度 | development(time_to_first_chunk) |
| status(錯誤類別) | error.type,只在操作失敗時才需要 | stable |
常見問題
一定要接 OpenTelemetry 才能開始追蹤嗎?
不用。本文的 JSONL 追蹤器只用 Python 標準函式庫,一支檔案就能記錄每一步的輸入雜湊、模型、token 數、延遲與結果,不需要先裝 SDK 或串接任何追蹤服務。OpenTelemetry 的 GenAI 語意慣例只是拿來對照屬性命名,等專案需要跟其他系統共用觀測資料時再考慮接上。
token 數為什麼是概估,不能直接算嗎?
本文的範例只用標準函式庫、不裝任何套件,所以既沒有呼叫供應商的計數工具,也沒有引入任何 tokenizer 套件,只用字串長度除以固定倍數估算,跟實際計費用量會有落差。正式串接時應該改讀供應商回應裡的用量欄位,只有在完全離線、沒有網路呼叫的情況下才適合先用概估值看趨勢。
追蹤紀錄要保留輸入原文嗎?
本文的做法是只存輸入的 SHA-256 雜湊,不存原文,用意是能比對兩次呼叫是不是同一組輸入,同時不必擔心把使用者資料或商業內容整份寫進檔案。如果真的需要回放原始輸入來除錯,應該另外設計遮蔽或有存取控制的儲存方式,不要直接放進追蹤檔。
離線評測集的分數,可以直接當成兩個模型的效能排名嗎?
不行。文中的評測集只用固定字串轉換當範例,用來示範跑分與比較的程式怎麼寫,不是任何模型的實際表現;分數也只反映這組題目涵蓋到的情境,題目數量少或型態單一時,總分差異不代表哪一版全面更好,還要看退步清單裡的個別題目。
JSONL 格式跟一般的 JSON 檔案有什麼不同?
JSONL 是一行一個獨立的 JSON 物件,新紀錄用附加的方式接在檔尾,不用讀出整個檔案再重新寫回去;一般的 JSON 檔案通常是一個大陣列或物件,中途新增一筆就得整份重寫。追蹤紀錄一直增加,用 JSONL 比較合適。
OpenTelemetry 的 GenAI 屬性名稱可以直接拿來當我自己追蹤器的欄位名嗎?
可以參考命名方式,但要注意本文查證當天,登錄檔列出的 72 個 gen_ai. 屬性全部標示為 Development,代表規格本身還可能調整;本文的表格只列出對照關係,方便以後要接上正式的觀測系統時知道欄位怎麼換算,實際採用前應該重新查一次當時的規格頁面。
多模型 AI 工作流教學:從拆任務到串接不同模型多模型 AI 工作流教學:從拆任務到串接不同模型這個系列教的是怎麼把一件工作拆開、交給合適的模型,再把結果接回同一條流程:判斷該不該拆、拆給誰,換供應商不改程式,設計便宜先試的路由與級聯,讓模型之間用結構化輸出交接資料,再到代理式工具怎麼分工、同一套工具怎麼給多個客戶端共用、Claude Code 與 Codex 怎麼搭本機模型,以及上線後怎麼追蹤與防護。一般使用者可以從判斷該不該拆的觀念讀起,已經會寫 Python 的人能直接進到換供應商、寫路由與交接資料的幾篇。閱讀全文
模型與代理評測(Evals)是什麼模型與代理評測(Evals)是什麼模型與代理評測把任務、輸入、執行條件和成功標準固定下來,觀察 AI 是否真的符合需求。本文用志工排班助理為例,說明案例、重複嘗試、評分器與外部結果的差別,比較程式、人類與模型評分,解釋為何單次答對和平均高分都不足以證明可靠。讀完能建立一組小而實用的評測,讓提示詞或模型更新有可比較的證據,也能保留尚未驗證的限制。閱讀全文
同主題延伸閱讀
生活分享
Claude Code、Codex 搭本機模型:兩種接法怎麼選
Claude Code 與 Codex 搭配本機模型有兩種接法:代理照常連雲端、把大量雜務交給腳本或 MCP 工具去問本機模型,或是把代理的模型整個換成本機模型。這篇用資料能不能出門、上下文開得夠不夠長、工作的類型三個問題幫你選,並對照 Ollama、LM Studio、Anthropic 與 OpenAI 的官方文件,分清楚本機權重、Ollama 的 cloud 標籤與供應商端點是三種不同的東西。
引用本文的文章
最新旅遊情報攻略

情報
2026 韓國楓葉預測:雪嶽山 10 月 20 日、首爾近郊 10 月底、內藏山與漢拏山 11 月上旬
韓國山林廳 2026 年 9 月 22 日公布的楓紅高峰預測:雪嶽山 10 月 20 日,春川、國立樹木園到首爾植物園落在 10 月 28 日到 11 月 2 日,內藏山 11 月 4 日、漢拏山 11 月 6 日,整體比最近 5 年晚約 0.8 天。整理各地楓樹與銀杏的預測日、首爾出發怎麼排,以及出發前去哪裡看即時楓況。2026 年 10 月查證。
- 季節活動
- 自然
- 觀景

攻略胡志明市
胡志明市到頭頓一日遊:白藤碼頭搭高速船、船票與班次,下船就是胡梅纜車與耶穌基督像
人在胡志明市挪一天去頭頓看海:市中心的白藤高速船碼頭搭船,航程 120 分鐘到頭頓的胡梅碼頭,平日成人 320,000 越南盾、週末 350,000,回程末班平日 15:00。下船就是胡梅纜車站,同一條路上有白宮,小山頂上是耶穌基督像。平日一天只有兩班船,整天要從末班船倒推著排。
- 交通
- 行程範例
- 海灘

攻略沖繩
沖繩不開車攻略:單軌只到浦添,美麗海水族館要坐兩個多小時的巴士,回那霸的最後一班直達車 17:22 就開走
不租車的沖繩怎麼移動:那霸市區靠沖繩都市單軌電車(ゆいレール),那霸機場站到終點てだこ浦西 19 站、17 公里、37 分鐘,一日券 1,000 日圓;美麗海水族館有那霸機場直達的高速巴士,單程 2,000 日圓起、官方時刻表上 2 小時上下,下車後還要走 10 分鐘;古宇利島要在今帰仁村役場轉車,當天來回光坐車就六個半小時;回程的最後一班直達車 17:22 就從記念公園前開走(2026 年 9 月查證)。
- 交通
- 行程範例
- 預算
資料來源
- Python 官方文件:logging — Logging facility for Python · 查證日期:
- Python 官方文件:functools — Higher-order functions and operations on callable objects · 查證日期:
- OpenTelemetry:Moved: Generative AI semantic conventions(原規格站的 GenAI 語意慣例頁) · 查證日期:
- OpenTelemetry semantic-conventions-genai:屬性登錄檔 gen_ai · 查證日期:
- OpenTelemetry semantic-conventions-genai:GenAI spans · 查證日期:
- OpenAI Platform:Working with evals · 查證日期:
- Claude Platform Docs:Define success criteria and build evaluations · 查證日期: