iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

https://ithelp.ithome.com.tw/upload/images/20260819/20161290spQl6WOvth.png

當流程不是線性一次完成,就要進入狀態設計。

在大多數 Hello World 等級的 Agent 教學中,工作流永遠都是「線性」的:使用者發起請求 $\rightarrow$ LLM 呼叫三個工具 $\rightarrow$ 回傳最終結果,整個過程耗時 3 秒完成。

然而在真實企業級業務場景中,中斷、等待與跨時區人機協作才是常態

  • 高風險折讓審批:AI 產出一份 25% 折扣的企業方案,系統必須暫停並發送通知給業務主管,等待主管在後台點擊「核准」或「退回修正」。
  • 資訊不足反問:客戶只說了「幫我訂機票」,Agent 必須停下來等待使用者補齊「出發日期」與「目的地」。
  • 長時非同步任務:呼叫外部批次徵信系統可能需要等待 10 分鐘,Thread 不能傻傻地 Thread.sleep 阻塞連線。

如果你的 Agent 無法在等待時「安全保存狀態」並在人類回覆時「精準喚醒恢復」,系統就會面臨連線超時中斷、記憶體洩漏或上下文遺失的毀滅性打擊。今天我們就來拆解 Embabel 的 @StateStage 狀態機與 WaitFor 人機協同機制


1. 今天要解決的痛點與核心觀念

痛點背景:線性 Agent 遇到人機協同(HITL)的三大死穴

  1. 連線與執行緒阻塞(Thread Blocking):為了等人類在 Slack 或 Web 介面上點擊確認,後端保持 HTTP 長連線或佔用 Worker Thread,導致伺服器連線池迅速耗盡崩潰。
  2. 上下文遺失(Context Amnesia):流程重啟時,之前的對話進度、中間計算出的 ActivitySummary 全部遺失,必須從頭重新問起。
  3. 無窮修改迴圈(Infinite Revision Loop):主管退回方案要求「修改折扣說明」,若沒有明確的狀態機約束,Agent 很容易在「生成 $\rightarrow$ 退回 $\rightarrow$ 再生成」中陷入死循環,耗盡 Token。

觀念圖解:基於 @State 的中斷與喚醒狀態機

https://ithelp.ithome.com.tw/upload/images/20260819/20161290WIFW9dQobl.png

Embabel 透過將流程劃分為不同的 Stage(階段狀態),並結合 WaitFor 掛起語義,實現優雅的人機協作:

https://ithelp.ithome.com.tw/upload/images/20260819/201612908Prg7V4nTi.jpg

  • 第 1 階段(初稿生成階段 DraftStage):生成方案草稿,若折扣超過 15% 則自動觸發審批。
  • 第 2 階段(主管審批階段 AssessStage):使用 WaitFor 掛起流程,狀態序列化存入 Redis/DB,釋放連線與執行緒。
  • 第 3A 階段(核准通過):轉移至 DoneStage 產出最終 ReviewedOffer (Goal)。
  • 第 3B 階段(退回修訂):轉移至 ReviseStage 結合主管回饋重新調整草稿並迴圈重製。

2. 官方核心技術依據與架構深度

1. @State 註解與 Stage 標記介面

在 Embabel 中,狀態不是一個字串變數,而是具備行為與資料邊界的 Java Record / Class

  • 定義一個標註了 @State 的 sealed interface(例如 Stage)。
  • 每個具體的業務階段(DraftStageAssessStageDoneStage)各自實作該介面。
  • 每個 Stage 內部可以宣告屬於該階段才允許執行的專屬 @Action

2. WaitFor 掛起機制(Suspension Primitive)

WaitFor 是 Embabel 專門為 Human-in-the-Loop 設計的掛起原語:

  • WaitFor.formSubmission(prompt, FormType.class):掛起當前 Process,發出事件要求外部提供指定表單物件。
  • WaitFor.userInput(prompt):掛起等待單純的自然語言補充。
  • 流程引擎捕捉到 WaitFor 時,會自動將當前 Blackboard 的狀態持久化(Persistence),並結束當前執行緒。

3. clearBlackboard 與狀態遷移(State Transition)

當 Action 回傳一個新的 Stage 時,代表狀態發生遷移。若在 @Action(clearBlackboard = true) 標記,引擎會自動清理上一階段的暫存雜訊,只保留新 Stage 物件中明確攜帶的上下文,有效防止上下文膨脹(Context Window Explosion)。


