iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
AI Engineering

從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent系列 第 20

Day 20|Semantic Memory vs Episodic Memory:AI 到底記得什麼?

  • 分享至 

  • xImage
  •  

昨天我們把 Long-term Memory 和 User Profile 分開了。

現在 Memora 會直接從 user_profile.json 取得目前的英文程度、學習目標與回答偏好;同時保留 Day 18 的 Retrieval,在需要時從 Chroma 找回相關的過去紀錄。不過Chroma 裡的所有 Memory 目前仍然長得一樣:

Document
Embedding
Metadata

對程式來說,下面兩筆資料都只是普通的 Memory:

使用者的英文程度是 B1。
使用者昨天完成了機場報到英文練習。

但它們描述的並不是同一種事情。第一筆比較像對使用者的一般認知;第二筆則記錄某一次發生過的經歷。今天要替既有的 Memory Record 加入:

memory_type

讓 Memora 開始分辨 Semantic MemoryEpisodic Memory


一、Semantic Memory 記住「知道什麼」

Semantic Memory 可以先理解成:

不必依賴某一次特定事件,也能成立的事實、概念或一般認知。

放進 Memora 的情境,可能是:

使用者的英文程度是 B1。
使用者希望加強旅遊英文。
使用者偏好透過簡短例句學習。
使用者經常混淆 present perfect 和 past simple。

這些資料的重點不是「哪一天說過」,而是它們能不能在未來協助 Memora 理解使用者或調整教學方式。

Semantic Memory 不一定要經過很多次對話才能形成。如果使用者明確說出:

我的英文程度是 B1。

即使只說過一次,它仍然可以被抽取成 Semantic Memory。


二、Episodic Memory 記住「發生過什麼」

Episodic Memory 則比較像一段具體經歷:

某一次互動、行動或事件曾經發生過。

例如:

使用者昨天完成了五題現在完成式練習。
使用者上週練習過機場報到對話。
使用者在上一堂練習中答錯了第三題。
使用者曾經請 Memora 比較 travel 和 journey。

這類 Memory 通常帶有某次互動的背景,可能和時間、任務或結果有關。它的價值不一定是永久改變 Memora 對使用者的認知,而是讓後續對話可以接回過去發生過的事情。例如使用者問:

上次我做錯的是哪一題?

這時真正需要的通常不是 Profile,而是某次練習留下的 Episodic Memory。


三、兩種 Memory 的差別

可以先用這張表整理:

比較項目 Semantic Memory Episodic Memory
核心問題 我們知道什麼? 曾經發生什麼?
內容特性 事實、偏好、一般認知 特定互動、事件、任務結果
是否依賴事件背景 通常較低 通常較高
例子 使用者的程度是 B1 使用者昨天完成 B1 閱讀練習
常見用途 個人化、教學調整 回顧進度、延續過去任務

這個分類不是單靠句子裡有沒有「昨天」就能完成。

例如:

使用者昨天說自己偏好簡短例句。

如果想保留的是「昨天說過這件事」,它比較接近 Episodic Memory;如果想讓未來回答都採用簡短例句,真正有用的內容則是:

使用者偏好簡短例句。

也就是 Semantic Memory。

分類的重點不是句子的表面格式,而是 Application 想保留哪一層意義。


四、Semantic Memory 和 User Profile 仍然不一樣

看到這裡,可能會覺得 Semantic Memory 和昨天的 User Profile 幾乎一樣。

它們確實可能包含相同內容:

Semantic Memory
使用者的英文程度是 B1。

User Profile
english_level = B1

但在目前的架構中,兩者的用途仍然不同:

Semantic Memory User Profile
資料型態 多筆可搜尋紀錄 一份固定欄位的目前狀態
取得方式 Semantic Search 直接讀取欄位
是否一定代表現在 不一定 代表 Application 目前採用的值
例子 使用者曾被記錄為 B1 english_level 目前是 B2

