iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
AI Engineering

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

Day 23|AI 也需要遺忘:Memory Decay、Recency 與 Forgetting

  • 分享至 

  • xImage
  •  

前一天我們替通過 Memory Policy 的資料加入:

importance_score

並把 Retrieval 從單純的 Semantic Similarity,改成:

先通過 Relevance Threshold
再用 Relevance + Importance 重新排序

現在 Memora 已經能讓重要的 Memory 在相關 Candidates 中獲得較高順位。但目前還有一個問題:時間完全沒有參與判斷。假設 Store 中有兩筆內容:

使用者三年前曾準備 TOEIC。
使用者上週開始準備 TOEIC。

如果兩筆文字的 Embedding 都和目前 Query 很接近,而且 Importance 也相同,現在的 Ranking 不知道哪一筆比較值得優先取回。今天要在昨天的 Retrieval Score 中加入第三個訊號:

Recency

同時也要釐清一件事:

Memory Decay 不等於立即刪除。它可以先讓很久沒有使用的 Memory 逐漸降低順位,真正刪除則需要另一個明確動作。


一、Recency 在衡量什麼?

Recency 可以先理解成:

這筆 Memory 最近有沒有被實際使用。

Day 17 已經替每筆資料保存:

created_at

但它只能回答:

這筆 Record 什麼時候被建立?

今天要新增:

last_accessed_at

它回答的是:

這筆 Memory 最近一次被選進模型 Context 是什麼時候?

兩者的用途不同:

欄位 意義 是否會更新
created_at Memory 寫進 Store 的時間 不會
last_accessed_at 最近一次被模型使用的時間

這裡沿用 Day 20 的界線:created_at 不是事件真正發生的時間。今天的 last_accessed_at 也不是使用者最後一次提到這件事的時間,而是 Application 最後一次把它放入模型 Context 的時間。


二、Decay、Soft Forgetting 與 Hard Forgetting

「遺忘」在 Memory System 裡不只一種意思。

Memory Decay

Memory 還存在 Store 中,但 Recency Score 隨時間下降:

剛使用過
→ Recency 高

很久沒使用
→ Recency 低

Soft Forgetting

Memory 沒有被刪除,只是因為 Ranking 較低,長時間沒有進入 Context。從模型的角度看,它像是暫時想不起來;從 Database 的角度看,資料仍然存在。

Hard Forgetting

Record 真正從 Long-term Memory Store 中刪除:

Chroma Record
→ delete
→ 不再存在

今天會完成:

自動 Memory Decay
自動 Soft Forgetting
明確指令觸發的 Hard Forgetting

但不會因為一筆 Memory 很舊,就讓 Application 自動永久刪除。等到 Day 29,才會進一步討論 Agent 是否能自己決定何時忘記。


三、在建立 Memory 時加入 last_accessed_at

先從 Day 22 的 LongTermMemoryStore.add() 繼續修改。

原本已經有:

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

新 Memory 剛建立時,還沒有被 Retrieval 找回過。為了讓它具有有效的初始 Recency,可以先令:

last_accessed_at = created_at

接著修改 Chroma Metadata:

metadatas=[
    {
        "source": source,
        "created_at": created_at,
        "last_accessed_at": last_accessed_at,
        "memory_type": memory.memory_type,
        "importance_score": (
            memory.importance_score
        )
    }
    for memory in memories
]

其他欄位全部保留。現在一筆 Record 可能是:

Document
The user wants to pass B2 in three months.

Metadata
source = automatic
created_at = 2026-09-14T03:20:00+00:00
last_accessed_at = 2026-09-14T03:20:00+00:00
memory_type = semantic
importance_score = 5

新建立的 Memory 不會因為尚未被搜尋過,就立刻得到最低 Recency。


四、擴充既有的 Read Models

Day 22 的 StoredMemoryMemorySearchResult 已經包含 importance_score。今天只增加同一個欄位:

class StoredMemory(BaseModel):
    memory_id: str
    content: str
    memory_type: StoredMemoryType
    importance_score: int = Field(
        ge=1,
        le=5
    )
    source: str
    created_at: str
    last_accessed_at: str


class MemorySearchResult(BaseModel):
    memory_id: str
    content: str
    memory_type: StoredMemoryType
    importance_score: int = Field(
        ge=1,
        le=5
    )
    source: str
    created_at: str
    last_accessed_at: str
    distance: float
    score: float

這裡不把 recency_score 存進 Model 或 Chroma。

