Skip to main content

05 - 文件系统式记忆

前置02 篇的抽取式写入、04 篇的检索管线。本篇是这两篇的替代路线。

本篇回答:不建向量库、不建图库,让模型自己读写一个目录,能走多远。

本篇会用到的词

意思
客户端执行工具模型只发出操作请求,实际执行在你的应用里。文件读写、路径校验都是你的代码在做
即时检索(just-in-time)不预先把所有相关信息塞进上下文,而是让模型在需要时自己去读。文件式记忆的核心主张
memFSLetta 对"记忆文件系统"的叫法,其记忆是一个 git 仓库而不是数据库表
记忆块(memory block)Letta v1 的记忆单元:一段带标签的文本,常驻在系统提示词里
路径穿越../ 之类的相对路径跳出限定目录,读到不该读的文件

一、这条路线的主张

前四篇里的记忆层有一个共同结构:系统替模型决定该记什么、该取回什么。抽取器挑事实、检索器挑条目,模型只是被动接收结果。

文件式记忆把这个决策权交回给模型:给它一个目录和六个文件操作,让它自己决定写什么文件、什么时候去读。

分野不在存储介质,在"谁决定记什么和取什么"检索式 —— 01 到 04 篇系统:抽取+ 检索模型:被动接收结果向量库 / 图库系统读写+ 行为可控,能做配额、审计、强制注入+ 检索延迟稳定,不占模型轮次− 抽取和检索都可能挑错,模型无从纠正文件式 —— 本篇系统:只提供六个文件命令模型:自己决定写什么、读什么一个目录模型读写+ 没有抽取损失,模型按自己需要组织+ 记忆人可读可手改,排查成本极低− 每次读写都占一个模型轮次,慢且贵
"每次读写占一个模型轮次"是文件式最硬的约束:检索式一次并行检索几十毫秒拿回十条,文件式要 view 目录、view 文件、可能再 view 另一个文件,每一步都是一次完整的模型往返。

二、Anthropic memory 工具

工具声明只有两个字段,没有 input_schema —— 输入格式内置在模型里:

{"type": "memory_20250818", "name": "memory"}

2.1 六个命令

命令参数行为需要注意的错误语义
viewpath,可选 view_range目录返回两层深的列表(大小 + 路径,tab 分隔);文件返回带 6 位右对齐行号的内容空目录的第一次 view 不是错误。超过 16,000 字符的文件会被截断,模型会跟一次带 view_range 的读
createpath, file_text建文件,已存在则覆盖覆盖前应自己留备份 —— 协议不负责
str_replacepath, old_str,可选 new_str替换唯一一处;省略 new_str 即删除该段old_str 出现 0 次或多次都必须报错并说明,不能自作主张替换第一处
insertpath, insert_line, insert_text插到第 insert_line 行之后,0 表示插到开头行号越界要返回带合法区间的错误信息
deletepath递归删除文件或目录必须拒绝删除 /memories 根目录本身
renameold_path, new_path重命名或移动目标已存在时报错,不要覆盖

str_replace 那条"多处匹配必须报错"看着琐碎,实际是这套协议里最重要的一条安全设计:模型对文件内容的记忆是不精确的,它以为唯一的字符串常常出现多次。静默替换第一处会造成难以察觉的记忆损坏。

2.2 一次典型交互

五个方框=五次模型往返。检索式记忆完成同样的事只需要一次① view /memories看目录里有什么API 自动提示它这么做② view 某个文件读回进度或用户偏好长文件要分段读③ 干正事这一步才是用户要的可能自己还要调工具④ str_replace更新进度文件多处匹配会被打回⑤ 回复用户中断也没关系进度已经落盘了①不需要你在提示词里写 —— 只要 tools 里带了 memory 工具,API 会自动往系统提示词里加一段记忆协议,大意是「动手之前先 view 你的记忆目录」「随时可能被打断,没写进记忆的进度都会丢」。这也解释了为什么这条路线特别适合长任务:④是显式的、可中断的存档点,而不是隐式的会话状态。
官方给的多会话开发范式正是围绕④设计的:第一个会话先建好进度日志和功能清单,之后每个会话开头读、结尾写。判断一个功能"完成"的标准是端到端验证通过,不是代码写完 —— 否则进度日志会越来越不可信。

2.3 路径穿越是你的责任

/memories 只是一个前缀,你的处理器把它映射到真实存储。模型给出的 path不可信输入

from pathlib import Path

MEMORY_ROOT = Path("/srv/agent-memory/u_1024").resolve()

def resolve_memory_path(model_supplied: str) -> Path:
# 1) 前缀必须是 /memories —— 先挡掉一眼就不合法的
if not model_supplied.startswith("/memories"):
raise ValueError("path must start with /memories")

# 2) 映射到真实路径后 **必须 resolve()**:
# resolve() 会把 ../ 和符号链接都展开成规范形式。
# 只做字符串检查挡不住 /memories/a/../../../etc/passwd,
# 也挡不住 /memories/link -> /etc 这种符号链接。
candidate = (MEMORY_ROOT / model_supplied.removeprefix("/memories").lstrip("/")).resolve()

