← 全部工程笔记Cognitive OS 系列 / 04
记忆与上下文第 04 / 08 篇

记忆与上下文分开管理:Cognitive OS 的 Memory Service 与 Context MMU

深入 MMU 评分、分级物化、缓存失效与预算拒绝分支,用长度和 hash 核对规范内容恢复。

文章目录

一段信息值得长期保留,并不代表每次请求都应把它加载进模型窗口。做 Agent 时,我发现“存什么”和“当前读什么”需要两套相互配合的机制。

这是 Cognitive OS 技术系列第 4 篇。本文关注任务之外仍然存在的知识,以及一次推理实际使用的工作集。

这篇文章要解决什么问题?

本文围绕“从事实保存理解上下文装配”展开。阅读顺序是:长期保存与本次加载 → 召回和物化 → 预算与驱逐 → 更新和隔离 → 恢复与评估。先建立概念与执行顺序,再进入具体函数、异常分支和测试,避免把模块名当成理解系统的起点。

阅读阶段 要回答的问题
Memory、Context 和表示层分别是什么? 先定义数据的归属与生命周期,避免把长期记录、派生表示和进程内存混成一个预算。
一次召回怎样找到并加载证据? 从作用域过滤到排序和物化逐步追踪,解释相关记录为何最终可能没有进入输入。
预算不足时如何拒绝、降级和驱逐? 把每个分支计入账本,再区分预算拒绝、受保护页面和反复驱逐造成的抖动。
事实变化后怎样保持一致与隔离? 更新不仅改正文,还影响缓存、索引、在途快照和用户可控制的保留范围。
怎样验证完整恢复和预算正确性? 用固定样本核对内容与资源不变量,再在隔离环境演练索引和派生表示重建。
怎样结合质量与成本持续调优? 回到失败任务,联合检查证据是否被召回、是否被加载以及是否真正被使用。

文中的源码行为以原文标注的 cca98b6 为阅读基线;涉及“应该”“可以”或后续演进的内容属于设计分析与验证建议。历史基准和故障记录不代表当前版本每次运行都会产生相同结果。

1. Memory、Context 和表示层分别是什么?

先定义数据的归属与生命周期,避免把长期记录、派生表示和进程内存混成一个预算。

贯穿案例:事实已经保存,为什么回答仍可能用错?

假设一个项目的测试端口从 18000 改成 18300。长期记忆保存了新记录,但某次回答仍给出旧端口。不要直接把问题归结为“模型不记得”,应沿下面的路径检查。

检查位置 要核对的证据 可能的原因
规范记录 项目作用域、正文、版本 新值没有写入,或写到了另一环境
检索候选 命中的记录 ID 与适用范围 相似度较高的旧环境记录排在前面
派生表示 概览内容与来源版本 正文更新后,旧摘要尚未失效
活动 Context 实际加载记录与预算拒绝原因 新记录被召回,但未获得加载预算
模型输入 最终输入中的端口与冲突信息 旧会话或工具结果再次引入旧值
最终回答 对来源的引用与实际采用值 已提供正确证据,模型仍没有遵循

同一错误现象可能来自六个不同位置,修复手段也不同:调高召回数量无法修复错误作用域;增加上下文预算无法修复旧摘要;更换模型也无法补救根本没有进入输入的证据。

可以先构造两个环境、两个端口的固定小样本,明确这次只查询测试环境,再记录每一层选中了哪个版本。先证明事实沿整条链路正确传递,再评估大样本的召回质量。后文将按这个顺序解释记录、表示、召回、预算与失效。此处端口变化是诊断示例,不是一次新的运行测量。

Memory 回答“保留什么”

项目通过 Memory Service 接口访问四类记忆:

类型 用途
Working 当前短期工作信息
Episodic 任务与结果形成的经验
Semantic 长期事实
Procedural 操作方法与过程知识

接口暴露 remember、forget、按键和按查询 recall 等操作,底层通过 vtable 接入实现。当前默认后端委托给项目自己的 memory facade;接口留下了替换空间,但不能把这种设计可能性写成已经完成的所有数据库集成。

更重要的是写入条件。一个任务只输出了计划、卡在工具失败或被取消,其内容不应该被当成成功经验共享给其他会话。Runtime 将完成证据和长期记忆更新联系起来,避免“我准备创建文件”逐渐变成系统记忆中的“文件已创建”。

Context 回答“这次加载什么”

模型上下文的容量与使用成本有限。即使长期记录全部保存,当前推理也只能读取一个经过选择的集合。

Context MMU 中有两个互相独立的维度:

维度 取值 含义
表示粒度 L0 / L1 / L2 摘要索引、概览、细节
驻留状态 HOT / WARM / COLD 细节已加载、概览已加载、主要保留元信息

因此,“冷”不等于“内容被永久删掉”,“概览”也不等于“原文”。原始规范记录由记录存储保留,当前活动表示可以按预算被驱逐或重新物化。

“Context 预算”为什么不能等同于进程内存

