AG Day 17 Human-in-the-loop:interrupt 與人工核准
執行需求:CPU 可跑。在昨天 AG Day 16(原文連結)中,我們替 research-agent 接上了 SQLite 版的 checkpointer,讓代理能透過 thread_id 記住每一個研究任務的完整對話狀態,即使程式重啟也能從上次停下的地方繼續。今天要利用這個「能被中斷、能被恢復」的能力,實作一個對正式系統很重要的設計:讓代理在準備執行高風險動作之前,主動停下來,等待人類明確核准之後才繼續,而不是自顧自地把所有決定都執行完。今天全部範例都能在沒有 API 金鑰的情況下跑完,唯一需要提前準備的是昨天建立的 checkpointer,因為人工核准機制完全建立在「能被中斷、能被恢復」這個能力之上。
引言
到目前為止,research-agent 的工具都相對溫和:查資料、抓網頁,出錯了大不了重試或回報失敗,不會對外部世界造成不可逆的影響。但一個真正要拿去用的研究助理,遲早會需要做一些「有代價」的動作:呼叫按用量計費的付費 API、把整理好的報告寄出去、或是把資料寫進一個別人也在用的正式資料庫。如果代理完全自主地決定什麼時候該做這些事,一旦提示詞設計有漏洞,或模型偶爾判斷失準,後果可能不是「重試一次就好」,而是「已經花了錢」或「已經寄出去了,收不回來」。
Human-in-the-loop(人機協作、簡稱 HITL)的核心想法很直接:在流程裡插入一個「暫停點」,讓真人看過目前的狀態、決定要不要放行,代理才能繼續往下走。這聽起來像是要另外寫一套暫停與恢復的機制,但昨天我們已經把地基打好了:只要圖有 checkpointer,LangGraph 就能在任何節點裡呼叫一個叫 interrupt() 的函式,暫停目前的執行、把控制權交還給呼叫端;等真人做出決定後,呼叫端再用 Command(resume=...) 把決定送回去,圖會從暫停的那一點,帶著這個決定繼續往下跑。今天我們就是要把這個機制,安裝在 research-agent 呼叫外部搜尋服務之前。
原理與觀念
interrupt() 怎麼暫停一個正在執行的節點
interrupt() 是 LangGraph 提供的一個函式,你可以在圖的任何節點函式內部呼叫它,並傳入一個你想讓人類看到的資料(例如「即將呼叫搜尋 API,關鍵字是什麼、預估會用掉多少額度」)。呼叫這個函式時,LangGraph 會利用先前設定好的 checkpointer,把當下的狀態完整存下來,然後拋出一個特殊的訊號讓整個 invoke 或 stream 呼叫提前結束,並把你傳入 interrupt() 的資料回傳給呼叫端。從呼叫端的角度看,這次呼叫並沒有跑完整張圖,而是在某個節點「卡住」了,等待進一步指示。
用 Command(resume=...) 把決定送回去
真人看到中斷資料、做出決定之後(例如核准或拒絕),呼叫端要再呼叫一次 app.invoke(Command(resume=決定內容), config=同一個thread_id的config)。這裡的關鍵是要帶上同一個 thread_id,這樣 LangGraph 才知道要從哪一個被中斷的快照繼續。圖恢復執行時,原本呼叫 interrupt() 的那一行程式碼,會像函式呼叫直接回傳一樣,把 resume 帶的內容當成回傳值,讓節點函式可以根據這個值決定接下來要做什麼,例如核准就真的呼叫外部服務、拒絕就回傳一則說明訊息給使用者。
為什麼一定要有 checkpointer 才能用 interrupt
這也是為什麼我們特意把記憶功能排在人工核准之前講解:interrupt() 能運作的前提,是圖已經設定了 checkpointer。沒有 checkpointer,圖沒有地方可以存放「中斷當下的完整狀態」,resume 時也就無從恢復起,程式只會直接報錯或行為不如預期。這個相依關係在官方文件裡有明確說明,但初學者常常想跳過記憶章節、直接用 interrupt,結果卡在莫名其妙的錯誤訊息上。
中斷不是例外,是一種正常的流程分支
剛接觸這個機制的人,常常把「中斷」跟程式語言裡的例外(exception)搞混,覺得 interrupt() 像是丟出一個錯誤、要用 try/except 接住。實際上中斷是圖執行過程中一種正常、預期內的流程分支,跟工具呼叫失敗完全是兩回事:工具失敗代表「事情沒有照計畫走」,需要昨天談的錯誤處理機制;中斷則代表「事情正照計畫走,只是這個計畫裡有一步需要先問過人」。把這兩種情境的心智模型分開,之後在設計更複雜的核准規則時,才不會把「失敗」與「需要核准」這兩種完全不同的狀態混為一談,錯誤地用同一套處理邏輯去應付。
完整實作
我們把「需要核准」的判斷邏輯,加在 search_web 這個工具前面,模擬一個「單次搜尋若標記為高成本查詢,需要人工核准才能真的送出」的情境。先在 src/research_agent/tools.py 補一個判斷式:
# research-agent/src/research_agent/tools.py(新增)
EXPENSIVE_KEYWORDS = ("深度分析", "大量", "全面")
def is_expensive_query(query: str) -> bool:
"""簡化示範:包含特定關鍵字的查詢視為高成本,正式規則應依實際計費規範調整。"""
return any(keyword in query for keyword in EXPENSIVE_KEYWORDS)
接著在 src/research_agent/graph.py 新增一個獨立節點 approval_gate,放在 agent 節點與 tools 節點之間,專門負責判斷要不要中斷、以及中斷後怎麼處理人類的決定:
# research-agent/src/research_agent/graph.py(新增節點)
from langgraph.types import interrupt, Command
from research_agent.tools import is_expensive_query
def approval_gate(state):
"""檢查最後一次工具呼叫是否為高成本查詢,是的話中斷等待人工核准。"""
last_message = state["messages"][-1]
tool_calls = getattr(last_message, "tool_calls", None) or []
for call in tool_calls:
if call["name"] == "search_web" and is_expensive_query(call["args"].get("query", "")):
decision = interrupt({
"type": "approval_required",
"tool": call["name"],
"args": call["args"],
"reason": "偵測到高成本查詢關鍵字,需要人工核准後才會實際送出。",
})
if decision != "approve":
# 拒絕時,直接把這個呼叫改標記為已拒絕,讓後續改回覆說明文字
call["args"]["_rejected"] = True
return state
把 approval_gate 接進圖裡,注意它要放在 agent 決定呼叫工具之後、真正執行 tools 節點之前:
# research-agent/src/research_agent/graph.py(調整邊的連法)
def build_graph(checkpointer=None):
graph = StateGraph(MessagesState)
graph.add_node("agent", call_model)
graph.add_node("approval_gate", approval_gate)
graph.add_node("tools", tool_node)
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition, {"tools": "approval_gate", END: END})
graph.add_edge("approval_gate", "tools")
graph.add_edge("tools", "agent")
return graph.compile(checkpointer=checkpointer)
然後寫一段驗證腳本,模擬「代理想做高成本查詢 → 中斷 → 人工核准 → 繼續執行」的完整流程:
# research-agent/verify_interrupt.py
from langchain_core.messages import HumanMessage
from langgraph.types import Command
from research_agent.graph import build_graph
from research_agent.memory import get_checkpointer
app = build_graph(checkpointer=get_checkpointer())
config = {"configurable": {"thread_id": "hitl-demo-001"}}
result = app.invoke(
{"messages": [HumanMessage(content="幫我對這個主題做一次深度分析搜尋")]},
config=config,
)
if "__interrupt__" in result:
payload = result["__interrupt__"][0].value
print("流程已中斷,等待人工核准:")
print(" - 工具:", payload["tool"])
print(" - 參數:", payload["args"])
print(" - 原因:", payload["reason"])
# 模擬真人核准
final = app.invoke(Command(resume="approve"), config=config)
print("\n核准後繼續執行,最終回覆:", final["messages"][-1].content[:80])
else:
print("此次呼叫未觸發中斷,最終回覆:", result["messages"][-1].content[:80])
離線模式下的示範輸出:
流程已中斷,等待人工核准:
- 工具: search_web
- 參數: {'query': '這個主題做一次深度分析搜尋'}
- 原因: 偵測到高成本查詢關鍵字,需要人工核准後才會實際送出。
核准後繼續執行,最終回覆: (模型根據離線模擬搜尋結果整理出的示範回應)
最後,示範「拒絕」的路徑,讓 CLI 使用者也能明確選擇不核准:
# research-agent/verify_interrupt_reject.py
from langchain_core.messages import HumanMessage
from langgraph.types import Command
from research_agent.graph import build_graph
from research_agent.memory import get_checkpointer
app = build_graph(checkpointer=get_checkpointer())
config = {"configurable": {"thread_id": "hitl-demo-002"}}
result = app.invoke(
{"messages": [HumanMessage(content="幫我對這個主題做一次深度分析搜尋")]},
config=config,
)
if "__interrupt__" in result:
final = app.invoke(Command(resume="reject"), config=config)
print("已拒絕,最終回覆:", final["messages"][-1].content[:80])
正式系統裡,核准者往往不是寫程式的人,不會直接呼叫 Python 腳本。我們在 runs 資料表補一個 status 欄位的更新函式,讓中斷發生時同步把任務狀態標記為「等待核准」,之後可以接上任何形式的通知或審核介面,而不必假設核准者一定守在終端機前:
# research-agent/src/research_agent/storage.py(新增)
import sqlite3
from pathlib import Path
def mark_run_status(db_path: Path, run_id: str, status: str) -> None:
"""更新任務狀態,供人工核准流程與監控儀表板查詢使用。"""
with sqlite3.connect(db_path) as conn:
conn.execute(
"UPDATE runs SET status = ? WHERE run_id = ?",
(status, run_id),
)
conn.commit()
def list_pending_approvals(db_path: Path) -> list[tuple[str, str]]:
"""列出所有等待人工核准的任務,供審核介面或排程通知使用。"""
with sqlite3.connect(db_path) as conn:
rows = conn.execute(
"SELECT run_id, user_query FROM runs WHERE status = 'PENDING_APPROVAL'"
).fetchall()
return rows
常見錯誤與踩雷
第一個常見錯誤,就是前面提過的忘記設定 checkpointer,導致呼叫 interrupt() 時得到令人困惑的錯誤訊息。看到跟 interrupt 相關的例外,第一件事永遠是先檢查 build_graph() 有沒有真的傳入 checkpointer、invoke 有沒有帶正確的 thread_id。
第二個是把太多邏輯塞進 interrupt() 傳入的資料裡,卻忘記這份資料最終需要被序列化、存進 checkpoint 資料庫。如果傳入的物件裡有不能被序列化的內容(例如某些自訂類別的實例、開啟中的檔案物件),儲存快照時可能失敗。建議 interrupt() 傳入的內容盡量維持成單純的字典、字串、數字這類基本型別,需要顯示更複雜資訊時,先自己轉換成純文字描述。
第三個雷是誤以為 resume 之後,圖會從頭重新執行一次。實際上恢復執行只會從中斷的那個節點繼續,之前已經執行過的節點不會重跑。這代表如果你在 approval_gate 之前的節點裡有副作用(例如寫了一筆日誌),核准或拒絕都只會觸發一次,不會因為呼叫了兩次 invoke 就重複寫入兩次。但如果誤把有副作用的程式碼寫在 approval_gate 節點「呼叫 interrupt() 之後」的部分,這段程式碼確實只會在恢復執行時才跑到,設計時要清楚每一段程式碼相對於中斷點的執行時機。
第四個常見誤解是把人工核准當成萬用的安全機制,對每個節點都無差別地插入中斷。這樣做會讓一個原本能自動跑完的研究任務,變成每一步都要人盯著,完全失去自動化的意義,也會讓使用者覺得系統很難用。人工核准應該只用在真正有代價、不可逆、或牽涉外部資源消耗的少數節點上,其餘節點維持全自動,這也呼應了我們在系列一開始就強調的原則:好的代理系統不是「什麼都問過人」,而是清楚知道哪裡真正需要人。
效能與實務提醒
從系統設計的角度看,加入人工核准之後,一個研究任務的生命週期可能會拉得很長:使用者可能提交查詢後就離開,幾個小時後才回來核准。這代表我們不能假設呼叫 invoke 的行程會一直存活著等待 resume;正式環境應該把「等待核准中」的任務狀態記錄在 runs 資料表(例如新增一個 status = "PENDING_APPROVAL"),並透過通知機制(Email、聊天機器人訊息等)提醒該核准的人,而不是讓一個 HTTP 請求或 CLI 行程一直卡著不釋放資源。這也代表 interrupt() 回傳的中斷資料,最好在 API 層額外整理成一份獨立的「待核准清單」回應格式,讓前端或審核介面不必理解 LangGraph 內部的資料結構,只需要知道「哪個任務、要核准什麼、核准或拒絕的按鈕分別對應哪支 API」。這種介面設計上的解耦,會在 AG Day 38 實作 FastAPI 部署時看到具體效果。
核准的時效性也是實務上容易忽略的一環。如果一個任務等待核准超過合理時間(例如二十四小時),系統應該有機制自動把它標記為逾時、取消,或是重新提醒核准者,而不是讓任務無限期停留在「等待核准」狀態,佔用著一份永遠不會被回收的檢查點快照。
另外要注意 interrupt() 傳入的內容,應該包含足夠的脈絡讓核准者能做出判斷,但不要洩漏不必要的敏感資訊。我們範例裡回傳了完整的工具參數,正式系統中如果參數涉及使用者個資,建議先做遮罩或摘要處理,再呈現給核准介面。
最後,建議把「哪些工具、哪些條件需要核准」抽成設定檔或資料表,而不是像今天範例一樣寫死在 EXPENSIVE_KEYWORDS 這種簡單關鍵字判斷裡。正式系統的高成本判斷邏輯可能牽涉即時的用量與預算查詢,把判斷邏輯與圖的結構解耦,未來調整規則時才不需要改動核心流程程式碼,也比較容易替不同的核准規則各自寫單元測試。
小結
今天我們替 research-agent 加上了人工核准機制:在 agent 決定呼叫工具、真正執行工具之前,插入一個 approval_gate 節點,用 interrupt() 暫停流程並回報「需要核准的原因與細節」;真人做出決定後,用 Command(resume=...) 把決定送回去,讓圖從中斷點繼續執行。這一切之所以可行,是因為昨天已經替圖裝上了 checkpointer——沒有記憶能力,就沒有可以恢復的「中斷點」。
今天新增的關鍵詞:人機協作(Human-in-the-loop)——在自動化流程中安排真人決策點的設計模式;中斷(interrupt)——暫停圖的執行並把控制權交還呼叫端;恢復(resume)——帶著人類的決定,讓圖從中斷點繼續往下跑。這套機制的價值不在於「讓代理變慢」,而是在少數真正有代價的節點上,把最終決定權留給人類,讓自動化與風險控管可以並存,而不是二選一。
結語
目前為止 research-agent 的圖結構還算單純:一個 agent 節點、一個 tools 節點、加上今天新增的 approval_gate。但接下來我們會陸續加入檢索、報告產生等更多功能,如果全部塞在同一張圖裡,圖會變得又肥又難測試。
明天,我們會進入「AG Day 18 子圖:把研究流程模組化」,把研究流程拆成幾個獨立、可以個別測試的子圖模組,並示範子圖之間如何共享或隔離狀態,讓 research-agent 的架構在功能持續增加時依然保持清晰、容易維護。
延伸資源
- LangGraph 官方文件:Human-in-the-loop 概念與
interrupt()、Command的使用說明。 - LangGraph 官方文件:Persistence 章節中關於中斷與恢復對 checkpointer 的相依說明。
- NIST AI 風險管理框架(AI RMF):關於自動化系統中人工監督(human oversight)設計原則的參考資料。
留言
張貼留言