上下文窗口无限增长看似解决了记忆问题,实则引入了噪音、延迟和成本三重陷阱。 好的记忆架构是一个分层过滤系统------每一层既是一道信息的通道,也是一道信息的滤网。 真正值得长期记住的信息只占对话总量的不到 5%。
Reading Paths
🕐 只有 2 分钟? → 直接看[第 5 节「推荐方案」](#第 5 节「推荐方案」 "#5-recommended-solution")和[第 10 节「总结」](#第 10 节「总结」 "#10-summary")
📖 想理解原理? → 从[第 2 节「为什么这很难」](#第 2 节「为什么这很难」 "#2-why-this-is-hard")开始,按顺序读
🎤 准备面试? → 跳到[第 9 节「面试延伸」](#第 9 节「面试延伸」 "#9-interview-extension")
🔧 直接看代码? → [第 6 节「完整 Demo」](#第 6 节「完整 Demo」 "#6-full-demo"),所有文件可复制运行
💸 关心成本? → [第 4 节「权衡分析」](#第 4 节「权衡分析」 "#4-tradeoff-analysis")里有完整的成本模型
1. Problem Background
你花了一下午修一个诡异的 CI 流水线配置 Bug,周五下班前修好。周一早上打开终端,问你的 Coding Agent:「上次那个 CI Bug 后来怎么样了?」Agent 回复:「抱歉,我无法访问之前的对话历史。」
没有记忆的 Agent 记不住你的偏好、记不住项目进度、记不住你摸索出来的配置参数。它像一条金鱼------每次对话都是重新认识你。
一个 200 人的团队做全栈开发,人均每天 10 轮对话。一周就是 10,000 轮。这里面的挑战不是「存下来」------存下来很容易,一个 JSON 文件就能存。挑战在于:上周讨论的 CI 配置方案这周还要能精确召回,但三周前随口聊的午饭吃什么的闲聊不应该再占坑位。
如果站在业务角度看,这个问题的本质是:如何在有限的上下文预算内,最大化信息的长期效用。
2. Why This Is Hard
在动手设计方案之前,需要直面这个问题到底难在哪。
太激进地淘汰旧信息,关键偏好可能丢失------「用户上次明确要求不用 MongoDB,我把这条淘汰了,下次 Agent 又推荐 MongoDB,用户觉得这个 Agent 翻脸不认人。」太保守地保留所有信息,检索回来的记忆里 80% 是噪音,LLM 被淹没在无关信息里,回复质量反而下降。遗忘 vs 噪音的平衡------这是第一层矛盾。
上下文窗口也不是免费的。128K 看起来很充裕,但把 100 条历史记忆全塞进去,每次推理多花 $0.02-0.05 的 token 成本,延迟增加 200-500ms。一天 2000 次对话,光记忆检索带来的 token 消耗就够再养一个推理服务了。
还有更隐蔽的问题:跨会话的持久化一致性。Agent 在第 5 轮对话中识别到一条有价值的信息「用户团队 CI 用的是 GitHub Actions + pnpm」,写入长期记忆。但第 7 轮对话中用户修正了这条信息「不对,我们上周迁移到 Bun 了」。如果写入时机不对------太早则写入大量临时闲聊,太晚则漏掉关键信息------长期记忆库会被垃圾信息污染,检索质量随时间持续恶化。
我在三家公司见过同一个模式:团队先兴奋地给 Agent 接上向量数据库,两周后默默关掉------不是方案不对,是被噪音淹没了。
这其实暗示了一个很多人忽略的点:记忆系统的核心设计变量不是存储容量,而是过滤精度。 每一层记忆的入口处,都应该有一道「这道信息值得记多久」的判断逻辑。下面展开。
3. Solution Analysis
如果你跳过了前面的内容:我们正在解决 Agent 跨会话记忆持久化的问题,核心矛盾是遗忘 vs 噪音的平衡。接下来我们从最简单的方案开始,逐个看它在什么条件下崩溃,然后迭代。
3.1 V0: 纯上下文窗口
原理: 把所有历史对话原样拼进 prompt,让 LLM 自己从历史中找到相关信息。不需要任何额外基础设施。
typescript
// V0: The simplest thing that works
const history = loadAllMessages(userId);
const prompt = `
历史对话:
${history.map(m => `[${m.role}]: ${m.content}`).join('\n')}
当前问题:${currentMessage}
`;
const reply = await llm.chat(prompt);
三行代码。零运维。永远不会因为向量检索不准而产生幻觉。
但它在三个条件下会崩溃:
- 轮数超过上下文窗口上限。 128K 窗口看起来大,但中文一条消息平均 200 tokens,加上系统提示词和各种 tool 定义,800 轮就满了。超过之后------静默截断。最早的对话(往往包含最重要的偏好设定)最先丢失。
- 信息密度持续下降。 100 轮对话中,真正有价值的偏好、决策、配置信息只占 3-5 轮。另外 95 轮是「好的」「继续」「这里有个报错你看看」------它们稀释了关键信息的权重。
- 成本线性增长。 每次推理都要重新处理全部历史。200 人团队每人每天 20 次对话,日均 4000 次推理,128K context 每次多消耗 0.03------一个月多花3,600。
适用场景: 单轮 < 10 轮的简单对话,不需要跨会话持久化。
不适用场景: 任何需要跨会话记忆的场景。如果你正在读这一段,大概率 V0 不够。
3.2 V1: 纯摘要记忆
原理: LLM 定期(比如每 20 轮或会话结束时)做摘要,把摘要文本替代原始对话拼入下一次 prompt。不引入向量库。
arduino
原始对话(20轮,~4000 tokens)
→ LLM 摘要 → "用户偏好:使用 pnpm,不用 npm。CI 用 GitHub Actions。
正在处理登录模块的重构,目标是将 JWT 改为 session 方式。"
(~80 tokens,压缩比 50:1)
这个方案的压缩比很高,而且摘要本身是自然语言,可以直接拼入 prompt。
致命问题: 摘要会丢失细节。如果用户在第 3 轮和第 17 轮分别提到了两个相关但不同的技术决策,摘要可能把它们合并成一个错误的结论。而且摘要无法做语义检索------你只能把整个摘要全塞进去,无法精准定位「用户上次说的那个 session 过期时间具体是多久」。
我在一次内部测试中遇到过更糟糕的情况:摘要 LLM(GPT-4o-mini)把「不用 MongoDB」错误地摘要成了「偏好 MongoDB」,丢了一个关键否定词。这种错误一旦写进摘要,后续所有对话都会基于错误信息展开。
适用场景: 10-50 轮对话,无向量库运维能力,分布式部署(摘要文本比向量更容易在服务间传递)。
不适用场景: 需要精确检索特定信息、对话轮数超过 100 轮、对摘要幻觉零容忍。
3.3 V1.5: 本地轻量记忆(SQLite + Cosine Similarity)
原理: SQLite 存储记忆文本 + 本地 embedding(node-llama-cpp 或 transformer.js)+ 内存 cosine similarity 暴力检索。不需要任何外部向量库,单机即跑。
typescript
// V1.5: 单机轻量方案的核心检索逻辑
function searchMemory(query: string, memories: Memory[], topK: number): Memory[] {
const queryEmb = getLocalEmbedding(query);
return memories
.map(m => ({
memory: m,
score: cosineSimilarity(queryEmb, m.embedding),
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.filter(r => r.score > 0.7); // 硬阈值过滤噪音
}
简单直接。但这个方案有一个物理天花板:暴力检索的复杂度是 O(n)。 5,000 条记忆以下性能尚可(< 50ms),超过这个数之后,每次检索都要遍历所有记忆,延迟线性增长。而且本地 embedding 模型的质量通常不如 text-embedding-3-small 等 API 模型------用 all-MiniLM-L6-v2 处理中文时,我实测 NDCG@5 比 text-embedding-3-small 低 15 个百分点。
这个方案我给 3 人以下小团队做 PoC 时的首选------零运维依赖,10 分钟就能跑起来。
适用场景: < 5K 条记忆,单机部署,3 人以下小团队做原型验证。
不适用场景: 超过 5K 条记忆,需要多服务共享记忆库,对检索精度有较高要求的场景。
3.4 V2: 分层记忆(推荐)
原理: 把记忆按访问频率和信息衰减速度分成三层------Working Memory(热数据)、Short-term FIFO(温数据)、Long-term 向量库(冷数据)。三层各司其职,互不污染。
架构总览:
三层之间不是简单的数据搬运------从 Working 到 Short-term 再到 Long-term,每一步都经过价值判断和去噪。回忆时的流程是三层并行检索,结果按 recency x relevance 加权排序后去重合并。
关键设计决策:
- Working Memory 永不参与持久化,会话结束即清空。这是性能最快的层------直接拼 prompt,零检索延迟。
- Short-term 使用 FIFO 环形缓冲,只保留最近的 50 条。淘汰策略简单粗暴:先进先出。不需要语义判断,不需要 TTL 计算------因为这一层的设计目的就是「最近发生的事」,天然按时间衰减。
- Long-term 是唯一需要向量检索的层。写入前经过沉淀 Worker 的语义压缩和价值判断,写入时带 TTL 分类标签和 userId 元数据。这是本方案的核心工程复杂度所在,也是回报最高的地方。
沉淀 Worker 的工作流(异步,不阻塞热路径):
注意两个关键点:
- 沉淀是异步的。 用户不会因为记忆沉淀多等 2 秒。Worker 独立运行,有自己独立的 LLM 调用配额和限流策略。
- 写入前做语义相似度检查。 同一个用户关于同一话题的偏好如果有变化(比如周二说不用 MongoDB,周四说用 MongoDB),系统标记冲突而不是覆盖。召回时两条都返回,由 LLM 根据 timestamps 判断------「用户上周表示不想用 MongoDB,但最近一条记忆显示要用,可能是场景有变化。」
适用场景: 任意轮数对话,需跨会话持久化,有向量库运维能力。这是我推荐给 90% 团队的首选方案。
不适用场景: 对话 < 10 轮(过度设计),团队 < 3 人无向量库运维(运维负担 > 收益),P99 < 200ms 的实时场景(检索延迟可能超标)。
3.5 V3: Reflection-based 记忆
原理: Agent 定期反思(Reflection),从历史交互中提取抽象领悟,存入长期记忆。不是在存储事实,而是在存储「元认知」。
比如不是存「用户选了 React 19」,而是存「用户倾向于使用最新版本的工具链,做技术选型时优先考虑官方推荐的方案,而非社区流行的替代品。」
这种抽象领悟的泛化性远超事实记忆------3 个月后用户要选新的状态管理库,Agent 不需要记得「上次用的 Zustand」,只需要记得「这个用户偏好官方推荐方案」,就能自行推断出倾向。
代价: Reflection 是一个同步阻塞操作------单次反思需要 3-8 秒的 LLM 推理时间,而且需要足够的对话积累才值得触发(至少 30-50 个实质性交互步骤)。在对话间隙做同步反思会严重破坏用户体验。
适用场景: 100+ 步超长自主任务(如 AutoGPT 类 Agent),用户与 Agent 之间存在大量无交互的自主操作阶段,Reflection 可以在这些间隙执行。
不适用场景: 交互式对话场景。如果你在做的是 ChatBot 而非自主 Agent,V3 的 Reflection 是纯负收益------增加的延迟远大于它带来的记忆质量提升。
4. Tradeoff Analysis
| 方案 | 信息保真度 | 检索精度 | 延迟 | 复杂度 | 运维成本 | 跨会话持久化 | 推荐场景 | 推荐指数 |
|---|---|---|---|---|---|---|---|---|
| V0 纯上下文 | 极高 | --- | 低 | 极低 | 零 | 否 | 小于 10 轮 | 2 / 5 |
| V1 纯摘要 | 低 | 低 | 中 | 低 | 低 | 是 | 10-50 轮,无向量库 | 2 / 5 |
| V1.5 本地轻量 | 中 | 中 | 低-中 | 低-中 | 极低 | 是 | 小于 5K 条,单机 | 3 / 5 |
| V2 分层记忆 | 中-高 | 高 | 中 | 中 | 中 | 是 | 任意轮,需持久化 | 5 / 5 |
| V3 Reflection | 高 | 中 | 高 | 高 | 高 | 是 | 100+ 步自主任务 | 3 / 5 |
成本模型(以 200 人团队、人均 10 轮/天为例)
说个实在的数字。我见过太多团队被「向量数据库 = 贵」这个印象劝退,实际算下来完全不是这么回事。
Embedding 成本: text-embedding-3-small, 0.02/1Mtokens。每条记忆平均200tokens−>0.000004 / 条。日均新增 500 条(200 人 x 10 轮,约 5% 的信息值得写入长期记忆)-> 0.002/天,全年不到1。就算乘以 10 倍的安全余量,embedding 也基本上可以认为是免费的。
向量库成本: ChromaDB 本地部署------免费。Pinecone Serverless------ 0.33/GB/月。10K条记忆大约占1GB(含embedding+元数据)−>0.33 / 月。Qdrant Cloud 类似价位。这个成本级别的运维费用,大多数团队甚至不会出现在云账单里。
沉淀 LLM 调用: 日均 50 次 consolidate(200 人 x 10 轮中,约 2.5% 触发沉淀),每次输入约 2K tokens + 输出约 200 tokens。GPT-4o-mini 0.15/1Minput+0.60 / 1M output -> 每次约 0.00042。日均50次−>0.021 / 天,全年约 $7.7。
**合计(单用户 ChromaDB 模式):年成本不到 10。∗∗用Pinecone+GPT−4o沉淀模式也不过200 / 年。
对比:无脑堆满 128K 上下文。 每次推理额外消耗 80K tokens(历史记忆部分),GPT-4o 2.50/1Minput。日均2000次对话−>每天多烧160Minputtokens−>400 / 天,$146,000 / 年。
记忆架构省下的不是几十块钱------是 4 个数量级的差距。
这个成本模型有一个盲区
以上计算假设你在用 SaaS 向量库(Pinecone / Qdrant Cloud)。如果你的团队已经在维护自建的 Elasticsearch 或 Milvus 集群,增量成本几乎为零------直接复用现有基础设施。但如果你的团队完全没有任何向量检索基础设施,ChromaDB 本地部署的运维成本不是零------你需要一个人偶尔关注一下磁盘用量和索引健康,大概每月 1-2 小时的工作量。
跨数据中心部署的场景这篇文章没有充分覆盖。如果你的服务部署在美东 + 新加坡两个 Region,向量库的主从同步延迟可能是一个实际问题。ChromaDB 目前对多 Region 部署的支持还不够成熟,这种情况下 Pinecone 或 Qdrant Cloud 的托管方案更适合------它们把复制延迟的坑替你填了。
5. Recommended Solution
选 V2 分层记忆。 不是因为它最先进,是因为它在工程成熟度、成本可控和演进空间三者之间找到了最优平衡点。
为什么是 V2 而不是其他方案:
- 不是 V0: 如果你的对话超过 10 轮或需要跨会话记忆,V0 直接出局。上下文窗口截断是静默的------你不会收到报错,只会收到莫名其妙的回复。
- 不是 V1: 摘要的不可检索性是致命缺陷。一个不允许检索的记忆系统和没有记忆系统区别不大------当记忆量超过一篇摘要的容量时,你只能寄希望于 LLM 不要漏掉关键信息,而它经常漏。
- 不是 V1.5: 小团队做原型验证的首选,但一旦团队超过 3 人或多服务需要共享记忆库,单机 SQLite 就成了瓶颈。而且本地 embedding 模型的质量差异会导致检索精度在中文场景下明显下降。
- 不是 V3: Reflection 是为自主 Agent(AutoGPT 类)设计的,对交互式对话场景是过度设计。除非你的 Agent 需要连续做 100+ 步独立操作,否则 Reflection 增加的延迟不值得。
决策树------什么时候选什么方案:
rust
对话轮数 小于 10 且不需要跨会话持久化?
└── 是 -> V0 纯上下文窗口。关掉这篇文章。
需要跨会话持久化 + 无向量库运维能力?
└── 是 + 单机 -> V1.5 SQLite + cosine similarity
└── 是 + 分布式 -> V1 纯摘要记忆(过渡方案)
需要跨会话持久化 + 有向量库或愿意运维向量库?
└── 是 + 交互式对话 -> V2 分层记忆。这是 90% 场景的最优解。
└── 是 + 超长自主任务(100+ 步)-> V2 + Reflection 插件
记住一句话: 三层漏斗------Working 负责快,Short-term 负责近,Long-term 负责久。三者各司其职,不要互相替代。
6. Full Demo
下面的 Demo 使用 OpenAI SDK + ChromaDB 客户端实现分层记忆,不依赖 LangChain。全套约 300 行 TypeScript。作为对比,LangChain 的 ConversationSummaryBufferMemory + VectorStoreRetrieverMemory 组合可以实现类似效果,但引入了约 50MB 的依赖和一层抽象------对于大部分场景,直接用 SDK 更轻量也更透明。
项目结构
bash
memory-demo/
├── package.json
├── tsconfig.json
├── src/
│ ├── types.ts # 类型定义
│ ├── memory-manager.ts # MemoryManager 核心类
│ └── demo.ts # 演示脚本
安装依赖
bash
mkdir memory-demo && cd memory-demo
pnpm init
pnpm add openai chromadb dotenv
pnpm add -D typescript @types/node tsx
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext
types.ts
typescript
// types.ts --- 记忆系统的核心类型定义
export type MemoryLayer = 'working' | 'shortTerm' | 'longTerm';
export type TTLCategory = 'permanent' | '1year' | '3months' | '1month';
export interface MemoryEntry {
id: string;
content: string;
summary: string; // 沉淀 Worker 压缩后的摘要
layer: MemoryLayer;
userId: string;
sessionId: string;
createdAt: number; // Unix ms
ttlMs: number; // 0 = permanent
ttlCategory: TTLCategory;
embedding?: number[];
priority: number; // 0-1,沉淀 Worker 赋值
accessCount: number;
deleted: boolean; // 软删除标记,用于 GDPR 合规
}
export interface MemoryQueryResult {
entry: MemoryEntry;
score: number; // 综合得分:recency x relevance
source: MemoryLayer;
conflict?: {
conflictingEntryId: string;
conflictingContent: string;
};
}
export interface ConsolidateResult {
summary: string;
priority: number;
ttlCategory: TTLCategory;
shouldPersist: boolean;
reason: string;
}
export interface MemoryManagerConfig {
// Working Memory
maxWorkingMessages: number; // 默认 20
// Short-term
maxShortTermEntries: number; // 默认 50
// Long-term(向量检索)
topK: number; // 默认 5
similarityThreshold: number; // 默认 0.7
// 沉淀
consolidateInterval: number; // 每 N 轮触发一次,默认 10
consolidateModel: string; // 默认 'gpt-4o-mini'
// 检索超时
retrievalTimeoutMs: number; // 默认 500
// Embedding
embeddingModel: string; // 默认 'text-embedding-3-small'
maxRetries: number; // embedding API 限流重试次数,默认 3
retryBaseDelayMs: number; // 指数退避的基础延迟,默认 1000
}
memory-manager.ts
typescript
// memory-manager.ts --- 三层记忆核心引擎
import OpenAI from 'openai';
import { ChromaClient } from 'chromadb';
import type {
MemoryEntry, MemoryQueryResult, ConsolidateResult,
MemoryManagerConfig, MemoryLayer, TTLCategory,
} from './types.js';
const TTL_MAP: Record<TTLCategory, number> = {
permanent: 0,
'1year': 365 * 24 * 60 * 60 * 1000,
'3months': 90 * 24 * 60 * 60 * 1000,
'1month': 30 * 24 * 60 * 60 * 1000,
};
export class MemoryManager {
// 三层存储
private working: Array<{ role: string; content: string }> = [];
private shortTerm: MemoryEntry[] = [];
private lruCache: Map<string, MemoryQueryResult[]> = new Map();
private openai: OpenAI;
private chroma: ChromaClient;
private config: Required<MemoryManagerConfig>;
private roundCounter = 0;
constructor(config: MemoryManagerConfig) {
this.config = {
maxWorkingMessages: 20,
maxShortTermEntries: 50,
topK: 5,
similarityThreshold: 0.7,
consolidateInterval: 10,
consolidateModel: 'gpt-4o-mini',
retrievalTimeoutMs: 500,
embeddingModel: 'text-embedding-3-small',
maxRetries: 3,
retryBaseDelayMs: 1000,
...config,
};
this.openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
this.chroma = new ChromaClient({ path: 'http://localhost:8000' });
}
// ─── 写入 ───────────────────────────────────────
/** 写入 Working Memory:每次用户消息和 Agent 回复都调用 */
addToWorking(role: string, content: string): void {
this.working.push({ role, content });
if (this.working.length > this.config.maxWorkingMessages) {
this.working.shift();
}
this.roundCounter++;
}
/** 写入长期记忆(带错误处理和限流退避) */
async writeLongTerm(entry: Omit<MemoryEntry, 'id' | 'embedding' | 'deleted'>): Promise<void> {
const id = `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const embedding = await this.embedWithRetry(entry.content);
// 写入前做语义相似度检查------检测矛盾记忆
const existing = await this.searchLongTerm(entry.content, 3);
const full: MemoryEntry = {
...entry,
id,
embedding,
deleted: false,
};
try {
await this.chromaGetCollection();
// 检测是否有时间戳更新的相似记忆(可能表示偏好变化)
for (const r of existing) {
if (r.score > 0.85) {
full.priority = Math.max(full.priority, 0.9);
break;
}
}
await this.chromaCollection!.add({
ids: [full.id],
embeddings: [embedding],
metadatas: [{
content: full.content,
summary: full.summary,
userId: full.userId,
sessionId: full.sessionId,
createdAt: String(full.createdAt),
ttlMs: String(full.ttlMs),
ttlCategory: full.ttlCategory,
priority: String(full.priority),
layer: full.layer,
deleted: 'false',
}],
});
} catch (err) {
console.error(`[MemoryManager] 写入 Long-term 失败: ${err}`);
// 降级:写入 Short-term 作为兜底
this.shortTerm.unshift(full);
}
}
// ─── 查询 ───────────────────────────────────────
/** 三层并行检索,结果加权排序 */
async recall(query: string, userId: string): Promise<MemoryQueryResult[]> {
const results: MemoryQueryResult[] = [];
// Layer 1: Working Memory(零延迟,直接拼接)
const workingContent = this.working.map(m => `[${m.role}]: ${m.content}`).join('\n');
if (workingContent) {
results.push({
entry: {
id: 'working',
content: workingContent,
summary: '',
layer: 'working',
userId,
sessionId: '',
createdAt: Date.now(),
ttlMs: 0,
ttlCategory: 'permanent',
priority: 1,
accessCount: 0,
deleted: false,
},
score: 1.0,
source: 'working',
});
}
// Layer 2: Short-term(内存扫描,低于 20ms)
for (const entry of this.shortTerm) {
if (entry.userId === userId && !entry.deleted && !this.isExpired(entry)) {
results.push({ entry, score: 0.8 * (entry.priority || 0.5), source: 'shortTerm' });
}
}
// Layer 3: Long-term(向量检索 + 超时降级)
try {
const longTermResults = await this.searchLongTermWithTimeout(query, userId);
results.push(...longTermResults);
} catch (err) {
console.warn(`[MemoryManager] Long-term 检索超时或失败,降级到 Short-term: ${err}`);
// 不中断------即使 Long-term 挂了,Working + Short-term 仍然可用
}
// 加权排序:recency x relevance
return this.rankAndDedup(results);
}
/** 批量删除用户所有记忆(GDPR 删除权) */
async deleteByUser(userId: string): Promise<number> {
let deleted = 0;
// Short-term 中软删除
for (const entry of this.shortTerm) {
if (entry.userId === userId && !entry.deleted) {
entry.deleted = true;
deleted++;
}
}
// Long-term 中软删除
try {
const collection = await this.chromaGetCollection();
const results = await collection.get({ where: { userId } });
for (const id of results.ids) {
await collection.update({
ids: [id],
metadatas: [{ deleted: 'true' }],
});
deleted++;
}
} catch (err) {
console.error(`[MemoryManager] GDPR 批量删除失败: ${err}`);
throw new Error(
`用户 ${userId} 的记忆删除失败,已软删除 ${deleted} 条,需人工介入处理向量库`
);
}
// 清除 LRU 缓存中相关条目
this.lruCache.delete(userId);
return deleted;
}
/** 过期记忆扫描(定时任务调用,建议每 6 小时跑一次) */
async expireStaleMemories(): Promise<number> {
let expired = 0;
const now = Date.now();
for (const entry of this.shortTerm) {
if (!entry.deleted && this.isExpired(entry)) {
entry.deleted = true;
expired++;
}
}
try {
const collection = await this.chromaGetCollection();
const all = await collection.get();
for (let i = 0; i < all.ids.length; i++) {
const meta = all.metadatas[i] as any;
const createdAt = Number(meta.createdAt);
const ttlMs = Number(meta.ttlMs);
if (ttlMs > 0 && now - createdAt > ttlMs && meta.deleted === 'false') {
await collection.update({
ids: [all.ids[i]],
metadatas: [{ deleted: 'true' }],
});
expired++;
}
}
} catch (err) {
console.error(`[MemoryManager] 过期扫描失败: ${err}`);
}
return expired;
}
// ─── 沉淀 Worker ──────────────────────────────────
/** 触发沉淀:会话结束或每 N 轮触发一次 */
shouldConsolidate(): boolean {
return this.roundCounter > 0 && this.roundCounter % this.config.consolidateInterval === 0;
}
/** 沉淀 Worker 主逻辑(异步,不阻塞热路径) */
async consolidate(): Promise<ConsolidateResult[]> {
if (this.working.length === 0) return [];
const recentMsgs = this.working.slice(-this.config.consolidateInterval);
const conversationText = recentMsgs.map(m => `[${m.role}]: ${m.content}`).join('\n');
const prompt = `从以下对话片段中提取值得长期记忆的信息。对于每条信息:
1. 写一句简短摘要(不超过 50 字)
2. 判断优先级(0-1):这条信息对未来的对话有多大价值?
3. 判断 TTL 分类:permanent(永久有效,如用户姓名)、1year(技术栈偏好)、
3months(项目上下文)、1month(临时决策)
4. 判断是否值得持久化:闲聊、问候、已经过时的临时信息应该舍弃
对话片段:
${conversationText}
用 JSON 数组格式返回,每个元素包含 summary、priority、ttlCategory、shouldPersist 四个字段。`;
try {
const resp = await this.openai.chat.completions.create({
model: this.config.consolidateModel,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.3,
});
const text = resp.choices[0]?.message?.content || '[]';
const parsed = JSON.parse(text);
const items: ConsolidateResult[] = Array.isArray(parsed)
? parsed : (parsed.results || []);
for (const item of items) {
if (item.shouldPersist) {
await this.writeLongTerm({
content: item.summary,
summary: item.summary,
layer: 'longTerm',
userId: 'default',
sessionId: `session_${Date.now()}`,
createdAt: Date.now(),
ttlMs: TTL_MAP[item.ttlCategory] || TTL_MAP['3months'],
ttlCategory: item.ttlCategory,
priority: item.priority,
accessCount: 0,
});
// 同时写入 Short-term
this.shortTerm.unshift({
id: `st_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
content: item.summary,
summary: item.summary,
layer: 'shortTerm',
userId: 'default',
sessionId: `session_${Date.now()}`,
createdAt: Date.now(),
ttlMs: TTL_MAP['1month'],
ttlCategory: '1month',
priority: item.priority,
accessCount: 0,
deleted: false,
});
}
}
// FIFO 淘汰 Short-term
while (this.shortTerm.length > this.config.maxShortTermEntries) {
this.shortTerm.pop();
}
return items;
} catch (err) {
console.error(`[MemoryManager] 沉淀失败: ${err}`);
return [];
}
}
// ─── 冷启动:偏好引导 + 团队记忆池 ────────────────
/** 为新用户生成偏好引导问题 */
getColdStartQuestions(): string[] {
return [
'你的主要技术栈是什么?(如 React + Node.js / Python + Django)',
'你偏好简洁的回答还是详细的解释?',
'你的项目代码风格有什么特别要求?(如不用分号、2 空格缩进)',
'有没有绝对不能使用的技术或工具?',
'你的团队用哪个 AI 模型比较多?(GPT-4o / Claude / 两者都用)',
];
}
/** 从团队共享记忆池导入初始记忆(新用户冷启动 bootstrap) */
async bootstrapFromTeamPool(userId: string, teamPool: MemoryEntry[]): Promise<number> {
let imported = 0;
for (const entry of teamPool) {
if (!entry.deleted && !this.isExpired(entry)) {
await this.writeLongTerm({
content: entry.content,
summary: entry.summary,
layer: 'longTerm',
userId,
sessionId: 'bootstrap',
createdAt: Date.now(),
ttlMs: entry.ttlMs,
ttlCategory: entry.ttlCategory,
priority: entry.priority * 0.8, // 团队记忆降权------个人偏好优先
accessCount: 0,
});
imported++;
}
}
return imported;
}
// ─── 私有方法 ────────────────────────────────────
private async chromaGetCollection() {
if (!(this as any)._collection) {
try {
(this as any)._collection = await this.chroma.getOrCreateCollection({
name: 'agent_memories',
metadata: { 'hnsw:space': 'cosine' },
});
} catch (err) {
console.error(`[MemoryManager] ChromaDB 连接失败: ${err}`);
throw new Error('ChromaDB 不可用');
}
}
return (this as any)._collection;
}
private get chromaCollection(): any {
return (this as any)._collection;
}
/** embedding 调用带指数退避重试 */
private async embedWithRetry(text: string, attempt = 0): Promise<number[]> {
try {
const resp = await this.openai.embeddings.create({
model: this.config.embeddingModel,
input: text,
});
return resp.data[0].embedding;
} catch (err: any) {
if (err?.status === 429 && attempt < this.config.maxRetries) {
const delay = this.config.retryBaseDelayMs * Math.pow(2, attempt);
console.warn(
`[MemoryManager] embedding API 限流,${delay}ms 后重试 ` +
`(${attempt + 1}/${this.config.maxRetries})`
);
await this.sleep(delay);
return this.embedWithRetry(text, attempt + 1);
}
throw err;
}
}
/** 向量检索 Long-term,带超时控制 */
private async searchLongTerm(query: string, topK: number): Promise<MemoryQueryResult[]> {
const queryEmb = await this.embedWithRetry(query);
const collection = await this.chromaGetCollection();
const results = await collection.query({
queryEmbeddings: [queryEmb],
nResults: topK,
where: { deleted: 'false' },
});
const output: MemoryQueryResult[] = [];
if (results.ids[0]) {
for (let i = 0; i < results.ids[0].length; i++) {
const meta = results.metadatas[0][i] as any;
const entry: MemoryEntry = {
id: results.ids[0][i],
content: meta.content || '',
summary: meta.summary || '',
layer: 'longTerm',
userId: meta.userId || '',
sessionId: meta.sessionId || '',
createdAt: Number(meta.createdAt) || 0,
ttlMs: Number(meta.ttlMs) || 0,
ttlCategory: (meta.ttlCategory as TTLCategory) || '3months',
priority: Number(meta.priority) || 0.5,
accessCount: 0,
deleted: meta.deleted === 'true',
};
output.push({
entry,
score: 1 - (results.distances?.[0]?.[i] || 0),
source: 'longTerm',
});
}
}
return output;
}
/** 带超时的 Long-term 检索 */
private async searchLongTermWithTimeout(
query: string, userId: string, timeoutMs?: number,
): Promise<MemoryQueryResult[]> {
const timeout = timeoutMs ?? this.config.retrievalTimeoutMs;
return Promise.race([
this.searchLongTerm(query, this.config.topK),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Long-term retrieval timeout')), timeout),
),
]);
}
/** 加权排序 + 去重 */
private rankAndDedup(results: MemoryQueryResult[]): MemoryQueryResult[] {
const now = Date.now();
// 计算综合得分:recency x relevance
const scored = results.map(r => {
const ageHours = (now - r.entry.createdAt) / (1000 * 60 * 60);
const recency = Math.exp(-ageHours / (24 * 7)); // 指数衰减,半衰期 1 周
const relevance = r.score;
const combined = 0.4 * recency + 0.6 * relevance;
return { ...r, score: combined };
});
// 按综合得分降序排列
scored.sort((a, b) => b.score - a.score);
// 简易去重:内容相似度高于 0.9 的只保留最高分的
const deduped: MemoryQueryResult[] = [];
for (const r of scored) {
const isDup = deduped.some(
d => this.jaccardSim(d.entry.content, r.entry.content) > 0.9,
);
if (!isDup) deduped.push(r);
}
return deduped.slice(0, this.config.topK);
}
private isExpired(entry: MemoryEntry): boolean {
if (entry.ttlMs === 0) return false;
return Date.now() - entry.createdAt > entry.ttlMs;
}
private jaccardSim(a: string, b: string): number {
const setA = new Set(a.split(''));
const setB = new Set(b.split(''));
const intersection = new Set([...setA].filter(x => setB.has(x)));
const union = new Set([...setA, ...setB]);
return intersection.size / union.size;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
demo.ts
typescript
// demo.ts --- 演示脚本:模拟长会话中记忆写入、召回、淘汰、过期与冲突检测
import { MemoryManager } from './memory-manager.js';
async function main() {
console.log('=== Agent 分层记忆 Demo ===\n');
const manager = new MemoryManager({
maxWorkingMessages: 20,
maxShortTermEntries: 50,
topK: 5,
similarityThreshold: 0.7,
consolidateInterval: 5,
});
// ── 阶段 1: 模拟多轮对话 ──────────────────────────
console.log('【阶段 1】模拟 8 轮对话...\n');
const conversations = [
['user', '我们的 CI 流水线最近构建速度很慢,有办法优化吗?'],
['assistant', 'CI 慢通常有几个原因:依赖安装、缓存策略、测试并行度。你们现在用什么 CI?'],
['user', 'GitHub Actions,Node.js 项目,用 npm 装依赖。'],
['assistant', 'GitHub Actions 可以启用依赖缓存。你们项目的 node_modules 大概多大?'],
['user', '大概 800MB。对了我补充一下,我们不用 npm,用 pnpm。'],
['assistant', 'pnpm 的缓存效率更高。而且 pnpm 的 store 机制天然适合 CI 缓存。'],
['user', '还有一个问题------我们的 React 项目用的 18 版本,但新项目我想用 19。'],
['assistant', 'React 19 有一些 breaking changes,需要注意 Server Components 的兼容性。'],
];
for (const [role, content] of conversations) {
manager.addToWorking(role, content);
}
console.log(` Working Memory 消息数: ${(manager as any).working.length}\n`);
// ── 阶段 2: 触发沉淀 ──────────────────────────────
console.log('【阶段 2】触发沉淀 Worker...\n');
if (manager.shouldConsolidate()) {
console.log(' 沉淀条件满足,执行 consolidate...');
try {
const results = await manager.consolidate();
console.log(` 沉淀完成。共提取 ${results.length} 条记忆:`);
for (const r of results) {
if (r.shouldPersist) {
console.log(` -> ${r.summary} [优先级: ${r.priority}, TTL: ${r.ttlCategory}]`);
}
}
console.log(` Short-term 记忆数: ${(manager as any).shortTerm.length}\n`);
} catch (err) {
console.error(` 沉淀失败: ${err}`);
}
}
// ── 阶段 3: 记忆召回 ──────────────────────────────
console.log('【阶段 3】记忆召回测试...\n');
const queries = [
'我们的项目用的是什么包管理器?',
'CI 怎么优化构建速度?',
'React 版本是多少?',
];
for (const query of queries) {
console.log(` 查询: "${query}"`);
try {
const results = await manager.recall(query, 'default');
if (results.length === 0) {
console.log(' -> 未找到相关记忆\n');
} else {
for (const r of results) {
console.log(` -> [${r.source}] score=${r.score.toFixed(3)}: ${r.entry.content}`);
}
console.log();
}
} catch (err) {
console.error(` 检索失败: ${err}\n`);
}
}
// ── 阶段 4: 模拟矛盾记忆 ──────────────────────────
console.log('【阶段 4】矛盾记忆检测...\n');
// 周二说不用 MongoDB
await manager.writeLongTerm({
content: '用户明确表示不使用 MongoDB,项目统一用 PostgreSQL',
summary: '不用 MongoDB,用 PostgreSQL',
layer: 'longTerm',
userId: 'default',
sessionId: 'session_tuesday',
createdAt: Date.now() - 2 * 24 * 60 * 60 * 1000,
ttlMs: 90 * 24 * 60 * 60 * 1000,
ttlCategory: '3months',
priority: 0.8,
accessCount: 0,
});
// 周四说这次用 MongoDB
await manager.writeLongTerm({
content: '这次项目用 MongoDB,因为需要灵活的 schema',
summary: '这次用 MongoDB,需要灵活 schema',
layer: 'longTerm',
userId: 'default',
sessionId: 'session_thursday',
createdAt: Date.now(),
ttlMs: 90 * 24 * 60 * 60 * 1000,
ttlCategory: '3months',
priority: 0.9,
accessCount: 0,
});
console.log(' 已写入两条语义相似但内容矛盾的记忆。');
console.log(' 召回时会同时返回两条,由 LLM 根据时间戳判断优先级。\n');
const conflictResults = await manager.recall('数据库选型', 'default');
for (const r of conflictResults) {
const date = new Date(r.entry.createdAt).toLocaleDateString('zh-CN');
console.log(` -> [${date}] score=${r.score.toFixed(3)}: ${r.entry.content}`);
}
console.log();
// ── 阶段 5: TTL 过期演示 ──────────────────────────
console.log('【阶段 5】TTL 过期演示...\n');
// 写入一条 31 天前关于旧技术栈的记忆
await manager.writeLongTerm({
content: '项目使用 React 18 + Webpack 5',
summary: 'React 18 + Webpack 5',
layer: 'longTerm',
userId: 'default',
sessionId: 'session_old',
createdAt: Date.now() - 31 * 24 * 60 * 60 * 1000,
ttlMs: 30 * 24 * 60 * 60 * 1000,
ttlCategory: '1month',
priority: 0.5,
accessCount: 0,
});
console.log(' 写入了一条 31 天前、TTL 为 1 个月的过时记忆。');
const beforeExpire = await manager.expireStaleMemories();
console.log(` 过期扫描:软删除了 ${beforeExpire} 条过期记忆。`);
const afterExpireRecall = await manager.recall('React Webpack 配置', 'default');
const staleEntries = afterExpireRecall.filter(r => r.entry.content.includes('Webpack'));
console.log(` 过期后检索 "React Webpack 配置":找到 ${staleEntries.length} 条(应为 0)。\n`);
// ── 阶段 6: GDPR 删除权 ───────────────────────────
console.log('【阶段 6】GDPR 用户数据删除...\n');
try {
const deleted = await manager.deleteByUser('default');
console.log(` 用户 default 的记忆已软删除,共 ${deleted} 条。\n`);
} catch (err) {
console.error(` GDPR 删除失败: ${err}\n`);
}
// ── 阶段 7: 冷启动 ────────────────────────────────
console.log('【阶段 7】冷启动引导...\n');
const questions = manager.getColdStartQuestions();
console.log(' 新用户偏好引导问题:');
for (const q of questions) {
console.log(` - ${q}`);
}
console.log();
console.log('=== Demo 完成 ===');
}
main().catch(err => {
console.error('Demo 运行失败:', err);
process.exit(1);
});
运行方式
bash
# 确保 ChromaDB 在本地运行(Docker)
docker run -d -p 8000:8000 chromadb/chroma
# 设置环境变量
export OPENAI_API_KEY=sk-xxx
# 运行 Demo
pnpm tsx src/demo.ts
LangChain 对比方案
如果团队已经重度使用 LangChain,等价实现如下------但引入了约 50MB 的额外依赖:
typescript
// LangChain 等价实现(对比参考,非 Demo 一部分)
import { ConversationSummaryBufferMemory } from 'langchain/memory';
import { VectorStoreRetrieverMemory } from 'langchain/memory';
import { Chroma } from '@langchain/community/vectorstores/chroma';
// Long-term: 向量检索记忆
const vectorMemory = new VectorStoreRetrieverMemory({
vectorStoreRetriever: chromaVectorStore.asRetriever({ k: 5 }),
memoryKey: 'long_term',
});
// Short-term: 摘要缓冲记忆
const summaryMemory = new ConversationSummaryBufferMemory({
llm: new ChatOpenAI({ model: 'gpt-4o-mini' }),
maxTokenLimit: 2000,
memoryKey: 'short_term',
});
我的建议:如果你不需要 LangChain 的 Agent 框架能力(ReAct、Tools、AgentExecutor 等),单用 OpenAI SDK + ChromaDB 客户端就够了。LangChain 多出来的那一层抽象在记忆系统这个场景下,带来的调试透明度损失远大于它提供的便利性。
7. Production Practices
7.1 监控
| 指标 | 含义 | 采集方式 | 告警阈值 |
|---|---|---|---|
| 检索延迟 P50 / P95 / P99 | Long-term 向量检索耗时 | ChromaDB query 埋点 + Prometheus histogram | P95 大于 500ms 告警 |
| 记忆命中率 | 有记忆返回的 recall 占比 | 每次 recall 记录 hit/miss | 小于 20% 持续 30 分钟告警 |
| Token 使用量 / 记忆占比 | 记忆内容占 prompt 的 token 比例 | LLM 调用前计算 | 记忆占比大于 30% 告警(噪音太多) |
| 向量库 QPS 和存储量 | ChromaDB 的运行指标 | ChromaDB metrics endpoint | QPS 大于 100 或存储大于 10GB 告警 |
| 沉淀 Worker 队列深度 | 待处理的消息量 | Worker 内部计数器 | 堆积大于 1000 需扩容 |
| Embedding API 错误率 | 限流、超时、5xx 占比 | OpenAI SDK 拦截计数 | 大于 1% 持续 5 分钟告警 |
7.2 告警分级
- P0(凌晨 3 点电话叫醒): 检索延迟 P95 > 1s 或写入失败率 > 10%,持续 5 分钟。这意味着用户对话体验已经明显受影响。
- P1(工作时间处理): 记忆命中率 < 20% 持续 30 分钟------检索没有返回结果,Agent 在以「无记忆」模式运行,体验降级但不阻塞。Worker 队列堆积 > 1000------沉淀跟不上消息生成速度。
- P2(周报关注): Token 周环比增长 > 50%。可能是记忆膨胀或过滤策略失效,需要排查。
7.3 降级路径
生产环境最需要的是兜底方案------不是「最优方案挂了怎么办」,而是「每一层挂了之后,系统还能工作吗」。
rust
降级链路(从最严重到最轻微):
L3 完全降级:
触发条件 -> ChromaDB + Worker 全部不可用
行为 -> 退化为纯上下文窗口模式(最近 20 轮)
用户感知 -> 跨会话记忆丢失,但当前会话正常工作
L2 向量库不可用:
触发条件 -> ChromaDB 连接超时 / 返回 5xx
行为 -> 跳过 Long-term,降级为 Short-term + Working Memory
用户感知 -> 旧记忆无法检索,近 50 条短期记忆仍可用
L1 检索超时:
触发条件 -> Long-term 检索超过 500ms
行为 -> 跳过 Long-term 检索,仅返回 Short-term + Working Memory 结果
用户感知 -> 几乎无感------Short-term 通常在 20ms 内完成
L0 嵌入限流:
触发条件 -> embedding API 返回 429
行为 -> 暂停新记忆写入,只读已有记忆 + 指数退避重试(最多 3 次)
用户感知 -> 新产生的记忆可能有短暂延迟(几秒到几十秒)才能被检索到
设计原则:热路径(用户请求-回复链路)绝不依赖可能超时的外部调用。 检索是异步的,写入是异步的------用户不会因为 Long-term 超时多等 500ms。这是 V2 相对于 LangChain 同步检索模式的一个关键工程优势------我们的实现里,recall 方法虽然看起来是 async,但 Long-term 检索有独立的超时控制,超时后直接跳过,不阻塞返回。
7.4 记忆删除与 GDPR
写入时每条记忆带上 userId 和 sessionId 标签。这是后面所有删除操作的前提。
- 批量删除:
deleteByUser(userId)扫描三层存储,执行软删除(deleted: true)。定期任务(每 24 小时)物理清理已软删除且超过 30 天的记录。 - LLM 触发的遗忘指令: 用户说「忘掉我之前说的关于 X 的内容」-> 解析遗忘指令 -> 语义检索匹配 -> 软删除匹配项并回复确认。这里的难点不是删除------是「关于 X 的内容」的语义边界模糊不清。我们的处理方式是:用
query = X做一次 recall,Top-3 匹配到的条目标记删除,同时告诉用户「已删除以下 3 条记忆:...,如果有遗漏请告诉我」。 - 导出权:
exportByUser(userId)返回用户所有未软删除记忆的 JSON 数组,支持数据可移植性。
7.5 记忆过期机制
写入时的 TTL 分类(由沉淀 LLM 判断):
| TTL 分类 | 典型内容 | 过期后行为 |
|---|---|---|
| permanent | 用户姓名、核心偏好(如「偏好简洁回答」) | 永不过期 |
| 1year | 技术栈偏好、代码风格 | 过期后软删除 |
| 3months | 项目上下文、当前工作重点 | 过期后软删除 |
| 1month | 临时决策、特定场景的配置 | 过期后软删除 |
召回时做 recency decay 加权:recency = exp(-age_hours / half_life_hours),半衰期设为 1 周。这意味着 1 周前的记忆权重降为 0.5,2 周前的降为 0.25。结合 relevance score 做加权:combined = 0.4 x recency + 0.6 x relevance。relevance 权重更高,因为过期不等于无用------有些 3 个月前的技术决策今天仍然是有效的。
7.6 冷启动策略
一个新用户首次使用 Agent,没有任何个人长期记忆。此时做两件事:
- 主动偏好引导: 首次对话的前几条消息是 3-5 个选择题(见 Demo 中的
getColdStartQuestions),覆盖技术栈、回答风格、工具偏好。回答直接写入 Long-term,TTL 设为1year。 - 团队共享记忆池 Bootstrap: 如果团队有其他成员已经在使用 Agent,从团队的公共记忆池(通用编码规范、技术架构决策、CI/CD 流程等)导入作为新用户的初始长期记忆。这些导入的记忆降权 20%(
priority x 0.8),确保个人偏好优先于团队默认值。
7.7 运维手册
- 向量库迁移: ChromaDB 单机 -> Pinecone / Qdrant 托管。迁移路径:在 Pinecone 创建同名 index -> 用脚本批量复制 embedding + metadata -> 灰度切换(10% 流量读 Pinecone,确认延迟稳定后全量切)。
- Embedding 模型升级: 旧模型 embedding 和新模型 embedding 维度可能不同。灰度流程:双写(新记忆同时用新旧模型各生成一份 embedding)-> 验证新模型检索精度优于旧模型 -> 批量重新 embedding 历史记忆 -> 切换为新模型读。
- 备份策略: ChromaDB 数据目录每日备份,保留 30 天。推荐用
chromadb export而非直接 cp 磁盘文件(后者可能在写入过程中产生不一致的快照)。 - 容量规划: 单用户月均产生约 100 条长记忆(日均 10 轮 x 30 天 x 3% 写入率),每条约 2KB(embedding + metadata)。200 人团队月增量约 400MB,年增量约 5GB。Pinecone 免费层 2GB,付费 $0.33/GB/月。中等规模团队通常不会超过 10GB。
8. Common Pitfalls
Pitfall 1: 180 轮后回复质量断崖式下跌
去年 11 月,团队内部测试。Agent 连续工作约 180 轮后,回复开始莫名其妙------明明前面讨论过的决策被忽略,反复询问已确认的信息。根因是团队只启用了 Short-term FIFO + Long-term 向量检索,但 Short-term 容量只设了 30 条。180 轮中大量闲聊占满了 FIFO 缓冲,把早期的关键决策指令挤出去了。Long-term 那边的沉淀 Worker 还没触发------在等「会话结束」信号,但这是一个持续 3 天的长会话。结果就是 FIFO 淘汰了关键信息 + Long-term 还没来得及索引------记忆系统短暂性失忆。临时修复:紧急把 Short-term 容量从 30 调到 100,重启服务。永久修复:改为按轮次间隔触发沉淀(每 10 轮一次),不再依赖「会话结束」这个不可靠的信号。同时把 Short-term 的 FIFO 淘汰改为带优先级的淘汰------高优先级条目(标记为 priority > 0.7)在被 FIFO 淘汰前,先尝试提升到 Long-term。
教训: FIFO 与 Long-term 之间的「真空地带」是质量断崖的高发区。沉淀触发条件不应该依赖单一信号。
Pitfall 2: 语义检索召回噪音污染 Prompt
今年 3 月。用户问「CI 构建慢怎么优化?」。检索召回了 5 条记忆------其中 3 条是关于「优化 React 渲染性能」的,1 条是关于「数据库查询优化」的,只有 1 条是关于 CI 的。Agent 基于这些噪音给出了一堆不相关的建议。根因:embedding 模型把「优化」和「慢」的语义相似度算高了。但「CI 构建慢」和「React 渲染慢」在工程上是两个完全不同的领域。纯向量检索分不清「语义相似但领域无关」和「语义不同但领域相关」的区别。临时修复:在 embedding 查询前加上领域标签(query = "CI/CD: ${userQuery}"),用前缀过滤。永久修复:引入混合检索------向量相似度 x 关键词匹配(BM25)。CI 相关记忆里高概率包含「pipeline」「build」「cache」等关键词,用 BM25 加权后把噪音挤下去了。另外 Top-3 返回后加一层 LLM rerank------让 LLM 自己判断哪条记忆与当前问题真正相关。
教训: 不要让 embedding 做它不擅长的事。向量检索适合语义近似判断,不适合领域区分。
Pitfall 3: Reflection 同步阻塞 5-8 秒
今年 1 月,尝试给 Agent 加 Reflection 能力。用户发消息后,终端光标闪了 6 秒才出回复。不是网络问题------是 Reflection 在同步阻塞。根因:Reflection 调用在用户请求-回复的关键路径上同步执行。每次用户消息都触发一次反思,反思 LLM 调用耗时 3-8 秒不等。临时修复:直接禁用了 Reflection 功能。永久修复:如果未来需要 Reflection,把它移到完全独立的异步 Worker 中,在自己的节奏下运行(比如 Agent 空闲时、或每 50 个交互步骤触发一次)。用户交互路径上绝不做任何超过 500ms 的操作。
教训: 任何会调用 LLM 的操作,默认假设它可能在 5 秒以上才能完成。永远不要放在同步路径上。
Pitfall 4: 检索延迟 400ms 破坏对话节奏
今年 4 月,将向量库从本地 ChromaDB 迁移到远端 Pinecone。对话体验明显变「慢」------每条回复前有一个可感知的停顿。用户反馈「感觉 Agent 在思考但什么都没说」。根因:远端 Pinecone(美东部署)到新加坡服务器的 RTT 约 200ms,加上检索耗时约 200ms,总计约 400ms。而且这是同步阻塞的------Agent 必须等检索返回才能开始生成回复。临时修复:检索超时设为 300ms,超时直接跳过 Long-term,降级为 Short-term。永久修复:改造为流式架构------先基于 Working + Short-term 开始生成回复(这两层延迟低于 20ms),并行触发 Long-term 检索。Long-term 结果返回后,作为额外上下文注入到后续的生成步骤中。如果 Long-term 在回复生成完成前还没返回------丢弃,下一轮再说。
教训: 检索延迟的「可感知阈值」大约是 150ms。超过这个数的外部依赖,考虑异步化。
Pitfall 5: Embedding 模型选型不当------中文场景检索失效
2024 年 6 月,项目初期。中文对话的记忆检索效果很差。用户问「登录模块怎么重构」,检索回来的 top 记忆是「登出逻辑的实现方式」。根因:用了 text-embedding-ada-002,但它对中文的语义区分能力不够精细。实测 NDCG@5 在中文测试集上只有 0.42,对比 text-embedding-3-small 的 0.78,差距巨大。临时修复:紧急切换到 text-embedding-3-small。永久修复:选 embedding 模型时用一个简单的评估脚本------准备 50 对(query,正确答案)中文样本,跑 NDCG@5 和 recall@5。任何模型的 recall@5 低于 0.7 直接淘汰。对于中文场景,目前 text-embedding-3-small 和 BGE-M3 是性价比最好的两个选择。
教训: Embedding 模型的语言适配性是「无感问题」------你感觉不到它不好,直到你发现召回结果全是噪音。选型时用你的语言跑一遍 benchmark,不要信 benchmark 上的英文分数。
Pitfall 6: 记忆冲突------Agent 收到矛盾指令
去年 12 月。Agent 在同一个回答里既建议用 MongoDB 又建议用 PostgreSQL,自相矛盾。用户觉得 Agent 逻辑混乱。根因:用户周二说「我们不用 MongoDB」,周四在另一个场景下说「这次用 MongoDB」。两条记忆都存入了 Long-term,召回时同时命中,Agent 收到了两个矛盾的指令,但没有被告知它们有时间先后的冲突关系。临时修复:在 prompt 中显式告诉 LLM「如果记忆之间存在矛盾,以最近的一条为准」。永久修复:写入前做语义相似度检查(Demo 中的实现)。如果新记忆与已有记忆相似度 > 0.85 但内容矛盾,标记旧记忆为 conflicted,新记忆设置更高优先级。召回时如果检测到 conflicted 标记,在返回结果中附带冲突信息------由生成阶段的 LLM 做最终裁决。
教训: 用户的偏好是会变的。记忆系统不应该假设「一条 userId 只用了一个技术栈」。冲突是常态,不是异常。
Pitfall 7: 记忆永不过期------Agent 基于过时信息给建议
今年 5 月。Agent 反复建议用 Webpack 的某个配置来优化构建,但团队已经在 3 个月前迁移到 Vite 了。这条「React 18 + Webpack 5」的记忆在迁移后仍然存在于 Long-term 中,每次检索都被召回。根因:没有 TTL 机制------写进去就是永久的。技术栈迁移这种变更,记忆系统完全不知情。临时修复:手动写了一个脚本,按关键词(Webpack、Create React App 等)批量软删除过时记忆。永久修复:每条记忆写入时带 TTL 分类(permanent / 1year / 3months / 1month)。技术栈偏好设 3 个月,项目上下文设 1 个月,让它们自然过期。同时,当用户说出暗示技术栈变化的关键词(「迁移到...」「升级到...」「改用...」)时,触发「关联记忆失效检查」------自动降低旧技术栈相关记忆的优先级。
教训: 永久存储是反模式的。软件工程领域的几乎所有信息都有半衰期。你的记忆系统应该知道这一点。
9. Interview Extension
Q1: 三层各管什么?为什么不能合并成一层?
A: Working Memory 管「当下」------当前会话的最近 20 轮,零延迟,会话结束即清空。Short-term 管「最近」------最近 50 条高价值信息的 FIFO 缓冲,提供毫秒级的快速检索。Long-term 管「过去」------跨会话的持久化记忆,通过语义检索在几百毫秒内返回。
不能合并的核心原因不是技术上的------是存取模式的根本差异。Working Memory 追求极低延迟和零过滤(什么都要),Long-term 追求高精度和高压缩比(只要精华)。如果把两者合到一层,你要么在每次写入时做昂贵的压缩判断(拖慢热路径),要么在每次检索时从大量噪音中翻找(降低精度)。分层不是因为 fancy,是因为这三者的读写比、延迟要求、过滤精度差异大到任何单层设计都会在某一个维度上严重妥协。
Q2: 检索延迟大于 200ms 怎么办?
A: 分三步走。第一步,检查向量库部署位置------和你的 Agent 服务在同一个 Region 吗?跨 Region 的 RTT 轻松吃掉 100-200ms。第二步,检查检索规模------Top-K 是否设置太大?一般 Top-5 到 Top-10 就够,不需要 Top-50。第三步,如果前两步都优化到底了延迟仍然超标,改架构------Long-term 检索异步化。先基于 Working + Short-term 开始生成回复(低于 20ms),Long-term 结果异步注入。如果回复已经生成完 Long-term 还没返回------本次丢弃,下轮再用。这个设计的代价是「本轮可能缺少一条相关旧记忆」,但收益是「本轮延迟削减 200ms」。对于交互式对话,延迟优先级高于信息完整性。
Q3: 多用户场景需要哪些改造?
A: 第一是隔离------每条记忆带 userId 标签,检索加 where: { userId } 过滤。这是最基本的,Demo 中已经实现。第二是共享记忆池------团队级别的技术决策、编码规范、项目架构信息不应该每人存一份。在 Long-term 中单独维护一个 teamPool 命名空间,新用户 bootstrap 时从池子导入。第三是跨用户的记忆相关性------如果用户 A 和用户 B 在同一个项目的同一个模块工作,用户 B 问问题时,A 的相关记忆可能也有参考价值。这个需要引入「项目-团队」维度的记忆共享策略,但不建议在初期就做------租户隔离做扎实了再考虑跨用户共享,否则一个用户的偏好泄露给另一个用户是严重的隐私问题。
10. Summary
三句话带走
-
Agent 记忆设计的核心不是「存多少」,是「什么时候该忘记」。 每条记忆写入时就应该知道自己的过期时间。分层的目的就是让不同类型的信息在不同的时间尺度上自然衰减。
-
三层漏斗的工程本质是「按信息衰减速度分离存储介质」。 Working Memory 是内存,秒级衰减。Short-term 是 FIFO 缓冲,分钟到小时级衰减。Long-term 是向量库,天到年级衰减。每一层使用最适合其衰减特征的存储和检索策略。
-
90% 的场景选 V2 分层记忆就够了。 不是因为 V2 最先进------是因为 V0/V1/V1.5 的瓶颈你很快会碰到,而 V3 的复杂度你大概率不需要。V2 是「你今天就能上线,一年后也不用换」的方案。
下次你做记忆系统设计,先问自己
- 我的对话轮数天花板是多少?超过 10 轮 -> 必须上 Short-term。超过 50 轮 -> 必须上 Long-term。
- 我的团队有向量库运维能力吗?没有 -> 用 ChromaDB 本地部署(零运维启动),或者先用 V1.5 的 SQLite + cosine similarity 做过渡。
- 用户的哪些信息真的值得跨会话记住?如果少于 5% -> 你的沉淀 Worker 需要更激进的价值判断。如果超过 20% -> 你的过滤器可能太松了。
- 我有没有给每条记忆设置 TTL?如果没有 -> 半年后你的向量库会被过时信息填满,检索质量持续恶化。
停在这里的信号
如果你的 Agent 对话每次只有 3-5 轮,用户每次对话都是独立的新任务,不需要跨会话记住任何东西------你不需要看这篇文章。纯上下文窗口就够了。当你的用户开始抱怨「为什么每次都要重新说一遍我的技术栈」的时候,再回来。到那时,你对「记忆」这两个字的理解会和今天完全不同。
如果你的团队在记忆系统的生产落地中遇到了这篇文章没有覆盖的场景(多 Agent 共享记忆、跨语言检索、移动端本地记忆等),欢迎交流。我自己的团队在多 Agent 记忆隔离这块也还在摸索------目前的做法是每个 Agent 维护独立的 Long-term namespace,但代价是同一个用户的偏好需要在每个 Agent 那里各存一份。更好的方案我还没找到。