Dark Dwarf Blog background

Agent Memory 设计

Agent Memory 设计

1. Agent Memory 概述

Agent Memory 是让 Agent 在多次对话甚至多次会话之后,仍然记得用户是谁、偏好什么、之前处理过什么的关键组件。这里的 Memory 就是 Context Engineering 中说到的应该从当前窗口 ISOLATE 到长期档案的信息。

在 CoALA 的认知架构分类中,Memory 的类型如下:

  • Working memory:当前 prompt、scratchpad、tool 结果。这就是 Context Engineering 中的上下文。
  • Episodic memory:会话历史、经验轨迹。它从 context 中来,但要比单轮窗口活得更久。
  • Semantic memory:事实、偏好、知识。它是 episodic memory 被抽象后的产物,读多写少。
  • Procedural memory:能力、规则、skill。极少写,但写一次影响最大。
层级存储内容写入频率写错代价典型业务形态
Episodic Memory会话历史、诊断案例、用户交互轨迹高,持续追加污染后续检索和经验复用历史故障案例、客服会话
Semantic Memory事实、偏好、业务规则中,需要抽象错误事实被反复召回用户画像、回答风格偏好
Procedural Memorysystem prompt、skill、agent 代码低,接近部署可能改变 agent 行为边界新增 skill、修改审批规则

三层记忆从上往下,写入频率降低,但写错的代价升高。这个规律直接影响工程策略: episodic memory 可以自动化摄取,但要做好隔离和审计;semantic memory 需要校验和置信度;procedural memory 最好走人工审批或严格的发布门禁。

2. Agent Memory 的基础架构

不管是哪一种记忆,在被持久化之前都要考虑下面的方面:这条记忆属于什么语义类型?它当前处于生命周期的哪个状态?谁可以看到它、谁可以写入它?它是否值得被记住?如果与已有记忆冲突该如何更新?写入出错会带来什么风险?

这些通用问题构成了 Memory 的基础架构。下面详细讲述下这个架构的设计。

a.a. kind、status 与 scope

Agent Memory 中的每条记录都可以用三个维度来划分:

维度解决的问题取值
kind这是什么类型的记忆?应该由哪个 handler 消费?preference、soft_preference、note、diagnosis_case
status这条记录当前处于生命周期的哪个阶段?active、superseded、observed、confirmed_resolved、disabled
scope谁能看到、谁能写入?user、group、tenant

这其中 kind 是语义类型,status 是生命周期状态,scope 是可见范围。它们共同决定了一条记忆能否被召回、能否覆盖其他记忆、能否被某个用户检索到。其中 status 和 scope 取值的含义如下

状态值说明
active正在生效
superseded被同 key 的新值替换,保留用于审计
observed中间态,仅记录、不用于推导最终结论
confirmed_resolved已验证并闭环,可沉淀为可复用经验
disabled被用户或系统显式停用
范围值说明
user个人私有
group群聊/团队共享
tenant租户级共享

b.b. Memory 提取门槛

不是所有发生过的信息都值得写入长期记忆。任何 kind 在写入前都应该经过统一的 eligibility gate。gate 总体上会审查下面的方面:

  1. 边界检查通过:记忆必须落在 Agent 的业务范围内。把边界外的问题沉淀为内部经验会污染记忆、导致 Agent 会检索到无关的信息。
  2. 状态要求满足:不同类型的 Memory 有不同的 status。例如 Episodic Memory 通常只写入 confirmed_resolved,硬偏好则要求信号明确、不是临时性表达。
  3. 置信度达到阈值:低于阈值的信号不应该进入长期记忆,否则会把弱信号放大成“事实”。
  4. 必须有可核实的证据:工具输出、检索结果、经过校验的实时数据都可以作为证据;但不能仅凭历史记忆、模型推断或用户口述。

下面是一个不针对特定 kind 的 eligibility gate 骨架:

export interface IngestionPolicy {
  boundaryCheckRequired: boolean;
  statusRequired: MemoryStatus;
  minConfidence: number;
  requiredEvidenceSourceTypes: EvidenceSourceType[];
}

function isEligibleForCapture(
  candidate: {
    status: MemoryStatus;
    confidence: { score: number };
    evidence: Evidence[];
    boundaryCheck?: { passed: boolean };
  },
  policy: IngestionPolicy,
): boolean {
  // 1. 证据必须来自可核实的实时来源,不能把历史记忆或模型推断当证据
  const currentEvidence = candidate.evidence.filter(
    (item) =>
      item.status === "verified" &&
      policy.requiredEvidenceSourceTypes.includes(item.sourceType),
  );

  return (
    // 2. 边界检查(如果业务需要)
    (!policy.boundaryCheckRequired ||
      candidate.boundaryCheck?.passed !== false) &&
    // 3. 状态要求
    candidate.status === policy.statusRequired &&
    // 4. 置信度门槛
    candidate.confidence.score >= policy.minConfidence &&
    // 5. 必须有证据
    currentEvidence.length > 0
  );
}

c.c. 状态机

记忆不是简单的新值覆盖旧值。同一条偏好、同一个诊断案例在不同时间被再次遇到时,系统需要判断:是新增、强化、替换,还是合并更新。这个过程可以用下面的状态机来描述:

操作条件效果
ADD新槽位/新案例,信号明确新增一条 active 记录
REINFORCE相同值/相同指纹再次出现增加 support_count、更新 last_seen_at
SUPERSEDE冲突值出现,信号明确旧记录变为 superseded,新记录变为 active
UPDATE同 key 需要覆盖或合并直接更新 active 记录的内容

这套模式适用于各种类型的 Memory。以 preference 为例:用户第一次说“以后回答都详细点”,对应 ADD;再次表达同样偏好,对应 REINFORCE;后来改口“以后回答简洁点”,则对 verbosity 这个 key 触发 SUPERSEDE;free_text 类的签名需要追加时,则走 UPDATE。临时性的“这次详细点”因为信号不明确,不会进入长期记忆。

Memory 的核心设计原则是:弱信号不写入,冲突信号不盲目覆盖。通过保留 superseded 记录,系统既能追踪偏好的历史演变,又不会在执行层同时召回两条冲突值。

d.d. 作用域与隔离

