iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
AI Engineering

從 MCP 到專屬 Agentic 模型:30 天走完一條可評測、可微調、可自架的 AI Agent 模型與服務製作流程系列 第 11

[ AI Agent ] Day 11 — Tracing、Event 記錄與除錯實戰:把每一輪決策變成可查的資料

  • 分享至 

  • xImage
  •  

Day 11 今日地圖:今天在整條閉環的位置、承接與產出

I. 前言:Agent 不是不能除錯,是沒有斷點

寫傳統程式時,遇到問題的處理方式相當直覺:下個中斷點、單步執行、看變數。因為程式的每一步都是我們自己寫的,行為是確定的。

Agent 不是這樣。它的每一次決策都是模型讀完上下文之後動態生成的,同一句話跑十次可能有三種結果。當它突然叫錯工具、參數填錯、或者陷入來回重試的迴圈時,斷點幫不上忙 —— 因為出問題的不是某一行程式碼,而是「模型當下看到了什麼、然後決定了什麼」。

所以 Agent 的除錯方式是不同的:不是中斷它,而是完整記錄它。

而這件事有第二個目的。Day 6 曾經提過一句:「從今天起,每次測試都把 Event Stream 保存下來。」今天要把那句話兌現成一套可以長期運作的機制 —— 因為評測階段拿它當比對對象,訓練資料階段的素材直接從它萃取。

今天記錄的東西,就是接下來二十天的原料。

以下的內容,將會說明 Event 的結構與判讀方式、用 Plugin 攔截事件流的正確做法、一份為訓練資料階段設計的紀錄格式,以及 OpenTelemetry 這一層跨行程的觀測能力。

II. Event:Google ADK 的最小觀測單位

Google ADK 裡沒有「事件類型」這種東西 —— 沒有 ToolCallEvent、也沒有 AgentResponseEvent只有一個 Event 類別,它繼承自 LlmResponse,靠內容判斷自己是什麼。

理解這一點很重要,因為它決定了解析程式該怎麼寫。

關鍵欄位

from google.adk.events import Event

表格:欄位、型別、用途

author 這個欄位在 Day 10 的雙 Agent 架構下特別有用 —— 它直接告訴您這個決定是查詢 Agent 做的,還是執行 Agent 做的。

三個判讀方法

event.get_function_calls()       # -> list[types.FunctionCall]
event.get_function_responses()   # -> list[types.FunctionResponse]
event.is_final_response()        # -> bool

這三個方法就是解析 event stream 的全部工具。判斷邏輯很單純:

if event.get_function_calls():
    # 模型決定要呼叫工具了
    for fc in event.get_function_calls():
        print(fc.name, fc.args)
elif event.get_function_responses():
    # 工具執行完,結果回來了
    for fr in event.get_function_responses():
        print(fr.name, fr.response)
elif event.is_final_response():
    # 這是要回給使用者的最終答案
    print(event.content.parts[0].text)

is_final_response() 不是「有文字就是最終回應」。 它的實際判斷是:沒有 function call、沒有 function response、不是 partial、也沒有 trailing code execution result。此外若 actions.skip_summarizationlong_running_tool_ids 有值,會直接回傳 True。多 agent 情境下,每個參與的 agent 都可能各自產生一個 is_final_response() 為真的事件,這是解析時容易誤判的地方。

一次工具呼叫會產生哪些 Event

一次 invocation 產生的 Event 序列與 Plugin 掛載點

以「把我那張家庭旅遊的特休送出審核」為例,一次成功的執行會依序產生:

1. author='user'            content: 使用者的原始指令
2. author='leave_copilot'     content: function_call(search_leaves, {...})
3. author='leave_copilot'     content: function_response(search_leaves, {...})
4. author='leave_copilot'     content: function_call(update_leave_status, {...})
5. author='leave_copilot'     content: function_response(update_leave_status, {...})
6. author='leave_copilot'     content: "已將 LV-7f3a91 送出審核"

難點 ① 的觀測點就在這裡。 如果第 2、3 步不見了、直接從 update_leave_status 開始,那就是模型捏造了 ID。這個判斷不需要理解語意,只要看 event 序列就成立 —— 這也是評測階段能夠自動化評測的基礎。