这里很容易产生宣传上的误解。MMU 管理的 token 预算,针对的是被物化的上下文表示;整个进程还可能持有规范原文、索引、序列化缓冲、调度对象和系统分配器空间。

所以一个 1 GiB 原文压力实验,可能占用更多 RSS。项目保存的 768/128/128 MiB 布局实验中,峰值 RSS 约为 3197.01 MiB。这说明测试确实有额外内存成本,不能只展示缩小后的概览字节数,就把它描述为整个系统的常驻内存。

概览是有损的。测试核对的“完整恢复”,指从规范记录重新加载正文后,原始长度和内容 hash 一致;它不表示可以从有损概览单独重建全部原文,也不代表向远程模型发送过 GiB 级输入。

Service 接口的价值,在于约束调用方

memory_service_ops 将 remember、forget、recall_key、recall_query、stats 和 destroy 放到同一个后端契约中。调用方先说明记忆类型,再调用统一接口;默认实现负责把这些请求映射到现有 memory facade。

这个设计减少了认知层对某个存储实现的直接依赖,但没有自动解决存储一致性、跨进程并发和数据迁移。切换到远程后端时,还要重新明确超时、重试、查询排序、删除语义和输出所有权。一个 vtable 是扩展接缝,不是这些工程问题的完成证明。

在 C 接口里还要留意 char **text 这类输出参数。接口约定返回文本由堆分配,调用者释放;默认后端借用原有 memory 对象,其析构又不能顺手释放不属于自己的资源。先读头文件的所有权说明,通常比直接钻进存储算法更快理解组件边界。

规范记录、派生页面和缓存是三种对象

阅读 MMU 时,容易把 mem_record 和 ctx_page 当成同一份数据的两个名字。更准确的理解是:规范记录保存需要恢复的事实正文,页面对象保存该事实在活动上下文中的派生表示、驻留级别和访问元数据,缓存则用于避免重复推导相同表示。三者生命周期可以不同。

一条记录可能长期存在而页面处于 COLD;COLD 页面仍有身份和排序信息,但活动全文可能已经释放。需要时读取规范记录,再派生 L1 概览或 L2 完整表示。缓存只有在内容 hash 与派生版本都有效时才应复用。若把缓存误认为规范数据,清缓存就会丢知识;若把规范原文计入活动 Context,也会让预算指标失去意义。

每次提升或驱逐都应维护一个不变量:页面记录的驻留状态、各级表示指针、token 数以及 mmu->token_used 相互一致。提升成功要在确认预算后更新状态与使用量;失败时释放本次临时派生内容,不能留下脱离账本的字符串;驱逐则减少与被释放表示相对应的使用量。测试可以在每次操作后重新遍历页面、按等级求和,再与全局计数比较。

这里还有单位问题。字段叫 token 数时,它可能只是由文本长度估算的近似值,并不等于 Provider 使用的 tokenizer 结果。不同语言、模型 tokenizer 和工具 schema 会改变真实 token 消耗。Context MMU 的预算主要用于管理项目自己的上下文表示,实际发送前仍需按目标模型准确计数,或至少保留请求级估算和服务端 usage 反馈。

L0、L1、L2 与 HOT、WARM、COLD 为什么是两套维度

L0/L1/L2 表示内容粒度,HOT/WARM/COLD 表示当前驻留方式。把它们混成同一个枚举,会让“内容可以有哪些表示”和“现在占用了哪些资源”难以分别演进。

一个页面可能保留稳定的 record ID、重要度和访问统计,同时释放派生正文。后续查询需要这条记录时,再从规范内容物化表示。这里的 ID 和元信息承担寻址作用,不能因为正文不在活动缓存里,就认为原始知识已经被忘记。

当前 derive_l2() 复制规范内容,L1 则通过确定性规则派生概览。它不是每次调用大模型生成摘要的服务。所谓 Context MMU 是软件层的工作集管理类比,也没有借助 CPU 页表替模型提供透明的虚拟内存。

Context 的生命周期与任务生命周期不同

一个 reasoning task 结束后,活动 Context 页面可以释放或降级,而 Memory record 仍保留;相反,短期工作上下文可以存在于当前会话,却不应该自动进入长期记忆。两个生命周期若没有清楚边界,临时文件内容可能长期留存,或重要事实随任务结束丢失。

请求开始时可建立 context snapshot/version,保证同一次规划轮次使用可解释的数据集合;后续轮次可以重新召回并获得新信息,也意味着回答可能基于新的记忆状态。需要可重复回放时保存 snapshot ID 或组成记录的版本;不需要的话则避免长期复制所有正文,减少隐私和存储成本。

任务取消后,尚未验证的模型总结不应进入共享记忆;成功写入的工具事实也需要区分文件是否仍存在。清理流程应能撤销本任务的临时记录,但不误删其他会话共享的长期规范。可为每个记录保存 creator task ID 与 scope,让保留与回滚按来源执行。

2. 一次召回怎样找到并加载证据?