scope 不只是查询时的过滤条件,它同时定义了写入边界。Memory 系统把 scope 解析抽象成一个可插拔接口:

// scope-policy.ts
export interface ScopePolicy {
  resolve(context: MemoryRequestContext): MemoryScope | undefined;
}

以默认实现 DirectChatScopePolicy 为例,它按单聊/群聊直接映射为 user 或 group:

// scope-policy.ts
export class DirectChatScopePolicy implements ScopePolicy {
  constructor(private readonly tenantId: string) {}

  resolve(context: MemoryRequestContext): MemoryScope | undefined {
    const sessionId = context.sessionId.trim();
    const userId = context.userId?.trim();
    if (!sessionId || sessionId === "unknown") return undefined;

    // 单聊:sessionId 等于 userId,按用户隔离
    const isDirect = Boolean(
      userId && userId !== "unknown" && sessionId === userId,
    );
    if (isDirect) {
      return { tenantId: this.tenantId, type: "user", subjectId: userId! };
    }

    // 群聊:按群会话隔离
    return { tenantId: this.tenantId, type: "group", subjectId: sessionId };
  }
}

MemoryService 在调用任何 handler 之前,先通过 ScopePolicy 解析出 scope,再把它注入给 handler:

// service.ts
export class MemoryService {
  private readonly scopePolicy: ScopePolicy;

  resolveScope(context: MemoryRequestContext): MemoryScope | undefined {
    return this.scopePolicy.resolve(context);
  }

  async handleCommand(context: MemoryRequestContext, rawInput: string) {
    const scope = this.resolveScope(context);
    if (!scope) return { handled: false };
    // ... 后续所有 store 操作都使用这个 scope
  }
}

三层默认作用域的含义如下:

scope可见/写入范围典型应用场景
user单个用户私有个人偏好、个人诊断历史
group一个群聊或团队内部团队内共享的故障经验
tenant租户下所有用户和群聊已脱敏、已闭环的组织级知识

handler 的输入接口里已经带了 scope,它由框架注入:

export interface OperationConsumeContext {
  scope: MemoryScope; // ← MemoryService 注入,handler 只读
  sessionId: string;
  actorId?: string;
  store: MemoryStore;
  policy: MemoryPolicy;
  // ...
}

export const preferenceMemoryHandler: MemoryKindHandler = {
  kind: "preference",

  async consumeOperation(operation, ctx) {
    if (operation.operation !== "upsert_preference") return;
    // 只能把偏好写入框架给定的 scope,无法自己决定写进别的 scope
    await ctx.store.upsertPreference({
      scope: ctx.scope,
      key: operation.key,
      value: operation.value,
      content: operation.content,
      // ...
    });
  },
};

这样 handler 就只能往限定的 scope 中写入记忆了。

对于读取,store 的检索接口以单个 scope 为入口,但会根据 kind 自动扩展查询 tenant 级共享记忆:

async search(request: MemorySearchRequest): Promise<MemoryRecord[]> {
  const kinds = request.kinds?.length ? request.kinds : ['preference', 'diagnosis_case'];
  // 诊断案例检索时,除了 user/group scope,还同时查 tenant scope
  const includeTenant = kinds.includes('diagnosis_case') && request.scope.type !== 'tenant';

  const ftsCandidates = this.searchFts(request.scope, request.query, kinds, request.topK * 3);
  const tenantFts = includeTenant
    ? this.searchFts(
        { tenantId: request.scope.tenantId, type: 'tenant', subjectId: request.scope.tenantId },
        request.query, kinds, request.topK * 3,
      )
    : [];

  // ... 结构化检索同理,最后 RRF 融合并去重
}

以群聊中的用户 alice 为例,检索诊断案例时会同时查 alice 的 user scope 私有记忆、当前群聊 group-42 的共享记忆,以及 tenant 级组织共享记忆。三层结果合并去重后按 RRF 排序。tenant 记忆不会默认覆盖 user 或 group 记忆,而是通过 provenance / trustLevel 参与排序,实现“个人偏好只影响个人,群聊经验在群内共享,组织级知识对所有人可见但不会因为更权威就抹掉个人设定”。

e.e. 写入风险与审计

长期记忆一旦写错,就会持续污染后续轮次的决策。通用风险包括:

  • 写错记忆污染后续决策:错误偏好、错误案例被反复召回,Agent 会把错误信息当成既定事实。
  • 无证据写入:把模型推断或用户口述直接存为经验,导致 Agent 开始编造事实。
  • 作用域污染:群聊记忆写入个人空间,或个人未脱敏案例被共享到租户。
  • 重复计数:同一经验被记录多次,造成“这个问题很普遍”的假象,影响排序和判断。

针对这些设计,Memory 系统采用了下面的做法:

  • 校验:写入前经过 SafetyFilter、EvidenceValidation、Canonicalization 等 stage(详见“提取 pipeline”一节)。
  • 状态机设计:保留 superseded 历史,避免新值直接销毁旧值。
  • 可遗忘:用户可以通过 /memory forget <id> 删除错误记忆;被删除的记录可以标记为 disabled,而不是物理抹去,保留审计可能。
  • 审计日志:保留用户原始 Markdown 笔记、操作来源、source_run_id、证据引用,方便事后追溯记忆来源。

这些机制是整个 Memory 系统的核心部分。之后的 Semantic Memory 和 Episodic Memory 章节会展示它们在这方面具体怎么做的。

3. Memory 组件设计

下面简单介绍下用于实现上述架构的一些组件接口设计。

a.a. 提取 pipeline

异步提取是一个 pipeline:

const stages: ExtractionStage[] = [
  new LlmExtractionStage(llm, policy, catalog), // LLM 负责语义理解
  new RuleExtractionStage(policy, catalog), // 规则兜底高置信表达
  new SafetyFilterStage(policy), // 业务/敏感/控制 gate
  new EvidenceValidationStage(policy), // offset/子串/token/embedding 核实、confidence、key-value 校验
  new CanonicalizationStage(), // 规范化 content
];

每个 stage 只负责一件事,输入是当前已累积的操作列表,输出新的列表。需要加入新的提取策略只需要加一个新 stage,不用改 BusinessMemoryExtractor 主体。

