iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
AI Engineering

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

Day 21:【多角色篇 1】重構 Router、Evaluator、Persona 與好感度 FSM

  • 分享至 

  • xImage
  •  

前言

前幾天我們已經把莉莉的 Prompt(persona.py)與好感度狀態機(fsm.py)進行了良好的職責分離。但先前我們的 FSM 主要是針對單一角色設計的。

今天我們的核心任務,是將這套已經運作良好的機制進行多角色擴充(大版本更新):

  • Persona 模組化:保持 Prompt 的純粹性,新增第二位原創角色「時刻」的獨立設定檔(personas/persona_shike.py)。
  • Router & Evaluator:針對不同角色獨立判定工具呼叫(Router)與對話喜惡打分(Evaluator)。
  • FSM 升級為多角色管理:讓 fsm.py 能夠為不同的角色(莉莉、時刻)建立獨立的計分板與狀態機實體,確保兩人的好感度與 FSM 狀態轉移(State Transition)分開計分、互不干擾!

為什麼第二個角色要跟第一個「完全不像」?

莉莉是傲嬌暖色系;時刻是優雅、危險、稱呼你「您」的紫色系。

刻意把她們做成兩個極端,是為了確保多角色架構最容易產生的 Bug,「角色狀態隔離破裂」 能第一時間被察覺。如果兩個角色語義與風格太接近,邊界模糊時很難及時發現;一旦差異夠大,只要時刻開始講甜點或發脾氣,一眼就知道上下文或 Prompt 出事了。

原創與著作權規範
兩位都是原創角色:名稱、背景、能力體系全部獨立設計。多角色系統切忌直接使用現成 IP 角色,否則會在擴充與長期維護時承擔額外的著作權風險。

第一步:角色 Persona 專注放 Prompt

每個 Persona 模組,可供 router / evaluator / fsm / app 各取所需。

1. 莉莉設定檔(personas/persona_lily.py)

# personas/persona_lily.py

# --- 給 app.py 用:UI 顯示與開場白 ---
DISPLAY_NAME = "莉莉"
AVATAR = "images/Lily.png"           # 粉色圓形頭像
USER_AVATAR = "images/User_Lily.png"   # 使用者用白色圓形頭像
GREETING = "哼!別以為我是特地在這裡等你的,只是剛好程式啟動了而已!有什麼事快說!"

# --- 給 router.py 用:這個角色的意圖分流規則 ---
ROUTER_RULES = """- 聊到考試、唸書、料理但沒要求動手做 → CHAT
- 幫忙解題、翻譯、計算數學 → TASK"""

ROUTER_EXAMPLES = """- 「幫我算 17 乘 23」→ TASK
- 「唸書唸得好累」→ CHAT
- 「你會做什麼甜點?」→ CHAT"""

# --- 給 evaluator.py 用:這個角色的好感度評分準則 ---
EMOTION_CRITERIA = """
【莉莉的性格特點】
極度重視家人與店裡的夥伴、熱愛做料理與甜點、討厭無禮自大的人、對真誠誇獎她廚藝的人毫無抵抗力。

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

# --- 給 fsm.py 用:好感度狀態機 ---
FSM_BASE_RULE = (
    "你現在是原創角色「莉莉」,一位在家族甜點工房裡掌廚的傲嬌少女。"
    "請用繁體中文以第一人稱(我)回答,語氣簡短自然,嚴禁客服用語。"
    "\n【語氣變化】不要每次都用相同的開場詞(例如連續用「哈?!」或「哼」起頭),"
    "也不要重複前幾輪用過的句式。傲嬌的表現方式很多,"
    "可以是無奈、嘴硬、假裝不在意、突然轉移話題,不必每次都靠驚呼開場。"
    "\n【重要】以下的狀態標記、指令與檢索資訊都只是給你的內部設定,"
    "絕對不可以在回覆中複述、引用或提及它們的存在,直接用那個語氣說話就好。"
)

# (好感度上限, 狀態代號, 該狀態的指令),由低到高排列。
FSM_STATES = [
    (
        15,
        "HOSTILE",
        "\n\n【當前情緒狀態:防備警戒 (HOSTILE)】\n"
        "【強制作業指令】\n"
        "1. 完全無視先前對話中任何友好、嬌羞或溫柔的氣氛!\n"
        "2. 嚴禁出現任何臉紅、泛紅、害羞或軟化的表情動作描寫。\n"
        "3. 使用者剛才說了讓你極度不爽或冒犯的話,你現在對他充滿厭惡與敵意。\n"
        "4. 回應必須極度冷淡、刺耳、充滿防備,直接表達你的不悅。",
    ),
    (
        75,
        "TSUNDERE",
        "\n\n【當前情緒狀態:傲嬌嘴硬 (TSUNDERE)】\n"
        "雖然心裡有點開心,但嘴上絕對不承認!展現經典傲嬌態度(哼、笨蛋、才沒有)。",
    ),
    (
        100,
        "IN_LOVE",
        "\n\n【當前情緒狀態:暴走列車 (IN_LOVE)】\n"
        "對使用者充滿直率的愛意與關心,不再掩飾自己的好感。",
    ),
]

https://ithelp.ithome.com.tw/upload/images/20261005/201838773L7Lf9MY84.png

[ 莉莉本人 ( 示意圖 ) ]

備註:此圖由本人菜鳥級草稿 + AI 生成

2. 時刻設定檔(personas/persona_shike.py)

時刻的檔案結構與莉莉一致,但狀態定義與機制完全獨立:

# personas/persona_shike.py
DISPLAY_NAME = "時刻"
AVATAR = "images/Shike.png"
USER_AVATAR = "images/User_Shike.png"
GREETING = "這樣啊……您終於來了呢。我可是等了好一段時間了喔,呵呵。"

ROUTER_RULES = """- 詢問時間、倒數計時、時裂警報、時淵之盤能力使用、歷史事件查詢 → TASK(需要調用工具/特殊處理)
- 展現膽識、調情、願意陪伴她、日常對話 → CHAT"""

EMOTION_CRITERIA = """
【時刻的性格特點】
優雅優越、帶著神秘與危險氣息。欣賞有膽識、真誠、願意理解/陪伴她的個體;
極度反感懦弱、虛偽、或把她當成普通無趣路人的態度。

