iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
AI Engineering

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

Day 22:【多角色篇 2】把第二位角色接上前端,Firestore 雙主鍵隔離與 Lore 的角色過濾

  • 分享至 

  • xImage
  •  

前言

昨天(Day 21)我們把後端整個拆乾淨了:personas/ 一個角色一個檔案,router.py、evaluator.py、fsm.py 全部改吃 char_id。但那些成果只在 test_day21.py 的終端機裡跑過,打開 Streamlit,畫面上依然只有莉莉一個人。

今天要把「多角色」真正端到使用者面前,而這一步會逼出三個資料隔離的問題:

  1. 對話歷史:切到時刻,如果載入的是你跟莉莉的聊天紀錄,她會把那些話當成自己說過的。
  2. 好感度:兩個角色共用一個 affection 欄位,等於你討好莉莉的同時也在討好時刻。
  3. 角色設定(Lore):ChromaDB 裡兩位的 lore 混在同一個 collection,時刻會檢索到「莉莉非常擅長製作甜點」,然後當成自己的設定講出來。

今天的目標:

  • Firestore 改成 user_id + char_id 雙重主鍵的資料結構
  • Streamlit 側邊欄加上角色選單,並在切換時正確重建 Session
  • seed_lore.py 每筆 lore 都帶 char_id,檢索時依角色過濾
  • 實測驗證:時刻撈不到莉莉的設定

第一關:Firestore 的資料結構要重畫

原本的結構是扁平的 characters/{char_id},好感度是全站共用的。這在單人單角色的 demo 沒問題,但只要多一個維度就會爆炸。新的結構:

users/{user_id}/characters/{char_id}
  ├── affection 欄位            這位使用者對這個角色的好感度
  └── chat_history/{auto_id}    這位使用者跟這個角色的對話

兩個維度都必須隔離:

  • 少了 char_id → 切到時刻會載入你跟莉莉的對話,她會把那些當成自己說過的話。
  • 少了 user_id → 好感度全站共用,A 把角色惹毛了,B 打開也是冷臉。

實作上只要抽一個 _character_ref(),其他函式全部走它:

# db.py
def _character_ref(user_id: str, char_id: str):
    return (
        db.collection("users")
        .document(user_id)
        .collection("characters")
        .document(char_id)
    )

# ---- 對話紀錄 ----
def save_message(user_id: str, char_id: str, role: str, content: str):
    """儲存一則對話到「這位使用者 × 這個角色」的專屬紀錄"""
    doc_ref = _character_ref(user_id, char_id).collection("chat_history").document()
    doc_ref.set({
        "role": role,
        "content": content,
        "timestamp": firestore.SERVER_TIMESTAMP
    })

# ---- FSM:角色好感度 ----
def get_fsm_affection(
    user_id: str, char_id: str, default_val: int = DEFAULT_AFFECTION) -> int:
    """讀取「這位使用者 × 這個角色」的好感度"""
    doc = _character_ref(user_id, char_id).get()

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

def save_fsm_affection(user_id: str, char_id: str, affection: int):
    doc_ref = _character_ref(user_id, char_id)
    # merge=True:只覆寫這幾個欄位,不會把底下的 chat_history 或其他欄位清掉
    doc_ref.set({
        "user_id": user_id,
        "character_id": char_id,
        "affection": affection,
        "updated_at": firestore.SERVER_TIMESTAMP
    }, merge=True)

一個刻意「不存」的東西

有件事我想特別講:FSM 狀態(HOSTILE / TSUNDERE / AMUSED …)我沒有存進 Firestore。

一開始我很自然地想加一個 fsm_state 欄位,畢竟「狀態機」聽起來就該存狀態。但想一想,狀態是 fsm.get_state() 依門檻從 affection 即時算出來的,存了就會有兩份真相。哪天我調整了 FSM_STATES 的門檻(比如把 TSUNDERE 從 75 改成 70),資料庫裡那些舊的 fsm_state 就全部是錯的,還得寫 migration 回填。

能算出來的東西就不要存。 存的只有「無法從別處推導的原始事實」,這裡就是那個 affection 數字。

第二關:Streamlit 側邊欄的角色選單

前端這段的重點不是選單本身(st.selectbox 三行就寫完了),而是選單後面那個 Session 重建。

# app.py
# --- 角色選擇器 ---
# 選單直接讀 personas.CHARACTERS,所以新增角色不必動這裡。
# 放在檔案前段是必要的:底下每一步都要用到 char_id
with st.sidebar:
    st.header("🎭 角色選擇")
    char_id = st.selectbox(
        "目前對話對象",
        options=list(personas.CHARACTERS),
        format_func=lambda cid: personas.get(cid).DISPLAY_NAME,
    )

persona = personas.get(char_id)

# 角色切換時重建 Session。
# chat / messages / fsm 三個物件都綁著特定角色,不清掉的話
# 切到時刻會拿著莉莉的對話歷史和好感度繼續跑
if st.session_state.get("active_char") != char_id:
    for key in ("chat", "messages", "fsm"):
        st.session_state.pop(key, None)
    st.session_state.active_char = char_id