b.b. 作用域 Policy

scope 解析被抽象成一个可插拔接口:

// scope-policy.ts
export interface ScopePolicy {
  resolve(context: MemoryRequestContext): MemoryScope | undefined;
}

默认实现 DirectChatScopePolicy 按单聊/群聊直接映射为 user 或 group:

// scope-policy.ts
export class DirectChatScopePolicy implements ScopePolicy {
  constructor(private readonly tenantId: string) {}

  resolve(context: MemoryRequestContext): MemoryScope | undefined {
    const sessionId = context.sessionId.trim();
    const userId = context.userId?.trim();
    if (!sessionId || sessionId === "unknown") return undefined;

    // 单聊:sessionId 等于 userId,按用户隔离
    const isDirect = Boolean(
      userId && userId !== "unknown" && sessionId === userId,
    );
    if (isDirect) {
      return { tenantId: this.tenantId, type: "user", subjectId: userId! };
    }

    // 群聊:按群会话隔离
    return { tenantId: this.tenantId, type: "group", subjectId: sessionId };
  }
}

MemoryService 只消费 resolve() 返回的 MemoryScope,不接触原始 userId/sessionId。如果需要部门级、项目级共享记忆,只要实现新的 ScopePolicy 并注入 MemoryService,无需改动提取或检索流程。

c.c. 记忆类型 handler

preference 和 diagnosis_case 不是写死在 service 里的,而是两个 MemoryKindHandler:

export interface MemoryKindHandler {
  readonly kind: MemoryKind;
  isEligible?(card: DiagnosisCard, policy: MemoryPolicy): boolean;
  capture?(
    input: DiagnosisCaptureInput,
    store: MemoryStore,
    policy: MemoryPolicy,
  ): Promise<void>;
  consumeOperation?(
    operation: MemoryExtractionOperation,
    ctx: OperationConsumeContext,
  ): Promise<void>;
  toEvidence?(
    record: MemoryRecord,
    startSequence: number,
    maxChars: number,
  ): DiagnosisEvidence | undefined;
}

具体 Memory 系统有的 handler 的分工:

Handler处理的 kind职责
preferenceMemoryHandlerpreference消费 upsert_preference 操作,把硬偏好写入长期记忆
softPreferenceMemoryHandlersoft_preference消费 store_soft_preference 操作,把软偏好放入召回池
noteMemoryHandlernote把 /note 命令写下的 Markdown 原样存入 memory_notes
diagnosisCaseMemoryHandlerdiagnosis_case摄取已解决的诊断案例,并在检索时把它转换成证据

这种设计的好处是扩展性。如果要新增一种记忆——比如“用户画像”或“授权范围”——只需要:

  1. 新增一个 UserProfileHandler implements MemoryKindHandler;
  2. 实现 capture、consumeOperation、toEvidence 中需要的几个方法;
  3. 把它注册进 handler 列表。

核心 service 的 capture/retrieve pipeline 不需要改任何代码,会自动按 kind 把请求分发给对应的 handler。

4. Semantic Memory

Semantic Memory 是记录事实、偏好、知识的记忆,它决定 Agent 怎么呈现回答:语言、结论顺序、细节程度、格式等。

最简单的整理方法是:用户说“回答简洁一点”,就把这句话存进向量库,下次检索出来加进 prompt。但这种处理方法有几个问题:

  • 临时性 vs 持久性:用户某一次说“这次简单点”,不一定意味着他永远喜欢简单。
  • 冲突与漂移:用户上个月喜欢详细,这个月喜欢简洁,旧偏好会和新偏好打架。
  • 越界风险:用户如果说“下次可以直接执行操作不用问我”,这条记忆如果直接生效,可能绕过审批。
  • 注入风险:攻击者可以通过间接提示,让 Agent 记住一条恶意规则。

所以用户偏好不能是“用户说了什么就存什么”。一个简单有效的设计是两层架构:用户看到的是可编辑的 Markdown 笔记,Agent 执行的是从笔记中提取并经校验的结构化偏好槽位以及软记忆。

a.a. 用户层:Markdown 笔记

用户需要能便捷地修改和查看记忆,因此我们设计 Markdown 为用户手动输入记忆的入口。Markdown 只是用户可编辑视图之一,它适合一次性整理多条偏好或写入自由文本。之后还会介绍其他记忆写入方式。

## 我的回答偏好

- 回答尽量简洁
- 先说结论,再给排查步骤
- 时区:Asia/Shanghai
- 签名:— 小王

使用 Markdown 管理记忆有如下的优点:

  • 可读可编辑:用户随时可以用 /note 写、改、删,也能通过 /memory notes 查看当前笔记;
  • 审计友好:原始表达会一直保留着,不会因为 LLM 抽取而丢失;
  • 未知内容不污染执行层:笔记里可以写任何东西,只有能落到已知槽位且通过校验的内容才会进入 PresentationPolicy。

为了降低用户第一次写笔记的成本,系统根据当前 catalog 生成一份默认 Markdown 模板。用户只发 /note 或 /note template 时,会收到类似下面的脚手架:

---
schema_version: supermodel-memory/v1
editable: true
---

## Preferences

- verbosity: <!-- 默认值:standard;可选值由目录定义 -->
- answer_order: <!-- 默认值:standard;可选值由目录定义 -->
- format: <!-- 默认值:bullets;可选值由目录定义 -->
- language: <!-- 默认值:zh-CN;可选值由目录定义 -->
- sql_visibility: <!-- 默认值:normal;可选值由目录定义 -->
- timezone: <!-- 默认值:Asia/Shanghai -->
- signature:

## Notes

把不会落入受控偏好的内容写在这里;备注不会直接改变执行策略。

## 我的偏好

(兼容旧版本标题;偏好请使用上面的 `key: value` 格式。)

## 其他备注

(兼容旧版本标题;自由文本仅作为审计笔记。)

模板只是脚手架,不是硬性约束,只是推荐用 ## Preferences 下的 key: value 格式书写,这样规则抽取器可以直接识别,无需调用 LLM;自由文本也能被 LLM 语义抽取。用户可以改值、删行、加自由文本,提取器仍会按 catalog 识别已知槽位。未知内容留在 ## Notes 里原样保留。这样既有“不知道怎么写”时的引导,又保留了“我知道自己要什么”时的自由度。