从作用域过滤到排序和物化逐步追踪,解释相关记录为何最终可能没有进入输入。

一次召回的路径

查询
 → 候选记录
 → 相关性及其他因素排序
 → WARM / L1 概览
 → 再排序
 → 部分记录提升到 HOT / L2
 → 超预算时淘汰

排序会记录 relevance、importance、confidence、utility、recency、salience、scope 等因素。与只输出一个“相关度分数”相比,这能帮助定位为什么某条记录进入或离开工作集。

表示通过内容 hash 缓存,正文变化后才需要重新派生。被选中的页面并不都能无限提升到 HOT,预算约束需要同时覆盖活动表示和缓存,避免某个未计费的细节副本绕过管理。

打开 ctx_mmu_recall(),看一次具体查询

召回首先同步规范记录与页面元信息,再根据查询获取候选相关性。源码会为各页面计算多个因子,将加权分数归一化。可以用下面的形式理解 factor_score():

score = clamp01(Σ(weight_i × factor_i) / Σ(weight_i))

factor = relevance、importance、confidence、utility、
         recency、salience、scope

其中 recency 按半衰期衰减,源码使用 exp2(-age / half_life)。如果半衰期为 H,其他条件相同,距离上次访问 H 时间的记录对应时间因子约为 0.5。这使“多久以前”可以与其他因子一起解释,而不是把新记录一律置顶。

随后按分数排序,先尝试将 COLD 页面提升到 WARM/L1,再选择部分页面提升到 HOT/L2。L2 的数量受 l2_top_k 等配置约束,提升还需要通过剩余 token 预算检查。候选排名高,不等于一定能加载全文。

一个值得继续优化的细节是排序范围。当前实现对页面集合做排序,使用插入排序;当集合很大时,不能因为向量检索只取 top-k,就假定后续所有处理都只有 k 项。评估扩展性应查看实际扫描与排序对象,再考虑局部候选、堆选择或其他策略。

召回排序不等于搜索质量

混合检索先从向量候选取相关性信息,再合并重要度、置信度、效用、时间、显著性和 scope 等因素。综合分数能够解释排序的多个输入,却不能自动证明相关性计算适合每个任务。候选集合本身若漏掉正确记录,后续评分再复杂也无法召回它。

排序函数将加权和除以有效权重,并将分数裁剪到 0–1 区间。实现时需要考虑缺失字段、NaN、权重总和为零、负值、范围外配置和相同分数的稳定次序。对极端输入加防御的目的不是追求“公式看起来数学化”,而是确保不合法配置不会让页面排名随机漂移或触发未定义行为。

为了评价召回,可以准备带标准答案的小型 query 集:每个 query 标注应召回的 record ID、应排除的近似记录、作用域和更新时间。统计 recall@k、precision@k、被提升到 L2 的有效记录比例,以及上下文预算拒绝了多少高相关页面。人工抽查案例,确认模型最终使用的是哪条证据。单独看向量距离或者缓存命中率,不能评价系统是否把正确事实放进了 prompt。

时间因子使用半衰期衰减时,有必要区分两种语义:内容越新可能越重要,或者稳定事实不应因为时间久就自动降低可信度。统一 recency 因子只能提供一个可调特征,不适合替代数据的有效期限和来源置信度。对于会过期的临时事实,应明确 TTL 或更新时间;对于长期规范,则可能降低时间惩罚。

向量索引重建、embedding 模型变更和文本分块策略变化都会影响候选分布。切换 embedding 后,即使数据库结构没变,旧向量也可能与新查询向量不在同一空间。迁移时应记录模型标识、维度、规范化策略和文档版本,做双写或离线重建,再比较召回集;不能只更新 Provider 配置后继续混用旧、新向量。

作用域和冲突解决优先于相似度

语义相似不等于数据可用。用户 A 与用户 B 都问“当前默认端口是什么”,文本相同但记忆 scope 不同;同一项目开发分支和发布分支也可能有不同设置。检索应先强制安全与适用范围过滤,再排序。让低 scope 记录靠高 relevance 排进第一名,会将访问控制降格成建议。

时间较新的值也可能是误输或临时测试配置。冲突时可以比较来源等级,例如用户明确纠正、已验证配置文件、模型总结、推断;还可以要求时效适用和版本匹配。来源优先级应由系统逻辑实施,不能将多条矛盾记忆都交给模型然后假设它总能挑对。

冲突摘要最好保留“值、来源、有效时间、适用范围、置信度”字段,而不是直接把旧值覆盖掉。这样可以处理分支差异、临时 override 和撤销。若存储模型目前只保留自由文本,就要将冲突风险标为限制,而不是描述成已解决的知识图谱能力。

L1 概览需要可解释的压缩规则

当前 derive_l1() 使用确定性规则生成概览,不是每次都调用 LLM 摘要。确定性处理带来成本和复现上的优势:同一内容与相同版本会生成相同概览,单元测试也能比较精确结果;代价是无法像模型一样理解跨段落因果关系或自动选择语义重点。

