iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
AI Engineering

從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作系列 第 11 篇

Day 11:【好感度持久化與好感度系統3】用 Firebase 記住喜怒哀樂,讓 Gemini 自動感受你的情緒

  • 分享至 

  • xImage
  •  

介紹

不知道你有沒有這種感覺:前幾天我們寫完 FSM(有限狀態機)之後,雖然可以手動按按鈕幫 Agent 加減好感度,但總覺得哪裡怪怪的?

「真正的聊天,誰會在旁邊準備一顆加分按鈕啊!」

如果莉莉 Agent 真是一個活生生的人,她應該要能從我們的字裡行間,自己感受到被尊重、被關心,或是被冒犯。而且更重要的是,她不能是個金魚腦!總不能網頁按個 F5 重新整理,她就把我們之前建立的羈絆跟好感度全都忘光了吧?

所以今天,我們要來做兩件讓角色「活過來」的核心工作:

  1. 搭起 Firebase Firestore 的記憶橋樑:把好感度鎖進 FSM 集合裡,就算重新整理網頁,情感狀態依然緊緊鎖住。
  2. 打造「情緒感應器 (Emotion Evaluator)」:讓 Gemini 在背景當莉莉的小幫手,自動讀懂你的發言,為好感度進行無形的加減分!

第一關:讓記憶跨越 F5(Firebase Firestore 的 FSM 集合)

打開 db.py,我們寫入這兩個讀寫函式:

# ---- FSM: 角色好感度 ----
# 好感度存在獨立的 FSM 集合,一個角色一份文件(不是一個使用者一份),
# 所以目前是「全站共用一個莉莉」;
# 等要做成每人各自的好感度時,這裡的 document key 要改成帶 user_id(未來式)

DEFAULT_AFFECTION = 20

# 從 Firestore 的 FSM 集合讀取角色的好感度數值
def get_fsm_affection(character_id: str = "lily", default_val: int = DEFAULT_AFFECTION) -> int:
    doc_ref = db.collection("FSM").document(character_id)
    doc = doc_ref.get()

    if doc.exists:
        data = doc.to_dict() or {}
        return data.get("affection", default_val)
    else:
        # 若資料不存在,建立預設文件,下次讀取就有值了
        save_fsm_affection(character_id, default_val)
        return default_val

# 將好感度數值寫入/更新至 Firestore 的 FSM 集合
def save_fsm_affection(character_id: str, affection: int):
    doc_ref = db.collection("FSM").document(character_id)
    # merge=True:只覆寫這三個欄位,不會把文件裡其他欄位清掉
    doc_ref.set({
        "character_id": character_id,
        "affection": affection,
        "updated_at": firestore.SERVER_TIMESTAMP
    }, merge=True)

第二關:狀態機好貼喔(自動跟雲端同步的 fsm.py)

現在,我們的 FSM 類別不再是孤島了。它一出生(初始化)就會去問 Firebase:「Hey,我們上次聊到哪了?」

Python

import db

class FSM:
    def __init__(self, character_id: str = "lily", default_affection: int = db.DEFAULT_AFFECTION):
        self.character_id = character_id
        # 一開始就自動去 Firebase 的 FSM 集合討記憶!
        self.affection = db.get_fsm_affection(self.character_id, default_val=default_affection)

    def update_affection(self, delta: int):
        """更新好感度分數,並限制在安全邊界內"""
        self.affection = min(max(0, self.affection + delta), 100)
        # 寫回雲端,讓記憶延續
        db.save_fsm_affection(self.character_id, self.affection)
        return self.affection

小提醒:前幾天測試後發現,原本 30 / 70 的門檻太容易讓角色在短時間內跨狀態,因此這次重新調整為 15 / 75,讓 HOSTILE 與 IN_LOVE 都需要更明顯的互動累積才能進入或離開。

第三關:裝上微表情感知器(evaluator.py)

這是我覺得比較有趣的部分!我用一個輕巧的 Gemini API 呼叫,擔任莉莉的「潛意識情商模組」。

Python

from google import genai

