深入学Agent Harness工程(09):Memory记住该记的内容
深入学Agent Harness工程(09):Memory记住该记的内容
本篇对应的官方文档
- Learn Claude Code:s09 Memory:支撑记忆索引、相关记忆选择、回合后提取与定期合并的教学顺序。
- OpenAI Agents SDK Sessions:用于区分保存完整会话历史的 Session 与经过选择、提炼后长期复用的 Memory。
- OpenAI Agents SDK Agent Memory:用于核对“摘要索引先注入、需要时再读取细节、运行结束后提炼经验”的公开机制;该能力目前属于 Sandbox Agent 的 Beta 功能。
- OpenAI Agents SDK Context Management:用于核对本地运行状态与模型可见上下文的区别,以及信息必须进入 instructions、input、工具或检索结果后模型才能看见。
本篇主要内容
第 08 篇已经把长工具结果、旧消息和整体历史压缩到可继续推理的大小,但 transcript 保存的是运行记录,summary 保存的是当前任务恢复点,它们都不会自动判断哪些偏好、项目事实和纠错经验值得跨会话保留。本篇增加文件化 Memory:用轻量索引描述已有记忆,根据当前问题选择少量正文,在模型调用前注入;回合结束后再从压缩前快照提取新信息,并在达到阈值时执行 consolidation。重点不是让 Agent 永远记住一切,而是建立选择、召回、更新和遗忘的受控链路。下篇预告
Memory 增加后,身份、工具、工作目录和记忆都在参与 system 输入。第 10 篇将把这些来源改造成可按运行状态组合的 System Prompt。
一、Context Compact保住了当前任务,为什么新会话仍然从零开始
第 08 篇处理的是活动上下文超限。它先卸载超大工具结果,再保护完整工具消息组进行裁剪,随后压缩早期结果,必要时把完整历史写入 transcript 并生成 summary。这样做能让当前任务继续,却没有把某条信息变成“以后遇到相关问题还应主动使用的知识”。
假设一次任务中确认了三个事实:
- 项目统一使用
src/作为源码目录; - 输出表格时不要省略失败记录;
- 某个旧接口已被替换,后续不得继续调用。
如果这些内容只留在 messages,会话结束后就消失;只留在 transcript,需要下一次任务主动找到对应运行记录;只留在 summary,又可能与当时的目标、文件状态和临时结论混在一起。真正的 Memory 需要从一次运行中挑出可复用信息,为它建立身份和描述,并允许以后查询、更新或删除。
这里先把三个容易混淆的对象分开:
- Context 是本次模型调用实际能看见的输入,容量有限;
- Session history 保存同一会话或同一会话标识下的消息历史;
- Memory 保存经过选择、准备在未来任务中复用的信息。

Session 可以让第二轮接着第一轮对话继续,但“保留全部历史”仍然会遇到上下文增长。Memory 则不要求恢复每句话,而是把稳定偏好、项目事实、纠错反馈或外部引用提炼成较小的信息单元。两者可以协作,却不能互相替代。
s09_memory.py 选择了一个便于观察的文件结构:
.memory/
├──MEMORY.md
├──user-profile.md
├──feedback-tabs.md
└──project-facts.md
MEMORY.md 只保存名称、文件链接和一句描述,单个文件保存 YAML frontmatter 与完整正文。索引常驻 system prompt,正文按需加载。这与第 07 篇 Skill Loading 的渐进披露思路相似,但两者的来源和用途不同:Skill 是预先维护的能力说明,Memory 是运行过程中积累的偏好、事实与经验。

第 07 篇复用到这里时,角色发生了变化。之前索引帮助模型决定“需要加载哪项能力”;现在索引帮助 Harness 缩小“哪些历史信息可能与当前问题有关”。索引只是候选目录,不是可信事实清单。文件正文仍可能过期、冲突或来自错误提取,因此加载以后也要服从当前代码、当前配置和当前明确要求。
这里还要把“长期”理解为生命周期长,而不是永不改变。偏好可能只对某个项目有效,事实可能在下一次发布后失效,纠错经验也可能被新的工程规范覆盖。一个合格的 Memory 条目至少要回答四件事:它来自哪里、对谁生效、在什么范围使用、何时需要复核。当前教学文件只保存 type、name、description 和正文,因此能够演示召回链,却没有完整表达作用域、有效期和证据等级。这个缺口决定了后面的选择器只能做相关性判断,不能独自完成真实性判断。
本篇的解决办法可以概括成五个动作:
建立记忆文件
→ 重建轻量索引
→ 根据当前问题选择相关文件
→ 在模型调用前注入正文
→ 回合结束后提取并定期合并
观察这条生命周期时,重点是每一步都有独立输入和退出条件:文件写入不代表一定被选中,被选中不代表一定注入,注入也不代表模型应无条件服从。

