不靠向量库:用文件系统为Agent做持久化记忆的两种路线

发布时间:2026/9/28 23:48:09
不靠向量库:用文件系统为Agent做持久化记忆的两种路线 做 Agent 应用开发最头疼的从来不是让模型把单次任务写好而是让它记住昨天刚定下来的那条路由规则、上次踩过的那个坑、以及用户反复强调的命名习惯。我近两个月同时用 Codex 和 Claude Code 维护同一个项目先后给它们做了两套完全不同的持久化记忆方案共同点只有一个都没上向量库。这篇文章就是这两条路线的完整记录。我会把项目背景、两套方案的落地细节、对比分析和踩坑过程都写出来。如果你正在给 AI 编程工具或类似的 Agent 应用设计记忆体系又在犹豫要不要引入向量检索这篇文章应该能帮你省下不少弯路。1. 为什么我先考虑的不是向量库而是 Agent 记忆本身1.1 这个项目的真实需求我是一个做内部工具开发的独立开发者手头有一个持续迭代了三个月的业务系统。系统本身不算大但模块之间耦合很深经常需要跨文件修改。为了提效我同时启用了 Codex 和 Claude Code 两个命令行编程工具让它们交替解决不同模块的问题。很快就发现一个问题模型确实能在一个会话里把代码写得很好但到了第二天再开一个新会话它完全不记得自己昨天改了哪些接口、为什么放弃某个方案、以及用户模板里哪些字段不允许改。模型的无状态特性决定了如果不做持久化记忆Agent 就只能是一个“高水平的临时工”。我需要存储的记忆大致分四类第一是架构决策比如“结算模块必须走 async 队列不允许同步等待”第二是编码约束比如“所有返回字段用 snake_case”第三是项目事实比如“测试环境数据库地址由 .env.test 注入”第四是踩坑记录比如“重新生成 migration 后必须手动清理 flyway 历史表”。这四类信息有一个共同特征量大不到哪里去但每一条都可能影响下一次改动的正确性。1.2 向量库方案为什么被我排除当初我也认真评估过向量库方案因为很多 Agent 记忆框架的默认架构都是“把记忆文本切成块、embedding、存入向量库、语义检索召回”。看起来很美但放到我这个项目里有四个现实问题。第一运维成本。向量库虽然有很多托管产品但要达到“随时可用、不丢数据、方便备份”的状态仍然需要引入一套额外的基础设施。对一个几 MB 的文档级记忆库来说这个代价偏高。第二embedding 的一致性问题。同一句话在不同语境下向量可能相差很远。比如“保留用户头像”和“删除用户缓存头像”在向量空间里可能非常接近但在代码开发场景下是完全相反的两个操作。语义检索的“模糊”恰恰是危险来源。第三延迟。每次会话开始都要做一次向量召回多一步网络请求就多一分失败概率。CLI 工具追求的是“开箱即用、秒级进入工作状态”我不能接受一个偶发的向量库超时拖垮整个开发流程。第四也是最关键的Agent 记忆本质上不是“模糊检索”问题而是“精确覆盖”问题。我需要的是“当时定的规则是什么”而不是“和这句话语义相似的内容”。后者适合做知识库问答前者更适合用结构化的文件系统去表达。1.3 两条不用向量库的路线确定了不碰向量库之后我分别研究了两边原生的持久化机制。Codex 这边官方支持 AGENTS.md 这种指导文件它会在每次会话启动时被自动读取Claude Code 这边则是 CLAUDE.md同样会在启动时注入上下文。两个工具都鼓励“用文件来管理长期状态”只是语法和加载时机略有差异。于是我的路线变得很清楚Codex 走“扁平记忆文件 精确命令检索”的路线Claude Code 走“分层记忆文件 脚本化统一写入”的路线。两套方案都用 Markdown 做存储介质用 grep 做检索工具不用 embedding不引入数据库把记忆变成项目里可读、可改、可提交的文本资产。2. 路线一Codex 的“文件即记忆”方案2.1 Codex 的持久化锚点AGENTS.mdCodex 启动时会自动加载项目根目录下的 AGENTS.md把它作为项目级指导文件。这个文件原本用于描述构建命令、代码风格、开发规范但它天然也可以承载“记忆索引”。我实际操作后发现一个关键点AGENTS.md 本身不适合直接塞大量记忆因为它的每一行都会被完整送进模型上下文写得太多就会挤占有效的推理空间。所以我在 AGENTS.md 里只放两样东西一是和当前任务强相关的硬性规范二是记忆文件的索引路径和读取规则。比如我的 AGENTS.md 开头是# 项目说明 这是一个订单结算系统使用 Python 3.11 FastAPI数据库为 PostgreSQL。 # 记忆索引 - 所有架构决策记录在 docs/memory/decisions.md - 用户偏好记录在 docs/memory/preferences.md - 历史踩坑记录在 docs/memory/lessons.md - 当任务涉及支付、结算、库存模块时必须先读取上述文件后再动手这样 Codex 不是被强迫“想起所有事”而是被引导到正确的记忆文件里按需读取。2.2 记忆文件的结构设计我的记忆文件不是随手写而是统一用一套模板。每个文件都包含一个元信息头部和若干条结构化记录。以 decisions.md 为例每一条决策我都要求 Codex 按这样的格式写入## 决策记录 D-023 日期2025-03-18 状态已实施 背景结算回调出现了重复通知幂等表冲突导致订单状态错乱 决策所有回调处理改为先查幂等表再更新订单状态两张表操作放入同一事务 影响回调接口延迟增加 20ms可接受为什么非要结构化因为后面检索的时候grep 到的不再是一团散文而是一条边界清晰的记录。我可以直接grep -n 幂等 docs/memory/decisions.md然后从命中行向上找到“决策”向下找到“影响”。Codex 拿到这些内容后能快速拼出上下文。preferences.md 则更简单每条一行- 日志统一使用 structlog禁用 print 调试 - API 响应字段统一 snake_case - 数据库迁移文件名必须带日期前缀这种“键值对式”的书写方式对 grep 和精确匹配特别友好。2.3 写入方式用规则约束模型而不是靠自觉给模型看模板只是第一步真正难的是让它在正确的时间点主动写入记忆。我一开始只是温和地写了一句“请记得更新记忆文件”结果 Codex 经常漏做因为在它的优先级里完成代码任务永远比维护文档重要。后来我把命令改成了条件触发式的硬规则放在 AGENTS.md 的显眼位置# 写入记忆的强制条件 当且仅当出现以下情况时你必须运行 ./memory.sh add decision ... 来追加记录 1. 你或用户做出了一个影响后续开发的架构选择 2. 你发现了一个可能导致返工的坑 3. 用户表达了一个稳定的偏好例如“以后都用 xx 库” 4. 你完成了一个大功能需要留下交接摘要 其他时刻禁止写入。配合上一小节里的模板Codex 会在满足条件时调用脚本把内容按标准格式追加进去。这个设计最核心的地方是“把记忆写入从一个模型自主行为变成了一个命令行为”成功率比靠自觉高非常多。2.4 检索方式grep 加文件名完全可控Codex 方案里检索不用向量库而是让模型自己在 bash 里执行 grep。这样做的好处是过程完全透明模型能亲眼看到命中行不会凭空“猜记忆”。实际操作中我会在 AGENTS.md 里教 Codex 一套检索套路查询记忆时 1. 先判断属于哪类决策/偏好/踩坑 2. 运行 grep -rn 关键词 docs/memory/ 3. 如果结果为空再尝试同义词或英文关键词 4. 找到相关内容后结合当前代码判断是否适用这个套路跑起来之后Codex 的“回忆”过程变得可预测。它不会像之前那样一本正经地生成一段听起来像记忆、实际是幻觉的内容因为它现在有明确的检索源。即使某个记忆没有命中它也会如实告诉你“docs/memory 里没有搜到相关内容”而不是编一个出来。2.5 这条路线的好处和边界好处非常明显文件本身就是项目的一部分跟着 git 走可以 diff、可以 review、可以回滚。一次错误的记忆写入用git checkout就能撤销。这在向量库里几乎做不到数据库记录就算删了也没有可读的历史。边界也很清楚它只适合单项目、单维护者、记忆总量在几十到几百条以内的场景。一旦你的记忆库膨胀到几千条并且内容变成大段文本而不是结构化短语grep 就开始吃力同义词召回率下降模型需要读很多无关结果。到那个阶段才真的需要考虑语义检索。3. 路线二Claude Code 的分层记忆方案3.1 Claude Code 的天然持久化CLAUDE.mdClaude Code 有着和 Codex 类似但更丰富的“说明文件”机制。项目根目录的 CLAUDE.md 会在启动时加载用户主目录下的~/.claude/CLAUDE.md也会被加载。后者适合放跨项目的通用偏好前者放当前项目的上下文。Claude Code 对 Markdown 格式的接受度很高而且它比 Codex 更擅长“顺着文档风格继续写”。这意味着你可以在 CLAUDE.md 里写很长的自然语言描述模型不会轻易搞乱结构。但这也是双刃剑文档太长时它会遗忘前面的内容所以分层就成了必选项。3.2 我把记忆拆成短期、中期、长期三层第一次给 Claude Code 做记忆时我把所有东西都塞进了一个 CLAUDE.md结果模型经常只记得文件中间的内容对文件末尾的规则视而不见。后来我把记忆拆成了三个层级。短期记忆放在项目根的handoff.md。它保存的是“一次性交接信息”比如“当前正在修改 payment_service.py还剩两个单元测试没写接口文档尚未更新”。每次新会话开始时Claude Code 读取这个文件继续干活任务结束后这个文件会被清空重写。这个层级的生命周期只有一次任务的跨度相当于人的“工作台”。中期记忆放在docs/claude-project-memory.md。它保存跨会话的架构决策和项目约定比如“所有错误码必须在 errors.py 里集中定义”。更新时机是当某个决定被确认会影响后续任务时由模型在任务结束时追加。长期记忆放在~/.claude/CLAUDE.md。它保存跨项目的通用偏好比如“我不喜欢代码里有 TODO 注释宁可写一个明确的 FIXME”“所有新项目都要带 README 和 LICENSE”。这个文件我不会频繁修改只会在用户偏好真正稳定下来后更新。3.3 用脚本统一记忆写入避免模型自由发挥Claude Code 最大的特点是有很强的工具调用能力尤其是 Bash 和文件编辑。如果只是告诉它“请更新记忆文档”它会用自己的格式写写出来的风格每次都不一样几天后文档就成一锅粥。我的方案是在 CLAUDE.md 里固定记忆操作命令要求模型在任务结束时必须运行一个 Python 脚本。CLAUDE.md 里的规则写得很具体# 记忆维护协议 当任务结束时你必须运行一次 python3 scripts/memory_tool.py add --type decision --content 内容 当用户询问历史决策时你必须运行 python3 scripts/memory_tool.py search --keyword 关键词 禁止直接修改 docs/claude-project-memory.md所有修改必须通过脚本完成。这样做的目的是把“写什么、怎么写、写到哪”全部交给脚本模型只负责提供内容。脚本内部会自己处理时间戳、去重、归类。Claude Code 执行命令很积极实测下来对这套协议的遵守率比 Codex 高不少。3.4 检索策略小文件全量读大文件精确搜Claude Code 的上下文窗口虽然大但也不能无限地把记忆文件全塞给它。我根据文件大小做了一个分档。当记忆文件小于 15KB 时我直接在 CLAUDE.md 里写“开始任务前运行cat docs/claude-project-memory.md”也就是全量读取。这样模型拥有完整的记忆地图检索出错率最低。当文件超过 15KB 后全量读取开始变得浪费 token。我会在 CLAUDE.md 里改成开始任务前根据任务关键词运行 python3 scripts/memory_tool.py search --keyword 任务相关词同时保留一个例外如果用户明确说“先看一下所有记忆”才允许全量读取。这个策略保证了日常开发的花费很稳定同时不丢失关键信息。3.5 Claude Code 的跨项目记忆优势Claude Code 这套方案比 Codex 更有优势的地方在于长期记忆的复用。因为~/.claude/CLAUDE.md是所有项目共用的我一旦写下“项目里禁止使用 print 调试”它在任何项目里都会遵守。这种偏好的跨项目迁移能力Codex 也有类似的用户级 AGENTS.md但我实际测试下来Claude Code 对用户级文件的服从度更高。可能跟它在启动时把多级说明文件按顺序拼装有关。总之如果你同时维护多个项目Claude Code 的分层路线会有天然的效率优势。4. 两条路线的硬核对比4.1 记忆载体、读写时机和检索方式一览对比维度Codex 路线Claude Code 路线锚点文件项目根 AGENTS.md项目 CLAUDE.md ~/.claude/CLAUDE.md记忆存放docs/memory 下多个扁平 Markdown 文件handoff.md claude-project-memory.md 用户级文件写入方式条件触发的 shell 脚本固定协议的 Python 脚本检索方式grep 文件内容按目录和文件名小文件全量 cat大文件脚本 search跨项目记忆弱主要是项目内强用户级文件全局生效记忆可审计性强git 可直接追踪强且分层更清晰适合场景单项目、偏代码、需要精确规则的场景多项目、偏交互、需要跨项目偏好的场景上手难度低中4.2 本质区别显式命令 vs 隐式引导Codex 的方案更像“显式命令”。它在 AGENTS.md 里写明“遇到这种情况你就去跑这个命令”模型按命令执行没有太多发挥空间。好处是可预测性极高坏处是它对复杂语义的理解较弱如果规则没有覆盖到某种情况它就不会主动联想。所以 Codex 路线的记忆规则必须写得像编程规范越机械越好。Claude Code 的方案更像“隐式引导”。它通过 CLAUDE.md 里的角色设定和协议让模型在对话中动态决定何时读记忆、何时写记忆。比如我会在 CLAUDE.md 里加一句“你是一个擅长保留上下文的工程师在动手改代码前先查看项目记忆以确认历史约束”这种描述对 Claude 系列模型特别有效。它不会把记忆当成一个必须执行的命令清单而是当成自己工作流的一部分。这两种差异直接导致了规则撰写方式的不同。给 Codex 写记忆协议我会更倾向于用“if-then”结构给 Claude Code 写则会用“场景描述 期望行动”的方式。4.3 为什么两条路线都能绕开向量库我把两条路线跑通之后反思了一下为什么向量库完全没有用武之地。核心原因是Agent 的持久化记忆本质上是一个“写入稀疏、读取明确”的系统。写入稀疏意味着一天最多产生几十条有效记忆数量级完全在文件系统可管理的范围内。读取明确意味着 Agent 读取记忆时通常已经知道自己要解决什么问题它可以带着“支付超时”这个关键词去搜而不是直接问“有没有和支付相关的历史记录”。这种情况下关键词精确匹配反而比向量语义召回更可靠。向量库真正擅长的是“你不知道关键词只能用意念搜索”的场景比如“我要找那段讨论用户增长的内容但我忘了原话”。这种需求适合全文检索引擎甚至都不一定要向量库。对 Agent 记忆来说记忆内容是我们自己写的结构也是我们自己定的直接上向量库就是过度设计。5. 实操落地一套脚本两边通用5.1 先搭一个最简单的记忆管理脚本我花了不到一小时写了一个memory_tool.py支撑了我后面的所有实验。它的核心功能只有三个写入、检索、列出分类。python3 scripts/memory_tool.py add --type decision --content 结算回调改为幂等优先 python3 scripts/memory_tool.py search --keyword 幂等 python3 scripts/memory_tool.py list --type lesson脚本内部会把内容分类写入docs/memory/{type}.md并在每一条前面加上时间戳。核心代码不长关键逻辑是这样的import argparse from pathlib import Path from datetime import datetime BASE Path(docs/memory) BASE.mkdir(parentsTrue, exist_okTrue) def add(type_name, content): file_path BASE / f{type_name}.md with open(file_path, a, encodingutf-8) as f: f.write(f- {datetime.now().isoformat()} | {content}\n) print(fadded to {file_path}) def search(keyword): for md in BASE.glob(*.md): for line in md.read_text(encodingutf-8).splitlines(): if keyword in line: print(f{md.name}: {line}) args argparse.ArgumentParser() # 省略参数解析细节这个脚本唯一的硬性要求是每条记录必须写在同一行方便后续 grep 和正则匹配。换行写长文本会让文件可读性变好但会让脚本的逐行检索失效。所以我约定内容超过 120 字时拆成标题 | 详情两列结构而不是另起一行。5.2 接入 CodexAGENTS.md 加载与解析在项目根目录创建 AGENTS.md写入以下关键内容# Codex 项目指令 先读取 docs/memory/decisions.md、preferences.md、lessons.md 了解历史约束。 只有记忆文件内容与当前任务冲突时才以最新对话为准。 # 记忆写入 执行完成任何大功能后运行 python3 scripts/memory_tool.py add --type handoff --content 功能名: 状态|待办这里我的经验是Codex 对中文自然语言指令的理解不如对“运行命令”的理解。所以我把“写入记忆”这种描述全部换成“运行这个命令”它照做的概率会高出不少。5.3 接入 Claude CodeCLAUDE.md 加载与解析在项目根目录创建 CLAUDE.md写入# 项目记忆协议 - 开始任务前如果任务涉及已有模块运行 python3 scripts/memory_tool.py search --keyword 模块名 - 任务结束时如果产生了决策或教训运行 python3 scripts/memory_tool.py add --type decision --content 内容 - 终端输出不加 emoji不解释脚本运行结果除非命令失败。Claude Code 的优点是它不会机械地只跑命令而是会主动结合脚本结果进行推理。比如搜索“幂等”返回三条记录后它会自己得出结论“原来这个模块已经做过幂等改造”而不是只把记录读一遍。这就是“脚本 模型推理”组合的魅力。5.4 用 git 给记忆加保险记忆文件最大的价值不是“能存”而是“可回溯”。两个工具生成的记忆最终都会落在docs/memory/目录所以我把它当成普通代码来管理。每次任务结束我会顺手把记忆改动一起提交commit message 写成chore: update agent memory。一旦某条记忆写错导致模型后续决策出现问题我可以很轻松地从 git 历史里找到修改记录把错误内容摘出去或者回退整个文件。这一步在生产环境里尤其重要。因为我发现模型有时会生成看似合理、实际错误的记忆比如把“测试环境不走支付网关”写成“生产环境不走支付网关”。这种错误如果不加审计会在后续任务中被当成真理反复使用后果非常严重。6. 常见问题与排查心得6.1 问题速查表现象大概率原因解决方法模型完全不读取记忆文件AGENTS.md/CLAUDE.md 中记忆规则放得太靠后把记忆规则提升到文件前三分之一处记忆写入了但检索不到脚本和模型工作目录不一致脚本路径改成绝对路径或先 cd 到项目根目录记忆文件越来越大token 开销猛涨没有做分层所有记忆都全量读取超过 15KB 后切换到 search 模式模型开始编造记忆内容写入条件不明确模型把“预测”当“事实”严格限定“只记录已发生决策不记录推测”grep 搜出大量无关记录文件里没有统一模板用脚本 add禁止直接改文件跨项目偏好不生效长期记忆放在了项目级文件放入 ~/.claude/CLAUDE.md 或用户级 AGENTS.md6.2 排查案例CLAUDE.md 写太多模型反而“失忆”有一次我在 CLAUDE.md 里写了满满一屏的规则和记忆包括编码规范、接口约定、历史决策、测试要求。结果模型在处理一个新功能时居然没有遵守最前面那条“禁止修改公共接口签名”的规则。我一开始以为是模型能力问题后来把 CLAUDE.md 精简成三段身份描述、记忆索引、强制命令。规则变短之后模型反而全部遵守了。原因在于长文档注入后模型注意力会把重要内容埋没。避免这个问题的思路就是我在前面反复强调的“CLAUDE.md 只承担索引和强制性命令具体记忆内容放到独立文件用脚本搜索后再读取”。让模型每次都读全量长文档是最差的用法。6.3 我踩过的几个坑第一个坑是让模型自己决定“哪些内容值得记”。它会把“用户今天说要用 Redis”和“用户看了 Redis 介绍”都写进去记忆很快就变成了噪音。现在我只让它按照四个强条件写入其他的一律不写。第二个坑是追求通用格式。我最初设计了一套非常漂亮的 JSON Schema每个记忆都有几十个字段。结果模型写起来极其痛苦经常缺字段脚本兼容又花了很多时间。后来我放弃复杂字段只保留时间戳 | 类型 | 内容三要素代码复杂度下降模型配合度上升。第三个坑是“记忆文件唯一真理”。有一次模型按照记忆里的旧接口文档写了新代码我以为是记忆错了查了半天才发现是代码已经升级了但没人更新记忆。从那以后我要求模型每次写入记忆前先对比当前代码如果记忆与代码不一致必须优先更新记忆而不是让它继续错下去。第四个坑是忘记权限控制。Claude Code 的 Bash 权限如果禁用了任何记忆脚本都会静默失败。我一开始没注意权限配置折腾了一小时才发现。检查顺序永远是权限、路径、脚本语法、模型指令。6.4 保留人工纠错通道无论脚本写得多么好模型对“自己刚写的记忆”总有一种迷之自信。所以我在每个记忆文件末尾保留一个# 人工校准区域专门用来记录我手动驳回或修正的内容。比如有一条模型写的记忆是“用户要求所有接口必须支持批量删除”但实际情况是用户只对“订单列表”接口提了这个要求。我在人工校准区用一行注记说明“仅限订单列表不要推广到所有接口”。这样下次模型搜索“批量删除”时会同时看到错误记忆和人工纠正有较大概率不会再把错误推广出去。这比告诉模型“不要相信某些记忆”更有效因为人工校准本身也是一个正向案例。最后再分享一个实在的小经验如果你也想按这两条路线给 Agent 做记忆我的建议是先别急着复刻我的目录结构而是先回答三个问题你希望 Agent 记住的是“规则”还是“闲聊”这些记忆是围绕单个项目还是跨项目你能否接受每两三天手动清理一次记忆文件把这三个问题想清楚再决定用 Codex 的扁平记忆还是 Claude Code 的分层记忆。我在实际使用中的体会是文件系统记忆最大的优势不是技术上的优雅而是它逼着你把 Agent 的“记忆”当成可以被审计、被修正、被版本化的工程资产。向量库虽然时髦但它让你更容易摆脱责任因为一旦语义检索出错你很难定位是哪一步出了问题。至少在当前这个阶段两条不用向量库的路线已经让我这个项目跑得很稳了。