Agent之记忆memory-消息链就是大脑
上篇你学会了 MCP——用标准协议接入全世界的工具。但工具多了、对话长了,一个新问题冒出来:Agent 记不住刚才说过什么。这篇讲 Agent 的记忆机制——为什么消息列表就是 Agent 的大脑,以及 Function Calling 之后记忆怎么维护。
小明发现 AI 失忆了
小明的 Agent 跑得很好。查天气、搜新闻、算数学,样样都行。
但今天测试时遇到了一个奇怪的现象——
小明愣住了。”我刚才第一句话就说了啊!它怎么就忘了?”
他去找老张。老张说:”因为大模型没有记忆。”
“什么意思?”
“每一次 API 调用都是独立的。你第一次发’我叫小明’和第二次发’我刚才说我叫什么名字’——这两次调用之间没有任何关联。模型不记得上一轮的对话。”
小明说:”可我之前的 Agent 明明能记住上下文?”
老张说:”那你要检查一下——你每次调用 API 时,传的 messages 列表里到底有几种消息。如果只传了当前用户输入这一条,模型当然只能根据这一条回答。”
核心认知:LLM 无状态,记忆是你传的
老张在白板上写了四个字:
模型无状态
“大模型本身不存储任何对话历史。每次 API 调用都是一次’初次见面’。模型能’记住’之前说过什么,是因为你把完整的历史消息都放进了 messages 列表里,每次都重新传一遍。“
他画了张图:
“看明白没?模型能不能’记住’,不取决于模型本身,取决于你的程序有没有把历史消息带上。”
小明恍然大悟:”所以 Agent 的记忆本质上就是一个列表——每次请求把整个列表传给模型。”
“对。消息列表就是 Agent 的短期记忆。 你写不写数据库、用不用向量存储都不重要——只要你每次 API 调用都传完整的历史消息,Agent 就能实现最基本的’多轮对话记忆’。”
普通对话的记忆很简单
老张说:”在没有 Function Calling 时,消息列表的结构很简单——三种角色轮转:”
| 轮次 | messages 新增 | 说明 |
|---|---|---|
| 第 1 轮 | user("我叫小明") → assistant("你好小明") |
一问一答 |
| 第 2 轮 | user("查天气") → assistant("哪个城市?") |
追问 |
| 第 3 轮 | user("成都") → assistant("多云") |
补充信息 |
“流程很简单——每次对话追加两条消息(user + assistant),调用 API 时把整个列表传过去。”
Function Calling 让记忆变复杂了
小明说:”但这和普通的聊天记录差不多啊。为什么说 Function Calling 之后记忆变复杂了?”
老张说:”因为 Function Calling 在一轮对话里,消息结构不再是简单的 user → assistant。中间多了一层工具调用链。“
他在白板上画出完整的一轮 Function Calling 消息流:
“一轮 Function Calling 可能产生 4 条消息——比你想象的多了两倍。”
| 消息 | role | 内容 | 谁产生的 |
|---|---|---|---|
| 1 | user |
“成都天气?” | 用户输入 |
| 2 | assistant |
tool_calls: get_weather(...) |
模型返回(content 为空) |
| 3 | tool |
"多云 16°C" |
你的代码执行结果 |
| 4 | assistant |
“成都多云…” | 模型基于结果生成 |
“你以为问一句话就加两条消息,实际上加了四条。如果你的记忆实现得不好,这些消息的顺序和配对关系就可能出问题。”
协议硬规则:assistant(tool_calls) 和 tool 消息必须成对
老张的表情严肃起来:”官方的 Function Calling 协议有一条铁律——”
assistant 消息里的 tool_calls 必须在 messages 列表中紧跟对应的 tool(结果)消息。
不能拆散,不能漏掉,不能乱序。
小明问:”如果第二轮的 user 消息不小心插在 assistant(tool_calls) 和 tool 之间,就炸了?”
“对。API 直接报错——协议规定 tool_calls 之后只能接 tool 消息。这就是为什么记忆管理不只是简单的 append——你得保证消息顺序合法。“
实现短期记忆:ShortTermMemory 类
老张说:”搞清了规则,看代码实现。核心就是一个 ShortTermMemory 类,底层是一个 list[dict]。”
“三个核心方法:”
| 方法 | 作用 | 什么时候调 |
|---|---|---|
add() |
追加一条消息 + 自动裁剪 | 每产生一条新消息都调 |
to_api_format() |
返回完整消息列表 | 每次调用 API 前调 |
clear_non_system() |
清空记忆(保留 system) | 用户输入 /clear 时调 |
“记忆的核心就是 _messages 这个列表。每次对话产生的新消息通过 add() 追加进去,调用 API 时通过 to_api_format() 把整个列表传过去。模型因为看到了全部历史,就能’记住’之前的对话。”
裁剪策略:删一条就够?不够
小明问:”_trim() 是做什么的?”
“消息列表不能无限增长。”老张说,”假设你设置了 max_messages=40,超过 40 条后,_trim() 自动删除最旧的消息。但这里有个坑——不能只删一条。“
“如果只删 [1](user 消息),[2] 的 tool_calls 孤零零留在前面,[3] 的 tool 结果就没有前置的 tool_calls 了——协议直接报错。”
正确的裁剪策略:
老张逐行解释:
-
跳过 system — system 消息是 Agent 的角色设定,不能删 -
找最旧的非 system — 从列表头部开始,找到第一条非 system 的消息 -
弹出它 — 删掉这条消息 -
吞掉紧跟的 tool 消息 — 如果被删的消息后面紧跟着 role=tool,一并删除
“这个策略叫’删当前 + 向后吞 tool 组‘。核心目的只有一个——保证不会留下 orphan tool 消息破坏协议。“
| 裁剪方式 | 结果 |
|---|---|
| 只删最旧一条 | 可能留孤儿 tool → API 报错 |
| 删当前 + 吞后面 tool | 始终保持协议合法性 ✅ |
完整的一条对话流
老张把 Agent 的一轮对话完整展开:
“注意每次对话回合都调 self.memory.add(),把新消息追加进去。关键操作都是对 self.memory 的 读(to_api_format())和 写(add())——Agent 本身不再直接操作 messages 列表。”
小明对照着时序图看:
“第三轮调用时,模型看到整个 message 列表里有’我叫小明’和’你好小明’,所以知道用户叫小明。记忆就是靠这个列表——不是模型记住了,是你传过去的历史让它看到了。“
/clear:可控的遗忘
老张说:”记忆除了会’记’,还要会’忘’。Agent 需要提供 重置记忆 的功能。”
“为什么?”
“比如用户想开始一段全新的对话,不想被之前的话题干扰。再比如测试的时候,你想验证新功能而不受旧对话影响。”
“实现很简单——保留 system 消息,清空其余:”
“在 CLI 里绑一个命令:”
“清空前:”
“清空后:”
“相当于 Agent 重启——只记得自己的角色设定,对话历史全忘。”
小明试了一下:
assistant_to_dict:把 SDK 对象转成可存储的 dict
老张指着一处细节说:”assistant_to_dict() 这个函数值得单独讲。”
“为什么?直接用 message.to_dict() 不行吗?”
“OpenAI SDK 返回的 message 对象可以直接 .to_dict(),但不同厂商的返回格式有差异。而且 content 可能是 None(当消息是 tool_calls 时)。为了兼容更多厂商,我们手写转换:”
“两个要点:”
| 要点 | 说明 |
|---|---|
content 用空字符串兜底 |
避免 None 被下游代码当作 "None" 字符串 |
tool_calls 结构扁平很明确 |
不用 SDK 的嵌套对象,用纯 dict——存起来方便,传回 API 也兼容 |
LLM 封装:第二轮传 tools=None
老张又指着 llm.py 说:”还有一个容易被忽略的参数——第二轮 API 调用时 tools 必须传 None。“
“第一轮调用时 tools=self.tools,模型可以决定调不调工具。第二轮调用时 tools=None——告诉模型’工具结果已经给你了,不许再调工具,直接生成回答’。“
“如果第二轮还传 tools,模型可能觉得’这个工具结果不够,我再调一个’——陷入无限循环。Day4 的规定是每轮用户输入最多一次工具回合,所以第二轮必须禁止工具调用。”
不同层的记忆
老张说:”今天讲的是短期记忆——存在 Agent 进程的 list 里,进程重启就没了。这是最基本的记忆层。但完整的 Agent 记忆体系有三层:”
| 记忆层 | 存储位置 | 生命周期 | 示例 |
|---|---|---|---|
| 短期记忆 | 进程内存(list[dict]) |
会话期间 | 对话历史、工具调用链 |
| 中期记忆 | 文件/数据库 | 跨会话 | 用户偏好、项目上下文 |
| 长期记忆 | 向量数据库 + RAG | 永久 | 知识库、文档、经验 |
“今天你学的是第一层。这是基础——消息链记不住,后面两层无从谈起。但只要消息链机制搞清楚了,后面的记忆层都是在它的基础上扩展。”
这篇文章的核心
小明把笔记整理成了一张速查表:
| 概念 | 一句话 |
|---|---|
| LLM 无状态 | 模型不存储对话,靠你的 messages 列表每次重新传 |
| 消息列表 = 短期记忆 | ShortTermMemory 本质是 list[dict],add() 写、to_api_format() 读 |
| tool_calls 和 tool 配对 | 协议铁律:不能拆散、不能漏掉、不能乱序 |
| 裁剪策略 | 删最旧 + 吞紧跟的 tool 消息,不留孤儿 |
| 第二轮 tools=None | 不让模型继续调工具,强制生成最终回答 |
/clear |
保留 system,清空其余,相当于重置会话 |
assistant_to_dict() |
SDK 对象转纯 dict,兼容多厂商 |
总结
老张最后在白板上写了一句话:
Agent 的记忆不是模型的功能,是你的程序写出来的。消息列表怎么管理,记忆就多好。
“上两篇你学会了工程化管理多个工具、用 MCP 接入外部服务。这篇你学会了给 Agent 加记忆——让它记住整个会话的历史。”
“核心三件事:
-
消息列表就是记忆 — 每次 API 调用把完整历史传过去 -
Function Calling 让记忆变复杂 — assistant(tool_calls) 和 tool 必须配对,裁剪时不能留孤儿 -
用 ShortTermMemory 封装 — 统一管理消息的读、写、裁剪、清空,Agent 代码不直接碰 messages列表”
小明说:”所以之前我们写的 Function Calling 代码,本质上就是在操作 Agent 的记忆?”
“对。messages.append() 每次都是在往记忆里写东西。只不过我们之前没有把这部分逻辑封装出来——记忆逻辑和对话逻辑混在一起。现在把记忆抽成独立的 ShortTermMemory 类,职责清晰了。”
“下一步呢?”
老张说:”下一步是 Agent 循环。你注意到了,今天的一轮对话最多调一次工具——模型说’需要天气’,你调了,喂结果,模型就回答了。但有时候,模型的第一次工具结果不够,它需要再调一个工具才能回答——这就需要 ‘多步工具链’。”
“怎么实现?”
“很简单——把今天的 if tool_calls → 执行 → 回答 换成 while tool_calls → 执行 → 再问模型。让模型在判断’我还需要更多信息’时继续调工具,直到它觉得够了、给出最终回答。”
“这就是下一篇文章的内容——Agent Loop 多步工具调用。”
转载请注明来源:Agent之记忆memory-消息链就是大脑