3. 完整程式碼實戰(Production-Ready Code)

以下我們實作一個完整的「方案生成 $\rightarrow$ 人工審查 $\rightarrow$ 判定通過或重製」的狀態機流程。

1. 定義階層式狀態介面與階段 Records

package com.antechinus.travel.state;

import com.antechinus.travel.domain.CustomerQuery;
import com.antechinus.travel.domain.OfferDraft;
import com.antechinus.travel.domain.ReviewedOffer;
import com.embabel.agent.annotation.AchievesGoal;
import com.embabel.agent.annotation.Action;
import com.embabel.agent.annotation.State;
import com.embabel.agent.api.Ai;
import com.embabel.agent.api.WaitFor;

/**
 * 流程階段狀態標記介面
 */
@State
public sealed interface CareFlowStage 
    permits DraftStage, AssessStage, ReviseStage, FinalDoneStage {}

/**
 * 人工主管審核回饋物件
 */
public record SupervisorFeedback(
    boolean approved,
    String rejectionReason,
    int maxAllowedDiscount
) {}

2. 各階段狀態機與掛起 Action 實作

package com.antechinus.travel.state;

import com.antechinus.travel.domain.CustomerQuery;
import com.antechinus.travel.domain.OfferDraft;
import com.antechinus.travel.domain.ReviewedOffer;
import com.embabel.agent.annotation.AchievesGoal;
import com.embabel.agent.annotation.Action;
import com.embabel.agent.api.Ai;
import com.embabel.agent.api.WaitFor;

import java.time.Instant;

/**
 * 階段 1:初稿生成完畢,準備進入審查
 */
public record DraftStage(CustomerQuery query, OfferDraft draft) implements CareFlowStage {
    
    /**
     * 發起人工審查 Action
     * 觸發流程掛起,等待外部 SupervisorFeedback 表單提交
     */
    @Action
    public CareFlowStage initiateReview() {
        if (draft.discountPercent() <= 15) {
            // 小額折扣免審批,直接進入完成階段
            return new FinalDoneStage(query, draft, "系統自動通過");
        }
        // 大額折扣掛起等待主管審批
        return new AssessStage(query, draft);
    }
}

/**
 * 階段 2:等待與處理人工審查回饋
 */
public record AssessStage(CustomerQuery query, OfferDraft draft) implements CareFlowStage {

    /**
     * 等待人工輸入 Action
     */
    @Action
    public SupervisorFeedback awaitSupervisor() {
        return WaitFor.formSubmission(
            "優惠折扣達 " + draft.discountPercent() + "%,請主管審核並提供回饋",
            SupervisorFeedback.class
        );
    }

    /**
     * 收到主管回饋後的狀態裁決 Action
     * 清理黑板雜訊,根據審核結果轉移至 完成 或 重製 階段
     */
    @Action(clearBlackboard = true)
    public CareFlowStage decide(SupervisorFeedback feedback, Ai ai) {
        if (feedback.approved()) {
            return new FinalDoneStage(query, draft, "主管核准: " + feedback.rejectionReason());
        }
        // 未通過審核,轉移到修訂階段
        return new ReviseStage(query, draft, feedback);
    }
}

/**
 * 階段 3:方案修訂階段 (可由 LLM 根據回饋重新生成)
 */
public record ReviseStage(
    CustomerQuery query, 
    OfferDraft previousDraft, 
    SupervisorFeedback feedback
) implements CareFlowStage {

    /**
     * 依據審核意見重新調整方案 Action
     */
    @Action
    public CareFlowStage reviseDraft(Ai ai) {
        // 使用 LLM 結合主管意見進行草稿微調
        String prompt = String.format(
            "請根據主管意見修訂方案。原方案: %s,主管要求最高折扣不可超過 %d%%,理由: %s",
            previousDraft.description(), feedback.maxAllowedDiscount(), feedback.rejectionReason()
        );

        OfferDraft newDraft = ai.withDefaultLlm().createObject(prompt, OfferDraft.class);
        
        // 修正後回到 Draft 階段重新評估
        return new DraftStage(query, newDraft);
    }
}

/**
 * 階段 4:完成階段,產出最終 Goal 物件
 */
