22 - 上下文工程与记忆管理

双层策略体系 — 运行时上下文裁剪(Context)+ 持久化记忆管理(Memory)

一、为什么需要上下文工程?

LLM 拥有有限的上下文窗口(Context Window),每次调用能接受的 token 数量有上限。当对话轮次增多,历史消息会迅速占满窗口,导致:

核心矛盾:用户希望 Agent "记住一切",但 LLM 物理上做不到。上下文工程就是在"记住尽可能多"和"不超出 token 预算"之间找到最优平衡。

二、双层策略架构

  双层策略体系 — 相互独立,各司其职

  ┌──────────────────────────────────────────────────────────────────┐
  │                     对话消息流                                   │
  │  User ⇄ [消息列表 messages]                                     │
  └───────────┬──────────────────────────────────┬──────────────────┘
              │                                  │
              ▼                                  ▼
  ┌───────────────────────┐        ┌───────────────────────┐
  │   Context 层           │        │   Memory 层            │
  │   (运行时裁剪)          │        │   (持久化管理)          │
  │                        │        │                        │
  │  作用时机: 每次 LLM 调用 │        │  作用时机: 对话结束/定期  │
  │  目标: 让当前请求不超限   │        │  目标: 管理持久化存储     │
  │  影响: 临时的,不改原数据 │        │  影响: 永久的,修改存储   │
  │                        │        │                        │
  │  接口:                  │        │  接口:                  │
  │  ContextStrategy        │        │  MemoryStrategy         │
  │  .apply(messages,cfg)   │        │  .apply(messages,cfg)   │
  └───────────────────────┘        └───────────────────────┘

  关键区别: Context 层是"拍照裁剪"(原图不变),Memory 层是"剪辑原片"(原片被修改)

三、Context 层 — 4 种策略

1. TRIM — 首尾保留裁剪

保留最早的 N 条和最新的 M 条消息,删除中间部分。适合保留对话开头的系统指令和最近的交互。

  原始消息: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]    共 10 条

  配置: keepFirst = 2, keepLast = 3

  结果:   [1, 2, ···(删除3-7)···, 8, 9, 10]     共 5 条

  ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬────┐
  │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │
  └─┬─┴─┬─┴───┴───┴───┴───┴───┴─┬─┴─┬─┴──┬─┘
    │   │     ← 删除中间 →       │   │    │
    ▼   ▼                        ▼   ▼    ▼
  ┌───┬───┐                    ┌───┬───┬────┐
  │ 1 │ 2 │                    │ 8 │ 9 │ 10 │
  └───┴───┘                    └───┴───┴────┘
   keepFirst                     keepLast

2. SUMMARIZE — LLM 摘要压缩

当消息数超过阈值时,调用 LLM 生成对话摘要,用一条摘要消息替换大量历史消息。

  SUMMARIZE 流程:

  messages.size() > threshold?
    ├── No  → 返回原消息(不处理)
    └── Yes → 进入摘要流程:
        │
        ├── 1. 截断保护: 总字符数 > MAX_CONVERSATION_CHARS(50000)?
        │   └── Yes → 截断到前 50000 字符(防止 LLM 调用本身超限)
        │
        ├── 2. 调用 LLM 生成摘要
        │   └── prompt: "请总结以下对话的关键信息..."
        │
        ├── 3. 构建结果:
        │   └── [摘要消息(SYSTEM)] + [最后几条原始消息]
        │
        └── 4. LLM 调用失败?
            └── 降级: 只保留前 3 条消息(graceful degradation)
50000 字符截断:这是一道安全护栏。假设对话已经有 200000 字符,直接发给 LLM 做摘要本身就会超限。先截断再摘要,确保摘要调用本身不会失败。

3. SLIDING_WINDOW — 滑动窗口

