iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
AI Engineering

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

Day 23:【自動記憶提取器】不要再叫我手動塞記憶了!用 JSON Schema 讓 Agent 自己抓事實

  • 分享至 

  • xImage
  •  

前言

到目前為止,我們的長期記憶其實有一個很尷尬的祕密:那些記憶都是我自己手動寫進去的。

test_rag.py 裡長這樣:

rag_memory.add_user_memory("mem_1", "使用者是資工系學生", ...)
rag_memory.add_user_memory("mem_2", "使用者下週有期末考", ...)

這根本不是「記憶」,這是我在幫她抄筆記。真正的陪伴 Agent 應該是,我只是隨口說了一句「我叫小明,是資工系的,最近在準備下週二的期末考」,她自己就把裡面的三件事拆好、分類好、存起來。

今天要做的就是這個自動記憶提取器(Memory Extractor)。

今天的目標:

  • 用 Gemini 的 response_schema 把提取結果鎖成結構化 JSON
  • 設計「什麼該記/什麼不該記」的提取準則
  • 加一道角色名防洩漏的守門(這是多角色系統特有的坑)
  • 用向量距離做記憶去重,並且實際量測門檻該訂在哪

第一關:為什麼不能只在 Prompt 裡拜託它回 JSON

第一版我是這樣寫的:「請以 JSON 格式回傳,格式為 {"facts": [...]}」。

結果模型很配合地回了我:

好的!以下是提取出的事實:

```json
{"facts": [...]}

希望這個結果對你有幫助!


`json.loads()` 直接炸。我當然可以寫正規表示式把 `json` 挖出來,但那是在跟模型玩貓捉老鼠,今天它加開場白,明天它可能加註解。

解法是 `response_schema`:

```python
_RESPONSE_SCHEMA = {
    "type": "object",
    "properties": {
        "facts": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "category": {"type": "string", "enum": CATEGORIES},
                    "text": {"type": "string"},
                },
                "required": ["category", "text"],
            },
        }
    },
    "required": ["facts"],
}

response = client.models.generate_content(
    model=LITE_MODEL,
    contents=user_message,
    config=types.GenerateContentConfig(
        system_instruction=EXTRACTOR_INSTRUCTION,
        temperature=0.0,
        # 用 response_schema 是解碼層級的強制約束,
        # 比在 prompt 裡拜託模型「只回傳 JSON」可靠得多
        response_mime_type="application/json",
        response_schema=_RESPONSE_SCHEMA,
    ),
)

