尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

零Token记忆:LLM Agent上下文外置的工程实践

零Token记忆:LLM Agent上下文外置的工程实践 LLM Agent 跑多轮任务时最费钱的往往不是模型“正在思考”的那段输出而是被反复塞进上下文的记忆内容。Zero-Mem 这个方向要解决的问题非常具体它把 Agent 的记忆操作做成零 Token 开销让模型在执行“保存、读取、遗忘”这类动作时不再需要把完整记忆文本重复拼进 Prompt。这个设计适合两类人一类是已经在用 LangChain、自建 Agent 流程、或者自己封装 API 调用明显感觉到长对话成本上升的开发者另一类是把 Agent 部署到本地模型上下文长度有限想尽可能把记忆外置的用户。这篇文章不讨论某个现成库的安装命令而是把这个方向的核心机制、最小实现、参数取舍和排查链路拆开讲一遍。你会发现零 Token 不是玄学它只是一个更严格的状态管理层。1. 先判断 Zero-Mem 解决什么问题1.1 上下文窗口和记忆的错位大模型本身没有记忆。你每次调用接口或者本地模型生成内容时模型只能看到当前 Prompt 里的信息。为了让 Agent 看起来“记得住”最常见的做法是把历史对话、事实摘要、任务进度全部拼在系统提示词里。这种方法在短任务里没问题。一旦任务变长问题就非常明显每一轮都要重复传入相同记忆Token 消耗线性增长。记忆越长模型注意力越分散关键信息反而容易被忽略。响应时间变长因为模型需要处理的前缀文本越来越多。很多长上下文的中间历史其实是冗余的。更麻烦的是Agent 场景里的记忆不只有对话历史还包括任务状态、临时记录、外部工具返回的结果。比如一个检索类 Agent它可能要把上一轮查到的数据、当前正在处理的文件路径、用户偏好全部保留下来。如果用传统方式这些都会被拼进上下文。上下文窗口再大也会很快被耗尽。1.2 Zero-Token 记忆操作的关键定义Zero-Mem 设计里的“Zero-Token”并不是说整个 Agent 不需要消耗任何 Token。模型生成回复仍然需要 Token。真正省掉的是“记忆内容被重复复制进上下文”的那部分 Token。核心思路是这样记忆内容存在模型外部模型只负责发出“我想保存这个”“我想读取那个”“这个可以删了”的操作指令。真正的内容搬运、存储、合并、删除都由外部状态层完成。模型不直接看到完整记忆它看到的只是一个很小的事件标记或者槽位编号。举个例子。传统写法是这样系统提示词 请记住用户的偏好喜欢简洁回复、使用中文、开发语言是 Python。 当前任务写一个爬虫脚本。每一轮调用这段偏好会被完整传入。如果记忆有 1000 条那每轮都要多消耗 1000 条文本的 Token。Zero-Mem 的写法变成系统提示词 当用户提到偏好时调用 mem_write(slot, value)。 当需要了解用户偏好时调用 mem_read(slot)。 当前任务写一个爬虫脚本。 用户偏好未直接附加在上下文中请通过 mem_read 获取。模型在某一轮调用 mem_write(reply_style, concise)框架解析这个动作之后把内容写进外部存储。下一轮 Prompt 里不会出现 concise 这个词但 Agent 框架在内部把读取出来的内容通过函数返回值提供给应用层。这样真正进入模型的 Token 就只有指令本身。1.3 这个方案适合哪些场景Zero-Mem 最值得用的场景是那些“记忆量增长快、但每轮真正需要调用的记忆不多”的 Agent 任务多轮对话助手用户历史偏好多但每轮只需要其中少数几条。流程型 Agent需要跨步骤保存任务状态但状态值很长。自主规划 Agent需要不断更新执行计划而不希望每轮把完整计划贴回去。本地推理环境上下文窗口有限无法承受长摘要。反过来如果 Agent 只有三五条短记忆每次都完整传进上下文可能更方便。零 Token 设计会额外增加存储层和函数调用解析的复杂度这时候反而没必要。2. 核心设计把记忆操作拆成零上下文字段2.1 状态外置与槽位管理要把记忆做得不占 Prompt第一步是让记忆内容与模型输入彻底分离。也就是说Agent 的 Prompt 里只出现“槽位名”或者“记忆事件”不出现“记忆正文”。我一般会把记忆拆成槽位。槽位就是有名字的容器比如user_pref、task_status、current_file。每个槽位保存一个结构化或半结构化对象。模型需要保存内容时调用写入工具需要获取内容时调用读取工具需要清空时调用删除工具。槽位管理的好处是可预期。你不用让模型面对一个无边界的大文本库而是给它一组清晰的键。模型只需要记住“我应该从 user_pref 里拿用户偏好”不需要把偏好内容记下来。2.2 读取、写入、遗忘三个动作一个最小的 Zero-Mem 记忆系统只需要三个操作写入把值存到某个槽位。模型发出的指令形如mem_write(slotuser_pref, value简洁中文回复)。解析之后这句话的 value 部分不会作为普通文本在下轮 Prompt 中再次出现。读取把某个槽位的值取出来。模型发出的指令形如mem_read(slotuser_pref)。框架读取外部存储后把结果通过函数返回值交给应用逻辑再由应用逻辑决定要不要展示给模型。遗忘删除某个槽位或者对槽位执行过期清理。模型一般不会主动清理需要由外部定时策略处理比如 TTL 过期或者任务完成后批量清空。这三个动作的解析成本极低。模型只是在生成 JSON 或一段结构化指令不需要生成完整记忆内容。真正的内容拷贝发生在外部存储层完全由代码控制。2.3 为什么这类设计能真正降到零 Token要理解这一点需要区分“模型看到的 Token”和“系统处理的数据”。零 Token 指的是前者。记忆正文不进入模型输入因此它不会形成 Token 消耗。哪怕记忆正文有几万字只要它没有被拼在 Prompt 里从模型 Token 计费的角度看它就是零开销。实际落地时有的人会混淆“零 Token”和“模型不能感知记忆”。一个设计良好的 Agent确实可以让模型感知不到具体记忆内容只感知到“有没有记忆槽位可用”。这样模型就不用承担携带大量事实的开销。注意这里的零 Token 只针对记忆重复进入 Prompt 的部分。如果模型调用工具后工具返回值又被拼进下一轮 Prompt那这部分 Token 依然会被计算。所以实现时要严格区分“工具执行结果”和“记忆正文”。3. 本地可复现的最小实现3.1 环境准备因为 Zero-Mem 是一个架构方向不是某个具体软件包所以环境要求很低。我建议先在本地用一个最小项目做验证不急着接大模型。建议环境Python 3.10 或更高版本。只要标准库即可不需要安装大型框架。可选安装pydantic做数据结构校验但不是必须。如果后面想接向量检索再考虑chromadb或sqlite-vss。内存存储非常适合第一版验证。验证通过后再换 SQLite 或 Redis 做持久化。不要一开始就把记忆系统做成分布式否则连问题出在哪都很难判断。3.2 最小存取代码示例下面是一个演示 Zero-Token 记忆操作的伪实现。它不包含复杂检索核心就是展示“模型调用解析 外部存储”的结构# 这是一个演示 Zero-Token 记忆操作的伪实现 from typing import Any from dataclasses import dataclass, field dataclass class MemoryManager: _slots: dict field(default_factorydict) def mem_write(self, slot: str, value: Any): # 值只保存到外部状态不会被自动拼入 Prompt self._slots[slot] value return {ok: True, slot: slot, stored: True} def mem_read(self, slot: str): # 读取时也不主动拼接大段文本 return self._slots.get(slot, None) def mem_forget(self, slot: str): self._slots.pop(slot, None) return {ok: True, slot: slot, forgotten: True} def snapshot(self): # 方便排查时查看状态 return dict(self._slots)这段代码本身不涉及模型但它定义了一个关键原则mem_write存储的是“值”而模型每轮需要携带的只是“调用指令”。如果你把mem_write(user_pref, value)的返回值再拼回 Prompt那反而是把零 Token 实现破坏了。3.3 接入一次 LLM Agent 调用接入 LLM 时最简单的做法是让模型输出一个结构化动作。比如你调用模型Prompt 长这样你是任务型 Agent。 如果用户提供了需要长期保留的信息请输出 JSON 动作 {action: mem_write, slot: user_pref, value: 喜欢的语言是 Python} 如果之后需要获取用户偏好先检查是否有 mem_read 动作可用。 不需要在回答中重复列出所有记忆内容。模型可能输出{action: mem_write, slot: user_pref, value: 喜欢的语言是 Python}你的代码解析这个 JSON调用MemoryManager.mem_write然后把值存入外部存储。下一条用户消息进来时Prompt 里不需要手动附加“喜欢的语言是 Python”而是修改系统提示词告诉模型当需要用户偏好时调用 mem_read(user_pref)。整个过程中模型并没有真正看到那个偏好的完整值但应用层可以在执行任务前用mem_read取出该值供代码逻辑使用。如果应用层需要把读取结果暴露给模型需要先确认这个结果是否会带来新的 Token 开销。3.4 用 Token 计数验证是否真的减少只靠“感觉”不够落地前要量化。我建议在验证阶段记录三层数据传入模型的完整输入字符数。模型输出字符数。外部存储里记忆正文的字节数。对比场景是传统方案把记忆正文拼进系统提示词。Zero-Mem 方案记忆正文存在外部存储模型只看到槽位操作指令。下面是一个模拟表格方案单轮 Prompt 大小50 轮积累记忆正文实际体积传统上下文记忆每轮附加所有历史记忆文本不断膨胀Token 成本线性增长存在于模型上下文Zero-Mem每轮只传槽位操作和少量控制指令Token 增长与记忆正文解耦存在于外部存储不影响上下文如果测试结果表明 Prompt 体积没有明显下降那就说明你在某个环节把记忆正文拼回去了。常见罪魁祸首是模型调用工具后把工具返回的全部内容加入下一轮消息历史。4. 生产环境怎么调整参数和接口4.1 参数表槽位数、TTL、索引、持久化Zero-Mem 落地到生产环境时要管理的不是“记忆内容”而是“槽位的生命周期”。建议用表格明确参数参数推荐初值调整方向说明槽位数上限64任务类型越复杂可以把上限调大防止外部存储无限膨胀槽位值大小上限4KB长文本记忆需要调大超过限制时应该拆分而不是硬塞TTL默认 24 小时对话型任务可以短一些防止过期状态干扰新任务索引方式仅键值检索量变大后加全文索引不要一开始就上向量检索持久化进程内内存生产环境至少用 SQLite避免进程重启后全部丢失这些参数的关键不是“越大越好”而是让 Agent 行为可预期。槽位太多模型不知道用哪个槽位太少状态又放不下。从 64 个槽位开始配合固定命名规则通常能覆盖大多数任务。4.2 从单进程到记忆服务当 Agent 从脚本变成服务时记忆管理不能再放在同一个函数内存里。你有两个选择第一把记忆模块换成一个独立类挂在 Agent 进程里。适合单实例部署。第二把记忆操作封装成 REST API让多个 Agent 共享同一份状态。适合多个 Agent 实例并发运行。REST API 的接口参考设计POST /mem/write Body: {slot: user_pref, value: 喜欢简洁回复} GET /mem/read?slotuser_pref POST /mem/forget Body: {slot: user_pref}这样 Agent 进程本身不需要保存任何持久状态。模型发出的mem_write动作被解析后你的代码调用/mem/write接口完成真正的写操作。这个接口可以复用 Redis、SQLite或者一个普通数据库服务。4.3 并发写入和一致性处理Agent 并发执行时容易出现两个问题多个任务同时写入同一槽位后写入的覆盖先写入的。一个任务读取槽位时另一个任务正在更新读到旧值。处理方式有几层如果对一致性要求不高普通覆盖就行。如果需要保留历史可以给槽位加版本号。如果要求严格一致使用数据库事务或者 Redis 的原子操作。开发阶段不需要过度设计。先把写入和读取接口做稳再根据真实冲突频率逐步加锁。大量 Agent 任务并发时我建议先看日志里是否有“槽位被覆盖”这种事件而不是提前给所有接口加锁。锁会增加延迟很多时候反而影响 Agent 执行速度。5. 常见失败链路与排查顺序5.1 现象记忆好像没生效模型明明调用过mem_write但后续任务没有使用这个槽位。先不要怀疑模型能力按这个顺序排查先确认动作解析是否成功。很多框架把模型输出包在 Markdown 代码块里导致解析失败。再确认存储层是否写入。直接打印MemoryManager.snapshot()看结果。接着检查读取时机。是否有异步任务在写入前就去读取了。最后看 Prompt 是否给了模型足够的“触发信息”。如果模型根本不知道什么时候调用mem_read它不会主动读取。这个问题里我遇到最多的其实是“解析失败”。模型会输出{action: mem_write, slot: user_pref, value: Python}但如果你用正则去匹配actionmem_write往往匹配不到。用严格 JSON 解析反而更靠谱。5.2 现象Token 不减反增有时候把记忆外置之后Token 消耗没有下降甚至更高。这可能由三种情况导致第一工具返回值被加入上下文。mem_read返回一大段记忆文本框架又把它塞回消息历史本质还是把记忆传入模型。第二系统提示词写太长。你为了解释记忆调用方式写了一大段说明单轮只省下几十个 Token却额外增加了两百个说明性 Token。第三模型反复调用工具每次都生成完整的 JSON 指令。虽然每条指令不长但几十轮下来也是不小开销。排查方式很简单对比每轮实际发送给模型的字符数。如果发现每轮都有大段记忆文本说明你在某处把工具返回值拼接进了消息历史。如果发现指令本身很长说明 Prompt 配置需要精简。5.3 现象外部存储和模型输出不一致模型可能在某一轮输出“用户偏好是 A”但外部存储里保存的是“用户偏好是 B”。这类问题通常不是存储错误而是模型在记忆缺失时进行了猜测。也就是说模型没有被明确告知“不知道就调用 mem_read”。这时它倾向于用自己学过的统计规律去猜而不是查询外部状态。解决办法是调整系统提示词加入更直接的约束涉及用户偏好、历史状态、任务进度时必须先调用 mem_read。 如果没有对应槽位必须明确回答“未找到相关记忆”不得编造。在本地模型上这个约束往往要重复强调。不同模型的指令遵循能力差异很大不能假设模型天然理解“读取外部记忆”这个动作。5.4 通用排查顺序任何时候先看日志再改参数。我自己常用的排查路径看原始模型输出确认模型是否真的生成了目标动作。看动作解析结果确认 JSON 或结构化指令有没有被正确提取。看外部存储内容确认写入是否发生、是否被覆盖、是否过期。看下一轮 Prompt 组装日志确认记忆正文是否被意外拼入上下文。看 Token 计数确认优化是否产生了预期效果。不要跳过第一步。很多时候问题根本就不在记忆模块而是模型并没有按你预期的方式生成mem_read指令。注意排查时优先使用小样本复现。构造一个“保存名字 - 换话题 - 再次询问名字”的最小用例能更快定位问题而不是直接跑完整的长流程 Agent。6. 实战结论和落地建议6.1 先跑通单条任务再谈批量Zero-Mem 这个方向真正落地时最忌讳一上来就做全功能。我建议分三步走第一步用本地内存实现mem_write、mem_read、mem_forget三个核心操作不接 LLM只做单元测试。第二步接入一个小模型或者 API让模型输出 JSON 动作程序解析并写入存储。第三步再做批量任务。批量任务比单任务多了一个关键要求输出要可追踪。每个任务实例必须使用独立命名空间否则任务 A 的槽位会覆盖任务 B 的槽位。批量跑起来之后还要加失败重试。不是所有任务都能一次成功重试时要注意先清理上次残留的槽位状态。6.2 什么情况下需要放弃零 Token 设计这个方案不是所有场景都适合。如果 Agent 的单次任务非常短记忆量也很小使用传统上下文拼接反而更简单。零 Token 设计要额外处理动作解析、外部存储、参数校验这些都会增加代码复杂度。如果任务要求模型必须对全部记忆内容进行整体推理零 Token 设计也不合适。比如你让模型基于 100 条历史记录做综合分析那模型就必须看到这 100 条内容外部存储帮不上忙。如果外部存储不稳定比如内存存储频繁丢失、Redis 服务抖动那 Agent 会出现“明明保存了却读不到”的严重问题。稳定性要求高的场景应该给记忆操作加上明确的错误重试和补偿机制。6.3 后续演进方向零 Token 记忆操作是记忆系统的基础层不是终点。跑稳之后可以逐步加上这些能力记忆重要性排序高频使用的槽位自动排在更易读取的位置。记忆摘要缓存低频记忆以摘要形式存放摘要本身不占 Prompt。结构化索引槽位数量大时增加按时间、任务类型、标签筛选的能力。分析日志与 Token 审计记录每一次mem_read和mem_write的执行时间、调用方、返回结果方便定位成本和状态问题。这些扩展仍然要遵守同一个原则记忆正文默认不进入模型上下文。只有经过显式决策需要让模型看到某条记忆时才临时注入用完即丢弃。我第一次实践 Zero-Mem 时最大的收获不是省了多少钱而是改变了设计思维。以前做 Agent 第一反应是“怎么把更多信息塞进上下文”现在第一反应是“能不能放在外面用到哪条读哪条”。这种思维转变的效果比单纯省 Token 更值得长期坚持。建议你先拿一个简单的偏好记忆场景做实验跑通后再逐步扩大应用范围这个方向很值得投入时间。
返回列表