AI Agent 开发实战(三):记忆不是存聊天记录,搞懂这三层记忆架构才算入门

这是「AI Agent 开发实战」系列的第 3 篇。上一篇讲了 LLM 调用与 Prompt 工程,这一篇继续拆三大基石——记忆系统。很多人的 Agent 跑两轮就"失忆",或者上下文一长就 Token 爆炸,根因都是没搞懂记忆该怎么设计。

一、为什么 Agent 需要记忆

先说一个反直觉的事实:LLM 本身是没有记忆的。

你每次调用 /chat/completions,LLM 看到的只有你传过去的 messages 数组。它不记得上一次对话说了什么,不记得用户的偏好,不记得三步之前做了什么。

┌──────────────────────────────────────────────────┐
│  无记忆的 Agent                                   │
│                                                  │
│  用户:我叫张三                                   │
│  Agent:你好张三!                                │
│                                                  │
│  用户:我叫什么?                                 │
│  Agent:抱歉,我不知道你叫什么。                  │
│                                                  │
│  原因:第二次调用时 messages 里没有第一次的内容   │
└──────────────────────────────────────────────────┘

这不是 LLM “笨”,而是它根本看不到。

记忆系统的本质,就是在每次调用 LLM 之前,把相关信息塞进 messages 数组里。

听起来简单,但问题在于:

  1. 上下文窗口有限(4K ~ 200K tokens),不可能全塞进去
  2. 什么该塞、什么不该塞、什么时候塞,直接决定 Agent 的智商
  3. 不同类型的记忆,存储方式、检索方式、过期策略完全不同

二、三层记忆架构

工程上,Agent 的记忆通常分为三层:

┌──────────────────────────────────────────────────────────┐
│                    Agent 记忆架构                         │
│                                                          │
│  ┌──────────────────────────────────────────────────┐    │
│  │  第一层:短期记忆(Working Memory)                │    │
│  │  存在 LLM 的上下文窗口里                          │    │
│  │  内容:当前对话历史、当前任务的中间状态            │    │
│  │  生命周期:单次会话                               │    │
│  │  特点:LLM 直接可见,但容量有限                    │    │
│  └──────────────────────────────────────────────────┘    │
│                          ↑↓                              │
│  ┌──────────────────────────────────────────────────┐    │
│  │  第二层:长期记忆(Long-term Memory)              │    │
│  │  存在向量数据库 / 关系数据库中                    │    │
│  │  内容:用户偏好、历史决策、知识库                  │    │
│  │  生命周期:跨会话持久化                            │    │
│  │  特点:需要检索才能进入短期记忆                    │    │
│  └──────────────────────────────────────────────────┘    │
│                          ↑↓                              │
│  ┌──────────────────────────────────────────────────┐    │
│  │  第三层:工作记忆(Scratchpad / Task Memory)      │    │
│  │  存在内存 / 缓存中                                │    │
│  │  内容:当前任务的执行计划、已完成步骤、待办事项    │    │
│  │  生命周期:单次任务                               │    │
│  │  特点:结构化存储,不直接进 LLM 上下文             │    │
│  └──────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────┘

三层对比:

维度 短期记忆 长期记忆 工作记忆
存储位置 LLM 上下文窗口 向量数据库 / 关系库 内存 / Redis
存储内容 对话历史 用户画像、知识、历史决策 执行计划、步骤状态
生命周期 单次会话 永久(或手动删除) 单次任务
LLM 可见性 直接可见 需检索后注入 按需注入
容量 小(受 Token 限制)
检索方式 不需要(全量在上下文) 向量相似度 / 关键词 直接读取

三、短期记忆:上下文窗口管理

短期记忆就是 LLM 的 messages 数组。核心问题是:对话越来越长,Token 放不下怎么办?

朴素方案:全量保留

List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));

// 每轮对话都往里塞
while (running) {
    messages.add(new UserMessage(userInput));
    LlmResponse resp = llm.chat(messages, tools);
    messages.add(new AssistantMessage(resp.getContent()));
}
// 问题:第 20 轮时,messages 可能已经 50K tokens,窗口爆了

方案一:滑动窗口截断

最简单粗暴——只保留最近 N 轮对话:

public class SlidingWindowMemory {
    private final int maxMessages;  // 保留最近多少条消息

    public List<Message> manage(List<Message> messages) {
        if (messages.size() <= maxMessages) {
            return messages;
        }
        // 保留 System Prompt(第一条)+ 最近 N 条
        List<Message> result = new ArrayList<>();
        result.add(messages.get(0));  // System 一定保留
        result.addAll(messages.subList(
            messages.size() - maxMessages + 1,
            messages.size()
        ));
        return result;
    }
}