基于 token 预算,从最新消息开始反向累加,直到预算耗尽。

  Token 估算公式:
  estimatedTokens = chars / 2.5 + 4 (每条消息固定开销)

  示例: maxTokens = 1000

  从最新消息开始反向累加:
  ┌──────────────────────────────────────────┐
  │ msg[9]  → 120 tokens  累计: 120   ✅    │
  │ msg[8]  → 200 tokens  累计: 320   ✅    │
  │ msg[7]  → 180 tokens  累计: 500   ✅    │
  │ msg[6]  → 250 tokens  累计: 750   ✅    │
  │ msg[5]  → 300 tokens  累计: 1050  ❌    │ ← 超预算,停止
  └──────────────────────────────────────────┘

  结果: 保留 [msg[6], msg[7], msg[8], msg[9]]

4. COMPOSITE — 组合策略

Pipeline 模式,将多个策略串联执行。默认组合:SUMMARIZE → SLIDING_WINDOW。

  COMPOSITE 执行流程:

  原始消息 ──→ [Strategy A] ──→ [Strategy B] ──→ [Strategy C] ──→ 最终结果

  默认组合:
  原始消息 ──→ [SUMMARIZE] ──→ [SLIDING_WINDOW] ──→ 裁剪后消息
               先压缩数量        再控制 token 总量

  为什么这个顺序?
  1. SUMMARIZE 先将 100 条消息压缩成"1条摘要 + 5条最新"
  2. SLIDING_WINDOW 再确保总 token 不超预算
  → 两层保险,既保留语义又控制成本

策略对比总结

策略算法优点缺点适用场景
TRIM保留首N+尾M简单快速,零 LLM 调用中间消息完全丢失系统指令重要的场景
SUMMARIZELLM 生成摘要语义保留最完整额外 LLM 调用成本长对话、需要保留完整上下文
SLIDING_WINDOWToken 预算反向累加精确控制 token 用量早期消息完全丢弃token 预算严格的场景
COMPOSITE策略串联管线多层保险,灵活组合配置复杂度高生产环境默认推荐

四、Memory 层 — 4 种策略

1. TRIM — 首尾保留(持久化版)

算法与 Context 层 TRIM 相同,但操作对象是持久化存储中的消息。裁剪后的结果会被写回存储,被删除的消息无法恢复。

2. SUMMARIZE — 持久化摘要

与 Context 版类似,但生成的摘要消息带有特殊前缀标记,并更侧重提取用户偏好和长期信息。

// Memory SUMMARIZE 的摘要前缀
"[持久化记忆摘要] " + summaryContent

// 摘要 prompt 侧重点不同:
// Context: "总结对话关键信息,保留上下文连贯性"
// Memory:  "提取用户偏好、习惯、重要事实,用于长期记忆"

3. DELETE — 简单截断

最简单的策略:只保留最新的 N 条消息,其余全部删除。适合不需要历史记忆的场景。

  DELETE 策略 (keepLatest = 5):

  存储中: [msg1, msg2, msg3, msg4, msg5, msg6, msg7, msg8]

  执行后: [msg4, msg5, msg6, msg7, msg8]   ← 只保留最新 5 条
           msg1-msg3 被永久删除

4. FILTER — 自定义过滤

提供 Predicate<HistoryMessage> 过滤器,由开发者自定义哪些消息需要保留。

// 示例: 只保留用户消息和包含关键词的助手消息
Predicate<HistoryMessage> filter = msg ->
    msg.getRole() == Role.USER ||
    (msg.getRole() == Role.ASSISTANT &&
     msg.getContent().contains("重要"));

// 不满足条件的消息将被从持久化存储中永久删除

五、Context vs Memory 对比

维度Context 层Memory 层
作用时机每次 LLM 调用前对话结束 / 定期触发
目标对象本次请求的消息列表(临时副本)持久化存储中的消息(原始数据)
影响范围仅影响当前 LLM 调用永久影响后续所有调用
接口ContextStrategyMemoryStrategy
可逆性可逆 — 原始消息不受影响不可逆 — 被删除/替换的消息无法恢复
类比照片裁剪预览(原图不变)视频剪辑导出(原片被修改)

六、面试高频问题

