iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
AI Engineering

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

Day 05:【記憶大腦2】告別金魚腦!用 Firestore 保存角色的長期記憶

  • 分享至 

  • xImage
  •  

介紹

昨天我們輕鬆搞定了 Firebase 專案創建與金鑰封印。今天我們要來解決 Day 03 發現的致命痛點,只要按下 F5 重新整理,對話紀錄就直接蒸發 的悲慘命運。

今天的任務很純粹:

  1. 編寫 db.py 測試連線與寫入
  2. 將 Firestore 整合進 Streamlit app.py
  3. 驗證「重整網頁後,對話依然還在」的魔法!

第一關:編寫 Firestore 工具模組 (db.py)

為了不讓 app.py 變得又長又亂,我們另外寫一個 db.py 來專門處理資料庫的讀寫。

在專案目錄下新增 db.py

import firebase_admin
from firebase_admin import credentials, firestore

# 初始化 Firebase (避免重複初始化)
if not firebase_admin._apps:
    cred = credentials.Certificate("firebase-key.json")
    firebase_admin.initialize_app(cred)

db = firestore.client()

# 一次載入的最大訊息總數(一輪對話 = user + assistant 共 2 筆,所以 50 筆約等於 25 輪)
# 這個上限同時擋住兩件事:Firestore 的讀取次數、以及送進 Gemini 的 token 量
MAX_HISTORY = 50

# 儲存對話紀錄到 Firestore
def save_message(user_id: str, role: str, content: str):
    doc_ref = db.collection("users").document(user_id).collection("chat_history").document()
    doc_ref.set({
        "role": role,
        "content": content,
        "timestamp": firestore.SERVER_TIMESTAMP
    })

# 讀取歷史對話紀錄 (只取最近的 MAX_HISTORY 筆)
def load_messages(user_id: str, limit: int = MAX_HISTORY):
    messages_ref = db.collection("users").document(user_id).collection("chat_history")

    # limit_to_last 取的是「排序後的尾端」,也就是時間最新的那幾筆,
    # 但回傳時仍維持 ASCENDING (舊 -> 新),剛好就是對話該有的順序
    # 注意:limit_to_last 只能搭配 .get(),用 .stream() 會直接拋錯
    docs = (
        messages_ref
        .order_by("timestamp", direction=firestore.Query.ASCENDING)
        .limit_to_last(limit)
        .get()
    )

    history = []
    for doc in docs:
        data = doc.to_dict()
        # 用 get 而不是 []:只要有一筆文件缺欄位(後台手動改壞、寫入中斷),
        # 用 [] 會直接 KeyError 讓整個 app 打不開,get 則是跳過那筆繼續讀
        role = data.get("role")
        content = data.get("content")
        if role is None or content is None:
            continue
        history.append({"role": role, "content": content})
    return history

第二關:整合進 Streamlit (app.py)

接著打開 Day 03 的 app.py,把剛才寫好的 db.py 匯入進來。

修改後的 app.py (大更正版本)如下:

import streamlit as st
from dotenv import load_dotenv
from google import genai
from google.genai import types
from db import load_messages, save_message

# 載入 API Key
load_dotenv()

# 設定網頁標題與圖示
st.set_page_config(page_title="AI 角色陪伴系統", page_icon="images/AI_icon.png", layout="wide")
st.title("你的專屬 AI 陪伴 Agent")

# 初始化 user_id (可根據實際需求改為登入系統的 userID 或固定的測試 ID)
if "user_id" not in st.session_state:
    st.session_state.user_id = "test_user_001"  # 範例用的固定 ID,也可改為 str(uuid.uuid4())

# 初始化 Gemini Client
@st.cache_resource
def get_gemini_client():
    return genai.Client()

client = get_gemini_client()

USER_AVATAR = "images/USER_icon.png"  # 使用者的圖片(沒有圖片也可以用 Emoji)
AI_AVATAR = "images/AI_icon.png"

# 初始化 Session State (記憶對話歷史)
if "messages" not in st.session_state:
    db_history = load_messages(st.session_state.user_id)

    if db_history:
        # 如果資料庫中有紀錄,載入進 Session State
        st.session_state.messages = db_history
    else:
        # 如果是新使用者,給予預設開場白並寫入 Firestore
        default_msg = "哼!別以為我是特地在這裡等你的,只是剛好程式啟動了而已!有什麼事快說!"
        st.session_state.messages = [{"role": "assistant", "content": default_msg}]
        save_message(st.session_state.user_id, "assistant", default_msg)

