AI Agent 记忆系统设计:从短期上下文到长期知识库的架构演进

cover

一、对话失忆与知识断层:Agent 落地的核心瓶颈

当前大模型 Agent 在实际落地中面临一个根本性问题——记忆缺失。多数 Agent 实现仅依赖单次对话的上下文窗口,一旦会话结束或上下文溢出,所有交互历史与学习成果瞬间归零。这种"金鱼记忆"导致三个典型痛点:用户必须反复提供相同背景信息,Agent 无法积累任务经验,跨会话的复杂工作流无法持续演进。

从工程角度看,记忆系统不是简单的"把聊天记录存下来"。它需要在有限的 Token 预算内,精准提取、压缩、检索与更新信息,同时保证推理延迟不因记忆检索而显著增加。本文将从短期工作记忆、中期会话记忆与长期知识库三个层次,系统剖析 Agent 记忆系统的架构设计与实现方案。

二、三层记忆架构:从认知科学到工程实现

认知科学将人类记忆分为感觉记忆、短期记忆与长期记忆。Agent 记忆系统可类比设计为三层架构:

graph TB
    subgraph 三层记忆架构
        A[短期工作记忆<br/>Working Memory] -->|上下文窗口| B[中期会话记忆<br/>Session Memory]
        B -->|摘要压缩| C[长期知识库<br/>Long-term Knowledge]
        C -->|语义检索| A
    end

    subgraph 数据流
        D[用户输入] --> A
        A -->|当前推理| E[LLM 推理引擎]
        E -->|生成回复| F[用户输出]
        A -->|溢出触发| B
        B -->|定期归档| C
        C -->|相关记忆召回| A
    end

    style A fill:#e1f5fe
    style B fill:#fff3e0
    style C fill:#e8f5e9

短期工作记忆直接映射到 LLM 的上下文窗口,存储当前对话的完整 Token 序列。其容量受模型窗口限制(如 128K Token),超出部分必须溢出到中层。

中期会话记忆以结构化摘要形式存储当前会话的关键信息,包括用户意图、已执行动作、中间结果与待办事项。它通过滑动窗口 + 摘要压缩策略,在保留语义完整性的同时控制 Token 消耗。

长期知识库跨会话持久化存储,采用向量数据库实现语义检索,支持 Agent 在新会话中召回历史经验与领域知识。

三、Rust 实现三层记忆系统

3.1 核心数据结构

use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::RwLock;

/// 记忆条目:统一的数据载体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryEntry {
    pub id: String,
    pub content: String,
    pub timestamp: i64,
    pub importance: f64,       // 重要性评分 [0.0, 1.0]
    pub access_count: u32,     // 被召回次数
    pub entry_type: MemoryType,
    pub embedding: Option<Vec<f32>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MemoryType {
    UserIntent,       // 用户意图
    ActionResult,     // 动作执行结果
    Fact,             // 事实性知识
    Preference,       // 用户偏好
    Summary,          // 会话摘要
}

/// 短期工作记忆:环形缓冲区 + Token 计数
pub struct WorkingMemory {
    buffer: VecDeque<MemoryEntry>,
    max_tokens: usize,
    current_tokens: usize,
}

impl WorkingMemory {
    pub fn new(max_tokens: usize) -> Self {
        Self {
            buffer: VecDeque::new(),
            max_tokens,
            current_tokens: 0,
        }
    }

    /// 写入记忆,超出容量时返回溢出条目
    pub fn push(&mut self, entry: MemoryEntry) -> Option<Vec<MemoryEntry>> {
        let entry_tokens = Self::estimate_tokens(&entry.content);
        self.current_tokens += entry_tokens;
        self.buffer.push_back(entry);

        // 溢出处理:从最旧的条目开始淘汰
        let mut overflow = Vec::new();
        while self.current_tokens > self.max_tokens && self.buffer.len() > 1 {
            if let Some(old) = self.buffer.pop_front() {
                self.current_tokens -= Self::estimate_tokens(&old.content);
                overflow.push(old);
            }
        }
        if overflow.is_empty() { None } else { Some(overflow) }
    }

    /// 粗略估算 Token 数(中文约 1.5 字/Token)
    fn estimate_tokens(text: &str) -> usize {
        (text.len() as f64 / 1.5).ceil() as usize
    }

    /// 导出为 LLM 可消费的上下文字符串
    pub fn to_context(&self) -> String {
        self.buffer
            .iter()
            .map(|e| format!("[{}] {}", e.entry_type.variant_name(), e.content))
            .collect::<Vec<_>>()
            .join("\n")
    }
}

3.2 中期会话记忆:摘要压缩策略

/// 会话记忆管理器:滑动窗口 + 摘要压缩
pub struct SessionMemory {
    /// 最近的完整消息(滑动窗口保留最近 N 条)
    recent_messages: VecDeque<MemoryEntry>,
    window_size: usize,
    /// 压缩后的会话摘要
    summaries: Vec<MemoryEntry>,
    /// 待办事项与关键事实
    key_facts: Vec<MemoryEntry>,
}

