我有一個用 Claude Agent SDK(Python)+ FastAPI 做的聊天應用:使用者丟一篇文章進聊天框,後端的 agent 自己跑幾百個工具呼叫,最後吐出一支影片。問題是 agent 一跑就是幾分鐘,中間做了什麼、每回合燒多少 token、哪個工具卡住,全都是黑箱。
這篇筆記記錄我怎麼幫它裝上 MLflow Tracing(一個聊天回合 = 一條 trace),過程中用了哪些 Claude Code skill 和 MCP 工具,以及踩到的坑。
**2026-07-06 更新(兩次)**:初版我寫「Agent SDK spawn CLI 子行程,autolog 完全掛不到」——**這句話是錯的**。官方其實有 [Claude Agent SDK (Python) 的 tracing 整合](https://mlflow.org/docs/latest/genai/tracing/integrations/listing/claude_agent_sdk_python/)(MLflow ≥ 3.5)。被讀者打臉之後我把官方整合完整實測了一輪、補上對比;然後想通了:**官方基底 + 30 行補丁層補掉它缺的**,維護面最小——生產環境已經切過去,本文最後一節就是最終落地版。手動重建 span 樹的完整實作仍保留在文中,需要真實時長/子代理巢狀的人直接抄。
先講結論
- MLflow ≥ 3.5 官方支援 trace Claude Agent SDK:
mlflow.anthropic.autolog(),它 patch 的不是anthropicSDK(你的行程裡根本沒有),而是ClaudeSDKClient.__init__——wrap 掉query()和receive_response(),等訊息流收完,再一口氣把整串訊息重建成 trace。 - 換句話說:官方的做法也是「從訊息流重建 span 樹」,跟我手動做的是同一條路——差別只在官方是「收完事後建」、我是「邊收邊建」。這個差別比聽起來大:事後建的 span,時長全部是假的(~0ms),你看不出哪個工具跑了 45 秒。
- 官方版實測限制:沒有 cost、子代理不巢狀、session 只能是 CLI 內部 id、異常中斷的回合不記 trace。其中 cost 和 session 用 trace tag 補丁就能補(見最終落地節);時長和巢狀補不了,要就得全手動。
- 套件選擇是個隱藏坑:官方整合的
mlflow.claude_code模組在mlflow-skinny裡有、在mlflow-tracing裡沒有,而且autolog()硬 importanthropic。最小可用組合 =mlflow-skinny + anthropic + python-dateutil。 - 最陰的坑(兩版都要面對):
AssistantMessage.usage的output_tokens恆為 1(streamingmessage_start的 stub 值),準確總量只在ResultMessage.usage。官方版跟我的解法一致:準確值放 root span。 - 我的最終選擇:官方 autolog 當基底 +
finalize_turn_trace()補丁層(補 chat_id、每回合成本、逐模型用量)。理由:跟著官方演進、自己只維護 30 行。
這次用到的工具組
1. MLflow 官方 Claude Code skills
MLflow 出了一組 Claude Code skills(裝在 ~/.claude/skills/),這次主要用兩個:
instrumenting-with-mlflow-tracing:tracing 的方法論——什麼該 trace(root 操作、LLM 呼叫、工具呼叫)、什麼不該(純資料轉換、字串處理),三種手法(autolog / decorator / manual span)怎麼選,以及最重要的:裝完一定要跑真流量驗證search_traces()查得到、span 不是空殼。agent-evaluation(這次還沒用到,下一步):接在 tracing 之上做 LLM-as-judge 品質評估。
另外搭配 agent-sdk-py skill:它的第一條規則是「不要憑記憶寫 Agent SDK 的 API」——這個 SDK 從 claude-code-sdk 改名成 claude-agent-sdk 之後 API 一直在動,skill 附了一支 introspection 腳本直接 dump 當前安裝版本的真實 API 表面:
python scripts/sdk_surface.py
# claude-agent-sdk == 0.2.104
# ResultMessage -> ['subtype', 'duration_ms', 'duration_api_ms', 'is_error',
# 'num_turns', 'session_id', 'stop_reason', 'total_cost_usd', 'usage',
# 'result', 'structured_output', 'model_usage', ...]
# AssistantMessage -> ['content', 'model', 'parent_tool_use_id', 'error',
# 'usage', 'message_id', 'stop_reason', ...]
Code language: PHP (php)這一步等於把「訊息流裡到底有什麼料可以進 trace」先盤點清楚——parent_tool_use_id 和 model_usage 這兩個欄位就是這樣挖出來的,後面都派上用場。
2. MLflow MCP server
MLflow 3.5+ 內建 MCP server,在 Claude Code 的設定裡註冊一條就能用:
{
"mlflow-mcp": {
"type": "stdio",
"command": "uv",
"args": ["run", "--with", "mlflow[mcp]>=3.5.1", "mlflow", "mcp", "run"],
"env": { "MLFLOW_TRACKING_URI": "http://your-mlflow-server:5000" }
}
}
Code language: JSON / JSON with Comments (json)裝好之後 Claude 就多了一排 mcp__mlflow-mcp__* 工具:search_traces、get_trace、create_experiment、log_trace_feedback、register_llm_judge_scorer⋯⋯。平常「幫我看昨天最慢的 trace 在幹嘛」這種話,Claude 可以直接查 server 回答,不用自己寫查詢腳本。
順帶一提,這次的 tracking URI 就是從這份 MCP 設定裡翻出來的——本機 localhost:5000 有一台 MLflow 是跑在容器裡的、宿主根本連不到(坑 3,後述)。
官方 autolog:真的能用(更正初版說法)
啟用只要兩行,但必須在建立 ClaudeSDKClient 之前呼叫:
import mlflow
import mlflow.anthropic
mlflow.set_experiment("my_claude_app")
mlflow.anthropic.autolog() # 必須在 ClaudeSDKClient 建立之前
client = ClaudeSDKClient(options=...) # 之後照常用
Code language: PHP (php)翻 mlflow/anthropic/__init__.py 看它到底 patch 了什麼:
# mlflow/anthropic/__init__.py(節錄)
def autolog(...):
from anthropic.resources import AsyncMessages, Messages # ← 硬依賴 anthropic 套件!
safe_patch(FLAVOR_NAME, Messages, "create", patched_class_call)
safe_patch(FLAVOR_NAME, AsyncMessages, "create", async_patched_class_call)
# Patch Claude Code SDK if available
try:
from claude_agent_sdk import ClaudeSDKClient
safe_patch(FLAVOR_NAME, ClaudeSDKClient, "__init__", patched_claude_sdk_init)
except ImportError:
...
Code language: PHP (php)patched_claude_sdk_init 做的事:wrap query() 捕捉 user prompt、wrap receive_response() 把 yield 出來的每則訊息累積到 list,等整個 async for 迭代結束,呼叫 mlflow.claude_code.tracing.process_sdk_messages(messages)——事後把整串訊息重建成一條 trace。
拿它實測跑一輪(Haiku + 一個 Bash 工具呼叫),產出的 trace 長這樣:
claude_code_conversation (AGENT) ← root,usage 完整(來自 ResultMessage)
├── tool_Bash (TOOL) duration = 0.1 ms(假的,實際跑了幾秒)
└── llm (LLM) duration = 0.1 ms(name 固定叫 "llm")
metadata: mlflow.trace.session = CLI 內部 session id
mlflow.trace.user = OS 使用者名
token_usage: {'input_tokens': 18, 'output_tokens': 210,
'cache_read_input_tokens': 18767, 'cache_creation_input_tokens': 18944}
Code language: JavaScript (javascript)能用、token 準確、prompt/回覆/工具輸入輸出都在。但注意 duration——所有 child span 都是迭代結束那一瞬間「補建」的,時長全部 ~0ms。另外官方文件明講:只支援 ClaudeSDKClient,直接呼叫 query() 的不會被 trace;還有一個實測發現:回合中途炸掉(例外把迭代打斷)的話,整條 trace 都不會建——官方只記「跑完的回合」。
官方版 vs 手動版:實測對比
| 面向 | 官方 autolog() |
手動 TurnTrace(下文實作) |
|---|---|---|
| 啟用成本 | 2 行 | ~200 行 |
| 依賴 | mlflow-skinny(或完整版)+ anthropic |
輕量 mlflow-tracing 即可 |
| span 建立時機 | 回合結束後一口氣補建 | 邊收訊息邊開關 |
| 工具 span 時長 | 全部 ~0ms(假的) | 真實 wall-time(看得出哪個工具卡 45 秒) |
| LLM span | 只有「純文字、無工具呼叫」的步驟才建,name 固定 llm |
每個 assistant 步驟一個,name = model 名 |
| 成本 | 無 → 可用 tag 補(見最終落地) | root span 帶 total_cost_usd + 逐模型 model_usage |
| 子代理 | 不巢狀(原始碼完全沒碰 parent_tool_use_id),補不了 |
靠 parent_tool_use_id 巢狀成樹 |
| session 分組 | CLI 內部 session id → chat id 可用 tag 補 | 自訂(chat_id 進 mlflow.trace.session) |
| 異常中斷的回合 | 不記 trace | 記(status=ERROR + 錯誤訊息) |
| token 總量 | ResultMessage.usage 放 root(正確) | 同(我踩完坑 1 才跟官方殊途同歸) |
query() 單發模式 |
不支援 | 訊息迴圈在哪都能掛 |
| 維護 | 官方演進,升級跟著走 | 自己扛(SDK 訊息格式變了要跟) |
怎麼選:看你缺的東西是「可補的」還是「補不了的」。cost、自己的 session id——tag 補丁 30 行搞定,選官方;一定要真實時長、子代理樹、異常回合也要有 trace——只能全手動。我一開始寫了全手動版(下文完整保留),想清楚之後把生產環境切到「官方 + 補丁」:agent 除錯時 90% 的問題看流程和成本就夠了,時長和巢狀是 nice-to-have,換官方演進 + 30 行維護面,划算。
最終落地:官方 autolog + 30 行補丁層
依賴:三個套件的差異先搞清楚
| 套件 | mlflow.anthropic |
mlflow.claude_code |
大小 |
|---|---|---|---|
mlflow(完整版) |
✅ | ✅ | 最重(server/UI 全家桶) |
mlflow-skinny |
✅ | ✅(缺 python-dateutil 要自己補) |
中 |
mlflow-tracing |
✅(但會炸,見坑 2) | ❌ | 最輕 |
最小可用組合:
uv add mlflow-skinny anthropic python-dateutil
anthropic 純粹是讓 autolog() 第一行的 import 不炸(Agent SDK 本身不需要它);python-dateutil 是 mlflow.claude_code 的隱藏依賴,skinny 沒帶。
setup:啟動時開 autolog
"""MLflow tracing via the official integration + a patch layer for what it misses."""
import asyncio
import json
import logging
import os
import mlflow
import mlflow.anthropic
from claude_agent_sdk import ResultMessage
from mlflow.tracing.constant import TraceMetadataKey
logger = logging.getLogger(__name__)
_enabled = False
def setup_tracing() -> bool:
"""App 啟動時(dotenv 之後、任何 ClaudeSDKClient 建立之前)呼叫一次。"""
global _enabled
uri = os.environ.get("MLFLOW_TRACKING_URI")
if not uri:
logger.info("MLflow tracing disabled (MLFLOW_TRACKING_URI not set)")
return False
try:
mlflow.set_tracking_uri(uri)
mlflow.set_experiment(os.environ.get("MLFLOW_EXPERIMENT_NAME", "chatapp"))
mlflow.anthropic.autolog() # patch ClaudeSDKClient.__init__
_enabled = True
logger.info("MLflow tracing enabled (official autolog) -> %s", uri)
except Exception as error:
logger.warning("MLflow tracing disabled (server unreachable?): %s", error)
_enabled = False
return _enabled
Code language: PHP (php)補丁層:turn 結束後補 tag
官方 trace 缺 cost 和自己的 chat id——好消息是 trace 建完之後可以用 set_trace_tag 補。三個細節:
- 時機:autolog 的 trace 是在
receive_response()迭代「完全耗盡」那一刻建的——補丁必須在 async for 迴圈退出之後才呼叫(迴圈裡處理ResultMessage時 trace 還不存在,先把 result 存起來)。 - flush:trace 走 async export queue,
set_trace_tag打的是 server——要先flush_trace_async_logging()等它上去,不然 tag 會落空。 - 競態:
get_last_active_trace_id()拿的是「本行程最後一條 trace」——多個聊天並行時可能搶到別人的。官方 trace 的 metadata 帶 CLI session id,比對一下再 tag。
async def finalize_turn_trace(
chat_id: str,
cli_session_id: str | None,
result: ResultMessage | None,
turn_cost: float,
interrupted: bool = False,
) -> None:
"""幫 autolog 剛寫完的 trace 補上它不記的東西。best-effort、絕不拋錯。"""
if not _enabled or result is None:
return
try:
await asyncio.to_thread( # 網路操作丟 thread,不卡 event loop
_finalize_sync, chat_id, cli_session_id, result, turn_cost, interrupted
)
except Exception:
logger.debug("trace finalize failed", exc_info=True)
def _finalize_sync(chat_id, cli_session_id, result, turn_cost, interrupted):
mlflow.flush_trace_async_logging() # 等 trace 真的上了 server
trace_id = mlflow.get_last_active_trace_id()
if not trace_id:
return
# 多聊天並行防搶錯:官方 trace 的 session = CLI session id
metadata = mlflow.get_trace(trace_id).info.trace_metadata or {}
trace_session = metadata.get(TraceMetadataKey.TRACE_SESSION)
if cli_session_id and trace_session and trace_session != cli_session_id:
return # 不是我這輪的,放手
tags = {"chat_id": chat_id, "turn_cost_usd": f"{turn_cost:.6f}"}
if result.total_cost_usd:
tags["cli_total_cost_usd"] = f"{result.total_cost_usd:.6f}"
if result.num_turns is not None:
tags["num_turns"] = str(result.num_turns)
if result.model_usage:
tags["model_usage"] = json.dumps(result.model_usage, ensure_ascii=False)[:4000]
if interrupted:
tags["interrupted"] = "true"
for key, value in tags.items():
mlflow.set_trace_tag(trace_id, key, value)
Code language: PHP (php)掛進回合迴圈
async with self._turn_lock:
self._last_result = None # 迴圈裡收到 ResultMessage 時存起來
self._last_turn_cost = 0.0
try:
await self.agent.client.query(prompt)
async for message in self.agent.client.receive_response():
await self._handle_sdk_message(message) # 這裡面存 _last_result
# ↑ 迭代結束的瞬間 autolog 建好 trace —— 現在才能補 tag
await finalize_turn_trace(
chat_id=self.chat_id,
cli_session_id=self._session_id,
result=self._last_result,
turn_cost=self._last_turn_cost,
)
except Exception as error:
... # 異常回合官方不記 trace,也就沒東西可補
Code language: PHP (php)實測跑一輪,trace 上的 tags:
tags:
chat_id = 20260618-165650
turn_cost_usd = 0.145098
cli_total_cost_usd = 0.145098
num_turns = 1
model_usage = {"claude-haiku-4-5-...": {...}, "claude-opus-4-8": {...}}
Code language: JavaScript (javascript)成本歸因回來了、chat_id 可以搜了,維護面只剩這 30 行。
替代方案:全手動 TurnTrace(要真實時長/子代理樹時用)
以下是我最初寫的全手動版,官方版補不了的它都有:工具 span 是真實 wall-time、子代理巢狀成樹、異常回合也記 trace、session 直接用自己的 chat_id。用輕量 mlflow-tracing 就能跑。取捨是 ~200 行自己維護。
訊息流 → span 樹的映射
| SDK 訊息 / 區塊 | MLflow span | 說明 |
|---|---|---|
| 整個回合 | chat_turn(AGENT, root) |
inputs = 使用者 prompt,outputs = 回覆 + 成本 |
AssistantMessage |
LLM span | 訊息到達時已完整,span 即開即關;掛 model 名與 usage |
ToolUseBlock(在 AssistantMessage 裡) |
TOOL span 開 | inputs = 工具參數 |
ToolResultBlock(在 UserMessage 裡) |
TOOL span 關 | 用 tool_use_id 配對;is_error 決定紅綠 |
帶 parent_tool_use_id 的訊息 |
巢狀 | 子代理(Agent 工具)內的一切掛在該 Agent TOOL span 底下 |
ResultMessage |
關 root span | total_cost_usd、num_turns、準確 token 總量 |
parent_tool_use_id 是整個設計最漂亮的一塊:主線 agent 開一個 Agent 工具去跑子代理時,子代理的所有訊息都帶著那個 tool_use_id。而 Agent 工具的 TOOL span 要等子代理跑完、tool_result 回來才會關——期間子代理的 LLM/TOOL span 全部自然巢狀在它底下,UI 上就是一棵漂亮的樹(官方版沒做這件事,子代理的東西是平的):
chat_turn (AGENT)
├── claude-opus-4-8 (LLM)
├── Read (TOOL)
├── Agent (TOOL) ← 子代理
│ ├── claude-opus-4-8 (LLM)
│ └── Bash (TOOL)
└── claude-opus-4-8 (LLM)
完整程式碼
設計原則只有一條:fail-open——沒設 MLFLOW_TRACKING_URI 就整個關閉、server 掛了就啟動時降級、observe/close 全部吞例外。監控永遠不准弄壞聊天本體。
"""MLflow tracing: one chat turn = one trace, built from the SDK message stream."""
import logging
import os
from typing import Any
import mlflow
from claude_agent_sdk import (
AssistantMessage,
ResultMessage,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
from mlflow.entities import SpanType
from mlflow.tracing.constant import SpanAttributeKey, TraceMetadataKey
logger = logging.getLogger(__name__)
_TRUNCATE_CHARS = 4000 # 工具輸出可能是整包檔案,砍掉免得灌爆 trace store
_enabled = False
def setup_tracing() -> bool:
"""指向 tracking server。App 啟動時(dotenv 之後)呼叫一次。"""
global _enabled
uri = os.environ.get("MLFLOW_TRACKING_URI")
if not uri:
logger.info("MLflow tracing disabled (MLFLOW_TRACKING_URI not set)")
return False
try:
mlflow.set_tracking_uri(uri)
mlflow.set_experiment(os.environ.get("MLFLOW_EXPERIMENT_NAME", "chatapp"))
# 3.14 起 trace 上傳預設就是 async,不會拖慢回合
_enabled = True
logger.info("MLflow tracing enabled -> %s", uri)
except Exception as error:
logger.warning("MLflow tracing disabled (server unreachable?): %s", error)
_enabled = False
return _enabled
def _truncate(value: Any) -> Any:
text = value if isinstance(value, str) else repr(value)
if len(text) > _TRUNCATE_CHARS:
return text[:_TRUNCATE_CHARS] + f"… [+{len(text) - _TRUNCATE_CHARS} chars]"
return value
def _usage_dict(usage: Any) -> dict | None:
"""把 SDK 的 Anthropic 風格 usage 映射到 MLflow 的 token-usage 格式。"""
if not isinstance(usage, dict):
return None
keys = (
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
)
out = {k: usage[k] for k in keys if isinstance(usage.get(k), int)}
if not out:
return None
out["total_tokens"] = sum(out.values())
return out
class TurnTrace:
"""每則 SDK 訊息餵給 observe();回合結束呼叫 close()。"""
def __init__(self, chat_id: str, prompt: str) -> None:
self._root = None
self._tools: dict[str, Any] = {} # tool_use_id -> 開著的 TOOL span
self._texts: list[str] = []
self._result: ResultMessage | None = None
if not _enabled:
return
try:
self._root = mlflow.start_span_no_context(
name="chat_turn",
span_type=SpanType.AGENT,
inputs={"prompt": prompt},
# 標準 session 欄位:同一聊天的回合在 UI 會被歸成一組
metadata={TraceMetadataKey.TRACE_SESSION: chat_id},
tags={"chat_id": chat_id},
)
except Exception as error:
logger.warning("MLflow trace not started: %s", error)
self._root = None
def observe(self, message: Any) -> None:
if self._root is None:
return
try:
if isinstance(message, AssistantMessage):
self._on_assistant(message)
elif isinstance(message, UserMessage):
self._on_tool_results(message)
elif isinstance(message, ResultMessage):
self._result = message
except Exception:
logger.debug("trace observe failed", exc_info=True)
def _parent_for(self, parent_tool_use_id: str | None) -> Any:
# 子代理的訊息帶著 spawn 它的 Agent 工具 id,
# 掛到那個(還開著的)TOOL span 底下 → 自然巢狀
return self._tools.get(parent_tool_use_id) or self._root
def _on_assistant(self, message: AssistantMessage) -> None:
parent = self._parent_for(message.parent_tool_use_id)
texts: list[str] = []
thinking_chars = 0
tool_uses: list[ToolUseBlock] = []
for block in message.content:
if isinstance(block, TextBlock):
texts.append(block.text)
elif isinstance(block, ThinkingBlock):
thinking_chars += len(block.thinking)
elif isinstance(block, ToolUseBlock):
tool_uses.append(block)
if message.parent_tool_use_id is None:
self._texts.extend(texts) # 只彙整主線回覆,子代理的不算
# 訊息到達時已完整,LLM span 在這裡開了馬上關
attributes: dict[str, Any] = {}
if message.model:
attributes[SpanAttributeKey.MODEL] = message.model
usage = _usage_dict(message.usage)
if usage:
# 注意:放普通屬性,「不要」放標準 token-usage 鍵!
# per-message usage 來自 stream 的 message_start,
# output_tokens 是 stub —— 準確總量在 close() 放 root(坑 1)
attributes["usage"] = usage
span = mlflow.start_span_no_context(
name=message.model or "assistant",
span_type=SpanType.LLM,
parent_span=parent,
)
outputs: dict[str, Any] = {}
if texts:
outputs["text"] = _truncate("\n".join(texts))
if thinking_chars:
outputs["thinking_chars"] = thinking_chars
if tool_uses:
outputs["tool_calls"] = [{"name": b.name, "id": b.id} for b in tool_uses]
span.end(outputs=outputs or None, attributes=attributes)
# 每個工具呼叫開一個 TOOL span,等配對的結果回來再關
for block in tool_uses:
self._tools[block.id] = mlflow.start_span_no_context(
name=block.name,
span_type=SpanType.TOOL,
parent_span=parent,
inputs=block.input,
)
def _on_tool_results(self, message: UserMessage) -> None:
content = message.content
if not isinstance(content, list):
return
for block in content:
if isinstance(block, ToolResultBlock):
span = self._tools.pop(block.tool_use_id, None)
if span is not None:
span.end(
outputs=_truncate(block.content),
status="ERROR" if block.is_error else "OK",
)
def close(self, error: BaseException | None = None) -> None:
if self._root is None:
return
root, self._root = self._root, None # 冪等
try:
# 回合中斷/失敗可能留下沒關的工具 span
for span in self._tools.values():
span.end(status="ERROR" if error else "OK")
self._tools.clear()
result = self._result
attributes: dict[str, Any] = {}
outputs: dict[str, Any] = {}
if self._texts:
outputs["reply"] = _truncate("\n".join(self._texts))
if result is not None:
outputs["subtype"] = result.subtype
for key in ("total_cost_usd", "num_turns", "duration_ms", "duration_api_ms"):
value = getattr(result, key, None)
if value is not None:
attributes[key] = value
if result.model_usage:
attributes["model_usage"] = result.model_usage
# 本回合權威 token 總量:整條 trace「唯一」的標準
# token-usage 屬性,所以 trace 層聚合 = 它(坑 1 的解法)
usage = _usage_dict(result.usage)
if usage:
attributes[SpanAttributeKey.CHAT_USAGE] = usage
if error is not None:
outputs["error"] = str(error)
failed = error is not None or (result is not None and result.is_error)
root.end(
outputs=outputs or None,
attributes=attributes or None,
status="ERROR" if failed else "OK",
)
except Exception:
logger.debug("trace close failed", exc_info=True)
掛進回合迴圈只碰四個地方:
from .tracing import TurnTrace
async with self._turn_lock:
trace = TurnTrace(self.chat_id, display) # 1. 開 trace
try:
await self.agent.client.query(prompt)
async for message in self.agent.client.receive_response():
trace.observe(message) # 2. 每則訊息餵進去
await self._handle_sdk_message(message)
trace.close() # 3. 正常收尾
except Exception as error:
trace.close(error) # 4. 錯誤也收尾
...
Code language: PHP (php)踩到的坑
坑 1:AssistantMessage.usage 的 output_tokens 恆為 1
裝完第一輪實測,trace 看起來一切正常——直到看 token 統計:
trace tokens: {'input_tokens': 7164, 'output_tokens': 1, ...}
Code language: JavaScript (javascript)agent 明明回了一整句話,output 卻是 1。原因:CLI 轉發的 assistant 事件,usage 是 Anthropic streaming message_start 的初始值——input/cache tokens 那時已知,output_tokens 是佔位的 1,真實值要到 message_delta 才有,但 SDK 的 AssistantMessage.usage 拿到的就是初始版。
更麻煩的是 MLflow 的聚合規則:trace 層的 token 統計 = 所有帶標準鍵 mlflow.chat.tokenUsage 的 span 加總。如果每個 LLM span 都掛標準鍵,output 全部低估、跨 step 的 input 還會重複累計。
解法:
- LLM span 的 per-message usage 放普通屬性(
usage),保留觀察價值、不進聚合; ResultMessage.usage(回合權威總量)放 root span,而且是整條 trace 唯一的mlflow.chat.tokenUsage——聚合出來的數字就等於它。
修完再跑一輪:output_tokens: 17,合理了。事後翻官方 process_sdk_messages 的原始碼,發現官方也是這個策略(usage 只放 root、從 ResultMessage 拿)——殊途同歸,等於幫我驗證了這個坑是真的。
坑 2:官方 autolog 在輕量包環境有「兩段死法」
為了不把整包 MLflow(server、UI、sklearn 全家桶)裝進 app,我一開始用了 mlflow-tracing 輕量包。如果你也是,官方 autolog 直接與你無緣,而且死法有兩段:
第一段:ModuleNotFoundError: No module named 'anthropic'。autolog() 函式第一行就是 from anthropic.resources import AsyncMessages, Messages——就算你只想 trace Agent SDK(它完全不依賴 anthropic 套件),也得先 pip install anthropic 陪葬。
第二段(更陰,靜默失敗):裝了 anthropic 之後 patch 會成功,但收完訊息要建 trace 時,它呼叫的 mlflow.claude_code.tracing.process_sdk_messages 不存在於 mlflow-tracing 輕量包——而這個 ImportError 被 except Exception: logger.debug(...) 吞掉。結果:不報錯、也永遠沒有 trace。你以為裝好了,其實什麼都沒記。
出路是 mlflow-skinny:它有 mlflow.claude_code(比 mlflow-tracing 多這塊、比完整版少 server/UI),只要再補一個它沒帶的隱藏依賴 python-dateutil 就能跑(見最終落地節的套件對照表)。
其他輕量包缺件(手動版路上撞到的):
mlflow.config.enable_async_logging(True)→ 沒有mlflow.config。查環境變數表才發現MLFLOW_ENABLE_ASYNC_TRACE_LOGGING預設就是True,這行根本不用寫。MlflowClient、get_experiment_by_name→ 都不在。要刪測試用的 experiment,最後直接走 REST API:
curl -X POST "$MLFLOW_TRACKING_URI/api/2.0/mlflow/experiments/delete" \
-H "Content-Type: application/json" -d '{"experiment_id": "2"}'
Code language: JavaScript (javascript)原則:用輕量包時,每個 API 都先 dir(mlflow) 確認存在再寫,官方文件寫的是完整包的 API 表面。
坑 3:tracking server 在容器裡,localhost 連不到
機器上 ps 看得到一台 MLflow server 在 5000 port,curl localhost:5000/health 卻是連線失敗——那台跑在容器裡,port 沒映射出來。真正可用的 server 是另一台,位址是從 mlflow-mcp 的 MCP 設定裡翻出來的。
連帶一個相關陷阱(skill 文件特別點名):MLFLOW_TRACKING_URI 沒設或設錯時,trace 不會報錯,而是安靜地寫進本地 ./mlruns 目錄——你以為在監控,其實資料全落在 app 的工作目錄裡。所以 setup_tracing() 才設計成:沒 URI 就明確停用並留 log,起不來就降級,絕不默默 fallback。
坑 4:CLI session resume 之後,重放的歷史訊息會變成假 trace
這個坑是上線後從 trace 資料裡發現的。我的 app 會把 CLI 的 session id 存起來,backend 重啟或 client 重建後帶 resume=<session_id> 接回原對話。結果 MLflow 上出現這種詭異 trace:
14:42 spans=15 duration=18ms ← 15 個 span 擠在 18ms?
13:40 spans=48 duration=44ms ← 48 個 span 擠在 44ms??
12:54 spans=363 duration=22 分鐘 ← 這條才是正常的
抽開 44ms 那條一看,裡面的 LLM/TOOL span 內容全是上一輪已經做完的工作——resume 之後,receive_response() 會把恢復的歷史訊息重放一遍,我的 TurnTrace 把它們當成本輪的即時事件,全數重建了一次。時長假、內容錯置。
這不是手動版獨有的問題:官方 autolog 一樣是收 receive_response() 的訊息流,同樣的重放同樣會進 trace。SDK 目前沒有在訊息物件上標記「這是 replay」(只有一個 extra_args={"replay-user-messages": None} 選項控制要不要重放 user 訊息),所以只能自己防——比對訊息 uuid、或 resume 後的第一輪丟棄「query() 發出之前就湧入」的訊息。這條的修法我還在收斂,先把坑立牌。
附贈小坑:pkill 自殺
驗證時要重啟 dev server,隨手一句:
pkill -f "npm run dev"; sleep 2; nohup npm run dev &
Code language: JavaScript (javascript)結果整條命令自己 exit 144。因為 pkill -f 比對的是完整命令列,而這條複合命令本身就含有 “npm run dev” 字串——pkill 把承載它的 shell 一起殺了。解法:pattern 用 "[n]pm run dev" 這種 regex 寫法避開自匹配,或 kill 和後續動作分兩次下。
驗證:不要裝完就當作有用
照 instrumenting-with-mlflow-tracing skill 的要求做兩層驗證,兩個方案都跑過:
第一層:合成訊息流(手動版)。手工組一串 AssistantMessage / UserMessage / ResultMessage(含子代理、含超長工具輸出、含錯誤結果)餵給 TurnTrace,打真 server,然後 search_traces() + get_trace() 斷言整棵樹:root 是 AGENT、工具配對正確、子代理巢狀對、截斷生效、聚合 token = ResultMessage.usage。15 條斷言全過才算數。
第二層:真流量(最終方案)。起 app、真的跑一個回合,去 MLflow UI 看官方 trace + 補丁 tags 都在:
trace: tr-0bc949373eef29a8... | state: OK
spans: claude_code_conversation (AGENT) └─ llm (LLM)
token_usage: {'input_tokens': 7411, 'output_tokens': 8,
'cache_read_input_tokens': 17192, 'cache_creation_input_tokens': 9857}
tags: chat_id=20260618-165650 turn_cost_usd=0.145098
num_turns=1 model_usage={...per-model...}
Code language: JavaScript (javascript)cost、chat 歸屬、cache 命中一目了然——prompt cache 有沒有生效,trace 上看得清清楚楚。
順帶一提:坑 4(resume 重放假 trace)就是這種「上線後持續看資料」抓出來的——驗證不是裝完那天的事。
下一步
Tracing 只是地基。接下來可以用 agent-evaluation skill 在這些 trace 上跑 LLM-as-judge:自動評每回合的回答品質、工具選擇對不對,再用 mlflow-mcp 的 register_llm_judge_scorer 掛常駐評分器。等 trace 累積夠多,「agent 最常在哪種任務上翻車」就不再是憑感覺回答的問題了。