本文面向大模型本身无状态、上下文窗口有限,而 Agent 需要跨会话长期记忆与个性化能力的场景,设计了一套基于请求驱动的通用记忆基础设施系统。该系统对调用方只暴露写入与召回两个高频接口,在内部自动完成事实抽取、记忆演化、权重衰减、容量淘汰、生命周期管理与隐私治理。
第一章 问题背景与设计目标
Agent 为什么需要记忆系统
我们需要知道,Agent 与单次 LLM 调用最大的区别在于:它在一个循环里反复决策,而模型本身是无状态的。每一轮请求承载的上下文来自系统提示词、工具定义、历史对话和检索结果,一轮对话结束后,这些上下文随之消失。由此带来四个具体问题:
- 对话无法延续。用户上一轮说过的事,下一轮就忘了,多轮一致性只能靠把历史全部塞进上下文硬撑。
- 无法积累个性化。模型不记录用户偏好。
- 知识有时效缺口。模型参数里的知识有截止时间。
- 上下文窗口是稀缺资源。历史全塞会挤占窗口,成本、延迟和模型注意力一起恶化。
因此,记忆系统的职责就是把对话沉淀为可检索的长期记忆,在需要时按相关度召回,让 Agent 在每一轮都能想起该记的事。业界对 Agent 记忆的分类已经比较一致,通常按认知科学映射成四类:
- 工作记忆(会话内临时信息)
- 情景记忆(具体事件经历)
- 语义记忆(抽象知识概念)
- 感知记忆(多模态信息)
本设计聚焦前三种,感知记忆作为扩展预留。
概念
有别于 Agent 的基础记忆系统,我们今天要介绍的 Agent 记忆系统是一套被动模式(请求驱动)的记忆基础设施:调用方(Agent)把对话丢进来,系统自动从中提炼事实、沉淀为长期记忆;当 Agent 下次需要读取记忆时,用一句自然语言召回最相关的记忆即可。中间所有工作,包括记忆的去重合并、衰减淘汰、删除归档等,全部由系统内部自动完成,调用方对此完全无感。
对调用方只暴露两个高频动作:写入(Add) 与 召回(Recall);更新、删除、历史、隐私四类为低频管理动作。这是整套设计的出发点:把复杂性收敛在系统内部,把简单留给调用方。
设计目标
| 目标 | 技术指标 | 说明 |
|---|---|---|
| 低延迟召回 | 召回 P99 ≤ 500ms,超时熔断 | 召回在对话主链路上,延迟敏感 |
| 异步写入 | 写入不阻塞主流程 | 抽取+演化是秒级耗时,必须与主链路解耦 |
| 高隔离性 | 用户 × 应用 × 会话 三维隔离 | 每条记忆只能被允许的场景看到 |
| 最终一致 | 并发写入以时间戳新者为准 | 记忆场景对写入实时可见性不敏感 |
| 隐私合规 | 删除限时生效、加密存储与传输 | 被遗忘权是硬约束 |
| 可演进 | LLM 可插拔、跨应用/多模态预留 | 模型与存储都会换代 |
设计原则
- 对上层透明:调用方只关注写入与召回,演化、衰减、淘汰、归档全自动。
- 读写分离:召回走在线低延迟链路;抽取、演化、生命周期走异步链路,互不阻塞、互不拖累。
- LLM 可插拔:抽取与演化的智能能力经统一适配层接入,不绑定具体供应商。
- 存储分层:元数据、向量索引、变更日志、冷归档分库存储,各用其长。
- 配置驱动:容量、权重、衰减、保留期、排序配比等阈值全部下沉配置中心,可动态调整、便于灰度调参。
- 被动不外推:系统不主动产生记忆、不主动推送,一切由调用方请求触发。
第二章 核心设计决策
这一章节我们将进一步描述核心设计决策。每个决策都按照问题、方案与取舍理由三个层面展开,这些决策共同构成整套系统的骨架,后续章节则是在骨架上填充血肉。
为什么写入异步、召回同步?
- 问题:写入需要调用 LLM 完成抽取和演化,这是秒级耗时且可能失败重试的操作,而召回位于对话主链路上,对延迟极其敏感。
- 决策:写入接口只做受理,即完成校验并投递消息队列后立即返回受理凭证,真正的抽取演化放到异步 Worker 中执行;召回则走纯在线链路,由向量检索、元数据并行与排序构成。
- 理由:抽取演化的耗时不应阻塞 Agent 与用户的对话节奏,异步化之后写入接口的响应时间可以控制在毫秒级;同时读写物理隔离部署,召回服务可以按读流量独立扩缩容,写入洪峰不会拖垮召回延迟,这是满足低延迟召回目标的前提。代价是写入最终一致,即写入后短暂时间内不可召回,但记忆场景对此完全可接受。
为什么要事实抽取 + 记忆演化两段式,而不是直接存对话?
- 问题:直接存储原始对话会导致记忆冗余、矛盾与膨胀,召回的噪声会很大。
- 决策:先用 LLM 把对话抽取成结构化事实,再让演化引擎把新事实与已有记忆比对,做 ADD / UPDATE / MERGE / SKIP / DELETE 五选一决策。
- 理由:抽取把口语化的对话压缩成可检索的事实,显著降低存储量与召回噪声;演化保证记忆库自我去重、自我纠错,比如用户改了住址,应该是 UPDATE 而不是新增一条矛盾记忆,这正是记忆区别于日志的本质;五种决策覆盖了记忆增删改的全部语义,且决策过程会写入变更日志,可追溯。
为什么用向量库 + 元数据库双存储,而不是单库?
- 问题:召回既需要语义相似,比如叫车能召回打车偏好,又需要精确过滤,比如按用户、应用、类型、状态过滤,还需要权重排序。
- 决策:向量数据库负责语义近邻检索(ANN,HNSW/IVF),元数据库负责精确过滤、权重、状态等结构化属性与强一致更新,两者通过引用标识关联。
- 理由:纯向量库做不了高效的结构化过滤与强一致更新,纯关系库做不了高维语义检索,各取所长是业界记忆与 RAG 系统的标准做法。召回时两库并行取数,向量库取候选与相关性、元数据库取权重等属性,再在排序引擎合并,兼顾语义与延迟。代价是双写一致性问题,用事务消息、幂等重试与定时对账兜底。
为什么权重用指数衰减模型?
- 问题:记忆要像人一样越久不用越淡、用到就加深。设计上需要一个可配置的硬锚点:普通记忆完全不召回时,90 天降到归档阈值 0.10。
- 决策:采用指数衰减 ,由锚点反解 (每天);重要记忆用因子 让衰减放慢到 1/10。
- 理由:指数衰减天然符合遗忘曲线,单参数即可精确命中 90 天锚点,数学上可推导、可配置;用乘性因子表达重要性,既能显著放缓衰减、又不做永久豁免,符合近似不衰减但不永久的语义。同时支持惰性衰减优化:召回时按上次衰减时间实时补算,定时任务兜底,避免每天全量扫描全表。
为什么删除用软删除 + 定时物理清理?
- 问题:用户的一键清除需要限时生效(比如 24h 内),但实时物理删除海量记忆代价高,且需要保留审计与可恢复窗口。
- 决策:删除即打删除标记实时退出召回,满足体验与合规时效,物理删除交由定时任务在保留期(如 30 天)后执行,并对变更日志脱敏。
- 理由:软删可以实时生效、可以对账、可以在保留期内追溯;物理清理离线批量做,成本低且不影响在线链路。
为什么状态机是活跃 → 归档 → 物理清理三段,而不是直接删?
- 问题:权重衰减到很低的记忆未必要立即丢弃,可能偶尔还会用到,但应该退出活跃召回以保证召回质量与索引规模。
- 决策:低于阈值先归档,即移出活跃向量索引、转冷存储、默认不召回但可显式查归档,归档超 180 天再物理清理。
- 理由:归档是降级而非删除,兼顾召回质量、索引成本与数据可追溯,给了一层缓冲。
为什么强调配置驱动与 LLM 可插拔?
- 记忆系统的关键阈值,包括容量、衰减系数、排序权重配比、保留期等,都需要上线后持续调参,硬编码会导致每次调整都要发版,下沉配置中心可以灰度、可以回滚。
- LLM 与 Embedding 供应商会演进,也可能需要多供应商降级容灾,统一适配层让上层逻辑与具体模型解耦。
第三章 总体架构
分层架构
整套系统按职责切分为六层,每一层只解决一类问题:
图1:分层架构
每一层独立成层的理由如下:
| 层 | 解决的核心问题 | 为什么独立成层 |
|---|---|---|
| 接入网关层 | 统一入口、鉴权限流、调用方识别、记忆开关前置拦截 | 把横切关注点(认证/限流/路由/关闭短路)从业务服务剥离;关闭记忆的用户在网关层即被短路,不消耗下游 LLM 与检索算力 |
| 核心服务层 | 编排写/读/管理/隐私四类能力 | 读写物理隔离是低延迟的关键:召回服务可按读流量独立扩缩容,写入洪峰不影响召回;隐私服务独立便于合规审计与权限收口 |
| 智能处理层 | 抽取、演化、排序,LLM/Embedding 经适配层接入 | 智能能力变化最快(模型迭代、供应商切换),独立成层 + 适配器隔离,上层逻辑不随模型变动;抽取与演化复用同一套相似检索能力 |
| 异步处理层 | 承载抽取/演化/提升/生命周期等耗时任务 | 用消息队列削峰解耦写入与处理,Worker 幂等消费保证可重试;把慢操作与在线请求彻底分离 |
| 存储层 | 元数据/向量/日志/缓存/冷归档分库 | 不同数据访问模式差异巨大(点查 vs 近邻检索 vs 追加写 vs 冷读),分库各用其长,用事务消息 + 对账保证最终一致 |
| 基础设施 | 配置中心、监控告警、密钥管理 | 配置驱动需要配置中心;记忆内容敏感需要密钥管理加密;全链路可观测需要监控 |
三条链路在架构上的物理隔离
- 在线低延迟链路:召回服务 → 排序引擎 → 向量库 / 元数据库 / 缓存。独立部署、独立扩缩容。
- 近线异步链路:写入服务 → 消息队列 → Worker → 抽取引擎 / 演化引擎。削峰解耦。
- 离线批处理链路:定时调度 → Worker → 衰减 / 归档 / 清理 / 对账。错峰限速,与在线资源隔离。
核心取舍:用最终一致换取召回低延迟、写入高吞吐与系统可演进。记忆场景对写入实时可见性不敏感,这个取舍成立。
模块职责
| 模块 | 职责 |
|---|---|
| WriteService | 接收写入请求,校验容量与记忆开关,投递异步抽取/演化任务,支持直写结构化记忆 |
| RecallService | 解析召回维度,调用排序引擎,更新召回元信息,处理空结果与超时 |
| MgmtService | 单条/批量更新、软删除、历史与变更日志查询 |
| PrivacyService | 记忆查看、一键清除、按维度清除、记忆开关、数据导出 |
| Extractor | 调 LLM 从对话原文抽取结构化事实,失败重试 |
| Evolution | 新事实与已有记忆对比,决策 ADD/UPDATE/MERGE/SKIP/DELETE |
| Ranker | 两阶段排序:相关性阈值过滤 + 综合分排序 |
| LifecycleManager | 状态机流转:活跃 → 归档 → 删除 → 物理清理 |
| CapacityManager | 容量检查与淘汰策略执行 |
| DecayCalculator | 权重衰减与召回恢复计算 |
| AuditLogger | 所有变更生成不可篡改的变更记录 |
| IsolationResolver | 将召回维度参数解析为存储查询条件,保证隔离 |
下面用一句话概括各模块之间的协作关系:
- 写链:
WriteService → MQ → Extractor → Evolution →(CapacityManager 把关)→ 元数据库/向量库/变更日志 - 读链:
RecallService → IsolationResolver → 向量库+元数据库(并行)→ Ranker → 返回 + 异步权重恢复 - 治理链:
定时调度 → DecayCalculator/LifecycleManager/CapacityManager → 元数据库/向量库/冷存储/变更日志 - 横切:配置客户端给所有模块下发阈值;审计日志记录所有写变更;隔离解析守住隔离边界。
第四章 数据模型
存储选型及理由
| 数据类别 | 选型 | 访问模式 | 为什么这样选 |
|---|---|---|---|
| 记忆元数据主表 | 分布式 KV / 关系库(按用户分片) | 高并发点查、按维度范围扫描、强一致更新 | 召回过滤、权重更新、容量管控都需要结构化属性与强一致;按用户分片天然贴合记忆按用户隔离,可水平扩展 |
| 语义向量索引 | 向量数据库(HNSW/IVF) | 高维近似最近邻(ANN)检索 | 语义召回必须靠向量相似度,关系库无法高效做高维近邻;HNSW 兼顾召回率与延迟 |
| 变更日志 | 追加写日志库 / 宽表 | 只增不改、按时间/记忆/用户范围查询 | 审计与历史要求不可篡改、可追溯;追加写性能高,天然适合日志型数据 |
| 热点缓存 | 分布式缓存 | 超高频读、低写 | 记忆开关、热点用户记忆、容量计数读取频繁,缓存挡住大部分读压力,是关闭拦截与低延迟召回的关键 |
| 归档冷数据 | 对象存储 + 二级索引 | 低频冷读 | 归档记忆访问稀少,冷存储成本低;保留二级索引以支持偶发的归档查询 |
实现以能力抽象描述,不绑定具体组件,便于落地选型。
为什么不用一个库搞定
- 纯向量库:缺乏高效结构化过滤与强一致更新,权重、状态、容量管控难做。
- 纯关系库:无法高效支持高维语义近邻检索。
- 结论:双存储(向量 + 元数据)并行取数、排序层合并,是语义召回系统的标准范式;代价(双写一致性)用事务消息 + 对账兜底。
记忆主表(Memory)
| 字段 | 类型 | 说明 |
|---|---|---|
| memory_id | string | 全局唯一记忆 ID(雪花/UUID) |
| user_id | uint64 | 所属用户标识(分片键) |
| app_id | string | 所属应用/Agent 实例标识,空 = 全局记忆 |
| session_id | string | 所属会话标识,可空 |
| level | enum | GLOBAL / APP / SESSION(作用域层级) |
| ownership | enum | USER / AGENT(记忆归属,谁产生的) |
| mem_type | enum | SEMANTIC / EPISODIC / PROCEDURAL(语义/情景/过程) |
| content | text | 结构化记忆文本(加密存储) |
| weight | decimal | 记忆权重 0.00~1.00(排序/淘汰索引) |
| status | enum | ACTIVE / ARCHIVED / DELETED |
| is_important | bool | 重要性标记 |
| recall_count | uint | 召回累计次数 |
| created_at / updated_at | timestamp | 创建与更新时间 |
| last_recalled_at | timestamp | 最后召回时间,未召回为空(淘汰二级排序) |
| expire_at | timestamp | 过期时间,可空 = 永久 |
| archived_at | timestamp | 归档时间,仅归档态有值 |
| content_hash | string | 内容哈希,写入幂等去重的最后一道闸 |
| source_session_id / source_msg_id | string | 来源会话与消息标识(可解释性) |
| custom_tags | json | 调用方自定义标签 |
| vector_ref | string | 向量库检索标识 |
| deleted_at | timestamp | 软删除时间 |
关键索引设计,每个索引服务于一类高频操作:
- 召回过滤复合索引:
(user_id, app_id, level, ownership, mem_type, status),服务在线召回的精确过滤。 - 淘汰排序索引:
(user_id, level, app_id, weight ASC, last_recalled_at ASC),服务容量淘汰先淘汰低权重久未用的记忆。 - 生命周期扫描索引:
(status, weight)、(status, expire_at)、(status, archived_at)、(status, deleted_at),服务各定时任务的批量扫描。
变更记录表(MemoryChangeLog)
| 字段 | 说明 |
|---|---|
| change_id | 变更记录唯一 ID |
| memory_id | 关联记忆 ID |
| user_id | 所属用户(便于按用户审计/清除) |
| op_type | ADD / UPDATE / DELETE / MERGE / ARCHIVE / RESTORE |
| content_before / content_after | 变更前后内容(物理清理后脱敏) |
| reason | 变更原因(MERGE 记录参与合并的事实来源) |
| op_source | SYSTEM / USER / ADMIN |
| op_time | 操作时间 |
| sanitized | 内容是否已脱敏 |
配置表与容量计数表
记忆配置表写少读多,由热点缓存承载,供网关在写入与召回前前置判断是否短路,内容包括用户级开关与按应用粒度的开关。
容量计数表按 (user_id, dimension) 维护实时计数,覆盖全局、各应用、总量与重要标记数,写入或删除时原子增减,避免每次容量判断都做全表 count 扫描。
为什么单独建计数表:容量判断在每次写入前都要执行,实时 count 海量记忆不可行;维护增量计数可以把 O(N) 扫描降为 O(1) 读取。
第五章 接口设计
接口总览
设计取向:高频动作(写入、召回)优先简单、异步化;管理与隐私动作低频、强一致。
| 分类 | 接口 | 方法 | 同步性 | 优先级 |
|---|---|---|---|---|
| 写入 | /memory/v1/add |
POST | 异步 | P0 |
| 写入 | /memory/v1/add_structured |
POST | 异步/同步 | P1 |
| 召回 | /memory/v1/recall |
POST | 同步 | P0 |
| 更新 | /memory/v1/update |
POST | 同步 | P0 |
| 删除 | /memory/v1/delete、/batch_delete |
POST | 同步 | P0 |
| 历史 | /memory/v1/history、/change_log |
POST | 同步 | P1 |
| 隐私 | /memory/v1/user/list、/purge、/switch、/export |
POST | 同步/异步 | P0/P1 |
| 会话 | /memory/v1/session/close |
POST | 异步 | P0 |
统一响应结构为 { code, msg, trace_id, data }。统一错误码覆盖:参数错误、记忆功能已关闭、容量已满且淘汰后仍无法写入、无相关记忆、抽取失败、召回超时熔断、跨应用能力未开放(预留)、重要标记超限。
写入接口 /memory/v1/add
请求携带:用户标识、应用标识(可空,空 = 全局)、会话标识、归属(USER/AGENT)、作用域层级(GLOBAL/APP/SESSION)、待抽取的对话原文(角色 + 内容 + 消息 ID),以及可选的抽取策略(侧重点、演化模式、自定义标签)。
接口响应只返回受理凭证(task_id),真正的执行将在后台运行,请求如下:
1 | { "code": 0, "data": { "task_id": "t_789", "accepted": true } } |
设计说明:
add默认异步,只返回受理凭证。真正的抽取演化在后台完成,避免阻塞对话主链路。
直写结构化记忆 /memory/v1/add_structured
统计聚合类记忆(如使用频率)已经是结构化结论,无需 LLM 抽取与演化,走旁路直写以降本提速:skip_evolution=true,直接指定内容、类型、重要性、过期时间与自定义标签。
这条接口还有一层身份:它同时是 Agent 的记忆自我编辑入口,Agent 想主动记一条关于自己的事实,直接结构化直写即可(见第九章)。
召回接口 /memory/v1/recall(核心)
请求:
1 | { |
响应:返回命中标识与记忆列表,每条记忆包含内容、归属、类型、权重、相关性、综合分与作用域层级。召回命中后会异步更新该记忆的召回时间与召回次数,并触发权重恢复,整个过程不阻塞响应返回。
更新、删除、历史与隐私
- 更新:按 memory_id 更新内容或元信息(重要性、过期时间),支持只更新元信息不动内容。
- 删除:单条或按维度批量,均为软删除,打标记后实时退出召回,物理清理由定时任务在保留期后执行。
- 历史:单条记忆的完整变更链;按用户、维度与时间范围查询的变更日志。
- 隐私:查看用户全部记忆(按维度组织)、一键清除(实时软删 + 异步物理清理)、记忆开关(全局/按应用)、异步导出。
会话结束接口 /memory/v1/session/close
会话结束时触发会话级记忆价值评估:有价值的记忆提升为 APP/GLOBAL 长期记忆,无价值的丢弃;若标记待续,则保存为短有效期(默认 24h)的上下文连续性记忆,供下一次会话衔接。
第六章 核心流程
写入与演化(异步链路)
1 | 调用方 → 网关(鉴权 + 记忆开关前置拦截)→ WriteService(校验 + 投递任务) |
演化决策的五个分支:
- ADD:容量检查 → 满则触发淘汰 → 仍满则拒绝写入;否则写新记忆(初始权重)+ 向量 + 变更记录。
- UPDATE:覆盖更新,权重重置为该类型初始值。
- MERGE:合并替换,权重取双方较高者。
- DELETE:软删旧记忆 + 新增新记忆(注意:这是演进后的语义,见第十章标失效改进)。
- SKIP:记录决策,不落库。
召回(在线低延迟链路)
1 | 调用方 → 网关 → RecallService(校验记忆开关)→ 隔离解析(scope/filter → 精确查询条件) |
整条链路超时熔断返回。命中后的异步更新不阻塞响应。
删除与隐私清除
一键清除的执行过程是:批量标记删除(实时生效)→ 失效缓存 → 生成变更记录 → 即时返回。保留期过后由定时任务物理删除,并对变更记录内容脱敏。
生命周期状态机(衰减 / 归档 / 清理)
每日批处理(错峰执行):
- 权重衰减:拉取活跃记忆(分片批量),按衰减公式计算新权重;跌破归档阈值(0.10)触发归档(移出活跃向量索引、转冷存储、记变更)。
- 过期归档:expire_at 到期的时效记忆转归档(非删除)。
- 物理清理:归档超 180 天转冷存储并脱敏;软删超 30 天物理删除并脱敏。
- 对账:校验主表 / 向量库 / 计数表一致性,修复漂移。
关键点:即使用户关闭了记忆开关,生命周期任务仍继续执行;重新开启后未清理的记忆自动恢复参与召回。
会话级记忆提升
会话结束时评估会话级记忆的价值:有价值的提升为 APP/GLOBAL 记忆(目标维度容量检查),无价值的丢弃(不持久化);待续会话保存为短有效期上下文连续性记忆。
多存储一致性保障
- 主表与容量计数同事务保证强一致。
- 向量库、变更日志通过事务消息 + 幂等重试保证最终一致。
- 对账任务定期校验主表与向量库/计数表的一致性,修复漂移。
第七章 关键算法
权重衰减算法
采用指数衰减模型,满足设计锚点:普通记忆完全未召回时 90 天降至归档阈值 0.10。
设初始权重 ,距上次召回天数 ,衰减系数 ,重要因子 (重要记忆 ,普通 ):
由锚点求 :取普通语义记忆 ,要求 :
为可配置参数,默认值以满足 90 天预期为准。重要记忆 时衰减速率为普通记忆的 1/10。
召回恢复算法
召回命中时权重提升,但不超过该类型初始权重上限:
为可配置恢复幅度;同时刷新 last_recalled_at 重置衰减起点。
召回综合排序算法(两阶段)
1 | # 阶段1(过滤): 保留 relevance ≥ min_relevance(默认 0.3) 的记忆 |
为什么相关性权重更高:召回首先要相关,权重用于在相关结果之间体现记忆强度,做次级区分。配比可经配置中心灰度调参。
初始权重表
| 归属 | 类型 | 初始权重 |
|---|---|---|
| 用户记忆 | 语义 | 0.80 |
| 用户记忆 | 情景 | 0.60 |
| 用户记忆 | 过程 | 0.70 |
| Agent 记忆 | 语义 | 0.70 |
| Agent 记忆 | 情景 | 0.50 |
| Agent 记忆 | 过程 | 0.60 |
| 时效性事件 | 不区分 | 0.85 |
| 重要标记 | 不区分 | 0.90(原值更高则保持) |
容量淘汰算法
1 | 写入前/巡检触发容量检查 → 维度计数 ≥ 上限? |
容量维度上限(均可配置):全局记忆/用户 500 条,单应用/用户 200 条,全维度总量/用户 2000 条,重要标记上限为各维度容量的 20%。
演化决策提示(LLM Prompt 设计要点)
演化引擎调用 LLM 时,输入新事实与 TopN 条相似已有记忆,约束输出五选一决策及目标记忆 ID:
| 决策 | 权重处理 |
|---|---|
| ADD | 按初始权重表设定 |
| UPDATE | 重置为该类型初始权重 |
| MERGE | 取双方较高者,且不低于初始权重 |
| DELETE(+ADD) | 旧软删,新记忆按 ADD 规则 |
| SKIP | 不变 |
决策过程与结果写入变更日志便于排查。自定义策略(抽取侧重点、保守/激进、自定义标签)通过抽取策略参数注入,但不可覆盖隐私与容量规则。
第八章 抽取与演化的工程细节
设计篇停在取舍层面,这一章下探到代码颗粒度。我们对照开源实现(Mem0、MemGPT、Zep)逐条把抽象设计落到能照着写的程度。先看抽取与演化这两个最核心的环节。
抽取:prompt 到底怎么写的
抽取听起来抽象,但开源实现把它具体化成了一段可照抄的 prompt。核心结构:给模型一个角色设定(你是个人信息整理员),列 7 类要记的信息(个人偏好、重要个人信息、计划与意图、活动与服务偏好、健康相关、职业信息、杂项),再用一组 few-shot 把什么该记、什么不该记掰开揉碎。few-shot 的价值在于教模型不该记什么:
1 | 输入 "Hi." → {"facts": []} // 寒暄,无事实 |
有几个设计里容易遗漏、但 prompt 中必须硬性约束的要点:
- 当前日期动态注入。在 prompt 里写明
Today's date is {now}。这对时效记忆很关键,比如我下周要去上海这类表述,没有当前日期模型无法算出绝对时间。 - 语言对齐。检测用户输入语言,用相同语言记事实。中文场景这条必须有。
- 不从系统消息抽取。只看 user 和 assistant 的内容,避免把系统提示词当成用户事实存进去。
- 空就返回空。没东西可记就返回空列表,不要硬凑。
抽取的 ownership 分流
一个容易忽略的细节:抽取应该按消息归属分流,而不是先混抽再打标签。实现上是三套 prompt 变体:通用抽取看 user + assistant;用户记忆抽取只看 user 消息;Agent 记忆抽取只看 assistant 消息。这样从源头保证用户记忆里不混进 Agent 的话,反之亦然,比先混抽再打标签干净得多。
演化决策 prompt 的细节
演化决策 prompt 定义四个操作(注意是四个,不是五个,合并被并进了 UPDATE):
| 操作 | 触发条件 | 关键约束 |
|---|---|---|
| ADD | 新事实在记忆里不存在 | 生成新 ID |
| UPDATE | 信息已有但内容不同 | 保持原 ID 不变,且带 old_memory 字段记录改之前的内容 |
| DELETE | 新事实与旧记忆矛盾 | 删旧的 |
| NONE | 新事实已存在或无关 | 啥都不做 |
few-shot 里几个很见功力的细节:
- 含义相同就别更新。专门举例:旧记忆是 Likes cheese pizza,新事实是 Loves cheese pizza,意思一样,不更新。这是在防 LLM 把同义改写当成新信息反复刷库。
- UPDATE 要合并而不是覆盖。旧记忆是我喜欢芝士披萨,新事实是喜欢鸡肉披萨,应更新成喜欢芝士和鸡肉披萨。不是拿新的盖掉旧的,而是揉成一条更全的。覆盖更新其实偏粗暴。
- ID 纪律。prompt 里反复强调:UPDATE/DELETE 只能用输入里给的 ID,不许造新 ID,只有 ADD 能造。这是为了让决策结果可以直接映射回数据库操作,不会出现 LLM 编造一个不存在的记忆 ID 的脏数据。
一个必须讲清楚的分歧:决策式演化 vs 纯追加
读开源实现时发现一个重要的反转,值得在写系统之前就想清楚:主流实现的主流程已经把 LLM 决策式的 ADD/UPDATE/DELETE 砍掉了。早期版本确实是抽取 → 检索相似旧记忆 → 让 LLM 五选一决策,和我们上面画的演化时序几乎一模一样;但现在的主管道改成了纯追加 + 哈希去重:抽取出来的事实全部按 ADD 处理,靠内容哈希去重,UPDATE 和 DELETE 退化成独立的显式 API,不在写入链路里自动跑。
从实现里可以推断出这样设计的原因:
- 决策式演化每写一条都要多一次 LLM 调用(先抽取、再决策),延迟和成本翻倍,还多一个失败点。
- LLM 决策不稳定。同样的新旧事实,模型今天判 UPDATE 明天判 ADD,记忆库会飘。
- 矛盾就 DELETE 旧的这一做法很危险,模型一旦误判,真实信息就被删了,不可逆。追加 + 去重则永远不丢信息,最多冗余,而冗余可以靠检索时排序消化掉。
这对设计意味着什么:决策式路线在记忆库干净、召回噪声低上更优,但要清醒地接受代价。一是写入链路上有两次 LLM 调用,端到端耗时预算要按两次算;二是 DELETE 决策必须有护栏,别让 LLM 直接物理删,软删是对的,保留了可恢复窗口;三是建议加一个降级开关,当演化 LLM 不可用或超时,自动退化成纯 ADD + 去重的模式,保证写入不中断。这是很实在的容灾设计。
相似检索取几条(topN 的取值)
演化决策需要输入新事实与若干条相似已有记忆,那么这些相似记忆到底应该取多少条?不同场景的取值并不一样:
| 场景 | 取值 | 用意 |
|---|---|---|
| 写入时拉相似旧记忆(给演化/决策当上下文) | top_k = 10 | 够判断有没有重复就行,多了费 token |
| 写入时拉会话最近消息 | limit = 10 | 给抽取补上下文 |
| 召回检索 | max(limit × 4, 60) | 过取:先多捞 4 倍,打完分再截到 limit |
| 实体关联检索 | top_k = 500 | 实体匹配要广撒网 |
最值得抄的是召回的过取再截断。召回链路是 ANN 检索 → 阈值过滤 → 排序 → TopK 截断,但 ANN 阶段捞多少条很容易被忽略。如果只捞 top_k 条,经过相关性阈值过滤后可能就剩三五条,排序失去意义。max(limit × 4, 60) 是个好默认,先捞够再筛。请求 top_k=10 时,底层 ANN 应捞 60 条候选,过滤排序后再返回 10 条。
向量什么时候重算
一个隐藏的大坑:文本变了向量没变,召回就会失准。规则必须明确:
| 操作 | 向量怎么处理 |
|---|---|
| 新增记忆 | 批量向量化,失败回退逐条 |
| 已有记忆(检索出来当上下文的) | 不重算,只取 payload 里的哈希用来去重 |
| 召回的 query | 算一次 |
| UPDATE 且文本变了 | 必须重算向量并覆盖向量库里的旧向量 |
| UPDATE 但只改元信息(如 is_important) | 不动向量 |
关键结论是:UPDATE 或 MERGE 只要改了内容,就必须重算向量,否则会出现记忆内容已经变成 A、但向量还保留 B 的语义的情况,召回时驴唇不对马嘴;只修改元信息不碰向量,还能省一次向量化调用。
去重与幂等
Worker 幂等不能只靠消息队列的消息 ID(那只防消息重投),还得防不同消息抽出了相同事实。做法很朴素但有效:每条记忆文本算内容哈希存字段,写入时拿新事实的哈希去比两组集合,一组是库里已有的哈希,另一组是本批次内已经见过的哈希,防止同一批里抽出两条一样的事实。命中任一就跳过。这是写入侧最后一道闸。
实体级去重(如果做了实体提取)分两层:精确层做文本归一化(去空格、转小写、折叠多空格)后精确匹配;语义层用向量相似度 ≥ 0.95 才算同一个实体。
这个 0.95 的阈值很有意思,它比召回的 0.3 阈值高得多。原因是:召回是找相关的,去重是找几乎一样的,后者必须严。将来如果做记忆合并(MERGE)的相似判定,阈值也应该往高了取(0.9+),不能用召回那套 0.3,否则会把相关但不同的记忆错误合并掉。这是个容易踩的坑。
并发与健壮性
几个值得记的实现细节:
- 异步卸载:所有阻塞 I/O(向量化、向量库、数据库)丢线程池,不堵事件循环。
- 批量失败回退逐条:批量写入/批量向量化失败时,自动退化成逐条重试,单条失败只记日志、不中断整批。别让一条脏数据拖垮一整批写入。
- 删除的竞态规避:批量删除别拆成 N 次单删去并发跑,尤其当多条记忆挂在同一个用户、同一个计数器上时,并发单删会让容量计数表的原子增减打架。批量删应该走一次聚合操作,把计数一次性扣减到位。
第九章 记忆分层与调度
设计里我们把记忆按作用域分成 GLOBAL / APP / SESSION 三层,但有一个开源项目(MemGPT)提供了另一套更有解释力的分层视角,它不是按作用域分,而是把记忆当成操作系统管内存那样分。两套分法的视角不同,放一起看能照出设计里没想透的地方。
核心隐喻:LLM 是 CPU,上下文窗口是 RAM
这个项目最初的洞察很简单:LLM 的上下文窗口就像内存条,容量有限且昂贵。装不下的东西怎么办?操作系统早就解决过这个问题——分页:把不常用的换出到磁盘,要用了再换回来。于是记忆分成三层,完全对应内存层次:
| 层 | 对应硬件 | 特点 | 谁来管 |
|---|---|---|---|
| Core memory(核心记忆) | 寄存器 / L1 缓存 | 常驻上下文窗口,随时可见,容量极小 | Agent 自己用工具编辑 |
| Recall memory(对话记忆) | 内存 | 完整对话历史,超窗后移出,可检索调回 | 系统自动 |
| Archival memory(归档记忆) | 磁盘 | 容量无限的外部库,按需检索调入 | Agent 显式存取 |
关键区别在在不在上下文里:core 永远在,archival 永远不在(用时才调),recall 是溢出区。
作用域轴与调度轴是正交的
很容易把这套三层和我们的 GLOBAL/APP/SESSION 对应起来,但对应是错的,先把这点掰清楚:
- 我们的分层是作用域维度:这条记忆该被哪些场景看到(全局 / 单个应用 / 单次会话)。回答的是可见性问题。
- MemGPT 的分层是在不在工作内存维度:这条记忆现在要不要占用宝贵的上下文窗口。回答的是调度问题。
这两个维度正交。一条全局记忆,既可能因为高频用而该常驻(core),也可能很久没用该躺在归档(archival)。设计里只有作用域这一根轴,缺了调度这根轴。
这不是说设计漏了,托管记忆库不直接管 Agent 的上下文窗口,调度那根轴本来就该留给调用方。但理解这个正交关系,能帮我们想清楚一件事:召回返回的 TopK,本质上就是在帮调用方做换页决策,从记忆库这个磁盘里,挑出这一轮该进 Agent 内存(上下文)的那几条。排序算法的角色,等同于操作系统的换页策略。
记忆的自我编辑
这个设计里最有名的一块是 core memory 可以被 Agent 自己用工具改:追加内容到核心记忆的某个块、替换某段、往归档存或取。core memory 通常分成有标签的块,最经典的两块是:
persona:我是谁(Agent 的人设)human:我面对的用户是谁(关于用户的核心事实)
Agent 在对话中发现用户说了重要的事,就主动调用工具把它写进 human 块。这就是记忆的自我编辑。
这对 Agent 记忆(ownership=AGENT)是个直接的补充:Agent 记忆怎么产生、记什么,设计里往往很模糊。这里给了两个答案:
- Agent 记忆里应该有一块类似
persona的稳定人设记忆,定义这个 Agent 是什么风格、什么定位。这种记忆不该走衰减,它不是越久不用越淡,而是人设就该稳定。衰减模型对记忆一视同仁的话,这里该开个口子:人设类 Agent 记忆应豁免衰减,比重要标记的处理更彻底。 - 我们的直写结构化记忆接口(
add_structured)其实就可以当成自我编辑的接口:Agent 想主动记一条关于自己的事实,直接add_structured(ownership=AGENT, skip_evolution=true)写进去,跳过抽取演化。这条链路设计里已经有了,只是没意识到它对应的就是记忆的自我编辑能力。
上下文快满了怎么办:压缩是第三条路
换页是被上下文快满了触发的:对话累积到接近窗口上限,触发压缩,把旧对话总结成摘要,腾出空间,细节沉到归档。托管记忆库没有上下文窗口的概念,但有容量上限。两者其实是同一类问题:有限空间满了,得决定留谁、走谁。
对照下来有两点可以借鉴:
第一,压缩可以作为淘汰之外的第三条路。 容量满了通常只有两招:归档或软删。压缩提示了第三招——把多条琐碎记忆总结成一条,既腾了空间又没全丢信息。比如用户点了 20 次外卖,与其存 20 条情景记忆挤占容量,不如压缩成 1 条语义记忆:用户常点外卖,偏好 X。统计聚合任务其实就是这个思路的雏形,但只用在推荐统计上;可以推广成通用的记忆压缩任务:同一维度下高度相似的低权重记忆,定期总结合并。
第二,会话级记忆的提升正是换出到长期。 对话结束或压缩时,把有价值的东西从对话记忆沉淀到归档。会话结束 → 评估价值 → 有价值的提升为长期记忆,是完全一样的动作。区别是那边让 Agent 自己判断价值,我们让演化引擎判断。这给了一个选项:提升的价值评估可以部分下放给调用方 Agent,它比系统更懂这次会话里哪句话重要。会话关闭接口可以加一个可选字段,让 Agent 显式标记这几条请务必提升。
远期动向:版本化记忆与做梦
新版本引入的两个机制,方向上值得记一笔:
- MemFS:把记忆当成一个带 git 版本追踪的文件系统来管。这和我们变更日志是同一个诉求,记忆的历史可追溯、可回溯,但它做得更彻底,直接是文件系统级的版本控制。变更日志已经能支撑查历史变更链,但要回滚到某个历史版本还差一层。
- Agent dreaming(做梦):Agent 在空闲时自动整理、巩固记忆,把零散记忆归纳、剔除矛盾。这其实就是离线批处理链路该干的事的智能版:现在的离线任务(衰减/归档/对账)是机械规则,dreaming 是让 LLM 在低峰期主动梳理记忆库。可以放进远期规划:离线链路除了机械治理,还可以有 LLM 巡检整理任务,定期发现并合并冗余、消解矛盾。
第十章 时序记忆与知识图谱
记忆如何随时间变化,如果只靠过期就归档这一种粗粒度机制来处理,那设计还停留在很浅的层面。有一个开源项目(Zep,底层是 Graphiti 引擎)在这件事上走得最远,它的核心观点能直接照出不足。
记忆图
前面把记忆当成一条条独立的文本(带向量、带权重)。而图模型不是这样,它把记忆建成一张时序知识图谱,节点是实体(用户、地点、商品、人),边是事实或关系,比如用户住在深圳就是一条连接用户和深圳的边。新数据进来不是新增一条孤立记忆,而是往图里加节点和边,并且让过时的边失效。
这个差别落到具体场景:用户先说住在深圳南山区,三个月后说搬到广州了。
- 条模型的做法:演化引擎检索到旧记忆,判 UPDATE 或 DELETE+ADD,旧记忆被覆盖或软删。住过深圳这个历史事实,就没了。
- 图模型的做法:不删旧边。给住深圳这条边标上失效时间,同时新增住广州这条边标上生效时间。两条事实都在,只是一条已失效、一条当前有效,且都带着时间戳。
valid_at / invalid_at:时效该有的样子
单点过期是非黑即白的:到点了就归档。图模型的时间模型是区间有效性,每条事实带两个时间:
| 字段 | 含义 |
|---|---|
valid_at |
这条事实从什么时候开始成立 |
invalid_at |
这条事实从什么时候开始不再成立(被新事实推翻时才填) |
关键设计在于:事实失效不等于删除。当新数据推翻旧事实,旧事实不被删,只是被填上一个失效时间,然后继续留在图里,历史完整保留。这解决了单点过期解决不了的三类问题:
- 曾经为真的可追溯。用户问之前是不是说过住深圳,条模型答不出来(记忆被覆盖了),区间模型能。
- 矛盾不是靠删,是靠时间排序。遇到矛盾要 DELETE 旧的(有误删风险),区间模型根本不删,靠失效时间自然把旧事实排到历史里,召回默认只取当前有效的。这比删除安全得多。
- 时间旅行式召回。理论上可以问用户在 X 时间点的偏好是什么,按生效时间过滤即可。这是条模型完全做不到的。
矛盾处理:从删除转向失效
这里和前面别让 LLM 直接删的教训正好呼应。那个教训是别删,这里给了正面答案:矛盾不删,标失效。演化引擎判定新事实与旧记忆矛盾时,不是软删旧记忆,而是把旧记忆标记为被新记忆取代,记下取代关系和时间。效果上召回时旧的退出(和软删一样),但信息不丢、关系可查。这几乎是零成本的改进,因为本来就是软删(不物理删),只是把语义从删除改成被取代,再把取代关系存进原因字段或新字段。
落地路径:针对会被新事实推翻的事实型记忆,加一个失效时间字段(或复用状态机):
- UPDATE / DELETE 决策发生时,别直接覆盖或软删旧记忆,而是给旧记忆打上失效时间,状态转为被取代态。
- 召回默认只看当前有效的(失效时间为空或未来)。
- 查历史时把被取代的版本一并拉出来,真正做到记忆的时间线可追溯。
这其实是把变更日志记录历史升级成记忆本身就携带时间线。变更日志已经记了变更前内容,信息是有的,只是散在日志里、不能直接召回;区间模型把它变成一等公民。
实体抽取:渐进式路径
实体关系预留了,但具体怎么做?图模型的思路:抽取时不只抽事实文本,还抽出事实里的实体和关系。渐进式路径分三步:
- 第一步(轻量):抽取时顺带抽出关键实体(人名、地点、商品),存进标签字段。不改架构,只是给记忆多打几个结构化标签,能让召回过滤更精准。
- 第二步(中期):建实体-记忆的关联表,让关于同一个实体的记忆能聚合召回。
- 第三步(远期):真正的图存储,边带时间区间。这是大改,放远期。
图模型还支持自定义实体/边类型。对多租户场景这意味着:不同应用可以声明自己关心的实体类型,比如电商关心商品、出行关心地点,正好对应多租户/开发者接入的扩展方向,抽取策略里可以让调用方声明关注的实体类型。
上下文原语与用户画像
图模型从图里能组装出好几种不同粒度的上下文:
| 原语 | 是什么 |
|---|---|
| Facts | 单条事实(边),对应我们的记忆 |
| Entities | 实体及其属性 |
| Episodes | 一段情节(一次交互) |
| Thread Summaries | 会话摘要 |
| User Summary | 用户整体画像摘要 |
最值得补的是 User Summary(用户整体画像)。现在的召回是检索相关的几条,但有些场景 Agent 需要的是这个用户整体什么样,一句话画像,而不是十条零散事实。做法:做一个派生记忆,定期(或召回时)把用户的高权重记忆汇总成一条画像摘要,存为一条特殊的全局记忆。Agent 开场时召回它,就能快速认识用户。这和前面 MemGPT 的 human 块异曲同工,也是统计聚合任务可以扩展去做的事。
图模型的边界
图模型很优雅,但不一定划算,有几个现实约束:
- 图谱构建更重:每次写入要做实体识别、关系抽取、图更新,比抽事实 + 存向量多好几步,延迟和成本都更高。
- 核心诉求是快速召回相关记忆,不是推理实体间的复杂关系。后者是图模型的强项,但不是 Agent 记忆的高频需求。
- 图存储的运维和扩展到大规模用户的复杂度,远高于分片 KV + 向量库。
所以判断是:时间模型(区间有效性、矛盾不删而失效)该吸收,因为它低成本高收益;但完整的图存储该克制,作为远期选项,别在第一期背这个包袱。 双存储是语义召回系统的标准范式,这个判断没错;图模型是另一条路线,适合关系推理重于语义召回的场景,和本设计的问题域不完全重合。
第十一章 隐私合规与治理
记忆系统存的是用户最私密的信息,合规不是加分项,是硬约束。这一章把治理链路讲清楚。
记忆开关(opt-out)控制链路
写入/召回请求到达后,第一件事是查记忆开关(缓存承载):
- 全局开关关闭:不新增任何记忆;已有记忆不参与召回(但用户仍可查看/删除)。
- 应用级开关关闭:该应用场景不新增任何记忆;该应用记忆不召回,全局记忆仍可召回。
一个关键设计是:关闭期间生命周期管理(衰减/归档/清理)继续执行;重新开启后未清理的记忆自动恢复参与召回。这样关闭只是退出服务,不是销毁数据,重开无成本。
被遗忘权实现
| 能力 | 实现 | 时效 |
|---|---|---|
| 删单条/多条 | 软删标记,实时退出召回 | 即时 |
| 一键清除全部 | 异步执行 + 实时标记,批量软删 | 限时生效(如 24h 内) |
| 按维度清除 | 按 scope 批量软删 | 限时生效 |
| 物理清理 | 保留期后异步执行,变更记录脱敏 | 异步 |
数据保护
- 传输加密:全链路 TLS 加密通道。
- 存储加密:内容字段加密存储,密钥由密钥管理系统管理。
- 第三方隔离:Agent 作为内部服务在授权范围访问,禁止透传记忆内容。
- 遥测脱敏:日志与监控仅含脱敏统计,不含记忆内容。
- 数据导出:异步导出用户全部记忆,签名下载链接限时有效。
可解释性
完整保留来源链路(来源会话 ID + 来源消息 ID)与变更日志,任何一条记忆都能追溯到它从哪句话来的、改过几次、为什么改。这是审计合规的基础,也是将来做记忆质量评估的原料。
参考文章
- Mem0(记忆 API 与演化引擎参考实现):https://github.com/mem0ai/mem0
- Letta / MemGPT(记忆分层与上下文管理参考):https://github.com/letta-ai/letta
- Zep(时序知识图谱参考):https://github.com/getzep/zep
- Graphiti(Zep 底层时序图引擎):https://github.com/getzep/graphiti