b.b. 执行层:从 schema 到 PresentationPolicy

用户层是自由的,但 Agent 执行需要确定性。所以用户的 Markdown 会经过下面一系列处理:

Markdown note
  ↓ MarkdownPreferenceExtractor(规则 + 可选 LLM)
  ↓ MemoryExtractionOperation(upsert_preference)
  ↓ SafetyFilter / EvidenceValidation / Canonicalization
  ↓ 状态机(参见“状态机:记忆如何更新”一节)
  ↓ PresentationPolicy 编译
  ↓ prompt 片段

提取 pipeline 由 LLM 负责语义理解,规则负责兜底:

const stages: ExtractionStage[] = [
  new LlmExtractionStage(llm, policy, catalog),
  new RuleExtractionStage(policy, catalog),
  new SafetyFilterStage(policy),
  new EvidenceValidationStage(policy),
  new CanonicalizationStage(),
];

RuleExtractionStage 不会因为在它之前 LLM 已经返回了结果就断掉。它会检查 LLM 已经覆盖的 key,只补充 LLM 遗漏的高置信槽位:

const coveredKeys = new Set(
  operations
    .filter((op) => op.operation === "upsert_preference")
    .map((op) => op.key),
);
const supplemental = ruleOps.filter(
  (op) => op.operation !== "upsert_preference" || !coveredKeys.has(op.key),
);
return [...operations, ...supplemental];

例如 LLM 只抽到 verbosity=concise,但用户消息里还有“先说结论”,规则阶段会补上 answer_order=conclusion_first。这保证所谓“兜底”是 true fallback,而不是 LLM 命中后就放弃规则。

这条链的核心是一份偏好 catalog,它声明 Agent 能消费哪些槽位、每个槽位合法取值是什么。槽位支持下面三种类型:

  • enum:封闭取值集合(如 verbosity=concise|detailed|standard);
  • regex:单一字符串值,必须匹配正则(如 timezone=^[A-Za-z_]+/[A-Za-z_]+$);
  • free_text:任意字符串值,但仍绑定到已知 slot key(如 signature)。
// config/preferences/default.json
{
  "slots": {
    "verbosity": {
      "type": "enum",
      "description": "回答详细程度",
      "default": "standard",
      "values": { ... },
      "rulePriority": ["concise", "detailed"]
    },
    "timezone": {
      "type": "regex",
      "description": "时区",
      "default": "Asia/Shanghai",
      "pattern": "^[A-Za-z_]+/[A-Za-z_]+$"
    },
    "signature": {
      "type": "free_text",
      "description": "签名",
      "default": ""
    }
  }
}

catalog 是执行层的单一事实来源。MarkdownPreferenceExtractor 从 Markdown 中识别已知槽位:

  • 对 enum,规则路径匹配 catalog 中的 patterns / rulePatterns,LLM 路径处理同义表达;
  • 对 regex,从文本中抽取匹配正则的子串并校验;
  • 对 free_text,主要靠 LLM 抽取,接受任意字符串。

未知内容不会被拒绝,只是不会生成 upsert_preference 操作,继续留在笔记里供用户查看、并有可能被整理到软偏好中。

无论哪条路径,候选操作都要经过同一套校验 stage:

  • SafetyFilterStage:content / evidenceQuote 命中业务/敏感/控制关键词就丢弃;
  • EvidenceValidationStage:evidenceQuote 必须能在原文中核实,置信度必须大于阈值;无法核实的 quote 直接丢弃;
  • CanonicalizationStage:把 content 规范化为 catalog 中的标准文案,避免同一偏好因措辞不同产生多个 revision。

key-value 配对校验发生在写入前:enum 的 value 必须在取值集合内,regex 必须匹配模式,free_text 必须是字符串。这防止 LLM 把 format=table 错填到 verbosity 槽位。

来源校验没有完美方案,只能做到“可核实才接受,无法核实就丢弃”。当前实现按严格程度递减分了四层:

  1. 字符偏移核实:如果 LLM 同时返回 evidenceOffset(start/end 字符偏移),先检查该偏移指向的原文片段是否包含 quote。这能防住 LLM 把别的句子里的词张冠李戴。
  2. 归一化子串匹配:把 quote 和原文都去空格、转小写后做包含匹配,覆盖 Asia/Shanghai vs asia/shanghai 这类大小写/空格差异。
  3. Token 软回退:子串不匹配时,把 quote 和原文按常见标点、斜杠、空格切分成 token,若 quote 中所有有效 token 都出现在原文中且至少有两个 token,则接受。这能覆盖 "asia/shanghai" 被 LLM 写成 "asia shanghai" 的情况。
  4. Embedding 语义兜底:如果前三层都失败,可以引入 Embedding Client 计算 quote 和 原文片段的语义相似度,超过阈值则接受。
export interface EvidenceVerificationOptions {
  source: string;
  quote: string;
  offset?: { start: number; end: number };
  embeddingClient?: EmbeddingClient;
  embeddingSimilarityThreshold?: number;
  embeddingMaxSpanChars?: number;
}

export function isEvidenceQuoteVerifiable(
  options: EvidenceVerificationOptions,
): Promise<boolean>;
async function isEvidenceQuoteVerifiableAsync(opts) {
  // 1. offset 核实
  if (offset valid && slice includes quote) return true;
  // 2. 归一化子串匹配
  if (normalized source includes normalized quote) return true;
  // 3. token 软匹配
  if (all quote tokens appear in source) return true;
  // 4. embedding 兜底(仅当提供了 EmbeddingClient)
  if (embeddingClient) {
    return verifyByEmbedding(source, quote, client, threshold, maxSpanChars);
  }
  return false;
}

通过校验的操作才会进入状态机,按 ADD / REINFORCE / SUPERSEDE / UPDATE 决定如何写入,避免新操作随意覆盖旧结果。在 Semantic Memory 中,临时性表述(如“这次详细点”)不应进入长期记忆,只有明确、稳定的偏好才会触发状态机写入;free_text 类槽位则更多走 UPDATE 或 SUPERSEDE。