actions 裡藏著副作用

EventActions 記錄的是「這個 event 造成了什麼改變」,其中幾個值得特別注意:

表格:欄位、意義

除錯多 Agent 系統時,transfer_to_agentbranch 這兩個欄位幾乎是唯一能還原「究竟是誰做了這個決定」的線索。

III. 最直接的讀法:Runner 的事件迴圈

Runner.run_async() 回傳的是一個 AsyncGenerator[Event, None] —— 換句話說,事件流本來就是攤開在您面前的

from google.adk.runners import Runner
from google.adk.apps import App
from google.adk.sessions import InMemorySessionService
from google.genai import types

app = App(name="leave_copilot", root_agent=root_agent)
runner = Runner(app=app, session_service=InMemorySessionService())

async for event in runner.run_async(
    user_id="simon",
    session_id=session.id,
    new_message=types.Content(
        role="user",
        parts=[types.Part(text="把我那張家庭旅遊的特休送出審核")],
    ),
):
    if calls := event.get_function_calls():
        for fc in calls:
            print(f"→ {fc.name}({fc.args})")
    elif event.is_final_response():
        print(f"✓ {event.content.parts[0].text}")

寫個小腳本印出來,多數的「它到底在幹嘛」都能當場看懂。

但這個做法有個限制:記錄邏輯與執行邏輯混在一起了。等到需要同時支援 adk webadk api_server 與自己的腳本時,這段程式碼就得複製三份。

IV. Plugin:把觀測從業務邏輯裡抽出來

Google ADK 為此提供了 Plugin 機制。它是應用層級的攔截器 —— 掛一次,整個 App 底下所有 agent 的所有執行都會經過。

完整的掛載點

BasePlugin 提供的 callback 覆蓋了整個生命週期:

表格:階段、Callback、回傳非 None 的效果

注意這些 callback 的回傳值不只是「觀測」。 before_tool_callback 回傳 dict 會直接跳過工具執行after_tool_callback 回傳 dict 會取代工具結果。這代表 Plugin 不只能看,還能改 —— 這一點在系列後期談生產防禦時會再用到,但今天要先克制:觀測用的 Plugin 一律回傳 None,否則您記錄的行為與實際行為會不一致。

掛載方式:用 App,不要用 Runner

from google.adk.apps import App
from google.adk.runners import Runner

app = App(
    name="leave_copilot",
    root_agent=root_agent,
    plugins=[TrajectoryRecorderPlugin(out_dir="traces/")],
)

runner = Runner(app=app, session_service=session_service)

Runner(plugins=[...]) 已經標記為 deprecated。 原始碼裡寫得很明白:

plugins: Deprecated. A list of plugins for the runner.
         Please use the `app` argument to provide plugins instead.

而且傳了 app 又傳 plugins 會直接拋錯。網路上不少範例還停在舊寫法,照抄會拿到 deprecation warning。

兩個內建 Plugin,先用再說

在自己動手寫之前,Google ADK 已經內建了兩個能直接用的:

from google.adk.plugins import LoggingPlugin, DebugLoggingPlugin

# 1. 全部印到終端機——最快看到發生什麼事
app = App(name="leave_copilot", root_agent=root_agent, plugins=[LoggingPlugin()])

# 2. 完整資訊寫成 YAML——適合存檔或貼給別人看
app = App(
    name="leave_copilot",
    root_agent=root_agent,
    plugins=[DebugLoggingPlugin(output_path="traces/adk_debug.yaml")],
)

DebugLoggingPlugin 記錄的內容相當完整:LLM request(含 system instruction、contents、tools)、LLM response、function call 與參數、function response、runner 產出的 event,以及每次 invocation 結束時的 session state。輸出格式是 YAML,每個 invocation 一份文件、以 --- 分隔。

遇到「它為什麼這樣選」的問題時,先開這個。 多數時候答案就在 system instruction 或 tools schema 裡,而那正是這個 plugin 會完整印出來的部分。

另一個順帶一提的內建 Plugin

ReflectAndRetryToolPlugin 值得知道一下 —— 它會攔截工具失敗、把結構化的錯誤說明回饋給模型、並在上限內重試:

from google.adk.plugins import ReflectAndRetryToolPlugin

app = App(
    name="leave_copilot",
    root_agent=root_agent,
    plugins=[ReflectAndRetryToolPlugin(max_retries=3)],
)

這正是 Day 9 談自我修正時的機制,只是用 Plugin 的形式提供,而且處理了併發安全與「單一工具成功即重置該工具計數」這類細節。

V. 動手寫:TrajectoryRecorderPlugin

內建的 plugin 適合除錯,但不適合當訓練資料的來源 —— YAML 不好逐筆處理、也沒有我們想要的欄位。所以要自己寫一個。

import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

from google.adk.events import Event
from google.adk.plugins import BasePlugin
from google.adk.tools import BaseTool

# 脫敏:token、密碼、私鑰在寫入前就要處理掉
_REDACT = [
    (re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._\-]{16,}"), r"\1<REDACTED>"),
    (re.compile(r"(?i)(\"?(password|api_key|token)\"?\s*[:=]\s*\"?)[^\"\s,}]+"),
     r"\1<REDACTED>"),
]


def scrub(text: str) -> str:
    for pattern, repl in _REDACT:
        text = pattern.sub(repl, text)
    return text


class TrajectoryRecorderPlugin(BasePlugin):
    """把每一次 invocation 的完整軌跡寫成 JSONL。"""

    def __init__(self, out_dir: str = "traces"):
        super().__init__(name="trajectory_recorder")
        self.out = Path(out_dir)
        self.out.mkdir(parents=True, exist_ok=True)
        self._files: dict[str, Any] = {}

    def _sink(self, invocation_id: str):
        if invocation_id not in self._files:
            path = self.out / f"{invocation_id}.jsonl"
            self._files[invocation_id] = path.open("a", encoding="utf-8")
        return self._files[invocation_id]

    def _write(self, invocation_id: str, record: dict) -> None:
        line = json.dumps(record, ensure_ascii=False, default=str)
        self._sink(invocation_id).write(scrub(line) + "\n")

    # ---- 工具層:訓練資料的主要來源 --------------------------------

    async def before_tool_callback(
        self, *, tool: BaseTool, tool_args: dict[str, Any], tool_context
    ) -> Optional[dict]:
        self._write(tool_context.invocation_id, {
            "kind": "tool_call",
            "ts": datetime.now(timezone.utc).isoformat(),
            "tool": tool.name,
            "args": tool_args,
        })
        return None          # 一定要回 None,否則會跳過工具執行

    async def after_tool_callback(
        self, *, tool: BaseTool, tool_args: dict[str, Any],
        tool_context, result: dict
    ) -> Optional[dict]:
        self._write(tool_context.invocation_id, {
            "kind": "tool_result",
            "ts": datetime.now(timezone.utc).isoformat(),
            "tool": tool.name,
            "result": result,
        })
        return None          # 同上,回 None 才不會取代結果

    async def on_tool_error_callback(
        self, *, tool: BaseTool, tool_args: dict[str, Any],
        tool_context, error: Exception
    ) -> Optional[dict]:
        self._write(tool_context.invocation_id, {
            "kind": "tool_error",
            "ts": datetime.now(timezone.utc).isoformat(),
            "tool": tool.name,
            "args": tool_args,
            "error": f"{type(error).__name__}: {error}",
        })
        return None

    # ---- Event 層:補上 id、author、state 變更 ----------------------

    async def on_event_callback(
        self, *, invocation_context, event: Event
    ) -> Optional[Event]:
        record = {
            "kind": "event",
            "event_id": event.id,
            "ts": event.timestamp,
            "author": event.author,
            "branch": event.branch,
            "final": event.is_final_response(),
            "state_delta": event.actions.state_delta or None,
        }
        if calls := event.get_function_calls():
            record["function_calls"] = [
                {"name": c.name, "args": c.args} for c in calls
            ]
        if event.content and event.content.parts:
            texts = [p.text for p in event.content.parts if p.text]
            if texts:
                record["text"] = "".join(texts)
        self._write(event.invocation_id, record)
        return None

    async def after_run_callback(self, *, invocation_context) -> None:
        fh = self._files.pop(invocation_context.invocation_id, None)
        if fh:
            fh.close()

    async def close(self) -> None:
        for fh in self._files.values():
            fh.close()
        self._files.clear()

