刚把前端那套状态管理的“肌肉记忆”带进 Agent 开发的时候,我一度觉得上下文窗口就是 Agent 的“脑子”。令牌越多,记性越好,能力越强。直到亲手把一个塞了十万字符对话历史的 Agent 怼到接口上限,又看到它在连续对话中反复问我已经交代过的偏好时,我才意识到一个残酷的现实:没有记忆系统的 Agent,就像一条只能记住七秒的鱼,上下文窗口再大,也只是加大了那条鱼缸而已。
不管是做自动化测试用例生成,还是构建一个能陪你聊天的角色扮演伙伴,记忆系统都是让 Agent “开窍”的关键一步。这里结合我实际转型踩坑的经验,从认知到落地产出一套记忆系统设计方案,全程给代码、给思路、给你能直接抄作业的配置。
1. 先别急着写代码,把“记忆”这个事拆明白
1.1 前端状态管理与 Agent 记忆的映射
作为前端过来人,我第一次听到 Agent 记忆这个概念,第一反应是:这不就是另一个 Vuex 或者 Redux 嘛。这个类比其实不算错,但格局要放大一点。
如果说前端状态管理管的是页面 UI 状态和用户交互数据,那 Agent 记忆系统管的就远不止这些了。它至少管四种东西:
工作记忆(Working Memory),相当于你当前这个函数里的局部变量。它保存的是这一次会话、这一轮请求内的关键上下文。比如用户刚说的那句“把颜色改成深色”,这属于缓存级别,上下两句之间要能接得上,靠的就是它。前端里最像它的是路由传参或者一个局部组件的state,用完即走,全部塞进每轮请求的messages数组里就行。
情景记忆(Episodic Memory),对应着你在项目里的 localStorage 或者 Cookie。它保存的是“以前发生过什么”。比如用户上个月设置过一个格式规范,或者三小时之前的一次会话里明确说“不要用宋体”。它记录的是特定时间、特定情境下的事实和偏好,时效性强,但对长期稳定身份影响不大。这玩意如果不做管理,就是一个数据爆炸的无底洞,跟前端 localStorage 存到爆调setItem直接报QuotaExceededError一样。
语义记忆(Semantic Memory),像前端的全局 config 文件。它沉淀下来的是用户的通用规则、性格、知识背景,这种记忆不那么揪着具体某时某地,而是从多次交互中提炼出的抽象结论。比如“用户是一名前端开发者,偏好 JavaScript 而非 TypeScript”,这已经完全脱离某一次的对话,成为 Agent 理解用户和世界的基础设定。
程序记忆(Procedural Memory),这个更好理解,就是技能包和工具链。Agent 已经掌握的 API 调用方法、写代码的风格规范、处理某一类问题时固定的思考动作,拔高了说就是“肌肉记忆”。前端里对应的就是webpack.config.js里沉淀的一套构建规则,不用每次重新思考。
1.2 没有记忆系统的 Agent 有多离谱
我刚开始做测试用例生成 Agent 的时候,样本特别“天真”,直接把历史对话一股脑塞进 prompt,以为上下文窗口能兜住一切。结果问题非常骨感:
- 对话稍长些就超 token 上限,直接 400 报错,得手动删上下文
- 用户中途纠正了一次测试规则,Agent 后面又按旧规则跑偏
- 换一个会话再问同一个需求,Agent 完全不记得问过,毫无连续性
这四个场景对应四类记忆各管一段,更关键的是触达了记忆系统设计的核心原则:**没有记忆系统,Agent 就是一个无状态的接口。**前端做组件要求可控、可复现、可管理状态,Agent 没记忆就没法持续迭代和个性化。
所以记忆系统的本质是什么?我的理解是——把 Agent 原本每次都要“临时抱佛脚”塞给大模型的对话包袱,拆成高频使用的缓存和低频持久化的仓储,再按需组装,打到合适的上下文层级里。
2. 记忆系统设计的第一课:分层存储
2.1 前端是没有分层记忆的,但 Agent 必须有
为什么?因为成本。前端里导一个 5MB 的 JSON 进内存不心疼,但大模型的 token 就是钱,就是延迟。每一个字进 prompt,响应都会更慢更烧钱。抛开成本和延迟谈记忆,全塞上下文就完事了,但那是懒人做法,不是工程。
一个成熟的记忆架构要分三层:会话内、会话间、全局沉淀。
第一层,会话内记忆。这一层的所有内容都临时拼在messages数组里直接发。注意两个细节:一是做 token 预算,比如一个四万 token 的上下文,我会规划出六成给对话历史,三成给静态指令,一成留给 Agent 的思考配额;二是维护一个滑动窗口,超出预算就把最早的内容压缩成摘要。
第二层,会话间记忆。用向量数据库做检索。每次对话结束之后执行一次抽取、清洗、向量化、入库的动作,新的会话开跑时,根据用户输入做相似度检索,把最相关的两百行历史记录拉回来。
第三层,全局沉淀。用户的稳定偏好、规则、身份背景。这类数据低频更新、高优先级,常驻在系统 prompt 里,一旦抽取出来,除非被新规则推翻,否则不丢。
这三层如果你熟前端,其实一眼就看透了:
- 会话内 = 组件局部 state
- 会话间 = localStorage + 持久化存储
- 全局沉淀 = 常量配置文件
技术翻新,内核不变。凡是要跨时间、跨场景复用的数据,都得寻一处仓储,找一套读写接口,再定一条更新清退策略。
2.2 记忆写入和读取的时机很关键
记忆不是无脑存的。存得猛没意义,关键在“时机”。实际项目里我定了四个触发点:
一是会话结束。这是一个完整叙事弧的句号,此时抽取记忆最合理。
二是用户明确表达偏好。比如“记住我喜欢 xxxxx”“下次不要再用 xxxxx 了”,这种是显式记忆信号,优先级最高,应当立即写入语义记忆区,不等会话结束。
三是用户纠正 Agent 行为。这比表达偏好更宝贵,因为它代表 Agent 之前的输出偏离预期,必须立刻写入情景记忆,重跑当前会话的上下文,阻止它继续错下去。
四是关键任务节点完成。比如成功生成了一套测试用例框架,或用户确认了一个模块的设计方案。这类里程碑信息适合进长期记忆。
同时,记忆读取也有讲究。不要每条历史都平等对待,我的做法是:
用户新发消息完之后,先做一轮意图识别,判断这条消息是在延续旧任务、查询旧信息,还是开启全新任务。
- 延续旧任务 → 加载情景记忆中最相关的几条历史对话
- 查询旧信息 → 走向量检索,拉取跨会话记录
- 开启全新任务 → 只加载语义记忆(用户画像和全局规则)
这个分流动作,就是前端开发里极其常见的“按路由拆包懒加载”。需要的时候才去读,绝不整包一股脑塞进来。
3. 动手实现:基于向量库的会话记忆模块
3.1 技术选型与技术栈
我跑过的组合方案里比较推荐的是:本地 SQLite + 向量索引 + LLM 抽取,这套方案对独立开发者极友好,不需要额外维护重型中间件集群。
拿我最近做的一个自动化测试生成 Agent 举例,技术栈是 Node.js 系的:
- SQLite(via better-sqlite3):存结构化记忆记录
- Embedding 服务:用 OpenAI 的 text-embedding-3-small 模型,把文本转向量
- 向量索引:sqlite-vec 或直接内存暴力检索(数据量小时够用)
- 提取 LLM:复用 GPT 主节点,在会话结束时跑一轮抽取提示词
选 SQLite 不选专门向量数据库,原因很简单:数据量没到百万级,先以精简架构为主。就像前端项目开始用 express 起服务就好,犯不上一开始就上微服务全套。
3.2 表结构设计
记忆表我继承了前端设计数据库表时的习惯,刻意保持通用的结构,方便不同类型记忆复用同一套读写逻辑:
CREATE TABLE memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type TEXT NOT NULL, -- 'episodic' | 'semantic' | 'procedural' content TEXT NOT NULL, -- 实际记忆内容文本 embedding BLOB, -- 向量存储 metadata TEXT, -- JSON,保存时间、场景、来源等 importance REAL DEFAULT 0.0, -- 重要度评分 access_count INTEGER DEFAULT 0, created_at DATETIME, last_accessed_at DATETIME ); CREATE INDEX idx_memories_user_type ON memories(user_id, type); CREATE INDEX idx_memories_user_created ON memories(user_id, created_at);这张表的字段里,有两个是你前端平时不会考虑的:
importance 重要度,太重要了。因为 Agent 的记忆是有遗忘曲线的,如果不引入重要度评分,最后记忆库里都是垃圾低频信息。怎么评分?抽取记忆时会带着上下文让 LLM 在 0~1 之间给重要度打分,读取时权重乘以它,高分记忆留存,低分定期清退。
metadata 元数据,它记录这条记忆的来源场景。比如“来源对话主题:生成 API 测试用例,时间:2024年某月某日”,跨会话检索时,同样的关键词能精准筛出历史来源,比光秃秃的内容向量靠谱太多。
3.3 记忆抽取与写入的提示词
记忆写入的核心就是一个结构化提取提示词。这地方一般难不住前端,因为本质上就是一次用大模型做信息抽取和结构化输出的 API 调用,跟用大模型解析一段非结构化文案提取 JSON 完全一个路子。
我的核心提示词如下,可以拿去改:
你现在是记忆提取引擎。根据下方的对话历史,提取值得长期记忆的信息。 要求: 1. 只提取事实性信息、用户明确偏好、长期规则,提取出的每条内容需要能让 AI 在未来不对同一件事提问两次 2. 忽略临时性的寒暄、聊天口水话 3. 过滤掉已经包含在系统设定里的信息 4. 每个记忆条目包含:content(简洁的事实描述)、type(semantic/episodic/procedural)、importance(0.0到1.0的数值,越重要分越高) 5. 输出JSON数组 对话历史: {{history}}这个抽取步骤要放在每次会话的结尾异步执行,不能阻塞主对话流。否则用户刚回复完等半天没反应,体验会断崖式崩。
3.4 写入与检索代码
写入端逻辑极其朴素,相当于前端一次localStorage.setItem,但多了向量化的动作。用 SQLite 参数化插入,注意写完别忘做两件事:一是更新last_accessed_at,二是把 embedding 列的值写进去。
查询代码是重头戏。以向量检索历史相关记忆的代码为例,加上基于关键词的粗筛,效果实在又稳定:
// 向量检索:从记忆库里找出最相关的历史片段 async function retrieveRelevantMemories(userId, queryText, topK = 5) { // 先算出当前问题的向量 const queryEmbedding = await embedText(`记忆检索查询:${queryText}`); const rows = db .prepare('SELECT id, content, metadata, importance FROM memories WHERE user_id = ? AND type IN (?, ?)') .all(userId, 'episodic', 'semantic'); const scored = rows.map((row) => { const emb = new Float32Array(row.embedding); const similarity = cosineSimilarity(queryEmbedding, emb); const score = similarity + row.importance * 0.3; // 重要度加权 return { row, score }; }).sort((a, b) => b.score - a.score); return scored.slice(0, topK).map((item) => ({ ...item.row, relevance: item.score.toFixed(4), })); } function cosineSimilarity(a, b) { let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); }检索出来之后怎么用进新会话?我一般干这个事:
const memories = await retrieveRelevantMemories(userId, latestUserMessage); const memoryBlock = memories .filter((m) => m.relevance > 0.55) .map((m) => `[记忆来自 ${m.metadata.created_at}] ${m.content}`) .join('\n'); const systemPrompt = `你是一个自动化测试生成助手。\n以下是用户相关的历史记忆,供你参考:\n${memoryBlock}`;这段代码里的 filter 阈值0.55是我拿百来条样本数据测试后做的贪心选择。高了会滤掉有用记忆,低了会拉进垃圾信息。数据量跑起来之后,你可以改成根据目标动态设阈值,或者调 topK 值,效果更稳。
3.5 记忆更新的动态链路
写记忆之后,还有个隐藏链路容易被忽略:更新。作为前端,你一定知道 Vue 的 computed 是惰性求值的;记忆也一样,不是每次抽取都全量覆盖写一遍。要维护一套“累积—覆盖”的机制。
比如用户这次说“别再生成 JUnit 测试了,改用 Jest”,这应该触发一次记忆更新,而不是新增一条:“用户偏好 Jest”。但上一次存的“用户偏好 JUnit”还在记忆库里,那下回检索就可能搜出两条打架的记忆。
解决办法:写入前做一轮冲突检测。以content做关键词相似度比对,如果新记忆与旧记忆主题重合,则将旧记忆标记为 superseded(已作废),写入新记忆。比如表格这样维护:
| 操作 | 触发条件 | 动作 |
|---|---|---|
| 新增记忆 | 全新的用户偏好/规则 | INSERT |
| 覆盖更新 | 新旧记忆主题相近且冲突 | UPDATE old row + INSERT new row |
| 降权/清除 | 超时未访问且重要度低 | UPDATE importance 或 DELETE |
| 强化 | 同一记忆再次被检索 | UPDATE access_count + refresh last_accessed_at |
这套动态链路做在前面,后面检索就不会被“互相矛盾”的旧记忆干扰。
4. 三种记忆操作在 Agent 里的实战表现
4.1 让会话内的记忆不再“断片”
做测试用例生成 Agent 时最崩溃的一刻是:我在一次会话里告诉它“这批接口的鉴权方式是 Bearer Token”,而它隔几个回合就忘得一干二净,生成出来的用例全是 401。
后来给工作记忆加了个小结构,在每个大模型请求前重新组装工作记忆,把用户最近明确过但尚未沉淀进长期记忆的指令堆在最前头:
function buildConversationContext(sessionState) { const recentInstructions = sessionState.pendingInstructions .filter((ins) => ins.active) .map((ins) => `[临时规则] ${ins.content}`); const slidingWindowMsgs = sessionState.messages.slice(-10); return { systemPrompt: basePrompt + '\n' + recentInstructions.join('\n'), messages: slidingWindowMsgs, }; }pendingInstructions是会话内最高优先级的工作记忆,一旦用户确认“就是这个规则”,立刻移动进长期记忆,并从工作区清出。
加了这段之后,“断片”问题基本绝迹。测试项目的技术人发现 Agent 能记住他随口说的编码规范,反馈说“突然变聪明了”,本质上不是模型变聪明,而是记忆链路做对了。
4.2 全局语义记忆,让个性化不再靠提示词硬编码
我做过一个给前端团队用的 “代码评审 Agent”,最开始的配置方式是把团队规范写死在系统 prompt 里。直到团队换了新规范、改了代码风格,我还在逐条改代码里的字符串。后来我把它改造成了语义记忆模式:
- 团队规范变更的每一次评审对话,结尾抽取“风格偏好类”记忆,入 semantic 类型库
- 每次评审请求发出前,检索相关的语义记忆动态拼进 system prompt
- 团队成员的风格偏好不再是铁板一块写死,而是随时跟着评审历史动态变化
这个思路想让 Agent 前端的工程化复用的话,简直是一模一样:把常量配置从源码里抽出去,变成运行时动态加载。
4.3 程序记忆:把 Agent 调教成“熟手工程师”
最像“训练肌肉记忆”的一层,是对 Agent 工具调用和工作流的固化。我的 Agent 如果已经成功用过某个“生成测试报告模板”的技能,那它会被我提取成一条 procedural 记忆,记录:这个场景用什么工具、输出什么格式、中途常踩什么坑。
下次遇到同类任务,它不再从零摸索,直接走老路。这跟前端从“手写 webpack 配置”到“用脚手架一键生成”的进化是一个逻辑。程序记忆的价值在于把一次性的成功经验,变成可复用的“方法论”,这比单纯记住用户偏好更值钱。
5. 实操避坑和调试建议
5.1 记忆污染问题:页面刷新大法不适用于 Agent
做前端时遇到状态脏了可以清刷新重来,Agent 项目里记忆一旦被污染,恢复远比前端重置复杂。最大的坑是:LLM 抽取记忆时也会幻觉,凭空捏造用户偏好。
举一个真实踩过的坑:某个测试生成 Agent 在抽取记忆时,把“用户让我生成登录模块的测试”错误吸收为“用户关心登录模块的安全性”,后续所有测试生成里都默认增加安全测试项,整个输出风格被带歪了。
解决办法:
- 抽取记忆的提示词里加一条“无法从对话中明确推导的信息不要生成”,语气要强硬
- 入库前人工抽查,正式前跑一批历史数据做回放测试
- 线上加一个
trust字段,机器抽取且重要度偏高的记忆,需要用户二次确认才生效
5.2 token 预算爆炸:记忆模块不是无限塞
有段时候我贪心,想着记忆多多益善,结果把向量检索到的 top 20 条记忆全塞进 prompt。输出延迟直接翻倍,用户体验明显变差,而且大模型被不相干的记忆干扰,反而变笨。
后来调整:一次检索最多能塞进 prompt 的记忆,控制在三到八条(按场景动态调整),而且每条内容在插入前做一遍长度裁剪(截断到 200 字以内)。上线之前,我用一批典型历史对话跑了个压测,把 topK、重要度权重、阈值这几个参数用表格排开对比:
| 参数 | 效果 | 当前值 |
|---|---|---|
| topK 数量 | 5 时最稳,10 时噪音增多 | 5 |
| 重要度权重 | 0.3 时偏好类命中提升明显 | 0.3 |
| 相关度阈值 | 0.55 以下容易误召回 | 0.55 |
| 单条记忆最大长度 | 200 字以上影响响应速度 | 200 |
前端的 A/B 测试思路在这里完全适用——先用数据定参数,再拿样本验证效果,不要靠感觉调。
5.3 不要迷信向量检索,混合检索才是常态
很多做记忆系统的资料会把向量数据库吹上天,但以我做过的几个真实项目看,纯向量检索在有明确实体名的场景下其实不如关键词匹配。比如用户问“之前设置过的超时时间是多少”,你拉向量召回,命中的可能是上下文相近但完全无关的记录。而关键词 “超时时间” 直接查到那条记忆的命中率接近满分。
我的稳妥做法是:关键词粗筛 + 向量精排,或者在检索入口做两路召回再合并打分。这跟前端用 Elasticsearch 做站内搜索时需要定制分词器一样,没有哪种搜索方案是银弹,最终效果要以真实场景验证为准。
6. 记忆系统的延展思考:让 Agent “越用越懂你”
一个记忆系统折腾完,Agent 的形态就变了:它会随着和用户交互次数的增多,说话风格、专业判断、工具手段都变得更贴合用户。这就是为什么我会说,“越用越懂你”不是一句空话,它的整个实现逻辑是迭代闭环:
对话 → 抽取 → 存储 → 检索 → 引导 → 更好的对话 → 再抽取
就像前端里一个“观察者模式”驱动的组件,每来一次新状态,自动更新视图,循环往复直到没有新事件。只要初始规则定得稳,技术方案跟得上,它就能稳定成长。
我个人的习惯是:在这个记忆系统跑完一轮真实场景后,回去挑一段旧对话,测试一下加入记忆前后 Agent 的回复质量差异。有一回对比非常直观——加记忆之前它给了个通用答案,加记忆之后它直接引用客户两轮前的需求细节,那种“它终于记住我”的感觉,就是记忆系统最大的成就感。
最后再分享一个实用的小技巧:给用户侧加一个“记忆调试模式”。在这个模式下,用户每轮对话的末尾会透出当前 Agent 记住了哪些关键信息,方便用户随时纠偏和验证。把记忆的过程透明化,用户的信任度和整个系统的可控度都会上一个台阶。