假設使用者從 B1 進步到 B2:

Past Semantic Memory
使用者的英文程度是 B1。

Current User Profile
english_level = B2

B1 這筆 Memory 可以仍然是過去曾經成立的紀錄,但本次教學應採用 Profile 裡的 B2。

因此 Day 19 已經在 build_background_messages() 定義:Profile 和 Past Memory 發生衝突時,優先使用 Profile;如果目前 User Message 又提供更新資訊,則以目前訊息為準。

今天不改變這個優先順序,只替 Long-term Memory 本身增加類型。


五、替 Memory Type 建立明確範圍

先在 Import 區加入:

from typing import Literal

接著定義兩個 Type Alias:

ExtractedMemoryType = Literal[
    "semantic",
    "episodic"
]

StoredMemoryType = Literal[
    "semantic",
    "episodic",
    "unclassified"
]

為什麼要分成兩個?

新抽取的 Memory 必須由 LLM 在 semanticepisodic 之間選擇,因此 ExtractedMemoryType 只有兩種合法值。

但是 Day 17 到 Day 19 已經存進 Chroma 的舊資料沒有 memory_type。如果直接把它們全部假設成 Semantic Memory,可能會把過去的事件標錯。因此從 Database 讀取時,額外保留:

unclassified

它不是第三種 Memory,而是代表:

這筆舊資料尚未分類。

這樣不需要刪除原本的 memora_db,也不必為了新增欄位假裝知道舊 Memory 的類型。


六、擴充既有的 Pydantic Models

Day 13 建立的 MemoryCandidate 原本只負責保存抽取後的文字。現在替它加入 memory_type

class MemoryCandidate(BaseModel):
    content: str
    memory_type: ExtractedMemoryType

接著修改 Day 14 的 EmbeddedMemoryCandidate

class EmbeddedMemoryCandidate(BaseModel):
    content: str
    memory_type: ExtractedMemoryType
    embedding: list[float]

MemoryCandidate 是 LLM Structured Output 的結果;EmbeddedMemoryCandidate 則是在同一筆資料加上 Embedding 後,準備送進 LongTermMemoryStore.add() 的物件。

最後修改 Day 17 的兩個回傳 Model:

class StoredMemory(BaseModel):
    memory_id: str
    content: str
    memory_type: StoredMemoryType
    source: str
    created_at: str


class MemorySearchResult(BaseModel):
    memory_id: str
    content: str
    memory_type: StoredMemoryType
    source: str
    created_at: str
    distance: float
    score: float

目前資料流就會變成:

MemoryCandidate
content + memory_type

EmbeddedMemoryCandidate
content + memory_type + embedding

StoredMemory / MemorySearchResult
從 Chroma 讀回 content + memory_type + metadata

這次沒有建立另一套 Memory Object,只是在原本的資料模型上增加同一個欄位。


七、讓 Structured Output 同時完成分類

只修改 Pydantic Model 還不夠。Memory Extraction Prompt 也要告訴模型兩種類型的判斷方式。

在 Day 13 延續使用的 Memory Extraction Prompt 中,保留原本「只抽取值得跨 Conversation 使用的資訊」等規則,再加入:

For each extracted memory, choose one memory_type:

- semantic:
  A fact, preference, goal, ability, or general observation
  that can be useful without referring to one specific event.

- episodic:
  A specific past interaction, activity, event, or result.

Use only "semantic" or "episodic".
Do not classify a memory only by keywords such as
"yesterday" or "last week". Classify the meaning that the
memory preserves.

Examples:
- "The user's English level is B1."
  → semantic
- "The user prefers short examples."
  → semantic
- "The user completed an airport check-in exercise yesterday."
  → episodic
- "The user answered three present-perfect questions incorrectly
  in the previous practice session."
  → episodic

因為 MemoryCandidate 已經成為 Structured Output Schema 的一部分,模型不只要產生:

{
  "content": "The user's English level is B1."
}

而是必須產生:

{
  "content": "The user's English level is B1.",
  "memory_type": "semantic"
}

原本負責解析 Structured Output 的 extract_memories() 不需要換成另一種呼叫方式。只要它仍然使用包含 MemoryCandidate 的既有 Pydantic Schema,解析結果就會多出 memory_type


八、建立 Embedding 時不要把類型弄丟

Embedding 仍然只根據 content 建立。memory_type 是 Metadata,不需要轉換成向量。

因此 Day 14 原本送給 create_embeddings() 的內容不變:

memory_contents = [
    memory_item.content
    for memory_item in memory_candidates
]

但在組合 EmbeddedMemoryCandidate 時,要把分類結果一起帶過去。

原本如果是:

EmbeddedMemoryCandidate(
    content=memory_item.content,
    embedding=embedding
)

現在改成:

EmbeddedMemoryCandidate(
    content=memory_item.content,
    memory_type=memory_item.memory_type,
    embedding=embedding
)

這個欄位必須一路保留:

LLM Classification
→ MemoryCandidate.memory_type
→ EmbeddedMemoryCandidate.memory_type
→ Chroma Metadata

如果中間少了這一步,Structured Output 雖然分類成功,寫入 Database 時仍然只剩文字和向量。


九、修改 LongTermMemoryStore.add()

接著從 Day 17 的 LongTermMemoryStore.add() 繼續修改。

原本每筆 Metadata 是:

{
    "source": source,
    "created_at": created_at
}

現在加入各自的 memory_type

def add(
    self,
    memories: list[EmbeddedMemoryCandidate],
    source: str
):
    if not memories:
        return []

    memory_ids = [
        str(uuid4())
        for _ in memories
    ]

    created_at = datetime.now(
        timezone.utc
    ).isoformat()

    self.collection.add(
        ids=memory_ids,
        documents=[
            memory.content
            for memory in memories
        ],
        embeddings=[
            memory.embedding
            for memory in memories
        ],
        metadatas=[
            {
                "source": source,
                "created_at": created_at,
                "memory_type": memory.memory_type
            }
            for memory in memories
        ]
    )

    return memory_ids

這裡只修改 metadatas,其餘的 ID、Document、Embedding 與 created_at 都沿用 Day 17。

現在一筆 Chroma Record 可以理解成:

Document
The user completed an airport exercise yesterday.

Embedding
[0.021, -0.037, ...]

Metadata
source = automatic
created_at = 2026-09-09T03:20:00+00:00
memory_type = episodic

memory_type 不會改變 Embedding,也不會自動提高某一類 Memory 的相似度。它的作用是讓 Application 讀回資料後知道這筆 Memory 屬於哪一類,未來也可以作為 Metadata Filter。


十、相容 Day 19 以前的舊資料

新的 Record 都會有 memory_type,但舊的 Chroma Records 沒有。

先新增一個 Helper:

def read_memory_type(
    metadata: dict
) -> StoredMemoryType:
    memory_type = metadata.get("memory_type")

    if memory_type in {
        "semantic",
        "episodic"
    }:
        return memory_type

    return "unclassified"

接著修改 LongTermMemoryStore.list_all() 建立 StoredMemory 的位置:

StoredMemory(
    memory_id=memory_id,
    content=document,
    memory_type=read_memory_type(metadata),
    source=metadata.get(
        "source",
        "unknown"
    ),
    created_at=metadata.get(
        "created_at",
        "unknown"
    )
)

同樣地,在 LongTermMemoryStore.search() 建立 MemorySearchResult 的位置加入:

MemorySearchResult(
    memory_id=memory_id,
    content=document,
    memory_type=read_memory_type(metadata),
    source=metadata.get(
        "source",
        "unknown"
    ),
    created_at=metadata.get(
        "created_at",
        "unknown"
    ),
    distance=float(distance),
    score=1 - float(distance)
)

原本兩個方法裡已經有:

metadata = metadata or {}