Q: Context 和 Memory 的区别?
A: Context 是运行时临时裁剪——每次 LLM 调用前对消息列表做"快照裁剪",原始消息不受影响,下次调用时仍从完整消息开始处理。Memory 是持久化删除/替换——直接修改存储中的消息数据,被删除的消息无法恢复。Context 解决"单次调用不超限",Memory 解决"存储不无限膨胀"。
Q: 为什么需要 50000 字符截断?
A: SUMMARIZE 策略需要调用 LLM 来生成摘要,但如果对��本身已经非常长(比如 200000 字符),把所有内容发给 LLM 做摘要时,这个摘要请求本身就可能超出 token 限制。50000 字符截断是一道安全护栏:先截断到安全长度,再调用 LLM 生成摘要,确保摘要过程不会因为输入过长而失败。
Q: CompositeStrategy 的执行顺序重要吗?
A: 非常重要。默认顺序是 SUMMARIZE → SLIDING_WINDOW:先用 LLM 将大量消息压缩为精简摘要(减少消息数量),再用滑动窗口确保总 token 不超预算(控制 token 总量)。如果反过来——先滑动窗口只保留最近几条消息,再做摘要——那摘要就没有足够的上下文来生成有意义的总结。顺序决定了信息保留的质量。

七、长期记忆系统(跨会话记忆)

长期记忆 vs 短期记忆对比

前面讨论的 Context / Memory 策略都是短期记忆(单次会话内的 token 管理)。真正的"记住用户"需要长期记忆系统——跨会话持久化、语义可检索的记忆存储。

维度短期记忆(Context)长期记忆(Memory)
范围单次会话跨会话持久
存储内存 / RedisMySQL + Vector
检索方式顺序 / 窗口语义向量检索
目的Token 预算管理个性化 / 学习
触发时机每次 LLM 调用会话开始时
容量限制Token 窗口数据库无限

MemoryToolProvider 架构

长期记忆的核心是 MemoryToolProvider,它在 Agent 的 ReAct 循环中负责检索和持久化记忆。

┌──────────────────────────────────────────┐
│          AbstractLlmAgent                │
│  ┌─────────────────────────────────────┐ │
│  │   executeReactIteration()           │ │
│  │                                     │ │
│  │  1. memoryProvider.retrieveMemories │ │
│  │  2. inject into system prompt       │ │
│  │  3. call LLM                        │ │
│  │  4. extract new memories            │ │
│  │  5. memoryProvider.persistMemory    │ │
│  └─────────────────────────────────────┘ │
└──────────────────────────────────────────┘
         │                    │
         ▼                    ▼
┌─────────────────┐  ┌─────────────────┐
│ Embedding Model │  │ MySQL + Vector  │
│ (text-embed-v3) │  │ ah_long_term_   │
└─────────────────┘  │   memory        │
                     └─────────────────┘
关键点:记忆检索发生在 LLM 调用之前(注入 system prompt),记忆提取发生在 LLM 调用之后(从回复中抽取新知识)。这是一个 read-before-write 的闭环。

记忆注入格式

检索到的长期记忆以结构化 XML 标签注入到 system prompt 中,每条记忆附带相关度分数:

<long_term_memory>
以下是你对该用户的长期记忆:
1. [FACT] 用户是Java后端开发者 (相关度: 0.92)
2. [PREFERENCE] 偏好代码示例 (相关度: 0.88)
3. [PROCEDURE] Docker部署已解决 (相关度: 0.75)
</long_term_memory>
为什么用 XML 标签? LLM 对结构化标签的理解能力强于纯文本分隔符。XML 标签让 LLM 明确知道这部分是"外部注入的记忆"而非"对话历史",减少混淆。

Memory Types — 记忆类型分类

长期记忆按语义类型分为四类,不同类型的记忆有不同的生命周期和检索权重:

类型含义示例特征
FACT用户事实用户是 Java 后端开发者长期稳定,权重高
PREFERENCE偏好设置偏好代码示例而非纯文字可随时间变化
PROCEDURE操作步骤Docker 部署问题已通过修改端口解决有时效性,可能过期
EPISODE重要事件用户上周完成了系统迁移时间绑定,衰减较快

语义检索流程

当新会话开始时,系统通过语义向量检索从长期记忆库中提取与当前对话最相关的记忆:

语义检索流程(会话开始时触发):

用户消息                                         注入 Prompt
   │                                                 ▲
   ▼                                                 │
┌──────────┐    ┌──────────┐    ┌──────────────┐    ┌─────────┐
│ Embedding │───▶│ 向量查询  │───▶│ Cosine 相似度 │───▶│ Top-K   │
│ 模型      │    │ (ANN)    │    │ 排序          │    │ 结果集  │
└──────────┘    └──────────┘    └──────────────┘    └─────────┘

Embedding: 将用户消息转为 768/1536 维向量
ANN:       近似最近邻搜索(HNSW 索引)
Cosine:    计算向量夹角余弦值 ∈ [-1, 1]
Top-K:     取相关度最高的 K 条记忆(默认 K=10)

八、ContextEditingInterceptor 集成

ContextEditingInterceptor 是拦截器链中 order=30 的拦截器,它将 ContextEngineeringService 以 Pipeline Interceptor 的形式集成到请求处理流程中。

拦截器链中的位置 (beforeModel 阶段):

  Request ──→ [order=10 拦截器] ──→ [order=20 拦截器]
                                         │
                                         ▼
                              ┌──────────────────────────┐
                              │ ContextEditingInterceptor │
                              │       (order=30)          │
                              │                          │
                              │ 1. 获取当前 messages      │
                              │ 2. 调用 ContextEngineering│
                              │    Service.apply()        │
                              │ 3. 替换为裁剪后的消息     │
                              └──────────────────────────┘
                                         │
                                         ▼
                              [order=40+ 拦截器] ──→ LLM
设计意图:将上下文裁剪逻辑从 Agent 代码中解耦,作为通用拦截器自动生效。任何经过拦截器链的 LLM 调用都会自动享受上下文管理,无需 Agent 开发者手动处理。

九、SummarizationHook 自动压缩

SummarizationHook 是注册在 BEFORE_LLM_CALL 阶段的 Hook(order=50),当检测到当前上下文的 token 数超过 80000 时自动触发压缩。

SummarizationHook 触发流程 (ReAct 循环中):

  ┌─────────────────────────────────────────────────┐
  │  ReAct Loop: iteration N                        │
  │                                                 │
  │  1. 准备 messages                               │
  │  2. 触发 BEFORE_LLM_CALL hooks                  │
  │     │                                           │
  │     ├── [order=30] ContextEditingInterceptor     │
  │     │   └── 常规裁剪                             │
  │     │                                           │
  │     ├── [order=50] SummarizationHook            │
  │     │   └── estimateTokens(messages) > 80000?   │
  │     │       ├── No  → pass through              │
  │     │       └── Yes → 触发紧急压缩:             │
  │     │           ├── chatModel != null?           │
  │     │           │   ├── Yes → LLM 摘要压缩      │
  │     │           │   └── No  → 硬截断降级        │
  │     │           └── 替换 messages                │
  │     │                                           │
  │  3. 调用 LLM                                    │
  └─────────────────────────────────────────────────┘
与 ContextEditingInterceptor 的关系:ContextEditing(order=30)先执行常规策略裁剪,SummarizationHook(order=50)后执行兜底压缩。两者是互补关系而非冲突——Hook 作为最后一道安全网,确保即使常规策略裁剪后仍然超限,也能通过紧急摘要压缩到安全范围内。

十、面试高频问题(进阶篇)