c.c. 软偏好:开放表达的低权重召回

Hard preference 解决的是“可枚举、可校验、强生效”的展示策略,但用户还有很多开放的表达偏好无法精确落到某个 slot。例如:

  • “解释时先给根因,再给排查的具体链路。”
  • “回答我的时候多举几个反例。”

这些偏好如果只走 catalog 白名单,可能会被拒之门外;如果直接丢弃,又浪费了用户反复表达的风格信息。一个更合理的做法是把它当作软偏好:低权重、会衰减、仅影响 LLM 的措辞,绝不改变事实、证据、工具或安全规则。

i.i. 三层记忆的有效强度

层级内容示例生效方式召回方式
Hard preferenceverbosity=concise、language=zh-CN 等 catalog 槽位强生效,确定性渲染按 scope 直接列出 active 记录
Soft preference“先说根因再给排查树”“多给反例”等开放表达低权重,仅影响表达按 query 语义召回,时间衰减
Note / archival原始 Markdown 笔记、历史对话材料默认不注入 Prompt只供 /memory notes 查询和审计

ii.ii. 软偏好的写入与召回规则

为了避免软偏好变成事实污染或提示词注入,需要给它定下面的边界:

  1. 只接收稳定的回答风格偏好:拒绝事实、业务结论、工具调用、权限、安全指令和凭据。
  2. 冲突由 LLM 根据当前上下文裁决:Hard preference(catalog 槽位)是确定性最强的执行策略;Soft preference 是按当前 query 召回的开放表达建议,仅影响措辞;catalog 默认值兜底。当前轮次的明确指令天然具有最高权重(比如发送的消息显式说了“请详细讲述xxx”,则 Agent 的回答优先遵循这个)。
  3. 按照置信度 + 相关度 + 时间衰减共同排序:
    • 重复出现或高置信度的软偏好权重更高;
    • 与当前 query 语义相关的优先;
    • 越久的软偏好重要性随实践指数衰减。
  4. 用户可见、可遗忘:软偏好以 soft_preference 形式出现在 /memory 列表中,用户可以通过 /memory forget <id> 删除。

iii.iii. 记忆召回优化

软偏好池的数量会随着时间增长,全量扫描代价比较高。一个轻量的实现是先做 FTS 预过滤,再对候选做权重排序。权重参数全部外置到 MemoryPolicy:

export interface SoftPreferenceRecallPolicy {
  topK: number; // 每次最多召回几条
  halfLifeDays: number; // 半衰期
  minWeight: number; // 权重阈值
  maxPromptChars: number; // Prompt 总长度上限
  baseRelevance: number; // 基础相关性分数
  maxRelevanceBoost: number; // relevance 最大加成
  baseSupport: number; // 支持次数基础分
  supportBoost: number; // 支持次数加成系数
}

async function retrieveSoftPreferences(
  context: MemoryRequestContext,
  query: string,
): Promise<SoftPreferenceRecall[]> {
  const scope = resolveScope(context);
  // 软偏好召回目前只在用户私有作用域生效,避免群共享记忆被误召回。
  if (!scope || scope.type !== "user" || !query.trim()) return [];
  const policy = memoryPolicy.softPreferenceRecall;

  // 1. 用 FTS 把软偏好池缩小到与 query 相关的一小批
  const softPreferences = await store.search({
    scope,
    query,
    kinds: ["soft_preference"],
    topK: policy.topK * 3,
  });

  // 2. 拿到当前硬偏好,避免软记忆重复提示已被 slot 覆盖的方面
  const hardPreferences = await store
    .list(scope, "preference")
    .then((records) => records.filter((r) => r.status === "active"));

  const queryTokens = tokenize(query);
  const now = Date.now();
  const halfLifeMs = policy.halfLifeDays * 86_400_000;

  return softPreferences
    .filter(
      (record) =>
        !isCoveredByHardPreference(record.content, hardPreferences, catalog),
    )
    .map((record) => {
      const ageMs = now - record.lastSeenAt;
      const recency = Math.exp((-Math.LN2 * ageMs) / halfLifeMs);
      const overlap = tokenOverlap(queryTokens, record.searchableText);
      const relevance =
        policy.baseRelevance +
        Math.min(policy.maxRelevanceBoost, overlap * policy.maxRelevanceBoost);
      const support = Math.min(
        1,
        policy.baseSupport +
          Math.log2(Math.max(1, record.occurrenceCount)) * policy.supportBoost,
      );
      const weight = record.confidence * support * recency * relevance;
      return { record, weight, ageDays };
    })
    .filter((item) => item.weight >= policy.minWeight)
    .sort((a, b) => b.weight - a.weight)
    .slice(0, policy.topK);
}

这里的关键是 “不覆盖硬记忆”:召回前先拿到当前 active 的硬偏好,如果某条软记忆的内容已经被某个硬 slot 覆盖(例如硬偏好已设置 answer_order=conclusion_first,软记忆又说“先说结论”),就直接过滤掉,避免潜在冲突。

这里判断是否覆盖的方式比较粗糙,实际中可以用下面两种思路:

  1. 把软硬记忆都写进 prompt,告诉不同记忆优先级让 LLM 自己选择。
  2. 使用小模型进行判断。

注入 Prompt 时,软偏好会带上明确的限制声明:

[相关的软性表达偏好,仅用于调整措辞;不得改变事实、证据、权限、工具]
- 先说根因再给排查(权重 0.82)

这套设计的核心取舍是:开放表达可以被利用,但被限制在表达层面。它不会像 Mem0 那样把任何事实都写进长期记忆。

d.d. 将记忆正确放入 Agent

偏好被提取后,不会直接拼进 prompt,而是先被编译成一个受 catalog 约束的 PresentationPolicy,具体而言:

  1. 过滤:只拿生效的偏好,跳过 superseded 等非 active 记录,只处理当前真正生效的偏好。

  2. 去重:同一个 key 如果因为多次编辑留下多条 active 记录,只取第一条,避免冲突。

  3. 取值安全校验:对每条记录,按“元数据合法 → 从 content 推断 → 默认值”的优先级决定最终值。如果存储的 value 不在 catalog 白名单里,直接丢弃并回默认,不让脏数据进 prompt。

  4. 记录血缘:保留来源 ID,方便后续在 /memory 里展示“这条偏好来自哪条记忆”。