這行繼續保留,再把整理後的 Dictionary 交給 read_memory_type()

如此一來:

Day 20 之後新增的資料
→ semantic 或 episodic

Day 19 以前已存在的資料
→ unclassified

舊資料仍然可以列出與搜尋,不需要刪掉整個 Database。


十一、同步修改 remember Command

Day 17 加入的手動指令原本是:

remember <memory>

現在 MemoryCandidate 多了必要欄位,手動建立 Memory 時也必須指定類型。

把格式改成:

remember semantic <memory>
remember episodic <memory>

例如:

remember semantic 使用者的英文程度是 B1。
remember episodic 使用者今天完成了機場報到練習。

在既有 remember 分支中,把 command_value 再切成兩部分:

remember_parts = command_value.strip().split(
    maxsplit=1
)

if (
    len(remember_parts) != 2
    or remember_parts[0].lower()
    not in {"semantic", "episodic"}
):
    print(
        "Usage: remember "
        "<semantic|episodic> <memory>"
    )
    continue

memory_type = remember_parts[0].lower()
memory_content = remember_parts[1].strip()

memory_candidate = MemoryCandidate(
    content=memory_content,
    memory_type=memory_type
)

接下來直接把 memory_candidate 放回原本 Day 14 建立 Embedding、Day 17 呼叫 long_term_memory.add() 的流程,不需要新增另一條手動儲存管線。

這次不替舊格式自動指定 semantic,因為:

remember 使用者昨天完成了練習。

明顯比較像 Episodic Memory。讓使用者明確指定,可以避免 Application 靜默做出錯誤分類。


十二、讓 Debug Output 顯示 Memory Type

現在輸入:

memories

時,可以在原本的輸出多印一行:

print(
    f"   Type: "
    f"{stored_memory.memory_type}"
)

結果可能是:

1. 使用者的英文程度是 B1。
   Type: semantic
   Source: automatic
   Created at: 2026-09-09T03:20:00+00:00

2. 使用者昨天完成了機場報到練習。
   Type: episodic
   Source: automatic
   Created at: 2026-09-09T03:25:00+00:00

3. 使用者偏好簡短例句。
   Type: unclassified
   Source: automatic
   Created at: 2026-09-08T06:10:00+00:00

第三筆不是第三種 Memory,只是 Day 20 以前建立、尚未分類的 Record。

search <query> 的輸出也可以加入:

print(
    f"   Type: "
    f"{search_result.memory_type}"
)

這樣可以直接觀察 Retrieval 找回了哪一種類型。


十三、把類型放進 Day 19 的 Background Context

Day 19 已經新增:

build_memory_context()

它會把 Retrieved Memory 整理後交給 build_background_messages()。現在只要替每一筆內容加上類型標記:

def build_memory_context(
    memories: list[MemorySearchResult]
) -> str:
    return "\n".join(
        (
            f"- [{memory_item.memory_type}] "
            f"{memory_item.content}"
        )
        for memory_item in memories
    )

模型看到的 Long-term Memory Section 就可能是:

<long_term_memories>
- [semantic] 使用者的英文程度是 B1。
- [episodic] 使用者昨天完成了機場報到練習。
</long_term_memories>

Day 19 的 build_background_messages()、Profile 優先順序與 prepare_context(background_messages=...) 都不需要修改。

一般聊天仍然走原本的路徑:

Current User Input
→ retrieve_relevant_memories()
→ LongTermMemoryStore.search()
→ build_background_messages()
→ memory.prepare_context()
→ Responses API

今天只是讓 Search Result 多帶一個 Type,並沒有重寫 Retrieval。


十四、Memory Type 目前還不會改變搜尋結果

雖然 memory_type 已經存進 Chroma Metadata,但目前:

LongTermMemoryStore.search()

仍然會在所有 Memory 中執行 Vector Search。

也就是說,Query:

我上次做過什麼練習?