public record FinalDoneStage(
    CustomerQuery query, 
    OfferDraft finalDraft, 
    String approvalNote
) implements CareFlowStage {

    /**
     * 達成終端目標 Action
     */
    @AchievesGoal(description = "產出最終審核完畢的優惠方案")
    @Action
    public ReviewedOffer complete() {
        return new ReviewedOffer(
            query.customerId(),
            finalDraft.discountPercent(),
            finalDraft.description(),
            "APPROVED",
            approvalNote,
            Instant.now()
        );
    }
}

3. Spring Boot Webhook 喚醒恢復 Controller

package com.antechinus.travel.web;

import com.antechinus.travel.state.SupervisorFeedback;
import com.embabel.agent.api.EmbabelClient;
import com.embabel.agent.api.ProcessResult;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

/**
 * 人機協同外部喚醒端點
 * 接收主管審批結果並喚醒掛起的 Agent 流程
 */
@RestController
@RequestMapping("/api/agent/care")
public class AgentApprovalController {

    private final EmbabelClient embabelClient;

    public AgentApprovalController(EmbabelClient embabelClient) {
        this.embabelClient = embabelClient;
    }

    /**
     * 接收主管審批回呼 Webhook
     *
     * @param processId 掛起的流程識別碼
     * @param feedback 主管審查表單
     */
    @PostMapping("/processes/{processId}/approve")
    public ResponseEntity<String> resumeProcess(
            @PathVariable String processId,
            @RequestBody SupervisorFeedback feedback) {
        
        // 喚醒掛起的進程並注入外部資料
        ProcessResult<?> result = embabelClient.resume(processId, feedback);

        if (result.isSuccess()) {
            return ResponseEntity.ok("流程已成功喚醒並推進至下一階段");
        } else {
            return ResponseEntity.internalServerError().body("流程恢復失敗: " + result.getErrorMessage());
        }
    }
}

4. 生產環境避坑指南與對比分析

常見踩雷與除錯秘訣

  1. 雷區一:使用 Thread.sleep 或輪詢 DB 來等人
    • 現象:Tomcat Worker Thread 耗盡,504 Gateway Timeout,伺服器重啟時所有等待中任務直接消失。
    • 解法:必須使用 WaitFor 原語,將狀態持久化後立即返回並釋放 Thread,完全依賴非同步 Webhook 事件喚醒。
  2. 雷區二:狀態遷移時未清理 Blackboard(Context Window 膨脹)
    • 現象:在修訂迴圈跑了 3 次後,黑板上堆積了 3 份歷史草稿與 3 次主管回饋,導致 Prompt 包含大量過期衝突資料。
    • 解法:在遷移 Action 上使用 clearBlackboard = true,僅將必要的參數透過新 Stage 的構造函式傳遞。
  3. 雷區三:缺少審批超時(Timeout)降級策略
    • 現象:主管出差一週未審批,流程無限期卡在 AssessStage
    • 解法:設定流程 TTL(例如 24 小時);超時後觸發定時任務自動轉移至 TimeoutFallbackStage(例如降級為預設 10% 通用優惠)。

狀態管理架構 Good vs Bad 對比表

評估維度 ❌ 傳統線性寫法 (Bad) ✅ Embabel @State 狀態機 (Good)
人機協同支援 只能一次性跑完,無法中途停下來等人 支援 WaitFor 非同步掛起與 Webhook 喚醒
狀態保存機制 塞在全局 HashMap 或 ThreadLocal(重啟即丟) 強型別 Record 狀態容器,天然支援 DB/Redis 序列化
修訂迴圈控制 容易寫出 while(true) 死循環耗盡預算 明確的 ReviseStage $\rightarrow$ DraftStage 狀態遷移軌跡
架構清晰度 邏輯被大量 if (status == 3) 瓜分破碎 每個 Stage 封裝專屬 Action,符合物件導向狀態模式

5. 今日動手實作任務與發文備註

🛠️ 今日實作任務

  1. 定義一組 sealed Stage 介面:為你的業務流程設計至少 3 個階段(如 InitialStageWaitingApprovalStageCompletedStage)。
  2. 實作 WaitFor 掛起:在審批階段使用 WaitFor.formSubmission 回傳等待結構,並觀察流程如何被暫停。
  3. 思考題:如果一個 Agent 同時需要「等主管核准」又需要「等客戶補充預算」,這兩個等待狀態應該設計在同一個 Stage 還是拆成兩個連續的 Stage?為什麼?

上一篇
Day 18:測試不要只看結果長什麼樣
系列文
讓 AI Agent 真的做事:用 Embabel 打造可控、可測試的智慧 Dashboard19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言