无论概览由规则还是模型生成,都应把摘要视为有损表示。内容可能只保留开头、结尾或关键词,也可能删掉限定条件。对安全规则、代码版本号和负面条件,摘要丢掉一个“不要”就可能改变执行含义。因此读取摘要之后,关键动作应回查规范原文和来源位置。

可在派生内容中保留原记录 ID、段落范围和哈希,使摘要能回溯到原文片段。若摘要被模型改写,还要保存生成模型、提示模板版本和生成时间,方便失效重算。当前规则化概览降低了这类模型版本依赖,但算法修改时也仍要考虑已缓存旧表示是否因版本不同而失效。

一项实用测试是“信息损失对抗集”:准备包含否定、数字、限定范围、时间条件、例外和不同实体名称的记录,检查 L1 是否丢失关键条件;若发生损失,确认系统会在用于决策前提升并读取 L2。测试不要假设摘要必须逐字保存全文,而要明确哪些语义必须保留、哪些场景必须回查原文。

从召回响应还原一条 Memory 决策

当一次回答引用了错误事实,可以从底层记录逐层还原:规范记录是否存在、查询是否找到它、候选排序落在哪个位置、是否因 scope 或类型过滤、L1/L2 哪一级进入了 Context、预算是否拒绝、最终 prompt 的排列顺序是否让旧值覆盖新值。这些层级分别归属于存储、检索、MMU 和请求构造,不应统一改一个 similarity threshold 后期待所有错误消失。

可以为诊断运行生成一份轻量 trace:

query hash: q-91ab
candidate IDs: [m-27, m-19, m-44]
score components: relevance / recency / importance / scope
selected levels: m-27=L2, m-19=L1, m-44=excluded
budget: used 8210 / limit 9000 (estimated units)
prompt inclusion: m-27 full, m-19 overview

这段数据只用于解释处理路径,不应把 record 正文写进默认日志。若查询命中敏感记忆,ID/hash 与得分仍可能透露访问模式,应控制读权限和保留时间。开启详细诊断后,要提醒用户可能增加资源和隐私暴露面。

评分因子归一化后仍需保存中间值,不然一个综合 0.81 难以说明为何某项胜出。相同 query 的重放可以比较配置变更前后排序;若最终 prompt 选择不同,检查的应是具体变化因素,不应仅凭输出答案判断评分更合理。

3. 预算不足时如何拒绝、降级和驱逐?

把每个分支计入账本,再区分预算拒绝、受保护页面和反复驱逐造成的抖动。

预算核算最容易漏掉哪个分支

正常路径容易测试,拒绝提升的路径更容易藏内存问题。假设 L1 文本已经生成,随后发现预算不足:如果仅保持 COLD 状态,却没有释放新生成的文本,这个页面就留下了一份未计入活动预算的副本。

当前实现会在拒绝提升时释放该 L1 表示并清零其 token 数。下面是核心节选:

if (tok > mmu->token_budget ||
    mmu->token_used > mmu->token_budget - tok) {
    free(pg->repr[CTX_L1]);
    pg->repr[CTX_L1] = NULL;
    pg->tok[CTX_L1] = 0;
    mmu->rejected_promotions++;
    continue;
}

第一项检查先处理 tok > budget,再使用减法形式,避免直接相加带来的无符号溢出风险。这样的细节连接了“策略正确”和“实现正确”:文字上写不超过预算,远不如错误分支也真的释放资源。

另外,pin 会影响驱逐。evict_to_budget() 跳过 pinned 页面;如果所有候选都被固定,就可能无法满足预算。pin 应被视为带成本的保留承诺,需要控制使用范围,而不是免费提高命中率的开关。

Context 装配应有可审计的预算账本

完整模型输入不只有 Memory 页面。通常还包含 system/developer 指令、工具 schema、当前用户目标、对话历史、已选记忆、附件摘要和之前的工具结果。MMU 只控制自己负责的那部分时,request builder 仍需计算最终输入长度,并为模型输出留出窗口空间。

预算可按组件分账:

模型窗口
= system + tools + current task + conversation
  + recalled memory + attachment representations + output reserve

不同 Provider 的窗口上限和计数器也可能不同。项目内部使用字节或字符估算时,要明确估算误差和保留安全余量;若 Provider 返回 context length 错误,应该记录哪一段预算超出并调整,而不是简单删除最相关记忆。

分层截断可以优先保留系统约束和当前任务,随后缩短历史、降级较低价值记忆、对附件摘要分页,再限制工具 schema。但顺序需要依据产品语义设计。丢掉负面约束比省掉一段旧闲聊更危险。每次裁剪都应记录组件、原长度、裁剪长度和策略,以便复现模型为什么没有看到某个事实。

使用真实 tokenizer 可以更准确,但需要管理不同模型 tokenizer 和版本;估算方法依赖少,却可能误差更大。最终请求应在可用时记录 Provider 返回的 input token usage,和本地预测比较误差。这个反馈可以帮助改进预算,但不能回溯修复已经超窗的请求。