因為 Recency 會隨目前時間改變。今天算出的分數到了明天就已經不同,所以應該在 Retrieval 時即時計算,而不是把一個很快過期的結果寫進 Database。


五、相容以前沒有 last_accessed_at 的資料

Day 22 以前的舊 Record 沒有新欄位,因此新增:

def parse_utc_timestamp(
    value: str
) -> datetime | None:
    try:
        timestamp = datetime.fromisoformat(value)
    except (TypeError, ValueError):
        return None

    if timestamp.tzinfo is None:
        timestamp = timestamp.replace(
            tzinfo=timezone.utc
        )

    return timestamp.astimezone(
        timezone.utc
    )

接著建立:

def read_last_accessed_at(
    metadata: dict
) -> str:
    last_accessed_at = metadata.get(
        "last_accessed_at"
    )

    if (
        isinstance(last_accessed_at, str)
        and parse_utc_timestamp(
            last_accessed_at
        ) is not None
    ):
        return last_accessed_at

    created_at = metadata.get(
        "created_at"
    )

    if (
        isinstance(created_at, str)
        and parse_utc_timestamp(
            created_at
        ) is not None
    ):
        return created_at

    return "unknown"

讀取規則是:

有 last_accessed_at
→ 使用 last_accessed_at

沒有 last_accessed_at,但有 created_at
→ 暫時使用 created_at

兩者都無法解析
→ unknown

這只是舊資料的 Compatibility Fallback,不能證明舊 Memory 最後一次真的在 created_at 被使用。最後在 list_all() 建立 StoredMemory,以及 search() 建立 MemorySearchResult 時加入:

last_accessed_at=read_last_accessed_at(
    metadata
)

原本的 read_memory_type()read_importance_score() 繼續保留。


六、用 Half-life 表達 Memory Decay

Generative Agents 的研究使用指數衰退計算 Recency。Memora 也採用 Exponential Decay,但改用比較容易理解的 Half-life 表示。

先設定:

RECENCY_HALF_LIFE_DAYS = 7
DEFAULT_RECENCY_SCORE = 0.5

計算公式是:

Recency Score
= 0.5 ^ (經過天數 / Half-life)

當 Half-life 是七天:

距離上次使用 Recency Score
0 天 1.000
7 天 0.500
14 天 0.250
21 天 0.125
28 天 0.063

它不會在第七天突然從 1 掉到 0.5,而是每天平滑下降。


七、實作 calculate_recency_score()

新增:

def calculate_recency_score(
    last_accessed_at: str,
    now: datetime | None = None
) -> float:
    accessed_at = parse_utc_timestamp(
        last_accessed_at
    )

    if accessed_at is None:
        return DEFAULT_RECENCY_SCORE

    if now is None:
        now = datetime.now(
            timezone.utc
        )
    elif now.tzinfo is None:
        now = now.replace(
            tzinfo=timezone.utc
        )
    else:
        now = now.astimezone(
            timezone.utc
        )

    age_seconds = max(
        0.0,
        (now - accessed_at).total_seconds()
    )

    age_days = age_seconds / 86400

    return 0.5 ** (
        age_days
        / RECENCY_HALF_LIFE_DAYS
    )

如果 Timestamp 無法解析,先回傳中立值:

0.5

不直接回傳 0,是為了避免舊資料只因為缺少新欄位,就被當成完全沒有價值。如果資料時間意外晚於目前時間,max(0.0, ...) 會把經過時間限制為零,避免產生大於 1 的 Recency Score。


八、把 Day 22 的 Ranking 擴充成三個訊號

Day 22 的權重是:

RELEVANCE_WEIGHT = 0.8
IMPORTANCE_WEIGHT = 0.2

今天加入 Recency,改成:

RELEVANCE_WEIGHT = 0.7
IMPORTANCE_WEIGHT = 0.2
RECENCY_WEIGHT = 0.1

三個權重加總仍然是 1.0

接著修改原本的 calculate_retrieval_score()

def calculate_retrieval_score(
    result: MemorySearchResult,
    now: datetime | None = None
) -> float:
    relevance_score = max(
        0.0,
        min(1.0, result.score)
    )

    importance_score = normalize_importance(
        result.importance_score
    )

    recency_score = calculate_recency_score(
        result.last_accessed_at,
        now=now
    )

    return (
        RELEVANCE_WEIGHT
        * relevance_score
        + IMPORTANCE_WEIGHT
        * importance_score
        + RECENCY_WEIGHT
        * recency_score
    )