function compileLayer(
  records: MemoryRecord[],
  catalog: PreferenceCatalog,
): {
  fields: Partial<Record<PreferenceKey, PreferenceValue>>;
  appliedMemoryIds: string[];
} {
  const fields: Partial<Record<PreferenceKey, PreferenceValue>> = {};
  const appliedMemoryIds: string[] = [];
  const selected = new Set<string>();

  for (const record of records) {
    // 步骤 1:过滤,只处理 active 的 preference
    if (record.kind !== "preference" || record.status !== "active") continue;

    const key = record.metadata.preferenceKey;

    // 步骤 2:去重 + 白名单守门;不认识的 key 直接跳过
    if (!catalog.isPreferenceKey(key) || selected.has(key)) continue;

    // 步骤 3:按优先级安全取值
    const raw = record.metadata.preferenceValue;
    let resolvedValue: string;
    let usedMemoryValue = false;

    if (typeof raw === "string" && catalog.isValidValueForKey(key, raw)) {
      // 3a:元数据合法 → 直接使用
      resolvedValue = raw;
      usedMemoryValue = true;
    } else if (raw === undefined) {
      // 3b:元数据缺失 → 尝试从 content 推断
      const inferred = inferValueForKey(key, record.content);
      if (inferred !== undefined && catalog.isValidValueForKey(key, inferred)) {
        resolvedValue = inferred;
        usedMemoryValue = true;
      } else {
        // 推断也失败 → 回默认值
        resolvedValue = catalog.slots[key].default;
      }
    } else {
      // 3c:元数据损坏/不合法 → 不信任,回默认值
      resolvedValue = catalog.slots[key].default;
    }

    fields[key] = resolvedValue;
    selected.add(key);

    // 步骤 4:记录这条记忆真正被用到了
    if (usedMemoryValue) appliedMemoryIds.push(record.shortId);
  }

  return { fields, appliedMemoryIds };
}

这个 policy 随后被转成一段 prompt 片段给 llm:

[受策略层校验的展示偏好;只调整表达形式,不得改变事实、证据、权限、工具或安全规则]
verbosity=concise; answer_order=conclusion_first; format=steps; language=zh-CN, ......

无论用户偏好怎么变,底层的诊断结论、证据链、工具调用逻辑都保持不变。Memory 只能改变“怎么说”,不能改变“查什么、结论是什么”。

PresentationPolicy 生成后,还要决定以什么身份进入 LLM 上下文。一个常见的做法是把偏好片段拼在 user message 末尾,实现简单,但这有两个问题:

  • 它会被写进对话历史,后续轮次可能被模型误认为是用户刚说的话;
  • 它的权威性不如 system prompt,模型可能把它和当前 user 指令混为一谈。

更稳妥的做法是把偏好上下文作为一条独立的 system message,插入到主 system prompt 之后、历史对话之前:

const messages: LlmMessage[] = [
  { role: "system", content: systemPrompt },
  { role: "system", content: preferenceContext }, // 独立 system message
  ...history,
  { role: "user", content: userInput },
];

这样偏好被明确标识为策略层指令,而不是用户输入的一部分,同时保留了对历史对话的可见性。

e.e. 三条写入路径:聊天、笔记、显式命令

用户不需要手写 Markdown 才能拥有长期记忆。系统可以设计不同的偏好写入路径,按“用户控制强度”从弱到强排列如下:

路径触发方式处理模式来源标记适用场景
异步对话提取普通聊天结束后自动触发后台 worker + 规则automatic_llm / automatic_rule用户自然表达偏好,如“以后回答简洁点”
/note 笔记用户主动写 Markdown异步 LLM + 规则user_markdown一次性整理多条偏好、自由文本、备注
/remember 显式命令用户说“记住…”同步 LLM + 规则explicit_command快速确认一句高置信度偏好

三条路径服务于不同的用户意图:聊天路径是被动学习,/note 是批量编辑,/remember 是即时确认。自动抽取的内容会进入 /memory 列表并标注为“自动”,用户随时可见、可管理这些记忆;不同的记忆也会在不同的数据库中进行持久化。

后台 worker 在提取硬偏好的同时,也会把无法映射到 catalog 的内容输出为 store_soft_preference。这些软偏好也同样会进入到软记忆召回池中:

// extractor-stages.ts 中 LLM 提取器的 prompt 片段
const prompt = `...
3. 用户明确、长期的回答偏好但无法映射到偏好目录时,输出 store_soft_preference;
   它会作为低权重、会衰减的软性表达偏好召回,不会改变执行策略。
...`;

softPreferenceMemoryHandler 消费这条操作,把它写入 memory_items(kind='soft_preference')。

这三条路径的具体提取与校验逻辑在“执行层:从 schema 到 PresentationPolicy”一节已经详细讲过:LLM 做语义理解、规则做兜底补充,再经过 SafetyFilter / EvidenceValidation / Canonicalization 三个 stage,最后进入状态机。这里不再重复。

三条路径产生的偏好用 source 分成两类:

来源类型优先级
user_markdown用户明确表达高
explicit_command用户明确表达高
automatic_rule系统推断低
automatic_llm系统推断低

运行时合并策略是用户明确表达高于、覆盖系统自动推断:

function compilePresentationPolicy(records, catalog) {
  const sourceOf = (r) => {
    const s = r.metadata.source;
    if (
      [
        "user_markdown",
        "explicit_command",
        "automatic_rule",
        "automatic_llm",
      ].includes(s)
    )
      return s;
    return "legacy"; // 老数据或未标记来源默认按用户意图层处理
  };
  const userRecords = records.filter((r) =>
    ["user_markdown", "explicit_command", "legacy"].includes(sourceOf(r)),
  );
  const autoRecords = records.filter((r) =>
    ["automatic_rule", "automatic_llm"].includes(sourceOf(r)),
  );

  const autoLayer = compileLayer(autoRecords, catalog);
  const userLayer = compileLayer(userRecords, catalog);

  return {
    ...catalog.buildDefaults(),
    ...autoLayer.fields,
    ...userLayer.fields,
    appliedMemoryIds: Array.from(
      new Set([...autoLayer.appliedMemoryIds, ...userLayer.appliedMemoryIds]),
    ),
  };
}