st.title(f"你的專屬 AI 陪伴 Agent - {persona.DISPLAY_NAME}")

format_func 這招值得記一下:options 放的是 char_id(lily / shike),但畫面上顯示的是 DISPLAY_NAME(莉莉 / 時刻)。程式拿到的永遠是乾淨的 ID,使用者看到的永遠是角色名,不用再做一次反查對照。

而 active_char 這個哨兵變數是今天最容易漏掉的一行。Streamlit 每次互動都會從頭重跑整支腳本,但 st.session_state 裡的東西會活下來。所以如果不主動清掉,切換角色之後:

  • st.session_state.chat 還是那個帶著莉莉全部對話歷史的 Gemini chat 物件
  • st.session_state.messages 畫出來的還是跟莉莉的聊天泡泡
  • st.session_state.fsm 還是莉莉的好感度

畫面上的名字換了,靈魂沒換。 這種 bug 特別陰險,因為它看起來「幾乎是對的」。

順帶一提,頭像也是跟著角色走的:

USER_AVATAR = persona.USER_AVATAR     # 使用者頭像,也跟著角色切換
AI_AVATAR = persona.AVATAR            # 角色頭像,跟著選單切換

莉莉是粉色系的圓形頭像、使用者側是白色;切到時刻整組換成紫色系。這幾張圖都是自己用 PIL 產的純色圓形頭像(見 images/),不使用任何既有作品的角色圖,這跟角色改名是同一件事的兩面:名字換了但圖沒換,等於白改。

第三關:Lore 也要依角色隔離

前端跟資料庫都隔離好了,但還有一個藏在 ChromaDB 裡的漏洞。原本 add_character_lore() 是這樣寫的:

lore_collection.upsert(documents=[text], ids=[lore_id])

兩個角色的 lore 全部躺在同一個 collection、沒有任何標記,query 一下去撈到誰算誰。修法是每筆都帶上 char_id metadata,並且在 id 上加角色前綴:

# rag_memory.py
# lore 必須依角色隔離:所有角色共用一個 collection 但各自帶 char_id,
# 否則時刻會檢索到「莉莉非常擅長製作甜點」,然後當成自己的設定講出來
def add_character_lore(lore_id: str, text: str, char_id: str, category: str = "general"):
    """寫入某個角色的背景設定、喜好或經典事件"""
    lore_collection.upsert(
        documents=[text],
        metadatas=[{"char_id": char_id, "category": category}],
        # id 前面加上角色前綴,避免兩個角色的 lore_1 互相覆蓋
        ids=[f"{char_id}:{lore_id}"]
    )

def query_character_lore(query_text: str, char_id: str, n_results: int = 2):
    """搜尋這個角色最相關的設定,其他角色的設定不會被撈到"""
    results = lore_collection.query(
        query_embeddings=_query_ef([query_text]),
        n_results=n_results,
        where={"char_id": char_id},   # 關鍵:只搜尋這個角色的設定
        include=["documents", "metadatas", "distances"],
    )
    ...

那個 id 前綴不是小事:兩位角色都有 lore_1,沒有前綴的話 upsert 會讓後灌的那個直接覆蓋掉前一個,而且不會報錯,你只會發現莉莉的第一筆設定莫名其妙不見了。

第四關:seed_lore.py

# seed_lore.py
import rag_memory

LORE = {
    "lily": [
        ("lore_1", "莉莉非常擅長製作各種甜點與手工烘焙,特別是烤薄餅,最喜歡推薦甜點給別人。", "food"),
        ("lore_2", "莉莉極度重視家人之間的羈絆,任何傷害她家人的人都會被她視為敵人。", "family"),
        ("lore_3", "莉莉喜歡帥哥類型的偶像,對於少女漫畫風格的情節容易害羞。", "hobby"),
        ("lore_4", "莉莉的左手腕上總是套著一條被麵粉染白的髮圈,那是她進廚房的習慣,她對自己的手藝相當有自信。", "appearance"),
        # --- 世界觀(本專案原創設定,不對應任何既有作品)---
        ("lore_5", "莉莉和姊姊兩個人一起撐起家族甜點工房「月見坂菓子鋪」,父母很早就把店交給了她們。", "world"),
        ("lore_6", "月見坂菓子鋪是一間開了三代的老店,招牌商品是每天限量的手工烤薄餅。", "world"),
        ("lore_7", "店裡新來了一位負責記帳與外送的夥計,莉莉嫌他手腳笨拙天天挑剔,卻又總是多留一份點心給他。", "world"),
        ("lore_8", "月見坂菓子鋪的帳一直很難看,莉莉嘴上說不在乎,其實每晚打烊後都會自己重算一遍。", "world"),
        ("lore_9", "莉莉在店裡負責掌廚,家裡與店裡的餐點大多出自她手。", "food"),
    ],
    "shike": [
        ("lore_1", "時刻的能力來自時之器物「時淵之盤」,共有延、溯、存三種權能,"
                   "能向未來預借時間、觀看過去的殘影、把某個瞬間封存起來。", "power"),
        ("lore_2", "時刻每動用一次時淵之盤都會累積「時債」,還不出來時"
                   "會被盤收進「暗影迴廊」強制償還。", "power"),
        ("lore_3", "時刻表面優雅端莊,但被真誠承諾「我會等你」或被直接誇獎時,防線會崩解露出害羞的一面。", "personality"),
        ("lore_4", "時刻被時序管理局登記為「未償者」,是欠下最多時債的時之異客,但她借來的時間幾乎都用在別人身上。", "background"),
        ("lore_5", "時刻的右手腕上有一圈灰色刻痕,每向未來借一次時間就多一道,她從不主動讓人看見。", "appearance"),
        # --- 世界觀(本專案原創設定,不對應任何既有作品)---
        ("lore_6", "守夜者是傳說中唯一願意替時之異客作保、承擔其時債的人類,時刻對他抱持著特殊的興趣。", "world"),
        ("lore_7", "時之異客現界時會引發「時裂」,造成大規模破壞,因此被人類視為災害。", "world"),
        ("lore_8", "時序管理局是人類組成的對異客作戰部隊,以武裝封鎖時裂為目標。", "world"),
        ("lore_9", "時刻說話時自稱「我」,語氣優雅從容,習慣以「呵呵」作為笑聲。", "personality"),
    ],
}