# 3) 展开之后再判断是否仍在根目录内。顺序不能反 —— 先判断再展开等于没判断。
if not candidate.is_relative_to(MEMORY_ROOT):
raise ValueError(f"path escapes memory root: {model_supplied}")

return candidate

除了 ../,还要挡 URL 编码形式(%2e%2e%2f)和反斜杠形式(..\\)。官方安全清单里另外三条:

  • 别存敏感信息。模型通常会拒绝把密钥写进记忆,但这是倾向不是保证;要强保证就在写入前做正则剥离
  • 限制文件大小与 view 返回长度,让模型用 view_range 分页读,否则一个膨胀的记忆文件会一次性吃掉上下文
  • 定期清理长期没被访问的文件 —— 也就是 03 篇第五节的遗忘,文件式一样躲不掉

三、Letta 的转向:从数据库记忆块到 git 目录

Letta(原 MemGPT)是"记忆块"这个概念的来源:一段带标签的文本常驻在系统提示词里,模型用专门的工具改写它。这套设计已经换代了。

letta-ai/letta 仓库现在只剩一个落地页,README 明写 V1 服务端"退役"、源码保留在 archive 分支且不再修复。当前实现在 letta-ai/letta-code(TypeScript,2025-10-25 建仓),记忆的形态变成了一个 git 仓库

// src/agent/memory-git.ts 的文件头注释
/**
* Git operations for git-backed agent memory.
*
* When memFS is enabled, the agent's memory is stored in a git repo
* on the server at $LETTA_MEMFS_BASE_URL/v1/git/$AGENT_ID/state.git
* ...
* This module provides the CLI harness helpers: clone on first run,
* pull on startup, commit memory writes, post-turn push for clean pending
* commits, and status checks for system reminders.
*/
同一个诉求「记忆要能被审查和回滚」,两代给出的答案完全不同V1(已退役)· 数据库记忆块blocks 表label + value + 只读标记常驻系统提示词专用工具改写+ 记忆永远在上下文里,不需要检索− 容量硬受限于上下文,装不下就得摘要− 历史版本要自己建表存,没有天然的回滚− 每改一次记忆,前缀缓存全部失效V2(letta-code)· 服务端 git 仓库state.git每个 agent 一个仓库启动 clone / pull写入 commit,回合末 push+ 版本、diff、回滚、责任人全部现成+ 容量不受上下文限制,按需读文件− 引入了并发合并问题:两端同时改要处理− 网络故障、非快进推送都要重试与修复
V2 里仍保留了记忆块的概念(MEMORY_BLOCK_LABELS = ["persona", "human"]),但它退化成两个默认块;真正承载长期记忆的是 memFS 那个 git 目录,且 memory_filesystem 这个块被标成只读,模型不能直接改写它。

memory-git.ts 里那几条正则很说明问题 —— 它们枚举了这套方案在生产上会遇到的全部麻烦:

// 非快进推送:两个客户端同时改了同一个 agent 的记忆
const NON_FAST_FORWARD_PUSH_ERROR_RE =
/(non-fast-forward|fetch first|failed to push some refs|updates were rejected|...)/i;
// 历史不相关:本地仓库和服务端仓库不是同一条历史,通常是重装或手工操作留下的
const UNRELATED_HISTORY_PULL_ERROR_RE = /(no common commits|refusing to merge unrelated histories)/i;
// 网络瞬时故障:520-524 是 CDN 层错误,要重试而不是报错给用户
const RETRYABLE_GIT_HTTP_ERROR_RE = /(?:\bHTTP\s+(?:520|521|522|523|524)\b|...)/i;

这是选型时要正视的代价:把记忆做成 git 仓库,等于把分布式版本控制的全部失败模式引进了记忆层。换来的是免费的版本历史、diff 和回滚 —— 对"记忆被写脏了怎么办"这个问题,这是目前最干净的答案。

四、第三种形态:把文件系统和向量检索缝在一起

前两种形态各有一个硬伤:memory 工具靠模型看文件名猜该读哪个,文件一多就开始猜错;Letta 的 git 目录同理。字节跳动火山引擎的 volcengine/OpenViking(33,440★,AGPL-3.0)给了第三个答案 —— 保留文件系统的形态,但让检索是向量的

记忆、资源、技能统一挂在一个 viking:// 协议下,Agent 用 ls / tree / find 浏览,而不是查一个黑盒向量库:

viking://
├── resources/ # 项目文档、代码仓、网页
│ └── my_project/
└── user/{user_id}/
├── memories/preferences/ # 抽取出来的用户偏好
├── resources/
├── skills/
└── peers/

关键设计不是这棵树,而是树上每一层都带自己的摘要:

写入时就分三层,检索时逐层下钻 —— 每一步都在几百 token 上做判断,而不是几万写入:每个目录和文件都生成三层L0 摘要 · .abstract约 100 token,只用来判断相关不相关L1 概览 · .overview约 2K token,结构与要点,够拿来做计划L2 全文原始内容,只在真的要读时才加载检索:目录递归,不是一次拍平① 向量检索先定位到得分最高的目录② 读该目录的 L0/L1,判断要不要往下走③ 只有最后命中的那个文件才加载 L2整条下钻路径被保留,结果不对时能看到是哪一步走偏的这正好补上了 memory 工具那条路线的短板:文件名不够判断时,还有一段 100 token 的摘要可以判断;而它又没有退回黑盒向量库 —— 目录结构还在,人还是能直接看。
「每个目录自带摘要」这一点,和上下文工程专题 04 篇第四节说的「指针的元数据要足够模型做判断」是同一条原则的完整实现 —— 那里给的是手写几行文件说明,这里把它做成了写入管线的一部分。

会话结束后,OpenViking 异步把用户偏好和 Agent 经验抽取进长期记忆 —— 也就是 02 篇第四节的后台写入。完整拆解(含它的检索结构、没解决什么、以及和腾讯那套的对照)在 08 篇

两点要注意

  • 许可证是 AGPL-3.0crates/ov_cliexamples 两处单独是 Apache-2.0)。记忆层几乎总是以服务形态部署,AGPL 的网络分发条款会被触发,见 06 篇2.1 节
  • 它自报的评测数字要按 07 篇第四节那套读法看。README 给的是「接上 OpenViking 前后」的对比(例如某客户端在 LoCoMo 上从 24.20% 到 82.08%),基线是各客户端的原生表现而不是别的记忆库,嵌入模型也是自家的 —— 这类数字能说明「接了比不接强」,不能用来和其他记忆库横向比

五、还有一类:人写的记忆

CLAUDE.mdAGENTS.md.cursor/rules 这类文件也是文件式记忆,只是写入者是人而不是模型。它们的特点:

维度模型写的记忆人写的记忆
内容从交互中学到的事实显式的约定与规范
出错方式抽错、过期、被投毒过时(代码改了文档没改)
审查需要专门建机制走代码评审,天然有
对应记忆分型语义 + 情景程序记忆(01 篇3.2)

两者可以共存于同一个目录,但不要让模型改写人写的那部分。Letta 用 READ_ONLY_BLOCK_LABELS 做这个隔离,memory 工具这边则要在处理器里按路径前缀拒绝写入。理由回到 01 篇 3.2:程序记忆改的是行为,且没有任何一轮对话会去纠正它。

六、文件式和检索式怎么选

维度纯文件式分层文件式(第四节)检索式
记忆规模几十个文件以内。目录列表本身要进上下文上万条。靠目录分层,不需要把全部列表进上下文十万条以上没问题
读取延迟每次读一个文件=一次模型往返,数百毫秒到秒级下钻几层就是几次往返,但每次读的量很小一次并行检索,几十毫秒
检索精度靠模型看文件名猜,文件一多就开始猜错每层有 100 token 摘要可判断,比看文件名准得多有召回率指标可以优化
排查成本极低。cat 一下就知道它记了什么低。目录结构还在,且下钻轨迹被保留高。要把检索结果打进 trace 才看得见
多用户隔离靠目录隔离,简单可靠user/{user_id}/ 子树隔离靠 filter 字段,漏传就越界(02 篇第七节)
强制注入做不到。模型不去 view 你就没办法同样做不到能。安全类记忆可以绕过排序直接注入
版本与回滚git 方案天然具备要自己建要自己建历史表

"强制注入做不到"是文件式最实质的短板。过敏源、禁忌药物这类记忆,检索式可以按 user_id 精确查询后无条件注入;文件式只能寄希望于模型每次都记得去 view。API 自动加的那段记忆协议("动手之前先看记忆目录")就是在补这个洞,但它是提示词层面的约束,不是机制层面的保证。

结论:单用户、长任务、记忆条目在几十条量级 —— 选纯文件式,它的排查成本优势非常实在。记忆上万条但仍想保留「人能直接看」这个性质 —— 选分层文件式。需要强制注入某些条目、或者读取延迟必须压到几十毫秒 —— 选检索式。混用也是可行的:用文件式管「当前任务的进度与约定」,用检索式管「这个用户的长期事实」。

七、小结

  • 文件式把"记什么、取什么"的决策权交给模型,代价是每次读写占一个模型轮次
  • memory 工具的六个命令里,str_replace 的"多处匹配必须报错"是最重要的安全设计 —— 模型对文件内容的记忆不精确
  • 路径校验必须是"先映射、再 resolve()、最后判断是否在根目录内",顺序反了等于没判断
  • Letta 用 git 仓库承载记忆,换来免费的版本与回滚,代价是引入了分布式版本控制的全部失败模式
  • 人写的记忆(CLAUDE.md 一类)属于程序记忆,不要让模型改写它
  • OpenViking 给了第三种形态:保留文件系统的形态,但每层目录自带摘要、检索走向量递归下钻,补上了「靠文件名猜」这个短板
  • 文件式的硬伤是无法强制注入;安全类记忆不适合只用文件式

下一篇:06 - 开源实现横评,把两条路线上的十五个项目放在同一张表里,含四个会让人白花一周的选型陷阱。

← 回到 专题索引