前端转Agent开发:如何自研一个记忆模块的?

------作为前端开发,我习惯了 const [memory, setMemory] = useState([])。但当我开始写 Agent 时,才发现记忆不是 setState 那么简单------它决定了 Agent 是否有"人格"

从前端到 Agent 的开发思维应用

从前端到 Agent, 最有利的条件是前端工程师的状态管理能力早已经过锤炼,而上下文管理在 Agent 中起到至关重要的作用。从页面和组件间状态管理,到 Agent 全链路记忆管理,我们面临的挑战是管理方法和思路的转变。

在我看来,状态和记忆的区别在于:

  1. 状态管理:维护变量、维护组件和页面间的状态交互
  2. 记忆管理:维护语义完整性、维护上下文连贯性。

前端状态管理,解决的是UI与数据一致性 的问题,让数据的变化,在页面上有"确定性"的反馈。

而 Agent 记忆管理,解决的是上下文和行为的连贯性 ,让 Agent 在用户进行多轮对话和跨会话交谈后,仍能感知到"身处何处、如何交谈",解决的是 LLM 天然无状态的问题,从而给出连贯的、符合用户偏好的回答

如何设计记忆管理方案

作为前端开发,我一开始觉得"记忆=全局变量",后来发现 LLM 是无状态的,记忆必须自己建索引。

首先需要解决对话如何跟随历史连贯,保护多轮对话的上下文完整性。于是滑动窗口短期记忆自然地加入方案中,这是最直接、最有效的上下文连续性的保证手段。同时,我们必须在注入最近 N轮对话消息时,限制Token预算。

那么接下来,对于超过最近 N 轮对话的历史记录,如何保留值得记忆的信息,进一步增强会话的完整性呢?

此时有两个方案,一是做历史对话内容的压缩和摘要提取 ;二是对历史对话内容进行有效信息提取 。 我选择后者,理由是:随着对话增加内容会不断膨胀,与其对每一轮对话反复压缩和提取摘要,不如精准提取有效的信息,并在新一轮对话中注入相关的信息。 比起狂轰滥炸式注入,精准打击目标更高效、更节省子弹。

分层记忆的设计

  1. 短期记忆 STM ------ 结合 Redis 实现最近 N 轮对话滑动窗口

  2. 长期记忆 LTM ------ 让 LLM 决定记住哪些信息

    1. 如何触发长期记忆提取:我设计了 LTM 三层触发机制:用户主动结束对话(L1)、会话长时间未更新达到超时阈值(L2)、消息累积达阈值(L3)。

    2. 提取什么内容类型的记忆:设计 prompt 时提供明确且有价值的信息类型,让 LLM 决定什么信息值得存入 LTM

    3. 如何处理新旧记忆的冲突:通过提供已有记忆和记忆版本管理的 few-shot(如 Update/ADD/DELETE),由 LLM 自行决定记忆的增删改操作 。当 LLM 决定"修改"或"删除"一条记忆时,记录旧记忆或软删除,让版本变化可追踪

    4. 如何选择记忆:采用bm25和向量检索双路召回结合 RRF 融合排序的方案,并增加记忆过滤机制,设置三层过滤:第零层 通过设置语义相关性门卫,拦截均匀噪声;第一层通过百分位截断进行长尾噪声兜底;第二层查询记忆的 embedding,利用贪心去重去除内容高度重复的记忆,既节省 Token,同时避免 LLM 被重复信息过度加权;最后按上限条数截断。

    5. 最终将选中记忆格式化为 system prompt 片段 → 注入 LLM

当然,长期记忆的方案并不是一开始就是完善的------具体方案的选定后,在思考如何落地实现时,会发现新的问题接踵而至。

  • 什么时候来提取记忆?------是在会话结束时,还是每次用户发送新消息就触发提取?
  • 提取什么类型的信息?------用户偏好和行为模式、已有事实、规划和目标、能力等等。定义需要提取的信息至关重要,这会影响 Agent 是否能保留会话过程的有效信息,并排除无关内容。
  • 当新提取的新记忆和已有记忆发生冲突,该如何处理?------此时我们需要对记忆进行版本管理。
  • 当我们尝试将长期记忆注入每条向 LLM 提问的新消息时,该如何选择记忆? ------记忆和 query 的相关性
  • 最后注入记忆时,如何设计注入格式更优雅和节省呢?