Q: 向量检索与关键词检索的混合策略如何实现?各自优劣?
A: 向量检索处理语义相似(同义词、近义表达),关键词检索处理精确匹配(专有名词、代码标识符)。混合策略实现:两路召回 + RRF(Reciprocal Rank Fusion)融合排序——分别获取两路结果的排名,按 1/(k+rank) 加权求和后重新排序。向量优势:语义理解能力强;劣势:无法精确匹配专有词。关键词优势:精确可控;劣势:无法处理同义词和语义变体。
Q: 记忆衰减如何实现?长期不访问的记忆应该如何处理?
A: 通过 access_count + last_accessed_at 字段追踪记忆活跃度。衰减策略:relevance_score *= decay_factor^(days_since_access)。清理策略:定期 Job 删除 score < threshold 且 access_count < N 的记忆。不做物理删除:标记为 archived 允许手动恢复,防止误删重要记忆。
Q: Token 预算分配算法如何在系统提示、记忆、历史、工具描述之间平衡?
A: 按优先级分配:system_prompt(固定) > tools(固定) > long_term_memory(max 20%) > recent_history(剩余 70%) > old_history(压缩)。超出预算时从低优先级开始裁剪。固定部分不可裁剪(裁掉系统指令 Agent 就失去角色定义),弹性部分按比例缩减。
Q: 如果摘要生成的质量很差(LLM 幻觉),如何兜底?
A: SummarizationHook 的降级策略:1. chatModel 为 null → 直接截断;2. LLM 调用失败 → 保留首尾各 1000 字符 + 中间省略标记;3. 生成后检查摘要长度是否合理(不应比原文更长)。不做语义验证——用另一个 LLM 验证摘要质量成本太高,不划算。
Q: ContextEditingInterceptor 与 SummarizationHook 会冲突吗?
A: 不会。执行顺序不同:ContextEditing(order=30, beforeModel 阶段)先执行SummarizationHook(BEFORE_LLM_CALL Hook, order=50)后执行。Hook 可能进一步压缩拦截器已处理的上下文,两者是互补关系——前者做常规裁剪,后者做超限兜底。
Q: 长期记忆的 embedding 如何做增量更新?全量重算太昂贵怎么办?
A: 新增记忆时实时计算 embedding(只算一次),存入数据库。已有记忆不重算。Embedding 模型升级时:异步批量 Job 逐步重算,期间新旧 embedding 混用不影响召回(只轻微影响排序精度)。核心原则:写时计算,读时不算
Q: 在分布式环境下,长期记忆如何保证一致性?
A: MySQL 作为单一写入源(不存在分布式写冲突)。读取:允许最终一致(缓存 TTL 5min)。并发写同一 user_id:乐观锁 + 唯一约束去重。设计上避免了分布式一致性的复杂性——记忆数据对实时性要求不高,最终一致足够。
Q: SLIDING_WINDOW 策略的窗口大小如何动态调整?
A: 当前实现为静态配置(固定 max-tokens),per-agent 可覆盖。动态调整方案:根据 Agent 类型(研究 Agent 大窗口、聊天 Agent 小窗口);根据用户历史对话模式(长对话趋势 → 增大窗口)。静态配置的优势是行为可预测、易调试,动态调整引入的复杂度通常不值得。

🧠 长期记忆系统 — 项目实战

系统概述

长期记忆系统让 Agent 能够跨会话持久化用户的偏好、关键事实和重要决策。核心理念:对话不应该是"从零开始"的——Agent 应当像优秀的人类助手一样,记住你是谁、你喜欢什么、你上次说了什么。

与前面的 Context / Memory 策略的关系:Context 和 Memory 是短期记忆(单次会话内的 token 管理)。长期记忆是跨会话的知识提炼,存储的不是原始对话消息,而是 LLM 提取的关键信息条目。

整体架构

  长期记忆系统架构:

  ┌──────────┐                    ┌───────────────────────────┐
  │ 用户消息  │ ──→ AbstractLlmAgent                          │
  └──────────┘      │                                         │
                    ├── 对话前: LongTermMemoryService          │
                    │          .loadMemories()                │
                    │          → 从 Store 加载记忆             │
                    │          → 注入 System Prompt            │
                    │                                         │
                    ├── 对话中: MemoryToolProvider (ReAct工具)  │
                    │          memory_save / memory_search    │
                    │          → Agent 主动读写记忆            │
                    │                                         │
                    └── 对话后: LongTermMemoryService          │
                               .extractAndSaveMemories()     │
                               → @Async 异步提取新记忆        │
                               → 调用 LLM 提取关键信息        │
                               → 存入 Store                  │
                    ┌─────────────────────────────────────────┘
                    ▼
           ┌──────────────────┐
           │ MysqlDatabaseStore │ ← 自研 MySQL 兼容实现
           │ (ah_long_term_memory) │   替代原生 H2 专有语法
           └──────────────────┘

