iT邦幫忙

2026 iThome 鐵人賽

DAY 12
1

Day 12 | 事件驅動架構:Callbacks、Events 與 Plugins

讀完能做到:知道要在哪個掛載點攔截 agent 行為、看懂一個 event 到底代表什麼、以及為什麼做安全 guardrail 時官方建議你用 Plugin 而不是 Callback。這是第二篇「裝備升級」的最後一天,也是理解後面所有多 agent 編排的地基。

一切都是 Event

前六天講了工具、MCP、grounding、session、context 最佳化——但這些東西怎麼串起來,變成一個真正在跑的 agent 系統?答案是:session 裡發生的每一件事,都是一個 Event。使用者的訊息是 event、agent 的回覆是 event、工具呼叫是 event、連 state 或 artifact 的更新都是包在一個 event 裡發生的。

理解這一點,是理解 ADK 2.0 之後整個 graph 執行引擎的前提——event 是整個系統狀態一致性的唯一真相來源。下一節會看到,這條原則具體落實成一條規則:連 state 變更本身,都要包在 event 裡才算數。

https://ithelp.ithome.com.tw/upload/images/20260908/20183762Bg1feIImXt.png

Event 的解剖:一個物件裡有什麼

概念上,EventLlmResponse 的擴充——除了回應內容本身,還加了幾個 ADK 專屬欄位:

  • author:誰發的,'user' 或 agent 名稱
  • invocation_id:標記整輪互動(同一次使用者輸入觸發的所有 event——包含中間的工具呼叫、模型回應——共用同一個 invocation_id
  • id:這個 event 自己的唯一識別碼
  • timestamp:發生時間
  • actionsEventActions,管副作用與控制流——待會會看到,這個欄位才是真正有戲的地方
  • branch:階層路徑(多 agent 樹狀結構裡,標記這個 event 是從哪一條 agent 路徑產生的,例如 root_agent.sub_agent

官方文件用四個實用問題當骨架,教你怎麼「讀」一個 event:

  • 來源與類型:是誰發的、屬於哪一種
  • 關鍵資訊:文字內容、函式呼叫參數
  • 動作與副作用:有沒有 state 或 artifact 變更
  • 是不是最終回應:有專門的 helper method is_final_response()

最後那一項官方建議直接用 helper,不要自己重造判斷邏輯——串流輸出(partial=True)跟工具呼叫的中繼結果,都會讓「這是不是最後一句話」變得沒有表面上看起來那麼直觀。

實際的 event 長什麼樣,看幾個典型例子最快:

這是什麼 event 認出它的特徵
使用者輸入 actions 通常是空的
agent 最終文字回應 partialfalseNoneturn_complete 只有串流模式才會是 true,非串流模式通常是 None
串流中的文字片段 partial: true,後面接一串同樣標記的 event
工具呼叫請求 content.parts 裡放的是 function_call
純 state/artifact 更新 content 甚至可能是 null,只靠 actions.state_deltaartifact_delta 傳遞資訊
agent 之間的責任轉移 actions.transfer_to_agent
LoopAgent(會重複執行子 agent 直到滿足條件的內建 workflow agent,第三篇會展開)裡子 agent 想跳出迴圈 actions.escalate: true

實務提醒:非串流模式下 partialturn_complete 常常是 None 而不是 False/True,直接寫 if event.turn_complete: 容易誤判成「還沒結束」。判斷是不是最終回應,一律用前面提到的 event.is_final_response() helper,不要自己比對這兩個欄位。

表格最後兩列值得多停留一下,因為那兩個欄位不是只有框架會設——Day 6 的工具函式,透過 tool_context.actions,可以直接自己設定它們

  • tool_context.actions.transfer_to_agent = "support_agent":偵測到某個確定性條件(例如查詢裡出現「緊急」關鍵字)時直接觸發轉移,完全不用等模型自己生成一次 transfer_to_agent 的 function call。
  • tool_context.actions.escalate = TrueLoopAgent 裡的工具可以直接喊停,比靠一個獨立的 LLM 檢查 agent 判斷「該不該停」更確定,也更省一次模型呼叫。

這正是貫穿這三十天的那條線——什麼交給模型決定、什麼用確定性程式碼鎖死——在事件系統這一層的具體切入點。

Event Loop:Runner 跟執行邏輯的一場接力賽

驅動這一切的心跳是 Event Loop,分工很清楚:Runner 是 orchestrator,管理整體執行流程;執行邏輯(Agent、Tool、Callback)負責做事,做完之後 yield 出一個 event。官方用 yield / pause / resume 的循環描述這個過程——執行邏輯把 event 交給 Runner,Runner 負責持久化、狀態提交、路由到下一步,再把控制權交還。

Day 1 提過 ADK 2.0 之後禁止手動 append event——精確地說,是 graph 節點不能自己把 event 塞進 session,必須把 event yield 出來,交由框架呼叫 SessionService.append_eventappend_event 這個方法本身仍然公開存在,只是呼叫它的人從「你的節點程式碼」變成了「框架」,這條界線正是接下來狀態一致性討論的關鍵。

這裡有一個新手很容易誤解、但寫過生產程式碼就會踩到的細節:透過 CallbackContextToolContext 改的 state,不會立即生效(Python v2.8.0 實測這兩個名字已經統一成同一個 Context 類別,CallbackContextToolContext 只是保留下來的別名;官方文件仍把它們當兩個獨立型別描述,這裡先沿用文件寫法,實際寫程式時不用擔心要記兩套 API)。它走的是這條路:

  1. 你在 callback 裡改了 state
  2. 變更先填進「即將產生的下一個 event」的 EventActions.state_delta
  3. SessionService.append_event 讀到這個 event,才真正套用到持久化儲存

也就是說,狀態變更永遠是跟著 event 串流按時間順序記錄的,不是你一改就馬上落地。這也是為什麼繞過 event 系統、直接改 session 物件的 state 會被官方明確警告——那筆變更根本不會被記錄成任何 event,之後排查問題時完全對不上時間軸。

連帶的還有 "dirty reads"(借用資料庫的講法:讀到別人還沒 commit 的資料):同一個 invocation 內,可能讀到尚未提交的 state 變更。寫 ParallelAgent(讓多個子 agent 平行執行的內建 workflow agent,同樣留到第三篇展開)這類並行流程時,這個行為會直接影響你的邏輯是否正確——如果你以為兩個平行分支之間可以即時看到彼此剛寫入的 state,實際跑起來很可能不是這樣。

Callbacks:三類掛載點

Callbacks 是掛在 agent 執行生命週期特定時點的自訂函式,用來觀察、修改、或直接攔截 agent 行為。核心掛載點分三類:

類別 Callback
Agent 生命週期 before_agent_callbackafter_agent_callback
LLM 互動 before_model_callbackafter_model_callback
工具執行 before_tool_callbackafter_tool_callback

除了這六個,LlmAgent 還多了 on_model_error_callbackon_tool_error_callback 兩個錯誤處理掛載點——遇到模型或工具丟例外時觸發

https://ithelp.ithome.com.tw/upload/images/20260909/20183762R45OxvS8Bc.png
來源:官網

Agent 層的兩個包住「整個請求處理過程」——從收到輸入到產出最終答案,中間所有的模型呼叫、工具呼叫全都在裡面。一個 before_model_callback 的實際簽章長這樣:

from google.adk.agents import LlmAgent
from google.adk.agents.callback_context import CallbackContext
from google.adk.models import LlmResponse, LlmRequest
from typing import Optional

def simple_before_model_modifier(
    callback_context: CallbackContext, llm_request: LlmRequest
) -> Optional[LlmResponse]:
    """Inspects/modifies the LLM request or skips the call."""
    agent_name = callback_context.agent_name
    last_user_message = ""
    if llm_request.contents and llm_request.contents[-1].role == 'user':
         if llm_request.contents[-1].parts:
            last_user_message = llm_request.contents[-1].parts[0].text
    # ... 檢查/修改 llm_request,或回傳一個 LlmResponse 直接跳過這次模型呼叫 ...

callback_contextllm_request 這兩個參數名不能隨意更改——ADK 是用 keyword argument 呼叫 callback 的,改了名字會在執行期直接噴 TypeError: got an unexpected keyword argument(實測於 Python v2.8.0)。掛上這個 callback 本身很直接:把它指定給 LlmAgent(before_model_callback=simple_before_model_modifier, ...) 就是掛載動作,其他五個掛載點是同一套指定方式。

這個回傳值的設計值得注意——before_model_callback 如果回傳一個 LlmResponse,框架就會直接用這個回應,完全跳過真正的模型呼叫。這是做快取、做內容安全過濾(發現違規內容直接擋掉,不用真的把它送去給模型)最直接的切入點;before_tool_callback 也是同樣的模式,可以在工具真正執行之前攔截並改變行為。

這個特性有個附帶好處:只要 before_model_callback 回傳了 LlmResponse,整個流程完全不會碰到真正的模型——想先動手驗證 guardrail 邏輯本身,不需要 API key、不需要登入 GCP,本機就能跑通。

這裡有一個真的會讓人抓頭的地雷,藏在 after_agent_callback。三個 after_ callback 看起來是同一套模式,回傳非 None 的值時語意卻不一樣:

Callback 回傳非 None 的語意
after_model_callback 取代原本的 LlmResponse
after_tool_callback 取代原本的工具結果
after_agent_callback 附加在 agent 輸出之後,多出一個 event

差別就卡在最後一列。如果你抱著「取代」的直覺,把 agent 剛產生的結果原封不動 return 回去——想著「反正沒改,這樣應該等於什麼都沒做」——Python 端會因此多發出一個內容一模一樣的重複 event,使用者畫面上就會看到同一句話被講兩次。

正確的「什麼都不做」寫法只有一種:return None

Plugin:跟 Callback 是同一套機制,範圍與掛載點都不同

Plugin 是一段自訂程式碼模組,透過 callback 掛鉤在 agent workflow 生命週期的各個階段被執行,用在適用於整個 agent workflow 的功能上。

Plugin 建立在 Callback 之上——這是 ADK 可擴充架構的關鍵設計元素。兩者的差異有兩層:第一層是作用範圍——一般的 Agent Callback 是設定在單一 agent、單一工具、針對特定任務的;Plugin 則是在 Runner(或透過 App 帶入,見下方範例)上註冊一次,它的 callback 就全域套用到那個 runner 管理的每一個 agent、工具、LLM 呼叫。第二層是掛載點的數量——Plugin 除了跟 Agent Callback 同名的那幾個,還多出 before_run_callbackafter_run_callbackon_user_message_callbackon_event_callbackon_agent_error_callbackon_run_error_callback 這類 Agent Callback 沒有的 hook。其中 on_event_callback 特別值得點名——它在每個 event 要被串流給 client 之前觸發,讓你能直接修改 event 本身,是這篇「事件驅動架構」裡唯一一個直接對 event 動手的掛載點。這些差異決定了兩者的自然分工——需要「只在這個 agent 這樣做」時用 Callback,需要「整個系統一致地這樣做」時用 Plugin。

官方給的典型應用有五類:

  • 日誌與追蹤:統一記錄每一次 agent、工具、模型呼叫
  • 政策執行:安全 guardrail,例如檢查使用者有沒有權限用某個工具
  • 監控與指標:收集 token 用量、執行時間,送到 Prometheus 或 Google Cloud Observability
  • 回應快取:重複請求直接回傳快取結果,跳過昂貴的模型呼叫
  • 請求或回應修改:動態把資訊加進 prompt,或統一化工具輸出格式

前兩類官方已經有現成品可以直接掛,不用自己寫:LoggingPluginBigQueryAgentAnalyticsPlugin。想看每個掛載點實際觸發的順序,Runner(..., plugins=[LoggingPlugin()]) 一行就夠了——這也是理解今天整篇「執行順序」最快的驗證方法。

官方明確給了一個提示(Tip):實作安全 guardrail 與政策時,用 Plugin 比用 Callback 更模組化、更有彈性——這句話值得特別記住,因為很多人的直覺是「先加個 callback 應付一下」,結果後來發現同樣的邏輯要在十個 agent 裡各貼一次,這時候才想到應該一開始就用 Plugin。

有一件事在 ADK 2.0 之後從「有幫助」變成「唯一正式管道」——用 Plugin 跟 Callback 做橫切關注點(cross-cutting concerns),已經不是錦上添花的選配功能,而是官方認可、注入自訂執行邏輯的正式方式,因為 2.0 的 graph engine 對「誰能改 event、誰能改流程」的掌控比 1.0 版本嚴格得多。

想自己寫一個 Plugin,最小骨架長這樣(繼承 BasePlugin,方法一律要 async def、參數是 keyword-only,實測可跑於 Python v2.8.0):

from google.adk.plugins.base_plugin import BasePlugin

class PolicyPlugin(BasePlugin):
    async def before_tool_callback(self, *, tool, tool_args, tool_context):
        if tool.name == "delete_user":
            return {"error": "沒有權限"}  # 回傳非 None 就直接擋下,工具本體不會執行
        return None

註冊時掛到 Runner(..., plugins=[PolicyPlugin(name="policy")])

一個官方打包好、可以直接掛的例子ReflectAndRetryToolPluginPython v1.16.0 / Go v0.5.0)。工具呼叫失敗時攔截錯誤、把結構化錯誤說明回饋給模型讓它反思修正,再自動重試到上限。以下範例假設你前面已經有一個 root_agent(例如 Day 3 建的那個):

from google.adk.apps.app import App
from google.adk.plugins import ReflectAndRetryToolPlugin

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

這正好補上前面「ADK 2.0 自動攔截例外以支援自動重試」這句話缺的另一半——RetryConfig 只負責「要不要重試」,這個 plugin 負責「讓模型知道上次錯在哪、下次該怎麼改」。

順帶一提,Plugin 不只能掛在 App 上,也可以直接掛在 Runner(plugins=[...])——兩種寫法都有效,前面「在 Runner 上註冊一次」講的是機制本身,App 只是另一個能把 plugins=[...] 帶進去的入口。

執行順序:Plugin 先於 Callback,而且會短路

這是這篇文章最值得記住的一句話:Plugin 的 callback 一律先於物件層(agent/tool)的 callback 執行,而且會短路後者。如果一個 Plugin 的 before_tool_callback 已經回傳了結果(例如判斷使用者沒有權限,直接擋下來),那麼這個工具自己定義的 before_tool_callback 根本不會被執行。這個順序邏輯是設計出來支撐「Plugin 適合做全域政策執行」這個定位的——政策層要能夠真正擋住底下的行為,而不是被個別 agent 的 callback 繞過去,順序上就必須是 Plugin 先跑。

銜接

第二篇「裝備升級」到今天走完七天:工具、MCP/OpenAPI、Grounding、Sessions、Context 壓縮、Caching 與 Artifacts,最後用 Callbacks/Events/Plugins 把整個執行模型串起來。這七天累積的東西,是接下來第三篇「戰術編排」的地基——從 Graph Workflows 開始,你會看到今天講的 Event、State delta、Plugin 優先序,全部變成理解一個節點怎麼跟另一個節點交接資料的關鍵詞彙。


Google ADK 官方網站
GitHub - Agent Development Kit (ADK) 2.0

GitHub 開源實作:https://github.com/SeanLinH/adk_tutor


上一篇
Day 11 - Caching 與 Artifacts:省錢與管檔案的兩把工具
下一篇
Day 13 - 告別失控的 AI:Graph Workflows 與 Graph Routes
系列文
Google ADK Agent 教戰:30 天從原型到可上線的 AI Agent 系統21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言