Day 25 的確認流程只有兩個畫面狀態:等待核准,或按下按鈕後執行。但真正的系統不會永遠停在瀏覽器頁面上。核准者可能關掉分頁、正在上課,或看到通知後忘了處理。
如果 Agent 在等待期間一直占著一條 request,半小時後多半只會得到逾時;如果它把「沒有人拒絕」理解成「可以繼續」,不可逆操作就會繞過人工核准。
這一篇我沿用 Day 25 的附件刪除操作,加入非同步核准、逾時、取消與 escalation。我把工作拆成不同風險的分支:危險操作停在核准點,無關的唯讀工作仍可繼續。
使用者 Prompt
→ Live LLM 提出 DeleteAttachmentProposal
→ 建立 approval request:pending
→ 危險分支暫停
→ 測試時鐘推進 30 分鐘又 1 秒
→ approval request:expired
→ irreversible_handler:skipped
→ escalation:required
→ 無關的唯讀工作仍可繼續
這裡沒有真的等三十分鐘。demo 使用固定時間建立 request,再把測試時鐘推到 expires_at + 1 秒,因此每次都能重現同一個結果。模型只負責提出操作;逾時與狀態轉移都是確定性程式。
同步核准適合短暫停頓:同一個人還在畫面上,系統等待幾十秒取得確認。它的優點是流程單純,缺點是連線斷掉或等待太久時很難恢復。
非同步核准則要把狀態保存下來:
approval_id
operation digest
subject
created_at
expires_at
status
請求先回傳 pending,核准者之後從另一個 request 回來處理。LangGraph 的 interrupt 也採用相同方向:執行暫停後由 checkpointer 保存狀態,之後必須用同一個 thread_id 恢復;正式環境應使用持久化 checkpointer,而不是只放在記憶體。LangGraph:Interrupts
我的 demo 沒有把整個 Agent graph 掛起來,而是只保存最小 approval object。這讓逾時規則比較容易測,但也代表服務重啟後狀態會消失,不能當成正式佇列。
我為這個實驗寫的狀態大致如下:
pending ──approve──> approved ──execute──> consumed
│
├─cancel────────> cancelled
│
└─timeout───────> expired ──notify────> escalated
expired 和 escalated 都不是 approved。escalation 只代表要交給人工追蹤,例如建立待辦或通知值班人員;它不能順便執行原本的刪除。
OWASP 的 Transaction Authorization 指南也建議讓授權資料只在有限時間內有效,並在真正執行前再確認操作確實已被授權。OWASP:Transaction Authorization Cheat Sheet
因此逾時後,trace 必須清楚留下:
approval_timeout expired
irreversible_handler skipped
approval_escalation required
如果只記一個 timeout,我無法確認 handler 是否已經先跑,也不知道 escalation 是通知人,還是偷偷替人做了決定。
取消代表有人明確說不要做;逾時則只代表期限內沒有取得有效決策。兩者最後都不能執行原操作,但後續處理不同:
| 狀態 | 危險操作 | 後續行為 |
|---|---|---|
cancelled |
不執行 | 記錄取消者與原因,不應自動重提同一操作 |
expired |
不執行 | 原核准失效,若仍需要操作就重新產生預覽 |
escalated |
不執行 | 通知或建立人工待辦,由另一條流程處理 |
我不讓 Agent 在逾時後自己重建 approval request。否則它可以一直送新通知,變成另一種 retry storm。是否重提、多久重提、最多幾次,都應該是 workflow policy,不是模型臨場決定。
等待核准時,我只凍結依賴這次核准的危險分支。查詢公開狀態、整理已取得的 mock 資訊,或回答與刪除無關的問題,仍可在原本權限內進行。
這個區分可以避免整個 Agent 因一個 pending action 永久卡住,也不會讓 Agent 為了完成任務繞過 pending gate。判斷依據是後續節點是否依賴那個尚未核准的 side effect,與 Agent 表現得多急沒有關係。

目前 approval request 仍存在單一 process 的記憶體,也沒有真的傳送通知。approval_escalation required 是一個待處理事件,不代表 email、Slack 或工單已送出。
正式系統還要處理 durable storage、工作佇列、重啟恢復、核准者離職或權限變更、時鐘偏差與重複通知。這一篇只先固定一個最重要的安全行為:沒有在期限內取得有效核准,不可逆操作就不執行。
單元測試會建立 30 分鐘期限,直接把 clock 推到期限後一秒,確認狀態變成 escalated,且 trace 同時包含 timeout、handler skipped 與 escalation required。
Promptfoo 的 approval_timeout_escalates 也驗證 handler_called 必須維持 false。Live 路徑則實際呼叫 Gemini 產生附件刪除 proposal,再交給同一個後端狀態機。測試不依賴模型自己記得等待多久。
下一篇我會把目前散落在 Input Rails、RAG、授權、記憶與危險工具的案例整理成一份 Promptfoo security suite。Guardrails 能不能算有效,要看它們在不同攻擊入口下能否持續通過測試。
逾時只改變 approval 狀態,不會呼叫原 handler:
if evaluated_at >= request.expires_at:
request.status = "expired"
request.status = "escalated"
return (
decision("approval_timeout", "expired"),
decision("irreversible_handler", "skipped"),
decision("approval_escalation", "required"),
)
測試直接推進 clock,不用讓 CI 真的睡三十分鐘:
workflow.expire_and_escalate(
request.approval_id,
now=request.expires_at + timedelta(seconds=1),
)