iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
AI Engineering

AI Agent 系統開發 30 天系列 第 10

實作多輪追問與資訊補齊

  • 分享至 

  • xImage
  •  

使用者在對話中不一定會一次給齊所有條件。例如顧客想退貨卻沒給訂單編號,系統無法直接進入退款流程,必須先停下來詢問單號;又或者顧客只說「推薦背包」而沒有提供預算,系統也需要主動提問來收斂範圍。

在上一篇,我們實作了單輪的意圖分類與防護分流。當顧客缺少單號或資訊不足時,系統能將流程導向補問節點。然而,單輪架構在發問後就抵達 END 結束了——當顧客回覆「ORD-1234」時,系統無法保留剛才的上下文,導致對話直接中斷。

真實的客服系統必須具備「多輪對話」的能力:發出問題後停下來等待顧客回答,收到回答後接續先前的狀態繼續處理

在這篇文章中,我們會處理:

  1. LangGraph 的中斷與恢復機制:使用 interrupt() 暫停流程並持久化狀態,待使用者回答後以 Command(resume=...) 喚醒接續執行。
  2. 程式檢查與 AI 動態追問的分工:讓 AI 負責模糊條件的提問生成(如推薦背包時詢問預算),由 Python Router 負責缺少必填單號的強制攔截。
  3. 多輪對話的狀態累積與防呆:將使用者的補充回答合併進對話歷史重新評估,並設定追問次數上限,避免系統無限跳針。

系統全貌架構

多輪對話的流程如下圖所示:
https://ithelp.ithome.com.tw/upload/images/20260921/20111896ZybyFVFe56.png

範例程式碼位於 ai-agent-sample/langgraph/langgraph-multi-turn-clarification/

第一步:升級意圖契約(讓 AI 產生追問內容)

對話意圖與契約定義在 models.py。之前 AI 在分析使用者訊息後,會回傳代表判定意圖的 IntentDecision。現在為了支援多輪對話,我們進一步擴充這個結構——新增兩個欄位,讓 AI 不只要辨識意圖,還要同步決定「是否需要追問」(needs_clarification),並在條件不足時動態產生「要問什麼」(clarification_question):

class IntentDecision(BaseModel):
    model_config = ConfigDict(extra="forbid")

    intent: Intent
    confidence: float = Field(ge=0, le=1)
    order_id: str | None = Field(default=None, pattern=r"^ORD-\d{4}$")
    needs_clarification: bool                                                 # 本篇新增:AI 判斷是否需要追問
    clarification_question: str | None = Field(default=None, max_length=200)  # 本篇新增:AI 動態產生的追問內容
    evidence: str = Field(min_length=1, max_length=200)

有了這兩個欄位,資料契約就具備了描述「是否需要追問」與「問題內容」的能力。

第二步:在 Prompt 中設定提問原則(引導 AI 何時追問)

定義好資料契約後,下一步是在 classifier.py 實作分類器與 System Prompt。

光有欄位,AI 並不知道何時該把 needs_clarification 設為 true。若缺乏明確指引,模型容易陷入每句都問或完全不問的極端。我們在 System Prompt 中給予 AI 具體的提問邊界,避免它機械式地每漏一個條件就追問:

資訊足以處理時,needs_clarification 設為 false。
缺少會明顯影響結果的資訊時,needs_clarification 設為 true,並提出一個簡短問題。
商品推薦不要因為缺少每項偏好就機械式追問;只有缺少的資訊會明顯改變推薦結果時才問。

例如當顧客只說「我要退貨」卻沒有提供訂單編號時,AI 就會判定關鍵資訊缺漏,將 needs_clarification 設為 true,並主動產生「請提供要處理的訂單編號」的追問內容,讓系統可以停下來等待顧客補充。

第三步:設計保存跨輪狀態的 State 與 Checkpointer

要支援對話暫停與恢復,State 必須能記錄累積的對話、暫存使用者的最新回覆,以及追問的次數:

class State(BaseModel):
    model_config = ConfigDict(extra="forbid")

    message: str = Field(min_length=1, max_length=500)      # 累積的對話全文
    locale: Locale = Locale.ZH_TW
    decision: IntentDecision | None = None                  # 當前輪次的 AI 決策
    classification_error: str | None = None
    pending_user_message: str | None = None                 # 暫存使用者剛補答的內容
    clarification_count: int = Field(default=0, ge=0)       # 已追問次數
    handled_by: str | None = None
    response: str = ""
    status: Status = Status.NEW

builder.compile() 時必須掛載 checkpointer 保存狀態,且呼叫時傳入穩定的 thread_id

graph = builder.compile(checkpointer=build_memory_checkpointer())

config = {
    "configurable": {
        "thread_id": "support-session-001",  # 標識同一個對話 Session
    }
}

第四步:用 Router 判斷是否需要追問

意圖分類完成後,流程進入 workflow.py 的 Router。它的職責是檢查當前資訊是否足夠執行業務,決定要直接執行、停下來向使用者追問,還是轉接真人。

雖然我們在第二步已經透過 System Prompt 引導 AI 判斷 needs_clarification,但光靠提示詞是不夠的。AI 本質上是機率模型,面對情緒化、語意長或結構混亂的輸入時,仍有漏判必填欄位的風險。如果退款缺少單號卻被 AI 判定放行,後端 API 就會拋出例外。因此,執行業務所需的必要條件,必須在程式層由確定性的邏輯把關。

CONFIDENCE_THRESHOLD = 0.7
MAX_CLARIFICATIONS = 2


def route_after_classification(state: State) -> Route:
    if state.classification_error is not None or state.decision is None:
        return "classification_fallback"

    decision = state.decision
    if should_ask(decision):
        # 防呆機制:若已追問達到上限,不再繼續追問,轉由真人接手
        if state.clarification_count >= MAX_CLARIFICATIONS:
            return "human_handoff"
        return "ask_user"

    return route_valid_decision(decision)


def should_ask(decision: IntentDecision) -> bool:
    return (
        requires_order_id(decision)                  # 1. 程式剛性把關:退款或查單缺少單號
        or decision.confidence < CONFIDENCE_THRESHOLD # 2. 品質防線:意圖辨識信心不足(低於 0.7)
        or decision.needs_clarification              # 3. 採納 AI 建議:前面模型判定需要追問
    )


def requires_order_id(decision: IntentDecision) -> bool:
    return (
        decision.intent in {Intent.ORDER_STATUS, Intent.REFUND}
        and decision.order_id is None
    )