这样系统自动抽到 verbosity=concise,但用户在 /note 里写了 verbosity=detailed,运行时就用 detailed。

Markdown 是 user 层的 source of truth。用户修改 /note 时,系统先停用该 scope 下所有 user_markdown 来源的 active 偏好,再重新提取 Markdown 并写入:

async function syncNoteToPreferences(scope, markdown) {
  await store.deactivatePreferencesBySource(scope, "user_markdown");
  // 实际会经过 SafetyFilterStage / EvidenceValidationStage / CanonicalizationStage
  const operations = await extractionPipeline.extract({
    userMessage: markdown,
  });
  for (const op of operations) {
    if (op.operation === "upsert_preference") {
      await store.upsertExtractedPreference({ ...op, source: "user_markdown" });
    }
  }
}

自动推断层不受这次重建影响,继续独立存在。如果用户发现系统自动抽到了错误偏好,用 /memory forget <id> 删除。至于软记忆的冲突出于复杂性的考虑这里暂时不处理。

自动抽取的偏好和软记忆不会写回用户手写的 Markdown 文件,但它们会出现在 /memory 列表中,并标注来源是“手动”还是“自动”。memory_notes 表保留用户原始表达作为审计层;memory_items 表保留结构化执行层;两者共同构成完整记忆视图。如果用户只关心“系统记住了什么”,直接看 /memory 即可,不需要翻 Markdown。

f.f. 与开源 Memory 系统的对比

不同开源/产品系统对记忆写入采用了不同的信任程度,前面讲述的 Memory 系统设计正是从它们身上取长补短的产物:

  • ChatGPT / Claude Memory 把记忆呈现为可查看、可编辑的文本摘要。Claude 团队版甚至为每个项目维护独立 memory summary(Simon Willison 的对比)。它们的共同点是:把人类可读、可编辑的视图作为信任基础,而不是把记忆锁在黑盒里。
  • CLAUDE.md / .cursorrules 把项目级规则写成 Markdown 文件放在仓库根目录,作为 coding agent 的过程记忆。核心设计是:存储普通文本而非结构化配置,用户可以直接阅读、编辑、版本控制、删除;透明性换取可审计性(HackerNoon 指南)。
  • Letta(原 MemGPT)把记忆分为 core / recall / archival 三层:core 常驻上下文且可被 agent 编辑,recall 是可搜索对话历史,archival 是向量长期存储(Letta 文档、arXiv 综述)。它把“当前生效执行上下文”与“可搜索原始记录”分开,与本设计的用户层/执行层分离思路一致。
  • mem0 走开放事实提取路线:从对话中蒸馏事实、链接实体。mem0 官方博客把记忆视为“typed, queryable objects linked to identities and scopes”(mem0.ai)。开放事实提取灵活但写入面大,学术分析指出纯模型抽取容易产生事实漂移和噪音记忆(arXiv 分析)。
  • LangMem / Trustcall 提供 schema 驱动的结构化提取:LangMem 用 Pydantic schema 定义记忆类型并支持 insert/update/delete(DeepWiki / LangMem);Trustcall 让 LLM 生成 JSON Patch 而不是整份重生成,对复杂嵌套 schema 更可靠(Trustcall README)。

相比之下,前面介绍的系统选择了一条更保守的路线:Markdown 用户层 + catalog 执行层 + 软偏好召回层 + 状态机 + 写入侧 key-value 配对。用户层借鉴 ChatGPT/Claude/CLAUDE.md 的可编辑文本思路;执行层借鉴 LangMem/Trustcall 的 schema 提取思想,但把 schema 收敛到业务白名单内。

它的灵活性不如 mem0,但写入面更小、更可预测、可审计;注册表让“封闭”本身可维护——加槽位只改一处,提取、编译、prompt 等全部自动更新。同时原始 Markdown 笔记保留,结构化偏好和软偏好都可追溯、可遗忘,兼顾了用户的控制使用与 Agent 执行的确定性。

5. Episodic Memory

Episodic Memory 记录的是“发生过什么”——不是抽象事实,而是具体场景中的完整轨迹。在业务系统里,它主要表现为已经闭环的诊断案例:某个故障在什么上下文里出现、排查时调用了哪些工具、最终结论是什么、由谁确认。

它和 Semantic Memory 的最大区别在于时间锚点和可复用性:

  • Semantic Memory 是蒸馏后的偏好和事实,读多写少,目标是“让 Agent 知道用户/业务长什么样”;
  • Episodic Memory 是原始经验的归档,持续追加,目标是“让 Agent 借鉴上次怎么解决的”。

Episodic Memory 的价值在于让 Agent 复用经验:遇到相似错误码、相似模块、相似上下文时,Agent 可以少调用几次工具就定位方向。但它的风险也来自这种相似——历史案例并不是当前事实,比如同一个 error code 在不同的系统、不同的时间代表的意思可能完全不同,如果 Agent 把历史结论直接当成当前结论,就会编造内容甚至给出已经过时的修复建议。

下面根据这些可能出现的问题,详细介绍下 Episodic Memory 的设计。

a.a. 诊断案例的摄取门槛

在 Memory 架构章节中,我们提出了写入任何记忆前都应该满足的四条通用摄取门槛:边界检查、状态要求、置信度阈值、可核实的工具证据。以诊断案例这个场景为例,它的 eligibility gate 如下:

export const diagnosisCaseMemoryHandler: MemoryKindHandler = {
  kind: "diagnosis_case",

  isEligible(card, policy) {
    // 只认当前这轮真正验证过的证据,避免把历史记忆或模型推断当证据
    const currentEvidence = card.evidence.filter(
      (item) =>
        item.status === "verified" &&
        policy.diagnosisCapture.requiredEvidenceSourceTypes.includes(
          item.sourceType,
        ),
    );
    return (
      (!policy.diagnosisCapture.boundaryCheckRequired ||
        card.boundaryCheck.passed) &&
      card.status === policy.diagnosisCapture.statusRequired &&
      card.confidence.score >= policy.diagnosisCapture.minConfidence &&
      currentEvidence.length > 0
    );
  },

  async capture(input, store, policy) {
    if (!isDiagnosisEligible(input.card, policy)) return;
    // ... 写入 SQLite
  },
};