為什麼工具層與 Event 層都要記

這兩層拿到的東西不一樣,而且互補:

表格:before/after_tool、on_event

工具層適合抽訓練樣本,Event 層適合還原現場。 兩者寫進同一個 JSONL、用 invocation_id 串起來,就構成一份完整的軌跡。

脫敏要在寫入前做,不是事後

上面那個 scrub() 刻意放在 _write() 裡面,這是有理由的:日誌一旦寫進磁碟,就已經外洩了。事後清理只能降低擴散範圍,無法回收。

正式環境還需要處理 PII(姓名、電話、身分證號),而那通常需要專門的工具而非 regex。但至少 token 與密碼這一類的,用上面幾行就能擋掉。

VI. 記錄什麼:一份為訓練資料階段設計的格式

記錄格式如果只考慮「現在好不好讀」,到了訓練資料階段就得重做一遍。以下三個原則是為了那時候而設的。

原則一:保留完整上下文,不要只存工具參數

一筆「呼叫了 update_leave_status(leave_id='LV-7f3a91')」的紀錄,單獨看是沒有價值的。那個 ID 是從哪來的才是重點 —— 是前一步 search_leaves 查到的,還是模型憑空生的?訓練資料階段會說明為什麼這件事會決定訓練資料的品質,這裡先記住結論:紀錄的最小單位是一次完整的 invocation,不是單次工具呼叫。

原則二:標記成功與失敗,而且要能事後補標

{"kind": "verdict", "invocation_id": "e-8a31", "label": "pass", "note": "先查後改,狀態逐級推進"}

當下判斷不了沒關係,重點是格式要留得下這個欄位。手動糾正過的軌跡尤其珍貴 —— 「模型錯了、人怎麼修的」這一組配對,是後續偏好對齊最直接的素材。

原則三:一個 invocation 一個檔案

invocation_id 當檔名,好處是天然分片:要丟掉某次失敗的實驗,刪一個檔就好;要平行處理,直接切檔案清單。

落地後的樣子

traces/
├── e-8a31c4.jsonl      # 一次完整互動
├── e-9f02de.jsonl
└── e-b17a55.jsonl

單一檔案的內容:

{"kind":"event","event_id":"ev-01","author":"user","text":"把我那張家庭旅遊的特休送出審核"}
{"kind":"tool_call","tool":"search_leaves","args":{"keyword":"家庭旅遊"}}
{"kind":"tool_result","tool":"search_leaves","result":{"leaves":[{"id":"LV-7f3a91"}]}}
{"kind":"tool_call","tool":"update_leave_status","args":{"leave_id":"LV-7f3a91","status":"submitted"}}
{"kind":"tool_result","tool":"update_leave_status","result":{"ok":true}}
{"kind":"event","event_id":"ev-06","author":"leave_copilot","final":true,"text":"已送出審核"}

一路到訓練資料階段萃取訓練資料為止,這個檔案不需要再做任何加工。

VII. OpenTelemetry:跨行程的另一半

Plugin 記錄的是「Agent 內部發生了什麼」。但我們的系統至少橫跨三個行程:

Agent (adk api_server:8000) ──HTTP──> MCP Server (:8090) ──> 資料層

當一次請求變慢時,慢在模型還是慢在工具? Plugin 答不了這個問題,因為它看不到另一個行程。這是 Tracing 要解決的部分。

Google ADK 內建 OpenTelemetry

Google ADK 本身就用 OpenTelemetry 做了完整的 instrumentation,而且遵循 GenAI semantic conventions

表格:Span 屬性、值

用標準的 semantic conventions 有個實際的好處:任何支援 OTLP 的後端都能直接接,不必為 Google ADK 寫專屬的解析器。

三種開啟方式

# 1. 送到自架的 OTLP 端點(Jaeger、Grafana Tempo、Langfuse⋯)
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
adk api_server agents/ --port 8000

# 2. 送到 Google Cloud Trace
adk web agents/ --trace_to_cloud