可能同時取得 Semantic 與 Episodic Candidates,再依照 Day 18 的相似度門檻篩選。

今天先不加入:

where={"memory_type": "episodic"}

因為 Application 必須先判斷目前問題需要哪種類型,才知道應該套用什麼 Filter。這已經不只是資料分類,而是 Retrieval Policy。

目前先完成三件事:

抽取時分類
儲存時保留類型
讀取時帶回類型

之後要依照 Type 搜尋時,就不必重新改造資料結構。


十五、created_at 不是事件發生時間

Episodic Memory 通常和時間有關,而 Day 17 已經保存:

created_at

但這裡要注意:

created_at 表示 Memory Record 寫進 Store 的時間,不一定是事件真正發生的時間。

假設使用者今天說:

我上週完成了機場英文練習。

created_at 是今天;事件時間則是上週。

目前事件的時間資訊仍然保留在 content 中,Day 20 不額外加入 event_at,避免在分類之外又引入日期解析與不確定時間推理。

Day 23 使用 created_at 討論 Recency 時,計算的會是「這筆 Memory 被建立多久」,不能直接把它解讀成事件距今多久。


十六、測試自動分類

現在可以輸入:

You: 我的英文程度是 B1,我比較喜歡簡短的例句。

Memory Extraction 可能產生:

{
  "memories": [
    {
      "content": "The user's English level is B1.",
      "memory_type": "semantic"
    },
    {
      "content": "The user prefers short examples.",
      "memory_type": "semantic"
    }
  ]
}

接著輸入:

You: 我今天完成了五題機場報到英文練習。

可能得到:

{
  "memories": [
    {
      "content": "The user completed five airport check-in exercises today.",
      "memory_type": "episodic"
    }
  ]
}

最後輸入:

You: memories

確認每筆新資料都具有 Type,再使用 search <query> 檢查搜尋結果是否仍然正常。

LLM Classification 並不保證每次都符合我們心中的邊界。同一句話可能同時包含穩定事實和特定事件,理想情況下應被拆成兩筆 Atomic Memory;如果分類結果仍然不穩定,就需要更多例子、測試資料與評估規則。今天先讓資料具有可觀察的 Type,下一步才有辦法討論「哪些類型、哪些內容真的應該留下」。


Day 20 小結

今天直接從 Day 19 的架構繼續,保留原本的:

UserProfileStore
Memory Extraction
Embedding
LongTermMemoryStore
Memory Retrieval
build_background_messages()
Short-term Context Management

我們新增了:

ExtractedMemoryType
StoredMemoryType
MemoryCandidate.memory_type
EmbeddedMemoryCandidate.memory_type
StoredMemory.memory_type
MemorySearchResult.memory_type

並把 memory_type 一路傳進 Chroma Metadata,再從 list_all()search() 讀回來。舊資料沒有這個欄位時,則標記為 unclassified,不必刪除原本的 Store。

今天最重要的觀念是:

Semantic Memory 保存不依賴特定事件的一般認知;Episodic Memory 保存某次發生過的互動或經歷。分類不是為了替資料貼標籤,而是替後續的寫入、搜尋與遺忘策略提供可以使用的結構。

不過,現在只要 Memory Extraction 覺得某句話值得保存,它仍然會被寫進 Long-term Memory。

但「可以分類」不代表「應該保存」。

Day 21|AI 應該什麼都記住嗎?Memory Policy

下一篇我們會正式建立 Memory Policy,讓 Application 開始判斷:

哪些內容允許保存?
哪些只應留在目前 Conversation?
使用者明確要求記住時怎麼處理?
哪些敏感或短暫資訊不應進入 Long-term Memory?

Memora 已經知道 Memory 屬於哪一類。接下來,要決定它到底有沒有資格被留下。


參考資料


上一篇
Day 19|Memory ≠ User Profile:記住事情和認識一個人的差別
下一篇
Day 21|AI 應該什麼都記住嗎?Memory Policy
系列文
從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言