在昨天的文章中,我們透過串接 LINE 的 Location 訊息,讓使用者能夠直接分享手機 GPS 座標,實現方圓一公里內的雷達式周邊推薦。
不過,在日常對話中,使用者的輸入往往是破碎且不完整的。例如使用者隨口輸入一句:
「我想吃日式拉麵」
在之前的架構下,因為缺乏地區資訊,系統只能無奈地自動套用預設值(例如預設為「板橋區」),這常常導致推薦結果與使用者的真實所在位置風馬牛不相及。
一個優秀的 AI Agent 不應該只是被動猜測,而是要具備主動溝通的能力——當發現關鍵決策條件缺漏時,能主動拋出追問:「請問你想在哪個地區或商圈用餐呢?」
今天 Day 17,我們將在微服務中引入有限狀態機(Finite State Machine, FSM)與對話上下文追蹤,讓 Agent 能夠記住對話進度並主動引導使用者補齊條件!
有限狀態機的核心概念非常直觀:每個使用者在不同時間點都會處於特定的「對話狀態(State)」。當收到新的輸入事件時,系統根據當前狀態與輸入內容,決定切換到下一個狀態或執行特定動作。
針對美食推薦任務,我們設計三種核心狀態:
[初始/閒置狀態 (IDLE)]
│
▼ (使用者輸入:「我想吃拉麵」)
[檢查條件:料理=日式, 地點=缺漏, 預算=缺漏]
│
▼ (切換狀態)
[等待補充地點 (AWAITING_LOCATION)] ──> 回覆追問:「請問你想在哪個地區或商圈用餐呢?」
│
▼ (使用者輸入:「西門町」)
[更新狀態:地點=西門町, 條件充足]
│
▼
[狀態重置為 IDLE] ──> 呼叫 Google Places API 產出推薦卡片!
為了讓每個使用者的狀態與尚未完整的暫存條件(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 + Gemini 大腦不只是硬塞預設值,而是誠實回傳「使用者究竟有沒有提及地點」。
回到 Dify 的系統提示詞(System Prompt),微調規則如下:
你是一個專業的美食推薦助理前置解析器。請從對話中提取以下欄位並「只輸出 JSON 格式」:
- cuisine: 料理類型(日式、韓式、台式、甜點/下午茶、咖啡廳、西式、全部)
- budget_max: 每人預算上限(若使用者未提及,請務必填寫 null,不要自作主張設定預設值)
- location: 地區、捷運站或商圈(若使用者未提及,請務必填寫 null,不要自作主張設定預設值)
- notes: 特殊飲食需求或關鍵字(若無則為空字串)
輸出範例:
{
"cuisine": "日式",
"budget_max": null,
"location": null,
"notes": "想吃拉麵"
}
現在我們將狀態流轉邏輯寫進推薦端點中:若條件不足,端點會回傳 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 呼叫完 /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 快速回覆按鈕! 敬請期待!