加载中...
  • Agent 的记忆系统:文件派、数据库派和生命周期 loading

    吃透 AI Agent 开发 · 第 23 篇 · 第四章 · Context Engineering

    聊天记录、会话状态、项目记忆和用户偏好生命周期不同。先分层,再决定文件还是数据库。

    你昨天用模型 A 和 Agent 讨论了一个项目,今天改用模型 B,恢复昨天的会话继续工作。结果系统读取历史记录后,又悄悄切回模型 A。

    从“还原现场”的角度看,这似乎合理;从用户角度看却很荒唐:我刚刚明确选择的模型为什么不生效?根源在于系统没有分清会话历史和当前运行配置。

    先画四个抽屉

    Agent 常见状态可以放进四个抽屉:

    1. 单步状态:当前请求、工具调用和取消信号。
    2. 会话状态:消息历史、压缩摘要、用量记录。
    3. 长期记忆:用户明确希望跨会话保留的信息。
    4. 运行配置:当前模型、Endpoint、权限模式和本进程覆盖值。

    恢复会话应该打开第二个抽屉,不应顺手覆盖第四个。长期记忆也不是把所有会话复制一遍,而是经过筛选的少量稳定信息。

    会话适合做事件日志

    本地 Agent 可以用 append-only JSONL 保存会话:一行一条事件,持续追加。

    {"type":"user","text":"检查登录失败"}
    {"type":"assistant","parts":[{"type":"tool-call","name":"grep"}]}
    {"type":"tool","name":"grep","summary":"找到 3 处"}
    {"type":"usage","inputTokens":3200,"outputTokens":280}
    

    它的优点很朴素:写入时不必重写整个大文件;进程中断通常只损坏最后一行;调试时可以按顺序看到发生过什么。

    如果使用数据库,也可以保留同样的事件思想。关键不是 JSONL,而是不要每轮只覆盖一份“当前状态”,导致工具轨迹和历史决定无法追查。

    type TranscriptEntry =
      | { type: 'user'; text: string; at: string }
      | { type: 'assistant'; parts: MessagePart[]; at: string }
      | { type: 'tool'; name: string; summary: string; at: string }
      | { type: 'usage'; inputTokens: number; outputTokens: number; at: string }
      | { type: 'compaction'; summary: string; at: string }
    

    恢复上下文,不恢复控制权

    历史 metadata 可以记录“当时使用模型 A”,用于展示、成本统计和排障。但新的模型请求应该使用当前运行时的 effective model。

    同样的边界还适用于:

    • 历史 Endpoint 不应覆盖当前配置。
    • 历史权限模式不应自动授予今天的能力。
    • 历史工作目录不存在时,应提示而不是盲目进入。
    • 历史工具结果可以恢复摘要,不应重新执行副作用。

    如果历史模型与当前模型不同,可以提示一次“本会话之前使用模型 A,本次将使用模型 B”,但不需要阻止用户。

    输入历史不是会话历史

    终端的上下键历史用于快速找回输入,生命周期和内容都不同。它可能跨会话,甚至跨项目;也更容易意外保存密钥、命令和私人路径。

    输入历史至少应该过滤:空白、连续重复、以空格开头的敏感输入和常见密钥模式。高隐私场景可以只保存 hash 或完全关闭。

    不要因为它也叫“history”,就把它塞进会话 transcript。

    什么时候一条信息才算记忆

    “记住我默认使用 pnpm”是长期偏好。“刚才测试失败”是当前任务事实。把后者写进长期记忆,几天后它就会变成过期噪声。

    稳妥的记忆策略可以很保守:

    • 只保存用户明确要求记住的长期信息。
    • 带上来源、创建时间和更新时间。
    • 按主题拆分,入口文件只做短索引。
    • 检索时先看标题和描述,再按预算加载正文。
    • 注入旧记忆时提示年龄,并要求结合当前证据验证。

    记忆有了时间,系统才有机会区分“偏好”和“陈年现场记录”。

    会话损坏怎么办

    Append-only 文件可以逐行解析。遇到一行损坏时,记录行号并跳过,而不是让整段会话无法恢复。但容错也不能静默吞掉关键状态:如果损坏行是用户消息或压缩摘要,界面应提示恢复不完整。

    写 metadata、索引和重命名信息时,优先使用原子写:先写临时文件,再替换目标。这样进程在写到一半时不容易留下截断 JSON。

    删除最好先进入回收站

    会话删除不是清空一个内存数组,而是用户数据操作。默认移动到 trash,支持恢复;真正 purge 时再明确范围和数量。路径计算应复用统一的存储 helper,避免一处使用项目目录,另一处使用用户目录,最后删错位置。

    用 Expiry + Validation 做个小练习

    实现一个会话恢复函数,输入三行 JSONL,其中第二行故意损坏:

    function recover(lines: string[]) {
      const entries = []
      const errors = []
    
      for (const [index, line] of lines.entries()) {
        try {
          entries.push(JSON.parse(line))
        } catch {
          errors.push(index + 1)
        }
      }
    
      return { entries, errors }
    }
    

    然后给返回结果增加 incomplete: boolean,并决定界面何时应该阻止继续执行,何时只提示警告。

    Turn State 到 Expiry + Validation 的小结

    会话保存过去发生的事情,记忆保存少量跨会话信息,运行配置决定现在怎样执行。三个边界分开后,恢复、切换和隐私策略才不会互相踩脚。

    下一篇把视线移到本地文件:怎样让 Agent 读取附件、修改代码并支持回滚,同时不突破工作区边界?

    一条消息会经过三种生命周期

    用户说“这个项目以后都用 pnpm”,当前轮次需要立刻遵守,会话恢复时也应看到;是否进入长期项目记忆,则取决于用户是否明确要求保存。相反,“当前 Git 有三个修改文件”只属于这一轮,写进 Memory 很快就会过期。

    flowchart TB
      Input[用户输入] --> Turn[本轮状态]
      Turn --> Transcript[会话记录]
      Turn --> Candidate[记忆候选]
      Candidate --> Confirm{明确要求保存?}
      Confirm -- 否 --> Expire[随任务结束]
      Confirm -- 是 --> Memory[长期记忆]
      Memory --> Select[按相关性和年龄选择]
      Select --> Next[未来请求]
    

    三种存储不要共享同一条真相

    存储 主要内容 不应该保存
    Turn state 当前输入、附件、临时状态 长期偏好
    Transcript 已发生的消息与工具元数据 图片正文和敏感工具原文
    Memory 明确保存的稳定信息 Git 状态、临时计划、模型猜测

    q-code 的生命周期路径

    阅读编号 源码路径 检查内容
    1. 会话存储 src/session/store.ts append-only 消息和 metadata 如何分开
    2. 模型边界 src/session/model-boundary.ts 恢复历史为何不能恢复旧模型选择
    3. 记忆目录 src/context/memory/memdir.ts 索引与主题文件怎样维护
    4. 记忆选择 src/context/memory/selection.ts headers、正文预算和年龄如何注入

    失败反例:把所有对话自动写成记忆

    把所有对话自动写成记忆,导致临时猜测、隐私和旧约束混在一起。之后每轮都注入更多“历史事实”,模型看似更懂用户,实际越来越难区分当前要求和旧背景。

    修复时默认关闭自动沉淀,只保存用户明确要求记住的长期信息。写入维护创建和更新时间;读取携带年龄与验证提示;用户说“忽略记忆”时,索引和正文都不得进入请求。

    练习:给十条信息分配归宿

    把十条真实信息分到本轮、会话、记忆或不保存四类,并为每条写删除条件。然后恢复会话并切换模型,确认历史上下文仍在、当前模型不被覆盖;再发一次“忽略记忆”,确认请求中没有记忆正文。

    Session Metadata 是目录,不是执行配置

    metadata 可以保存标题、创建时间、最后活动时间、当时模型、消息数、用量和项目键,帮助 /sessions 列表与搜索。恢复时它指向 transcript,却不拥有当前 endpoint、API key 或权限。

    历史模型仍有价值:成本报表知道当时用什么,界面在模型变化时提示一次,排障能解释 provider 私有 part。它是描述过去的字段,不是下次请求的 setter。

    这条规则要覆盖所有恢复入口:--continue、指定 session、TUI 切换和输入历史召回。只修一个命令,另一个入口仍可能把旧模型带回来。

    项目键要稳定,也不能泄露绝对路径

    会话按项目分组时,可以从规范化 cwd 生成 projectKey。Windows 盘符大小写、分隔符与 symlink 需要统一,否则同一项目会出现多个目录。界面只显示项目名或脱敏摘要,不把本机绝对路径上传到观测平台。

    项目移动后,旧 projectKey 可能不再匹配。可以提供显式迁移或链接映射,不能扫描全盘猜测;/sessions 默认只展示当前项目记录,避免用户在共享屏幕时看到其他私人项目。

    存储路径计算集中在 helper。删除、导出、artifact 和 file-history 都使用同一 projectKey 规则,避免一处写 home、一处写项目目录。

    Append-only 也需要完整性与并发策略

    两条请求同时 append 同一 JSONL,若没有串行队列,长行可能交错。单进程可按 session 建写入队列,多进程需要文件锁或明确不支持并发写;metadata 通过临时文件加原子 replace 更新。

    每条事件带 eventId、schemaVersion 和可选 previous hash,读取时能发现重复、顺序跳跃和篡改。普通个人会话未必需要完整哈希链,但至少要检测截断尾行并报告 incomplete。

    工具正文、图片 base64 和 Shell 全文不进 transcript。只保存协议所需消息、脱敏摘要和 artifact 引用,既控制体积,也降低会话文件泄露风险。

    会话搜索和模型上下文不是一回事

    用户搜索“上次讨论缓存”可以在所有 transcript 的脱敏文本索引中找候选;选中会话后,恢复器再按压缩记录和预算构建模型上下文。搜索命中不代表全文要一次注入。

    索引可以从会话原件重建,不做唯一存储。删除或移入 trash 后立即从活跃索引撤下;恢复时再加回。高隐私模式可关闭全文索引,只按标题、日期和用户自定义标签查找。

    Memory 写入要保留 createdAt 和 updatedAt

    第一次保存“本项目使用 pnpm”写 createdAt;规则变化时更新正文与 updatedAt,createdAt 不应重置。lastAccessedAt 只说明最近被选择,不说明内容被验证。

    主题文件头可以很短:name、description、type、source、createdAt、updatedAt、status。MEMORY.md 只列 headers 与链接,选择器先看索引,再为当前 query 挑少量正文。

    若写入时索引更新失败,不能出现主题文件存在但永远不可发现的静默状态。使用临时文件与原子替换,或在启动时扫描重建索引并报告不一致。

    注入预算要跨轮累计

    单轮每个记忆文件 4KB、总共 20KB 看似有限,长会话每轮重复注入仍会消耗很多。可以设会话累计预算,并对已验证、近期使用的条目只注入短提醒或引用;任务真正需要时再读正文。

    选择只使用 headers 与 userQuery,避免为了决定相关性先把所有正文塞给模型。正文进入 transient context,不写回历史,防止下一轮既从 transcript 又从 Memory 重复看到。

    用户说“忽略记忆”时,连索引摘要也不注入。不能只隐藏正文,却告诉模型有哪些记忆标题。

    隐私模式要覆盖输入历史、会话和记忆

    关闭 Memory 自动提取,不代表输入历史没有保存密钥;会话 redact,也不代表 Shell artifact 没有原文。用户需要理解各存储层及开关。

    默认过滤常见 secret pattern、空格开头输入和连续重复;高隐私模式可以只存 hash 或完全关闭 history。会话导出前再次脱敏,并列出不包含的 artifact;长期记忆只保存用户明确要求的稳定信息。

    数据在本地也需要文件权限与清理策略。本地优先不是无需安全,而是把控制权留给用户。

    Trash、Restore、Purge 是三个动作

    删除会话先移动到 trash,记录原 sessionId 和删除时间;restore 检查目标是否冲突;purge 才真正删除正文和相关索引。artifact、file-history 是否一起清理要明确提示数量和可恢复性。

    会话导出则生成一个版本化包,包含 transcript、metadata 和可选 artifact manifest,不默认打包秘密原文。导入时校验 schema 与路径,不能让归档中的相对路径覆盖包外文件。

    恢复演练要包含模型切换和损坏尾行

    创建会话,用模型 A 产生工具调用,尾行写一半模拟崩溃;新进程选择模型 B 后恢复。期望是:有效事件被恢复,损坏行有提示,旧模型只用于展示,新请求使用 B,历史工具不重跑,当前权限重新计算。

    再把一条项目记忆设为 stale、一条设为 verified,发“忽略记忆”。请求 manifest 中两条都应缺席。这样的演练比单独测 JSON.parse 更接近真实生命周期。

    本文目录
    本文目录