双通道记忆机制

维度通道 A — 自动提取(被动)通道 B — 工具调用(主动)
触发方式每轮对话后自动触发Agent 在 ReAct 循环中主动调用
执行时机@Async 异步,不阻塞用户同步执行,工具调用结果返回
来源标识source: "auto_extract"source: "agent_tool"
去重无(每次新建条目)有(子串匹配去重)
启用配置auto-extract: truememory-tools: true
适用场景全量覆盖,不遗漏精准记忆,实时判断

命名空间与数据隔离

命名空间是一个 JSON 数组三级结构,实现 Agent + 用户维度的记忆隔离:

["agent:{namespace}", "user:{userId}", "memories"]
     ↑                     ↑               ↑
   Agent 记忆空间      用户唯一标识      固定后缀

// 示例:通用聊天助手的用户42的记忆
["agent:chat", "user:42", "memories"]

// value_json 结构
{
  "content": "用户是后端Java开发者,主要使用Spring Boot",
  "agentId": "chat",
  "source": "auto_extract"
}

安全设计_userId_namespaceAbstractLlmAgentSecurityContext 注入,LLM 无需感知也无法伪造,防止跨用户/跨 Agent 记忆泄露。

前置过滤 — 避免浪费 LLM 调用

shouldSkipExtraction() 方法在调用 LLM 提取前进行多层过滤:

设计原则:宁可漏提取,不可浪费 LLM 调用。对低价值对话过滤后,只有真正有信息量的对话才触发 LLM 提取。

三层记忆体系对比

维度上下文工程 (Context)记忆管理 (Memory)长期记忆 (Long-term)
作用域单次 LLM 调用单个会话跨所有会话
存储位置内存MySQL chat_messageMySQL ah_long_term_memory
数据粒度原始对话消息原始对话消息提炼后的知识条目
操作方式裁剪/压缩消息列表删除/摘要旧消息LLM 提取 + 主动工具调用
主要目的不超 Token 限制控制数据库存储量用户个性化
核心类ContextEngineeringServiceMemoryCleanupServiceLongTermMemoryService

核心配置

agent:
  defaults:
    long-term-memory:
      enabled: false          # 全局默认关闭(按 Agent 单独开启)
      namespace: default      # 记忆命名空间前缀
      max-items: 100          # 单用户最大记忆条数
      auto-extract: true      # 对话后自动提取记忆
      auto-load: true         # 对话前自动加载记忆
      load-max-items: 10      # 加载时最大检索条数
    memory-tools: false       # 全局默认关闭 Agent 记忆工具

  agents:
    chat:                     # 通用聊天助手 — 开启长期记忆
      memory-tools: true
      long-term-memory:
        enabled: true
        namespace: chat       # 独立记忆命名空间
        max-items: 200
Q: 长期记忆系统的双通道机制有什么优势?
A: 两个通道互补解决不同问题。自动提取通道:每轮对话后 @Async 异步调用 LLM 提取关键信息(偏好/事实/决策),不遗漏、不阻塞用户,但去重能力弱。工具调用通道:Agent 在 ReAct 循环中通过 memory_save/memory_search 主动读写记忆,支持子串匹配去重,适合精准场景。安全方面,_userId/_namespace 由框架从 SecurityContext 注入,LLM 无法伪造。存储层使用自研的 MysqlDatabaseStore(替代原生 H2 实现),通过 ReadWriteLock 保证并发安全,INSERT ... ON DUPLICATE KEY UPDATE 实现 upsert 语义。
Q: 为什么需要 MysqlDatabaseStore 替代原生 DatabaseStore?
A: Spring AI Alibaba 原生的 DatabaseStore 使用 id TEXT PRIMARY KEY(MySQL 不支持 TEXT 作主键)和 MERGE INTO(H2 专有语法)。MysqlDatabaseStore 改为 VARCHAR(255) 主键和 INSERT ... ON DUPLICATE KEY UPDATE,实现 MySQL 兼容。这是适配生产级数据库的典型案例。