# 評分準則獨立成常數,讓之後合併呼叫時可以直接引用,
# 不必把同一份規則抄兩遍(抄兩遍就一定會有天只改到其中一邊)
EMOTION_CRITERIA = """
莉莉的性格特點:極度重視家人與店裡的夥伴、熱愛做料理與甜點、討厭無禮自大的人、
對真誠誇獎她廚藝的人毫無抵抗力。

評分的標準如下:
- 真誠誇獎她的料理/甜點、表達對她家人的尊重、展現貼心與照顧:+5 到 +11 分
- 一般日常問候、無特別偏向的對話、無心的玩笑或誤會:0 到 -2 分
- 態度輕浮、開不適當的玩笑、批評她的家人或料理:-5 到 -9 分(除非極度惡劣否則嚴禁低於 -11 分)
"""

# client 由呼叫端傳入;設定只改一處,測試時也能塞假 client 進來,不必真的打 API
def evaluate_emotion(client: genai.Client, user_message: str) -> int:
    """讓 Gemini 充當情商裁判,感受使用者話語中的溫度,回傳好感度波動"""
    prompt = f"""
你是一個極度了解莉莉性格的情緒裁判。
{EMOTION_CRITERIA}

請仔細閱讀使用者剛才說的話,評估這句話對莉莉心理造成的影響:
使用者說:「{user_message}」

請務必只回傳一個整數數字(例如:10 或 -5),不要包含任何多餘文字。
"""
    try:
        response = client.models.generate_content(
            model="gemini-3.1-flash-lite",
            contents=prompt
        )
        # response.text 被 safety filter 擋掉時會是 None,用 or "" 墊底
        score_text = (response.text or "").strip()
        return int(score_text)
    except Exception as e:
        # 萬一情商模組偶爾走神,預設不影響心情
        # 靜默 return 0 的話畫面上只會看到「好感度都不動」,完全無從查起
        print(f"[evaluator] 情緒評分失敗,本輪以 0 計:{type(e).__name__}: {e}")
        return 0

第四關:在 app.py 串起溫暖的對話閉環

Python

if user_input := st.chat_input("跟你的角色說點什麼吧..."):
    with st.chat_message("user", avatar=USER_AVATAR):
        st.write(user_input)

    # 意圖判斷也放進 try 裡:classify_intent 內部同樣是一次 API 呼叫,
    # 一輪對話等於打兩次 API,免費層的 RPM 上限真實砍半,
    # 放在 try 外面的話 router 一撞 429 就是整頁 traceback,接不到下面的 st.error
    try:
        intent = router.classify_intent(client, user_input)

        if intent == "CHAT":
            # 背景靜悄悄地感知情緒,換算成好感度增減
            delta = evaluator.evaluate_emotion(client, user_input)

            # 只在 CHAT 路線評分:TASK 是叫她算數學、寫程式,
            # 那種話沒有情緒溫度可言,拿去評分只是白花一次 API
            st.session_state.fsm.update_affection(delta)

            # 取得當前 FSM 狀態對應的動態 System Instruction
            # (好感度更新後才取,這一輪就會反映最新狀態)
            current_instruction = st.session_state.fsm.get_system_instruction()

            response = st.session_state.chat.send_message(
                user_input,
                config=types.GenerateContentConfig(system_instruction=current_instruction)
            )
            ai_reply = response.text
        else:
            response = st.session_state.chat.send_message(
                user_input,
                config=types.GenerateContentConfig(
                    system_instruction="請以極度精準、高效率的方式完成使用者的任務,不要有任何傲嬌廢話或角色扮演。"
                )
            )
            ai_reply = f"(哼!這種正經事就交給我吧)\n\n{response.text}"

        # 用旗標而不是 st.stop():st.stop() 會中斷整個 script,
        # 連下面的側邊欄儀表板都不會被畫出來,出錯時等於整個側邊欄消失
        replied = True

    except Exception as e:
        st.error(f"角色沒有回應,API 出錯了:{e}")
        st.info("這句話沒有被記錄,重新整理後會消失,可以直接再說一次。")
        replied = False

    if replied:
        with st.chat_message("assistant", avatar=AI_AVATAR):
            st.write(ai_reply)

        st.session_state.messages.append({"role": "user", "content": user_input})
        save_message(st.session_state.user_id, "user", user_input)
        st.session_state.messages.append({"role": "assistant", "content": ai_reply})
        save_message(st.session_state.user_id, "assistant", ai_reply)

