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 Memory | system prompt、skill、agent 代码 | 低,接近部署 | 可能改变 agent 行为边界 | 新增 skill、修改审批规则 |
三层记忆从上往下,写入频率降低,但写错的代价升高。这个规律直接影响工程策略: episodic memory 可以自动化摄取,但要做好隔离和审计;semantic memory 需要校验和置信度;procedural memory 最好走人工审批或严格的发布门禁。
2. Agent Memory 的基础架构
不管是哪一种记忆,在被持久化之前都要考虑下面的方面:这条记忆属于什么语义类型?它当前处于生命周期的哪个状态?谁可以看到它、谁可以写入它?它是否值得被记住?如果与已有记忆冲突该如何更新?写入出错会带来什么风险?
这些通用问题构成了 Memory 的基础架构。下面详细讲述下这个架构的设计。
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 | 租户级共享 |
Memory 提取门槛
不是所有发生过的信息都值得写入长期记忆。任何 kind 在写入前都应该经过统一的 eligibility gate。gate 总体上会审查下面的方面:
- 边界检查通过:记忆必须落在 Agent 的业务范围内。把边界外的问题沉淀为内部经验会污染记忆、导致 Agent 会检索到无关的信息。
- 状态要求满足:不同类型的 Memory 有不同的
status。例如 Episodic Memory 通常只写入confirmed_resolved,硬偏好则要求信号明确、不是临时性表达。 - 置信度达到阈值:低于阈值的信号不应该进入长期记忆,否则会把弱信号放大成“事实”。
- 必须有可核实的证据:工具输出、检索结果、经过校验的实时数据都可以作为证据;但不能仅凭历史记忆、模型推断或用户口述。
下面是一个不针对特定 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
);
}
状态机
记忆不是简单的新值覆盖旧值。同一条偏好、同一个诊断案例在不同时间被再次遇到时,系统需要判断:是新增、强化、替换,还是合并更新。这个过程可以用下面的状态机来描述:
| 操作 | 条件 | 效果 |
|---|---|---|
| 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记录,系统既能追踪偏好的历史演变,又不会在执行层同时召回两条冲突值。
作用域与隔离
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 参与排序,实现“个人偏好只影响个人,群聊经验在群内共享,组织级知识对所有人可见但不会因为更权威就抹掉个人设定”。
写入风险与审计
长期记忆一旦写错,就会持续污染后续轮次的决策。通用风险包括:
- 写错记忆污染后续决策:错误偏好、错误案例被反复召回,Agent 会把错误信息当成既定事实。
- 无证据写入:把模型推断或用户口述直接存为经验,导致 Agent 开始编造事实。
- 作用域污染:群聊记忆写入个人空间,或个人未脱敏案例被共享到租户。
- 重复计数:同一经验被记录多次,造成“这个问题很普遍”的假象,影响排序和判断。
针对这些设计,Memory 系统采用了下面的做法:
- 校验:写入前经过 SafetyFilter、EvidenceValidation、Canonicalization 等 stage(详见“提取 pipeline”一节)。
- 状态机设计:保留
superseded历史,避免新值直接销毁旧值。 - 可遗忘:用户可以通过
/memory forget <id>删除错误记忆;被删除的记录可以标记为disabled,而不是物理抹去,保留审计可能。 - 审计日志:保留用户原始 Markdown 笔记、操作来源、
source_run_id、证据引用,方便事后追溯记忆来源。
这些机制是整个 Memory 系统的核心部分。之后的 Semantic Memory 和 Episodic Memory 章节会展示它们在这方面具体怎么做的。
3. Memory 组件设计
下面简单介绍下用于实现上述架构的一些组件接口设计。
提取 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 主体。
作用域 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,无需改动提取或检索流程。
记忆类型 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 | 职责 |
|---|---|---|
preferenceMemoryHandler | preference | 消费 upsert_preference 操作,把硬偏好写入长期记忆 |
softPreferenceMemoryHandler | soft_preference | 消费 store_soft_preference 操作,把软偏好放入召回池 |
noteMemoryHandler | note | 把 /note 命令写下的 Markdown 原样存入 memory_notes |
diagnosisCaseMemoryHandler | diagnosis_case | 摄取已解决的诊断案例,并在检索时把它转换成证据 |
这种设计的好处是扩展性。如果要新增一种记忆——比如“用户画像”或“授权范围”——只需要:
- 新增一个
UserProfileHandler implements MemoryKindHandler; - 实现
capture、consumeOperation、toEvidence中需要的几个方法; - 把它注册进 handler 列表。
核心 service 的 capture/retrieve pipeline 不需要改任何代码,会自动按 kind 把请求分发给对应的 handler。
4. Semantic Memory
Semantic Memory 是记录事实、偏好、知识的记忆,它决定 Agent 怎么呈现回答:语言、结论顺序、细节程度、格式等。
最简单的整理方法是:用户说“回答简洁一点”,就把这句话存进向量库,下次检索出来加进 prompt。但这种处理方法有几个问题:
- 临时性 vs 持久性:用户某一次说“这次简单点”,不一定意味着他永远喜欢简单。
- 冲突与漂移:用户上个月喜欢详细,这个月喜欢简洁,旧偏好会和新偏好打架。
- 越界风险:用户如果说“下次可以直接执行操作不用问我”,这条记忆如果直接生效,可能绕过审批。
- 注入风险:攻击者可以通过间接提示,让 Agent 记住一条恶意规则。
所以用户偏好不能是“用户说了什么就存什么”。一个简单有效的设计是两层架构:用户看到的是可编辑的 Markdown 笔记,Agent 执行的是从笔记中提取并经校验的结构化偏好槽位以及软记忆。
用户层: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 里原样保留。这样既有“不知道怎么写”时的引导,又保留了“我知道自己要什么”时的自由度。
执行层:从 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 槽位。
来源校验没有完美方案,只能做到“可核实才接受,无法核实就丢弃”。当前实现按严格程度递减分了四层:
- 字符偏移核实:如果 LLM 同时返回
evidenceOffset(start/end 字符偏移),先检查该偏移指向的原文片段是否包含 quote。这能防住 LLM 把别的句子里的词张冠李戴。 - 归一化子串匹配:把 quote 和原文都去空格、转小写后做包含匹配,覆盖
Asia/Shanghaivsasia/shanghai这类大小写/空格差异。 - Token 软回退:子串不匹配时,把 quote 和原文按常见标点、斜杠、空格切分成 token,若 quote 中所有有效 token 都出现在原文中且至少有两个 token,则接受。这能覆盖
"asia/shanghai"被 LLM 写成"asia shanghai"的情况。 - 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。
软偏好:开放表达的低权重召回
Hard preference 解决的是“可枚举、可校验、强生效”的展示策略,但用户还有很多开放的表达偏好无法精确落到某个 slot。例如:
- “解释时先给根因,再给排查的具体链路。”
- “回答我的时候多举几个反例。”
这些偏好如果只走 catalog 白名单,可能会被拒之门外;如果直接丢弃,又浪费了用户反复表达的风格信息。一个更合理的做法是把它当作软偏好:低权重、会衰减、仅影响 LLM 的措辞,绝不改变事实、证据、工具或安全规则。
三层记忆的有效强度
| 层级 | 内容示例 | 生效方式 | 召回方式 |
|---|---|---|---|
| Hard preference | verbosity=concise、language=zh-CN 等 catalog 槽位 | 强生效,确定性渲染 | 按 scope 直接列出 active 记录 |
| Soft preference | “先说根因再给排查树”“多给反例”等开放表达 | 低权重,仅影响表达 | 按 query 语义召回,时间衰减 |
| Note / archival | 原始 Markdown 笔记、历史对话材料 | 默认不注入 Prompt | 只供 /memory notes 查询和审计 |
软偏好的写入与召回规则
为了避免软偏好变成事实污染或提示词注入,需要给它定下面的边界:
- 只接收稳定的回答风格偏好:拒绝事实、业务结论、工具调用、权限、安全指令和凭据。
- 冲突由 LLM 根据当前上下文裁决:Hard preference(catalog 槽位)是确定性最强的执行策略;Soft preference 是按当前 query 召回的开放表达建议,仅影响措辞;catalog 默认值兜底。当前轮次的明确指令天然具有最高权重(比如发送的消息显式说了“请详细讲述xxx”,则 Agent 的回答优先遵循这个)。
- 按照置信度 + 相关度 + 时间衰减共同排序:
- 重复出现或高置信度的软偏好权重更高;
- 与当前 query 语义相关的优先;
- 越久的软偏好重要性随实践指数衰减。
- 用户可见、可遗忘:软偏好以
soft_preference形式出现在/memory列表中,用户可以通过/memory forget <id>删除。
记忆召回优化
软偏好池的数量会随着时间增长,全量扫描代价比较高。一个轻量的实现是先做 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,软记忆又说“先说结论”),就直接过滤掉,避免潜在冲突。
这里判断是否覆盖的方式比较粗糙,实际中可以用下面两种思路:
- 把软硬记忆都写进 prompt,告诉不同记忆优先级让 LLM 自己选择。
- 使用小模型进行判断。
注入 Prompt 时,软偏好会带上明确的限制声明:
[相关的软性表达偏好,仅用于调整措辞;不得改变事实、证据、权限、工具]
- 先说根因再给排查(权重 0.82)
这套设计的核心取舍是:开放表达可以被利用,但被限制在表达层面。它不会像 Mem0 那样把任何事实都写进长期记忆。
将记忆正确放入 Agent
偏好被提取后,不会直接拼进 prompt,而是先被编译成一个受 catalog 约束的 PresentationPolicy,具体而言:
-
过滤:只拿生效的偏好,跳过 superseded 等非 active 记录,只处理当前真正生效的偏好。
-
去重:同一个 key 如果因为多次编辑留下多条 active 记录,只取第一条,避免冲突。
-
取值安全校验:对每条记录,按“元数据合法 → 从 content 推断 → 默认值”的优先级决定最终值。如果存储的 value 不在 catalog 白名单里,直接丢弃并回默认,不让脏数据进 prompt。
-
记录血缘:保留来源 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 },
];
这样偏好被明确标识为策略层指令,而不是用户输入的一部分,同时保留了对历史对话的可见性。
三条写入路径:聊天、笔记、显式命令
用户不需要手写 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。
与开源 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 的设计。
诊断案例的摄取门槛
在 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 会开始编故事,把模型自己的推断当成真实发生过的经验。
指纹去重与发生计数
同一个 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 去重,避免同一轮对话内重复计数。
检索时的置信度标注
Episodic Memory 被检索回来后,不能直接当成实时事实。业务系统会把它明确标注为:
(历史参考,不代表当前实时状态)
历史记忆可以给 Agent 提供排查思路,但不能替代当前实时检查,不能作为最终结论的主要依据。
为了进一步区分可信度,系统在把历史案例注入当前诊断时,会根据原记录状态给 evidence 打上不同的 status:
confirmed_resolved→verified(可信历史经验,但仍标注sourceType: historical_memory);observed→historical_reference(防御性标注,不应被召回到当前诊断里)。
只有 status === 'confirmed_resolved' 的案例才会进入诊断召回池;observed 案例留在库里供审计,但不作为证据注入当前诊断。即使 confirmed_resolved 被召回,它的 sourceType 也会告诉下游:这是一条历史参考,排序低于 Agent 实际调用工具得到的结果。
记忆检索
Episodic Memory 的检索不能只靠全文匹配。同样的文本描述可能对应完全不同的根因,因此需要把案例的关键元数据结构化存储,并与全文检索混合使用。
通用做法是在写入时从经验中提取相对稳定、可索引的维度,和原文一起存入数据库并建立索引。检索时同时跑两条路:
- FTS 全文检索:对
searchable_text做 BM25 关键词匹配,覆盖同义表达和长尾描述; - 结构化检索:对关键元数据做精确或前缀匹配,覆盖“类型相同但文本不同”的情况。
最后用 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 从“看起来像”变为“真的是同一类问题”。
共享记忆设计
个人/群聊的经验默认相互隔离,但已经闭环且脱敏的经验可以沉淀为租户级共享记忆。MemoryService 提供 /promote <memory-id> 命令,要求原记录状态必须是 confirmed_resolved。
提升时会做三件事:
- 匿名化:清空
created_by_hash、session_hash等敏感字段,内容/searchableText 经redactMemoryText脱敏; - 改 scope:写入
scope_type='tenant',该租户下所有用户/群聊都能检索到; - 提可信度:元数据标记
provenance: 'organizational'、trustLevel: 'high'。
检索 user/group 时,系统会同时查询同租户的 tenant scope,组织记忆以更高可信度参与排序。未解决的私有经验仍然隔离——用户 A 的 observed 记录在提升前不会被用户 B 看到。
这个机制让“个人排查经验 → 团队共享经验 → 组织级知识”有一条明确的升级路径,同时避免未脱敏、未闭环的私有记录被意外共享。
否定记忆(/notthis):不删除、只降权
用户有时会说“不是这个问题”。如果直接删除历史案例,就丢失了审计信息;如果留着,下次检索它又会出来误导。处理方式是追加一条否定反馈记录,而不是删除原案例:
// sqlite-memory-store.ts
async addNegativeFeedback(scope, memoryId, feedbackText?, actorId?)
否定反馈本身也是一条记忆,可以写入另一个新表。检索时:
- 对被否定的案例,RRF 总分乘以惩罚系数(当前为 0.5);
- 返回证据时标注
【曾被用户否定】,让 Agent 在生成结论时保持警惕; - 原案例仍然保留在库中,包含原始证据、原始结论和否定记录。
为什么保留原案例?因为否定本身是有上下文的:用户说“不是这个问题”,可能是因为当时的症状相似但根因不同,也可能是因为环境已经变化。删除原案例会同时删除这些上下文。保留原案例并附加否定记录,可以实现下面的事情:
- 审计:管理员能查到“这条建议为什么被否定”;
- 避免反复误导:同一条错误建议不会反复出现在高排序位置;
- 可撤销:如果后来发现否定是误判,可以删除否定反馈,原案例恢复权重。