1. 一个被反复混淆的概念

很多项目在介绍自己的 Agent 时会说:「它有记忆,因为它把历史对话都带上了。」

这不叫记忆,这叫上下文窗口。

两者的差别可以用人来类比:

  • 上下文窗口相当于你此刻的工作台面。台面有限,放多了就乱,人一走开全部清空。
  • 记忆相当于你的大脑和笔记。你不在工位的时候,事情依然记着;下次坐下来,相关的内容会被想起来。

一个每轮都把所有历史塞进 prompt 的系统,会同时遇到三个问题:

  1. 成本线性增长:第 100 轮对话的输入 token 是第 1 轮的几十上百倍
  2. 注意力稀释:上下文越长,模型对中段信息的关注越弱
  3. 跨会话失忆:新开一个会话,之前所有积累归零

要解决这三个问题,就必须把「记忆」从「上下文」里独立出来,做成一套真正的子系统。

2. 记忆的四种类型

认知科学对记忆的分类,对 Agent 设计有很强的借鉴意义。实践中通常映射成四类:

类型认知科学含义Agent 中的对应物典型生命周期
工作记忆当前正在处理的信息当前对话上下文、任务清单单次任务
情景记忆经历过的事件历史交互记录、任务执行轨迹天 ~ 月
语义记忆抽象的事实与知识用户偏好、项目事实、领域知识月 ~ 永久
程序记忆学会的技能工具使用模式、成功的解决方案模板长期

大部分 Agent 架子只实现了第一类(工作记忆),然后指望上下文窗口能兜住剩下三类——这就是它们在长任务上崩掉的根因。

一个成熟的记忆系统,至少要把后三类显式建模。

3. 架构:两条路径

记忆系统的核心就是两条路径:写入和检索。

┌───────────────────────────┐
│        Agent Loop         │
└───────┬───────────▲───────┘
        │           │
 写入候选          注入上下文
        │           │
┌───────▼───────────┴───────┐
│      Memory Manager       │
│  提取 / 去重 / 冲突消解    │
└───────┬───────────▲───────┘
        │           │
┌───────▼───────────┴───────┐
│       Memory Store        │
│ 向量库 + KV + 图 + 关系库  │
└───────────────────────────┘

3.1 写入路径

每轮交互结束后,从对话中提取值得长期保留的信息:

  • 事实:「用户的主力语言是 TypeScript」
  • 偏好:「用户不喜欢过度注释的代码」
  • 决定:「项目决定用 Postgres 而不是 MySQL」
  • 约束:「生产环境不能重启数据库」

关键点是:不要什么都存。把所有对话原文塞进向量库,效果往往比不存还差——检索出来的全是噪音。

一个实用的提取策略是让模型按固定 schema 输出候选记忆:

{
  "memories": [
    {
      "type": "preference",
      "content": "用户偏好函数式风格,避免可变状态",
      "confidence": 0.85,
      "entities": ["用户", "代码风格"]
    }
  ]
}

然后用规则过滤掉低置信度、重复、以及明显是「当前任务临时信息」的条目。

3.2 检索路径

检索的核心问题是:当前这一步,需要想起什么?

最朴素的实现是「用当前 query 做向量检索,取 Top-K」。但纯向量检索在记忆场景下有几个明显缺陷:

  • 时间盲区:三个月前的一条相似记忆和昨天的一条,向量相似度可能几乎一样
  • 精确匹配弱:用户说「Postgres」,存的是「PostgreSQL」,纯向量可能匹配不好
  • 关系断裂:查「项目 X 的技术栈」,无法自然地带出「项目 X 用的数据库」这条关联记忆

因此实践中常见的检索是混合检索 + 多重排序:

候选 = 向量检索(Top 20)
     ∪ 关键词/BM25 检索(Top 20)
     ∪ 实体关联检索(Top 10)
     ∪ 最近记忆(Top 5)

最终 = 重排(候选, 权重)
       权重 = w1·语义相似度
            + w2·时间衰减
            + w3·重要性
            + w4·使用频次

时间衰减通常用指数衰减:

import math

def time_decay(age_days: float, half_life_days: float = 30.0) -> float:
    """越久远的记忆权重越低,半衰期默认 30 天"""
    return math.exp(-math.log(2) * age_days / half_life_days)

注意「重要性」和「使用频次」这两个因子。一条被反复检索命中的记忆,说明它确实有用,应该获得更高的持久权重——这是让记忆系统自我优化的一个简单手段。

4. 存储选型

不存在「一个数据库搞定所有记忆」的方案。实际系统通常是组合:

存储承担的记忆类型理由
KV / 关系库用户画像、配置、结构化事实需要精确读写和事务
向量库语义记忆、情景记忆需要模糊语义检索
图数据库实体关系、知识图谱需要多跳关联
对象存储原始对话、长文档冷数据,成本敏感

大多数团队的现实选择是:关系库 + 向量库起步,图数据库等真正需要多跳推理时再加。