预算拒绝、Pinned 页面和降级路径

预算拒绝不应该只有一个计数。系统还要解释当前已使用多少、候选表示有多大、是否尝试驱逐、为何驱逐失败以及拒绝的是哪一级表示。对 prompt 构建而言,可以继续用摘要、丢弃该条或返回“上下文不足”,每种降级都应在 request trace 里留下记录。

若 L1 加载后发现预算不足,必须释放刚生成的文本。类似地,L2 已生成但无法替换 L1 时,也要回收 L2 缓冲,并保持计数仍对应 L1。如果 token 使用量使用无符号类型,used + need > budget 可能在溢出后误通过;先检查 need > budget 再用 used > budget - need 的写法更安全。

Pinned 页面不参与普通驱逐,因此 pin 总量可能超过预算。策略可以拒绝新的 pin、为 pinned 分区单独计费、按任务结束自动解除,或允许预算硬超限并报出理由。无论采取哪种方式,应让调用方知道 pin 是持久承诺而非免费标签。

长期任务还可能触发“预算够用但缓存不断膨胀”。MMU 账本只记录特定表示,并不自动限制所有源文件、临时 JSON 和 allocator arena。应结合 heap profile 和 RSS 解释资源;预算曲线平稳却 RSS 增长时,就该检查其它缓冲和历史,而非继续调 MMU token 限制。

驱逐策略的公平性与抖动

LRU 类策略适合“最近用过的数据短期内更可能再用”的假设,但并不适合所有任务。一个低质量记录若被反复访问,可能持续留在 HOT;一个低频但关键的规范可能因长时间未访问而降级。importance、scope、pin 和 recency 一起参与决策,仍需明确每个因子的业务语义和可测试边界。

当预算接近上限,连续召回可能让相似页面来回提升、驱逐,造成抖动。可通过滞回阈值、最小驻留时间、按任务隔离工作集或分区预算降低来回转换。每种做法都可能保留过多旧页面,所以要同时记录重物化成本与被驱逐内容的再命中频率。

pin 提供明确的保留承诺,但如果固定项总量超过预算,系统无法同时满足两者。应把“固定失败”变成可观察状态,并在 API 层说明哪些页面因 pin 不能驱逐。若调用方把每条新证据都 pin,预算门禁就只剩名义上的限制。可以为 pin 加上租约、额度或任务结束时自动解除,不过这些改动必须与跨请求持久性语义一致。

多租户场景还要讨论公平性。一个大上下文任务可能消耗全部 HOT budget,使其他会话没有空间。全局预算最大化利用率,却可能造成某会话饥饿;每租户保底分区提升隔离,但可能造成空闲分区无法共享容量。需要根据产品的隔离承诺和负载分布决定策略,不能由单机 benchmark 直接推导多租户表现。

4. 事实变化后怎样保持一致与隔离?

更新不仅改正文,还影响缓存、索引、在途快照和用户可控制的保留范围。

缓存失效与事实更新

materialize() 会计算规范内容 hash,发现正文变化后释放旧的各级表示,再按需派生新的内容。因此缓存 key 不应只用 record ID:同一个 ID 下的事实可能被修正,继续使用旧概览会把“更新成功”变成“模型仍看到旧版本”。

还要注意,正文 hash 只覆盖对应内容变化。若未来概览算法、提示词或派生配置改变,也应考虑将表示版本纳入缓存有效性判断。这里是从现有实现推导的演进需求,不表示当前已经拥有完整的跨版本缓存协议。

对长期记忆而言,更新与遗忘也应保留来源。一个低置信度推断若反复被召回,可能看起来越来越像事实;单纯增加访问频次不能证明内容正确。系统最好能区分用户明确提供的事实、工具观察和模型归纳,并让错误记忆可定位、可删除。

锁、缓存一致性和并发读写

Memory Service 的 vtable 让调用方使用统一操作,但后端内部仍有并发语义需要明确。两个 worker 同时更新同一个 record 时,是最后写入覆盖、版本冲突失败,还是做合并?查询和删除同时发生时,召回结果可能包含已删除记录吗?单进程互斥锁无法提供跨进程一致性,文件级锁、数据库事务或版本条件更新有不同保证。

页面缓存重新物化时也会与 forget 竞争。如果查询先读取规范记录,随后 forget 删除它,查询可能仍将旧文本装进上下文。是否允许这次已开始的读取继续使用旧快照,取决于 API 的一致性约定。若需要强一致,可让 record 带版本,在缓存落地前再次确认版本相同;若最终一致可接受,则要明确允许的陈旧窗口。

对象所有权应从接口头文件读起。char **text 是后端新分配还是借用内部缓冲?返回的 record 是否由调用方释放?调用 destroy 时是否拥有底层 store?默认实现借用现有对象时,释放 service 不应错误销毁它的宿主。统一 vtable 只统一函数签名,不自动统一资源所有权。

