预检先行
所有 LLM 调用前先做 token budget 检查,超长 prompt 永远不送进模型
压缩可证
压缩后必须保留用户目标、约束、工具关键结果、HITL 决策;信息丢失则拒绝
写回受控
只有成功 Run 才写长期记忆;失败、拒绝、取消路径绝不污染长期经验
详细设计说明
让模型带着"正确上下文"执行,而不是带着"所有历史"执行
这份文档把 Memory / Context 从"上下文装配的一个步骤"提升为独立子系统。重点回答五个动作(预检 / 召回 / 压缩 / 写回 / 隔离)如何在 token 预算、隐私边界、长期经验三个约束下协同工作,避免超长上下文、跨租户串数据、危险经验沉淀这三类典型故障。
设计主张
Memory 的核心不是"存得多",而是在合适的边界内召回必要信息,并能证明压缩后关键目标、约束和决策没有丢失。读路径要可预算,写路径要受控,scope 要硬隔离。
评审关注点
- 超长 prompt 是否在到 LLM 之前就被拦截
- 压缩是否保留用户目标 / 约束 / 工具关键结果 / HITL 决策
- 失败 / 拒绝 / 取消路径是否真的不写长期记忆
- tenant / user / session 三层 scope 是否强隔离
- OAuth token / cookie 等敏感字段是否会进长期 store
1. 定位与概念
Memory 子系统在每次 LLM 调用前后做五件事——召回 / 预检 / 压缩 / 写回 / 隔离。它不是 Context Assembler 的子模块:装配是"拼 prompt",Memory 是"上下文治理"——按 scope 控制召回什么、在 token 预算内决定丢什么、终态后决定写回什么。
1.1 总体架构图
Memory 跨三层存在:T1 配置(保留时长 / 写回开关 / scope 策略),T2 长期 + 团队共享(跨 Run 召回),T3 短期 + 装配(Run 内)。五个动作按 token 预算调度,外层受 scope 隔离硬约束。点色块跳章节。
1.2 上下文装配时序图
五动作有序触发:Precheck(估 token)→ Retrieval(按 scope 召回)→(超预算才走)Compression → ctx 就绪 → LLM Call → 终态后 Writeback。左侧是 Harness s2_context_assemble,右侧是 Memory Store。点 lifeline 跳章节。
1.3 为什么要做:缺口与解决的问题
6 类已在生产发生过的真实故障 → 对应 D1–D5 五条强约束。
| 缺口 | 未做时的现象 | 本设计的解决方式 |
|---|---|---|
| 没有预检 | 历史拼太长直接被 LLM 拒绝(context length exceeded),用户看到一堆错误重试也没用 | D1 预检先行:调用 LLM 之前先估算 token,触发召回限流或压缩 |
| 召回粒度过粗 | 把整段历史塞进 prompt,模型注意力被无关历史冲淡,回答跑题 | D2 相关召回:按优先级 + token cap 维度分层召回 |
| 压缩信息丢失 | 简单截断丢掉了用户最初的目标,模型走偏;HITL 审批结果被丢,重新跑还是被拦 | D3 压缩可证:关键约束清单一票否决,丢了就拒绝执行 |
| 失败经验沉淀 | 被 Policy 拒绝的危险操作经过总结后进了长期记忆,下次又被建议 | D4 写回受控:仅 SUCCEEDED 终态写长期,REJECTED / FAILED 一律不写 |
| 跨租户数据泄漏 | A 租户的项目事实被召回到 B 租户的 prompt | D5 多层隔离:tenant_id 行级强制注入,DAO 层一票否决 |
| OAuth token 进长期 | 临时凭据被压缩进对话摘要,长期存在 store 里造成横向泄漏 | D5 写前安全过滤:token / cookie / 临时密钥写前必剔 |
1.4 三层职责分工
Memory store 横跨三层、每层策略不同——T1 只读底座、T3 用完即弃、T2 是所有写回与召回的唯一持久层。
| Tier | 承担的 store | 核心职责 | 是否参与压缩 / 写回 |
|---|---|---|---|
| Tier 1 · 配置(蓝) | System prompt · Persona · Agent 元数据 · Team 配置 | 提供"模型是谁、能力是什么"的稳定底座,跨 Run 不变 | 不压缩、不写回——这一层是只读的 |
| Tier 2 · 长期 + 共享(紫) | user_memory(按用户)· agent_memory(按 Agent)· team_memory(按团队,共享) | 沉淀长期偏好 / 项目事实 / 团队经验,是写回的核心目标 | 需召回、需压缩、需写回受控——所有 D3/D4/D5 约束都作用在这一层 |
| Tier 3 · 短期(金) | session_history(最近 N 轮)· tool result scratch(工具结果暂存) | 承载当次 session 的对话 + 工具暂存,session 结束可丢 | 压缩首选目标——超预算时优先摘要这一层;不直接写长期 store |
"是否写长期"的决策只在 T2 发生——配合终态门禁(仅 SUCCEEDED 写)+ PII 过滤(写前剔 token / cookie / 临时密钥)形成闭环。
2. 预检 Precheck
说明在 LLM 调用之前如何估算 token 预算、何时触发后续动作、超预算的判定阈值是多少,保证超长 prompt 永远不进 LLM。
2.1 职责与触发时机
预检是 Memory 子系统的入口:每次 Harness 进入 s2_context_assemble 阶段,就会先做一次预检估算,再决定召回、压缩、装配的具体策略。它的核心输出是"当前估算 token / 目标预算 / 是否需要压缩"三个标量,下游所有动作都基于这个输出做决策。
2.2 token 预算估算口径
估算的是"如果按当前候选条目全量装配,会用掉多少 token"。组成包括 system prompt、history(按 session 配置的最近 N 轮)、工具 schema 描述、候选记忆条目(来自召回前的初步候选集)四部分。估算精度不要求字面级精确——能在 5% 误差内即可,足够支撑触发决策。
2.3 触发压缩的判定规则
把估算结果与目标预算比较,目标预算定为 model_context_window × 0.8,留 20% 给响应输出。判定规则按优先级如下。
| 判定情境 | 处理动作 |
|---|---|
| 估算 ≤ 目标预算 × 0.6 | 无需压缩,直接进入召回与装配 |
| 0.6 < 估算 / 预算 ≤ 1.0 | 触发轻量裁剪:减少 history 轮数 + 限制召回 top-N |
| 估算 > 目标预算 | 触发完整压缩链路(详见第 4 章) |
| 压缩二次预检仍超预算 | 拒绝执行,写 ErrorTrace + 退到无记忆模式或终止 |
2.4 输出 Trace
预检每次产出一条 StepTrace,作为 s2 的子步骤记录,包含current_tokens、target_budget、decision(pass / trim / compress / reject)、candidate_count 四个关键字段。这条 trace 是后续调试"为什么 prompt 这么长"的第一手数据。
3. 召回 Retrieval
给出召回管道的总览定位;具体打分公式、Hybrid 检索、阈值截断、Provider 抽象等实现细节单独写在 Memory Retrieval 召回算法设计 ↗。
召回是 Memory 子系统里最复杂的一个动作——它涉及向量检索、关键词检索、多维打分、阈值截断、降级路径多个环节,单独成篇详细描述。本节只给总览,所有实现细节请直接看专篇文档。
3.1 五阶段管道
| 阶段 | 核心职责 |
|---|---|
| ① scope 过滤 | tenant / user / persona / team 行级硬隔离,DAO 层强制注入,scope 缺失直接终止 Run |
| ② 候选生成 | Hybrid Search:向量召回 + BM25 关键词召回,RRF 融合产出 topN_candidate |
| ③ 打分排序 | 四维加权:语义相似度 + 时间衰减 + 频次加成 + 来源可信度 |
| ④ 截断双闸 | score 阈值(质量优先)+ topK 上限 + token 预算(≤ target_budget × 0.4)三者取交集 |
| ⑤ Trace | 每次召回写一条 memory_retrieve ToolTrace,记录 scope / 候选 / 打分分布 / 截断 / 降级标记 |
3.2 与本子系统的边界
Retrieval 的输入:当前 query、CallerIdentity(来自 RunContext)、token 预算余量(来自 §2 Precheck)。输出:一组 MemoryItem,由 §4 Compression 决定是否要进一步压缩、由 ctx 装配最终拼成 system message 段。所有写入 store 的元数据(来源 / 写入时间 / 频次)由 §5 Writeback 维护,召回时只读不写。
详细设计:Memory Retrieval 召回算法设计 →(scope 过滤的 DAO 注入细节、Hybrid 融合公式、四维打分的归一与权重区间、截断三闸优先级、六类故障降级动作、ToolTrace 字段集、Provider 选型边界)
4. 压缩 Compression
说明超预算时如何分级压缩、压缩中绝不能丢失的关键信息清单、压缩失败时的降级链。
4.1 压缩策略链
压缩是分级递进的,先做轻量动作再做重量动作;每一级压缩后都做一次预检,能进入预算就停止。
- 裁剪冗余历史 · 去重相同事实、合并相近操作(无信息损失)
- 摘要工具结果 · 超长 tool_result 只保留摘要 + 引用 ID,原始结果留在 trace
- LLM 压缩历史对话 · 用专用压缩 LLM 把若干轮对话合并成一段摘要
- 二次预检 · 仍超预算 → 拒绝执行(绝不允许超长 prompt 进 LLM)
4.2 关键约束(一票否决清单)
压缩本质是"丢信息以换 token",但有些信息丢了之后整个 Run 就失去意义。这份清单是压缩链路的硬约束:压缩前后必须存在;丢失任意一项,本次压缩失败,回退到上一级压缩或拒绝执行。
| 必保留项 | 原因 | 校验方式 |
|---|---|---|
| 用户目标(最初的 user input) | 丢了模型不知道在做什么 | 压缩前后都必须包含原始 input 的语义指纹 |
| 用户约束(明确说"不要做 X") | 丢了模型可能直接做禁止操作 | 关键否定词 + 约束陈述必须在压缩输出中保留 |
| 工具关键结果(影响后续决策的工具输出) | 丢了模型基于错误事实推理 | tool_result 摘要必须保留 status + 关键字段值 |
| HITL 决策(已批准 / 已拒绝) | 丢了重新跑还要再审批一次 | HITL 事件必须以原始结构形式保留,不能摘要 |
4.3 降级链
压缩本身可能失败(专用 LLM 不可用、超时、输出格式错误等),需要明确的降级路径。
| 触发情境 | 降级动作 |
|---|---|
| 压缩 LLM 调用成功 + 关键约束保留 | 使用压缩结果,正常进入装配 |
| 压缩 LLM 调用失败 / 超时 | 降级为简单裁剪(截取最近 N 轮 + 工具结果摘要) |
| 简单裁剪后仍超预算 | 降级为"无记忆执行"——只保留 system + 当前 input |
| 无记忆模式仍超预算(极少见,仅当 system 本身过长) | 拒绝执行 · 写 ErrorTrace · Run 进入 FAILED 终态 |
| 压缩成功但关键约束清单校验未过 | 视为压缩失败,按上述链路降级(不接受"目标都丢了的压缩") |
压缩动作每次写一条 StepTrace + summary_id,记录原始 token / 压缩后 token / 压缩模型 / 关键约束校验结果,便于事后追溯"这条 prompt 为什么变短了"。
5. 写回 Writeback
说明哪些终态才允许写长期记忆、写前必须做的安全过滤、不同终态的差异化处理,避免"危险经验"被沉淀。
5.1 按终态决定写回
写回是 Run 收口阶段(Harness s8_trace 之后、Run finalize)的最后一个动作。是否写长期记忆完全由 RunState 终态决定,不允许业务层覆盖。
| 终态 | user_memory | agent_memory | session 摘要 | 语义 |
|---|---|---|---|---|
| SUCCEEDED | 写(稳定偏好 / 项目事实 / 长期结论) | 写(岗位上下文) | 写 | 唯一允许写长期的终态 |
| CLOSED_FAILED | 不写 | 不写 | 写(仅错误 trace 引用,方便复盘) | 不沉淀失败结论,避免错误经验复用 |
| CLOSED_REJECTED · Policy 拒绝 | 绝对不写 | 不写 | 写"被拒绝"事实 | 禁止把危险操作经验写入长期 |
| CLOSED_REJECTED · HITL 拒绝 | 不写 | 不写 | 写 | 不写"已完成事实" |
| CLOSED_CANCELLED · 用户取消 | 不写 | 不写 | 写 | 不写不完整结论 |
| TIMEOUT | 不写 | 不写 | 写 | 状态不确定,不沉淀 |
5.2 写前安全过滤
即便终态是 SUCCEEDED,写入长期 store 之前还要过一道安全过滤——这一层是 Memory 子系统对外的最后一道隐私边界。
| 字段类型 | 处理动作 |
|---|---|
| 临时密钥 / OAuth token / cookie / 临时密码 | 必须剔除,绝不入长期 store |
| 一次性参数(本次调用的具体 ID / 临时文件路径) | 不写长期,仅留在 trace |
| 短期 PII(手机号 / 身份证号) | 保持 mask 状态写入,不存原文 |
| 邮箱 / 用户名等长期 PII | 允许写入但需打上 PII tag,便于后续 GDPR 删除 |
| 金额 / 业务事实 | 允许写入,配合 confidence 评分 |
5.3 写回元数据
每条写入的长期 memory 都要带溯源信息:source_run_id(来自哪次 Run)、confidence(0-1 置信度,未来支持基于多次确认升权)、expires_at(可选过期时间)。这些字段是后续做"记忆版本化、过期淘汰、置信度更新"的基础。
写回动作每次写一条 StepTrace + AuditLog 关键事件 memory_written,记录条目种类、大小、source_run_id,便于审计"这条记忆是怎么进来的"。
6. 隔离 + 安全过滤
把 scope 隔离与隐私过滤合并讨论:前者管"读路径不串",后者管"写路径不漏",两者共同构成 Memory 子系统的安全边界。
6.1 三层 scope 强制隔离
scope 是 Memory 子系统最底层的安全约束,任何召回 / 写回路径都必须先过 scope。三层从强到弱依次为 tenant、user、session。
| 层级 | 字段 | 实施位置 | 违反后果 |
|---|---|---|---|
| tenant | tenant_id | DAO 行级强制注入 · 编译期校验 | 一票否决,违反即视为重大事故 |
| user | user_id + createBy | DAO 行级过滤 · 业务层校验 | 视为越权访问,立即终止 Run |
| session | session_id | 应用层过滤 · 仅 session 私有摘要可见 | 跨 session 串数据,写 WarnTrace + 返回空 |
6.2 隔离不变式(必测)
这三条不变式是上线前必须通过的验收测试,缺一项不允许放行。
- 用户隔离:用户 A 的 user_memory 在任何召回 / 检索 / 列表接口下,都不得被用户 B 的 Run 看到
- session 隔离:不同 session 的 session 私有摘要不得跨串(团队共享 memory 不在此约束内,那走 team scope)
- tenant 隔离:不同租户的任何 memory 在任何路径下都不得跨可见,包括误用 SQL、误用缓存键、误用向量索引
6.3 安全过滤与 scope 的协同
scope 隔离管"读路径不串",安全过滤管"写路径不漏"。两者协同覆盖三类典型故障:
| 故障类型 | scope 隔离的角色 | 安全过滤的角色 |
|---|---|---|
| 跨租户数据看见 | tenant_id 行级过滤兜底 | 不直接相关 |
| OAuth token 落入长期 store | 不直接相关 | 写前剔除 token / cookie |
| 把别的用户的偏好误召回 | user_id 行级过滤 + 二次校验 | 不直接相关 |
| 对话摘要里嵌入了临时密钥 | 不直接相关 | 压缩 LLM 输出过 PII 检测器 |
| 团队 memory 被非成员召回 | team_id + 成员关系双向校验 | 不直接相关 |
6.4 PII 处理策略
PII 不是简单的"全部脱敏"——不同类型 PII 的处理策略不同。设计原则是:能 mask 就 mask,必须保留原文的字段(如长期联系邮箱)打 PII tag 便于后续 GDPR 删除。
| PII 类型 | 处理动作 | 原因 |
|---|---|---|
| 手机号 / 身份证号 | mask 后写入(保留前 3 后 4) | 支持模糊匹配但避免完整暴露 |
| 邮箱(业务联系用) | 原文写入 + PII tag | 后续召回需要精确匹配 |
| 姓名 | 原文写入 + PII tag | 对话语境需要 |
| 地址 / 金额 / 业务数据 | 原文写入 + PII tag | 语义不可替代 |
| 临时密钥 / OAuth token / cookie | 绝不写入 | 不是 PII 而是凭据,长期保留无意义且高风险 |
所有打了 PII tag 的字段在 store 层支持按 user_id 一键删除,配合公司 GDPR 流程使用。