impl SessionMemory {
    pub fn new(window_size: usize) -> Self {
        Self {
            recent_messages: VecDeque::new(),
            window_size,
            summaries: Vec::new(),
            key_facts: Vec::new(),
        }
    }

    /// 接收短期记忆的溢出条目
    pub fn absorb_overflow(&mut self, entries: Vec<MemoryEntry>) {
        for entry in entries {
            // 高重要性条目直接进入 key_facts
            if entry.importance > 0.8 {
                self.key_facts.push(entry);
                continue;
            }
            self.recent_messages.push_back(entry);
        }

        // 滑动窗口溢出时触发摘要压缩
        if self.recent_messages.len() > self.window_size {
            self.compress_oldest();
        }
    }

    /// 将最旧的一批消息压缩为摘要
    /// 实际生产中此处调用 LLM 生成摘要,此处用简化逻辑示意
    fn compress_oldest(&mut self) {
        let compress_count = self.recent_messages.len() - self.window_size / 2;
        if compress_count == 0 {
            return;
        }

        let to_compress: Vec<MemoryEntry> = self.recent_messages
            .drain(..compress_count)
            .collect();

        // 合并为摘要条目
        let combined = to_compress
            .iter()
            .map(|e| e.content.as_str())
            .collect::<Vec<_>>()
            .join(";");

        let summary = MemoryEntry {
            id: format!("summary_{}", chrono::Utc::now().timestamp_millis()),
            content: format!("[会话摘要] {}", Self::truncate_summary(&combined, 500)),
            timestamp: chrono::Utc::now().timestamp(),
            importance: 0.5,
            access_count: 0,
            entry_type: MemoryType::Summary,
            embedding: None,
        };

        self.summaries.push(summary);
    }

    fn truncate_summary(text: &str, max_len: usize) -> String {
        if text.len() <= max_len {
            text.to_string()
        } else {
            format!("{}...", &text[..max_len])
        }
    }

    /// 导出会话上下文供 LLM 使用
    pub fn to_context(&self) -> String {
        let mut parts = Vec::new();

        if !self.summaries.is_empty() {
            parts.push("## 历史摘要".to_string());
            parts.extend(
                self.summaries.iter().map(|s| s.content.clone())
            );
        }

        if !self.key_facts.is_empty() {
            parts.push("## 关键事实".to_string());
            parts.extend(
                self.key_facts.iter().map(|f| format!("- {}", f.content))
            );
        }

        if !self.recent_messages.is_empty() {
            parts.push("## 近期交互".to_string());
            parts.extend(
                self.recent_messages.iter().map(|m| m.content.clone())
            );
        }

        parts.join("\n")
    }
}

3.3 长期知识库:向量检索与持久化

use rusqlite::{Connection, params};

/// 长期知识库:SQLite 持久化 + 向量检索
pub struct LongTermKnowledge {
    db: Connection,
    // 生产环境中此处替换为 Qdrant / Milvus 等向量数据库客户端
    embedding_dim: usize,
}

impl LongTermKnowledge {
    pub fn new(db_path: &str) -> Result<Self, Box<dyn std::error::Error>> {
        let db = Connection::open(db_path)?;
        db.execute(
            "CREATE TABLE IF NOT EXISTS memories (
                id TEXT PRIMARY KEY,
                content TEXT NOT NULL,
                timestamp INTEGER NOT NULL,
                importance REAL NOT NULL,
                access_count INTEGER DEFAULT 0,
                entry_type TEXT NOT NULL,
                embedding BLOB
            )",
            [],
        )?;
        // 为 content 创建全文搜索索引
        db.execute(
            "CREATE INDEX IF NOT EXISTS idx_fts ON memories(content)",
            [],
        )?;

        Ok(Self { db, embedding_dim: 1536 })
    }

    /// 持久化记忆条目
    pub fn store(&self, entry: &MemoryEntry) -> Result<(), Box<dyn std::error::Error>> {
        let embedding_blob = entry.embedding.as_ref()
            .map(|v| serde_json::to_vec(v).unwrap());

        self.db.execute(
            "INSERT OR REPLACE INTO memories
             (id, content, timestamp, importance, access_count, entry_type, embedding)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
            params![
                entry.id,
                entry.content,
                entry.timestamp,
                entry.importance,
                entry.access_count,
                serde_json::to_string(&entry.entry_type)?,
                embedding_blob,
            ],
        )?;
        Ok(())
    }

    /// 基于关键词的粗检索(向量检索的降级方案)
    pub fn search_by_keyword(
        &self,
        keyword: &str,
        limit: usize,
    ) -> Result<Vec<MemoryEntry>, Box<dyn std::error::Error>> {
        let mut stmt = self.db.prepare(
            "SELECT id, content, timestamp, importance, access_count, entry_type
             FROM memories
             WHERE content LIKE ?1
             ORDER BY importance DESC, timestamp DESC
             LIMIT ?2"
        )?;

        let pattern = format!("%{}%", keyword);
        let entries = stmt.query_map(params![pattern, limit], |row| {
            Ok(MemoryEntry {
                id: row.get(0)?,
                content: row.get(1)?,
                timestamp: row.get(2)?,
                importance: row.get(3)?,
                access_count: row.get(4)?,
                entry_type: serde_json::from_str(&row.get::<_, String>(5)?).unwrap(),
                embedding: None,
            })
        })?.filter_map(|e| e.ok()).collect();

        Ok(entries)
    }

    /// 更新访问计数(实现记忆衰减的基础)
    pub fn touch(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {
        self.db.execute(
            "UPDATE memories SET access_count = access_count + 1 WHERE id = ?1",
            params![id],
        )?;
        Ok(())
    }
}

