昨天(Day 21)我們把後端整個拆乾淨了:personas/ 一個角色一個檔案,router.py、evaluator.py、fsm.py 全部改吃 char_id。但那些成果只在 test_day21.py 的終端機裡跑過,打開 Streamlit,畫面上依然只有莉莉一個人。
今天要把「多角色」真正端到使用者面前,而這一步會逼出三個資料隔離的問題:
affection 欄位,等於你討好莉莉的同時也在討好時刻。今天的目標:
user_id + char_id 雙重主鍵的資料結構seed_lore.py 每筆 lore 都帶 char_id,檢索時依角色過濾原本的結構是扁平的 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數字。
前端這段的重點不是選單本身(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/),不使用任何既有作品的角色圖,這跟角色改名是同一件事的兩面:名字換了但圖沒換,等於白改。
前端跟資料庫都隔離好了,但還有一個藏在 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做完才想到要截的,但差不多就長這樣
使用者:莉莉,你上次烤的薄餅真的超好吃!
莉莉:這、這不是理所當然的嗎?我的手藝怎麼可能會出錯!你這傢伙突然誇我,我、我才不會高興呢……不過,看在你還算有眼光的份上,下次有空再勉強烤給你吃就是了。對了,你昨天那個資工系的期末考……考得怎麼樣啊?要是考得太難看,以後可別說你認識我,更不准說你吃過我做的甜點,聽到沒有!

使用者:時刻,你借來的那些時間,最後都用到哪裡去了?
時刻:手腕上那些沉重的代價,究竟落在了哪裡呢……?您看著我,眼神裡寫滿了好奇。這真是一場有趣的試探……