这条链路把“记住”拆成多个可检查步骤。任何一步都可能拒绝写入或撤销已有内容,而不是把模型生成的每个总结都永久保存;接下来进入选择、注入和提取三个会直接影响当前调用的节点。
二、Memory闭环怎样运转
文件化存储从 write_memory_file() 开始。函数把名称转换成文件名,写入 name、description、type 与正文,然后调用 _rebuild_index()。四种 type 分别表示用户偏好、反馈指导、项目事实和外部引用,它们只是教学分类,不等于完整的数据治理模型。
def write_memory_file(name, mem_type, description, body):
slug = name.lower().replace(" ", "-").replace("/", "-")
filepath = MEMORY_DIR / f"{slug}.md"
filepath.write_text(
f"---\n"
f"name: {name}\n"
f"description: {description}\n"
f"type: {mem_type}\n"
f"---\n\n{body}\n"
)
_rebuild_index()
return filepath
这里有两个重要边界。第一,slug 只替换空格和斜杠,没有处理重名、保留字符、大小写碰撞和超长名称。第二,文件写入后会覆盖同名记忆,没有版本、乐观锁或审计记录。教学实现能展示对象关系,不能直接承担多人并发写入。
_rebuild_index() 遍历除 MEMORY.md 以外的 Markdown 文件,从 frontmatter 读取 name 和 description,生成一行一个链接的目录。索引短小,适合每轮常驻;正文较长,只在被选中后进入请求。
选择阶段由 select_relevant_memories(messages) 完成。它先收集最近三条用户消息,截取 2000 字符,再把所有记忆的名称和描述组成目录,发起一次不带工具的模型调用,要求只返回 JSON 整数数组。有效下标最多取五个,调用失败时退化为名称与描述的关键词匹配。
选择结果并不直接进入模型请求。load_memories() 根据文件名读取正文,用 <relevant_memories> 包裹后返回;agent_loop() 再把这段内容拼到当前用户消息前面。于是相关记忆真正进入本轮 messages,模型才有机会使用。

这条链路有三次筛选:记忆提取时决定是否保存,目录选择时决定是否相关,请求组装时决定注入哪个位置。只做第一步会让全部记忆常驻上下文;只做第二步却不注入,模型仍看不到文件内容;直接把整个目录正文拼进 system prompt,又会重回第 08 篇的上下文膨胀问题。
OpenAI-compatible 协议交点只发生在最终请求。chat_completion() 把 Harness 生成的 system 字符串放进 role="system" 消息,把注入记忆后的用户消息放在后面,再调用 client.chat.completions.create(...)。Memory 文件、选择模型、XML 标签和文件目录都属于本地 Harness,不是 Chat Completions 自动提供的字段。
request_messages = messages
if memories_content and memory_turn is not None:
request_messages = messages.copy()
request_messages[memory_turn] = {
**messages[memory_turn],
"content": (
memories_content
+ "\n\n"
+ messages[memory_turn]["content"]
),
}
response = chat_completion(
model=MODEL,
system=system,
messages=request_messages,
tools=openai_tools(TOOLS),
max_tokens=8000,
)
协议边界要观察的是数据形态的变化:文件、索引和选择结果都留在 Harness 内部,只有转换后的文本片段才会进入 messages。

messages.copy() 只复制列表和当前被替换的字典,避免把注入内容永久写回原始历史。这样下一轮不会在同一条用户消息前反复叠加 <relevant_memories>。但 memory_turn 在压缩前按列表位置记录,后续若 snip_compact() 或 compact_history() 改变消息长度,这个下标可能失效或指向其他消息。代码用 memory_turn < len(messages) 避免越界,却没有证明该位置仍是原用户轮次。
这正是代码阅读时应抓住的增量坐标。第 09 篇保留第 08 篇的工具预算、snip、micro、summary 和 reactive compact,只在四处接入 Memory:
- 模型调用前读取索引并选择相关正文;
- 每个用户轮次构建一次包含索引的 system;
- 压缩前保存
pre_compress快照; - 最终回答产生后,从快照提取记忆并尝试 consolidation。