优点:简单、快、可预测。

缺点:丢掉的对话可能正好是 LLM 需要的。用户第 2 轮说了"我偏好高股息股票",到第 10 轮 Agent 就忘了。

方案二:摘要压缩

用 LLM 把旧对话总结成一段摘要,替换原始消息:

public class SummaryMemory {
    private String summary = "";
    private final int summarizeThreshold = 10;  // 超过 10 轮触发摘要

    public List<Message> manage(List<Message> messages) {
        if (messages.size() <= summarizeThreshold) {
            return messages;
        }

        // 把旧消息交给 LLM 总结
        List<Message> oldMessages = messages.subList(1, messages.size() - 5);
        String newSummary = llm.chat(List.of(
            new SystemMessage("请将以下对话总结为关键信息,保留用户偏好、重要决策和未完成的事项。"),
            new UserMessage(formatMessages(oldMessages))
        ));

        // 用摘要替换旧消息
        summary = summary + "\n" + newSummary;
        List<Message> result = new ArrayList<>();
        result.add(messages.get(0));  // System
        result.add(new SystemMessage("之前的对话摘要:\n" + summary));
        result.addAll(messages.subList(messages.size() - 5, messages.size()));  // 最近 5 轮
        return result;
    }
}

优点:保留了关键信息,Token 占用大幅降低。

缺点:摘要本身要调一次 LLM(有成本和延迟),且摘要可能丢失细节。

方案三:混合策略(推荐)

实际工程中,通常组合使用:

┌─────────────────────────────────────────────────┐
│  上下文窗口(128K)                              │
│                                                  │
│  System Prompt(固定 2K)                        │
│  ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░     │
│                                                  │
│  对话摘要(动态 1K)                             │
│  ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░     │
│                                                  │
│  最近 N 轮原文(动态 10K)                       │
│  ████████████████████░░░░░░░░░░░░░░░░░░░░░     │
│                                                  │
│  从长期记忆检索的相关信息(动态 2K)              │
│  ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░     │
│                                                  │
│  当前用户输入(0.5K)                            │
│  ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░     │
│                                                  │
│  预留输出空间(4K)                              │
└─────────────────────────────────────────────────┘

四、长期记忆:跨会话持久化

短期记忆解决的是"单次会话内"的问题。长期记忆解决的是:Agent 怎么记住跨会话的信息?

比如:

  • 用户上次说"我偏好高股息央企股"——下次会话 Agent 应该记住
  • Agent 上次帮用户分析过茅台——下次可以引用之前的结论
  • 用户问过的问题模式——Agent 可以预判需求

存储方案选型

方案 适用场景 优点 缺点
向量数据库 语义记忆(用户偏好、知识) 语义检索强 部署复杂
关系数据库 结构化记忆(用户画像、配置) 查询精确 语义检索弱
键值存储 简单事实记忆 读写快 无法语义检索
图数据库 关系型记忆(实体关联) 关联推理强 过度设计风险

工程建议:从向量数据库开始,80% 的场景够用了。

向量记忆的核心流程

写入记忆                          读取记忆
────────                          ────────
                                   
用户说了一句话        ┌──────┐    新一轮对话开始
       │             │      │         │
       ▼             │ 判断  │ ───────▶│
  提取"记忆 worthy"  │ 是否  │  是否   │
  的信息             │ 值得  │  需要   │
       │             │ 记住  │  历史   │
       ▼             │      │  记忆? │
  Embedding         └──────┘         │
  向量化                │ Yes         │ Yes
       │                ▼            ▼
       ▼          存入向量库    查询向量库
  存入向量库                     │
                                 ▼
                           Top-K 相关记忆
                                 │
                                 ▼
                           注入到 messages
                           的 System 部分

写入:什么值得记住

不是每句话都值得存。Agent 需要判断:

public class MemoryWriter {

    private final LlmClient llm;
    private final VectorStore vectorStore;

    public void maybeSave(String userMessage, String agentResponse) {
        // 用 LLM 判断这条对话是否包含值得记住的信息
        String judgment = llm.chat(List.of(
            new SystemMessage("""
                判断以下对话是否包含值得长期记忆的信息。
                值得记忆的:用户偏好、个人事实、重要决策、任务结论。
                不值得记忆的:寒暄、临时性问题、已过期信息。
                只回答 YES 或 NO。
                """),
            new UserMessage("用户: " + userMessage + "\n助手: " + agentResponse)
        ));

        if (judgment.trim().toUpperCase().startsWith("YES")) {
            // 提取结构化记忆
            String memory = llm.chat(List.of(
                new SystemMessage("将以下对话中的关键信息提取为一句简洁的记忆陈述。"),
                new UserMessage("用户: " + userMessage + "\n助手: " + agentResponse)
            ));

            // 向量化并存储
            vectorStore.add(memory, Map.of(
                "timestamp", Instant.now().toString(),
                "type", "user_preference"
            ));
        }
    }
}