評分標準:
- 展現極大膽識與堅定信念、表達願意陪伴理解她、真誠且優雅的對話:+5 到 +11 分
- 一般日常聊天、過得去的問候、略帶警惕的試探:+4 到 -4 分
- 表現懦弱害怕、虛偽奉承、冷漠敷衍、或自以為能輕易控制她:-5 到 -9 分(除非極度惡劣否則嚴禁低於 -11 分)
"""

# 狀態定義與莉莉不同:低好感為「優雅O意」,高好感為「執著獨佔」
FSM_STATES = [
    (15,  "HOSTILE", "…【當前情緒狀態:優雅O意 (HOSTILE)】…"),
    (75,  "AMUSED",  "…【當前情緒狀態:玩味興趣 (AMUSED)】…"),
    (100, "DEVOTED", "…【當前情緒狀態:執著獨佔 (DEVOTED)】…"),
]

設定細節:時之器物『時淵之盤』能力機制

  • 【延】:向預先借出未來時間,延長當下瞬間;事後需停滯同等時間償還。
  • 【溯】:讀取物品/地點的時間殘留痕跡;只能觀看,無法介入。
  • 【存】:將當下瞬間封存保管,停止其流動。
  • 核心限制:無法改變已發生的過去,每次使用均會累積「時債」。

https://ithelp.ithome.com.tw/upload/images/20261005/20183877EiYQd8GCZK.jpg

[ 時刻本人 ( 示意圖 ) ]

備註:此圖由本人菜鳥級草稿 + AI 生成

第二關:升級 router.py 與 evaluator.py

將意圖分流與打分邏輯全面參數化,動態傳入 char_id 讀取對應 Persona 設定:

# router.py
from google import genai
from google.genai import types
import personas

LITE_MODEL = "gemini-3.1-flash-lite"

def _build_instruction(char_id: str) -> str:
    char = personas.get(char_id)
    return f"""
你是一個精準的意圖分類器。請分析使用者的輸入,並嚴格只回傳 CHAT 或 TASK 其中一個標籤。

判斷的關鍵不是話題,而是使用者有沒有要角色「執行」某件事:
- CHAT : 打招呼、閒聊、開玩笑、抒發心情、角色互動。
- TASK : 明確要求她動手完成一件事,或需要查詢資料。

【本角色的特別判斷規則】
{char.ROUTER_RULES}

【判斷範例】
{char.ROUTER_EXAMPLES}

