到目前為止,我們的長期記憶其實有一個很尷尬的祕密:那些記憶都是我自己手動寫進去的。
test_rag.py 裡長這樣:
rag_memory.add_user_memory("mem_1", "使用者是資工系學生", ...)
rag_memory.add_user_memory("mem_2", "使用者下週有期末考", ...)
這根本不是「記憶」,這是我在幫她抄筆記。真正的陪伴 Agent 應該是,我只是隨口說了一句「我叫小明,是資工系的,最近在準備下週二的期末考」,她自己就把裡面的三件事拆好、分類好、存起來。
今天要做的就是這個自動記憶提取器(Memory Extractor)。
今天的目標:
response_schema 把提取結果鎖成結構化 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 筆
(測試資料已清除)
六個案例全部符合預期。特別滿意的兩個:
今天最大的收穫不是「做出了記憶提取器」,而是兩個可以帶著走的原則:
約束比祈求可靠
第一次用 JSON Schema 才知道有多香。以前在 Prompt 拜託模型「請回傳 JSON」,結果它多回一句「好的」系統就崩潰。直接用 response schema 加 enum 從解碼層級鎖死,模型物理上吐不出自由發揮的分類,工程上真的約束比祈求可靠太多。
門檻不要用猜的,去量;量完發現分不開,就去比較誤判的代價。 那張距離表最有價值的地方不是給了我 0.25 這個數字,而是**證明了沒有完美的數字,**芹菜 0.264 比小明 0.393 還近。認清這件事之後,設計思路才從「找到正確門檻」變成「決定要往哪邊犯錯」。