# 初始化 Gemini 記憶對話物件
if "chat" not in st.session_state:
    # 將舊有的對話格式轉換為 Gemini Chat 所需的 Content 格式
    # 開場白 (model 角色) 會一起帶進去,實測 history 以 model 開頭是合法的
    gemini_history = []
    for msg in st.session_state.messages:
        # 轉換 role 格式:assistant -> model, user -> user
        gemini_role = "model" if msg["role"] == "assistant" else "user"
        gemini_history.append(
            types.Content(
                role=gemini_role,
                parts=[types.Part.from_text(text=msg["content"])]
            )
        )

    st.session_state.chat = client.chats.create(
        model="gemini-3.6-flash",
        history=gemini_history,
        config=types.GenerateContentConfig(
            system_instruction="你現在是一個傲嬌角色。請直接以第一人稱回覆使用者,回答請簡短,不要列出選項或多餘說明。"
        )
    )

# 歷史對話紀錄
for msg in st.session_state.messages:
    # 判斷如果是 assistant 就用圖片,否則用預設/其他頭像
    avatar_icon = AI_AVATAR if msg["role"] == "assistant" else USER_AVATAR

    with st.chat_message(msg["role"], avatar=avatar_icon):
        st.write(msg["content"])

# 接收使用者輸入
if user_input := st.chat_input("跟你的角色說點什麼吧..."):
    # 顯示使用者發送的訊息
    with st.chat_message("user", avatar=USER_AVATAR):
        st.write(user_input)

    # 先呼叫 API,成功之後才寫入紀錄
    # 順序很重要:如果先存 user 訊息、API 才失敗,
    # Firestore 會留下筆沒有回覆的孤兒訊息,下次載入時歷史就以 user 結尾
    # 用 send_message!Gemini 會自動帶入之前的對話上下文
    try:
        response = st.session_state.chat.send_message(user_input)
    except Exception as e:
        # 這一輪完全不落地:session 和 Firestore 都維持送出前的狀態
        st.error(f"角色沒有回應,API 出錯了:{e}")
        st.info("這句話沒有被記錄,重新整理後會消失,可以直接再說一次。")
        st.stop()

    # 顯示 AI 角色的回應
    with st.chat_message("assistant", avatar=AI_AVATAR):
        st.write(response.text)

    # 確定有回應了,才把這一輪的兩筆訊息一起存入 Session 與 Firestore
    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": response.text})
    save_message(st.session_state.user_id, "assistant", response.text)

這裡提一下,正式環境不可使用固定的 test_user_001

本文僅使用固定 ID 方便 Demo 與測試;正式環境應使用經過驗證的使用者 ID,並由後端驗證使用者身分,不能直接信任前端傳入的。

第三關:驗證魔法時刻!奇蹟和魔法都是存在的

二次啟動測試

https://ithelp.ithome.com.tw/upload/images/20260919/20183877gRqIT7nhvU.png

Firestore 歷史對話載入

https://ithelp.ithome.com.tw/upload/images/20260919/201838772qlES3sKiD.png
https://ithelp.ithome.com.tw/upload/images/20260919/20183877Q2X6HJxHKB.png
https://ithelp.ithome.com.tw/upload/images/20260919/201838779HGOdL652W.png

今日踩坑與優化點總結

1. 訊息變孤兒

  • 問題:還沒收到 AI 回覆,就把使用者的話先存進資料庫。如果 API 突然壞掉,資料庫就會留下一句「沒有人理」的孤兒訊息。
  • 解法:等 AI 成功回覆後,才把問答一起存檔;若 API 壞掉就立刻停止,不留垃圾資料。

2. 壞資料讓系統當機

  • 問題:直接用 data["role"] 硬抓資料,只要資料庫裡有一筆紀錄漏掉欄位,整個網頁就直接噴錯(歐歐歐歐的)死掉。
  • 解法:改用 .get() 溫和讀取,遇到欄位缺失的壞資料就自動跳過,保障程式正常運作。

3. 歷史紀錄塞爆(防讀取慢與 Token 爆炸)

  • 問題:一次把成立以來的聊天紀錄全部載入,導致讀取越來越慢,發給 AI 的 Token 費也大噴發。
  • 解法:設定只讀取最新的 50 筆紀錄。另外注意到 Firebase 的小眉角:使用 limit_to_last 時,必須把 .stream() 改成 .get() 才不會報錯。

上一篇
Day 04:【記憶大腦1】拯救金魚腦!Firebase 專案開通
下一篇
Day 06:【靈魂注入1】告別 Prompt 提示詞!使用 System Instruction 與 Chats 物件烙印角色人格
系列文
從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言