以上的每个问题,都是我在实践过程中真实产生的疑问。当我们询问什么时机提取记忆,在前端中可类比于什么时机更新状态;当新记忆和老记忆冲突时,可类比前端的"状态一致性"问题,类似 React 的协调机制;每次发生新query时,是每次都要检索全部历史?还是按相关性召回 Top-K?这就像前端的"虚拟滚动",海量数据不能全量渲染。

三个关键设计

接下来分享 Mnemo 记忆模块的三个关键实现细节。

三层触发机制

设计思路

用户使用过的会话往往可能出现多种情况:

  • 用户开启新会话,问了两三个问题后,下次使用时又开启了新会话
  • 高频对话场景,单个会话内消息逐渐累积过多
  • 会话跨越时间过长,当用户结束会话时,消息内容和现状相比已过期
  • 用户永不主动结束会话
  • ......

提炼长期记忆应当满足以下几点:

  1. 保证消息内容被提取时,产生的记忆是有效的
  2. 减少每次记忆提取的消息数量,避免 Token 过长导致提取质量下降或超过上限
  3. 当会话永不结束,或者消息数量无法达阈值时,不遗漏消息

因此,在单个会话过程中,当消息数达到阈值时,应触发强制增量提取,并重置计数器;当用户主动结束会话时,立即进行增量提取,并标记会话 session 结束;若未主动结束,由定时任务周期性扫描会话的最后活跃时间,超时则触发静默超时提取,作为兜底触发的安全网。

scss 复制代码
用户消息 → [第三层] 消息数 ≥ 阈值? → 是 → 强制提取(增量) → 重置计数器
                ↓ 否
           正常对话流转
                ↓
    ┌─── 用户显式结束? ──→ 是 → [第一层] 立即增量提取 → 标记session关闭
    │          ↓ 否
    │      SSE响应完成(仅记录last_active_at,不触发提取!)
    │          ↓
    └─── [第二层] 定时任务扫描 last_active_at > 3天? → 是 → 异步提取 → 标记session关闭

关键行为定义

  1. L1/L2 写入 extracted 终态后,该 session 的「记忆窗口」关闭。

  2. 用户在同一 sessionId 继续发送消息 → 视为开启新会话阶段

    1. 会话上下文(STM / 工作记忆)重置,不注入上一阶段的对话上下文;
    2. LTM 提取生命周期重启:重置 cursor 、 重置 msg_count清除 extracted 终态标记,使 L3 可对新阶段消息重新触发;
    3. 已提取的 LTM 仍作为跨会话记忆可被检索(检索层行为,不在触发机制职责内)。
  3. 重置动作归属 Session 生命周期管理服务 (在消息入库时检测 extracted 已存在即执行),不是 触发层Coordinator 职责------Coordinator 只管「单次生命周期内的提取与终态」。

设计意图:同一 sessionId 被复用为「连续的新对话」,既符合用户心智(继续聊就是新话题),又避免终态标记永久阻塞后续提取。终端不可逆性限定在「单次生命周期内」,跨阶段复用由显式重置打破。

触发层 触发条件 写终态 提取方式 角色
L1 显性结束 前端「结束/删除会话」按钮 → endSession API 增量收尾 实时关闭记忆窗口
L2 超时扫描 周期扫描 last_active_at 超时(进程内 setInterval 驱动) 增量收尾 兜底安全网
L3 消息计数阈值 消息累积达阈值(如 20 条消息) 增量提取 阶段性存档,会话可继续