# 側邊欄顯示好感度
# 放在檔案最後才畫:Streamlit 由上往下執行,
# 若放在開頭,讀到的會是這一輪更新「之前」的好感度,畫面永遠慢一拍。
with st.sidebar:
    st.header("💖 心情儀表板")
    st.metric("當前好感度", f"{st.session_state.fsm.affection} / 100")
    st.write(f"目前狀態:**{st.session_state.fsm.get_state()}**")

第五關:這就是人性嗎?實測看看!

好感度:20 ⇒ 15(TSUNDERE ⇒ HOSTILE)

  • 使用者:「你想要得心臟病嗎?」

  • 莉莉:「哈?一開始就咒人生病,你也太沒禮貌了吧。你腦子要是壞了就快點去看醫生,少在那邊噁心我了。」
    https://ithelp.ithome.com.tw/upload/images/20260925/20183877LCM5gAXP2H.png
    好感度:15 ⇒ 19(HOSTILE ⇒ TSUNDERE)

  • 使用者:「我最近找到一家很好吃的甜點店,有機會約你一起去嗎?」

  • 莉莉:「哼,甜點店?別以為用這種東西就能打發我!……不過,看在你這麼有誠意的份上,我就勉為其難陪你去一次好了。先說好喔!這才不是什麼約會,我只是想去幫你鑑定一下那家店的水準而已,笨蛋!」

好感度:19 ⇒ 18(TSUNDERE ⇒ TSUNDERE)

  • 使用者:「別那麼冷漠嗎?那家甜點店有很可愛的貓貓喔」
  • 莉莉:「誰、誰冷漠了啊!我對每個人都是這個態度好佳嗎!貓咪什麼的……哼,我才沒有特別心動呢!不過,要是牠們真的很可愛,我也不是不能勉強拍幾張照片啦……真是的,快點把地址傳給我啦,笨蛋!」
    https://ithelp.ithome.com.tw/upload/images/20260925/20183877hV2FJA5E6s.png
    當你按下 F5 重新整理:側邊欄的儀表板依然穩穩地顯示著剛剛累積的分數與狀態,關係完全沒有被打回原形!

踩坑紀錄:API 限流與型號選擇的陷阱

  • 現象描述
    測試對話時突發 429 RESOURCE_EXHAUSTED 錯誤,提示 Quota exceeded ... limit: 20,導致 Agent 系統全面無法回應。

  • 原因分析

    1. 測試版模型配額極低:誤用了實驗/測試性質的 gemini-3.6-flash 模型,其免費層(Free Tier)設定了嚴苛的 RPD 20(每日最多 20 次請求) 限制。
    2. 多重 API 呼叫疊加放大:系統架構設計了「意圖分類 Router」與「情緒評估 Evaluator」,使用者傳送一句話就會引發 2~3 次 API 呼叫,導致 20 次的每日配額在幾輪測試內秒速耗盡。

    核心解決方案:模型分流與輕量化(Model Offloading)
    為了兼顧回應速度、額度消耗與角色扮演的深度,採用輕量模型處理輔助任務,主力模型處理角色對話的分流架構:
    • 輔助任務(Router / Evaluator)⇒ gemini-3.1-flash-lite
    意圖分類與情緒評估只需要精準輸出標籤或分數,不需要複雜的角色扮演與文采。改用 flash-lite 能極大地節省 API 配額並降低回應延遲(Latency)。


上一篇
Day 10:【好感度系統2】掛上好感度儀表板!用 Python 實作 FSM 狀態機與動態 Prompt(實作篇)
下一篇
Day 12:【RAG 理論篇】為什麼 Prompt 寫再長都沒用?讓 Agent 擁有「外掛大腦」與向量資料庫
系列文
從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言