提取必须使用 pre_compress,而不是压缩后的活动消息。原因很直接:micro compact 可能把旧工具结果替换成占位符,summary 可能遗漏某条纠错反馈。如果再从有损版本提取 Memory,错误和遗漏会被固化到长期存储。下方代码实现了“先保留提取证据,再执行压缩”的调用顺序。
pre_compress = [
m if isinstance(m, dict) else {
"role": m.get("role", ""),
"content": str(m.get("content", "")),
}
for m in messages
]
messages[:] = tool_result_budget(messages)
messages[:] = snip_compact(messages)
messages[:] = micro_compact(messages)
...
if not message.tool_calls:
extract_memories(pre_compress)
consolidate_memories()
return
这里的“快照”仍然是浅复制。原字典对象会被继续传给 micro_compact(),旧 tool message 的 content 被原地修改时,pre_compress 中对应字典也可能一起改变。若要保证完整保真,应使用深复制或先序列化成不可变 transcript,再执行任何原地压缩。代码注释表达了正确目标,当前实现却没有完全实现该目标,这个差异不能略过。
快照问题也揭示了 Memory 与日志的不同。日志追求完整、按时间追加和可审计;Memory 追求少量、可复用和会被更新。提取器可以从日志中产生候选记忆,却不应覆盖原始日志。否则 consolidation 删除了某条内容以后,系统连“它为什么曾经存在”都无法追溯。更稳妥的结构是保留不可变事件或 transcript,把 Memory 当成可重建的派生视图;记忆损坏时可以从证据重算,而不是把合并后的文件当成唯一事实。
提取阶段把最近十条消息整理成文本,连同已有记忆的名称和描述交给模型,要求返回 {name, type, description, body} 数组。只有描述和正文同时存在才写文件。这个过程发生在模型已经给出最终回答之后,因此新提取的记忆不会反过来影响刚结束的同一轮。
当记忆文件达到 CONSOLIDATE_THRESHOLD = 10,consolidate_memories() 会要求模型合并重复项、移除过期或冲突项,并限制总数。它随后删除原文件,再写回模型返回的新集合。