昨蔫的 normalize_importance() 不需要修改。

目前分數可以理解成:

Relevance
這筆 Memory 和現在的問題有多相關?

Importance
它對未來英文學習有多重要?

Recency
它最近有沒有被使用?

九、修改 retrieve_relevant_memories()

Day 22 已經先做 Relevance Threshold,再進行 Re-ranking。這個順序今天繼續保留。

只修改排序位置:

def retrieve_relevant_memories(
    query: str
) -> tuple[list[MemorySearchResult], int]:
    (
        candidates,
        embedding_tokens
    ) = semantic_search(
        query=query,
        top_k=RETRIEVAL_CANDIDATE_K
    )

    relevant_candidates = [
        result
        for result in candidates
        if result.score
        >= RETRIEVAL_MIN_SCORE
    ]

    ranking_time = datetime.now(
        timezone.utc
    )

    ranked_memories = sorted(
        relevant_candidates,
        key=lambda result: (
            calculate_retrieval_score(
                result,
                now=ranking_time
            )
        ),
        reverse=True
    )

    return (
        ranked_memories[
            :RETRIEVAL_LIMIT
        ],
        embedding_tokens
    )

這裡先建立一次:

ranking_time

再用同一個時間計算所有 Candidates,避免排序同一批資料時,每筆 Memory 使用略微不同的現在時間。

而且仍然先執行:

result.score >= RETRIEVAL_MIN_SCORE

所以高 Importance 或高 Recency 不能把完全不相關的 Memory 強行送進 Context。


十、看看三個分數如何互相影響

假設兩筆 Memory 都通過 Relevance Threshold:

Memory Relevance Importance 距離上次使用
A:三個月後要參加 B2 檢定 0.78 5 30 天
B:上週做過機場單字練習 0.72 2 1 天

當 Half-life 是七天時:

Memory A Recency
≈ 0.5 ^ (30 / 7)
≈ 0.051

因此 A 的 Retrieval Score 約為:

0.7 × 0.78
+ 0.2 × 1.00
+ 0.1 × 0.051
= 0.751

Memory B 的 Importance 2 會被正規化成 0.25,Recency 約為 0.906

0.7 × 0.72
+ 0.2 × 0.25
+ 0.1 × 0.906
= 0.645

所以 A 雖然很久沒被使用,仍然可能因為 Relevance 與 Importance 較高而排在前面。

Decay 不是「時間久了就一律沒用」,而是讓時間成為 Ranking 的其中一個訊號。


十一、什麼時候才算 Access?

如果 Chroma 回傳八筆 Candidates,但最後只有三筆通過篩選並被放入 Context,不能把八筆全部更新成「剛使用過」。

今天把 Access 定義成:

Memory 被選入成功送給模型的 Context。

因此下面這些操作不更新 last_accessed_at

被 Chroma 列為 Candidate,但最後沒有入選
輸入 memories 查看所有資料
輸入 search 執行開發階段的搜尋測試

只有一般聊天中真正進入:

background_messages

並成功完成 Responses API Request 的 Retrieved Memory,才算被使用。


十二、在 LongTermMemoryStore 加入 touch()

接下來要更新 Chroma Metadata。

新增:

def touch(
    self,
    memory_ids: list[str]
):
    unique_ids = list(
        dict.fromkeys(memory_ids)
    )

    if not unique_ids:
        return

    records = self.collection.get(
        ids=unique_ids,
        include=["metadatas"]
    )

    record_ids = records.get(
        "ids",
        []
    )

    record_metadatas = (
        records.get("metadatas")
        or [{} for _ in record_ids]
    )

    metadata_by_id = {
        memory_id: dict(metadata or {})
        for memory_id, metadata in zip(
            record_ids,
            record_metadatas
        )
    }

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

    updated_ids = []
    updated_metadatas = []

    for memory_id in unique_ids:
        if memory_id not in metadata_by_id:
            continue

        metadata = metadata_by_id[
            memory_id
        ]

        metadata["last_accessed_at"] = (
            accessed_at
        )

        updated_ids.append(memory_id)
        updated_metadatas.append(metadata)

    if not updated_ids:
        return

    self.collection.update(
        ids=updated_ids,
        metadatas=updated_metadatas
    )

這裡先讀回原本 Metadata,再只修改:

last_accessed_at

最後把完整 Metadata 寫回去,避免更新時間時不小心丟掉:

source
created_at
memory_type
importance_score

touch() 不修改 Document 或 Embedding,因此不需要重新呼叫 Embeddings API。


十三、在成功回答後更新 Access Time

不要在 semantic_search() 剛找到 Candidates 時立即呼叫 touch()。因為模型 Request 仍然可能失敗。如果 Request 沒有成功,這些 Memory 就沒有真正完成本次使用。Day22的主迴圈取得 assistant_reply 後,加入:

retrieved_memory_ids = [
    memory_item.memory_id
    for memory_item in retrieved_memories
]

try:
    long_term_memory.touch(
        retrieved_memory_ids
    )
except Exception as error:
    print(
        "Could not update memory access time:",
        error
    )

接著再保留原本的:

memory.finish_turn(
    assistant_reply=assistant_reply,
    context_messages=(
        memory_stats["context_messages"]
    ),
    response_tokens=(
        response.usage.total_tokens
    )
)

touch() 失敗時只顯示 Warning,不丟棄已經成功取得的 Assistant Response。

這次錯誤處理和主要模型 Request 分開,是因為兩者的影響不同:

Responses API 失敗
→ 本輪沒有回答

touch() 失敗
→ 回答仍然有效,只是 Recency 沒有更新

十四、讓 Debug Output 顯示 Recency

memories Command 可以再增加:

recency_score = calculate_recency_score(
    stored_memory.last_accessed_at
)

print(
    f"   Last accessed: "
    f"{stored_memory.last_accessed_at}"
)

print(
    f"   Recency: "
    f"{recency_score:.3f}"
)

結果可能是:

1. 使用者希望三個月後通過 B2 檢定。
   ID: 4d92...
   Type: semantic
   Importance: 5/5
   Last accessed: 2026-09-14T03:20:00+00:00
   Recency: 0.998

2. 使用者曾經完成機場英文練習。
   ID: 6a71...
   Type: episodic
   Importance: 2/5
   Last accessed: 2026-08-17T03:20:00+00:00
   Recency: 0.063

這裡也把完整 memory_id 顯示出來,因為接下來 Hard Forgetting 會使用它。

只執行 memories 查看資料時,不呼叫 touch()。Debug 不應該改變正式 Retrieval 的 Access History。


十五、Decay 不代表要立刻刪除

假設一筆重要的 Memory 很久沒有被取回:

使用者的長期目標是準備 IELTS。

即使它的 Recency 已經很低,也不代表 Application 應該只根據時間把它刪除。

反過來,一筆 Importance 很低的資料,也可能在某個特殊 Query 出現時突然變得高度相關。

所以目前不建立這種規則:

if recency_score < 0.1:
    delete_memory()

Recency Score 的功能是調整 Retrieval 順序,不是宣判資料已經沒有價值。

如果未來要自動清理,至少還要一起考慮:

Importance
多久沒有被 Access
Memory Type
是否仍存在於 User Profile
是否有新的 Memory 取代它
使用者是否要求保留

其中「是否有新資料取代」會和 Day 24 的 Update 與 Contradiction 直接相關。


十六、加入明確的 Hard Forgetting

雖然今天不讓時間自動刪除 Memory,仍然可以先提供一個明確的刪除介面。

LongTermMemoryStore 新增:

def delete(
    self,
    memory_id: str
) -> bool:
    records = self.collection.get(
        ids=[memory_id],
        include=["metadatas"]
    )

    if not records.get("ids"):
        return False

    self.collection.delete(
        ids=[memory_id]
    )

    return True

這裡先檢查 ID 是否存在,再執行 Chroma 的 delete()

接著在原本 Command Branch 中加入:

if command_name == "forget":
    memory_id = command_value.strip()

    if not memory_id:
        print(
            "Usage: forget <memory_id>"
        )
        continue

    deleted = long_term_memory.delete(
        memory_id
    )

    if deleted:
        print("Memory deleted.")
    else:
        print("Memory not found.")

    continue

使用方式是先輸入:

memories

找到完整 ID,再輸入:

forget 4d92f23c-...

這個指令使用完整 ID,而不是直接用一段自然語言猜測要刪除哪一筆,避免兩筆語意相近的 Memory 被刪錯。


十七、刪除 Long-term Memory 不等於修改 User Profile

Day 19 已經把 User Profile 和可搜尋的 Long-term Memory 分開。

因此:

forget <memory_id>

只刪除 Chroma 中對應的 Memory Record,不會修改:

user_profile.json