小规模场景(几千到几万条记忆)甚至可以直接用 pgvector 或 sqlite-vec,没必要一上来就引入独立向量数据库——多一个组件就多一份运维成本和故障面。

5. 遗忘:被忽视但关键的一环

只增不减的记忆系统,最终一定会退化。原因很简单:噪音会淹没信号。

遗忘机制有几种常见实现:

5.1 TTL(生存时间)

临时信息设置明确过期时间。比如「用户现在在改 A 文件」这条,两小时后就没有意义了。

5.2 重要性衰减

重要性不是静态的。一条长期未被检索、未被引用的记忆,重要性应该缓慢下降,降到阈值以下就被归档或删除。

5.3 合并与压缩

多条高度相似的记忆应该合并成一条更抽象的。比如:

"用户喜欢用 pnpm"
"用户不喜欢 npm 的安装速度"
"用户的项目都用 pnpm workspace"

    合并为 ↓

"用户偏好 pnpm,常用 workspace 管理多包项目"

这本质上是对记忆做「摘要」,能显著降低检索噪音。

5.4 冲突消解

当新记忆与旧记忆矛盾时,不能简单追加,否则检索时两条矛盾信息会一起返回,模型就懵了。

处理策略:

  • 同一实体的同一属性 → 用新值覆盖旧值,保留旧值的变更历史
  • 偏好类 → 以最新为准,但保留时间戳
  • 事实类 → 需要判断是「更正」还是「补充」
def resolve_conflict(old: Memory, new: Memory) -> Memory:
    # 同实体同属性:新值覆盖,旧值进历史
    if old.entity == new.entity and old.attribute == new.attribute:
        new.history = old.history + [old.snapshot()]
        return new

    # 不同属性或不同实体:并存
    return new

6. 工程上的五个坑

坑一:把「用户说过的话」当成「事实」

用户说「我最近在学 Rust」,不代表「用户是 Rust 工程师」。提取时要区分陈述和事实,置信度要分开。

坑二:记忆污染

如果模型自己生成的内容也被写回记忆,错误会被反复强化。原则上:只把用户明确提供或有外部证据的信息写入长期记忆。

坑三:上下文膨胀

检索回来 20 条记忆,全塞进 prompt,等于把上下文窗口又浪费了。记忆的注入也需要预算控制——通常 2~5 条高相关的就够,宁可少而精。

坑四:隐私与合规

长期记忆本质上是在持续收集用户数据。需要考虑:用户能否查看、能否删除、数据保留多久、是否加密、跨用户是否隔离。这不是「以后再说」的事,而是架构期就要决定的。

坑五:没有评估

记忆系统最大的问题不是「存不下」,而是「记错了没人发现」。必须建立评估:

  • 检索质量:给定 query,期望命中的记忆是否在 Top-K 内(Recall@K)
  • 端到端:有记忆 vs 无记忆,任务成功率差多少
  • 负面指标:注入的记忆里,有多少实际被模型使用、有多少是噪音

7. 一个最小可用实现

如果不想一上来就搞复杂,可以从这个最小结构开始:

class SimpleMemory:
    def __init__(self, store):
        self.store = store

    def write(self, content: str, mem_type: str,
              importance: float, entities: list[str]):
        if importance < 0.4:          # 低价值直接丢弃
            return
        if self._is_duplicate(content):  # 去重
            return
        self.store.insert({
            "content": content,
            "type": mem_type,
            "importance": importance,
            "entities": entities,
            "created_at": now(),
            "last_used_at": now(),
            "use_count": 0,
        })

    def retrieve(self, query: str, limit: int = 5) -> list[str]:
        cands = self.store.search(query, top_k=20)
        scored = []
        for m in cands:
            score = (
                0.5 * m.similarity
                + 0.2 * time_decay(days_since(m.created_at))
                + 0.2 * m.importance
                + 0.1 * min(m.use_count / 10, 1.0)
            )
            scored.append((score, m))
        scored.sort(reverse=True, key=lambda x: x[0])
        result = scored[:limit]
        self._touch(result)   # 更新 last_used_at / use_count
        return [m.content for _, m in result]

这个实现不到 40 行,但覆盖了写入过滤、去重、混合打分、使用反馈四个关键点。先跑起来,再谈优化。

8. 小结

记忆系统的本质,是把「无限的历史」压缩成「当前需要的少量信息」。它和上下文工程解决的是同一个问题的两面:

  • 上下文工程关心:这一轮该给模型看什么
  • 记忆系统关心:什么东西值得被留到下一轮、下一周、下一次会话

按优先级排序,一个 Agent 的记忆能力大致应该这样演进:

  1. 先做工作记忆:任务状态、待办、中间结果的结构化管理
  2. 再做语义记忆:用户偏好和项目事实,这是收益最直接的一层
  3. 然后做情景记忆:历史交互检索,注意做好衰减和去重
  4. 最后才考虑图记忆:多跳关联、复杂推理,成本也最高

别倒着做。