# 3. 送到 Cloud Trace + Cloud Logging(trace / metrics / logs 都送)
adk web agents/ --otel_to_cloud

第一種在本機開發時最實用 —— 起一個 Jaeger 容器就能看到完整的 span 樹,包括每次 LLM 呼叫與每次工具呼叫各花了多久。

Google ADK 的 OTel 設定是非侵入式的:如果您已經在應用層自己設好了 TracerProvider,Google ADK 不會覆蓋它,而是沿用您的設定。這代表把 Agent 接進既有的可觀測性架構是可行的,不需要另外拉一套。

Dev Server 內建的 Trace 檢視

adk web 開發模式還提供了兩個端點,可以直接取出 span:

# 某個 session 的所有 span
curl http://localhost:8000/dev/apps/leave_copilot/debug/trace/session/{session_id}

# 某個 event 對應的 trace
curl http://localhost:8000/dev/apps/leave_copilot/debug/trace/{event_id}

回傳的是 span 的 name、trace_id、span_id、parent_span_id、起訖時間與 attributes —— 不需要架 Jaeger 就能看。

這組路徑在 2.x 換過位置。 舊版是 /debug/trace/...,2.x 之後統一收到 /dev/apps/{app_name}/ 底下,而且只有 adk web 的開發伺服器才有adk api_server 不提供。這是 Day 5 提到的「2.0 有破壞性變更」的一個具體例子。

VIII. 讓 Session 落地,不要只留在記憶體

最後一個容易忽略的設定。adk webadk api_server 預設把 session 存在記憶體裡 —— 行程一關,所有 event 都沒了。

adk api_server agents/ \
  --port 8000 \
  --session_service_uri "sqlite:///./leave_sessions.db"

加上這一行,所有 session 與 event 就會寫進 SQLite。它支援的是標準的 SQLAlchemy URI,所以 PostgreSQL 之類的也可以:

表格:URI、用途

落地之後就能用 API 隨時把歷史撈回來:

curl http://localhost:8000/apps/leave_copilot/users/simon/sessions
curl http://localhost:8000/apps/leave_copilot/users/simon/sessions/{session_id}

Plugin 寫的 JSONL 與 SQLite 裡的 session 是兩份互補的紀錄:前者是為訓練設計的、格式受我們控制;後者是 Google ADK 的原始資料、完整但格式跟著版本走。兩份都留著,訓練資料階段時會慶幸自己這麼做過。

IX. 一份由外而內的排查順序

有了紀錄之後,除錯就從「猜」變成「查」。但查也要有順序 —— 這裡整理一份到目前為止踩過的坑,由外而內排列:先確認服務活著,再往協定、框架、模型的方向收斂。

表格:症狀、先查什麼

這張表會隨著系列往後延伸。 進入評測與訓練之後會出現另一批症狀,屆時再補。

一個實務上的提醒:排查時不要跳過「服務活著嗎」這一層。 我自己踩過最多次的,就是花了二十分鐘研究模型為什麼不叫工具,最後發現 MCP Server 那個終端機視窗早就被關掉了。

X. Day 5 至 Day 11 到此收束

七天下來,我們手上有的東西是:

  • 一個接上 Leave Copilot MCP Server 的 Google ADK Agent(Day 5-7)
  • 能處理 Elicitation 三態的 callback(Day 7)
  • state 的正確用法與跨呼叫依賴的狀態管理(Day 8)
  • PlanReActPlanner 產出的結構化執行計畫(Day 9)
  • tool_filter 做權限分離的雙 Agent 架構(Day 10)
  • 以及一整批結構化的失敗軌跡(Day 11)

最後一項才是接下來的主角。攤開那些 JSONL,典型的失敗長這樣:

表格:指令、預期序列、實際序列、錯在哪

看著這份清單,最自然的反應會是:再修改幾輪 Instruction,應該就能解決了吧?

這個念頭是對的,而且是必要的步驟。然而在動手之前,有一個更基礎的問題必須先解決:

您怎麼知道改了之後有變好?

