在 LLM Agent 工程里,记忆机制是决定长期任务能不能持续的关键,也是 token 消耗的重灾区。常见做法是把历史对话、工具结果、用户偏好直接拼接进 prompt,让模型“看到”记忆。这个方案简单,但对话一旦变长,成本会迅速升高,上下文窗口也可能被无关内容占满。Zero-Mem 提出的思路是:把记忆操作从提示词内容里剥离出来,让存储、更新、过期、归档这类确定性操作由 Agent Runtime 执行,LLM 的上下文里只保留必要的结果摘要或操作 ID。严格说,只要模型开口做一次工具调用,控制层面仍会产生少量 token;Zero-Mem 真正消除的是记忆正文在上下文中的反复搬运。
1. 先理解 Agent 记忆为什么不能一直塞进上下文
1.1 记忆在 Agent 里的四种形态
Agent 的记忆大致分四类:工作记忆、情景记忆、语义记忆和程序记忆。
- 工作记忆:当前任务进行到哪一步、已经拿到哪些结果、下一步等待什么输入。这类信息必须在一次任务执行过程中持续可见。
- 情景记忆:历史任务中发生了什么,某个请求在哪个时间点被处理过,当时的输入输出是什么。
- 语义记忆:用户偏好、领域规则、产品约束等稳定知识,不随单次任务变化。
- 程序记忆:Agent 应该如何调用工具、按照什么流程处理问题,很多时候被固化在代码、插件或 Prompt 模板里。
前三种经常直接塞进 prompt,第四种则容易被写成很长的系统提示词。无论哪一种,只要以自然语言文本的形式进入模型上下文,就会占用 token。
1.2 记忆正文进入上下文后会发生什么
记忆正文进入 prompt 后主要带来三个副作用。
第一是 token 成本线性增长。每次用户消息都要携带全部记忆,即使其中 80% 与当前请求无关,也要付费和占用上下文窗口。
第二是注意力被稀释。模型需要同时关注当前请求和大量背景信息,记忆越长,关键信息越可能被忽略。这个现象在长上下文场景里尤其明显。
第三是记忆维护变得困难。历史记录中间有冲突、重复、过期内容时,如果都留在上下文里,模型容易依据旧信息行动。要修改一条记忆,也只能靠重新生成一段文字,无法做到精确更新。
Zero-Mem 的核心动机就是改变这三件事:成本、注意力和可维护性。
1.3 典型记忆实现与其 token 表现
| 实现方式 | 记忆正文是否进上下文 | token 成本 | 维护难度 | 典型场景 |
|---|---|---|---|---|
| 全量拼接历史对话 | 是 | 持续增长 | 低 | 短会话 Demo |
| 摘要压缩历史 | 是,但内容变小 | 随摘要大小增长 | 中 | 长对话兜底 |
| 向量库 RAG 召回 | 召回结果进上下文 | 按召回条数增长 | 中高 | 知识问答、语义检索 |
| 外部 JSON/SQL 状态存储 | 否,只有操作 ID 或摘要 | 接近零 | 中 | 任务状态、用户偏好 |
| Zero-Mem 操作层 | 否,记忆正文不进 prompt | 仅操作元信息 | 中高 | 长期运行 Agent |
从表里可以清楚看到,零 token 并不是指整个 Agent 没有 token,而是指“记忆正文不进上下文”。这份区别在后面会反复用到。
1.4 记忆进入 prompt 前要做一次成本评审
在实际项目里,判断一条记忆要不要进入 prompt,可以用一个很简单的标准:模型如果没有这段文字,是否一定会答错或者无法行动。如果答案是“不一定”,那这段文字就应该留在外部存储里,而不是进入上下文。
例如“用户上次登录时间是昨天”这类事实,如果只在用户询问时才需要展示,那么平时不需要放进 prompt。如果每次都放进去,100 个用户就会产生 100 条类似记录,模型还要额外辨识哪条属于当前用户。把这个逻辑放到 Runtime 层按需读取,比让模型在 prompt 中大海捞针要稳定得多。
2. Zero-Mem 的设计边界:什么才是“零 token”
2.1 核心原则:记忆操作和记忆内容分离
一个记忆系统如果只考虑“存什么、取什么”,很容易退化成把内容拼回 prompt 的 RAG。Zero-Mem 先区分两类事件:
- 记忆操作:保存、更新、删除、过期、归档、统计、同步。
- 记忆内容:真正需要被模型理解的事实和上下文。
操作可以由代码直接完成,比如任务开始了就写入一个新的状态记录,工具调用成功后就更新某条记录的更新时间。这些操作不需要模型推理,也不需要让模型看到正文,因此它们可以做到零 token。
内容只有在“模型必须据此做决策”时,才需要进入上下文。比如用户问“我的订单到哪了”,Agent 必须把订单状态这段文字交给模型,否则模型无法回答。这类读取不是零 token 的。
所以准确地说,Zero-Mem 优化的是记忆操作链路,不是记忆读取链路。读取时仍然要控制内容大小和格式。
2.2 零 token 能覆盖哪类操作
下面的操作类型适合放入 Runtime 层,不经过 LLM 生成:
| 操作 | 含义 | 示例 |
|---|---|---|
| save | 新写入一条记录 | 任务启动时保存输入参数 |
| update | 更新已有记录 | 工具返回后替换中间结果 |
| touch | 刷新时间,不修改正文 | 用户访问后更新最近使用时间 |
| archive | 归档不再需要实时访问的数据 | 已完成任务移动到归档区 |
| delete | 删除无价值记录 | 用户撤销授权后删除偏好 |
| expire | 按过期时间清理 | 超过 24 小时的临时状态 |
这些操作的共性是非常确定:写入哪里、更新哪个字段、清理什么条件,都能用代码描述。模型参与这些操作反而会引入随机性,没有任何收益。
2.3 用 Memory Receipt 代替记忆正文
当 Runtime 完成一次记忆操作后,它拿到的是一份MemoryReceipt。这份回执只包含操作结果和记忆 ID,不包含记忆正文。
from dataclasses import dataclass from datetime import datetime, timezone @dataclass class MemoryReceipt: memory_id: str operation: str created_at: str content_preview: str = "" def to_context(self) -> str: # 只把元信息放进 prompt,不暴露正文 if self.content_preview: return f"[memo:{self.memory_id}] {self.operation} ({self.content_preview})" return f"[memo:{self.memory_id}] {self.operation}"例如一次保存操作得到的回执是:
[memo:a1b2c3] save如果必须给模型一点语义提示,可以加一个很短的 preview,比如[memo:a1b2c3] save (user_pref:timezone)。注意这里放的是字段名,不是字段值,token 消耗很低,也不会泄露完整内容。
这就是 Zero-Mem 的“零 token”所在:记忆正文零 token,操作元信息仍然会占少量 token。
2.4 为什么不能把语义召回也做成零 token
有些方案会宣传“向量库召回结果不占 token”,这通常建立在模型侧另有隐藏记忆注入的假设上。对于通过公开 API 调用的大模型,模型能看到的只有进入输入上下文的内容。任何没有被模型读取的文本,都不可能影响模型输出。
因此语义召回在标准 API 场景下不可能做到真正的零 token。Zero-Mem 能做的是:把召回的时机延后,把召回的条数压缩,把召回结果的格式尽量结构化。控制住的 token 是“被搬运进上下文的内容”,而不是“外部存储的大小”。
3. 最小实现:一个不向提示词塞记忆内容的 Agent Runtime
下面实现一个最小可运行的 Zero-Mem 风格 Runtime。它不依赖任何重量级框架,用 Python 标准库即可跑通,方便观察完整链路。
3.1 项目结构
zero_mem_demo/ ├── memory_store.py # 记忆存储层 ├── agent_runtime.py # Agent 运行时,负责协调 LLM 和记忆操作 └── main.py # 最小验证入口这里故意不引入数据库、消息队列和向量库。先把核心链路看清,再到生产环境替换存储层。
3.2 定义记忆记录和存储接口
memory_store.py负责保存和操作记忆记录。它不关心 LLM,只提供纯数据能力。
# memory_store.py from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, Optional from uuid import uuid4 @dataclass class MemoryRecord: memory_id: str namespace: str kind: str content: str metadata: dict[str, Any] = field(default_factory=dict) created_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) updated_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) expires_at: Optional[str] = None @dataclass class MemoryReceipt: memory_id: str operation: str created_at: str preview: str = "" class MemoryStore: def __init__(self) -> None: self._records: dict[str, MemoryRecord] = {} def save(self, record: MemoryRecord) -> MemoryReceipt: self._records[record.memory_id] = record return MemoryReceipt( memory_id=record.memory_id, operation="save", created_at=record.updated_at, preview=record.kind, ) def update( self, memory_id: str, content: str, metadata: Optional[dict[str, Any]] = None, ) -> MemoryReceipt: record = self._records[memory_id] record.content = content if metadata: record.metadata.update(metadata) record.updated_at = datetime.now(timezone.utc).isoformat() return MemoryReceipt( memory_id=memory_id, operation="update", created_at=record.updated_at, preview=record.kind, ) def get(self, memory_id: str) -> MemoryRecord: return self._records[memory_id]这段代码的主要意图是保证“操作回执”和“记忆正文”分离。MemoryReceipt里没有content字段,只有preview,默认是 kind。
3.3 Runtime 在 Agent 生命周期里触发记忆操作
agent_runtime.py负责把记忆操作挂到 Agent 的关键节点上,例如任务开始、工具调用完成、任务结束。
# agent_runtime.py from memory_store import MemoryRecord, MemoryStore class ZeroMemRuntime: def __init__(self, store: MemoryStore) -> None: self.store = store self.namespace = "default" def on_task_start(self, task_id: str, content: str) -> MemoryReceipt: record = MemoryRecord( memory_id=task_id, namespace=self.namespace, kind="task_state", content=content, ) return self.store.save(record) def on_tool_success(self, memory_id: str, tool_result: str) -> MemoryReceipt: # 工具结果是事实型数据,先保存在外部,不全量进入 prompt return self.store.update(memory_id, tool_result) def recall_for_prompt(self, memory_ids: list[str]) -> str: # 只有当模型真的需要内容时,才把正文拿出来,并做长度控制 lines: list[str] = [] for mid in memory_ids: record = self.store.get(mid) content = record.content if len(content) > 200: content = content[:200] + "..." lines.append(f"[memo:{mid}] {record.kind}: {content}") return "\n".join(lines)recall_for_prompt是显式读取接口,它不是零 token。真正零 token 的是on_task_start和on_tool_success:它们执行完后,Prompt 里只需要放一行操作回执,不需要放正文。
3.4 用主流程验证行为
main.py模拟一个没有真正 LLM 的简化流程,用于演示零 token 记忆操作。
# main.py from agent_runtime import ZeroMemRuntime from memory_store import MemoryStore def main() -> None: store = MemoryStore() runtime = ZeroMemRuntime(store) # 任务启动:写入状态,不向 prompt 展示正文 receipt = runtime.on_task_start("task-001", "用户正在申请退款,流程到第二步") print("save receipt:", receipt.to_context()) # 工具成功:更新结果,同样不展示正文 updated = runtime.on_tool_success("task-001", "退款状态: 审核通过") print("update receipt:", updated.to_context()) # 模型需要决策时,才主动读取正文 context = runtime.recall_for_prompt(["task-001"]) print("recall context:", context) if __name__ == "__main__": main()运行输出:
save receipt: [memo:task-001] save (task_state) update receipt: [memo:task-001] update (task_state) recall context: [memo:task-001] task_state: 退款状态: 审核通过前面两行是记忆操作,不会把“用户正在申请退款”写进 prompt;第三行是决策前显式读取,这时才产生内容 token。这个区分的价值在于:大量状态维护类操作不再消耗正文 token,只有真正需要被模型理解的时刻才付出内容成本。
3.5 接入真实 LLM 时的挂载位置
真实项目里,不要把记忆操作散落在各个工具函数中。建议在 Agent Runtime 里提供统一事件钩子:
class AgentRunner: def execute_tool(self, tool_name: str, payload: dict): result = call_tool(tool_name, payload) if result.success: zero_mem.on_tool_success(payload.get("memory_id"), result.content) else: zero_mem.on_tool_error(payload.get("memory_id"), result.error) return result只要工具函数能返回一个memory_id,Runtime 就能在工具执行完成后自动更新外部状态,不需要模型把工具结果重新复述一遍。这个挂载点必须在调用 LLM 生成下一轮回复之前完成,否则模型拿到的仍可能是旧状态。
4. 关键参数设计和容量估算
4.1 记忆系统需要关注哪些参数
实际生产时,零 token 记忆系统不能只有 save 和 update,还需要一套参数控制记录生命周期。
| 参数 | 默认值 | 作用 | 调大影响 | 调小影响 |
|---|---|---|---|---|
| max_memory_items | 1000 | 单个 namespace 最大记录数 | 占用更多存储,查询变慢 | 可能丢历史状态 |
| retention_days | 30 | 记录保留天数 | 历史信息更全 | 状态容易过期 |
| compaction_threshold | 200 | 单条内容超过该长度触发压缩 | 保留更多细节 | 损失细节 |
| dedupe_key | 无 | 用于识别重复记录的字段 | 减少重复 | 重复率升高 |
| max_recall_items | 10 | 一次读取最多进入 prompt 的记录数 | 信息更多 | 更省 token |
在 Zero-Mem 风格设计里,关键是不要把max_memory_items当成上下文长度限制。因为记忆正文不在上下文里,存储大小和 token 大小是两个维度。存储可以很大,进入 prompt 的只能是max_recall_items条紧凑结果。
4.2 一个可计算的 token 节约量示例
假设一个长期 Agent 任务累计产生了 80000 token 的记忆正文。如果使用全量拼接方案,每轮对话都要把 80000 token 带入上下文。
使用 Zero-Mem 后,每轮只带操作回执。假设每轮有 3 条回执,每条约 15 token,那么每轮记忆相关 token 约 45 token。按 100 轮计算:
全量拼接:80000 * 100 = 8,000,000 token Zero-Mem:45 * 100 = 4,500 token 节约比例:(8,000,000 - 4,500) / 8,000,000 ≈ 99.94%这个例子用来理解数量级,不是固定结论。真实项目的回执数量、召回内容、工具调用本身都会影响最终数值。
4.3 学习环境与生产环境的差别很重要
本地演示使用内存字典就够了,生产环境需要替换成真正的存储引擎,还要考虑并发、持久化和数据安全。
- 本地:Python
dict或 SQLite 文件,验证逻辑为主。 - 开发:SQLite 或 PostgreSQL,增加日志和调试接口。
- 测试:独立数据库,保留测试数据,做批量验证。
- 生产:分布式存储或向量库,至少开启备份、监控和权限控制。
不要把本地代码直接部署到生产。尤其不要把存有用户隐私的记忆记录放在无鉴权的文件里。
4.4 忘记比存储更重要
很多记忆系统只关心“写不写”,不关心“什么时候删”。结果长期运行后,外部存储里积累了大量过期记录。这些记录虽然不占 prompt token,但会占用数据库容量,也会让按 ID 读取时难以判断哪一条是最新状态。
建议在每条记录上保存expires_at,并定期执行清理。清理逻辑也属于确定性操作,完全可以放在后台任务中,不需要模型参与。过期策略至少要有三种:按时间过期、按任务结束过期、按业务事件失效。
5. 常见坑和排查链路
5.1 记忆操作写进去了,但模型明显不知道
现象:Agent 执行完任务状态保存后,下一轮仍问“我们现在处理到哪一步”。
可能原因:把记忆操作结果放到了 prompt 之外,但模型需要做出决策时,并没有调用recall_for_prompt读取内容。
检查方式:打印每轮实际进入 prompt 的内容,确认其中是否包含[memo:task-001]或正文摘要。
解决方案:区分“维护型记忆操作”和“决策型记忆读取”。每次构建 prompt 前,明确列出模型本次必须知道的事实,用recall_for_prompt或工具调用读取这些事实。不要把零 token 误解成“模型永远不用看内容”。
5.2 读取到的是旧状态
现象:工具调用已经更新了记录,但模型看到的内容仍是上一次的。
可能原因:MemoryStore.update修改的是新对象,但读取时机太早;或者运行时把整份记录缓存到了本地,更新没有同步缓存。
检查方式:在 update 和 recall 之间夹一条日志,打印 record.updated_at。
解决方案:统一从 store 读取,不要自己维护一份 prompt 侧缓存;确需缓存时,要监听更新事件并失效。
5.3 重复写入导致记录膨胀
现象:同一用户偏好被保存了 20 次,每次都是新 ID。
可能原因:缺少dedupe_key,save每次生成新记录。
检查方式:统计同一 namespace 下 kind 为user_pref的记录数和内容相似度。
解决方案:保存前先按dedupe_key查询;存在相同 key 就执行 update,否则 save。这个逻辑放在 Runtime 或 Repository 层,不交给 LLM。
5.4 把工具调用本身的 token 也算成了零 token
现象:文档里写“零 token”,但账单上仍然有函数调用 token。
原因:模型要调用外部工具或者返回一个 tool_call,底层仍然需要生成结构化 token。Zero-Mem 消除的是记忆正文重复携带的 token,不是模型与外部世界通信的控制 token。
解决方案:在成本统计中分三列:系统提示词、输入正文、工具调用控制。Zero-Mem 只优化“输入正文中的记忆内容”这一列。
5.5 排查路径总结
遇到记忆相关异常时,建议按下面的顺序排查:
- 先确认记忆操作是否真的执行成功,看 store 里的记录和 updated_at。
- 再确认操作回执是否放进了 prompt,以及回执格式是否被模型理解。
- 然后确认需要决策时,是否显式读取了正文,而不是只放了回执。
- 最后确认没有并发写入把新记录覆盖回旧值。
这个顺序从确定性代码到模型输入逐层推进,能快速定位是 Runtime 问题还是 prompt 问题。
6. 生产落地检查清单和扩展方向
6.1 发布前检查清单
无论用现成框架还是自研 Runtime,上线前建议逐条核对:
- 记忆正文是否真的没有默认拼进 prompt。
- 每个操作是否有返回回执,回执是否不包含敏感正文。
- 是否有过期清理任务,清理逻辑有没有操作日志。
- 是否有
dedupe_key,重复写入是否会被合并。 - 读取接口是否限制
max_recall_items和单条长度。 - 是否支持按 namespace 隔离不同 Agent 或租户。
- 是否有持久化、备份和回滚方案。
- 是否记录了
memory_id -> content的变更历史。 - 是否需要为正文内容做脱敏或加密。
- 是否在成本监控里区分记忆操作 token 和决策读取 token。
6.2 扩展方向:从零 token 操作走向分层记忆
Zero-Mem 可以继续扩展为分层记忆系统。
第一层是操作层,负责确定性读写,对应前面实现的 Runtime。第二层是索引层,把记忆 ID 和关键词、标签、向量索引关联起来,让召回更快。第三层是检索压缩层,当模型确实需要读取正文时,先用摘要或抽取结果压缩内容,减少进入上下文的大小。第四层是策略层,决定哪些记忆必须实时可见、哪些只保留摘要、哪些可以直接过期。
这套分层思路和 LLM 工程里的外部知识库、LLM Wiki、RAG 方案可以结合使用。区别在于,Zero-Mem 优先解决的是“记忆操作”的效率和成本,外部知识库优先解决的是“知识如何被检索到”。两者不冲突,可以叠加。
6.3 对这个方案最现实的判断
Zero-Mem 不是银弹。它适合长周期、多步骤、状态更新频繁的 Agent 场景;不适合一次几句话就结束的轻量对话。因为引入外部存储和 Runtime 也需要开发与维护成本。
落地时最值得把握的判断是:凡是确定的、可编码的、不需要模型理解的记忆维护工作,尽量移出上下文;凡是模型必须基于事实做决策的读取,仍然要精心设计 prompt 和压缩策略。把这条界线划清楚,Zero-Mem 的收益才会真正体现出来。推荐先跑通上面的最小系统,再用真实历史记录做一轮 token 对照统计,最后再决定哪些记忆操作值得外部化。