ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent分层记忆体系:突破上下文限制的工程实践

Agent分层记忆体系:突破上下文限制的工程实践 做Agent开发久了你会发现一个很分裂的现象Demo阶段一切顺风顺水一进入真实使用就开始露馅。用户上午刚告诉Agent我不吃香菜下午Agent就推荐了香菜馅饺子上周已经敲定的调研框架这周重新打开会话它像失忆一样重问一遍。这些问题十有八九都指向同一个根子——Agent记忆更具体地说是记忆没有被分层管理。这段时间我在反复调优自己的ai agent项目最有收获的一块就是把记忆改成分层记忆体系。这篇文章我会尽量讲透三层东西为什么Agent需要一套分层记忆而不是简单塞上下文工作记忆、情景记忆、语义记忆、程序性记忆每一层到底存什么、怎么落地以及检索、写入、排错、验证这些工程环节里容易被做坏的细节。适合两类人看正在从零搭Agent框架的新手以及觉得自家Agent总差点意思但说不清差在哪的开发老手。1. 为什么记忆是Agent从Demo走向可用的那道坎1.1 没有记忆的Agent三个真实翻车现场先看第一个现场。用户在一段长对话里明确说了我不喜欢吃香菜因为对话太长这句话早就被截断清出了上下文窗口。过了几轮用户让Agent推荐外卖Agent认认真真推荐了一家以香菜为灵魂的云南米线店。用户当场无语但这真的不是模型笨而是它根本看不到那句偏好了。第二个现场更典型。一个跨两天的调研任务第一天Agent已经跟用户确认了三份数据源、一套结论框架晚上用户关掉了页面。第二天回来继续做Agent像第一次见面一样重新问您想从哪个角度开始分析。用户开始怀疑自己是不是雇了一个每天失忆的实习生。第三个现场出现在多会话场景。同一个用户同时开着三个窗口一个问我常用的代码风格是什么一个让Agent写Python脚本一个在讨论画像字段设计。三个会话各聊各的Agent在A窗口说我偏好函数式写法在B窗口完全没理会这件事。用户觉得这个Agent像人格分裂。这三个翻车现场分别对应不同的记忆缺口第一个是工作记忆管理不当第二个是没有长期的事件记忆第三个是没有跨会话共享的用户事实。也就是说记忆不是一份而是需要分层的。1.2 分层记忆的由来不是发明是借鉴很多开发者一听到给Agent加记忆第一反应是把所有历史塞进向量库。这个方向没错但太粗糙了。真正好用的设计思路是把认知科学里那套人类记忆模型做一层工程映射。我这边的划分是这样的工作记忆上下文窗口相当于桌面上正在处理的那堆文件。特点是容量有限、变动频繁用完就要清理。情景记忆按时间线记录发生过的事件相当于日记本。记的是某时某刻做过什么事、结果怎么样。语义记忆从事件里提炼出来的稳定事实与偏好相当于知识卡片。记的是用户是谁、用户喜欢什么、业务背景是什么。程序性记忆沉淀下来的可复用操作方法相当于肌肉记忆。它让Agent不用每次从零思考同一类任务怎么做。分层的好处在于每一层都可以有自己的存储介质、写入时机、召回方式和淘汰策略。工作记忆追求低延迟只保留当次任务的上下文情景记忆追求完整可回溯但内容要做压缩语义记忆追求准确和一致不能轻易被噪声污染程序性记忆追求稳定复用需要版本管理。四层各管一摊互不干扰出了问题也容易定位。1.3 先分清记忆不是日志也不是缓存在agent开发群里我经常看到有人把记忆、日志、缓存混为一谈。日志是全量事实记录系统每一步发生了什么都往里面写日志越多越好但记忆是经过筛选的、面向复用和推理的可用信息写多了反而会污染Agent的判断。举个例子日志里可以留着用户在某天搜过法语课程但记忆里没必要存这条除非它真的影响后续服务。缓存解决的是性能问题记忆解决的是能力问题。缓存命不中顶多多花几十毫秒记忆不准确直接让Agent做出离谱决策。还有状态管理它通常是会话级的临时状态比如当前表单填到第几步了而记忆需要跨会话、跨天、跨任务存活生命周期完全不同。还有一个值得分清的概念正好回应harness和agent区别这类搜索。在常见的agent架构里Harness编排层负责执行循环、工具调度和上下文包装记忆模块是挂在Harness下面的一个组件它决定把哪些历史信息投喂给模型而不是替模型做推理。把记忆和编排层的关系理清之后你就能理解为什么不是所有Agent框架都内置记忆——它们把记忆当作可插拔能力而不是执行循环的一部分。2. 四层记忆的职责切分先搞清楚每一层到底存什么2.1 工作记忆桌面只有那么大的地方工作记忆就是现在正在处理的事对应模型上下文窗口里的全部内容系统提示词、用户消息、工具返回结果、Agent自己的推理步骤。它的核心矛盾是容量有限而任务信息会持续增长。你不可能让一个只有几万token的窗口装下用户过去三个月的所有对话。我的做法是把工作记忆当作一个结构化Buffer来管理而不是一个裸的消息数组。它分成四个区指令区放系统提示词常驻不动任务区放当前任务的必要信息比如目标、约束、关键参数动态刷新对话区放最近N轮消息滚动淘汰临时区放工具返回结果用完就丢。这样设计的原因是不同分区的生命周期不同统一用FIFO淘汰必然误伤。淘汰策略也不能一刀切。早期我踩过坑超出窗口就丢掉最早的消息结果把最开头那条用户要求全程用中文回复的指令给丢了。后来改成超窗时先对最早一段消息做摘要提炼把核心目标放进任务区再淘汰原始消息。模型始终能看到历史概要加近期细节连续性好很多。2.2 情景记忆记录发生过什么情景记忆存的是事件不是结论。它回答的问题是What happened。一条典型的情景记忆长这样某天某用户让Agent生成一份竞品分析报告采用了哪几个数据源最终输出了什么结构用户当时是否认可。它不是一句用户做过竞品分析就完了而是保留完整的事件脉络。工程实现上我建议把每条情景记忆存成结构化记录id、用户标识、事件类型、时间戳、重要性评分、压缩后的内容摘要、内容向量。摘要用LLM压缩过向量用于召回时的相似度计算元数据单独留字段是为了支持按时间、类型、用户做过滤。这比直接把整段对话扔进向量库要可控得多。情景记忆最典型的召回场景是回顾类请求。用户说帮我把上次那份周报改一下如果Agent有情景记忆就能先想起上周那份周报的框架是什么没有的话只能傻傻地反问一句哪份周报。就这一句话的差别用户的体感是一个天上一个地下。2.3 语义记忆沉淀下来的事实与偏好语义记忆是从情景记忆里抽象出来的稳定事实回答的是What does it mean。它存的不是某天用户说了一句话而是用户偏好简洁回复用户的工作日是周一到周五项目A的技术栈是Rust。这些东西是跨会话复用的基础。很多项目只做情景记忆不做语义记忆结果就是模型能想起上周的事但总结不出用户偏好。因为每次对话都要面对一堆事件记录重新归纳既费token又容易不稳定。语义记忆应该像一张不断更新的用户事实表有专门的结构化字段而不是靠每次临时去向量库里猜。语义记忆要处理冲突。新事实和旧事实矛盾时我默认不直接覆盖而是把旧事实标记为过期并保留历史版本。因为用户偏好是会变的保留历史能在回溯时解释Agent为什么会从A方案切到B方案排查问题时很有用。2.4 程序性记忆把做过的事变成会做的事程序性记忆在开源社区里有个更流行的名字Skill。最近agent skillagent skills教程agent开发这些搜索热度很高本质上大家就是在找怎么教Agent掌握固定流程。举个例子你写过一次把网页保存成Markdown的脚本之后这个能力就该固化成技能而不是每次让模型重新设计一遍。程序性记忆的载体可以是一个注册表技能名称、功能描述、触发条件、参数定义、执行入口。模型在相关任务出现时先查注册表、匹配技能、填参数、执行全程不用重新试验。这一层我通常放最后做。原因是只有前几层记忆积累到一定程度你才能发现自己重复调用的流程到底有哪些。太早固化技能基本上是在拍脑袋设计——你以为的高频任务可能根本不是真实需求。2.5 四层记忆对比一张表看清全貌记忆层存什么生命周期典型存储召回方式典型用例工作记忆当前任务上下文会话级消息Buffer、摘要块窗口内直接读取当次对话连续性情景记忆历史事件与结果数周至数月SQLite/Postgres加向量字段时间过滤加相似度回顾上次任务语义记忆用户事实与偏好长期事实表/图谱实体匹配加向量检索回答个性化问题程序性记忆可复用技能与流程长期更新Skill注册表/函数库意图路由加参数解析固定流程自动化我建议把这张表贴在工位前。很多记忆系统设计不合理根源就是把某几层混在一起了最典型的是把语义记忆塞进情景记忆表用向量检索去查用户偏好是什么结果召回一堆不相关的事件。3. 落地一套最小分层记忆系统从Buffer到向量库再到压缩3.1 选型不要一上来就上重型框架聊落地很多新手第一反应是上一套完整的agent框架再配一个专门的向量数据库。我劝你先冷静。一个最小可用的分层记忆系统只需要三样东西一个关系型数据库存结构化记忆一个能算embedding的接口一个能做时间衰减重排的脚本。我选SQLite起步而不是直接上大型向量库原因是分层记忆的第一步是先把数据模型设计对。数据模型错了换任何存储都是白搭。等单机验证通过再迁移到Postgres加pgvector或者专门的向量库也不迟迁移成本远低于一开始就陷入复杂基础设施的维护。下面这套示例代码用Python写逻辑完全可以迁移到其他语言。3.2 工作记忆的结构化Buffer超出窗口不丢关键信息先看工作记忆的代码。下面是一个我在小项目里用过的简化版本class WorkingMemory: def __init__(self, max_turns20, max_task_tokens1200): self.system_prompt # 指令区 self.task_context [] # 任务区核心目标与约束 self.dialogue_turns [] # 对话区 self.temp_tool_results [] # 临时区 self.max_turns max_turns self.max_task_tokens max_task_tokens def add_user_message(self, msg): self.dialogue_turns.append({role: user, content: msg}) def add_assistant_message(self, msg): self.dialogue_turns.append({role: assistant, content: msg}) def add_task_context(self, key, content): for i, (k, _) in enumerate(self.task_context): if k key: self.task_context[i] (k, content) return self.task_context.append((key, content)) def compact(self, summary_fn): # 超过窗口后把最旧的一半做摘要放进任务区 if len(self.dialogue_turns) self.max_turns: return overflow self.dialogue_turns[: len(self.dialogue_turns) // 2] summary summary_fn(overflow) self.add_task_context(history_summary, summary) self.dialogue_turns self.dialogue_turns[len(self.dialogue_turns) // 2:] def to_messages(self): messages [{role: system, content: self.system_prompt}] for key, content in self.task_context: messages.append({role: system, content: f[{key}] {content}}) messages.extend(self.dialogue_turns) return messages核心逻辑是当对话轮次超过上限时不直接丢弃最老消息而是调一次summary_fn把前半段压缩成摘要放进任务区。这样模型始终能看到历史概要加近期消息。摘要的generation成本不高但对连续性的提升非常明显。3.3 情景记忆的写入与检索一个事件一张卡情景记忆的存储结构我用的是下面这张表CREATE TABLE episodic_memory ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, user_id TEXT NOT NULL, event_type TEXT NOT NULL, -- task_completed / preference / correction ... content TEXT NOT NULL, -- 压缩后的描述 content_embedding BLOB, -- embedding 向量 importance REAL DEFAULT 0.5, -- 重要性 0~1 source_session TEXT, -- 来源会话ID created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_episodic_user_time ON episodic_memory(user_id, created_at); CREATE INDEX idx_episodic_event_type ON episodic_memory(event_type);写入时机比表结构更容易踩坑。我建议在三种时机写入用户明确表达好恶、一个复杂任务完成并且用户确认结果、用户纠正了Agent的错误。一般寒暄和无关紧要的请求不要写。写入示例def write_episodic_memory(conn, agent_id, user_id, event_type, content, embedding, importance): conn.execute( INSERT INTO episodic_memory(agent_id, user_id, event_type, content, content_embedding, importance, source_session) VALUES (?, ?, ?, ?, ?, ?, ?), (agent_id, user_id, event_type, content, serialize(embedding), importance, current_session_id()), ) conn.commit()检索时我更推荐先过滤再相似度的顺序。先按user_id过滤再按event_type可选过滤最后才在候选集合里算embedding相似度。原因是embedding计算有成本而且候选集里如果混了别的用户事件召回质量会明显下降。3.4 语义记忆的抽取用LLM把事件变成事实语义记忆不能靠手工维护一定要从事件里自动抽取。我的做法是每个会话结束时把当次对话交给LLM按固定模板抽取候选用户事实EXTRACT_PROMPT 你是用户画像分析师。根据下面的对话抽取关于该用户的稳定事实。 要求 1. 只抽取对话中明确表达或可合理推断的事实不要脑补。 2. 每条事实必须附上对话原文证据。 3. 输出JSON数组[{fact: ..., type: preference|attribute|constraint, evidence: 原文}] 对话内容 {transcript} 抽取出来的事实先写入待确认事实表而不是直接进语义记忆表。如果连续两次会话抽到同一事实再正式入库。这个两次确认策略非常有效能把一次性噪声过滤掉一大半。至于为什么用LLM抽取而不是正则是因为用户表达偏好的方式千奇百怪别给我发太长的邮件和工作日别老打扰我都需要语义理解正则扛不住。3.5 程序性记忆的注册Skill注册表长什么样程序性记忆的落地形态就是技能注册表。一个技能需要几个字段技能名称、给模型看的描述、触发条件、参数Schema、执行入口。注册表长这样class SkillRegistry: def __init__(self): self.skills {} def register(self, skill): self.skills[skill.name] skill def match(self, task_intent): candidates [] for skill in self.skills.values(): score cosine_similarity(embed(task_intent), embed(skill.description)) candidates.append((score, skill)) candidates.sort(keylambda x: x[0], reverseTrue) return candidates[:3] def execute(self, skill_name, params): return self.skills[skill_name].run(params)匹配不一定要用embedding相似度也可以用LLM路由但轻量场景下embedding够用了。等匹配精度成为瓶颈再上LLM也不迟。这里的要点是先匹配再执行让技能通过描述被模型发现而不是硬编码一堆if-else触发条件。4. 检索与写入策略分层记忆里最容易被做坏的环节4.1 为什么向量相似度一把梭不行只要真正做过一次记忆召回你就会发现纯向量检索的问题它检索的是语义相似不是对当前任务有用。举个实际例子用户说帮我写周报向量库里和周报语义最接近的三条记忆可能是上周周报的内容、用户说周报格式太啰嗦的偏好、一条和ERP系统相关的上下文碎片。如果只按相似度取前三返回给模型模型会被一堆碎片搞迷糊。所以我的结论是向量相似度只是基础筛选真正决定召回质量的是后续的重排策略。这一步做不好记忆系统越是内容丰富Agent越容易把不相关的记忆掺进回答里。4.2 混合重排时间、重要性、相关度三因素叠加我私下整理过一个混合评分公式实测比纯相似度好用很多score w1 * similarity w2 * recency w3 * importance recency exp(-(now - created_at) / half_life)三个权重根据业务调。回顾类任务把recency权重拉高偏好类任务把importance权重拉高。简化实现def rerank(candidates, now, w(0.6, 0.25, 0.15), half_life_days7.0): scored [] for cand in candidates: sim cand[similarity] recency math.exp(-max(0, (now - cand[created_at]).days) / half_life_days) importance cand[importance] total w[0] * sim w[1] * recency w[2] * importance scored.append((total, cand)) scored.sort(keylambda x: x[0], reverseTrue) return [c for _, c in scored]时间衰减参数是重灾区。我踩过一版把半衰期设成2天的结果用户三个月前明确的编程语言偏好被严重降权模型又开始追问您喜欢用什么语言。除非业务确实只关心近期行为否则半衰期不建议短于7天。4.3 记忆路由让任务去对口的记忆层找人比重排更前置的问题是你打算访问哪一层记忆用户问我上个月的总结报告在哪应该查情景记忆用户问我的写作风格偏好应该查语义记忆用户说接着刚才聊应该读工作记忆。我建议做一个轻量路由函数def route_query(query): if 包含时间回溯词(上次, 上周, 之前, 上个月): return episodic if 包含偏好词(喜欢, 偏好, 不喜欢, 觉得): return semantic if 查询的是当前对话上下文: return working if 包含操作意图(帮我把, 这个流程能): return procedural return episodic这个函数看起来简单价值却很大。它避免了你每次提问都对四层记忆各检索一遍省token、省延迟。等规则路由不够用了再换成LLM路由也不难只要保证路由结果仍然是去哪一层。4.4 写入策略不是所有对话都值得记住记忆污染的最大来源是写入策略太宽松。一条对话值不值得写入记忆我建议至少过三个问题这个信息以后还会被用吗它跟已有记忆是重复还是矛盾写入代价能接受吗重要性评分可以用规则用户明确说记住给0.9纠正行为给0.8完成任务给0.6一般请求给0.3。规则不完美但胜在稳定、可解释。写入方式上优先选异步批量。不要在用户请求的同步链路上做embedding和写库否则响应延迟会直接变大。我见过把写入放在主线程里的项目Agent每次回答后多出800毫秒体验直接崩掉。正确做法是回答返回的同时把需要写入的消息丢进队列后台任务慢慢写。5. 上线必踩的坑记忆污染、幻觉写入与并发竞态5.1 记忆污染模型把一次性噪声当成了长期事实第一个坑我踩得最惨。当时规则是对话结束后把整段对话存进记忆表结果用户随口说了一句我最近在学法语被当成长期偏好存进了语义记忆。之后Agent在完全无关的场景里频繁推荐法语学习资源用户被烦到差点弃用。完整排查链路是这样的先在语义记忆表里查最新记录发现一条用户正在学法语的事实再去查这条记录的产生时间确认它来自某次闲聊回看那次对话的原始日志发现用户只是在给朋友介绍业余爱好根本没有要求Agent长期服务这个需求最后定位根因——写入策略没有区分闲聊和明确表达偏好。修复方案是把写入时机改成抽取式。不是整段对话入库而是先让模型抽取候选事实再判断候选事实是否属于长期偏好同时加上两次确认机制。从那以后语义记忆表的噪声记录肉眼可见地减少了。5.2 幻觉式写入LLM总结事件时会脑补第二个坑是LLM总结事件时的幻觉式脑补。用户说这版回复很好但下次不要用太多列表LLM抽取时总结成用户不喜欢列表再过一轮可能升级成用户要求所有输出都用段落。这种逐级放大会让记忆系统变得非常不可信。我的对策有三条。第一抽取prompt里强制要求evidence字段事实必须引用对话原文没有原文证据就不准输出。第二给每条事实加一个confidence字段证据不充分的置信度就低检索排序时权重自动降低。第三对置信度低但影响大的语义记忆提供人工确认入口也就是所谓的记忆审核后台。第三个对策看起来最重但很有必要。记忆系统一旦上线模型的决策就会依赖记忆如果记忆本身是幻觉后面所有推理都建立在沙地上。这个坑难的不是技术而是承认LLM总结不可信这个前提。5.3 并发写入竞态两个会话同时写同一条记忆ai agent怎么扛并发这个搜索词其实也出现在我的实战范围里。用户经常同时开多个会话两个会话里的Agent可能同时抽取到同一条事实比如用户偏好简洁回答然后同时写入结果语义记忆表里出现两条几乎一样的记录。更坏的是两个会话抽到的内容互相矛盾后写的直接覆盖先写的用户觉得Agent变了一个人。排查链路基本是这样的先在语义记忆表里按事实内容做group by发现大量重复记录再看写入时间和会话ID确认是并发会话同时写入最后确认数据库的upsert条件只用了自增id没有对事实内容用户建唯一约束。修复分三步。第一给语义记忆表加唯一索引比如(user_id, fact_normalized)。第二写入使用insert ... on conflict do update。第三冲突时先比较两条候选记录的置信度和证据条数保留置信度高的而不是简单后写覆盖。程序性记忆的并发注册也要做类似处理比如技能签名唯一约束避免同一个技能被注册多份。5.4 成本与延迟记忆不能成为主链路的负担还有一个每天都在发生的隐性坑成本。如果每次Agent请求都把四层记忆全部检索一遍embedding调用和token消耗会占掉账单的大头。我的经验是三层控制第一用路由函数减少无效召回比如普通闲聊根本不用查语义记忆第二给记忆检索加缓存常见问题对应的召回结果缓存10分钟重复问题直接命中缓存第三把压缩和抽取放到异步任务里主链路只做最轻量的top-k召回。另外记忆表要定期优化索引。实测中情景记忆表到几十万条之后没有user_id加created_at复合索引的查询会明显变慢。数据量上来以后这类结构性问题比模型调参更影响体验。6. 怎么证明分层记忆真的有用离线评测与线上指标6.1 离线评测构建一份记忆问答集记忆系统最容易陷入自嗨。代码写完了效果好不好全靠感觉。我的建议是构建一份离线评测集从真实会话历史里挑出一批跨会话提问比如上次用户提到不喜欢什么用户项目A用的什么框架上个月那份报告里写了哪几个部分。每个问题配标准答案和对应的记忆类型。然后跑评测脚本对每个问题执行分层记忆的写入与检索全流程看Agent的回答是否命中正确答案统计三个指标指标含义作用召回率标准答案对应的记忆是否被检索到检验检索层准确率检索到的记忆里有多少与问题相关检验重排与路由事实一致性Agent基于记忆的回答是否与标准事实一致检验记忆写入质量我建议把评测脚本写进CI。每次改写入策略或检索权重都跑一遍防止修好一个坑、打崩一片回忆。6.2 线上指标别只看像不像有记忆线上指标主要盯四个。一是跨会话任务完成率用户第二次提到同一件事时Agent能不能直接续做而不是重新询问。二是重复提问率用户问过的问题是否还会再问这个指标降下来说明记忆在起作用。三是用户纠错率Agent犯错后用户纠正的频率如果引入记忆后不降反升多半是记忆污染已经出现了。四是平均会话轮次有记忆的Agent应该能更快收敛。注意第四个指标是双刃剑轮次减少也可能是因为Agent直接翻出历史答案应付所以要结合用户满意度一起看。好的记忆系统不只是让对话变短而是让对话变准。6.3 A/B测试的要点对照组要真无记忆很多人做A/B测试对照组其实也带了上下文窗口的记忆结果差异不明显就得出记忆没用的结论。这是不对的。真正的无记忆对照组应该每次都清空工作记忆之外的记忆层只保留系统提示词和当前对话。测试用例也一定要选跨会话场景否则根本测不出长期记忆的差异。我实测过一组数据在同样的跨会话任务上使用分层记忆后任务完成率提升了大约15到20个百分点平均会话轮次下降了约30%。最明显的改善不是模型变聪明了而是它不再重复问已经回答过的问题。这件事听起来基础但对用户体感来说提升非常大。6.4 一个常见的评估误区把记住刚才当成了有记忆系统最后提醒一句单轮对话里模型能记住之前说的内容那是上下文窗口的功劳不是记忆系统的功劳。真正的分层记忆评价一定要落在跨会话、跨天、跨任务上。如果一个评测集里所有问题都在同一个上下文窗口内那测出来的数字只能说明Prompt Engineering做得好不能说明记忆系统有效。这就是我把工作记忆单独列入记忆系统的原因。它是记忆体系的入口但绝不是全部。很多人以为Agent能记住我刚才说的话就够了等到真正做长线任务、个性化服务、多会话共享的时候才知道分层记忆的价值。做Agent开发这一年多我最大的体会是分层记忆的核心难点不在技术选型而在取舍。哪些信息该进工作记忆、哪些该沉淀成情景记忆、哪些要抽成用户事实、哪些干脆就该遗忘这些决策比模型本身更能决定Agent的上限。别急着堆功能先把最小闭环跑通结构化Buffer管工作记忆、一张表管情景记忆、LLM抽事实管语义记忆等数据量上来了再谈技能固化和大规模检索。最后分享一个排查问题的小技巧给每条记忆记录source_session和confidence两个字段将来遇到Agent为什么说出这么离谱的话时能顺着这两个字段在半天内找到根因省下的时间绝对值得。
返回列表