「AI Python 系列」第 01 栏 · AI 时代的 Python 办公自动化
全栏 18 篇 · 零成本跟完
品牌:梅雅达编程笔记
摘要:前面两篇搭好了 ReAct Agent,但它有个致命问题——每次对话都是"新生",上一轮说过的话下一轮就忘。本篇给 Agent 加装双层记忆系统:短时记忆用对话列表维持上下文连贯,长时记忆用 JSON 文件做跨会话持久化。本篇使用 GLM-4.7-Flash,永久免费,4 轮对话约消耗 5000 token,零成本跑完。
关键词:Agent 记忆、上下文管理、JSON 持久化、长任务、System Prompt 注入
环境与前置条件
| 项目 | 版本 / 说明 |
|---|---|
| 操作系统 | Windows 10/11、macOS、Linux 均可 |
| Python | 3.9 及以上(本文用 3.12 验证) |
| 依赖库 | json(标准库)、llm_client(本专栏封装的 LLM 调用模块,见 Day 04) |
| LLM 提供商 | 智谱 GLM-4.7-Flash(免费),代码中可切换 DeepSeek / Qwen |
| 前置知识 | 需先完成 Day 14-15,理解 ReAct Agent 和 Function Calling 的基本原理 |
| 预计耗时 | 阅读 15 分钟 + 动手验证 10 分钟 |
开篇 · 一个让我翻车的真实场景
Day 14-15 我们做了能调用工具的 ReAct Agent,我兴冲冲拿给学生用。结果第一个学生就反馈:“老师,它记性怎么这么差?”
事情是这样的:学生先跟 Agent 说"我叫小明,今年 10 岁",Agent 回复"你好小明"。然后学生紧接着问"我叫什么",Agent 直接回了句"抱歉,我不知道你的名字"。
学生当场就愣住了,然后跑来找我告状。
我一开始还以为是模型出问题了,排查了半天才发现——根本不是 AI 不够聪明,是代码层面压根没把上一轮对话传过去。每次调 API 都是一次独立请求,模型本身无状态,你不把历史消息塞进去,它当然什么都不记得。
这就像你跟一个人打电话,每说一句话就挂断再重拨,对方怎么可能记得你刚才说了什么?
找到原因后,我给 Agent 加了一套双层记忆系统。今天这篇就讲这个事情——怎么让 Agent 真的"记住你"。
一、为什么要分两层?一层 JSON 不行吗?
我第一版其实是只用了一个 JSON 文件,把所有东西都往里塞。跑了两天就发现不对——每次对话都要读写磁盘,速度肉眼可见地变慢。打开 JSON 文件一看,好家伙,光对话历史就涨了好几 KB。
后来我改成只存在内存里,速度是快了,但程序一关全没了。用户第二天打开又得重新自我介绍,体验极差。
折腾了两版之后我才意识到:这两种记忆的性质完全不同,必须分开处理。
| 维度 | 短时记忆(内存) | 长时记忆(磁盘) |
|---|---|---|
| 生命周期 | 程序关了就没了 | 写进文件,重启还在 |
| 访问频率 | 每轮对话都要读写 | 只在"记住/回忆"时才动 |
| 丢了代价 | 低——当前对话断就断了 | 高——用户数据永久丢失 |
| 适合存什么 | 当前聊天的上下文 | 用户名、偏好、长期事实 |
热数据放内存,冷数据落磁盘——这个思路不是我发明的,数据库早就这么干了。但在 Agent 里实现起来其实很朴素。
二、短时记忆:就是个列表,别想复杂了
短时记忆的实现特别简单——一个 Python 列表,每轮对话把用户消息和 AI 回复都append进去,就这样。
classMemoryAgent:def__init__(self,memory_file="agent_memory.json",provider="glm"):self.client=LLMClient(provider=provider)self.memory_file=memory_file self.history=[]# 短时记忆:就是个列表self.long_term=self._load_memory()# 长时记忆:从文件加载对话的时候,把最近的历史拼成文本传给模型:
defchat(self,message):self.history.append({"role":"user","content":message})recent=self.history[-10:]# 只取最近10条context="\n".join([f"{'用户'ifm['role']=='user'else'助手'}:{m['content']}"forminrecent])response=self.client.chat(context,system_prompt=system_prompt,temperature=0.5,max_tokens=500)self.history.append({"role":"assistant","content":response})returnresponse你可能会问:为什么不把全部历史都传给模型?
我一开始还真是这么干的——结果聊了 20 轮之后,API 直接报 token 超限的错误。这才想起来模型的上下文窗口是有限的。后来我改成[-10:]只取最近 10 条,问题就解决了。
10 条这个数字也不是精确计算出来的,就是试了几次发现够用:用户说"那个文件""刚才那个"之类的指代,模型基本都能回溯到。如果你的场景需要更长的回溯,可以调大到 15-20 条,但别贪多,token 消耗是实打实的。
三、长时记忆:一个 JSON 文件搞定
长时记忆要解决的是"关掉程序再打开,之前记住的东西还在"。我用 JSON 文件来做——够简单,够直观,不需要装额外的库。
3.1 加载与保存
def_load_memory(self):ifos.path.exists(self.memory_file):try:withopen(self.memory_file,"r",encoding="utf-8")asf:returnjson.load(f)except(json.JSONDecodeError,IOError):passreturn{"facts":{},"notes":[]}def_save_memory(self):withopen(self.memory_file,"w",encoding="utf-8")asf:json.dump(self.long_term,f,ensure_ascii=False,indent=2)这里有个我踩过的坑:有一次我调试的时候手动 Ctrl+C 强杀了程序,结果 JSON 文件写到一半就断了,变成了非法格式。下次启动 Agent 的时候直接报错崩溃。后来我加了try/except捕获JSONDecodeError,文件坏了就给个空结构重新来,至少不会让整个程序挂掉。
不过说实话,这么做有个隐患——用户的记忆文件坏了你静默丢弃,用户可能根本不知道。生产环境最好加个日志记录,或者把损坏的文件备份成.bak再新建。
3.2 记住与回忆
defremember(self,key,value):self.long_term["facts"][key]=value self._save_memory()returnf"已记住:{key}={value}"defrecall(self,key):value=self.long_term["facts"].get(key)ifvalue:returnf"回忆起来:{key}={value}"returnf"没有关于「{key}」的记忆"每次remember都立即写盘。这样做最安全,不会丢数据。但有个代价——如果你频繁调用 remember(比如让它批量记住一堆东西),磁盘 IO 会很频繁。我个人使用场景下感觉不到延迟,但如果是服务端多人并发用,建议改成"攒一批再写"的模式,并且加文件锁。
3.3 笔记:给没有明确 key 的信息留个位置
除了键值对,还有种信息不太好塞进 facts——比如用户说"下周要交报告"。这不是一个"key=value"能表达的东西,更像一条备忘录。所以我加了一个 notes 列表,每条带时间戳:
defadd_note(self,note):timestamp=datetime.now().strftime("%Y-%m-%d %H:%M")self.long_term["notes"].append({"time":timestamp,"note":note})self._save_memory()returnf"已记录笔记:{note}"简单总结下分工:有明确名字的东西(用户名、所在城市)存 facts;事件、提醒、随手记存 notes。
四、最关键的一步:记忆怎么"喂"给模型
记忆存好了还不够。模型看不到你的 JSON 文件,它只能看到你传给它的文本。所以必须在每轮对话时,把记忆格式化后塞进 system prompt。
这个环节折腾了我一阵。一开始我把记忆拼成一个长字符串直接丢进去,结果发现模型有时候会"无视"我注入的信息。后来我学聪明了,在 system prompt 里加了明确的规则,告诉模型"这些是你记住的信息,要在回答中用到"。
格式化函数长这样:
def_format_memory_for_prompt(self):parts=[]ifself.long_term["facts"]:facts="\n".join([f" -{k}:{v}"fork,vinself.long_term["facts"].items()])parts.append(f"已知信息:\n{facts}")ifself.long_term["notes"]:notes="\n".join([f" - [{n['time']}]{n['note']}"forninself.long_term["notes"][-5:]])parts.append(f"近期笔记:\n{notes}")return"\n\n".join(parts)ifpartselse"暂无记忆"然后在chat里把它拼进 system prompt:
memory_str=self._format_memory_for_prompt()system_prompt=f"""你是一个有记忆能力的智能助手。 你的长时记忆:{memory_str}规则: - 对话中自然地使用你记住的信息 - 如果用户说"记住xxx是yyy",主动调用记忆功能 - 如果用户问"你还记得xxx吗",从记忆中查找 - 保持简洁友好的风格"""有两件事需要注意:
第一,notes 我取了[-5:]只给最近 5 条。原因跟对话历史截断一样——记忆越多 token 消耗越大,而且一周前的笔记对今天的对话通常没什么参考价值。
第二,这个方案的 token 成本会随记忆量线性增长。100 条 facts 大概占 500+ token,每轮对话都重复发送。如果你的 Agent 需要记住很多东西,后面可以考虑改成向量检索——只注入跟当前问题相关的记忆。这个进阶方案在文末有提到。
五、记忆在长任务里还有另一层作用
前面说的都是"记住用户是谁"这类基础功能。但记忆系统在更复杂的场景里作用更大——它可以充当任务的检查点。
举个实际例子:假设让 Agent 帮你做一份月度报告,要读 Excel、统计品类、分析趋势、生成文字、输出 Markdown,5 个步骤。如果没有记忆,第 3 步的时候第 1 步读的数据已经丢了。有了记忆系统,每完成一步就把关键中间结果存下来:
# 第1步完成后agent.remember("销售数据_总行数","12453")agent.remember("销售数据_时间范围","2026-07-01 至 2026-07-31")agent.add_note("第1步完成:数据读取成功,共12453条")更实际的价值在于中断恢复。我有一次让学生跑一个多步骤的数据处理任务,跑到第 4 步程序崩了(好像是内存不够)。如果没有记忆,得从头来。但有了记忆,重启后 Agent 看到 notes 里写着"前 3 步已完成",可以直接从第 4 步接着干。
这种"检查点"模式在很多工程系统里都有,游戏里的存档也是同一个思路。本篇的代码实现了基础记忆机制,检查点功能可以在此基础上扩展。
六、我踩过的三个坑
写到这里,说几个我在实际开发中真实遇到的问题,不是理论上的"可能出问题",是真真切切踩过的。
6.1 JSON 文件写到一半程序崩了
前面提到过一次,这里展开说。原因是_save_memory在写入过程中如果程序被强杀(Ctrl+C、断电、OOM),文件就只写了一半,JSON 格式不完整。下次启动时json.load()直接抛JSONDecodeError。
我的处理方式是加了异常捕获,文件坏了就给空结构。但更好的做法是"先写临时文件,再原子重命名":
def_save_memory(self):tmp_file=self.memory_file+".tmp"withopen(tmp_file,"w",encoding="utf-8")asf:json.dump(self.long_term,f,ensure_ascii=False,indent=2)os.replace(tmp_file,self.memory_file)# 原子替换os.replace在大多数操作系统上是原子操作,即使写入过程中断电,旧文件也不会被破坏。这个改动虽然小,但能避免很多莫名其妙的数据丢失。
6.2 两个人同时用,记忆会互相覆盖
这个坑是我把 Agent 部署成 Web 服务之后发现的。两个用户同时调用remember,都触发了_save_memory,后写的直接覆盖先写的——用户 A 的记忆把用户 B 的盖掉了。
如果只是个人本地用,这个问题不存在。但如果多人共用,必须加锁。Linux 上用fcntl,Windows 上用msvcrt.locking,或者干脆用filelock这个跨平台的库。
6.3 记忆越攒越多,JSON 越来越大
用了一个月之后打开 JSON 文件,里面存了几百条 facts。每次启动都要全量加载到内存,每轮对话都要把几百条 facts 格式化后塞进 system prompt——token 消耗肉眼可见地在涨。
目前我的临时方案是:facts 超过 50 条就手动清理一下。但这显然不是长久之计。后续准备加一个"淘汰策略",比如超过 30 天没被 recall 过的 facts 自动归档,notes 超过 3 个月的自动清理。更彻底的方案是做记忆总结——让 AI 把零散的记忆压缩成几条核心摘要。
七、完整代码与运行验证
核心代码都在下面了。llm_client.py是 Day 04 封装好的 LLM 调用模块,这里直接复用。
importosimportjsonfromllm_clientimportLLMClientclassMemoryAgent:def__init__(self,memory_file="agent_memory.json",provider="glm"):self.client=LLMClient(provider=provider)self.memory_file=memory_file self.history=[]# 短时记忆self.long_term=self._load_memory()# 长时记忆def_load_memory(self):ifos.path.exists(self.memory_file):try:withopen(self.memory_file,"r",encoding="utf-8")asf:returnjson.load(f)except(json.JSONDecodeError,IOError):passreturn{"facts":{},"notes":[]}def_save_memory(self):# 先写临时文件,再原子替换,防止写到一半崩溃tmp_file=self.memory_file+".tmp"withopen(tmp_file,"w",encoding="utf-8")asf:json.dump(self.long_term,f,ensure_ascii=False,indent=2)os.replace(tmp_file,self.memory_file)defremember(self,key,value):self.long_term["facts"][key]=value self._save_memory()returnf"已记住:{key}={value}"defrecall(self,key):value=self.long_term["facts"].get(key)returnf"回忆起来:{key}={value}"ifvalueelsef"没有关于「{key}」的记忆"defadd_note(self,note):fromdatetimeimportdatetime timestamp=datetime.now().strftime("%Y-%m-%d %H:%M")if"notes"notinself.long_term:self.long_term["notes"]=[]self.long_term["notes"].append({"time":timestamp,"note":note})self._save_memory()returnf"已记录笔记:{note}"defchat(self,message):# 把长时记忆注入 system promptmemory_str=self._format_memory_for_prompt()system_prompt=f"你是一个有记忆的助手。已知信息:\n{memory_str}\n自然地使用这些信息回答。"# 短时记忆:记录对话,只取最近10条self.history.append({"role":"user","content":message})recent=self.history[-10:]context="\n".join([f"{'用户'ifm['role']=='user'else'助手'}:{m['content']}"forminrecent])response=self.client.chat(context,system_prompt=system_prompt,temperature=0.5,max_tokens=500)self.history.append({"role":"assistant","content":responseor"(API异常)"})returnresponsedef_format_memory_for_prompt(self):parts=[]ifself.long_term.get("facts"):facts="\n".join([f" -{k}:{v}"fork,vinself.long_term["facts"].items()])parts.append(f"已知信息:\n{facts}")ifself.long_term.get("notes"):notes="\n".join([f" - [{n['time']}]{n['note']}"forninself.long_term["notes"][-5:]])parts.append(f"近期笔记:\n{notes}")return"\n\n".join(parts)ifpartselse"暂无记忆"怎么跑
目录结构:
day16_code/ ├── memory_agent.py # 本篇代码 ├── llm_client.py # Day 04 的封装,直接复用 └── requirements.txtcdday16_code pipinstall-rrequirements.txt python memory_agent.py跑起来之后
试试这几步,感受一下记忆是怎么工作的:
你: /remember 用户名=梅雅达 已记住:用户名 = 梅雅达 你: /remember 所在城市=北京 已记住:所在城市 = 北京 你: 你好,我是梅雅达 助手: 你好梅雅达!你在北京工作,有什么需要帮忙的吗? 你: 你还记得我叫什么吗? 助手: 你叫梅雅达,在北京工作。然后退出程序。你会发现当前目录多了一个agent_memory.json:
{"facts":{"用户名":"梅雅达","所在城市":"北京"},"notes":[]}重新运行python memory_agent.py,直接输入"我叫什么"——如果它还能回答出来,说明记忆确实落盘了。
如果重启后记忆丢了,检查两点:①
memory_file路径是不是相对路径导致写到了别的目录(建议用绝对路径);② 程序有没有当前目录的写权限。
八、写在最后
Agent 的记忆系统说到底就是状态管理——模型本身是无状态的,每一次 API 调用都是一个独立的请求。我们要做的是在外部维护状态,然后在对话时把相关状态"喂"给模型。
短时记忆管当前对话的连贯性,长时记忆管跨会话的数据持久化,两者通过 system prompt 注入汇合。这个分层思路不只适用于聊天机器人——你以后做更复杂的 Agent,不管是自动化报表、数据分析还是工具编排,都会用到类似的状态管理机制。
JSON 方案够轻量、够直观,适合个人使用和小规模场景。等记忆量涨到几百条以上,或者需要多人并发,就该考虑 SQLite 或向量数据库了。但在那之前,先用 JSON 把功能跑通再说。
你可以接着折腾的方向
加个"遗忘"功能。用户改名了、搬城市了,旧记忆就该能删掉。实现起来就一行del self.long_term["facts"][key],但别忘了处理 key 不存在的情况。
用向量检索替代全量注入。每轮对话把所有 facts 都塞进 prompt,记忆多了 token 扛不住。试试给每条记忆生成 embedding,查找时按相似度排序只注入 Top-3。sentence-transformers可以本地跑,也可以调 GLM 的 embedding API。
做一个"记忆总结"。对话超过 20 轮时,让 AI 把前面的内容总结成一段摘要存进 notes,然后清空短时记忆。核心逻辑大致是:if len(history) > 20→ 调模型总结 →add_note(summary)→history.clear()。这个思路和 MemGPT 论文里的做法很像,感兴趣的可以去翻翻。
下期预告
Day 17 · 综合实战 A:日报/周报/月报自动化 Agent
Agent 有工具、有记忆了,该来干正事了。明天做一个报告自动化 Agent——读取经营数据,AI 分析趋势和异常,自动生成 Markdown 格式的日报/周报/月报。整合前面学的所有技能。
资源与工具
- Python json 模块文档:https://docs.python.org/3/library/json.html
- LLM 记忆机制综述(MemGPT):https://arxiv.org/abs/2304.11477
- 智谱 GLM 模型文档:https://docs.bigmodel.cn/
- 本专栏配套代码:CSDN 下载区
往期回顾
- Day 15 · 多工具 Agent:让 AI 自主选择和组合工具
专栏:「AI Python 系列」第 01 栏 · AI+办公自动化
©️ 梅雅达编程笔记原创 · 首发 CSDN · 转载请注明出处