要发现这类并发问题,可以使用两个 worker 同步访问同一 record:一个循环改正文并更新 hash,另一个反复召回和物化。每个观测都检查内容版本与页面版本匹配,操作完成后再查询最终规范数据。再增加 delete/forget 分支,检查对象释放后没有读者访问旧指针。线程竞态检测器和地址检测器能辅助定位,但应以协议层断言定义预期。

记录创建、更新和忘记是一组数据生命周期

Memory 的写入 API 不只是把一段文本追加到存储。它要决定记录 ID、类型、作用域、来源、置信度、时间戳和有效期。即使当前实现只在部分调用路径保存这些元信息,也应以真实数据结构为准;文章或调用方不能假设任意文本都能被统一归类成事实。

更新已有事实和新增一条相似事实会影响冲突处理。比如用户先说“项目默认端口是 8080”,后来纠正为 18300。若两个记录都长期保留且召回只按相关度排序,模型可能随机拿到旧值。系统需要使用版本、失效标记、来源可信度或显式纠正关系,让旧事实不会与新事实并列扮演当前答案。

Forget 也应该定义一致性:删除规范记录后,向量索引、派生页面和缓存何时同步删除?操作成功是否表示所有副本已清理,还是只是标记下次重建?若有备份,保留多长时间?对隐私删除,这些是用户承诺的一部分;对普通陈旧记忆,它们决定错误事实会不会再次被检索出来。

一个生命周期测试可以执行:创建 record → 按 ID recall → 修改正文 → 查询旧 hash 不可再用 → forget → 按 ID 与 query recall 均不再返回 → 清理/重建向量索引后仍不可返回。每一步检查所有后端副本。如果服务承诺最终一致,应记录最大传播延迟并测试在窗口结束后收敛。

数据库和文件后端的一致性选择

本地文件后端操作简单,可用于单进程和离线场景;若多个进程同时写入,锁、追加记录原子性和缓存一致性需要专门设计。SQLite 等嵌入式数据库提供事务和索引,但仍需定义 busy timeout、迁移、备份和 WAL checkpoint。远程数据库增加网络超时与一致性模型,统一接口只隐藏调用形式,不能隐藏运营成本。

记忆和向量索引若分开存储,就有双写窗口:record 写成功但 embedding 更新失败,检索仍可能返回旧向量;embedding 删除成功但正文回滚,记录又搜不到。outbox、版本号、可重建索引或后台修复任务可以缓解,但需记录每个索引版本。可重建性减少对向量索引的单点依赖,却仍需要保留源文本和 embedding 配置。

备份和恢复也应覆盖元数据关联。只备份正文而漏掉 scope 与来源,恢复后数据可能被错误共享;只备份向量不保留原始记录,未来想换模型重建时又无从着手。定期在临时数据库执行 restore test,核对记录数、hash、权限字段和向量版本,才能证明备份可用。

多租户、缓存共享和隔离策略

Memory service 的接口允许一个 Runtime 使用统一访问方式,但实际部署可能有多个用户或 workspace。页面 cache key 如果只有 record ID,而不同租户的 ID 命名空间可重复,缓存就可能串数据;如果 key 带租户 ID,隔离更清楚,但缓存共享率下降。权限边界必须进入 record lookup 与 cache key,而不能只在 UI 做过滤。

共享语义也需要定义。公共项目文档可以被多会话共享;用户私有记忆不能因为相似内容而泄漏给其他用户。scope 字段参与排序,不等于真正的访问控制。数据查询时就要筛掉无权访问的记录,再做排序;否则即使最终不把内容放进 prompt,候选 ID 与相关度也可能泄漏信息。

清理一个用户数据时,需同步处理规范记录、倒排/向量索引、MMU 页、缓存、备份与日志。分布式部署里还要对消息队列和副本设置 tombstone 或版本号,使延迟到达的旧更新不会把已删除记忆重新写回来。单进程单线程的测试覆盖不到副本延迟,需要故障注入模拟乱序更新。

向量召回的可复现性和数据隐私

向量检索的结果可能受 embedding 模型、分块、归一化和索引版本影响。相同 query 如果使用另一 embedding provider,相关度值和候选顺序不一定可比。基准应把这些信息一起保存,并且不要在迁移期间把不同模型产生的向量直接混在一个未标注的空间里。

更新 embedding 需要策略:在线双写两个版本并逐步切读,离线重建后原子切换,或在查询时根据记录版本走不同索引。每种做法都有空间与延迟成本。若数据量巨大,重建时间和失败回滚也要计划;只测试一个空数据库启动,无法验证升级路径。

记忆内容往往比一般日志更敏感。向量本身也可能泄露原文特征,不能因为不可直接阅读就当作匿名数据。Provider 做远程 embedding 时,应用要确认用户许可与数据边界,并为记忆导出、删除和 retention 提供清晰行为。调试日志只保存必要 ID、尺寸、时间和错误码,正文应受权限控制。

长记忆还可能包含不可信文本与提示注入。召回结果在 prompt 中应标注来源和可信度,并用明确格式同系统约束分开。安全规则、工具权限和用户授权不能从 recall 内容中自动覆盖。召回回答“这次可能需要哪些信息”,不回答“允许系统执行什么操作”。