“Dream”在这里不是模型自己睡眠学习,而是 Harness 触发的一次批量重写。合并能减少重复,却也可能删错重要事实。当前实现没有事务:旧文件删除以后若新文件只写入一半就异常,Memory 会处于不完整状态。生产系统至少需要临时目录、完整校验、原子切换和可回滚版本。接下来用一条跨会话偏好追踪上述状态怎样重新进入调用。
三、跨会话偏好怎样在真实代码中重新出现
沿“输出表格时保留失败记录”这个稳定偏好做静态推演,可以看清跨会话链路:
会话 A 中出现明确偏好
→ 最终回答后extract_memories(pre_compress)提取候选
→write_memory_file()写入feedback-table-output.md
→_rebuild_index()更新MEMORY.md
→ 进程或会话结束
→ 会话 B 提出生成报表任务
→ 最近问题与索引描述匹配
→load_memories()读取偏好正文
→ Harness 将正文注入当前用户消息
→ 模型在本轮请求中看到该偏好
跨会话成立的关键不是 Python 列表 history。主程序每次启动都会创建空列表,真正跨进程保留信息的是 .memory/ 文件。只要工作目录和记忆目录保持一致,新进程仍能读取索引;切换工作目录则会得到另一套 Memory。
这也说明 Memory 隔离必须有明确键。当前代码把 WORKDIR / ".memory" 当成唯一命名空间,适合单项目教学。若多个用户共享同一目录,偏好和项目事实会互相泄漏;若同一用户有多个项目,统一目录又会把不同项目的规则混在一起。生产系统通常至少需要 tenant、user、project、agent 和环境维度中的若干项。
命名空间不仅决定“能否看见”,还决定“谁可以修改”。项目事实可由项目维护流程更新,个人偏好应只允许对应身份修改,外部引用则需要保留来源和抓取时间。如果所有类型都落入同一目录、共享同一写入函数,任何一次模型提取都可能改写高可信事实。生产实现通常会把读取授权、写入授权和合并授权分开,并让高风险类型进入审批或规则校验,而不是让一次自然语言输出直接成为长期状态。
build_system() 每轮把索引写入 system prompt:
def build_system():
index = read_memory_index()
memories_section = (
f"\n\nMemories available:\n{index}"
if index else ""
)
return (
f"You are a coding agent at {WORKDIR}."
f"{memories_section}\n"
"Relevant memories are injected below. "
"Respect user preferences from memory.\n"
"When the user says 'remember' or expresses a clear "
"preference, extract it as a memory."
)
索引告诉模型“有哪些记忆”,相关正文又被拼到用户消息前。两处内容可能重复,也可能形成层级冲突。更严格的设计会让索引只服务 Harness 的检索器,不直接暴露全部文件名;模型只接收经过授权和排序的正文,并带上来源、更新时间、作用域和可信度。
本篇没有配置 API,也没有运行真实模型。跨会话偏好的结果来自对文件写入、索引重建、选择、注入和回合结束分支的静态控制流追踪。它能证明数据会沿哪些函数移动,不能证明选择模型总能挑中正确文件,也不能证明提取模型不会生成错误记忆。
四、长期记忆为什么必须允许遗忘和纠错
Memory 的价值来自跨任务复用,它的风险也来自跨任务持续生效。把一次错误判断保存成 Memory,比在单次回答中犯错更危险,因为后续任务可能反复使用它。
最常见的失败路径有四类:
- 隐私泄漏:把令牌、个人信息或客户数据提取成长期文件;
- 内容过期:接口、目录或项目规则已经变化,旧事实仍被召回;
- 并发覆盖:两个进程同时重建索引或合并文件,后写入者覆盖前者;
- 纠错失败:当前明确要求与旧偏好冲突,系统仍优先采用 Memory。
观察这些失败路径时,要关注风险如何跨任务传播,以及脱敏、时效、版本和可追溯删除分别在哪个节点阻断传播。

这四类风险决定了生产 Memory 不能只有 write 和 read。至少还需要:
- 写入前做敏感信息检测和字段白名单;
- 为记忆记录
created_at、updated_at、来源和作用域; - 为容易变化的事实设置 TTL 或复核时间;
- 保存版本、删除原因和纠错记录;
- 使用原子写入、文件锁或数据库事务处理并发;
- 将当前明确要求置于旧 Memory 之上;
- 在召回时返回分数、来源和更新时间,而不是只返回正文;
- 对提取、选择、注入和使用建立可观察日志;
- 提供查看、编辑、遗忘和导出入口;
- 用当前代码、配置和外部事实验证旧 Memory,而不是盲目信任。
记忆合并也应区分“重复”和“冲突”。“输出表格保留失败记录”与“输出表格只展示成功项”不是两条可以直接合并的同义信息。系统应保留来源和时间,判断哪条是新要求,必要时把冲突交给人工确认。仅让模型生成一份新数组,会隐藏冲突决策过程。
评估 Memory 不能只问“第二次是否答对”。至少要覆盖:
- 相关偏好能否被召回;
- 无关任务是否保持不注入;
- 旧事实能否被新证据纠正;
- 敏感信息是否被拒绝持久化;
- 多用户、多项目之间是否隔离;
- consolidation 后重要约束是否仍存在;
- 文件损坏或选择失败时 Agent 是否仍能继续。
第 09 篇的完整增量是:在第 08 篇压缩流水线外增加 .memory/ 存储,用 MEMORY.md 建立轻量目录,根据最近问题选择少量正文,在模型调用前注入,再从压缩前证据提取新记忆并定期合并。Context Compact 决定当前窗口里保留什么,Memory 决定哪些信息值得离开当前窗口、在未来重新出现。
Memory 接入以后,build_system() 已经同时包含身份、工作目录、记忆索引和行为要求;工具列表与运行状态也在其他位置维护。继续把这些内容写进一个固定字符串,会产生重复、遗漏和缓存不稳定。第 10 篇将把 System Prompt 拆成稳定片段,根据真实运行状态组装,并解释动态提示词为什么仍然不能替代 Permission 与 Hook。
更多推荐

所有评论(0)