should_ask 函式將兩種不同性質的判斷整合在同一個入口:

  1. 程式硬性守門(requires_order_id:退款或查單若缺少單號,不論 AI 主觀認為如何,程式一律強制判定需要追問。
  2. 品質防線(confidence < 0.7:AI 的把握度過低時,代表語意模糊或辨識困難,要求使用者重新描述。
  3. 採納 AI 建議(needs_clarification:如商品諮詢缺乏具體條件時,尊重模型在語意層的追問提議。

should_asktrue 時,流程會走向 ask_user 發問;但若追問次數已達上限(本例為 2 次),Router 會果斷轉向 human_handoff 轉真人客服,避免系統無限詢問。如果都不符合,代表資料已備齊,流程便順利放行至對應的業務節點。

第五步:用 interrupt() 暫停等待,用 Command(resume=...) 恢復

當 Router 決定走向 ask_user 節點時,我們挑選合適的問題,並呼叫 LangGraph 的 interrupt()

def clarification_question(state: State) -> str:
    decision = require_decision(state)
    # 程式政策產生的問題優先於 AI 問題
    if requires_order_id(decision):
        return POLICY_QUESTIONS[state.locale]["order_id"]
    if decision.confidence < CONFIDENCE_THRESHOLD:
        return POLICY_QUESTIONS[state.locale]["rephrase"]
    if decision.needs_clarification and decision.clarification_question is not None:
        return decision.clarification_question
    raise ValueError("ask_user 找不到可用的補問內容")


def ask_user(state: State) -> dict[str, object]:
    # interrupt 會在此處中斷執行,並將 question 回傳給呼叫端
    answer = interrupt({"question": clarification_question(state)})
    # 當外部以 Command(resume=...) 恢復時,answer 會取得傳入的值
    return {"pending_user_message": str(answer)}

呼叫與恢復的運作機制

  1. 第一次觸發(觸發中斷):

    result = graph.invoke(
        {"message": "我要退款", "locale": "zh-TW"},
        config=config,
        version="v2",
    )
    
    # 從中斷事件中取得問題並顯示給使用者
    print(result.interrupts[0].value["question"])
    # 輸出:請提供 ORD-1234 格式的訂單編號。
    

    此時 Graph 執行在 interrupt() 處暫停,State 被自動存入 Checkpointer,return 尚未執行。

  2. 使用者回答後(恢復執行):

    result = graph.invoke(
        Command(resume="ORD-1234"),
        config=config,
        version="v2",
    )
    

    LangGraph 會找到先前的 checkpoint,喚醒同一個節點,將 "ORD-1234" 作為 interrupt() 的返回值,接著執行 return {"pending_user_message": "ORD-1234"}

第六步:合併回答並回到開頭重新分類

當外部透過 Command(resume=...) 送回顧客的回答後,LangGraph 會依照設定的 Edge 將流程推進到 apply_answer 節點:

builder.add_edge("ask_user", "apply_answer")       # 收到回答後,進入合併節點
builder.add_edge("apply_answer", "classify_intent") # 合併完成後,回到開頭重新分類

收到顧客的補充回答後,系統不能直接跳到退款或業務節點,因為補充的內容可能依然不完整。因此,流程必須回到 classify_intent,把補充後的完整對話重新交給分類器與 Router 評估一次。

apply_answer 節點內部,會呼叫 merge_answer 輔助函式將使用者的回答與原始需求串接,並在 State 中清除舊決策、遞增追問計數:

def merge_answer(original_message: str, answer: str) -> str:
    merged = f"{original_message.strip()};使用者補充:{answer.strip()}"
    return merged[:500]  # 截斷長度,符合 State.message 的 max_length=500 限制


def apply_answer(state: State) -> dict[str, object]:
    if state.pending_user_message is None:
        raise ValueError("apply_answer 需要 pending_user_message")

    return {
        "message": merge_answer(state.message, state.pending_user_message),
        "decision": None,               # 清除舊決策
        "classification_error": None,
        "pending_user_message": None,   # 清空暫存
        "clarification_count": state.clarification_count + 1,
        "status": Status.NEW,
    }

例如原本顧客說「我要退掉昨天買的背包」,補答「ORD-1234」,合併後下一次 classify_intent 看到的輸入就是:

我要退掉昨天買的背包;使用者補充:ORD-1234

這樣一來,分類器在第二輪就能同時提取到退款意圖與訂單編號,讓 Router 能順利放行至後續的退款流程。

小結:用程式碼表達特定目的 Agent 的核心邊界

回顧從上一篇到本篇的多輪追問,無論是意圖的結構化驗證、關鍵欄位的格式檢查,還是何時該追問、何時該轉接真人,我們都選擇用確定性的程式碼(Pydantic 模型與 Router 條件分流)來表達,而不是全交給 AI 自由決定。

我們打造的是具有特定目的的 Agent,而不是毫無邊界的通用聊天機器人。這些特定目的——例如「退款必須持有單號」、「意圖不明時不能盲目放行」、「最多只能追問兩次」——正是系統的業務核心。

系統的核心價值與安全邊界,必須由工程師透過程式碼明確寫出來、強制執行。AI 的職責在於提供自然語言理解與彈性生成的輔助,狀態轉移與控制權限則牢牢掌握在程式手中。兩者各司其職,Agent 才能在保有對話彈性的同時,具備生產環境所必需的穩定度與可預測性。


上一篇
用 Pydantic 驗證意圖並控制 LangGraph 路由
下一篇
用 LangGraph 與 Guardrails 建立安全邊界
系列文
AI Agent 系統開發 30 天11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言