读取:怎么找到相关记忆

public class MemoryRetriever {

    private final VectorStore vectorStore;
    private static final int TOP_K = 5;

    public String retrieve(String currentQuery) {
        // 1. 把当前查询向量化
        // 2. 在向量库中找 Top-K 最相似的记忆
        List<MemoryItem> memories = vectorStore.search(currentQuery, TOP_K);

        if (memories.isEmpty()) {
            return "";
        }

        // 2. 拼接成上下文
        StringBuilder sb = new StringBuilder("相关历史记忆:\n");
        for (MemoryItem m : memories) {
            sb.append("- ").append(m.getContent()).append("\n");
        }
        return sb.toString();
    }
}

一个完整的记忆增强 Agent 循环

public class MemoryEnhancedAgent {

    private final LlmClient llm;
    private final MemoryWriter memoryWriter;
    private final MemoryRetriever memoryRetriever;
    private final ContextManager contextManager;

    public String run(String userId, String userInput) {
        // 1. 从长期记忆中检索相关信息
        String relatedMemories = memoryRetriever.retrieve(userInput);

        // 2. 组装 messages
        List<Message> messages = new ArrayList<>();
        messages.add(new SystemMessage(systemPrompt));
        if (!relatedMemories.isEmpty()) {
            messages.add(new SystemMessage(relatedMemories));
        }
        messages.addAll(contextManager.manage(sessionHistory));
        messages.add(new UserMessage(userInput));

        // 3. 调用 LLM
        LlmResponse resp = llm.chat(messages, tools);

        // 4. 写入长期记忆(异步,不阻塞响应)
        memoryWriter.maybeSave(userInput, resp.getContent());

        return resp.getContent();
    }
}

五、工作记忆:任务执行的黑板

工作记忆是 Agent 在执行单个复杂任务时的"草稿纸"。

和短期记忆的区别:短期记忆是对话历史(谁说了什么),工作记忆是任务状态(做了什么、还要做什么)。

┌──────────────────────────────────────────────┐
│  工作记忆示例                                 │
│                                              │
│  任务目标:分析 600519 是否值得加仓           │
│                                              │
│  执行计划:                                   │
│  ✅ Step 1: 查询最近 30 天收盘价             │
│  ✅ Step 2: 计算 20 日均线和 MACD            │
│  ⬜ Step 3: 搜索近期公司新闻                  │
│  ⬜ Step 4: 综合分析给出建议                  │
│                                              │
│  中间结果:                                   │
│  - 30 天均价: 1685.3                         │
│  - 20 日均线: 拐头向上                       │
│  - MACD: 金叉                                │
│                                              │
│  待办:                                       │
│  - 需要确认近期是否有利空消息                 │
└──────────────────────────────────────────────┘

工作记忆的工程实现

public class Scratchpad {

    private String goal;                    // 任务目标
    private List<TaskStep> plan;            // 执行计划
    private Map<String, Object> results;    // 中间结果
    private List<String> notes;             // 备注

    // 序列化为文本,注入到 LLM 上下文
    public String toPromptText() {
        StringBuilder sb = new StringBuilder();
        sb.append("【当前任务】").append(goal).append("\n\n");
        sb.append("【执行计划】\n");
        for (TaskStep step : plan) {
            sb.append(step.isDone() ? "✅" : "⬜")
              .append(" Step ").append(step.getIndex())
              .append(": ").append(step.getDescription()).append("\n");
        }
        if (!results.isEmpty()) {
            sb.append("\n【中间结果】\n");
            results.forEach((k, v) -> sb.append("- ").append(k).append(": ").append(v).append("\n"));
        }
        if (!notes.isEmpty()) {
            sb.append("\n【备注】\n");
            notes.forEach(n -> sb.append("- ").append(n).append("\n"));
        }
        return sb.toString();
    }

    // 更新步骤状态
    public void markStepDone(int index, Object result) {
        plan.get(index).setDone(true);
        results.put(plan.get(index).getDescription(), result);
    }
}

关键点:工作记忆不是全量塞进 LLM 上下文的。聪明的做法是只注入"当前步骤 +/- 2 步"的上下文,而不是整个计划。这样既能让 LLM 知道自己在哪一步,又不浪费 Token。

六、记忆的遗忘策略

记忆不是越多越好。过期的、矛盾的、低价值的信息如果不清理,反而会让 Agent 越来越蠢。