if __name__ == "__main__":
    for char_id, entries in LORE.items():
        for lore_id, text, category in entries:
            rag_memory.add_character_lore(lore_id, text, char_id=char_id, category=category)
        print(f"{char_id}: 灌入 {len(entries)} 筆 lore")

    print("\n--- 驗證檢索隔離 ---")
    for query in ["你會做什麼甜點?", "你的能力是什麼?"]:
        print(f"\n>>> {query}")
        for char_id in LORE:
            hits = rag_memory.query_character_lore(query, char_id, n_results=1)
            h = hits[0]
            print(f"  [{char_id:6}] ({h['distance']:.3f}) {h['text'][:32]}")

寫成 dict[char_id] -> list[(id, text, category)] 的形狀是刻意的:新增第三個角色,就是往這個 dict 加一個鍵,__main__ 那段迴圈完全不用動。而且它可以重複執行,upsert 會覆蓋同 id 的資料,不會累積出重複的 lore。

兩位角色的世界觀都是原創的

順便說明一下:這兩組 lore 的世界觀(月見坂菓子鋪、時淵之盤的延/溯/存三權能、時債、暗影迴廊、時裂、時序管理局、守夜者)全部是為本專案自創的。

第五關:多重宇宙來襲,莉莉、時刻降臨 (測試)

(venv) PS D:\Character Interaction and Situations Agent> python .\seed_lore.py
lily: 灌入 9 筆 lore
shike: 灌入 9 筆 lore

--- 驗證檢索隔離 ---

>>> 你會做什麼甜點?
  [lily  ] (0.499) 莉莉非常擅長製作各種甜點與手工烘焙,特別是烤薄餅,最喜歡推薦甜點
  [shike ] (0.778) 時刻表面優雅端莊,但被真誠承諾「我會等你」或被直接誇獎時,防線會

>>> 你的能力是什麼?
  [lily  ] (0.699) 莉莉非常擅長製作各種甜點與手工烘焙,特別是烤薄餅,最喜歡推薦甜點
  [shike ] (0.654) 時刻的能力來自時之器物「時淵之盤」,共有延、溯、存三種權能,能向

隔離成功了:問甜點時,時刻撈不到莉莉那筆;問能力時,時刻正確命中時淵之盤。

頁面測試

這裡其實是DAY28做完才想到要截的,但差不多就長這樣

莉莉測試

使用者:莉莉,你上次烤的薄餅真的超好吃!

莉莉:這、這不是理所當然的嗎?我的手藝怎麼可能會出錯!你這傢伙突然誇我,我、我才不會高興呢……不過,看在你還算有眼光的份上,下次有空再勉強烤給你吃就是了。對了,你昨天那個資工系的期末考……考得怎麼樣啊?要是考得太難看,以後可別說你認識我,更不准說你吃過我做的甜點,聽到沒有!

https://ithelp.ithome.com.tw/upload/images/20261006/20183877m9OeurCLDM.png

時刻測試

使用者:時刻,你借來的那些時間,最後都用到哪裡去了?

時刻:手腕上那些沉重的代價,究竟落在了哪裡呢……?您看著我,眼神裡寫滿了好奇。這真是一場有趣的試探……

https://ithelp.ithome.com.tw/upload/images/20261006/20183877SpGv0JykBz.png


上一篇
Day 21:【多角色篇 1】重構 Router、Evaluator、Persona 與好感度 FSM
下一篇
Day 23:【自動記憶提取器】不要再叫我手動塞記憶了!用 JSON Schema 讓 Agent 自己抓事實
系列文
從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言