差別在於 prompt 是「請求」,schema 是「約束」。response_schema 是在解碼階段限制模型能吐出什麼 token,它根本沒有機會寫出 ````json` 這幾個字。

其中我最喜歡的是 enum:

# 事實的分類。用 enum 鎖死,模型就不會自由發揮出「使用者的喜好」這種分類,
# 之後想依類別篩選記憶時才有一致的鍵值
CATEGORIES = ["name", "preference", "hobby", "habit", "schedule", "other"]

沒有 enum 的話,同一個概念模型會今天寫 preference、明天寫 喜好、後天寫 user_preference。等你之後想做「只撈出 schedule 類記憶來做過期清理」的時候,就會發現資料庫裡是一團無法查詢的垃圾。

第二關:提取準則,「不該記什麼」比「該記什麼」難寫

EXTRACTOR_INSTRUCTION = f"""
你是一個嚴謹的記憶提取器。請從使用者說的話裡,找出「關於這位使用者本人」的、值得長期記住的事實。

【什麼該提取】
- name       : 姓名、綽號、希望被怎麼稱呼
- preference : 喜好與厭惡(食物、口味、類型…)
- hobby      : 興趣、專長、正在追的作品
- habit      : 生活習慣、作息、固定行程
- schedule   : 特定時間點的計畫或事件(考試、旅行、截止日)
- other      : 身分、職業、就讀科系等其他長期屬性

【什麼不該提取】
- 打招呼、寒暄、情緒抒發(「哈囉」「好累喔」「你好可愛」)
- 對角色的提問或評論(那是關於角色,不是關於使用者)
- 一次性的當下狀態(「我現在有點餓」)
- 使用者要求角色做的任務(「幫我算 17 乘 23」)
- 無法確定是否為事實的推測

【寫法要求】
- 每筆用完整的一句話描述,主詞寫「使用者」,讓這句話單獨拿出來看也讀得懂。
  好:「使用者討厭吃香菜」 壞:「討厭香菜」
- 一句話只講一件事。使用者一次講三件事就拆成三筆。
- 沒有任何值得記住的事實時,回傳空陣列 facts: []。寧可不記,也不要記垃圾。

可用的 category 只有:{", ".join(CATEGORIES)}
"""

「主詞寫使用者」這條規則的重要性超乎我預期。 因為這些記憶最後會被 RAG 撈出來、貼進 system_instruction 裡。如果存的是「討厭香菜」,模型看到這行孤零零的字,根本分不清是使用者討厭、還是角色討厭。加上主詞,這句話單獨拿出來也讀得懂,這就是所謂「self-contained 的記憶」。

「寧可不記,也不要記垃圾」也是刻意寫進去的。第一版沒寫這句,結果我說「哈囉」,它硬是提取出一筆「使用者會打招呼」。記憶庫很快就被這種廢話塞滿,真正重要的事實反而被稀釋掉。

第三關:多角色系統特有的坑,記憶會洩漏角色的存在

這一關是我做到一半才驚覺的。

使用者記憶跟角色 lore 不一樣:lore 有 char_id 隔離,但使用者記憶是跨角色共用的(你叫什麼名字,不管跟誰聊都該記得)。

問題來了。我跟莉莉說「你做的薄餅超好吃」,提取器很自然地存下:

使用者喜歡莉莉做的薄餅

然後我切到時刻。她 RAG 撈到這筆記憶,看到「莉莉」這兩個字,她現在知道莉莉存在了。 char_id 隔離守了整個專案,卻被使用者記憶這條側門整個繞過去。

所以我在 prompt 裡加了明確禁令:

- **絕對不要在事實裡寫出任何角色的名字**,改寫成中性的描述。
  好:「使用者喜歡吃薄餅」 
  壞:「使用者喜歡莉莉做的薄餅」
  原因:這裡存的是「關於使用者」的事實,會被所有角色共用;
  夾帶角色名等於讓 A 角色知道 B 角色的存在,破壞角色之間的隔離。

但這樣還不夠,prompt 是請求不是約束(這句話今天已經是第二次出現了)。所以程式端再擋一道:

def _character_names() -> list[str]:
    """所有角色的顯示名稱。新增角色時自動跟著長,不必回頭改這裡"""
    return [personas.get(cid).DISPLAY_NAME for cid in personas.CHARACTERS]

def _mentions_character(text: str) -> bool:
    """這筆事實有沒有夾帶角色名字?

    選擇整筆丟棄而不是把名字挖掉:挖掉會產生「使用者喜歡做的薄餅」
    這種讀不通的句子,而讀不通的記憶注入 prompt 只會讓模型亂猜。
    丟掉一筆喜好,使用者下次還會再提;洩漏一次角色存在,收不回來。
    """
    for name in _character_names():
        if name in text:
            print(f"[memory_extractor] 事實夾帶角色名「{name}」,整筆丟棄:{text}")
            return True
    return False

「丟掉一筆喜好,使用者下次還會再提;洩漏一次角色存在,收不回來」,這個代價不對稱的思路,今天還會再用到一次。

第四關:記憶去重,這次我真的去量了

新事實要寫進去之前得先確認「這件事是不是已經知道了」。用字串比對完全沒用:「我討厭香菜」和「香菜我一點都不喜歡」一個字都對不上,但講的是同一件事。所以要用向量距離。

問題是:門檻要訂多少?

我原本想隨便抓個 0.4 就好。但既然有現成的 embedding,不如直接把幾組代表性的句子丟進去量:

0.201  使用者不吃香菜        ←→ 使用者討厭吃香菜   (重複)
0.211  期末考在下週二        ←→ 下週二有期末考     (重複)
0.264  使用者討厭吃芹菜      ←→ 使用者討厭吃香菜   (不同!)
0.328  香菜我一點都不喜歡    ←→ 使用者討厭吃香菜   (重複)
0.393  我的名字是小明        ←→ 使用者叫小明       (重複)
0.408  使用者叫小華          ←→ 使用者叫小明       (不同)

看第三列跟第五列。

「討厭芹菜 vs 討厭香菜」(0.264,是兩件不同的事)比「我的名字是小明 vs 使用者叫小明」(0.393,是同一件事)還要近。

這代表沒有任何單一門檻能完美分開重複與不同。兩個區間是重疊的,不管切在哪裡都會有誤判。

那就只能看誤判的代價哪邊比較貴:

  • 誤判為重複 → 這個事實永久遺失。你說你討厭芹菜,系統以為已經知道了,於是那件事再也沒被記錄。
  • 誤判為不同 → 只是多存一筆重複的。檢索時囉唆一點,不致命。

代價完全不對稱,所以門檻要壓低:

# 兩害相權取其輕,門檻壓低到只擋掉幾乎逐字重複的那些
DUPLICATE_DISTANCE = 0.25

0.25 這個數字擋掉了 0.201 和 0.211 那兩組逐字重複,同時放行 0.264 的芹菜(正確)。代價是 0.328 的「香菜我一點都不喜歡」會被誤判成新事實而多存一筆,我接受這個代價。

(伏筆:這個灰色地帶後來還是被我處理掉了,但用的不是門檻,而是叫語意模型來判斷關係。那是 Day 25 的事。)

第五關:組起來 extract_and_store()

def extract_and_store(client, user_message: str, user_id: str = "default_user") -> dict:
    """完整流程:提取事實 → 記憶比對 → 寫入"""
    facts = extract_facts(client, user_message)
    if not facts:
        return {"stored": [], "skipped": []}

    stored, skipped = [], []
    for fact in facts:
        text = fact["text"]

        # 用向量比對而不是字串比對,因為「我討厭香菜」和「香菜我不喜歡」
        # 是同一件事,字串比對完全抓不到
        dup = rag_memory.find_similar_memory(text, user_id=user_id)
        if dup:
            skipped.append((text, dup["text"], dup["distance"]))
            continue

        # id 用 uuid:自動提取沒辦法像 test_rag.py 那樣手動編號。
        # 去重交給上面的向量比對,不靠 id 碰撞
        rag_memory.add_user_memory(
            memory_id=uuid.uuid4().hex,
            text=text,
            user_id=user_id,
            category=fact["category"],
        )
        stored.append(fact)

    return {"stored": stored, "skipped": skipped}

回傳 stored / skipped 兩個清單而不是只回一個布林值,是為了讓測試能看見「它決定不記什麼、為什麼不記」。記憶系統最難 debug 的就是「東西沒被記住」,如果函式只回 True,你永遠不知道是提取階段沒抓到、還是去重階段擋掉了。

第六關:測試(test_day23.py)

測試用一個獨立的 user_id,跑完自動清掉,不汙染真實記憶:

USER = "test_day23_user"

# 1. 一句話含多個事實 → 應該拆成多筆
show("我叫小明,是資工系的,最近在準備下週二的期末考", ...)
# 2. 喜好
show("我超討厭吃香菜,看到就反胃", ...)
# 3. 換句話說同一件事 → 記憶比對應該擋下來
show("(換句話說)香菜我一點都不喜歡", ...)
# 4. 純寒暄 → 不該提取出任何東西
show("哈囉,你今天看起來很不錯耶", ...)
# 5. 對角色的提問 → 那是關於角色,不是關於使用者
show("你會做什麼甜點?", ...)
# 6. 相似但不同的事實 → 不該被誤判成重複
show("其實芹菜我也不敢吃", ...)

實際輸出:

============================================================
Day 23:長期記憶提取器
============================================================

【我叫小明,是資工系的,最近在準備下週二的期末考】
  + [name      ] 使用者叫小明
  + [other     ] 使用者是資工系的學生
  + [schedule  ] 使用者下週二有期末考

【我超討厭吃香菜,看到就反胃】
  + [preference] 使用者討厭吃香菜

【(換句話說)香菜我一點都不喜歡】
  - 跳過(距離 0.213,已有「使用者討厭吃香菜」):使用者不喜歡吃香菜

【哈囉,你今天看起來很不錯耶】
  (沒有值得記住的事實)

【你會做什麼甜點?】
  (沒有值得記住的事實)

【其實芹菜我也不敢吃】
  + [preference] 使用者不敢吃芹菜

============================================================
最終記憶庫內容
============================================================
  [name      ] 使用者叫小明
  [other     ] 使用者是資工系的學生
  [schedule  ] 使用者下週二有期末考
  [preference] 使用者討厭吃香菜
  [preference] 使用者不敢吃芹菜

共 5 筆
(測試資料已清除)

六個案例全部符合預期。特別滿意的兩個:

  • 案例 4、5 都正確地什麼都沒記。 「你會做什麼甜點?」很容易被誤判成「使用者喜歡甜點」,但那是對角色的提問,不是關於使用者的事實。prompt 裡「什麼不該提取」那一段真的有在工作。
  • 案例 3 和案例 6 的分野。 「香菜我一點都不喜歡」被擋下(0.213),「芹菜我也不敢吃」被存進去,這正是第四關那張量測表在保護的邊界。有趣的是實際跑出來的 0.213 比我量測時的 0.328 更近,因為模型把它改寫成了更接近既有記憶的「使用者不喜歡吃香菜」。提取器的改寫會影響去重的距離,這是量測表沒涵蓋到的一個變因。

今日心得

今天最大的收穫不是「做出了記憶提取器」,而是兩個可以帶著走的原則:

約束比祈求可靠
第一次用 JSON Schema 才知道有多香。以前在 Prompt 拜託模型「請回傳 JSON」,結果它多回一句「好的」系統就崩潰。直接用 response schema 加 enum 從解碼層級鎖死,模型物理上吐不出自由發揮的分類,工程上真的約束比祈求可靠太多。

門檻不要用猜的,去量;量完發現分不開,就去比較誤判的代價。 那張距離表最有價值的地方不是給了我 0.25 這個數字,而是**證明了沒有完美的數字,**芹菜 0.264 比小明 0.393 還近。認清這件事之後,設計思路才從「找到正確門檻」變成「決定要往哪邊犯錯」。


上一篇
Day 22:【多角色篇 2】把第二位角色接上前端,Firestore 雙主鍵隔離與 Lore 的角色過濾
下一篇
Day 24:【用戶輪廓與主動關心】從「你問我才想起來」進化成「我一直記得你」
系列文
從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言