遗忘策略 触发条件 实现
TTL 过期 超过设定时间 每条记忆带 timestamp,定期清理
重要性衰减 长时间未被检索命中 检索时降权,长期不命中则删除
矛盾覆盖 新记忆与旧记忆矛盾 用 LLM 判断,保留新的,删除旧的
容量限制 超过最大条数 按 importance score 淘汰最低的
用户显式删除 用户说"忘掉这个" 按关键词或时间范围删除
public class MemoryJanitor {

    private final VectorStore vectorStore;
    private static final int MAX_MEMORIES = 1000;
    private static final long TTL_DAYS = 90;

    // 定期清理
    public void cleanup() {
        // 1. 删除过期记忆
        long cutoff = Instant.now().minus(TTL_DAYS, ChronoUnit.DAYS).toEpochMilli();
        vectorStore.deleteByMetadata("timestamp", cutoff);

        // 2. 如果还是太多,按重要性淘汰
        long count = vectorStore.count();
        if (count > MAX_MEMORIES) {
            // 按检索命中次数排序,淘汰最少的
            List<MemoryItem> all = vectorStore.findAll();
            all.sort(Comparator.comparingInt(MemoryItem::getHitCount));
            int toDelete = (int) (count - MAX_MEMORIES);
            for (int i = 0; i < toDelete; i++) {
                vectorStore.delete(all.get(i).getId());
            }
        }
    }
}

七、三种记忆的协同流程

把三层记忆拼在一起,一次完整的 Agent 调用流程:

用户输入
    │
    ▼
┌─────────────────────────────────────────┐
│ 1. 检索长期记忆                          │
│    用用户输入查询向量库                   │
│    拿到 Top-K 相关历史记忆               │
└──────────────────┬──────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────┐
│ 2. 读取工作记忆                          │
│    当前任务计划、已完成步骤、中间结果      │
└──────────────────┬──────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────┐
│ 3. 组装上下文                            │
│    System Prompt                         │
│    + 长期记忆摘要                         │
│    + 工作记忆状态                         │
│    + 短期记忆(最近 N 轮对话)            │
│    + 当前用户输入                         │
└──────────────────┬──────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────┐
│ 4. 调用 LLM                              │
└──────────────────┬──────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────┐
│ 5. 更新记忆                              │
│    短期:追加到对话历史                   │
│    工作:更新步骤状态和中间结果            │
│    长期:异步判断是否值得持久化            │
└─────────────────────────────────────────┘

八、常见踩坑

坑 1:把所有对话原样存向量库

问题:每轮对话都 Embedding 存库,结果向量库里全是"你好""谢谢"这种垃圾数据,检索时噪声巨大。

解法:写入前先过一道 LLM 判断(见上面的 MemoryWriter),只存"记忆 worthy"的信息。

坑 2:检索 Top-K 固定不变

问题:不管什么问题都返回 Top-5,简单问题信息过多(干扰 LLM),复杂问题信息过少(不够推理)。

解法:按相似度分数动态调整。分数 > 0.9 的全要,0.7 ~ 0.9 的最多取 3 条,< 0.7 的不要。

坑 3:记忆只存不删

问题:用户三个月前说"我看好茅台",现在改主意了说"茅台太高了要减仓",但旧记忆还在,Agent 检索到两条矛盾记忆,行为混乱。

解法:写入新记忆时,用 LLM 检查是否有矛盾旧记忆,有则标记旧记忆为"已失效"或直接删除。

坑 4:工作记忆全量注入

问题:任务执行到第 20 步,把前 19 步的所有中间结果全塞进上下文,Token 爆炸。

解法:只注入"当前步骤 + 相关的中间结果"。哪些相关?按步骤间的依赖关系决定。

九、小结

记忆系统的核心要点:

  1. 三层架构:短期记忆(对话历史)+ 长期记忆(向量库)+ 工作记忆(任务黑板)
  2. 短期记忆靠管理:滑动窗口截断 + 摘要压缩 + 混合策略
  3. 长期记忆靠检索:写入要过滤(不是什么都存),读取要排序(Top-K + 分数阈值)
  4. 工作记忆靠结构化:执行计划 + 步骤状态 + 中间结果,按需注入而非全量
  5. 遗忘比记忆更重要:TTL 过期 + 重要性衰减 + 矛盾覆盖 + 容量淘汰

下一篇讲三大基石的最后一块——工具调用。Agent 怎么知道有哪些工具可用、怎么让 LLM 选对工具、怎么处理工具执行失败,都会展开讲。


这是「AI Agent 开发实战」系列第 3 篇,后续会持续更新,欢迎关注。如有错误或想法,欢迎评论区交流。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