AsyncK
全部笔记

Agent 的记忆不是数据库:状态、上下文与检索的边界

把三类完全不同的问题塞进同一个向量库,是 Agent 记忆设计里最贵的错误。这篇拆开状态、上下文预算与检索治理,并给出可落地的分层。

“给 Agent 加个记忆”这句话,通常同时指三件毫不相干的事:

  • 记住用户刚才说了什么;
  • 记住用户三个月前的偏好;
  • 从一堆文档里找出和当前问题相关的那一段。

它们的失效方式不同、存储介质不同、出错后的补救方式也不同。把它们塞进同一个向量库,短期能跑,长期一定会塌——而且塌的时候没人说得清是哪一层。

我的建议是先做一次粗暴的切割:状态归状态,上下文归上下文,检索归检索。

状态:应该被确定性地存储

状态是“系统现在处于哪一步”。它包含会话进度、已确认的事实、待办动作、权限上下文。这类东西有一个共同特征:它们必须是精确的。

订单号不能“语义相近”,退款金额不能“大概对”,用户已经确认过的地址不能被摘要改写。所以状态不应该进入模型,至少不应该只存在于模型的上下文里。

正确的位置是结构化存储:一张表、一个键值对、一段带 schema 的 JSON。模型负责读写它,但不负责持有它。

这里有一个常见的误区:把状态写进系统提示词,指望模型“记住”。这在三步任务里能工作,在三十步任务里必然漂移——因为上下文会被截断、会被摘要、会被新的指令挤出注意力。更糟的是,漂移是静默的:模型不会告诉你它忘了,它会用编造的内容把流程走完。

一个可用的约定是:每一次工具调用都携带当前状态的关键字段,由运行时注入,而不是由模型复述。模型可以选择下一步做什么,但不能自己声明“用户已经付过款了”——这个事实只能来自存储。

上下文:预算,而不是仓库

上下文窗口是一个预算问题。你有 N 个 token,要分给系统指令、工具 schema、最近对话、历史摘要、检索结果、以及留给模型思考的空间。任何一项超支,都会从别的地方扣。

大多数团队的做法是“能塞就塞”,直到撞上窗口上限,然后开始截断——通常从最早的消息开始截断,而那恰好是最重要的背景。

更稳的做法是显式分配预算,并且让分配策略可观测:

分区 典型占比 超支时的处理
系统指令与工具 schema 10–15% 精简 schema,拆分子 Agent
最近若干轮原文 20–30% 保留最近,其余转摘要
结构化状态 5–10% 只放关键字段,细节走工具查询
检索片段 20–30% 降条数、提升阈值
输出预留 15–25% 不可压缩

这张表不是标准答案,但它强迫团队回答一个平时不会问的问题:当预算不够时,先砍哪一块? 没有答案的系统,会在最忙的时候随机砍。

摘要值得单独说。摘要是压缩,不是真理。它一定丢信息,问题在于丢的是不是关键信息。所以摘要必须保留可回溯的指针:摘要里说“用户已确认退款到原卡”,就应该能点回去看到那轮对话。否则三个月后出现争议,你手里只有一句被模型改写过的二手事实。

还有一条:不要把摘要当成状态。摘要是历史叙述,状态是当前事实。两者混用,会出现“摘要说已退款,状态显示待处理”这种无法自动消解的冲突,最后只能靠人去数据库里翻。

检索:写入时的治理比查询时的技巧更重要

检索质量差,八成不是 embedding 模型的问题,是写入时没有治理。

常见的三个坑:

去重缺失。 同一份文档被上传三次,检索时三条一样的结果占满预算。更隐蔽的是版本重复:v1 和 v2 内容 90% 相同,模型看到的是互相矛盾的两段。

时效缺失。 一条 2023 年的政策和一个 2026 年的修订版,在向量空间里可能非常接近。没有时间权重,模型只能随机选一个,然后自信地告诉你答案。

来源缺失。 检索结果没有出处,模型就无法判断该不该采信,用户也无法核验。在需要严谨的场景里,没有出处的片段等于噪声。

对应的治理动作很朴素:入库时做规范化与去重、给每条记录打上时间戳和来源、为同一主题维护“当前有效版本”标记。这些工作没有技术含量,但它们决定了检索的上限。查询侧的 rerank、混合检索、多路召回,都是在这个上限之内做优化——地基不平,装修再贵也会裂。

一个够用的分层

把上面三件事落成结构,大概是这样:

┌─────────────────────────────────────────┐
│ 运行时状态(数据库 / KV)                  │  精确、可审计、可回滚
│  · 会话进度  · 已确认事实  · 权限          │
└─────────────────────────────────────────┘
                    ↓ 按需注入关键字段
┌─────────────────────────────────────────┐
│ 上下文装配器(每次请求重新计算)             │  预算、优先级、截断策略
│  · 系统指令  · 最近对话  · 状态摘要         │
└─────────────────────────────────────────┘
                    ↑ 召回带出处的片段
┌─────────────────────────────────────────┐
│ 知识层(向量库 + 元数据)                   │  去重、时效、来源
│  · 文档片段  · 版本标记  · 访问控制         │
└─────────────────────────────────────────┘

三层之间只有单向依赖:上下文装配器读状态和知识层,状态和知识层互不认识。这个约束看起来多余,实际能省掉大量“记忆串味”的 bug——比如把某个用户的偏好写进了共享知识库,或者把检索到的文档内容当成了用户确认过的事实。

遗忘是功能,不是缺陷

最后说一件很少被写进设计文档的事:遗忘。

真实系统里,信息会过期、会被撤回、会被证明是错的。一个只会写入不会删除的记忆系统,最终会变成一个不断污染自己的噪声源。所以从第一天起就该有:

  • 单条记录的删除(用户要求删除时,要真的删干净,包括派生摘要);
  • 按时间的自然衰减(旧偏好权重下降,除非被再次确认);
  • 冲突检测(新事实与旧事实矛盾时,标记而不是覆盖)。

“标记而不是覆盖”这一条尤其重要。覆盖会丢掉“用户曾经这么说过”这个信息,而这在客服、医疗、金融场景里往往才是关键。

记忆系统的目标不是记住更多,而是在该记住的时候准确,在该忘记的时候干净。做到这一点,靠的不是更大的向量库,而是三件无聊的事:明确的分层、显式的写入、可解释的召回。