触发驱动机制实现

  1. L1 通过调用 endSession API 显式触发

  2. L2 周期扫描的驱动机制

    1. L2 由 SessionTimeoutScanner 周期性调用 scanOnce() 实现;scanOnce() 负责 SCAN 失活会话并逐个 executeTerminalTrigger('timeout')
    2. 周期驱动方式(当前阶段)scanner.start() 内部用进程内 setInterval(scanOnce, l2ScanIntervalSec * 1000)。零依赖、随组合根 index.ts 加载自启动、进程重启自愈。
    3. 何时升级为真正的 cron(外部调度器) :当服务水平扩展为多实例、且要求「全集群每轮仅扫描一次」时,将 scanner.start() 替换为外部调度器。由该端点调用 scanner.scanOnce()。此时扫描独立于应用进程、且只跑在一台指定机器。该 infra 在开发/单实例阶段不值当,故暂用 setInterval
  3. 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 各管一件事。

  1. 锁( memory:session:{sid}:lock )保护临界区读写一致性SET NX PX 10000,value 是随机 UUID;释放锁使用 Lua 脚本,获取到值为对应token时才删除,防止误删。锁的持有时间短(50ms 以内,纯 Redis 读写),所以 TTL 10s 纯属崩溃保险丝。持锁短,所以不需要续期。
  2. 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。

  1. 排序按最近更新 ,而不是最新创建。被 UPDATE 过的记忆会浮到列表前面,让 LLM 总是看到最新版本。
  2. 只注入 id 和内容,不带 category、confidence 等元信息。

没有记忆时注入"(无)"占位------LLM 看到占位符默认这个用户没有历史,只输出 ADD。

版本规则定义

LLM 自行决定增删改,听起来很自由,但自由的前提是规则边界足够清晰。prompt 里用一张表定义完整的版本控制分支:

条件 action
全新事实 ADD
语义一致但需更新/补充/替换 UPDATE
彻底失效且无替代物(已离职、不再养猫) DELETE
一条旧记忆应拆成多条更细的 DELETE + 多条 ADD
与已有记忆重复且无变更 跳过,不输出

除了增删改,多出来的两个分支很关键:

  • DELETE + 拆分 ADD处理记忆粒度变化
  • 记忆重复时跳过,防止提取时产出大量噪音
  • UPDATE 和 DELETE 都要求携带 old_memory_id,并对它立了铁律:必须从已有记忆列表里逐字复制,禁止编造

设计 Few-shot:一个示例只教一件事

版本规则已为 LLM 提供明确边界,但我们还需要设计少量关键示例教它怎么做。设计如下:

  1. 一条对话榨出多条 ADD,分属不同 category(skill / goal / preference / behavior_pattern......共 11 类枚举)------教「一条消息可以产出多条记忆」
  2. 旧记忆被颠覆 → UPDATE,教「同一 id 重写 content」
  3. 一条旧记忆炸成四条 → DELETE + 拆分 ADD,教「先删后加」
  4. 反例:用户说「我是小熊的妈妈」------❌ 错误提取「养了一只宠物」;✅ 正确提取「有一只小熊,自称其妈妈」

第 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 看见旧记忆",现在可以更新为:版本管理本身就是一次协调------先有可对比的旧状态,才有最小化的变更集。

无法平移的部分

  1. 可观测性: 前端状态在 DevTools 里裸奔,竞态打个断点就能抓;Agent 的状态变更发生在另一个进程的 300 秒租约里,中间态你永远观测不到。所以只能靠不变式和幂等兜底(TTL 断言、白名单、幂等 upsert),不能靠调试------这也是触发机制那一节为什么长成那样的原因。

  2. 确定性: 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 交流。

相关推荐
山顶夕景1 小时前
【Omni】OmniGAIA: Towards Native Omni-Modal AI Agents
agent·多模态·vlm·omni·全模态
HIT_Weston2 小时前
205、【Agent】【OpenCode】TUI 内部:.tsx 与 .ts 的分界线
人工智能·agent·opencode
武子康2 小时前
中文 Prosody-Aware Eval:从四象限样本到可诊断的实时语音评测
人工智能·llm·agent
夏文强2 小时前
DeepSeek Harness 可观测性:用 OpenTelemetry 把会话遥测出去
人工智能·开源·大模型·agent·deepseek
Csvn3 小时前
第 17 章 评估与自检 Evaluation
人工智能·aigc·agent
ShallWeL3 小时前
RAG 向量索引重建与回归
人工智能·agent·知识库·工作流·rag
HIT_Weston3 小时前
206、【Agent】【OpenCode】TUI 内部:装配层与 context 工厂
人工智能·agent·opencode
夏文强3 小时前
DeepSeek Harness SDK 集成:把 Agent 嵌进你的应用
人工智能·开源·大模型·agent·deepseek
AIGC大时代4 小时前
LangGraph 生产级笔记:Checkpointer + interrupt(),把人审做成可恢复状态机
python·agent·状态机·langgraph·hitl