------作为前端开发,我习惯了
const [memory, setMemory] = useState([])。但当我开始写 Agent 时,才发现记忆不是setState那么简单------它决定了 Agent 是否有"人格" 。
从前端到 Agent 的开发思维应用
从前端到 Agent, 最有利的条件是前端工程师的状态管理能力早已经过锤炼,而上下文管理在 Agent 中起到至关重要的作用。从页面和组件间状态管理,到 Agent 全链路记忆管理,我们面临的挑战是管理方法和思路的转变。
在我看来,状态和记忆的区别在于:
- 状态管理:维护变量、维护组件和页面间的状态交互
- 记忆管理:维护语义完整性、维护上下文连贯性。
前端状态管理,解决的是UI与数据一致性 的问题,让数据的变化,在页面上有"确定性"的反馈。
而 Agent 记忆管理,解决的是上下文和行为的连贯性 ,让 Agent 在用户进行多轮对话和跨会话交谈后,仍能感知到"身处何处、如何交谈",解决的是 LLM 天然无状态的问题,从而给出连贯的、符合用户偏好的回答。
如何设计记忆管理方案
作为前端开发,我一开始觉得"记忆=全局变量",后来发现 LLM 是无状态的,记忆必须自己建索引。
首先需要解决对话如何跟随历史连贯,保护多轮对话的上下文完整性。于是滑动窗口短期记忆自然地加入方案中,这是最直接、最有效的上下文连续性的保证手段。同时,我们必须在注入最近 N轮对话消息时,限制Token预算。
那么接下来,对于超过最近 N 轮对话的历史记录,如何保留值得记忆的信息,进一步增强会话的完整性呢?
此时有两个方案,一是做历史对话内容的压缩和摘要提取 ;二是对历史对话内容进行有效信息提取 。 我选择后者,理由是:随着对话增加内容会不断膨胀,与其对每一轮对话反复压缩和提取摘要,不如精准提取有效的信息,并在新一轮对话中注入相关的信息。 比起狂轰滥炸式注入,精准打击目标更高效、更节省子弹。
分层记忆的设计
-
短期记忆 STM ------ 结合 Redis 实现最近 N 轮对话滑动窗口
-
长期记忆 LTM ------ 让 LLM 决定记住哪些信息
-
如何触发长期记忆提取:我设计了 LTM 三层触发机制:用户主动结束对话(L1)、会话长时间未更新达到超时阈值(L2)、消息累积达阈值(L3)。
-
提取什么内容类型的记忆:设计 prompt 时提供明确且有价值的信息类型,让 LLM 决定什么信息值得存入 LTM。
-
如何处理新旧记忆的冲突:通过提供已有记忆和记忆版本管理的 few-shot(如 Update/ADD/DELETE),由 LLM 自行决定记忆的增删改操作 。当 LLM 决定"修改"或"删除"一条记忆时,记录旧记忆或软删除,让版本变化可追踪。
-
如何选择记忆:采用bm25和向量检索双路召回结合 RRF 融合排序的方案,并增加记忆过滤机制,设置三层过滤:第零层 通过设置语义相关性门卫,拦截均匀噪声;第一层通过百分位截断进行长尾噪声兜底;第二层查询记忆的 embedding,利用贪心去重去除内容高度重复的记忆,既节省 Token,同时避免 LLM 被重复信息过度加权;最后按上限条数截断。
-
最终将选中记忆格式化为 system prompt 片段 → 注入 LLM
-
当然,长期记忆的方案并不是一开始就是完善的------具体方案的选定后,在思考如何落地实现时,会发现新的问题接踵而至。
- 什么时候来提取记忆?------是在会话结束时,还是每次用户发送新消息就触发提取?
- 提取什么类型的信息?------用户偏好和行为模式、已有事实、规划和目标、能力等等。定义需要提取的信息至关重要,这会影响 Agent 是否能保留会话过程的有效信息,并排除无关内容。
- 当新提取的新记忆和已有记忆发生冲突,该如何处理?------此时我们需要对记忆进行版本管理。
- 当我们尝试将长期记忆注入每条向 LLM 提问的新消息时,该如何选择记忆? ------记忆和 query 的相关性
- 最后注入记忆时,如何设计注入格式更优雅和节省呢?
以上的每个问题,都是我在实践过程中真实产生的疑问。当我们询问什么时机提取记忆,在前端中可类比于什么时机更新状态;当新记忆和老记忆冲突时,可类比前端的"状态一致性"问题,类似 React 的协调机制;每次发生新query时,是每次都要检索全部历史?还是按相关性召回 Top-K?这就像前端的"虚拟滚动",海量数据不能全量渲染。
三个关键设计
接下来分享 Mnemo 记忆模块的三个关键实现细节。
三层触发机制
设计思路
用户使用过的会话往往可能出现多种情况:
- 用户开启新会话,问了两三个问题后,下次使用时又开启了新会话
- 高频对话场景,单个会话内消息逐渐累积过多
- 会话跨越时间过长,当用户结束会话时,消息内容和现状相比已过期
- 用户永不主动结束会话
- ......
提炼长期记忆应当满足以下几点:
- 保证消息内容被提取时,产生的记忆是有效的
- 减少每次记忆提取的消息数量,避免 Token 过长导致提取质量下降或超过上限
- 当会话永不结束,或者消息数量无法达阈值时,不遗漏消息
因此,在单个会话过程中,当消息数达到阈值时,应触发强制增量提取,并重置计数器;当用户主动结束会话时,立即进行增量提取,并标记会话 session 结束;若未主动结束,由定时任务周期性扫描会话的最后活跃时间,超时则触发静默超时提取,作为兜底触发的安全网。
scss
用户消息 → [第三层] 消息数 ≥ 阈值? → 是 → 强制提取(增量) → 重置计数器
↓ 否
正常对话流转
↓
┌─── 用户显式结束? ──→ 是 → [第一层] 立即增量提取 → 标记session关闭
│ ↓ 否
│ SSE响应完成(仅记录last_active_at,不触发提取!)
│ ↓
└─── [第二层] 定时任务扫描 last_active_at > 3天? → 是 → 异步提取 → 标记session关闭
关键行为定义
-
L1/L2 写入
extracted终态后,该session的「记忆窗口」关闭。 -
用户在同一 sessionId 继续发送消息 → 视为开启新会话阶段:
- 会话上下文(STM / 工作记忆)重置,不注入上一阶段的对话上下文;
- LTM 提取生命周期重启:重置
cursor、 重置msg_count、清除extracted终态标记,使 L3 可对新阶段消息重新触发; - 已提取的 LTM 仍作为跨会话记忆可被检索(检索层行为,不在触发机制职责内)。
-
重置动作归属 Session 生命周期管理服务 (在消息入库时检测
extracted已存在即执行),不是 触发层Coordinator 职责------Coordinator 只管「单次生命周期内的提取与终态」。
设计意图:同一 sessionId 被复用为「连续的新对话」,既符合用户心智(继续聊就是新话题),又避免终态标记永久阻塞后续提取。终端不可逆性限定在「单次生命周期内」,跨阶段复用由显式重置打破。
| 触发层 | 触发条件 | 写终态 | 提取方式 | 角色 |
|---|---|---|---|---|
| L1 显性结束 | 前端「结束/删除会话」按钮 → endSession API |
是 | 增量收尾 | 实时关闭记忆窗口 |
| L2 超时扫描 | 周期扫描 last_active_at 超时(进程内 setInterval 驱动) |
是 | 增量收尾 | 兜底安全网 |
| L3 消息计数阈值 | 消息累积达阈值(如 20 条消息) | 否 | 增量提取 | 阶段性存档,会话可继续 |
触发驱动机制实现
-
L1 通过调用
endSessionAPI 显式触发 -
L2 周期扫描的驱动机制
- L2 由
SessionTimeoutScanner周期性调用scanOnce()实现;scanOnce()负责 SCAN 失活会话并逐个executeTerminalTrigger('timeout')。 - 周期驱动方式(当前阶段) :
scanner.start()内部用进程内setInterval(scanOnce, l2ScanIntervalSec * 1000)。零依赖、随组合根index.ts加载自启动、进程重启自愈。 - 何时升级为真正的 cron(外部调度器) :当服务水平扩展为多实例、且要求「全集群每轮仅扫描一次」时,将
scanner.start()替换为外部调度器。由该端点调用scanner.scanOnce()。此时扫描独立于应用进程、且只跑在一台指定机器。该 infra 在开发/单实例阶段不值当,故暂用setInterval。
- L2 由
-
L3 消息计数达阈值触发 :消息落库后计数
messageCounter.record(sessionId),达到阈值触发增量提取
短锁门卫 + 无锁流水线
Mnemo 的长期记忆提取有三个入口,任何一个都可能把会话送进提取流程:
- L1 显性触发:用户点「结束会话」按钮
- L2 超时触发:后台每 30 分钟扫描一次,发现会话 3 天不活跃就收尾
- L3 阈值触发:消息攒够 20 条,阶段性提取一次
三个触发器是互不知晓,同一时刻对同一个 session 触发提取时,会重复调用 LLM,写入重复记忆,因此要加以防护。
锁的设计
如果用一把分布式锁从头包到尾,锁就必须覆盖 10-30 秒的 LLM 调用,那么锁的TTL该设置几秒?TTL太短,LLM 调用未完成锁就过期了,那么可能有新的任务进来;如果TTL太长,持锁进程崩溃,那么该 session 长时间无法提取记忆,TTL 太长还要引入看门狗续期,续期本身又是新的故障点。更糟糕的是排队,同会话的后续触发器全部阻塞等锁,进入饥饿状态。
把以上矛盾放一起看,我们其实需要两种不同的保护:
第一种,保护临界区的一致性:「读终态 → 决定 → 写终态」这几步必须是原子的,不能被人插队。这个临界区只有几毫秒。
第二种,保证调用 LLM 时耗时任务之间互斥:LLM 提取这 10-30 秒里,同会话不能有第二个人在做同样的事。这个流程很长。
Mnemo 用两个 Redis Key 各管一件事。
- 锁(
memory:session:{sid}:lock)保护临界区读写一致性 。SET NX PX 10000,value 是随机 UUID;释放锁使用 Lua 脚本,获取到值为对应token时才删除,防止误删。锁的持有时间短(50ms 以内,纯 Redis 读写),所以 TTL 10s 纯属崩溃保险丝。持锁短,所以不需要续期。 - processing 标记(
memory:session:{sid}:processing)保护长流程互斥 。SET NX PX 300000,注意 value 不是布尔量,而是触发层标识(explicit/timeout/threshold),一旦出了问题快速定位是哪一层触发器。它在进入长流程前设置,横跨整个无锁提取期,承担锁的互斥职责。TTL 设置为processingTtl ≥ 2 × llmTimeoutMax + 60s,即 300s ≥ 2×120s + 60s,留了 2 倍以上余量,并且写成了启动期断言------将来谁上调 LLM 超时忘了配套调 TTL,服务直接起不来报错,而不是线上静默并发。
锁活得短,只保临界区原子性;标记活得久,横跨长流程做互斥。崩溃时,锁 10 秒自愈,标记 300 秒自愈,都不需要人工干预。
三阶段编排 ------ 短锁、无锁、再短锁
Phase 1,短锁当门卫(<50ms) :在锁的保护下完成查终态、查 processing、设 processing,任一不过就短路返回。
Phase 2,无锁跑流水线(10-30s) :await pipeline.run(sessionId),LLM 提取全程不持锁。P2 一结束(无论成败),processing 就在 finally 块里被清除。协调器不处理游标、消息,分别由 Pipeline 和 STM 内部管理。
Phase 3,短锁收尾(<50ms) :重新拿锁。这里是全流程唯一会等的地方 ------重试 3 次、重试间隔 1 秒,因为刚跑完长流程,可能正好撞上别人的临界区。拿到锁先再查一次终态再写:极端情况下两个触发器可能都进了 Phase 2,但这道二次校验保证终态只写一次。 接着 L1/L2 写终态、清 STM;L3 是阶段性存档,因此什么都不做。收尾时再清一次 processing ,防御性冗余,幂等无害。
注意一个容易忽略的细节:extracted 终态写入和 processing 清除在同一把锁内完成,不会出现「终态写了但标记没清」的中间态被别人观测到。
三阶段时序图如下:
scss
时间轴 ──────────────────────────────────────────────────────────►
lock ┌─P1获锁(10s)─┐ ┌─P3获锁(10s)─┐
└───DEL───┘ └───DEL────┘
<50ms <50ms
processing ┌──SET(300s)────DEL────┐
│ 横跨 P2 无锁期(不续期) │
└── P2结束时 finally 清除 ──┘
P1设置 ← P2→P3窗口 → P3获锁重试(≤3s)后写终态
extracted ┌─SET(不可逆)─
│
└─── 存活至Session销毁/续聊重置 ──►
cursor ┌─已有──────────────────Pipeline内部更新─────────────────────►
│ │
└──────────────── 横跨整个Session生命周期 ────────────────►
让 LLM 做记忆增删改的 prompt 设计
在开始设计记忆版本管理之前,说说我对状态更新的理解。
拿 React的函数式更新举例: setState(prev => ...) 永远基于上一次的 state,而不是凭空造一个新值。
但 LLM 无状态,看不到记忆库里已经存了什么,如果不给提供已有记忆的上下文,它只会新增记忆。
于是问题来了:用户说"我项目从 Express 迁到 NestJS 了",如果 LLM 看不到已有记忆"用户项目使用 Express + MongoDB",它会新增一条。于是数据库里存在两条互相矛盾的记忆,检索时一起被召回,将冲突的记忆注入会降低回答质量。
因此,版本管理的前提是 LLM 能看见旧记忆。
把已有记忆喂给 LLM 当对照系
每次触发提取时,先从 MongoDB 获取该用户最近被更新的 50 条记忆,格式化后注入 prompt。
- 排序按最近更新 ,而不是最新创建。被 UPDATE 过的记忆会浮到列表前面,让 LLM 总是看到最新版本。
- 只注入 id 和内容,不带 category、confidence 等元信息。
没有记忆时注入"(无)"占位------LLM 看到占位符默认这个用户没有历史,只输出 ADD。
版本规则定义
让 LLM 自行决定增删改,听起来很自由,但自由的前提是规则边界足够清晰。prompt 里用一张表定义完整的版本控制分支:
| 条件 | action |
|---|---|
| 全新事实 | ADD |
| 语义一致但需更新/补充/替换 | UPDATE |
| 彻底失效且无替代物(已离职、不再养猫) | DELETE |
| 一条旧记忆应拆成多条更细的 | DELETE + 多条 ADD |
| 与已有记忆重复且无变更 | 跳过,不输出 |
除了增删改,多出来的两个分支很关键:
- DELETE + 拆分 ADD :处理记忆粒度变化
- 记忆重复时跳过,防止提取时产出大量噪音
- UPDATE 和 DELETE 都要求携带
old_memory_id,并对它立了铁律:必须从已有记忆列表里逐字复制,禁止编造。
设计 Few-shot:一个示例只教一件事
版本规则已为 LLM 提供明确边界,但我们还需要设计少量关键示例教它怎么做。设计如下:
- 一条对话榨出多条 ADD,分属不同 category(skill / goal / preference / behavior_pattern......共 11 类枚举)------教「一条消息可以产出多条记忆」
- 旧记忆被颠覆 → UPDATE,教「同一 id 重写 content」
- 一条旧记忆炸成四条 → DELETE + 拆分 ADD,教「先删后加」
- 反例:用户说「我是小熊的妈妈」------❌ 错误提取「养了一只宠物」;✅ 正确提取「有一只小熊,自称其妈妈」
第 4 个反例目的是限制 LLM 自动语义补全的职业病------提取记忆不该增加用户没说的额外信息,宁可保守,不可添油加醋。
输出格式约束
我在 prompt 末尾给出严格的 JSON 骨架------facts 数组,每条含 action / content / old_memory_id / confidence / category / sourceMessageIds------并声明仅返回 JSON,其余勿输出。
另外,代码侧还增加了兜底逻辑:剥掉 json 包裹 → JSON.parse → 解析失败用 temperature=0 重试一次 → 再失败整批跳过。同时过滤低置信结果(confidence 过低的直接丢弃)。
数据安全防线
在设计好完善的prompt之外,我们仍需要在代码中做校验和检查------大模型的幻觉不可能完全消除,即便我们在prompt中提到:"old_memory_id 禁止编造",LLM 仍然可能返回一个不存在的 id。所以获得提取结果后,需要校验数据正确性。
这就像前端处理用户输入:表单提示写得再清楚,提交前照样要过 schema 校验------输入永远不可信,可信的是校验层。LLM 在我这里的定位也一样:它负责建议,是否采纳建议由代码说了算。
最后,在实现记忆版本管理时,对 ADD/UPDATE/DELETE 的落地是三个不同的动作:
- ADD:向量化及计算 contentHash,入库前做内容幂等检查,不写重复记忆
- UPDATE:保留原 _id ,只更新旧记忆和新内容,记忆检索索引和引用关系不变
- DELATE:软删除,写入 deleteAt,可追溯可恢复
前端思维如何平移到 Agent 开发
在React,状态变化,引起视图重新计算;而在 Agent 中,上下文不同,大模型的回复不同。区别在于前端的 state 在内存里而Agent 的state 是 STM 窗口 + 检索出的 LTM,分别存储在 Redis 和数据库里,而且负责计算的还是个概率模型。
状态分层 vs. 记忆分层
状态要放对层级是 React 入门的第一课,组件内部的状态不能全局化,跨页面共享的信息别塞在组件里。
Mnemo 的记忆分层结构是类似的原理:
- STM(滑动窗口)= 组件局部 state:生命周期跟着会话走,会话结束即清,快、便宜、不需要检索
- LTM(MemoryFact)= 全局 store:跨会话存活,有索引有检索,慢一点但可复用
而记忆提取本质上是状态提升:把会话里攒的局部状态,按需提升到全局层。区别是前端手动决定何时提升,Agent 侧需要设计三层触发机制替用户做这个决定------这也是为什么触发时机值得单独设计一整节。
不可变更新
Redux DevTools 能回放每一次状态变更,前提是不可变更新:不改旧对象,追加新版本。
记忆管理我借了同一个思想:UPDATE 不覆盖历史、DELETE 不物理删除(软删写 deletedAt)、每次变更记录 action 和 old_memory_id。代价是多存一点数据,换来的是「Agent 为什么这么回复」可以一路回溯到具体哪条记忆、哪次变更。出问题时能 debug 的系统,才敢上生产------这个意识就是前端调试器喂出来的
协调机制:diff + 稳定的 key
React 更新列表不重渲染整个 DOM,靠 key 做 diff,只更新变化的节点。
记忆的增删改是同一套思想:提取记忆时向 LLM 注入已有记忆列表对照新对话,对比出差异------ADD、UPDATE、DELETE,不需要每次全量重写记忆库。其中 UPDATE 原地保留 _id,等价于 React 列表的 key 稳定性:id 不变,记忆的检索索引和引用关系就不断。
前面我提到"版本管理的前提是 LLM 看见旧记忆",现在可以更新为:版本管理本身就是一次协调------先有可对比的旧状态,才有最小化的变更集。
无法平移的部分
-
可观测性: 前端状态在 DevTools 里裸奔,竞态打个断点就能抓;Agent 的状态变更发生在另一个进程的 300 秒租约里,中间态你永远观测不到。所以只能靠不变式和幂等兜底(TTL 断言、白名单、幂等 upsert),不能靠调试------这也是触发机制那一节为什么长成那样的原因。
-
确定性: setState 是确定性的,reducer 是纯函数;而记忆提取的「状态更新函数」是个 LLM------它会幻觉、会不听 prompt。前端思维里「更新逻辑写对就完事」在 Agent 侧不成立,每一层更新都要配代码侧的校验和兜底。
踩坑与反思
Scope 教训:三路检索砍成两路
最初的检索方案是三路:BM25 + 向量检索之外,还要上知识图谱(KG)和 Cross-Encoder 重排。但真正开始评估工期时,我才不得不承认:KG 的实体抽取、关系维护、图查询,每一项都是独立的工程量;Cross-Encoder 的推理延迟和模型部署,对单体服务同样是负担。这是一个人做项目,不是一个团队------一个人就得按一个人的方式做决策。
最后我调整了方向:砍掉 KG 和 Cross-Encoder,换成笔记 RAG 和遗忘机制。这两个模块都能在 solo 工期内落地,而且和现有 pipeline 的复用度极高。
反思:设计阶段什么都想加,落地阶段才知道每个模块的真实代价。砍需求不是失败,是把止损做在写代码之前。我现在的顺序是:先评估产出比,再确定是否做。
其他小坑
- Atlas
$vectorSearch的索引 filter 字段必须和查询时的 filter 完全一致,差一个字段整个索引不生效,报错信息还很绕 - Mongoose 全局
autoIndex: false之后,新建集合的索引要手动 sync------忘了就是线上裸奔查询
结语
这一个月最大的变化不是学会了 Redis 锁或 RAG,而是开发习惯变化:写前端的时候,出问题会有报错,白屏、console 红字,错误自己就找上门了;做 Agent 之后我发现,大部分问题不报错,只是结果悄悄地不对。所以我养成了三个动作:代码和文档对一遍,配置和 prompt 对一遍,结果和预期比一遍。坑不会主动暴露,只能自己一层层去核对。
项目完整代码开源在 GitHub:github.com/bobolittleb... ,记忆模块的实现在
src/services/memory目录下,欢迎 star 和 issue 交流。