status === "verified" 保证证据已经被校验;requiredEvidenceSourceTypes 是“必须有证据”的硬性规定:没有证据的案例不能进入记忆库,否则 agent 会开始编故事,把模型自己的推断当成真实发生过的经验。

b.b. 指纹去重与发生计数

同一个 case 会反复出现:同一用户每周问一次类似问题,或者同一故障在多个群里被报告。如果每次写入都创建新记录,记忆库会迅速膨胀,检索结果里也会充满重复。对此的去重策略是基于已验证证据生成指纹:

// 基于已验证证据生成指纹
const fpSource = verifiedEvidence
  .map((e) => `${e.sourceType}:${e.title}:${e.summary}`)
  .sort()
  .join("|");
const fingerprint = hash("diagnosis_case", fpSource);

指纹相同意味着核心证据集合相同,可以判定为同一类案例。此时不应创建新记忆,而是更新现有记录,使得记忆不混乱的情况下能记录每个记忆的具体状况:

  • occurrence_count:反映该案例的流行程度,可用于排序权重;
  • last_seen_at:最近一次出现时间,影响权重;
  • expires_at:每次命中后延长有效期,长期未见的案例权重下降;
  • source_run_id:按 run 去重,避免同一轮对话内重复计数。

c.c. 检索时的置信度标注

Episodic Memory 被检索回来后,不能直接当成实时事实。业务系统会把它明确标注为:

(历史参考,不代表当前实时状态)

历史记忆可以给 Agent 提供排查思路,但不能替代当前实时检查,不能作为最终结论的主要依据。

为了进一步区分可信度,系统在把历史案例注入当前诊断时,会根据原记录状态给 evidence 打上不同的 status:

  • confirmed_resolved → verified(可信历史经验,但仍标注 sourceType: historical_memory);
  • observed → historical_reference(防御性标注,不应被召回到当前诊断里)。

只有 status === 'confirmed_resolved' 的案例才会进入诊断召回池;observed 案例留在库里供审计,但不作为证据注入当前诊断。即使 confirmed_resolved 被召回,它的 sourceType 也会告诉下游:这是一条历史参考,排序低于 Agent 实际调用工具得到的结果。

d.d. 记忆检索

Episodic Memory 的检索不能只靠全文匹配。同样的文本描述可能对应完全不同的根因,因此需要把案例的关键元数据结构化存储,并与全文检索混合使用。

通用做法是在写入时从经验中提取相对稳定、可索引的维度,和原文一起存入数据库并建立索引。检索时同时跑两条路:

  1. FTS 全文检索:对 searchable_text 做 BM25 关键词匹配,覆盖同义表达和长尾描述;
  2. 结构化检索:对关键元数据做精确或前缀匹配,覆盖“类型相同但文本不同”的情况。

最后用 RRF 把两套排序融合:score = Σ 1/(rank + k)。这样一条查询可以把结构化匹配命中的经验排在纯文本匹配之前,避免“关键词相同但场景不同”的误召回。

结构化字段的选择取决于业务,但设计原则是:把经验中稳定、可抽取、可索引的维度拿出来,不要把所有信息都压进一段文本。这些字段同时也会参与去重指纹的生成,让同一类经验在物理上只保留一条记录。

// 抽象示例:从经验记录中抽取结构化字段
function extractStructuredFields(record) {
  return {
    category: extractCategory(record.content), // 经验类别
    module: extractModule(record.content), // 涉及模块
    period: extractPeriod(record.content), // 时间/周期
    tags: extractTags(record.content), // 标签集合
  };
}

混合检索的意义在于:文本负责召回语义相关,结构化负责筛掉语义相似但条件不匹配。两者结合才能让 Episodic Memory 从“看起来像”变为“真的是同一类问题”。

e.e. 共享记忆设计

个人/群聊的经验默认相互隔离,但已经闭环且脱敏的经验可以沉淀为租户级共享记忆。MemoryService 提供 /promote <memory-id> 命令,要求原记录状态必须是 confirmed_resolved。

提升时会做三件事:

  1. 匿名化:清空 created_by_hash、session_hash 等敏感字段,内容/searchableText 经 redactMemoryText 脱敏;
  2. 改 scope:写入 scope_type='tenant',该租户下所有用户/群聊都能检索到;
  3. 提可信度:元数据标记 provenance: 'organizational'、trustLevel: 'high'。

检索 user/group 时,系统会同时查询同租户的 tenant scope,组织记忆以更高可信度参与排序。未解决的私有经验仍然隔离——用户 A 的 observed 记录在提升前不会被用户 B 看到。

这个机制让“个人排查经验 → 团队共享经验 → 组织级知识”有一条明确的升级路径,同时避免未脱敏、未闭环的私有记录被意外共享。

f.f. 否定记忆(/notthis):不删除、只降权

用户有时会说“不是这个问题”。如果直接删除历史案例,就丢失了审计信息;如果留着,下次检索它又会出来误导。处理方式是追加一条否定反馈记录,而不是删除原案例:

// sqlite-memory-store.ts
async addNegativeFeedback(scope, memoryId, feedbackText?, actorId?)

否定反馈本身也是一条记忆,可以写入另一个新表。检索时:

  • 对被否定的案例,RRF 总分乘以惩罚系数(当前为 0.5);
  • 返回证据时标注 【曾被用户否定】,让 Agent 在生成结论时保持警惕;
  • 原案例仍然保留在库中,包含原始证据、原始结论和否定记录。

为什么保留原案例?因为否定本身是有上下文的:用户说“不是这个问题”,可能是因为当时的症状相似但根因不同,也可能是因为环境已经变化。删除原案例会同时删除这些上下文。保留原案例并附加否定记录,可以实现下面的事情:

  • 审计:管理员能查到“这条建议为什么被否定”;
  • 避免反复误导:同一条错误建议不会反复出现在高排序位置;
  • 可撤销:如果后来发现否定是误判,可以删除否定反馈,原案例恢复权重。