iT邦幫忙

0

# Day 17 設計多輪對話狀態機(FSM)實現智慧主動追問

n8n
  • 分享至 

  • xImage
  •  

在昨天的文章中,我們透過串接 LINE 的 Location 訊息,讓使用者能夠直接分享手機 GPS 座標,實現方圓一公里內的雷達式周邊推薦。

不過,在日常對話中,使用者的輸入往往是破碎且不完整的。例如使用者隨口輸入一句:

「我想吃日式拉麵」

在之前的架構下,因為缺乏地區資訊,系統只能無奈地自動套用預設值(例如預設為「板橋區」),這常常導致推薦結果與使用者的真實所在位置風馬牛不相及。

一個優秀的 AI Agent 不應該只是被動猜測,而是要具備主動溝通的能力——當發現關鍵決策條件缺漏時,能主動拋出追問:「請問你想在哪個地區或商圈用餐呢?」

今天 Day 17,我們將在微服務中引入有限狀態機(Finite State Machine, FSM)與對話上下文追蹤,讓 Agent 能夠記住對話進度並主動引導使用者補齊條件!


一、多輪對話狀態機(FSM)架構設計

有限狀態機的核心概念非常直觀:每個使用者在不同時間點都會處於特定的「對話狀態(State)」。當收到新的輸入事件時,系統根據當前狀態與輸入內容,決定切換到下一個狀態或執行特定動作。

針對美食推薦任務,我們設計三種核心狀態:

[初始/閒置狀態 (IDLE)]
       │
       ▼ (使用者輸入:「我想吃拉麵」)
[檢查條件:料理=日式, 地點=缺漏, 預算=缺漏]
       │
       ▼ (切換狀態)
[等待補充地點 (AWAITING_LOCATION)] ──> 回覆追問:「請問你想在哪個地區或商圈用餐呢?」
       │
       ▼ (使用者輸入:「西門町」)
[更新狀態:地點=西門町, 條件充足]
       │
       ▼
[狀態重置為 IDLE] ──> 呼叫 Google Places API 產出推薦卡片!

二、擴充資料庫模型:記錄對話對話狀態 (app/models/memory.py)

為了讓每個使用者的狀態與尚未完整的暫存條件(Slot)能被保存下來,我們擴充昨天的 SQLite 資料庫模型。

在 app/models/memory.py 的 UserModel 中加入狀態追蹤欄位:

from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base

