【AI】Agent 全栈进阶|Agent 记忆机制

【AI】Agent 全栈进阶|Agent 记忆机制
◆ 博主名称 QuZhengRongAI俘虏样式苦手⭐️ Agent专栏 Agent⭐️ LuckReport专栏 LuckReport⭐️ SpringBoot专栏 SpringBoot目录一、Agent 的失忆症二、短期记忆三、长期记忆写入过筛、按需召回、用完更新四、总结五、LuckReport 项目推荐1、项目简介2、在线体验到了研究 Agent 记忆系统的时候了其实上一篇已经提到过 Agent 记忆的雏形Plan-and-Execute 里 Executor 每一步都带着已完成步骤的结果列表那就是最朴素的工作记忆。但真实场景比调研任务复杂得多当对话轮数达到几十轮时大模型的上下文窗口可能会溢出再和大模型对话模型可能会直接抛出异常。这一篇讲 Agent 的记忆机制怎么管理 Agent 的短期记忆长期记忆一、Agent 的失忆症以勇哥的场景为例说明模型的两种失忆情况第一类会话内忘事。粉丝第一轮就说了预算 40 万、想开面馆。聊到十几轮之后问你还是按我最初说的那个预算帮我算算助手开始反问“请问您的预算大概是多少”。这是早期信息被挤出上下文窗口丢失了数据。第二类跨会话忘事。粉丝昨天花了一小时跟助手聊清楚选址、预算、品类今天开个新会话接着聊助手第一句话是你好请问想咨询什么。它不记得昨天的对话对于模型来说记忆只有上下文窗口这一块当前的窗口就是它的全部世界要让模型记住东西就得往上下文窗口里追加数据。那是不是可以把聊天记录全存起来对话的时候再全塞进去呢在对话轮数少的情况下是可以这么做的但如果对话内容过多超出了模型上下文那就得考虑压缩消息了。所以记忆机制要解决的不是存多少的问题而是存什么的问题。常见的记忆有以下两种类型存储位置存活时间作用短期记忆上下文窗口messages 列表当前会话记住当前对话的来龙去脉长期记忆外部存储数据库/文件跨会话记住用户的情况和偏好二、短期记忆短期记忆的本质就是每次发给模型的 messages 列表System Prompt 对话历史 工具调用记录。所谓短期记忆管理就是管这个列表里什么进、什么出、什么被压缩。短期记忆管理的三板斧1、滑动窗口只保留最近 N 轮对话超出的直接丢。最简单但丢得最狠。2、摘要压缩丢之前先让模型把早期对话压成一段摘要关键信息以摘要形式存活。3、选择性保留核心约束预算、品类和工具的关键结果单独存结构化数据不走有损的压缩通道。实战中前面两个经常一起用窗口内的保留原文窗口外的压成摘要。代码演示这套组合沿用前面几篇的.env配置# 短期记忆滑动窗口 摘要压缩演示聊了十几轮后第一轮说的预算还在# 主题勇哥说餐饮——粉丝咨询开店早期聊定的预算和品类到后期还能被想起fromdotenvimportload_dotenvimportosfromlangchain_openaiimportChatOpenAIfromlangchain_core.messagesimportSystemMessage,HumanMessage,AIMessage load_dotenv()llmChatOpenAI(modelqwen3.7-max-2026-06-08,api_keyos.getenv(API_KEY),base_urlos.getenv(BASE_URL))SYSTEM_PROMPT你是抖音勇哥餐饮创业说的勇哥说话直接、不留情面。 粉丝咨询开店问题按你的方法论给建议 房租别超营业额 15%、人工别超 20%、食材别超 30%超标就是给房东和员工打工。# 1. 模拟一段已经聊了 10 轮的咨询真实场景里这些消息是一轮轮攒出来的history[(粉丝,勇哥你好我准备开店预算总共 40 万帮我参谋参谋),(勇哥,40 万在深圳开小店够呛但能做一分不能乱花。你想做什么品类),(粉丝,想做面馆家里传下来的手擀面手艺),(勇哥,面馆品类不踩坑但手擀面人工重人工成本得先想好怎么压),(粉丝,我看中南山区一个 100 平米的铺子),(勇哥,南山租金不便宜100 平米月租奔 3 万营业额得做到 20 万往上才扛得住),(粉丝,那我把面积砍到 60 平米呢),(勇哥,砍面积是对的房租压力小一半先活下来再谈扩张),(粉丝,装修我想搞点网红风格多花点钱),(勇哥,装修别超总预算两成网红风半年就过气钱要花在后厨设备上),]# 2. 滑动窗口只保留最近 4 条消息2 轮对话其余的等待压缩KEEP_RECENT4defcompress_messages(dropped:list,old_summary:str)-str:把被挤出窗口的早期消息压缩成摘要并与旧摘要合并。 Args: dropped: 被滑动窗口挤出的早期消息列表 old_summary: 之前已有的摘要压缩时一并带上防止信息断层 Returns: 合并后的新摘要文本 # 拼成角色: 内容的对话稿喂给模型压缩transcript\n.join(f{粉丝ifisinstance(m,HumanMessage)else勇哥}:{m.content}formindropped)promptf把下面这段餐饮开店咨询对话压缩成不超过 150 字的要点摘要。 要求预算、面积、品类、城市、已经聊定的决策必须保留寒暄和客套全部丢掉。 已有摘要把新内容合并进去仍不超过 150 字{old_summaryor无}对话内容{transcript}returnllm.invoke(prompt).contentdeftrim_memory(messages:list,summary:str)-tuple:整理短期记忆超窗的早期消息压缩进摘要返回裁剪后的消息列表和新摘要。 Args: messages: 当前完整消息列表第一条必须是 System Prompt summary: 当前已有的事件摘要 Returns: (裁剪后的消息列表, 更新后的摘要) # System Prompt 永远保留参与裁剪的只有对话部分system_msgmessages[0]chatmessages[1:]# 对话条数没超窗口不用动iflen(chat)KEEP_RECENT:returnmessages,summary# 窗口外的压进摘要窗口内的保留原文dropped,keptchat[:-KEEP_RECENT],chat[-KEEP_RECENT:]new_summarycompress_messages(dropped,summary)# 摘要作为一条 SystemMessage 续在后面保证模型每一轮都看得见summary_msgSystemMessage(contentf【之前对话的摘要】{new_summary})return[system_msg,summary_msg]kept,new_summary# 3. 组装消息并整理一次记忆看窗口外的 6 条消息去哪了messages[SystemMessage(contentSYSTEM_PROMPT)]forwho,textinhistory:messages.append(HumanMessage(contenttext)ifwho粉丝elseAIMessage(contenttext))messages,summarytrim_memory(messages,)print(f 压缩后的摘要 \n{summary}\n)print(f 实际发给模型的消息共{len(messages)}条)forminmessages:contentm.contentiflen(m.content)40elsem.content[:40]……print(f[{type(m).__name__}]{content})# 4. 第 11 轮提问预算只在被压缩掉的早期对话里出现过messages.append(HumanMessage(content勇哥你还是按我最开始说的那个预算帮我算算 60 平米的店这钱怎么分配))responsellm.invoke(messages)print(f\n 第 11 轮回答 \n{response.content})运行输出 压缩后的摘要 粉丝预算 40 万在深圳开店已定品类为面馆家传手擀面。原看中南山区 100 平米铺子因租金压力决定砍到 60 平米。装修预算控制在总预算两成内重点花钱在后厨设备。 实际发给模型的消息共 6 条 [SystemMessage] 你是抖音勇哥餐饮创业说的勇哥说话直接、不留情面…… [SystemMessage] 【之前对话的摘要】粉丝预算 40 万在深圳开店已定品类为面馆…… [HumanMessage] 装修我想搞点网红风格多花点钱 [AIMessage] 装修别超总预算两成网红风半年就过气…… [HumanMessage] 勇哥你还是按我最开始说的那个预算帮我算算 60 平米的店这钱怎么分配 第 11 轮回答 40 万这么分铺面押金转让加首期房租留 12 万装修 8 万封顶别超两成后厨设备 10 万必须到位首批物料 3 万剩下 7 万是活命钱撑过前三个月爬坡期……第一轮说的40 万原始消息早就不在列表里了但通过摘要活了下来第 11 轮照样能引用。注意点1、不要压缩 System Prompt。它是行为准则丢了人设就崩了裁剪只动对话部分。2、摘要要合并不要覆盖。每次压缩都把旧摘要带上一起合并否则多轮压缩后早期信息会一层层蒸发。3、关键信息别赌摘要质量。摘要终究是有损压缩预算、品类这种核心约束写入时就单独存一份结构化数据更稳——这正是下一节长期记忆要干的事。三、长期记忆写入过筛、按需召回、用完更新短期记忆只是会话级的只在会话内生效。跨会话记住用户的情况要靠长期记忆把值得记的事实存到外部新会话开始时取出来拼回上下文。长期记忆的存储看重筛选。下面有两条粉丝发言“我最近在研究奶茶加盟感觉挺火的”——临时起意下周可能就变了“我在深圳南山开了家 60 平米的面馆上个月刚开业”——稳定事实以后每次咨询都用得上第一条记下来就是噪音第二条才值得进长期记忆。可以订制下面三条写入规矩1、粉丝明确说过的才算。模型推测出来的粉丝可能预算有限不算数。2、半年后还有用的才算。预算、城市、品类、经营现状算今天心情好不好不算。3、隐私敏感的不记。手机号、身份证、银行卡这类信息一律不进记忆库。长期记忆的三件套写入、存储、召回。用 JSON 文件做一个最小实现# 长期记忆三件套写入过筛 - 结构化存储 - 按需召回演示跨会话记住粉丝情况# 主题勇哥说餐饮——粉丝第一次咨询完第二次来不用重新自我介绍fromdotenvimportload_dotenvimportosimportjsonfromdatetimeimportdatefromlangchain_openaiimportChatOpenAIfromlangchain_core.messagesimportSystemMessage,HumanMessage,AIMessagefrompydanticimportBaseModel,Field load_dotenv()llmChatOpenAI(modelqwen3.7-max-2026-06-08,api_keyos.getenv(API_KEY),base_urlos.getenv(BASE_URL))SYSTEM_PROMPT你是抖音勇哥餐饮创业说的勇哥说话直接、不留情面。 粉丝咨询开店问题按你的方法论给建议 房租别超营业额 15%、人工别超 20%、食材别超 30%超标就是给房东和员工打工。# 记忆文件落在脚本同目录从任何位置运行都不会丢MEMORY_FILEos.path.join(os.path.dirname(os.path.abspath(__file__)),fan_memory.json)# 1. 记忆条目结构一条记忆 类型 一句话事实 记录日期# kind 用来做覆盖式更新粉丝改口了同类型新事实直接替换旧事实不无限堆积# 注意noted_at 由程序写入不劳烦模型填模型只负责提取事实本身classMemoryNote(BaseModel):kind:strField(description记忆类型从这几个里选开店计划/经营现状/咨询结论)content:strField(description一句话事实必须是粉丝明确说过的)classMemoryNotes(BaseModel):notes:list[MemoryNote]Field(description提取出的记忆条目列表没有值得记的就返回空列表)# 2. 写入过筛三条规矩写进提示词让模型当守门员EXTRACT_PROMPT从下面这段咨询对话里提取值得长期记住的事实守三条规矩 1. 粉丝明确说过的才算你推测的不算 2. 半年以后还有用的才算开店计划、预算、城市、品类、经营现状临时闲聊不算 3. 手机号、身份证这类隐私信息一律不记 没有值得记的就返回空列表。defextract_memories(conversation:list)-list[MemoryNote]:从一段对话中过筛提取值得长期记住的事实。 Args: conversation: 消息列表HumanMessage / AIMessage Returns: 提取出的 MemoryNote 条目列表可能为空 transcript\n.join(f{粉丝ifisinstance(m,HumanMessage)else勇哥}:{m.content}forminconversation)# 结构化输出保证提取结果是程序能直接落库的数据而不是一段自由文本returnllm.with_structured_output(MemoryNotes).invoke(f{EXTRACT_PROMPT}\n\n对话内容\n{transcript}).notesdefsave_memories(new_notes:list[MemoryNote])-None:把新记忆写入 JSON 文件同类型的新事实覆盖旧事实。 Args: new_notes: 本轮提取出的 MemoryNote 条目列表 # 文件不存在时初始化为空列表ifnotos.path.exists(MEMORY_FILE):withopen(MEMORY_FILE,w,encodingutf-8)asf:json.dump([],f,ensure_asciiFalse)withopen(MEMORY_FILE,r,encodingutf-8)asf:storedjson.load(f)# 覆盖式更新比如粉丝预算变了同 kind 旧条目直接替换防止新旧打架fornoteinnew_notes:record{kind:note.kind,content:note.content,noted_at:date.today().isoformat()}stored[mforminstoredifm[kind]!note.kind][record]withopen(MEMORY_FILE,w,encodingutf-8)asf:json.dump(stored,f,ensure_asciiFalse,indent2)defrecall_memories()-str:读取全部记忆条目拼成一段文字供拼进 System Prompt。 Returns: 拼好的记忆文本没有记忆时返回空字符串 ifnotos.path.exists(MEMORY_FILE):returnwithopen(MEMORY_FILE,r,encodingutf-8)asf:storedjson.load(f)ifnotstored:returnlines[f-{m[kind]}{m[content]}记录于{m[noted_at]}forminstored]return这位粉丝的已知情况\n\n.join(lines)# 3. 会话一第一次咨询模拟 4 条消息的对话结束后提取记忆入库chat_one[HumanMessage(content勇哥我在深圳南山开了家 60 平米的面馆上个月刚开业现在每天流水四千左右),AIMessage(content刚开业日流水四千不算差但要盯住三个占比房租、人工、食材别超 15%、20%、30%),HumanMessage(content记下了。我下一步想加外卖你觉得呢),AIMessage(content可以加但外卖平台抽成两成起步先把堂食单量稳住再谈),]notesextract_memories(chat_one)print( 会话一提取的记忆 )forninnotes:print(f-{n.kind}{n.content})save_memories(notes)# 4. 会话二新开会话先召回记忆拼进 System Prompt粉丝不用重新自我介绍memory_textrecall_memories()print(f\n 会话二的 System Prompt 末尾 \n{memory_text})messages[SystemMessage(contentSYSTEM_PROMPT\n\nmemory_text),HumanMessage(content勇哥我又来了上次说的外卖的事帮我细说说怎么起步),]responsellm.invoke(messages)print(f\n 会话二回答 \n{response.content})运行输出 会话一提取的记忆 - 经营现状粉丝在深圳南山经营 60 平米面馆上个月开业日流水约四千 - 咨询结论已建议加外卖前先稳住堂食单量注意外卖平台抽成两成起步 会话二的 System Prompt 末尾 这位粉丝的已知情况 - 经营现状粉丝在深圳南山经营 60 平米面馆上个月开业日流水约四千记录于 2026-08-24 - 咨询结论已建议加外卖前先稳住堂食单量注意外卖平台抽成两成起步记录于 2026-08-24 会话二回答 你那 60 平米的店刚开业一个月日流水四千的底子外卖别急着全量上。先算账平台抽成两成起步加上打包盒和满减一单的利润比堂食薄不少……会话二里粉丝一个字都没介绍自己但回答里60 平米、日流水四千都对上了这些信息来自 JSON 文件不是来自模型。这就是长期记忆记忆存储在外部每次对话按需注入模型只负责使用。注意点1、提取必须用结构化输出。MemoryNotes 约束了字段和取值提取结果是程序能直接落库的数据让模型自由发挥写一段话存进去就是没法维护的文本。2、同类型覆盖不做无限追加。粉丝预算从 40 万改成 50 万覆盖同 kind 的旧条目。只进不出的记忆库最后会变成新旧打架的垃圾堆——记忆系统要有更新和淘汰它不是只进不出的仓库。3、召回方式随规模升级。几十条记忆全量读出来拼进 Prompt 完全够用上千条之后就得换成向量检索——把记忆条目向量化按当前问题检索最相关的几条。技术还是 RAG 那一篇讲的那套只是库里放的从文档分块换成了记忆条目。四、总结短期记忆就是 messages 列表管理三板斧滑动窗口丢远、摘要压缩保关键、核心信息单独结构化保留。长期记忆是过筛后的事实库写入守规矩明确说过、长期有用、不碰隐私存储结构化按需召回拼回上下文同类型覆盖更新。到这里Agent 的能力扩展讲完了但还有最后一道坎——Demo 跑得再顺生产环境里它照样会死循环、会调错工具、会越权执行。下一篇讲 Agent 工程化与兜底。五、LuckReport 项目推荐导航LuckReport专栏1、项目简介Luck-Report 是一款基于开源项目 UReport2 重构的 Java 高性能报表引擎通过迭代单元格可以实现任意复杂的中国式报表。相较于 UReport2在技术架构上进行了全新升级后端基于 SpringBoot 框架开发、前端采用 Vue 框架构建技术选型贴合当下主流项目开发标准可精准适配各类实际开发需求。Luck-Report 提供了全新的基于网页的报表设计器可以在 Chrome、Firefox、Edge 等各种主流浏览器运行IE 浏览器除外。使用 Luck-Report打开浏览器即可完成各种复杂报表的设计制作。Luck-Report 基于 Apache-2.0 开源协议开源2、在线体验体验地址https://www.quzhe.top/luck-report/report/designer源码地址https://gitee.com/LuckyPools/luck-report文档地址https://www.quzhe.top/luck-report-blog/report

最新新闻

日新闻

周新闻

月新闻