“遗忘”对检索评测的影响

遗忘操作可以是物理删除,也可以是逻辑 tombstone。物理删除节省空间,却可能影响正在读取的任务;逻辑删除便于并发和审计,但查询过滤必须严格,后台压缩也要最终移除数据。外部向量服务删除的延迟可能不同于本地主库,因此 API 的“forget 成功”要说明代表请求被接受还是所有副本都已清理。

删除后的 query 测试要覆盖语义相近的检索,不能只按 ID 查询。若旧 embedding 仍留在索引,query 仍可能返回旧 record ID;服务层再查不到正文时,可能给模型一个空占位或错误。应明确跳过、重试同步或触发索引修复,并记录 stale hit 指标。

备份保留策略让“彻底遗忘”更复杂。若备份不可按单条记录改写,系统可以采用加密密钥销毁、到期滚动或保留名单;具体承诺取决于存储和产品策略。技术说明要按实际操作写,不能仅凭 API 名称 forget 就声称所有历史副本立即消失。

记忆可信度随着反馈更新

记录的 confidence 可以来自来源和验证,而不是访问次数。若一条模型推断反复被模型自身召回并重复陈述,访问频率会形成自我强化,却没有新增证据。更可靠的更新机制应将外部工具验证、用户确认和模型归纳分别标注,再由不同事件调整可信度。

用户纠错是一种强信号,但也要绑定作用域和时间。纠正一个项目分支的端口值,不应覆盖其他产品线。操作系统版本、部署环境或发布日期变化后,旧配置可能自然失效;可以用有效区间和 verified_at 表达,召回时先筛出适用值。

如果当前 record schema 没有来源结构,先在外层关联 metadata 或事件 journal 记录变更历史,避免只保留最后的自由文本。这样开发者能解释“这条事实从哪里来、谁修改过、为什么被淘汰”,也能处理用户要求删除时的审计与隐私冲突。

用户可见的记忆控制入口

长期记忆是可影响后续回答的数据,用户需要看到它如何被创建、修正和删除。至少可以提供按类型和 scope 浏览、显示来源与最近更新时间、编辑错误事实、忘记某条记录和导出数据的入口。若系统只能在聊天里隐式生成记忆,用户就难以判断哪些信息会持续影响后续会话。

编辑一条记忆时应显示旧值和新值,并让引用旧 hash 的派生页面失效。删除后可提示其他 active task 仍可能持有已经加载的快照,或等待当前调用结束。这样的提示比承诺瞬间使所有在途模型输入遗忘数据更准确。

用户也应能临时关闭跨任务 Memory,而不必删除全部记录。此时仍可使用当前会话上下文,但长期记忆不应被召回或更新。开关状态要进入请求 trace,才能在用户问“为什么这次没有记住”时解释。

5. 怎样验证完整恢复和预算正确性?

用固定样本核对内容与资源不变量,再在隔离环境演练索引和派生表示重建。

怎样设计“完整恢复”的实验

实验至少应保留三类独立观测:规范内容的长度与 hash,活动表示的大小与驻留状态,进程 RSS。只展示一个概览比例,无法判断正文究竟保存在哪里。

写入三条规范记录,记录 length/hash
→ 物化为 HOT,确认有完整表示
→ 压低预算并驱逐,确认活动正文释放
→ 提升为 WARM,记录概览字节数
→ 再提升为 HOT,核对 length/hash
→ 从磁盘重新加载规范记录,再核对 length/hash

如果最后仅比较字符串前缀,就不能排除末段被截断;如果复用同一个内存对象,又不能证明磁盘恢复成功。因此测试把持久加载与缓存重建分开,并对内容长度与 hash 都做核对。

这里使用的 hash 用于测试内容一致性,不应直接扩展成安全领域的防篡改声明。要抵抗恶意修改,还需要另外的威胁模型与完整性机制。

测试预算策略不需要昂贵模型

MMU 规则和表示格式可以用固定 fixture 测试。准备若干已知大小的规范记录,预计算各级表示和估计 token;设置很小预算,逐条提升、驱逐、pin 和拒绝,再检查 token_used 是否与当前驻留内容总和一致。每个边界加上刚好相等、少一个单位和多一个单位的三种情况。

缓存失效测试在同一 record ID 下修改正文,确保旧 L1/L2 缓冲被释放并重新派生。可以同时跟踪哈希分配器或分配/释放计数,检查拒绝提升路径没有泄漏;没有 sanitizer 时,至少统计内部表示数回到预期值。

检索排序测试不需要 LLM。给候选填入确定性 score factors,检查权重、半衰期、scope 和并列 tie-break。向量后端本身可被 stub,返回固定 top-k,再单测 hybrid scoring。之后再做 embedding provider 的兼容性测试,确认向量维度不匹配、超时和重建错误有正确反馈。

