一、引言:为什么 Agent 需要记忆?
去年不花yao udi我接手了一个客服 AI Agent 项目。上线第一周效果惊艳------准确回答率 94%。但到了第三周,同样的问题准确率跌到了 61%。
不是模型退化了。是 Agent 没有记忆。
一个人的对话是这样的:
- 用户:"我的订单号是 ORD-2025-0816"
- 客服:"好的,我查一下这个订单。"
- 用户:"是昨天下的,还没发货。"
- 客服:"好的,ORD-2025-0816 昨天下的单,我来看看物流状态。"
但我的 Agent 在第二轮对话里,已经把"ORD-2025-0816"忘得一干二净。用户的"昨天下的"在模型看来就是一个孤立的陈述------它根本不记得这是哪个订单。
这就是 LLM Agent 最被低估的工程挑战:记忆体系。
当我们在聊天框里跟 AI 对话时,每一轮的消息拼接在一起,看起来像是有记忆的。但这只是"token-window 幻觉"------模型只是看到了前面的文本,而不是真的"记得"什么。一旦消息轮次超过模型的上下文窗口(通常是 128K tokens),最早的对话就会像进了碎纸机一样被丢掉。
更致命的是:Agent 不只是对话。它会调用 API、执行数据库查询、写文件、操作外部系统。这些操作的中间结果如果没有被妥善保存,Agent 就没有"长期工作经验"可以参考。
经过四个月的迭代,我们构建了一套三层记忆体系。本文从工程角度完整分享这套设计------不做概念科普,只讲代码、架构和踩过的坑。
二、三层记忆体系架构
先看整体设计,再逐个拆解。我们不重新发明轮子,而是在成熟组件之上构建。
scss
┌─────────────────────────────────────────────────┐
│ Agent Memory System │
├──────────────────┬────────────────┬───────────────┤
│ 工作记忆(WM) │ 短期记忆(STM) │ 长期记忆(LTM) │
│ Running Buffer │ Session Store │ Vector Store │
│ │ │ │
│ • 当前推理内容 │ • 本轮对话 │ • 历史对话 │
│ • 最近工具调用 │ • 中间状态 │ • 用户画像 │
│ • 临时上下文 │ • 引用数据 │ • 知识库 │
│ │ │ │
│ ≈8K tokens │ 1-24h TTL │ 永久存储 │
│ 进程内内存 │ Redis │ PostgreSQL + │
│ │ │ pgvector │
└──────────────────┴────────────────┴───────────────┘
工作记忆 (Working Memory):Agent 一次推理循环中的临时上下文,包括当前用户问题、最近几轮对话、待处理的工具调用结果。相当于人类的大脑 RAM,用完即弃。
短期记忆 (Short-Term Memory):一次会话的生命周期数据。从用户发起会话到结束或超时,记录对话历史、Agent 的决策轨迹、中间变量。存储在后端 Redis,有 TTL。
长期记忆 (Long-Term Memory):跨会话的持久化知识。包括用户偏好、历史行为模式、重要的上下文片段。存储在向量数据库中,通过语义检索按需召回。
这三个层级的数据流向是单向的------工作记忆溢出到短期记忆,短期记忆中的关键信息通过提炼存到长期记忆。一条朝下的通道。
三、工作记忆:Agent 的在线缓存
工作记忆的实现核心是一个带淘汰策略的环形缓冲区。它只保存"此刻 Agent 必须知道"的信息。
3.1 核心实现
typescript
interface WorkingMemoryEntry {
role: 'user' | 'assistant' | 'system' | 'tool_call' | 'tool_result';
content: string;
timestamp: number;
tokenCount: number;
metadata?: Record<string, unknown>;
}
class WorkingMemory {
private buffer: WorkingMemoryEntry[] = [];
private maxTokens: number;
private currentTokens: number = 0;
constructor(maxTokens: number = 8000) {
this.maxTokens = maxTokens;
}
append(entry: WorkingMemoryEntry): void {
this.buffer.push(entry);
this.currentTokens += entry.tokenCount;
this.evict();
}
private evict(): void {
while (this.currentTokens > this.maxTokens && this.buffer.length > 0) {
const evictCandidate = this.findEvictCandidate();
if (!evictCandidate) break;
this.currentTokens -= evictCandidate.tokenCount;
const idx = this.buffer.indexOf(evictCandidate);
this.buffer.splice(idx, 1);
}
}
private findEvictCandidate(): WorkingMemoryEntry | null {
for (const entry of this.buffer) {
if (entry.role === 'tool_result' && this.shouldEvict(entry)) {
return entry;
}
}
return null;
}
private shouldEvict(entry: WorkingMemoryEntry): boolean {
const recentResults = this.buffer
.filter(e => e.role === 'tool_result')
.slice(-2);
return !recentResults.includes(entry);
}
toMessages(): Array<{role: string; content: string}> {
return this.buffer.map(e => ({
role: e.role === 'tool_call' || e.role === 'tool_result'
? 'tool' : e.role,
content: e.content
}));
}
get usage(): { used: number; max: number; ratio: number } {
return {
used: this.currentTokens,
max: this.maxTokens,
ratio: this.currentTokens / this.maxTokens,
};
}
}
这段代码的核心思想是:别把所有历史都塞给模型。Agent 系统跑一段时间后,工作记忆里最多的东西总是 tool_call / tool_result 的配对------模型调用外部 API 时,返回的 JSON 可能几千上万 tokens。把这些精简一下,能省出一大块空间放真正有用的对话上下文。
3.2 我们踩的第一个坑:无节制的 token 堆积
上线第一周,我们发现 8K 的工作记忆窗口,结果工具调用结果占了 6.5K,只剩 1.5K 给真正的对话。
解决方案是对工具结果做摘要:
typescript
class SummarizedWorkingMemory extends WorkingMemory {
async appendToolResult(
toolCallId: string,
rawResult: string,
summarizeFn: (content: string) => Promise<string>
): Promise<void> {
const summarized = await summarizeFn(rawResult);
const tokenCount = this.estimateTokens(summarized);
const entry: WorkingMemoryEntry = {
role: 'tool_result',
content: rawResult.length > summarized.length
? `[摘要] ${summarized}\n[全文长度: ${rawResult.length} 字符,已省略]\n`
: rawResult,
timestamp: Date.now(),
tokenCount,
};
this.append(entry);
}
private estimateTokens(text: string): number {
const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length;
const englishWords = text.split(/\s+/).filter(w => /[a-zA-Z]/.test(w)).length;
return Math.ceil(chineseChars * 1.5 + englishWords * 1.3 + text.length * 0.25);
}
}
调用一个 LLM 来总结另一个 LLM 的中间结果听起来浪费,但实际收益巨大:一个 3000 token 的 API 响应摘要后只有 200-400 tokens,减少了 85% 以上的空间占用。而 LLM 的总结调用使用最便宜的模型(比如 DeepSeek-V3 或 Qwen-Turbo),成本几乎可以忽略。
四、短期记忆:会话级持久化
工作记忆在 Agent 进程重启后就会丢失。短期记忆补上这一层------它在会话生命周期内持久化,但不会跨会话。
4.1 Redis 存储方案
typescript
interface SessionMemory {
sessionId: string;
userId: string;
messages: Array<{
role: string;
content: string;
timestamp: number;
tokenCount: number;
}>;
agentState: Record<string, unknown>;
createdAt: number;
updatedAt: number;
}
class RedisShortTermMemory {
private redis: Redis;
private ttlSeconds: number;
constructor(redisUrl: string, ttlSeconds: number = 86400) {
this.redis = new Redis(redisUrl);
this.ttlSeconds = ttlSeconds;
}
async save(sessionId: string, memory: Partial<SessionMemory>): Promise<void> {
const key = `session:${sessionId}`;
await this.redis.hset(key, {
messages: JSON.stringify(memory.messages || []),
agentState: JSON.stringify(memory.agentState || {}),
updatedAt: Date.now().toString(),
});
await this.redis.expire(key, this.ttlSeconds);
}
async load(sessionId: string): Promise<SessionMemory | null> {
const key = `session:${sessionId}`;
const data = await this.redis.hgetall(key);
if (!data || Object.keys(data).length === 0) return null;
return {
sessionId,
userId: data.userId || '',
messages: JSON.parse(data.messages || '[]'),
agentState: JSON.parse(data.agentState || '{}'),
createdAt: parseInt(data.createdAt || '0'),
updatedAt: parseInt(data.updatedAt || '0'),
};
}
async appendMessage(
sessionId: string,
message: SessionMemory['messages'][0]
): Promise<void> {
const key = `session:${sessionId}`;
await this.redis.lpush(
`session:${sessionId}:msgs`,
JSON.stringify(message)
);
await this.redis.ltrim(`session:${sessionId}:msgs`, 0, 199);
await this.redis.expire(`session:${sessionId}:msgs`, this.ttlSeconds);
await this.redis.expire(key, this.ttlSeconds);
}
async getRecentMessages(
sessionId: string,
count: number = 50
): Promise<SessionMemory['messages']> {
const raw = await this.redis.lrange(
`session:${sessionId}:msgs`,
0,
count - 1
);
return raw.map(r => JSON.parse(r)).reverse();
}
}
4.2 第二坑:Redis 消息列表膨胀
短期记忆的一个常见灾难是消息列表无限增长。一个用户如果跟 Agent 持续对话几小时,可能产生上千条消息。如果每次推理都把全部消息塞给模型,token 计数和推理延迟都会爆炸。
我们在 Redis list 做了两层控制:
- 硬限 200 条:任何会话最多保留 200 条消息,再多就自动淘汰。
- 消息分段压缩:超过 50 条时,将早期的消息分段压缩成摘要。
typescript
async function compressOldMessages(
sessionId: string,
messages: SessionMemory['messages'],
compressFn: (msgs: SessionMemory['messages']) => Promise<string>
): Promise<SessionMemory['messages']> {
if (messages.length <= 50) return messages;
const recentMessages = messages.slice(-30);
const oldMessages = messages.slice(0, -30);
const chunks = [];
for (let i = 0; i < oldMessages.length; i += 10) {
chunks.push(oldMessages.slice(i, i + 10));
}
const summaries = await Promise.all(
chunks.map(chunk => compressFn(chunk))
);
return [
{
role: 'system',
content: `[早期对话摘要]\n${summaries.join('\n')}`,
timestamp: messages[0].timestamp,
tokenCount: summaries.reduce((a, s) => a + s.length, 0),
},
...recentMessages,
];
}
4.3 状态持久化
短期记忆不只存对话,还存 Agent 的状态机数据。我们的 Agent 是一个有限状态机------不同阶段有不同的行为模式。
typescript
interface AgentStateMachine {
currentState: 'greeting' | 'collecting_info' | 'processing' | 'confirming' | 'resolved';
pendingActions: string[];
collectedInfo: Record<string, unknown>;
retryCount: number;
}
async function persistStateTransition(
sessionId: string,
oldState: AgentStateMachine,
newState: AgentStateMachine
): Promise<void> {
const transition = {
from: oldState.currentState,
to: newState.currentState,
timestamp: Date.now(),
reason: `Completed action: ${oldState.pendingActions[0]}`,
};
await redis.lpush(
`session:${sessionId}:transitions`,
JSON.stringify(transition)
);
await redis.ltrim(`session:${sessionId}:transitions`, 0, 49);
await redis.expire(`session:${sessionId}:transitions`, 86400);
await redis.hset(`session:${sessionId}`, {
agentState: JSON.stringify(newState),
updatedAt: Date.now().toString(),
});
}
五、长期记忆:跨会话的知识沉淀
长期记忆是整个记忆体系中最具挑战性的部分。它需要解决三个问题:
- 存什么------不是所有对话都值得记住
- 怎么存------结构化还是向量化
- 怎么召回------什么时候需要把什么信息拿出来
5.1 存储架构
我们使用 PostgreSQL + pgvector 作为长期记忆的存储后端。为什么不用专门的向量数据库?
- 团队已经用 PostgreSQL,减少运维复杂度
- 长期记忆不只是向量检索,还需要结构化查询
- 大多数应用数据量远没到需要专门向量库的程度
typescript
const MEMORY_SCHEMA = `
CREATE TABLE IF NOT EXISTS long_term_memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(128) NOT NULL,
memory_type VARCHAR(32) NOT NULL,
content TEXT NOT NULL,
embedding vector(1536),
source_session_id VARCHAR(64),
confidence FLOAT DEFAULT 1.0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
access_count INT DEFAULT 0,
last_accessed_at TIMESTAMPTZ,
expiry TIMESTAMPTZ,
metadata JSONB DEFAULT '{}',
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_memories_user_type ON long_term_memories(user_id, memory_type);
CREATE INDEX idx_memories_confidence ON long_term_memories(confidence DESC);
CREATE INDEX idx_memories_expiry ON long_term_memories(expiry) WHERE expiry IS NOT NULL;
CREATE INDEX idx_memories_embedding ON long_term_memories
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
`;
5.2 记忆提炼 Pipeline
记忆从短期到长期的转换不是全量复制,而是提炼(consolidation)。我们设计了一个离线 Pipeline 来处理这个转化。
typescript
interface MemoryExtraction {
type: 'fact' | 'preference' | 'experience';
content: string;
confidence: number;
metadata: Record<string, unknown>;
}
class MemoryConsolidationService {
constructor(
private llm: LLMClient,
private db: DatabaseClient,
private embeddingModel: EmbeddingClient
) {}
async extractFromSession(
userId: string,
messages: SessionMemory['messages']
): Promise<MemoryExtraction[]> {
const prompt = this.buildExtractionPrompt(messages);
const response = await this.llm.chat({
model: 'deepseek/deepseek-chat',
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
});
const extractions: MemoryExtraction[] = JSON.parse(response.content);
return extractions.filter(e => e.confidence >= 0.7);
}
async consolidate(memory: MemoryExtraction): Promise<void> {
const embedding = await this.embeddingModel.embed(memory.content);
const similar = await this.findSimilar(memory.content, embedding, 0.92);
if (similar.length > 0) {
await this.mergeMemory(similar[0].id, memory, embedding);
} else {
await this.insertMemory(memory, embedding);
}
}
private buildExtractionPrompt(messages: SessionMemory['messages']): string {
return `从以下对话中提取需要长期记住的信息,以 JSON 数组格式返回。
提取标准:
- fact: 明确的事实性信息
- preference: 用户的明确偏好表达
- experience: 用户的经历或过去的行为模式
对话内容:
${messages.map(m => `${m.role}: ${m.content}`).join('\n')}
输出格式:
[
{
"type": "fact|preference|experience",
"content": "提取的信息",
"confidence": 0.95,
"metadata": {}
}
]`;
}
private async findSimilar(
content: string,
embedding: number[],
threshold: number
): Promise<Array<{id: string; similarity: number}>> {
const result = await this.db.query(
`SELECT id, 1 - (embedding <=> $1) as similarity
FROM long_term_memories
WHERE embedding <=> $1 < $2
ORDER BY embedding <=> $1
LIMIT 3`,
[embedding, 1 - threshold]
);
return result.rows;
}
}
5.3 第三坑:记忆重复暴增
这个坑踩得最痛。上线长期记忆一周后,数据库膨胀了 8 倍。排查发现同一个 user_id 下存储了 47 条内容相近的记忆。
根因是两个问题:
- 查重阈值太低:0.85 的 cosine similarity 阈值对于生日这类短文本不够。
- 没有去重窗口:同一会话中,如果用户多次确认同一信息,每次都会触发提取。
修复方案:
typescript
async function dedupCheck(
content: string,
userId: string,
timeWindowMinutes: number = 60
): Promise<boolean> {
const existing = await db.query(
`SELECT id FROM long_term_memories
WHERE user_id = $1
AND content = $2
AND created_at > NOW() - INTERVAL '${timeWindowMinutes} minutes'
LIMIT 1`,
[userId, content]
);
if (existing.rows.length > 0) return true;
const embedding = await embeddingModel.embed(content);
const similar = await findSimilar(embedding, 0.95);
return similar.length > 0;
}
5.4 记忆的主动遗忘机制
长期记忆不是只增不减的。我们实现了三阶遗忘策略:
typescript
async function applyForgettingPolicy(): Promise<void> {
const NOW = new Date();
// 第一阶段:降权(超过30天未访问,置信度打折)
await db.query(`
UPDATE long_term_memories
SET confidence = confidence * 0.9
WHERE last_accessed_at < NOW() - INTERVAL '30 days'
AND confidence > 0.3
`);
// 第二阶段:归档(超过90天未访问 + 低置信度)
await db.query(`
UPDATE long_term_memories
SET metadata = jsonb_set(metadata, '{archived}', 'true')
WHERE last_accessed_at < NOW() - INTERVAL '90 days'
AND confidence < 0.5
AND (metadata->>'archived') IS NULL
`);
// 第三阶段:删除(超过180天未访问 + 置信度低于0.3)
await db.query(`
DELETE FROM long_term_memories
WHERE last_accessed_at < NOW() - INTERVAL '180 days'
AND confidence < 0.3
`);
}
六、记忆召回:何时从长期记忆拉取数据
6.1 主动召回
typescript
class MemoryRecallService {
async recall(
userId: string,
query: string,
maxMemories: number = 5
): Promise<MemoryContext> {
const queryEmbedding = await this.embeddingModel.embed(query);
const memories = await this.db.query(`
SELECT content, memory_type, confidence, metadata
FROM long_term_memories
WHERE user_id = $1
AND (expiry IS NULL OR expiry > NOW())
AND (metadata->>'archived') IS NULL
ORDER BY embedding <=> $2
LIMIT $3
`, [userId, queryEmbedding, maxMemories]);
if (memories.rows.length > 0) {
const ids = memories.rows.map(r => r.id);
await this.db.query(`
UPDATE long_term_memories
SET access_count = access_count + 1, last_accessed_at = NOW()
WHERE id = ANY($1)
`, [ids]);
}
return {
user_id: userId,
memories: memories.rows.map(r => ({
content: r.content,
type: r.memory_type,
confidence: r.confidence,
})),
query,
timestamp: Date.now(),
};
}
}
6.2 延迟召回(Deferred Retrieval)
大多数文档只说主动召回。但我们的测试发现:不是每轮对话都需要查长期记忆。
typescript
class DeferredRecallAgent {
private shortTermMemory: RedisShortTermMemory;
private longTermMemory: MemoryRecallService;
async processTurn(userId: string, sessionId: string, userMessage: string) {
const initialResponse = await this.agentInfer(sessionId, userMessage);
const needsContext = this.detectConfusion(initialResponse);
if (needsContext) {
const memories = await this.longTermMemory.recall(userId, userMessage);
if (memories.memories.length > 0) {
const augmentedResponse = await this.agentInfer(
sessionId,
userMessage,
this.formatMemoryContext(memories)
);
return augmentedResponse;
}
}
return initialResponse;
}
private detectConfusion(response: string): boolean {
const confusionPatterns = [
'我不确定', '我不记得', '请提供更多信息',
'我没有找到', '您能说明一下吗',
];
return confusionPatterns.some(p => response.includes(p));
}
}
七、第四坑:记忆不一致问题
长期记忆和短期记忆之间会出现不一致------这是记忆系统最难调试的问题。
根因是记忆更新没有原子性。用户更新了偏好,但短期记忆的更新和长期记忆的提炼之间存在时间差,导致两个系统读到不同版本。
7.1 事件驱动的记忆同步方案
typescript
interface MemoryEvent {
type: 'memory.created' | 'memory.updated' | 'memory.confirmed' | 'memory.conflicted';
userId: string;
sessionId: string;
content: string;
source: 'short_term' | 'long_term';
timestamp: number;
}
class MemoryEventBus {
private publishers: Map<string, (event: MemoryEvent) => Promise<void>> = new Map();
subscribe(name: string, handler: (event: MemoryEvent) => Promise<void>): void {
this.publishers.set(name, handler);
}
async publish(event: MemoryEvent): Promise<void> {
const currentVersion = await this.getCurrentVersion(event);
if (currentVersion && currentVersion.timestamp > event.timestamp) {
await this.handleConflict(event, currentVersion);
return;
}
for (const handler of this.publishers.values()) {
await handler(event);
}
}
}
八、性能与成本数据
8.1 性能指标
| 指标 | 无记忆系统 | 有记忆系统 | 改善 |
|---|---|---|---|
| 用户首次问题准确率 | 67% | 89% | +22% |
| 会话第5轮准确率 | 41% | 83% | +42% |
| 用户意图理解耗时 | 1.2s | 1.4s | +0.2s(可接受) |
| 需重复信息的比例 | 34% | 8% | -76% |
| 用户满意度(NPS) | 32 | 71 | +39 |
8.2 成本数据
| 组件 | 月成本 | 说明 |
|---|---|---|
| Redis (短期记忆) | ~¥100 | 1GB 实例,足够 5000 并发会话 |
| PostgreSQL + pgvector | ~¥200 | 共享已有数据库,增量成本极小 |
| 记忆提炼 LLM 调用 | ~¥80 | 使用 DeepSeek-V3,每会话约 2 次提取 |
| Embedding 生成 | ~¥35 | 每月约 50 万次向量化 |
每月记忆系统总成本约 ¥415,换来了准确率提升 22% 和用户满意度翻倍。
九、总结
AI Agent 记忆体系建设中最值得记住的几件事:
-
分三层建,不要一层搞。工作记忆管推理中的上下文,短期记忆管会话持久化,长期记忆管跨会话的知识沉淀。三层职责清晰,互不干扰。
-
便宜模型做摘要和提炼。不用每次都让主力模型处理记忆。工具结果的摘要、长对话的压缩、记忆提取------这些事用 DeepSeek-V3 或 Qwen-Turbo 级别的模型绰绰有余。
-
查重和去重做在前面。记忆库一旦膨胀起来,清理成本远高于预防成本。高阈值的 embedding 查重 + 时间窗口去重,两个机制缺一不可。
-
主动遗忘比记住更重要。AI Agent 最可怕的不是记不住,而是什么都记。每天降权、归档、删除的遗忘 pipeline 保证记忆库始终高质量。
-
延迟召回省 60% 的向量查询。不是每轮对话都需要翻长期记忆。先推理,检测到"不确定"信号再查,效果一样好,成本低得多。
人类每天都在遗忘------大脑通过睡眠时的突触修剪来自自动完成。而我们的代码要实现这一层,只能靠精心设计的 pipeline。最讽刺的是,最终让 Agent 看起来"更聪明"的,恰恰是它学会了什么时候该忘。
本文涉及的代码已经过简化以突出核心逻辑。完整生产代码还包含额外的错误处理、监控打点和熔断逻辑。