現在的「測試」是手動丟幾句話、肉眼看結果。這種方式有三個致命問題:

  • 不可重複。 每次丟的話都不太一樣。
  • 樣本太小。 模型是非確定性的,同一句話跑十次可能有三種結果。丟一次看到對的,不代表它會一直對。
  • 無法量化。 「感覺好像好一點」不是可以拿來做決策的依據。

而且就算開始量化了,還有三個更麻煩的問題等著:

  1. 比對工具呼叫時,要不要一併檢查參數?只比名字太寬鬆,連參數都比又會被無關的欄位干擾。
  2. 模型多呼叫了一次 get_leave 才更新 —— 這算錯嗎?結果是對的,而且「先確認再修改」其實是好習慣。
  3. 順序重不重要?先改後查與先查後改,最終狀態可能一樣,但行為天差地遠。

這三個問題沒有標準答案,但它們的答案會直接決定整套評測結果的可信度。明天就要處理它們。

XI. 結語

Agent 的可觀測性不是「順便做一下」的工程衛生,它是後續所有工作的前提 —— 沒有紀錄,就沒有評測;沒有評測,就沒有訓練資料。

總結來說,今天有三個重點值得帶走:

  • Google ADK 只有一個 Event 類別,靠內容判斷身分: get_function_calls()get_function_responses()is_final_response() 這三個方法就是解析事件流的全部工具。而 is_final_response() 在多 Agent 情境下每個 agent 都可能為真,這是最容易誤判的地方。
  • Plugin 是攔截事件流的正確位置,而且要用 App 掛載: Runner(plugins=[...]) 已經 deprecated。另外要特別注意,觀測用的 callback 一律回傳 None —— 回傳非 None 會改變實際行為,讓紀錄與事實脫節。
  • 記錄格式現在就要為訓練資料階段設計: 保留完整 invocation 上下文、留下可事後補標的 verdict 欄位、在寫入前就完成脫敏。這三件事現在多花十分鐘,訓練資料階段會省下重跑一整批實驗的時間。

明天起的三天。要打造的第一把尺是 ADEval —— 而在看它怎麼用之前,得先把上面那三個問題的答案講清楚。

Day 11 Cheat Sheet:指令、參數與容易踩的地方


參考來源

  • google/adk-python——google-adk 2.7.1
  • google/adk/events/event.py——Event 欄位、is_final_response() / get_function_calls() / get_function_responses()
  • google/adk/events/event_actions.py——state_deltatransfer_to_agentrequested_tool_confirmationsescalate
  • google/adk/plugins/base_plugin.py——完整 callback 清單與各自的回傳語意
  • google/adk/plugins/debug_logging_plugin.py——DebugLoggingPlugin(output_path=...) YAML 輸出內容
  • google/adk/plugins/reflect_retry_tool_plugin.py——ReflectAndRetryToolPlugin(max_retries=...)
  • google/adk/apps/app.pygoogle/adk/runners.py——App(plugins=[...])Runner(plugins=) 的 deprecation 訊息
  • google/adk/telemetry/tracing.py——GenAI semantic conventions span 屬性
  • google/adk/cli/cli_tools_click.py——--trace_to_cloud--otel_to_cloud--session_service_uri
  • google/adk/cli/dev_server.py——/dev/apps/{app_name}/debug/trace/session/{session_id}
  • OpenTelemetry・GenAI semantic conventions——屬性命名標準

查證日期:2026-08-24


I am Simon

大家好,我是 Simon 劉育維,是一位 AI 領域解決方案專家,目前也擔任 Google Cloud AI 領域開發者專家 (GDE),期待能夠幫助企業導入人工智慧相關技術解決問題。如果這篇文章對您有幫助,歡迎在我的 Linkedin 上留言提供意見,並與我一起討論有關人工智慧的主題,期待能夠對大家有所幫助!

我的個人部落格資訊:https://medium.com/@simon3458


上一篇
[ AI Agent ] Day 10 — Multi-Agent 協作與權限分離:權限邊界該畫在哪裡
下一篇
[ DevOps in AI Agent ] Day 12 — ADEval 架構與指標體系:知道指標的偏誤,比知道分數更重要
系列文
從 MCP 到專屬 Agentic 模型:30 天走完一條可評測、可微調、可自架的 AI Agent 模型與服務製作流程30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言