高并发与长期运行测试可以较小数据开始,通过重复批次观察页面数、缓存数、堆字节和查询延迟。百万条记录实验适合测扩展,不应作为每次提交必跑;CI 用稳定小 fixture 保证基本不变量,定期任务在专用机器上跑较大工作量并保存原始 JSON。

记忆系统的恢复演练

可以定期导出一份脱敏测试数据库到隔离目录,删除派生缓存和向量索引,再按保存的版本配置重建。比对规范记录数、ID、内容 hash、scope 和来源字段,并对固定 query 集检查召回差异。这个演练能验证索引确实可重建,也能发现只存在于缓存、没有规范来源的“幽灵事实”。

若重建后结果变化,应先区分 embedding 模型或分块版本变化、随机索引构建顺序和数据丢失。为可复现起见,应固定配置并保存索引版本。恢复报告列出新增、缺失和变化记录后,再切换生产索引;重建失败时继续使用旧索引,避免维护操作直接让所有记忆不可用。

怎样检查这套实现

先构建 runtime-bench,再运行:

cd backend
python tests/test_runtime_benchmark.py

其中的小规模测试会执行真实 MMU 的 HOT 分配、驱逐、概览加载、重新物化和磁盘校验。CI 使用较小的数据检查正确性;历史大内存数据与运行条件保存在基准报告中。

源码:Memory Service、Context MMU、记录存储。数据:扩展基准报告。

6. 怎样结合质量与成本持续调优?

回到失败任务,联合检查证据是否被召回、是否被加载以及是否真正被使用。

给 Memory 和 Context 建立分层指标

对开发者有用的指标应能分清逻辑命中与资源开销:

指标 能回答的问题 单独不能回答什么
候选数与召回 ID 搜索阶段返回了什么 候选是否对任务有用
L1/L2 提升、拒绝次数 哪些内容进入活动表示 Provider 是否实际收到全文
token 估计和 materialized bytes Runtime 预算如何使用 进程 RSS 的全部来源
cache hit / stale invalidation 是否复用派生内容 缓存命中是否节省端到端时间
eviction / rematerialize latency 淘汰后重建成本 淘汰页面是否被模型需要
RSS 与堆分配量 进程资源占用 哪个模块造成变化

观测最好带上会话或脱敏后的租户标签、内容版本和时间区间。原始记忆可能含有敏感信息,指标不应为了调试把正文全量写进日志。记录 ID、hash、字节数和结果状态通常足以关联问题;受控诊断时再导出特定记录。

压测应使用冷热混合和内容更新,而不是只重复一个固定 query。加入新记录、修正旧内容、删除记录、压低预算、恢复页面,可以覆盖缓存失效和驱逐路径。只跑热缓存可能测到最好情况,却漏掉第一次物化的延迟与临时内存峰值。

从单个失败样本开始调优

当模型回答用了旧端口时,先从最终 prompt 或 trace 看这条事实是否被召回,随后判断它在候选阶段被漏掉、评分太低、scope 不符、L1 摘要丢失还是预算拒绝。如果 prompt 中已有新事实却模型仍答旧值,问题可能在模型上下文顺序或系统提示,而不在 Memory 检索。

维护一份小型回归集会让调优更可靠。每个 case 包含记忆集合、query、应召回与应排除的 ID、预算、预期表示等级和答案证据。每次改权重或驱逐策略后,报告召回率、超预算拒绝、内存与延迟,再人工审核损失案例。只优化单一 query 的 top-1 结果可能让其他类别明显退步。

还可以定义对抗案例:新旧冲突事实、同一事实不同租户、同名实体、引用否定条件、范围过期、长文首尾矛盾、用户要求忽略旧记忆。检索结果要提供足够来源信息,让模型能识别更新;若业务规则要求最新事实优先,就让检索引擎显式实施版本顺序,不依赖模型从两段相反文本中自行推断。

将召回质量与资源预算一起评估

相关性决定优先读取哪些记录,预算决定能加载多少、使用何种粒度。一条高相关记录可能很大,因此只限制候选条数不足以约束工作集。

Context 的估算预算也不等于最终请求 token 数。系统提示、工具定义、会话历史与附件都会占用输入,真实 Provider 用量应另行计量。

调优时应同时查看候选命中、提升与拒绝数量、物化字节、驱逐和重新物化延迟,并检查任务是否实际使用了正确证据。缓存命中率高,也可能只是反复命中无用内容。

源码阅读基线:MMU 召回、评分、物化与驱逐、Service 契约、Service 默认后端。

延伸阅读与实践顺序

完成本文后,建议选取“预算不足时如何拒绝、降级和驱逐?”中的一个分支,用固定输入记录预期行为、实际结果和差异。先验证一个具体不变量,再扩大任务规模;若结果不同,优先核对源码版本、配置与输入证据。

上一篇:Flow 依赖;下一篇:工具协议与执行证据。如果你正在处理长任务和大型代码库,欢迎从“规范内容与活动表示分离”这个边界一起讨论。

在 GitHub 阅读 Markdown 源文反馈与交流 ↗