

Agent 的记忆不等于上下文:从 smolagents 看三个关键的架构设计
通过 smolagents 的 MemoryStep、summary_mode 与 Soft Planning,理解 Agent 执行历史、上下文视图与规划机制之间的职责划分。
记录过去并不困难,困难的是决定哪些过去值得参与下一次判断。
项目: Hugging Face / smolagents
研究方向: Agent Memory · Context Engineering · Planning
系列: Agent 源码研究 · Part 02
TL;DR#
阅读 smolagents 的 Memory 与 Planning 实现时,我发现了一条很值得借鉴的设计思路:不要把 Agent 的执行历史直接等同于模型的上下文。
它用结构化的 MemoryStep 保存执行轨迹,通过 to_messages() 为不同调用构造消息视图,再利用 summary_mode=True 让 Replanning 排除部分旧推理与历史计划。没有复杂的记忆算法,却把「保存历史」「选择历史」与「利用历史做决策」分开了。
写在前面:一个不断增长的 messages 数组#
在上一篇文章中,我沿着 MultiStepAgent 的执行循环,研究了 Agent 如何从模型决策走向工具调用,再通过 Observation 获得环境反馈。
但继续往下读,一个问题逐渐变得突出:Agent 每执行一步,都会产生新的历史。这些历史究竟应该如何管理?
如果自己实现一个简单的 Agent,最直观的方法就是维护一个不断增长的消息数组:
# 伪代码:只展示历史累积的基本思路
messages = [{"role": "user", "content": task}]
while not finished:
response = model.generate(messages)
result = execute(response)
messages.append(response)
messages.append(result)python对于简单任务,这样做并没有什么问题。麻烦出现在任务变长之后:Agent 读取了十几个文件、反复搜索、遇到错误,又因为最初的判断不准确而调整路线。此时的历史中,不仅有对当前任务有用的执行结果,也混杂着临时猜测、失败尝试和过期计划。
全部交给模型,上下文成本不断增加,冗余信息也可能干扰判断;直接截断,又容易丢掉早期的重要线索。真正困难的不是让 Agent 记住历史,而是决定下一轮模型应该看见什么。
其实在读这部分源码之前,我就一直对 Agent 的记忆机制挺感兴趣。我想做的并不只是一个能够记住聊天记录的助手。相比「上次聊了什么」,我更在意它能否逐渐形成对一个人的理解:知道哪些偏好比较稳定,哪些想法只是随口一提;能够延续之前的讨论,也能在发现新信息时修正旧判断。
但仔细想想,这件事比简单保存对话麻烦得多。记忆应该在什么时候产生?哪些内容值得长期保留?如果两次对话里的信息互相矛盾,又该相信哪一次?更重要的是,保存下来的东西,真的有必要在每次对话中重新拿出来吗?
带着这些问题继续读 smolagents,倒是有些意外。它的 AgentMemory 并没有实现我设想中的长期记忆能力,却从另一个角度给了我启发:记忆如何保存,与记忆如何参与当前决策,原来可以是两套不同的机制。
一、先看全貌:执行历史与模型上下文为什么要分开?#
先把几个模块之间的数据流放在一起:
Agent Loop
│
│ 每轮执行产生 Step
▼
┌──────────────┐
│ AgentMemory │
│ 结构化执行历史 │
└──────┬───────┘
│
▼
write_memory_to_messages()
│
┌─────────┴─────────┐
│ │
▼ ▼
普通消息视图 Summary 视图
│ │
▼ ▼
下一轮行动 更新 Planning
│ │
└─────────┬─────────┘
▼
LLMtext可以借用一个后端开发的概念理解这套结构。AgentMemory 像一份结构化执行日志,记录任务运行时发生了什么;write_memory_to_messages() 则像面向不同请求生成 DTO 或 View 的转换层。
底层保存着同一份记录,但「正常执行下一步」与「重新制定计划」,不一定需要相同的信息。这不是严格的 Event Sourcing——MemoryStep 可以修改,历史也可以重置——但它保留了一个重要思想:数据的存储表示,不必等于数据的消费表示。
顺着这个区别,可以看懂三个值得细品的设计决策。
二、决策一:为什么不直接保存 ChatMessage,而要包一层 MemoryStep?#
既然模型最终接收的还是 ChatMessage,为什么不直接把它们存进 list[ChatMessage]?
打开 memory.py,AgentMemory 的核心其实非常简单:
class AgentMemory:
def __init__(self, system_prompt: str):
self.system_prompt: SystemPromptStep = SystemPromptStep(
system_prompt=system_prompt
)
self.steps: list[TaskStep | ActionStep | PlanningStep] = []python它保存的不是已经拼好的对话消息,而是一组带有明确类型的记录。一次 ActionStep 还会分别保存模型输出、工具调用、观察结果与错误:
# ActionStep 关键字段节选
@dataclass
class ActionStep(MemoryStep):
step_number: int
timing: Timing
tool_calls: list[ToolCall] | None = None
error: AgentError | None = None
model_output: str | list[dict[str, Any]] | None = None
observations: str | None = None
observations_images: list["PIL.Image.Image"] | None = Nonepython看上去只是多包装了一层对象,但它让系统获得了三种很实际的自由度。
字段级投影:同一段历史可以有不同的读法#
如果工具调用、模型推理和执行反馈早已混成一段普通文本,那么想「只保留工具调用与 Observation,暂时不呈现模型先前的推理」,就需要重新解析文本,甚至可能恢复不了字段边界。
结构化的 ActionStep 不存在这个问题。比如 ActionStep.to_messages() 的开头就有一条关键判断:
if self.model_output is not None and not summary_mode:
messages.append(
ChatMessage(
role=MessageRole.ASSISTANT,
content=[{"type": "text", "text": self.model_output.strip()}],
)
)python当 summary_mode=True 时,消息转换会跳过 model_output。注意,它不是删除 Memory 里的记录,只是这一次不把这条模型输出交给模型。
定点清理:不必把整段历史一起扔掉#
Browser Agent 是一个很直观的例子。每轮浏览器操作都可能生成截图,几十轮之后,历史图片会不断占据视觉上下文。由于图片单独保存在 observations_images 中,开发者可以通过 Step Callback 清理较早的截图,同时保留对应的 Tool Call 和文字 Observation。
# 示意代码:可在回调中按策略清理旧截图
for step in agent.memory.steps:
if isinstance(step, ActionStep):
if step.step_number < current_step - 2:
step.observations_images = Nonepython这里要区分:to_messages() 的投影不修改原始记录;Callback 清理则会实际改变 Memory。它们共同依赖的能力,是执行历史具有可操作的结构,而不是不可拆分的文本块。
可观测性:一次失败不再只是一段对话#
当 Agent 调用失败时,究竟是模型选错工具、参数不合法,还是工具内部执行异常?结构化记录不能自动定位根因,但至少为追踪提供了明确入口:tool_calls、observations、error、model_output 各自保存不同阶段的信息。
因此,MemoryStep 多出来的复杂度并不是为了让代码显得更「框架化」,而是换来对执行历史进行筛选、清理与分析的能力。对于只运行两三轮的 Demo,直接维护消息数组也许足够;但当执行过程本身需要治理时,这层抽象就值得了。
三、决策二:挂着 summary 名字,却没有任何摘要模型?#
整个 agents.py 里,我觉得最值得追下去的连接点之一,是 write_memory_to_messages() 的 summary_mode 参数。
def write_memory_to_messages(
self,
summary_mode: bool = False,
) -> list[ChatMessage]:
messages = self.memory.system_prompt.to_messages(
summary_mode=summary_mode
)
for memory_step in self.memory.steps:
messages.extend(memory_step.to_messages(summary_mode=summary_mode))
return messagespython看到 Summary,很容易先想到用另一个 LLM 把长历史压缩成短摘要。但继续点进各个 Step 的 to_messages(),发现这里完全没有额外的模型调用。它采用的是按记录类型和字段筛选消息的硬规则。
| 历史记录 | 普通模式 | Summary Mode |
|---|---|---|
| 用户原始 Task | 保留 | 保留 |
| System Prompt | 保留 | 排除 |
| 旧 PlanningStep | 保留 | 排除 |
| ActionStep 的模型文本输出 | 保留 | 排除 |
| Tool Call | 保留 | 保留 |
| Observation / Error | 保留 | 保留 |
| Observation Images | 保留 | 仍可保留 |
这个规则的取向很清晰:减少部分历史推理与旧计划,保留任务和实际执行轨迹。
想象 Agent 在分析仓库时,先认定入口位于 module_a.py,随后读取文件却发现真正的入口是 module_c.py。前一个判断不是系统需要反复强化的事实;相比之下,「读过什么文件」「工具实际返回什么」往往更能帮助重新梳理路线。
这不意味着模型输出没有价值,也不意味着 Observation 天然正确。Observation 可以来自过期文档、错误的网页或被污染的外部内容。它只是执行时拿到的证据,不等于经过验证的事实。
为什么不直接做语义摘要?#
LLM Summary 可以理解长历史,但也引入额外模型调用、延迟和费用,而且有遗漏关键细节、混淆事实与推测的风险。固定规则没有这样的生成过程,行为可预测,也更容易定位是哪条规则导致了信息缺失。
代价同样明显:规则不懂语义,无法识别某条 Observation 是否已经过期;如果单条工具反馈本身有几万 Token,summary_mode 也不会自动缩短它。所谓「低成本」,指的是没有额外的摘要模型调用,而不是重规划不消耗 Token,更不是完全无损。
这一层真正值得借鉴的地方不在布尔参数本身,而在过滤发生的位置:不是存储时决定某条记录以后永远有没有用,而是模型调用时根据目的决定这次呈现什么。
那么,为什么重新制定计划会需要一份与普通行动不同的上下文?
四、决策三:为什么 Planning 不需要复杂的 Workflow Engine?#
Agent Loop 允许模型走一步、观察一步,但复杂任务只靠局部决策也容易迷路。例如研究大型仓库时,Agent 可能被某个分支文件吸引,反复追踪局部实现,却忘记最终目标是解释整体架构。
smolagents 在执行循环中增加了可选的 Planning 阶段,由 planning_interval 控制:
if self.planning_interval is not None and (
self.step_number == 1
or (self.step_number - 1) % self.planning_interval == 0
):
for element in self._generate_planning_step(
task,
is_first_step=len(self.memory.steps) == 1,
step=self.step_number,
):
yield elementpython例如 planning_interval=5,通常会在第 1、6、11……轮行动前规划。初始规划会获得任务、工具和 Managed Agents 等信息,后续再根据执行历史更新路线。
但有意思的不是规划何时触发,而是计划生成以后,框架如何让 Agent 执行它。
Soft Planning:计划并没有编译成 DAG#
查看 PlanningStep.to_messages():
def to_messages(self, summary_mode: bool = False):
if summary_mode:
return []
return [
ChatMessage(
role=MessageRole.ASSISTANT,
content=[{"type": "text", "text": self.plan.strip()}],
),
ChatMessage(
role=MessageRole.USER,
content=[{
"type": "text",
"text": "Now proceed and carry out this plan.",
}],
),
]python框架没有把计划解析为结构化任务 DAG,也没有调度器逐项读取它。计划只是一段自然语言,作为 Assistant 消息重新进入上下文;紧接着,框架还人为构造了一条 USER 消息,要求继续执行。
这条 USER 消息并非真实用户发送。源码注释明确指出,角色切换是为了防止模型接着生成计划文本。这是一个很典型的 Prompt Engineering 处理:用消息角色和内容组织,促使模型从规划回到行动。
可以把它理解为 Soft Planning(软规划):计划提供方向,但不强制下一步必须执行哪个节点。
与结构化 Workflow 相比,它的优势是灵活。探索陌生仓库时,读取第一个文件就可能推翻原有路线;固定 DAG 需要更新依赖状态,软规划则允许模型根据最新反馈直接调整行为。
但如果场景是「退款前必须通过风控校验」,自然语言计划就不能当作程序级保证。严格依赖、权限和前置条件仍需要在执行层验证。smolagents 的选择并不是 Workflow Engine 的通用替代品,而是以更低编排复杂度换取开放式探索的灵活性。
不过它留下了更有意思的问题:既然计划会进入后续上下文,那已经过时的旧计划该怎么办?
五、最值得记住的一步:Replanning 为什么要主动排除旧计划?#
假设 Agent 最初制定了这份计划:
1. 阅读 README
2. 从 module_a.py 定位执行入口
3. 分析 module_b.py 的调用关系
4. 整理架构报告text执行到第三步时发现,module_a.py 只是接口定义,真正的入口位于 module_c.py。下一次更新 Planning 时,最简单的做法似乎是把旧计划和全部历史都放进 Prompt,再要求模型修订。
但旧计划可能成为新的锚点:模型虽然看到了相反证据,却仍倾向于在原路线中修修补补,而不是重新评价最初的判断。
smolagents 的处理方式非常直接。_generate_planning_step() 的更新分支会这样构建输入:
memory_messages = self.write_memory_to_messages(
summary_mode=True
)
input_messages = (
[plan_update_pre]
+ memory_messages
+ [plan_update_post]
)python而前面 PlanningStep.to_messages() 的第一条规则就是:
if summary_mode:
return []python两段代码连起来,设计意图就很清楚了:旧 PlanningStep 仍然保存在 Memory 中,但这一次不会被转换成供 Planner 阅读的历史消息。 任务、工具调用和 Observation 等执行信息仍然可以保留。
源码注释也明确说明,排除之前的 Planning 消息,是为了避免它们过度影响新计划。这并不是凭空揣测作者动机。
AgentMemory
│
┌─────────┴─────────┐
│ │
▼ ▼
旧计划 执行记录
│ │
本轮不呈现 Tool / Observation
│ │
└─────────┬─────────┘
▼
Replanning
│
▼
新计划text这一点我很喜欢:系统没有为了重新开始就抹掉历史,也没有因为历史存在,就让旧路线始终占据模型的注意力。
从架构上看,前面三个看似普通的设计终于连成一条线:MemoryStep 保留可操作的执行结构,summary_mode 从结构中投影出另一种上下文,Replanning 再利用这份上下文减少旧计划的影响。
「发生过」和「现在仍应作为决策依据」,是两件不同的事。
当然,这也不能彻底消除路径依赖。两条互相冲突的 Observation 仍可能同时存在,规则式筛选无法识别哪条信息更新、更可信。它解决的是一种明确的旧计划干扰,而不是完整的事实验证与知识更新。
六、跳出源码:这套极简方案在哪里不够用?#
读源码不能只顾着夸。一个设计在特定约束下足够聪明,不意味着它能直接覆盖所有生产需求。
1. 单条 Observation 过长:字段过滤救不了所有 Token#
summary_mode 可以排除部分模型输出和旧计划,但不会自动压缩 Observation。如果某次网页抓取返回数万 Token,这些内容仍可能进入后续上下文。
更复杂的 Agent Runtime 往往还需要工具结果截断、完整输出落盘、按需读取局部内容等机制。字段筛选与单条工具输出治理,属于两个不同层次的问题。
2. 历史证据失效:Observation 不等于权威知识#
Agent 可能先读取了旧版文档,之后才找到新版;也可能在某个 Git 分支读过文件,随后仓库已切到另一个分支。Memory 忠实记录「当时返回了什么」,却无法直接保证「现在仍然成立」。
因此,长期任务中还需要来源、版本、时间与重新验证机制。执行日志可以成为知识的证据来源,却不应该自动充当权威知识库。
3. Soft Planning 缺少硬性护栏#
自然语言计划适合探索,不适合独自保证必须遵守的业务流程。如果某个操作需要先通过权限校验或安全检查,程序应该在执行前强制验证,而不能只依赖 Prompt 写着「先检查」。
这也呼应了上一篇研究 Tool Calling 时的判断:模型负责提出行动,Runtime 负责可控地执行行动。计划再聪明,也不能替代执行边界。
归根结底,MemoryStep 不是长期知识库,summary_mode 不是完整的语义压缩器,Soft Planning 也不是 Workflow Engine。值得借鉴的是它们清楚的职责划分,而不是把任何一种机制当成万能方案。
七、写在最后#
读完这部分源码,我又想起了之前一直琢磨的那个问题:如果要让一个 Agent 真正拥有某种连续的记忆,它应该记住什么?
最初的想法其实挺贪心。既希望它记得过去的对话,又希望它能理解人的偏好、习惯,甚至那些没有被明确说出口,却在多次交流中逐渐显露的东西。
可越想越觉得,记忆多未必是好事。
人本来就是会变化的。昨天喜欢的东西,今天未必还喜欢;某次情绪下说的话,也不一定代表长期的态度。如果 Agent 把这些信息都当成稳定事实保存下来,所谓的「了解」,反而可能慢慢变成一种误解。
smolagents 并没有直接回答这些问题。它处理的主要还是执行历史,而不是对人的长期理解。但它让我意识到一个很值得借鉴的区别:可以允许系统保留过去,却不必让过去始终拥有解释现在的权力。
至于怎样判断一段记忆是否值得保留,什么时候应该重新验证,又该如何让旧的认识随着新的交流慢慢改变,我还没有想得特别清楚。这些问题大概也比设计一个 Memory 类困难得多。
下一篇继续研究 Multi-Agent 和 CodeAgent。当多个 Agent 开始协作、各自拥有不同的上下文时,信息又应该怎样传递和保持一致?
参考资料#
注:文中部分代码为突出关键执行逻辑而进行的节选或简化,具体实现以源码为准。