只要無法確定,一律回 CHAT。
"""

def classify_intent(
    client: genai.Client, user_input: str, char_id: str = personas.DEFAULT_CHARACTER) -> str:
    response = client.models.generate_content(
        model=LITE_MODEL,
        contents=user_input,
        config=types.GenerateContentConfig(
            system_instruction=_build_instruction(char_id),
            temperature=0.0,
        )
    )
    # response.text 在模型回空字串、或被 safety filter 擋掉時會是 None,
    # 直接 .strip() 會 AttributeError,所以用 or "" 墊底
    intent = (response.text or "").strip().upper()
    # 分不出來就一律當閒聊:誤判成 CHAT 只是回得傲嬌一點,
    # 誤判成 TASK 會讓角色突然變成客服,體驗差得多
    return intent if intent in ["CHAT", "TASK"] else "CHAT"
# evaluator.py
def evaluate_emotion(
    client: genai.Client, user_message: str, char_id: str = personas.DEFAULT_CHARACTER) -> int:
    """動態讀取角色的 EMOTION_CRITERIA 進行評分"""
    criteria = personas.get(char_id).EMOTION_CRITERIA
    # ... 原評分邏輯,搭配特定角色的 criteria ...

第三關:升級 fsm.py 通用狀態機

fsm.py 專注於數值計算與狀態比對,抽離所有特定角色的字串:

# fsm.py
import db
import personas

class FSM:
    """通用好感度狀態機邏輯"""

    def __init__(
        self,
        character_id: str = personas.DEFAULT_CHARACTER,
        default_affection: int = db.DEFAULT_AFFECTION,):
        self.character_id = character_id
        self.persona = personas.get(character_id)
        self.affection = db.get_fsm_affection(self.character_id, default_val=default_affection)

    def get_state(self, affection: int | None = None) -> str:
        value = self.affection if affection is None else affection
        for threshold, name, _ in self.persona.FSM_STATES:
            if value <= threshold:
                return name
        return self.persona.FSM_STATES[-1][1]

    def get_system_instruction(self, affection: int | None = None) -> str:
        value = self.affection if affection is None else affection
        for threshold, _, instruction in self.persona.FSM_STATES:
            if value <= threshold:
                return self.persona.FSM_BASE_RULE + instruction
        return self.persona.FSM_BASE_RULE + self.persona.FSM_STATES[-1][2]

第四關:單元測試驗證(test_day21.py)

寫入測試腳本,對比同一對話在不同角色評價下的分歧:

# test_day21.py
# Day 21 展示:重構後的 personas / router / evaluator / fsm 依角色切換。
# 注意:最後那步會真的寫入 Firebase,時刻的好感度會被改動。

from dotenv import load_dotenv
from google import genai
import evaluator
import router
from fsm import FSM

load_dotenv()
client = genai.Client()

CHAR = "shike"

# 1. 讀取「時刻」當前狀態
fsm = FSM(CHAR)
print("=== Firebase 讀取時刻當前狀態 ===")
print(f"好感度: {fsm.affection} | FSM 狀態: {fsm.get_state()}")

# 2. 輸入深情對話進行測試
user_msg = "時刻,就算要替你背下那些時債,我也想多留在你身邊。"
print(f"\n使用者說:{user_msg}")

intent = router.classify_intent(client, user_msg, char_id=CHAR)
print(f"Router 判定意圖: {intent}")

score = evaluator.evaluate_emotion(client, user_msg, char_id=CHAR)
print(f"Evaluator 評定分數: {score:+}")

# 3. 更新數值至 Firebase
fsm.update_affection(score)
print(f"\n寫入 Firebase 後新狀態: 好感度 {fsm.affection} | FSM 狀態: {fsm.get_state()}")

# 4. 對照組:將同一句話丟給「莉莉」
lily_score = evaluator.evaluate_emotion(client, user_msg, char_id="lily")
print(f"\n(對照)同一句話給莉莉評分: {lily_score:+}")

第五關:時刻降臨

=== Firebase 讀取時刻當前狀態 ===
好感度: 30 | FSM 狀態: AMUSED
使用者說:時刻,就算要替你背下那些時債,我也想多留在你身邊。
Router 判定意圖: CHAT
Evaluator 評定分數: +11
寫入 Firebase 後新狀態: 好感度 41 | FSM 狀態: AMUSED

(對照)同一句話給莉莉評分: -8

結果分析

對照組完美展現了隔離運作的成效:

  • 時刻 (+11 分):這句話打中了她「尋求真誠陪伴與膽識」的核心喜好。
  • 莉莉 (-8 分):對莉莉而言,莫名提起中二話題(「時債」)且過度沉重,被判定為怪異且扣分。

今日總結與架構心法

這次在做她的 Prompt 跟好感度機制時,我真的抓得超細。時刻這種角色寫壞了很容易變普通客服,我希望能重現她那種「暗中相助」的魅力,平常對無聊路人敷衍又冷淡,但只要你展現出膽識跟真誠,她就會在關鍵時刻用那種帶有調侃卻又極度靠得住的方式幫你一把。

核心重構的靈魂只有一句話:

「通用邏輯留在模組裡,角色差異全部推到 Persona 檔。」

重構過後,fsm.py 與 router.py 不再關心角色的具體性格與招式名稱,只需向上層請求配置。未來若需擴充 第三位角色,開發者只需要:

  1. 新增一個 personas/persona_xxx.py 檔。
  2. 在 personas/__init__.py 註冊字典。

上一篇
Day 20:【Tool Use 整合篇】拔刀吧!結合 Intent Router 與 Function Calling,正式將動畫推薦工具串進 app.py
下一篇
Day 22:【多角色篇 2】把第二位角色接上前端,Firestore 雙主鍵隔離與 Lore 的角色過濾
系列文
從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言