幫 Claude Agent SDK 接上 MLflow Tracing:官方 autolog + 30 行補丁層(cost、chat id),與全手動重建 span 樹的取捨

MLflow 3.5+ 官方支援 trace Claude Agent SDK:mlflow.anthropic.autolog() 會 patch ClaudeSDKClient、從訊息流事後重建 trace。實測它缺 cost、子代理不巢狀、span 時長全假——其中 cost 和自己的 chat id 用 30 行 set_trace_tag 補丁就能補回,這是我最終落地的方案(mlflow-skinny + anthropic + python-dateutil 最小組合)。要真實時長/子代理樹則附全手動 TurnTrace 實作。外加四個坑:token stub、輕量包兩段死法、容器裡的 tracking server、resume 重放假 trace。

我有一個用 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 SDKmlflow.anthropic.autolog(),它 patch 的不是 anthropic SDK(你的行程裡根本沒有),而是 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() 硬 import anthropic。最小可用組合 = mlflow-skinny + anthropic + python-dateutil
  • 最陰的坑(兩版都要面對):AssistantMessage.usageoutput_tokens 恆為 1(streaming message_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_idmodel_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_tracesget_tracecreate_experimentlog_trace_feedbackregister_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-dateutilmlflow.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 補。三個細節:

  1. 時機:autolog 的 trace 是在 receive_response() 迭代「完全耗盡」那一刻建的——補丁必須在 async for 迴圈退出之後才呼叫(迴圈裡處理 ResultMessage 時 trace 還不存在,先把 result 存起來)。
  2. flush:trace 走 async export queue,set_trace_tag 打的是 server——要先 flush_trace_async_logging() 等它上去,不然 tag 會落空。
  3. 競態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_usdnum_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,這行根本不用寫。
  • MlflowClientget_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-mcpregister_llm_judge_scorer 掛常駐評分器。等 trace 累積夠多,「agent 最常在哪種任務上翻車」就不再是憑感覺回答的問題了。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *