乐于分享
好东西不私藏

跟我学 AI Agent : 第 10 课 — Memory Management:让Agent拥有记忆

跟我学 AI Agent : 第 10 课 — Memory Management:让Agent拥有记忆

模块: 模块三:高级编排与规划 | 难度: ⭐⭐⭐ | 预计时长: 3.0 小时参考: Agentic Design Patterns: A Hands-On Guide to Building Intelligent Systemspi-mono API: AgentState, transformContext()


🧠 “记忆是智慧之母。”— 埃斯库罗斯


1. 开篇故事:每天重新认识你的朋友

想象你有这样一个朋友——

每天早上见面,他都不记得你是谁。

“你好,请问你叫什么名字?”“我叫Jack啊,我们昨天聊了一整天……”“是吗?那请问你是做什么工作的?”“我是程序员,昨天跟你说过三遍了。”“哦,那你用什么操作系统?”“macOS。我昨天还帮你调试了一个shell脚本……”

你每次对话,都要从零开始:重新介绍自己、重新解释背景、重新说明偏好。一天下来,你花了80%的时间在重复昨天说过的话,只有20%在做真正有意义的事。

这个”健忘的朋友”,就是没有记忆的 Agent

现在换一个场景:你有一个同事,你们合作了三年。他知道你用 macOS、偏好 TypeScript、讨厌深嵌套的代码、最近在做一个 Agent 课程项目。你只需说”继续昨天的进度”,他就能从上次断点无缝接上。

这个”默契的老搭档”,就是拥有记忆的 Agent

从”健忘的朋友”到”默契的老搭档”,中间差的不是智商,而是记忆

💡 核心问题: LLM 本身是无状态的——同样的 prompt 永远给出同样的回答。Agent 如何跨越”无状态”的鸿沟,获得持续记忆的能力?


2. 为什么记忆对 LLM Agent 至关重要

在深入技术细节之前,我们需要先理解一个根本性的矛盾:LLM 的能力很强,但持续性为零。

2.1 LLM 的”无状态”本质

LLM 是一个纯函数:输入 prompt,输出 completion。它没有内部状态,没有”上次对话”的概念。

你: "我叫Jack"LLM: "你好Jack!很高兴认识你!"(新对话)你: "我叫什么名字?"LLM: "抱歉,我不知道你的名字。"

这不是 bug,而是 LLM 的设计本质。每次调用都是独立的,模型权重在两次调用之间不会改变。就像一个超级博学但每次见面都失忆的教授——他什么都知道,就是不记得跟你说过什么。

那现有的 Agent 是怎么”记住”对话的?靠的是把历史消息拼进 prompt

[System] 你是一个助手[User] 我叫Jack[Assistant] 你好Jack![User] 我叫什么名字?    ← LLM 能回答,因为历史消息在 prompt 里[Assistant] 你叫Jack。

这就是所谓的短期记忆——把对话历史塞进 Context Window。它有效,但有致命的限制。

2.2 短期记忆的天花板

Context Window 就像一个固定大小的白板:

模型
Context Window
大约能存
GPT-4o
128K tokens
~300页文本
Claude Sonnet
200K tokens
~500页文本
Gemini 2.5 Pro
1M tokens
~2500页文本

看起来很大?但现实是:

问题一:Token 成本

每轮对话都要把全部历史重新发给 LLM。对话越长,token 消耗越大。一个 100 轮的对话,第 100 轮要付 100 轮的 token 费用。

第1轮:  [System] + [User1] + [Asst1]                     → 500 tokens第10轮: [System] + [User1-10] + [Asst1-10]               → 5,000 tokens第50轮: [System] + [User1-50] + [Asst1-50]               → 25,000 tokens第100轮: [System] + [User1-100] + [Asst1-100]            → 50,000 tokens                                                          ↑ 成本线性增长

问题二:信息稀释(Lost in the Middle)

研究表明(Context Engineering:长上下文是如何失效的?),LLM 对超长上下文中的信息提取能力并非均匀分布。它对开头结尾的信息关注度高,对中间的信息经常”视而不见”。斯坦福大学 2023 年的研究 “Lost in the Middle” 发现:当上下文中有大量文档时,放在中间位置的文档被正确引用的概率显著下降。

这意味着:往 Context Window 塞太多信息,不仅没有帮助,反而会降低回答质量。

问题三:会话结束即遗忘

Context Window 是临时的。会话一旦结束,白板被擦干净。下次新对话,一切从零开始。

会话A(周一):  你: "我喜欢TypeScript,用macOS"  Agent: "记住了!"  (会话结束 → 历史消失)会话B(周三):  你: "帮我写一个脚本"  Agent: "好的,你用什么语言?什么系统?"  ← 完全不记得了

这三个问题叠加在一起,揭示了一个核心矛盾:Context Window 是必要的短期记忆,但它远远不够。

2.3 没有记忆的 Agent 到底差在哪里?

让我们用具体场景来感受差异:

场景一:客服机器人

没有记忆:

用户: "我的订单 #12345 还没到"Agent: "让我查一下……订单 #12345,状态:运输中,预计明天到达。"用户: "那我明天不在家怎么办?"Agent: "您是说要改配送地址吗?请问您的订单号是多少?"  ← 忘了

有记忆(短期):

用户: "我的订单 #12345 还没到"Agent: "订单 #12345 运输中,预计明天到。"用户: "那我明天不在家怎么办?"Agent: "您要改配送时间或地址吗?我可以帮您安排后天配送。"

有记忆(长期):

用户: "我的订单还没到"Agent: "您是说订单 #12345 吗?按您之前的偏好,我帮您安排放在门口快递柜。需要我联系快递员吗?"                    ↑ 记住了订单号 + 配送偏好

场景二:编程助手

没有记忆:

程序员: "我的项目用 TypeScript + Node.js,包管理器是 pnpm"Agent: "了解了!"(新会话)程序员: "帮我加一个新 API 端点"Agent: "好的,请问你的项目用什么语言?包管理器是npm还是yarn?测试框架用的什么?"       ← 每次都要重新交代项目背景

有记忆:

程序员: "帮我加一个新 API 端点"Agent: "基于你的 TypeScript + pnpm 项目结构,我建议在 src/routes/ 下新增文件。        要不要我按照你之前偏好的模式:Zod schema 验证 + Express Router?"                                 ↑ 记住了技术栈 + 编码风格偏好

场景三:多步骤任务

一个数据分析 Agent 需要完成:获取数据 → 清洗 → 分析 → 生成报告。

没有记忆:

步骤1完成 → 结果丢失步骤2: "请问数据源在哪里?格式是什么?"  ← 不记得步骤1的输出

有短期记忆(但任务跨会话):

周一:完成数据获取和清洗(会话结束)周二:继续分析 → "上次清洗的中间结果呢?"  ← 会话间没有记忆传递

有长期记忆:

周一:完成获取和清洗,中间结果存入记忆周二:Agent 自动调取上次的清洗结果,从分析步骤继续

2.4 记忆:从”工具”到”伙伴”的分水岭

把上面的观察归纳起来,记忆对 Agent 的影响不只是”方便”,而是质变

维度
没有记忆
有记忆
对话能力
单轮问答
多轮连贯对话
个性化
千人一面
了解用户偏好,因人而异
任务深度
一次性简单任务
多步骤、跨会话复杂任务
学习能力
不会进步
从错误中学习,持续优化
用户体验
每次都要重复背景
“它懂我”的默契感

Antonio Gulli 用一句话概括了这个洞察:

“Without a memory mechanism, agents are stateless, unable to maintain conversational context, learn from experience, or personalize responses for users. This fundamentally limits them to simple, one-shot interactions.”

翻译过来:没有记忆,Agent 永远被锁在”一次性工具”的牢笼里。

记忆不是锦上添花的功能,而是 Agent 从”工具”进化为”伙伴”的基础设施。就像数据库之于 Web 应用——你不会说”数据库是 Web 应用的可选功能”,因为没有了它,应用连基本的用户登录都做不了。

理解了”为什么”之后,接下来的问题就是”怎么做”——如何为 Agent 构建一套完整的记忆系统?这正是下一节要回答的。


3. 记忆的三层模型

人的记忆不是一坨混沌的东西。认知科学告诉我们,人脑至少有工作记忆(你此刻在想的)、短期记忆(今天发生的事)和长期记忆(你的知识和经验)。

Agent 的记忆也可以类比为三层结构,从最临时的到最持久的:

记忆可以分为短期和长期两大类,但实践中,中期记忆(Session State) 承上启下,不可或缺。下面逐层拆解。

3.1 第一层:短期记忆(Context Window)

是什么: 当前对话中所有消息的集合——用户输入、Agent 回复、工具调用和返回结果。就是 LLM 每次调用时收到的 messages 数组。

类比人脑: 工作记忆(Working Memory)。你此刻脑中能同时处理的信息量,大约 7±2 个”组块”。

核心限制:

限制
说明
Token 上限
即使百万 token 的窗口,也会被长对话填满
成本递增
每轮调用都要重发全部历史,token 消耗线性增长
信息稀释
上下文越长,LLM 对中间信息的关注度越低(Lost in the Middle)
临时性
会话结束,记忆消失

管理策略:

  1. 1. 滑动窗口(Sliding Window):只保留最近 N 轮对话,丢弃更早的。简单粗暴,但会丢失重要信息。
保留最近 10 轮:[User_11] [Asst_11] [User_12] [Asst_12] ... [User_20] [Asst_20]                                              ↑ 最早的 10 轮被丢弃
  1. 2. 摘要压缩(Summarization):用 LLM 把旧对话压缩成一段摘要,替代原始消息。这是最常用的策略。
压缩前(2000 tokens 的旧对话):  [User] "我叫Jack" → [Asst] "你好Jack"  [User] "我用macOS" → [Asst] "了解了"  [User] "帮我写个脚本" → [Asst] "好的,什么语言?"  ... (20轮)压缩后(200 tokens):  [Summary] "用户叫Jack,使用macOS。之前讨论了编写shell脚本,             使用TypeScript + Node.js,包管理器pnpm。"当前对话:  [Summary] + [最近5轮原始消息]
  1. 3. 关键信息提取:从对话中识别并提取关键实体(人名、偏好、决策),单独存储,确保不丢失。

这三种策略不是互斥的,而是组合使用的。后面代码实战会看到具体实现。

3.2 第二层:中期记忆(Session State)

是什么: 跨多个轮次但仍在同一个任务/会话中的结构化状态。它不是对话历史的简单堆叠,而是经过组织的任务数据

类比人脑: 你在做一道复杂数学题时,草稿纸上记的中间步骤、当前算到哪一步、用的是什么方法。

典型存储内容:

Session State(key-value 字典):{  "task_phase": "data_cleaning",           // 当前任务阶段  "steps_completed": ["fetch", "parse"],   // 已完成步骤  "steps_remaining": ["clean", "analyze"], // 剩余步骤  "data_source": "users.csv",             // 数据源  "row_count": 15432,                     // 中间结果  "user_preference_lang": "python",       // 临时偏好  "errors_encountered": ["encoding: utf-8 fail, fallback to latin-1"]}

与短期记忆的区别:

维度
短期记忆(Context Window)
中期记忆(Session State)
数据格式
原始消息序列(对话文本)
结构化 key-value
访问方式
LLM 自动读取
代码主动读写
信息类型
自然语言
程序化数据
成本
每轮 token 消耗
几乎为零(本地存储)
适用场景
维持对话连贯性
追踪任务状态和进度

为什么需要中期记忆? 因为不是所有信息都适合用自然语言对话来传递。比如”当前已完成第3步,共7步”——这种结构化状态用 key-value 存取远比塞进对话历史高效且准确。

Google ADK 的 session.state 就是典型的中期记忆实现,它使用前缀来区分数据作用域:

  • • 无前缀:会话级别,仅当前对话可见
  • • user: 前缀:用户级别,该用户的所有会话共享
  • • app: 前缀:应用级别,所有用户共享
  • • temp: 前缀:仅当前处理轮次,不持久化

3.3 第三层:长期记忆(Persistent Store)

是什么: 跨会话、跨任务的持久化知识。会话结束了它还在,下次新对话时可以调取。

类比人脑: 你的知识、经历和技能——不会因为你睡了一觉就消失。

长期记忆可以进一步分为三种类型,借用认知心理学的经典分类:

语义记忆(Semantic Memory)—— “知道什么”

记住事实和知识。就像你知道”巴黎是法国的首都”,Agent 需要知道关于用户的客观事实。

语义记忆示例:{  "user_profile": {    "name": "Jack",    "language": "Chinese",    "os": "macOS",    "tech_stack": ["TypeScript", "Node.js", "pnpm"],    "preferences": {      "code_style": "函数式,避免深嵌套",      "response_length": "详细"    }  }}

存储方式: 通常是 JSON 文档或向量嵌入。可以是一个持续更新的”用户档案”,也可以是一组独立的事实文档。

检索方式: 直接读取(用户档案)或语义搜索(事实集合)。

pi-mono 场景: Agent 的 persona 配置、用户 profile 文件。

情节记忆(Episodic Memory)—— “经历过什么”

记住过去的经历和事件。就像你记得”上次去巴黎吃了家很好的可丽饼店”,Agent 需要记住过去的交互经历。

情节记忆示例:{  "past_experiences": [    {      "date": "2026-04-20",      "task": "解析CSV文件",      "approach": "使用csv-parser库,逐行处理",      "outcome": "成功,但遇到编码问题",      "lesson": "大文件先检测编码再读取"    },    {      "date": "2026-04-21",      "task": "部署到Cloudflare",      "approach": "wrangler deploy",      "outcome": "成功",      "lesson": "部署前检查wrangler.toml的compatibility_date"    }  ]}

情节记忆在实践中经常通过Few-shot 示例来实现:Agent 回忆过去成功的交互序列,作为当前任务的参考。这与 L04(Reflection)模式天然配合——反思的结果存入情节记忆,下次遇到类似情况时调取。

存储方式: 向量数据库(按语义相似度检索相关经历)或时间序列数据库(按时间检索)。

检索方式: “当前任务与过去哪些经历最相似?” → 语义搜索 Top-K。

pi-mono 场景: 过去任务的执行记录和反思日志。

程序记忆(Procedural Memory)—— “怎么做”

记住行为规则和技能。就像你学会了骑自行车之后不需要再思考每一步怎么做,Agent 需要记住”在什么情况下应该采取什么行动”。

程序记忆示例:{  "rules": [    "当用户报告bug时,先查看日志再建议解决方案",    "生成代码后自动运行lint检查",    "遇到API错误时,先检查密钥是否过期",    "用户说'简单说一下'时,回复控制在3句话以内"  ]}

程序记忆最有趣的地方在于:它可以是 Agent 自己修改的。一种强大的技术——让 Agent 通过”反思”来更新自己的行为规则:

1. Agent 用当前的程序记忆(规则集)处理任务2. 任务完成后,反思执行过程:"哪些规则有效?哪些需要修改?"3. 生成新的/修改后的规则4. 将更新后的规则存回程序记忆

这其实就是 L07(Reflection)模式在记忆层面的应用——Agent 不仅反思任务结果,还反思自己的行为模式,并据此优化规则。

存储方式: 通常存放在 Agent 的 system prompt 中,或独立的规则文件。

检索方式: 每次对话开始时,加载为 Agent 的行为指令。

pi-mono 场景: Agent 的 instruction 模板 + 通过 extension 管理的行为规则。

三种长期记忆的对比

类型
记什么
类比
典型存储
典型检索
语义记忆
事实和知识
“巴黎是法国首都”
JSON 文档 / 向量库
直接读取 / 语义搜索
情节记忆
经历和事件
“上次在巴黎迷路了”
向量数据库
语义相似度搜索
程序记忆
行为规则
“过马路先看左边”
System prompt / 规则文件
指令加载

三者协同工作:语义记忆告诉 Agent “用户是谁”,情节记忆告诉 Agent “之前怎么做的”,程序记忆告诉 Agent “以后应该怎么做”。


4. 记忆的写入与检索

理解了记忆的三层结构之后,关键问题来了:什么时候写?怎么检索?怎么更新?

这是记忆系统最核心的工程问题,也是区分”玩具级记忆”和”生产级记忆”的关键。

4.1 什么时候写入记忆?

记忆写入有三种时机:

策略一:每轮对话后自动写入

用户每说一句话 → Agent 回复 → 自动提取关键信息 → 写入记忆

优点:不会遗漏。缺点:噪音太多。”你好”、”谢谢”这种消息也触发写入,会产生大量无用记忆。

策略二:任务完成后写入

完整任务结束 → 总结整个过程 → 提取经验教训 → 写入记忆

优点:记忆质量高,都是经过提炼的。缺点:如果任务中途崩溃,什么都记不住。

策略三:检测到重要信息时写入

每轮对话 → 判断"这条信息是否值得记住" → 是则写入,否则跳过

这是最常用的策略,但核心难题在于:谁来决定什么是”重要的”?

4.2 谁来决定写什么?(记忆提取策略)

这是实践中最容易被忽视、却最影响记忆质量的问题。

方案A:LLM 自己判断(In-context Extraction)

让 LLM 在每轮对话后,自己判断哪些信息值得记住:

给 LLM 的提取 prompt:"以下是一段对话。请提取出值得长期记住的信息: - 用户的事实性信息(姓名、偏好、背景) - 重要的决策或结论 - 有价值的经验教训 如果没有值得记住的信息,返回空列表。"对话内容:[User] "帮我用Python写个爬虫"[Asst] "好的,用requests还是scrapy?"[User] "我喜欢用requests,简单直接,不喜欢重框架"[Asst] "了解,那我给你写一个基于requests的..."LLM 提取结果:{  "fact": "用户偏好轻量级工具,不喜欢重框架",  "tech_preference": "Python HTTP 库偏好 requests > scrapy"}

优点:灵活,能捕捉隐含信息(”不喜欢重框架”这种偏好不会明确说出来,但 LLM 能理解)。缺点:不稳定——同样的对话,两次提取可能得到不同结果。成本也高(每轮额外一次 LLM 调用)。

方案B:规则驱动(Keyword/NER 提取)

用预定义的规则或命名实体识别(NER)来提取信息:

规则示例:- 检测到 "我叫XXX" / "我的名字是XXX" → 写入 user.name = XXX- 检测到 "我喜欢XXX" / "偏好XXX" → 写入 user.preferences- 检测到 "不要XXX" / "不喜欢XXX" → 写入 user.negative_preferences- 检测到错误报告 → 写入 error_log

优点:可靠、可预测、成本为零。缺点:死板,无法捕捉隐含信息。”我一般用TS写后端”不会匹配任何规则,但显然是有价值的偏好信息。

方案C:混合方案(推荐)

结合两种方案的优点:

第一层(规则):快速、零成本地捕获明确的结构化信息  → "我叫Jack" → name = "Jack"  → "订单号#12345" → current_order = "12345"第二层(LLM):定期(比如每5轮或任务关键节点)用 LLM 做深度提取  → 发现隐含偏好、经验教训、情绪状态写入策略:  - 规则捕获的 → 立即写入,高置信度  - LLM 提取的 → 写入前校验(与现有记忆比对,避免矛盾)

Google Vertex AI Memory Bank 用的就是类似的混合方案:它用 Gemini 模型异步分析对话历史,提取关键事实和偏好,然后与已有记忆比对,自动合并和解决矛盾。

4.3 怎么检索记忆?

检索是记忆的”另一半”。写了很多记忆但检索不到,等于没写。

检索方式一:关键词匹配

最简单的方式——在记忆库中搜索包含特定关键词的条目。

用户: "我上次那个CSV解析的问题解决了吗?"关键词提取: ["CSV", "解析", "问题"]记忆搜索: SELECT * FROM memories WHERE content LIKE '%CSV%' OR content LIKE '%解析%'

优点:快速、简单、无额外成本。缺点:语义鸿沟——搜”解析”找不到”数据清洗”,搜”bug”找不到”错误”。

检索方式二:语义相似度搜索(向量检索)

将记忆和查询都转化为向量(embedding),按语义相似度排序返回 Top-K 条。

用户: "我上次那个CSV解析的问题解决了吗?"查询向量: embed("CSV解析问题")记忆库搜索:  1. "处理 users.csv 时遇到编码错误" → 相似度 0.92 ✓  2. "使用 csv-parser 库逐行读取" → 相似度 0.85 ✓  3. "部署到 Cloudflare 的步骤" → 相似度 0.31 ✗返回 Top-2

推荐这种方式:”In vector databases, information is converted into numerical vectors and stored, enabling agents to retrieve data based on semantic similarity rather than exact keyword matches.”

检索参数的关键选择:

参数
说明
权衡
Top-K
返回最相似的 K 条记忆
K 太大→噪音多;K 太小→遗漏
相似度阈值
低于此阈值的记忆不返回
太高→召回不足;太低→精度下降
新鲜度权重
最近的记忆权重更高
适合偏好类信息;不适合事实类

Vertex AI RAG MemoryService 的默认配置是 Top-K=5,向量距离阈值=0.7,可以作为起步参考。

4.4 检索结果怎么注入?

检索到记忆后,怎么”喂”给 Agent?注入位置对效果影响很大。

方式一:拼入 System Prompt

[System]你是一个编程助手。以下是关于用户的已知信息:- 用户名:Jack- 技术栈:TypeScript, Node.js- 偏好:函数式编程风格- 过去遇到的问题:CSV编码问题(已解决)[User] 帮我写一个文件读取函数

优点:Agent 一开始就知道背景信息,贯穿整个对话。缺点:占用 System Prompt 空间;所有检索到的记忆都被同等对待,没有区分相关性。

方式二:作为独立消息注入

[System] 你是一个编程助手。[Memory] 以下记忆与当前问题相关:  - 用户偏好函数式编程风格  - 过去用 fs.readFileSync 出过性能问题[User] 帮我写一个文件读取函数

优点:结构清晰,可以动态调整注入内容。缺点:增加了一条额外消息,占 token。

方式三:作为 Tool 结果返回

[User] 帮我写一个文件读取函数[Tool Call] search_memory("文件读取")[Tool Result] 发现相关记忆:用户偏好函数式风格,过去用同步读取有性能问题[Assistant] 基于你的偏好,我用流式读取...

优点:按需检索,不浪费 token;检索结果与当前问题高度相关。缺点:需要额外一轮 LLM 调用来决定是否检索记忆。

实践中,方式一适合少量核心记忆(用户档案),方式二适合中等规模方式三适合大规模记忆库。可以组合使用。

4.5 记忆的衰减与更新

记忆不是一成不变的。用户说”我现在用 Python 了”,你不能还记着”用户用 TypeScript”。

三个核心问题:

问题一:旧记忆要淘汰吗?

不是所有旧记忆都应该保留。”用户今天心情不好”可能三天后就没意义了,但”用户名是 Jack”永远有效。

常见策略:

  • • 时间衰减:每条记忆带时间戳,检索时按 相似度 × 时间衰减因子 排序
  • • 访问计数:经常被命中的记忆保留,长期未命中的降级或归档
  • • 手动标注有效期:写入时标记过期时间(”临时偏好” vs “永久事实”)

问题二:矛盾信息怎么处理?

现有记忆: "用户使用 TypeScript"新信息: "我不用TypeScript了,转Rust了"

处理方式:

  • • 覆盖:直接用新信息替换旧信息(适合事实类)
  • • 追加:两条都保留,检索时按时间排序(适合经历类)
  • • 合并:LLM 自动合并两条记忆为一条综合信息

Vertex AI Memory Bank 会自动”consolidate new data and resolve contradictions”——这正是生产级记忆系统需要的能力。

问题三:记忆容量管理

长期记忆库不能无限增长。需要:

  • • 定期归档低频访问的记忆
  • • 合并重复或高度相似的记忆条目
  • • 为不同类型的记忆设置不同的容量上限

5. 记忆架构的模式

不同的应用场景需要不同的记忆架构。从简单到复杂,有四种经典模式:

模式A:无记忆(纯函数式)

输入 → LLM → 输出

每次调用独立,不保存任何状态。

适合场景: 一次性工具调用——翻译一句话、格式化一段代码、提取关键信息。用完即走,无需后续。

优点: 最简单、无状态、易测试、可水平扩展。缺点: 无法进行多轮对话。

// 模式A:无记忆的纯函数式 Agentimport { Agent } from"@mariozechner/pi-agent-core";import { getModel } from"@mariozechner/pi-ai";const translator = newAgent({name"translator",modelgetModel("minimax-cn""MiniMax-M2.7"),instruction"你是一个中英翻译器。直接输出翻译结果,不加解释。",});// 每次调用完全独立const result1 = await translator.prompt("翻译:今天天气不错");// "Nice weather today."const result2 = await translator.prompt("我上一句让你翻译了什么?");// "抱歉,我没有之前对话的记录。"  ← 完全无状态

模式B:会话记忆

保留当前会话的对话历史,但会话结束后记忆消失。

适合场景: 单次多轮对话——客服对话、一次性任务咨询、代码评审。

优点: 实现简单(框架自动管理 messages 数组),能满足大部分单次对话需求。缺点: 无法跨会话,无法积累用户知识。

// 模式B:会话记忆(messages 数组自动管理)const agent = newAgent({name"coding-assistant",modelgetModel("minimax-cn""MiniMax-M2.7"),instruction"你是一个编程助手。",});// pi-mono 的 Agent 自动维护 messages 历史const stream = agent.stream("我叫Jack,我用TypeScript");forawait (const event of stream) {if (event.type === "text") process.stdout.write(event.text);}// 同一个 Agent 实例内,上下文保持const stream2 = agent.stream("我叫什么名字?");// "你叫Jack。"  ← 记住了,但仅限当前会话

模式C:用户记忆

每个用户拥有独立的长期记忆档案。新会话开始时,加载用户档案注入 Agent。

适合场景: 个性化助手——私人 AI 助手、学习伴侣、健康顾问。

优点: 跨会话的个性化体验,Agent “认识”每个用户。缺点: 需要用户身份认证,需要持久化存储,隐私问题更复杂。

// 模式C:用户记忆(文件持久化)import { readFileSync, writeFileSync, existsSync } from"fs";// 用户档案路径constprofilePath = (userIdstring) => `./memory/profiles/${userId}.json`;// 加载用户档案functionloadUserProfile(userIdstring): Record<stringany> {if (!existsSync(profilePath(userId))) return {};returnJSON.parse(readFileSync(profilePath(userId), "utf-8"));}// 保存用户档案functionsaveUserProfile(userIdstringprofileRecord<stringany>) {writeFileSync(profilePath(userId), JSON.stringify(profile, null2));}// 新会话时,将用户档案注入 system promptconst userId = "user_jack";const profile = loadUserProfile(userId);const agent = newAgent({name"personal-assistant",modelgetModel("minimax-cn""MiniMax-M2.7"),instruction`你是一个私人AI助手。以下是关于用户的已知信息:${JSON.stringify(profile, null2)}请基于这些信息提供个性化服务。`,});// 对话结束后,提取并更新档案// (后面代码实战会展示完整的提取逻辑)

模式D:共享知识库

多个 Agent 共享同一个知识库,互相积累的知识可以交叉复用。

适合场景: 团队/企业 Agent——客服团队共享产品知识、研发团队共享技术文档。

优点: 知识复用效率最高,新 Agent 能立即”继承”已有知识。缺点: 权限管理复杂,记忆一致性需要额外处理(Agent A 的经验可能不适用于 Agent B 的场景)。

四种模式的选择指南

模式
记忆范围
实现复杂度
适用场景
A 无记忆
一次性工具
B 会话记忆
单次会话
⭐⭐
单次多轮对话
C 用户记忆
跨会话(用户级)
⭐⭐⭐
个性化助手
D 共享知识库
跨会话(全局)
⭐⭐⭐⭐
团队/企业

一个重要原则:从最简单的模式开始,只在有明确需求时才升级。 不要一上来就搭建模式D——大部分场景模式B或C就足够了。


6. 代码实战:用 pi-mono 构建三层记忆系统

理论讲够了,动手写代码。我们按照”先痛后爽”的教学思路,三层递进:

6.1 基础版:手动管理的对话历史 + key-value 状态

不依赖任何记忆框架,纯手写,理解底层机制。

// memory-basic.ts — 基础版:手动记忆管理import { Agent } from"@mariozechner/pi-agent-core";import { getModel } from"@mariozechner/pi-ai";// ========== 第一层:短期记忆(手动 messages 数组)==========constmessagesArray<{ rolestringcontentstring }> = [];// ========== 第二层:中期记忆(Session State)==========constsessionStateRecord<stringany> = {taskPhase"idle",stepsCompleted: [],};// ========== 第三层:长期记忆(JSON 文件模拟)==========constlongTermMemoryArray<{ contentstringtimestampstring }> = [];// 模拟向量检索:简单的关键词匹配functionsearchMemory(querystring, topK = 3): string[] {const keywords = query.toLowerCase().split(/\s+/);const scored = longTermMemory    .map((m) => {const matches = keywords.filter((kw) => m.content.toLowerCase().includes(kw)      ).length;return { memory: m, score: matches };    })    .filter((m) => m.score > 0)    .sort((a, b) => b.score - a.score)    .slice(0, topK);return scored.map((s) => s.memory.content);}// 将长期记忆注入 system promptfunctionbuildSystemPrompt(): string {const baseInstruction = `你是一个编程助手。请用中文回答。`;const recentMemories = longTermMemory.slice(-5); // 最近5条if (recentMemories.length === 0return baseInstruction;const memorySection = recentMemories    .map((m) =>`- ${m.content}`)    .join("\n");return`${baseInstruction}关于用户的已知信息:${memorySection}当前任务状态:${sessionState.taskPhase}已完成步骤:${sessionState.stepsCompleted.join(", ") || "无"}`;}// 模拟对话循环asyncfunctionchat(userInputstring): Promise<string> {// 1. 构建当前 prompt(含长期记忆)const systemPrompt = buildSystemPrompt();// 2. 组装 messages(短期记忆)  messages.push({ role"user"content: userInput });const agent = newAgent({name"memory-basic",modelgetModel("minimax-cn""MiniMax-M2.7"),instruction: systemPrompt,  });// 3. 调用 Agentlet response = "";const stream = agent.stream(userInput);forawait (const event of stream) {if (event.type === "text") response += event.text;  }// 4. 保存到短期记忆  messages.push({ role"assistant"content: response });// 5. 简单的规则驱动记忆提取extractMemory(userInput, response);return response;}// 规则驱动的记忆提取functionextractMemory(userInputstringresponsestring): void {construlesArray<{ patternRegExptemplatestring }> = [    {pattern/我叫(\S+)|我的名字是(\S+)/,template"用户名字:{match}",    },    {pattern/我用(.*?)系统|我的系统是(\S+)/,template"用户操作系统:{match}",    },    {pattern/我喜欢(.*?)风格|偏好(.*)/,template"用户偏好:{match}",    },  ];for (const rule of rules) {const match = userInput.match(rule.pattern);if (match) {const value = match[1] || match[2] || match[0];      longTermMemory.push({content: rule.template.replace("{match}", value),timestampnewDate().toISOString(),      });console.log(`[记忆写入] ${rule.template.replace("{match}", value)}`);    }  }}// ========== 测试 ==========asyncfunctionmain() {console.log("=== 基础版记忆系统测试 ===\n");// 第一轮:介绍自己let reply = awaitchat("你好,我叫Jack,我用macOS");console.log(`用户: 你好,我叫Jack,我用macOS`);console.log(`Agent: ${reply}\n`);// 第二轮:新会话模拟(清空短期记忆)  messages.length = 0;console.log("--- 新会话开始(短期记忆已清空)---\n");  reply = awaitchat("帮我写一个文件读取函数");console.log(`用户: 帮我写一个文件读取函数`);console.log(`Agent: ${reply}\n`);console.log("--- 长期记忆内容 ---");console.log(JSON.stringify(longTermMemory, null2));}main().catch(console.error);

运行结果预期:

=== 基础版记忆系统测试 ===用户: 你好,我叫Jack,我用macOS[记忆写入] 用户名字:Jack[记忆写入] 用户操作系统:macOSAgent: 你好Jack!很高兴认识你。macOS是个很棒的开发环境...--- 新会话开始(短期记忆已清空)---用户: 帮我写一个文件读取函数Agent: 好的,Jack!基于你的macOS环境,我建议...--- 长期记忆内容 ---[  { "content": "用户名字:Jack", "timestamp": "..." },  { "content": "用户操作系统:macOS", "timestamp": "..." }]

关键观察: 即使短期记忆被清空(模拟新会话),长期记忆中的 “用户名字:Jack” 和 “用户操作系统:macOS” 仍然被注入了 system prompt,所以 Agent 仍然”认识”用户。

但基础版的问题很明显:记忆提取是规则驱动的,只能捕获明确说出的信息;检索是关键词匹配的,语义理解能力为零。下面进阶。

6.2 进阶版:带摘要压缩的短期记忆 + 文件持久化的长期记忆

// memory-advanced.ts — 进阶版:摘要压缩 + 文件持久化 + LLM提取import { Agent } from"@mariozechner/pi-agent-core";import { getModel } from"@mariozechner/pi-ai";import {  readFileSync,  writeFileSync,  existsSync,  mkdirSync,from"fs";import { join } from"path";constMEMORY_DIR = "./memory";// ========== 短期记忆:带摘要压缩 ==========interfaceConversationMemory {messagesArray<{ rolestringcontentstring }>;summarystring;maxRoundsBeforeSummarizenumber// 触发摘要的轮数阈值}constshortTermConversationMemory = {messages: [],summary"",maxRoundsBeforeSummarize5,};// 摘要压缩:用 LLM 压缩旧对话asyncfunctionsummarizeOldMessages(): Promise<string> {if (shortTerm.messages.length === 0return shortTerm.summary;const summarizer = newAgent({name"summarizer",modelgetModel("minimax-cn""MiniMax-M2.7"), // 用便宜模型做摘要instruction`将以下对话历史压缩为一段简洁的摘要(200字以内)。保留关键事实、决策和偏好信息。忽略寒暄和重复内容。当前已有摘要:${shortTerm.summary}对话内容:`,  });const conversationText = shortTerm.messages    .map((m) =>`${m.role}${m.content}`)    .join("\n");let summary = "";const stream = summarizer.stream(conversationText);forawait (const event of stream) {if (event.type === "text") summary += event.text;  }return summary;}// ========== 长期记忆:文件持久化 + LLM提取 ==========interfaceMemoryEntry {idstring;type"semantic" | "episodic" | "procedural";contentstring;timestampstring;accessCountnumber;}classLongTermMemoryStore {privatefilePathstring;constructor(userIdstring) {const dir = join(MEMORY_DIR"longterm");if (!existsSync(dir)) mkdirSync(dir, { recursivetrue });this.filePath = join(dir, `${userId}.json`);  }privateload(): MemoryEntry[] {if (!existsSync(this.filePath)) return [];returnJSON.parse(readFileSync(this.filePath"utf-8"));  }privatesave(entriesMemoryEntry[]): void {writeFileSync(this.filePathJSON.stringify(entries, null2));  }// 写入记忆add(entryOmit<MemoryEntry"id" | "timestamp" | "accessCount">): void {const entries = this.load();// 去重:检查是否已有相似记忆const isDuplicate = entries.some((e) =>        e.type === entry.type &&        e.content.toLowerCase() === entry.content.toLowerCase()    );if (isDuplicate) return;    entries.push({      ...entry,id`mem_${Date.now()}`,timestampnewDate().toISOString(),accessCount0,    });this.save(entries);console.log(`[长期记忆写入] [${entry.type}${entry.content}`);  }// 检索记忆(简单语义匹配)search(querystring, topK = 5): MemoryEntry[] {const entries = this.load();const queryLower = query.toLowerCase();const queryWords = queryLower.split(/\s+/);const scored = entries      .map((entry) => {const contentLower = entry.content.toLowerCase();const wordMatches = queryWords.filter((w) => w.length > 1 && contentLower.includes(w)        ).length;// 时间衰减:越新权重越高const ageInDays =          (Date.now() - newDate(entry.timestamp).getTime()) /          (1000 * 60 * 60 * 24);const timeDecay = Math.exp(-ageInDays / 30); // 30天半衰期const score = wordMatches * 0.7 + timeDecay * 0.3;return { entry, score };      })      .filter((s) => s.score > 0)      .sort((a, b) => b.score - a.score)      .slice(0, topK);// 更新访问计数if (scored.length > 0) {const all = this.load();for (const s of scored) {const entry = all.find((e) => e.id === s.entry.id);if (entry) entry.accessCount++;      }this.save(all);    }return scored.map((s) => s.entry);  }// 解决矛盾:覆盖同类旧记忆resolveConflict(newContentstringtypeMemoryEntry["type"]): void {const entries = this.load();// 找到同类型的矛盾记忆(简单实现:同类型 + 关键词重叠)const keywords = newContent.toLowerCase().split(/\s+/);const conflicting = entries.filter((e) =>        e.type === type &&        keywords.some((kw) => kw.length > 2 && e.content.toLowerCase().includes(kw)        )    );// 删除旧矛盾记忆const remaining = entries.filter((e) => !conflicting.includes(e));    remaining.push({id`mem_${Date.now()}`,type,content: newContent,timestampnewDate().toISOString(),accessCount0,    });this.save(remaining);if (conflicting.length > 0) {console.log(`[记忆更新] 解决矛盾:${conflicting.length}条旧记忆被新记忆替代`      );    }  }}// LLM 驱动的记忆提取asyncfunctionextractMemoriesWithLLM(userMessagestring,assistantReplystring,storeLongTermMemoryStore): Promise<void> {const extractor = newAgent({name"memory-extractor",modelgetModel("minimax-cn""MiniMax-M2.7"),instruction`分析以下对话,提取值得长期记住的信息。按类型分类输出 JSON:- "semantic": 用户的事实信息(姓名、偏好、技术栈、环境)- "episodic": 经验教训(什么做法有效/无效、遇到的坑)- "procedural": 行为规则(Agent应该遵循的规则)如果没有值得记住的信息,返回空数组 []。只输出 JSON,不要解释。`,  });const dialog = `用户: ${userMessage}\n助手: ${assistantReply}`;let result = "";const stream = extractor.stream(dialog);forawait (const event of stream) {if (event.type === "text") result += event.text;  }try {constextractedArray<{ typestringcontentstring }> =JSON.parse(result);for (const item of extracted) {if (["semantic""episodic""procedural"].includes(item.type)) {// 尝试解决矛盾        store.resolveConflict(item.content, item.typeasMemoryEntry["type"]);        store.add({type: item.typeasMemoryEntry["type"],content: item.content,        });      }    }  } catch {// LLM 输出格式异常时静默跳过console.log("[记忆提取] 格式异常,跳过本次提取");  }}// ========== 完整对话流程 ==========const ltmStore = newLongTermMemoryStore("user_jack");asyncfunctionchatAdvanced(userInputstring): Promise<string> {// 1. 检索相关长期记忆const relevantMemories = ltmStore.search(userInput);const memoryContext =    relevantMemories.length > 0      ? `关于用户的已知信息:\n${relevantMemories.map((m) => `- [${m.type}${m.content}`).join("\n")}`      : "";// 2. 构建 system prompt(摘要 + 长期记忆)const systemPrompt = `你是一个编程助手。请用中文回答。${shortTerm.summary ? `之前的对话摘要:${shortTerm.summary}\n` : ""}${memoryContext}`;// 3. 调用 Agent  shortTerm.messages.push({ role"user"content: userInput });const agent = newAgent({name"memory-advanced",modelgetModel("minimax-cn""MiniMax-M2.7"),instruction: systemPrompt,  });let response = "";const stream = agent.stream(userInput);forawait (const event of stream) {if (event.type === "text") response += event.text;  }  shortTerm.messages.push({ role"assistant"content: response });// 4. LLM 驱动的记忆提取awaitextractMemoriesWithLLM(userInput, response, ltmStore);// 5. 检查是否需要摘要压缩if (    shortTerm.messages.length >=    shortTerm.maxRoundsBeforeSummarize * 2  ) {    shortTerm.summary = awaitsummarizeOldMessages();// 只保留最近 3 轮    shortTerm.messages = shortTerm.messages.slice(-6);console.log("[短期记忆] 已完成摘要压缩,保留最近3轮");  }return response;}

关键变化(对比基础版):

  • • 短期记忆:多了摘要压缩机制,超过5轮自动压缩旧对话
  • • 长期记忆:文件持久化(重启不丢失)、三种类型分类存储、时间衰减排序、访问计数
  • • 记忆提取:从规则驱动升级为 LLM 驱动,能捕捉隐含信息
  • • 矛盾处理:写入前检测同类旧记忆,自动覆盖

6.3 生产版要点

生产环境中还需要考虑:

// 生产版需要额外处理的问题:// 1. 向量数据库替代关键词匹配// 使用 Pinecone / Weaviate / Chroma 实现真正的语义检索// import { ChromaClient } from "chromadb";// const collection = await chroma.getOrCreateCollection("memories");// await collection.add({ ids, embeddings, documents });// 2. 异步记忆提取(不阻塞对话响应)// 先给用户返回响应,后台异步提取记忆// async function backgroundExtract(dialog: string) { ... }// 3. 记忆容量管理// 定期归档 accessCount < 阈值 且 age > 90天的记忆// function archiveOldMemories() { ... }// 4. 多用户隔离// 每个用户独立的记忆空间,互不干扰// const store = new LongTermMemoryStore(userId);// 5. 隐私过滤// 检测并过滤敏感信息(密码、信用卡号、身份证号)// function sanitizeBeforeStore(content: string): string { ... }

📎 完整代码code/L10-memory-system.ts


7. 要点总结

#
Takeaway
说明
1
记忆是 Agent 的基础设施
不是可选功能,而是从”工具”到”伙伴”的分水岭
2
三层模型各有分工
短期(Context Window)→ 中期(Session State)→ 长期(Persistent Store)
3
长期记忆三种类型
语义(事实)+ 情节(经历)+ 程序(规则),三者协同工作
4
混合提取策略最优
规则驱动捕获明确信息 + LLM 提取捕捉隐含信息
5
检索比写入更关键
写了很多但检索不到等于没写;注入位置影响效果
6
从最简单的模式开始
不要一上来就搭共享知识库,按需升级

8. 动手练习

练习 1: 基础 (⭐)

基于 6.1 的基础版代码,为 extractMemory 函数添加以下规则:

  • • 检测到技术相关词汇(如 “Python”, “TypeScript”, “React”)→ 记录为用户技术栈
  • • 检测到否定偏好(”不要XXX”, “别用XXX”)→ 记录为 negative_preference

练习 2: 进阶 (⭐⭐)

基于 6.2 的进阶版代码,实现”记忆衰减”功能:

  • • 每条记忆带 halfLife 字段(语义记忆=永久,情节记忆=30天,程序记忆=90天)
  • • 检索时计算 有效分 = 相似度 × 2^(-年龄/半衰期)
  • • 超过 3 个半衰期且 accessCount < 2 的记忆自动归档

练习 3: 挑战 (⭐⭐⭐)

实现一个完整的”程序记忆自优化”循环:

  1. 1. Agent 用当前规则集处理 5 个任务
  2. 2. 收集执行结果和用户反馈
  3. 3. 用 LLM 反思:哪些规则有效?哪些需要修改?应该新增什么规则?
  4. 4. 自动更新规则集并存回长期记忆
  5. 5. 下一轮对话使用更新后的规则

提示:这需要结合 L07(Reflection)模式的思路,让 Agent 能够审视和修改自己的行为指令。


9. 延伸阅读

  • • 🔗 Lost in the Middle[1] — LLM 长上下文信息提取的研究
  • • 🔗 LangGraph Memory[2] — LangGraph 的记忆实现参考
  • • 🔗 Google ADK Memory[3] — ADK 的 Session/State/Memory 三组件设计
  • • 🔗 pi-mono 文档: AgentState 和 transformContext() API
源码:https://github.com/OmniTexts/learning-ai-agent

引用链接

[1] Lost in the Middle: https://arxiv.org/abs/2307.03172[2] LangGraph Memory: https://langchain-ai.github.io/langgraph/concepts/memory/[3] Google ADK Memory: https://google.github.io/adk-docs/sessions/memory/