1. 从状态管理到记忆系统:前端人做 Agent 开发最容易踩的认知断层
做前端转 Agent 开发的朋友,十个里有八个卡在同一个地方:前面几篇把工具调用、提示词编排、流式输出都跑通了,一到多轮对话就露馅。用户第二句问“那它呢”,Agent 一脸茫然;用户说“按我刚才说的风格改”,Agent 把上一轮的约束忘得干干净净。这不是模型不行,是记忆系统没搭起来。
我在前端写了七八年,转过来做 Agent 最大的感受是:前端的“状态”思维和 Agent 的“记忆”思维,看着像,其实是两码事。前端状态是短生命周期的、确定性的、UI 驱动的;Agent 记忆是长生命周期的、模糊的、语义驱动的。你拿 Redux 那套去套,必然翻车。这篇就把 Agent Memory 从设计到落地完整拆一遍,重点讲清楚为什么这么设计,以及前端背景的人容易在哪些地方想当然。
先说清楚这篇适合谁看:已经能跑通单轮 Agent 调用、想让它具备多轮上下文能力的前端开发者;或者正在做对话类产品、被“记不住”问题折磨的工程师。不需要你有后端或算法背景,但需要你理解基本的异步流程和数据结构。
2. Agent Memory 到底在解决什么问题:先想清楚再动手
2.1 记忆不是“把历史全塞进 prompt”
新手最常见的做法,是把所有历史消息拼成一个数组,每轮全量丢给模型。我一开始也这么干,跑三轮还行,跑到第十轮 token 直接爆掉,而且模型开始“注意力涣散”——前面聊的重点被后面一堆寒暄稀释了。这就是典型的把记忆等同于存储。
记忆系统的本质是在有限的上下文窗口里,做出最优的信息取舍。它要回答三个问题:什么该记、什么该忘、什么时候该想起来。这三个问题对应了记忆系统的三个核心模块:写入、压缩、检索。前端思维习惯的是“存下来就完事”,但 Agent 记忆里,遗忘和压缩比存储更重要。
2.2 三类记忆,对应三种不同的生命周期
我习惯把 Agent 记忆分成三层,这个分法不是学术定义,是实操中最好落地的切法:
| 记忆类型 | 生命周期 | 存储位置 | 典型内容 | 前端类比 |
|---|---|---|---|---|
| 短期记忆 | 单次会话 | 内存/会话存储 | 最近几轮对话原文 | 组件内 state |
| 工作记忆 | 单次任务 | 内存+结构化 | 任务目标、已完成的步骤、中间结果 | 全局 store |
| 长期记忆 | 跨会话 | 向量库/数据库 | 用户偏好、事实性知识、历史结论 | 持久化缓存 |
短期记忆负责“接得上话”,工作记忆负责“办得成事”,长期记忆负责“记得住人”。三者混在一起存,是绝大多数翻车现场的共同原因。我见过一个项目把用户偏好和临时对话塞进同一个数组,结果用户改了偏好,旧对话里的老偏好还在检索里冒出来,Agent 行为前后矛盾。
2.3 为什么前端背景反而有优势
别觉得自己是转行的就吃亏。前端对异步数据流和状态派生的理解,恰恰是记忆系统最需要的。记忆的写入、压缩、检索全是异步的,而且检索结果要根据当前 query 动态派生。你写过的那些 selector、memo、防抖节流,换个场景就是记忆系统的核心逻辑。真正要补的是向量检索和token 预算这两块,后面会细讲。
3. 记忆系统的核心架构:写入、压缩、检索三件套
3.1 写入策略:不是每句话都值得记
写入的第一原则是过滤。用户说“你好”“谢谢”“嗯嗯”,这些没有信息增量,写进去纯属污染。我的做法是给每条消息打一个“信息密度”分,低于阈值的直接丢弃。
具体怎么打分?我用的是一个轻量规则+模型判断的组合:
- 规则层:长度小于 6 个字符、纯语气词、纯标点,直接过滤
- 模型层:让模型判断这条消息是否包含“事实、偏好、指令、结论”四类信息之一
// 伪代码:写入前的信息密度判断 async function shouldWrite(message, context) { if (message.length < 6) return false; if (isPureFiller(message)) return false; const judgment = await model.judge({ prompt: `判断以下消息是否包含值得长期记忆的信息。 信息类型:事实、偏好、指令、结论。 消息:${message} 只返回 true 或 false。` }); return judgment === 'true'; }注意:这个判断本身也要花 token,所以别每条都调模型。我的经验是先用规则过滤掉 60% 的噪音,剩下的再走模型,成本能压到可接受范围。
3.2 压缩策略:把十轮对话压成三句话
压缩是记忆系统里最考验功力的部分。全量保留会爆 token,全量丢弃会失忆,中间那条线就是摘要。但摘要不是随便让模型总结一下,那样会丢关键细节。
我的压缩策略是分层摘要:每 N 轮对话做一次局部摘要,每 M 次局部摘要做一次全局摘要。局部摘要保留具体细节(谁、什么时候、做了什么),全局摘要只保留结论和偏好。
// 局部摘要的 prompt 模板 const localSummaryPrompt = ` 将以下对话压缩成不超过 100 字的摘要,必须保留: 1. 用户明确表达的偏好或约束 2. 已确认的事实性信息 3. 未完成的任务和当前进度 不要保留:寒暄、重复确认、已被推翻的中间结论。 对话内容: {conversation} `;实测下来,10 轮对话压成 100 字左右,信息保留率能到 85% 以上。关键是 prompt 里那三条“必须保留”和一条“不要保留”,这是压缩质量的命门。你不写清楚,模型就会把“用户说你好”也当成信息保留下来。
3.3 检索策略:让该想起来的东西自己浮上来
检索是记忆系统的“临门一脚”。存得再好,检索不出来等于没存。前端背景的人容易把检索想成“按 key 查”,但 Agent 记忆检索是语义相似度匹配。
核心流程是:把当前 query 转成向量,在向量库里找最相似的 top-k 条记忆,再根据时间衰减和重要性加权排序。
// 检索的加权排序逻辑 function rankMemories(candidates, queryEmbedding) { return candidates .map(mem => { const similarity = cosineSimilarity(queryEmbedding, mem.embedding); const recency = Math.exp(-0.01 * daysSince(mem.timestamp)); // 时间衰减 const importance = mem.importanceScore; // 写入时打的 0-1 分 return { ...mem, score: similarity * 0.6 + recency * 0.2 + importance * 0.2 }; }) .sort((a, b) => b.score - a.score) .slice(0, 5); }这三个权重(0.6/0.2/0.2)不是拍脑袋来的。相似度是主信号,必须占大头;时间衰减防止老记忆一直霸榜;重要性分让用户明确强调过的信息有更高优先级。你可以根据自己场景调,但相似度权重别低于 0.5,否则检索就变成随机推荐了。
4. 实操落地:从零搭一个能跑的记忆系统
4.1 技术选型:别一上来就上重型方案
前端转过来最容易犯的错,是听说“向量数据库”就立刻去装一套重型服务。我建议按阶段来:
- 阶段一(验证期):内存数组 + 简单余弦相似度,够跑通逻辑
- 阶段二(小规模):本地文件 + 轻量向量库,比如基于 SQLite 的方案
- 阶段三(生产):独立向量数据库 + 缓存层
我第一个版本就是用内存数组做的,200 条记忆以内检索延迟可以忽略。等记忆量上千了再换,别过早优化。
4.2 数据结构设计:一条记忆长什么样
一条记忆记录至少要有这些字段,缺一个后面都会难受:
const memoryRecord = { id: 'uuid', content: '用户偏好用 TypeScript 而不是 JavaScript', embedding: [0.12, -0.34, ...], // 向量 type: 'preference', // fact | preference | instruction | conclusion importance: 0.8, // 0-1,写入时评估 timestamp: 1699999999999, sessionId: 'session-abc', accessCount: 0, // 被检索次数,用于热度加权 lastAccessed: 1699999999999 };type字段特别重要。检索时你可以按类型过滤,比如生成代码时只检索preference和instruction,闲聊时只检索fact。accessCount和lastAccessed是我后来加的,用来做“热度衰减”——一条记忆被反复检索说明它有用,权重应该上升。
4.3 完整流程串起来:一次对话里记忆怎么流转
把上面三块拼起来,一次完整的对话流程是这样的:
- 用户发消息,先走写入判断,决定是否存
- 如果存,生成 embedding,评估 importance,写入记忆库
- 同时把当前消息加入短期记忆(最近 N 轮原文)
- 用当前消息的 embedding 去检索长期记忆,取 top-k
- 把短期记忆 + 检索到的长期记忆 + 系统提示词拼成最终 prompt
- 模型返回后,判断是否需要触发压缩(比如每 5 轮一次)
- 更新被检索记忆的 accessCount 和 lastAccessed
async function buildPrompt(userMessage, sessionId) { // 1. 写入判断与存储 if (await shouldWrite(userMessage)) { const embedding = await embed(userMessage); await memoryStore.insert({ content: userMessage, embedding, type: await classifyType(userMessage), importance: await scoreImportance(userMessage), sessionId, timestamp: Date.now() }); } // 2. 短期记忆 const shortTerm = getRecentMessages(sessionId, 5); // 3. 长期记忆检索 const queryEmbedding = await embed(userMessage); const longTerm = await retrieveMemories(queryEmbedding, 5); // 4. 拼装 return assemblePrompt({ system: SYSTEM_PROMPT, longTermMemory: longTerm, shortTermMemory: shortTerm, current: userMessage }); }这个流程看着简单,但每一步都有坑。比如第 3 步的检索,如果你把当前消息也写进去了,检索时可能把自己检索出来,造成重复。我的做法是先检索后写入,或者写入时打个标记排除当前轮。
4.4 token 预算:给记忆留多少空间
这是前端最容易忽略的。模型上下文窗口是固定的,系统提示词、记忆、当前对话、预留输出,四块要分。我的分配比例是:
- 系统提示词:15%
- 长期记忆:25%
- 短期记忆:30%
- 当前对话 + 预留输出:30%
按 8k 上下文算,长期记忆大概 2000 token,能放 5-8 条摘要记忆。所以检索 top-k 不能贪多,k=5 是个比较稳的值。超过这个数,要么压缩记忆,要么砍条数。
提示:一定要在拼 prompt 前做一次 token 计数,超了就按优先级砍。我踩过的坑是没做计数,线上偶发超限报错,排查了半天才发现是某条记忆特别长。
5. 常见问题与排查技巧实录
5.1 记忆“串味”:不同会话互相污染
现象:用户在 A 会话聊的内容,跑到 B 会话里冒出来。
原因:检索时没按 sessionId 过滤,或者长期记忆设计成全局共享但没做隔离。
解决:长期记忆分两类——全局记忆(用户偏好、事实)和会话记忆(任务上下文)。检索时全局记忆永远参与,会话记忆只在同 sessionId 内检索。别图省事全放一起。
5.2 记忆“僵化”:用户改了偏好,旧偏好还在生效
现象:用户说“以后都用 JavaScript”,但 Agent 还是按之前的 TypeScript 偏好走。
原因:新旧两条偏好记忆同时存在,检索时旧的可能相似度更高。
解决:写入新偏好时,检索同类旧偏好,把旧的 importance 降到 0 或标记为 superseded。我用的做法是给记忆加一个supersededBy字段,检索时过滤掉已被取代的。
5.3 检索“答非所问”:相似度高但没用
现象:检索出来的记忆语义相似,但对当前问题没帮助。
原因:纯向量相似度不够,缺少类型和场景过滤。
解决:检索时加类型过滤。比如当前是代码生成任务,就只检索preference和instruction类型,把fact类型排除。这一步能把准确率拉高一大截。
5.4 常见问题速查表
| 问题 | 典型原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 记不住 | 写入过滤太狠 | 看写入日志 | 降低过滤阈值 |
| 记太多 | 无压缩机制 | 看 token 占用 | 加分层摘要 |
| 串会话 | 无 session 隔离 | 看检索过滤条件 | 加 sessionId 过滤 |
| 偏好冲突 | 旧记忆未失效 | 看同类记忆数量 | 加 superseded 标记 |
| 检索慢 | 记忆量过大 | 看检索耗时 | 加缓存或分片 |
| 答非所问 | 缺类型过滤 | 看检索结果类型 | 加 type 过滤 |
5.5 几个我踩过的坑
第一个坑是embedding 模型换了没重算。我中途换了个 embedding 模型,旧记忆的向量和新 query 的向量不在一个空间,检索全乱。换模型必须全量重算,没有捷径。
第二个坑是importance 打分太随意。一开始我让模型随便打个分,结果所有记忆都是 0.5,加权排序形同虚设。后来改成给明确的评分标准(用户明确强调=1.0,一般偏好=0.7,普通事实=0.5),效果才出来。
第三个坑是压缩时机不对。我一开始是每轮都压缩,结果把正在进行的任务上下文也压没了。后来改成任务完成后才压缩,进行中的任务保留原文。
6. 让记忆系统真正“懂你”的几个进阶思路
6.1 记忆的主动召回:不等检索,主动提醒
基础检索是“用户问了才找”,进阶做法是主动召回。比如用户提到“上次那个方案”,即使当前 query 和上次方案相似度不高,也应该主动把相关记忆拉出来。实现方式是在 prompt 里加一句“如果用户提到过去的内容,优先检索相关历史”,让模型自己判断要不要触发二次检索。
6.2 记忆的冲突消解:让 Agent 学会“改主意”
用户偏好会变,事实会更新。记忆系统要能处理冲突,而不是简单叠加。我的做法是写入时做一次冲突检测:新记忆和已有记忆语义相似度超过 0.9 但内容矛盾,就触发消解流程——要么覆盖,要么标记时间线。这个逻辑不复杂,但能避免大量“精神分裂”式的回答。
6.3 记忆的可解释性:让用户能看见、能编辑
这是前端背景的人最有优势的地方。记忆系统不该是黑盒,用户应该能看到 Agent 记住了什么,并且能删改。我做过一个简单的记忆管理面板,列出所有长期记忆,支持删除和编辑。用户一旦能看见,信任感立刻不一样。而且这个面板本身就是个调试工具,排查记忆问题时特别有用。
6.4 记忆的冷启动:新用户怎么办
新用户没有历史记忆,检索为空,Agent 表现和单轮没区别。我的做法是准备一批通用记忆模板,比如“默认用简洁风格回答”“代码示例优先用主流写法”,作为初始记忆注入。等用户产生真实记忆后,这些模板的 importance 逐渐降低。这样新用户第一轮就有“被懂”的感觉。
7. 我个人的一点实操体会
做记忆系统这几个月,最大的体会是:别追求一步到位。我见过太多人一上来就设计一套复杂的多层记忆架构,结果连最基本的“记住上一轮”都没跑通。正确的顺序是先让短期记忆稳定,再加工作记忆,最后上长期记忆和向量检索。每加一层,都要有明确的收益验证。
另一个体会是,记忆系统的质量取决于写入质量,而不是检索算法。你存进去的都是垃圾,检索再花哨也白搭。所以我把大量精力花在写入过滤和 importance 打分上,这两块做扎实了,检索用最朴素的余弦相似度都能有不错的效果。
最后分享一个小技巧:给记忆系统加一个**“记忆命中率”指标**,统计检索出来的记忆有多少真正被模型用上了(可以通过模型输出里是否引用了记忆内容来判断)。这个指标低于 30% 就说明检索有问题,高于 70% 说明可以适当放宽检索范围。用数据驱动调优,比凭感觉改参数靠谱得多。