假設下面的資訊同時存在兩邊:

Long-term Memory
使用者的英文程度是 B1。

User Profile
english_level = B1

刪除 Long-term Memory 後,Profile 仍然會把 B1 放進 Background Context。

這不是刪除失敗,而是兩個 Store 本來就具有不同責任。若要更改目前採用的英文程度,仍然應使用 Day 19 的 Profile Command。


十八、Recency 可能形成 Feedback Loop

更新 last_accessed_at 之後,還有一個值得注意的現象。

某筆 Memory 一旦被取回:

被放進 Context
→ last_accessed_at 更新
→ Recency 變高
→ 下一次更容易再次被取回

這可能形成 Feedback Loop,讓少數經常出現的 Memory 長期佔據 Ranking 前面。

目前有三個設計可以降低這個問題:

先通過 Relevance Threshold
Recency Weight 只設定為 0.1
只 Touch 最後真正入選的 Memory

但它仍然不可能完全避免偏差。之後可以透過不同類型配額、Retrieval Diversity 或更完整的評估資料繼續調整。

因此 Weight 不是寫完就永遠不變的常數,而是需要依實際對話測試的 Hyperparameter。


十九、測試 Recency 與 Forgetting

首先建立兩筆 Memory:

remember semantic 使用者希望準備 B2 檢定。
remember episodic 使用者今天完成機場英文練習。

輸入:

memories

確認新 Record 具有:

created_at
last_accessed_at
importance_score

接著問一個相關問題:

幫我安排 B2 檢定的讀書計畫。

再次輸入 memories,確認真正進入本輪 Context 的 Memory,其 last_accessed_at 已經更新。

也要檢查以下情況:

Chroma 找到但未進入前 3 名的 Candidate
→ 不更新

memories 或 search Debug
→ 不更新

Responses API Request 失敗
→ 不更新

最後複製其中一筆完整 ID:

forget <memory_id>

再輸入:

memories

確認該 Record 已經不存在。


二十、目前的 Forgetting 還不是 Agentic Forgetting

現在的 Memora 已經會:

讓久未使用的 Memory 逐漸降低 Recency
在 Ranking 中產生 Soft Forgetting
依照明確指令刪除指定 Memory

但它還不會自己思考:

這筆資料是不是已經過時?
是否有新資料可以取代它?
兩筆互相矛盾時應該刪除哪一筆?
這次應該更新舊 Record,還是建立新 Record?

這些問題不能單靠時間回答。

因此今天只讓時間影響 Retrieval,並保留可控的刪除介面;不把「舊」直接等同於「錯」或「沒有價值」。


Day 23 小結

今天直接從 Day 22 的 Importance Ranking 繼續,保留原本的:

Memory Policy
Importance Scoring
Embedding
LongTermMemoryStore
Relevance Threshold
Retrieval Re-ranking
User Profile
Short-term Context Management

新增的是:

last_accessed_at
parse_utc_timestamp()
read_last_accessed_at()
calculate_recency_score()
RECENCY_WEIGHT
LongTermMemoryStore.touch()
LongTermMemoryStore.delete()
forget <memory_id>

Retrieval Ranking 現在由三個訊號組成:

Relevance
+ Importance
+ Recency

但順序仍然是:

Semantic Search
→ Relevance Threshold
→ 綜合 Ranking
→ 選出要進入 Context 的 Memory
→ 成功回答後更新 last_accessed_at

今天最重要的觀念是:

Memory Decay 不必立刻刪除資料。先讓久未使用的 Memory 降低 Retrieval 順位,就能形成可逆的 Soft Forgetting;真正的 Hard Forgetting 則應該透過明確而可控的刪除動作完成。

不過,目前 Store 中仍然可能同時存在:

重複的 Memory
已經過時的 Memory
互相矛盾的 Memory

只降低舊資料的 Recency,並不能判斷哪一筆才是目前正確的狀態。

Day 24|AI 記錯了怎麼辦?Deduplication、Update 與 Contradiction

下一篇我們會從今天的 LongTermMemoryStore 繼續修改,在新增 Memory 前先搜尋相近資料,判斷應該:

建立新的 Record
略過重複內容
更新既有 Memory
保留具有時間意義的不同事件
處理互相矛盾的狀態

今天讓 Memory 的使用機會隨時間改變;Day 24 則要開始維護 Memory 內容本身的一致性。


參考資料


上一篇
Day 22|Importance Score:哪些記憶比較重要?
系列文
從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言