3.4 三层记忆协调器

/// 记忆系统协调器:统一管理三层记忆的数据流转
pub struct MemoryCoordinator {
    working: Arc<RwLock<WorkingMemory>>,
    session: Arc<RwLock<SessionMemory>>,
    long_term: Arc<LongTermKnowledge>,
}

impl MemoryCoordinator {
    pub fn new(
        working_tokens: usize,
        session_window: usize,
        db_path: &str,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        Ok(Self {
            working: Arc::new(RwLock::new(WorkingMemory::new(working_tokens))),
            session: Arc::new(RwLock::new(SessionMemory::new(session_window))),
            long_term: Arc::new(LongTermKnowledge::new(db_path)?),
        })
    }

    /// 写入新记忆
    pub async fn remember(&self, entry: MemoryEntry) -> Result<(), Box<dyn std::error::Error>> {
        let mut working = self.working.write().await;
        if let Some(overflow) = working.push(entry) {
            // 溢出条目流入中期记忆
            let mut session = self.session.write().await;
            session.absorb_overflow(overflow);
        }
        Ok(())
    }

    /// 召回相关记忆:从长期知识库检索并注入短期记忆
    pub async fn recall(
        &self,
        query: &str,
        top_k: usize,
    ) -> Result<Vec<MemoryEntry>, Box<dyn std::error::Error>> {
        let recalled = self.long_term.search_by_keyword(query, top_k)?;

        // 更新访问计数
        for entry in &recalled {
            self.long_term.touch(&entry.id)?;
        }

        // 将召回结果注入短期工作记忆
        let mut working = self.working.write().await;
        for entry in recalled.clone() {
            working.push(entry);
        }

        Ok(recalled)
    }

    /// 生成完整的上下文提示
    pub async fn build_context(&self) -> String {
        let working = self.working.read().await;
        let session = self.session.read().await;

        let mut context = String::new();
        context.push_str(&session.to_context());
        context.push_str("\n\n");
        context.push_str(&working.to_context());
        context
    }
}

四、记忆系统的架构权衡与边界条件

4.1 Token 预算与信息密度的矛盾

三层记忆架构的核心矛盾在于:Token 预算有限,但信息密度需求无限。摘要压缩不可避免地丢失细节,而保留原始信息又会导致上下文膨胀。实际测试发现,摘要压缩率在 60%-80% 时,下游任务准确率下降约 5%-12%。这意味着压缩策略必须根据任务类型动态调整——事实性查询可容忍更高压缩率,而代码生成任务需要保留更多原始细节。

4.2 检索延迟与召回质量的取舍

长期记忆的语义检索依赖向量数据库,其查询延迟通常在 10-50ms 量级。但在高并发场景下,向量检索可能成为瓶颈。降级为关键词检索虽能保证延迟,但召回质量显著下降。生产环境中需要设计多级检索策略:先关键词粗筛,再向量精排,最后用 LLM 重排序。

4.3 记忆衰减与遗忘机制

人类记忆会随时间衰减,Agent 记忆同样需要遗忘机制。简单的时间衰减(指数遗忘曲线)容易丢失重要但久远的记忆;基于访问频率的衰减则偏向近期热点。实际方案需要综合时间衰减、访问频率与重要性评分三个维度,且衰减参数需要根据业务场景调优。

4.4 一致性与并发安全

多轮对话中,记忆的读写存在竞态条件。例如用户快速连续提问时,前一轮的记忆写入可能尚未完成,后一轮的召回就会遗漏关键信息。Rust 的 RwLock 提供了编译期的并发安全保障,但在高并发下写锁争用会导致性能退化,需要考虑分片锁或无锁数据结构。

五、总结

Agent 记忆系统的三层架构——短期工作记忆、中期会话记忆与长期知识库——解决了大模型上下文窗口有限与跨会话知识持续性的矛盾。核心设计要点包括:短期记忆采用环形缓冲区管理 Token 预算,中期记忆通过滑动窗口 + 摘要压缩平衡信息密度与容量,长期记忆依赖向量数据库实现语义检索。

落地路线建议:第一阶段实现短期 + 中期双层记忆,验证摘要压缩对任务准确率的影响;第二阶段接入向量数据库实现长期记忆,重点优化检索延迟与召回质量的平衡;第三阶段引入记忆衰减与重要性评分,实现自适应遗忘机制。每阶段都需要建立量化评估基准,用任务准确率与响应延迟两个核心指标驱动迭代。

更多推荐