class UserModel(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    line_user_id = Column(String(64), unique=True, index=True, nullable=False)
    
    # 新增對話狀態追蹤欄位
    dialog_state = Column(String(30), default="IDLE")  # IDLE, AWAITING_LOCATION, AWAITING_BUDGET
    pending_cuisine = Column(String(50), nullable=True) # 暫存已識別的料理類型
    pending_budget = Column(Integer, nullable=True)     # 暫存已識別的預算
    pending_notes = Column(Text, nullable=True)        # 暫存備註
    
    favorite_cuisine = Column(String(50), default="全部")
    default_budget = Column(Integer, default=500)
    dietary_notes = Column(Text, default="")
    created_at = Column(DateTime, default=datetime.utcnow)

    history = relationship("RecommendationHistoryModel", back_populates="user")

三、升級 Dify 抽取大腦:支援「缺漏標記」

我們要讓 Dify + Gemini 大腦不只是硬塞預設值,而是誠實回傳「使用者究竟有沒有提及地點」。

回到 Dify 的系統提示詞(System Prompt),微調規則如下:

你是一個專業的美食推薦助理前置解析器。請從對話中提取以下欄位並「只輸出 JSON 格式」:

- cuisine: 料理類型(日式、韓式、台式、甜點/下午茶、咖啡廳、西式、全部)
- budget_max: 每人預算上限(若使用者未提及,請務必填寫 null,不要自作主張設定預設值)
- location: 地區、捷運站或商圈(若使用者未提及,請務必填寫 null,不要自作主張設定預設值)
- notes: 特殊飲食需求或關鍵字(若無則為空字串)

輸出範例:
{
  "cuisine": "日式",
  "budget_max": null,
  "location": null,
  "notes": "想吃拉麵"
}

四、實作狀態機跳轉與追問路由 (app/routers/recommendation.py)

現在我們將狀態流轉邏輯寫進推薦端點中:若條件不足,端點會回傳 status: "clarification_needed" 與追問話術;若條件充足,則正常回傳推薦餐廳。

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.memory import UserModel, RecommendationHistoryModel
from app.schemas.food import UserPreference, RecommendationResponse
from app.services.places import search_places
from typing import Optional
from pydantic import BaseModel

router = APIRouter(
    prefix="/api/v1/recommend",
    tags=["Food Recommendation"]
)

class AgentDialogueResponse(BaseModel):
    status: str                         # "success" (出餐) 或 "need_more_info" (需追問)
    ask_message: Optional[str] = None   # 給使用者的追問語句
    recommendations: Optional[list] = []

@router.post("/chat", response_model=AgentDialogueResponse)
async def chat_with_agent(preference: UserPreference, db: Session = Depends(get_db)):
    # 1. 取得或初始化使用者
    user = db.query(UserModel).filter(UserModel.line_user_id == preference.user_id).first()
    if not user:
        user = UserModel(line_user_id=preference.user_id, dialog_state="IDLE")
        db.add(user)
        db.commit()
        db.refresh(user)

    # 2. 狀態判斷與槽位填補 (Slot Filling)
    if user.dialog_state == "AWAITING_LOCATION":
        # 上一輪正在等使用者回答地點
        target_location = preference.location or preference.notes
        target_cuisine = user.pending_cuisine or "全部"
        target_budget = user.pending_budget or 500

        # 重置狀態回 IDLE
        user.dialog_state = "IDLE"
        user.pending_cuisine = None
        user.pending_budget = None
        db.commit()

        # 執行真正推薦
        places = await search_places(query_location=target_location, query_cuisine=target_cuisine, budget_max=target_budget)
        return AgentDialogueResponse(status="success", recommendations=places)

    # 3. 初始狀態 (IDLE) 的輸入判定
    if not preference.location and not (preference.latitude and preference.longitude):
        # 關鍵條件:缺漏地點,切換狀態並主動追問
        user.dialog_state = "AWAITING_LOCATION"
        user.pending_cuisine = preference.cuisine
        user.pending_budget = preference.budget_max
        db.commit()

        return AgentDialogueResponse(
            status="need_more_info",
            ask_message=f"好的,想找美味的【{preference.cuisine}】!😋\n請問你想在哪個捷運站、商圈或地區用餐呢?(也可以直接點左下角「+」傳送目前位置給我哦!)"
        )

    # 4. 條件齊全直接搜尋
    places = await search_places(
        query_location=preference.location or "板橋區",
        query_cuisine=preference.cuisine,
        budget_max=preference.budget_max,
        latitude=preference.latitude,
        longitude=preference.longitude
    )
    return AgentDialogueResponse(status="success", recommendations=places)

五、在 n8n 中處理分流回覆

在 n8n 呼叫完 /api/v1/recommend/chat 端點後,新增一個 If / Switch 節點判斷後端回傳的 status:

若 status == "need_more_info":

走文字回覆分支,將 ask_message 內容透過 LINE Reply API 直接回傳給使用者。

若 status == "success":

走原本的 Flex Message Carousel 分支,將推薦小卡呈現在聊天室中。

六、實測驗證:體驗自然的來回互動

拿起手機打開 LINE 美食小幫手,開始多輪對話測試:

回合 1(條件缺漏):

使用者:「今天好想吃韓式烤肉」

美食小幫手:
「好的,想找美味的【韓式】!😋
請問你想在哪個捷運站、商圈或地區用餐呢?(也可以直接點左下角「+」傳送目前位置給我哦!)」

回合 2(補齊資訊):

使用者:「西門町」

美食小幫手:
(立即彈出西門町周邊高評價韓式烤肉的 Flex Carousel 輪播卡片!)
今日結語與明日預告
今天我們解決了 AI Agent 最常被詬病的「自說自話」與「機械性預設值」問題:

透過 有限狀態機(FSM) 架構,精確掌控對話當前所處的階段。

實現了智慧型 槽位填補(Slot Filling),缺什麼補什麼。

賦予機器人自然、有溫度的主動提問引導體驗!

不過,很多時候使用者不是不知道去哪裡,而是「選擇障礙發作」,不知道今天該吃哪一家好。
明天 Day 18,我們將帶來趣味又實用的功能:拯救選擇困難!加入隨機抽選、美食轉盤模式與 Quick Reply 快速回覆按鈕! 敬請期待!


*提醒邦友,使用第三方服務/API 時,請務必評估資安風險與隱私保護
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言