双层策略体系 — 运行时上下文裁剪(Context)+ 持久化记忆管理(Memory)
LLM 拥有有限的上下文窗口(Context Window),每次调用能接受的 token 数量有上限。当对话轮次增多,历史消息会迅速占满窗口,导致:
双层策略体系 — 相互独立,各司其职
┌──────────────────────────────────────────────────────────────────┐
│ 对话消息流 │
│ User ⇄ [消息列表 messages] │
└───────────┬──────────────────────────────────┬──────────────────┘
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Context 层 │ │ Memory 层 │
│ (运行时裁剪) │ │ (持久化管理) │
│ │ │ │
│ 作用时机: 每次 LLM 调用 │ │ 作用时机: 对话结束/定期 │
│ 目标: 让当前请求不超限 │ │ 目标: 管理持久化存储 │
│ 影响: 临时的,不改原数据 │ │ 影响: 永久的,修改存储 │
│ │ │ │
│ 接口: │ │ 接口: │
│ ContextStrategy │ │ MemoryStrategy │
│ .apply(messages,cfg) │ │ .apply(messages,cfg) │
└───────────────────────┘ └───────────────────────┘
关键区别: Context 层是"拍照裁剪"(原图不变),Memory 层是"剪辑原片"(原片被修改)
保留最早的 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
当消息数超过阈值时,调用 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)
基于 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]]
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 调用 | 中间消息完全丢失 | 系统指令重要的场景 |
| SUMMARIZE | LLM 生成摘要 | 语义保留最完整 | 额外 LLM 调用成本 | 长对话、需要保留完整上下文 |
| SLIDING_WINDOW | Token 预算反向累加 | 精确控制 token 用量 | 早期消息完全丢弃 | token 预算严格的场景 |
| COMPOSITE | 策略串联管线 | 多层保险,灵活组合 | 配置复杂度高 | 生产环境默认推荐 |
算法与 Context 层 TRIM 相同,但操作对象是持久化存储中的消息。裁剪后的结果会被写回存储,被删除的消息无法恢复。
与 Context 版类似,但生成的摘要消息带有特殊前缀标记,并更侧重提取用户偏好和长期信息。
// Memory SUMMARIZE 的摘要前缀 "[持久化记忆摘要] " + summaryContent // 摘要 prompt 侧重点不同: // Context: "总结对话关键信息,保留上下文连贯性" // Memory: "提取用户偏好、习惯、重要事实,用于长期记忆"
最简单的策略:只保留最新的 N 条消息,其余全部删除。适合不需要历史记忆的场景。
DELETE 策略 (keepLatest = 5):
存储中: [msg1, msg2, msg3, msg4, msg5, msg6, msg7, msg8]
执行后: [msg4, msg5, msg6, msg7, msg8] ← 只保留最新 5 条
msg1-msg3 被永久删除
提供 Predicate<HistoryMessage> 过滤器,由开发者自定义哪些消息需要保留。
// 示例: 只保留用户消息和包含关键词的助手消息
Predicate<HistoryMessage> filter = msg ->
msg.getRole() == Role.USER ||
(msg.getRole() == Role.ASSISTANT &&
msg.getContent().contains("重要"));
// 不满足条件的消息将被从持久化存储中永久删除
| 维度 | Context 层 | Memory 层 |
|---|---|---|
| 作用时机 | 每次 LLM 调用前 | 对话结束 / 定期触发 |
| 目标对象 | 本次请求的消息列表(临时副本) | 持久化存储中的消息(原始数据) |
| 影响范围 | 仅影响当前 LLM 调用 | 永久影响后续所有调用 |
| 接口 | ContextStrategy | MemoryStrategy |
| 可逆性 | 可逆 — 原始消息不受影响 | 不可逆 — 被删除/替换的消息无法恢复 |
| 类比 | 照片裁剪预览(原图不变) | 视频剪辑导出(原片被修改) |
前面讨论的 Context / Memory 策略都是短期记忆(单次会话内的 token 管理)。真正的"记住用户"需要长期记忆系统——跨会话持久化、语义可检索的记忆存储。
| 维度 | 短期记忆(Context) | 长期记忆(Memory) |
|---|---|---|
| 范围 | 单次会话 | 跨会话持久 |
| 存储 | 内存 / Redis | MySQL + Vector |
| 检索方式 | 顺序 / 窗口 | 语义向量检索 |
| 目的 | Token 预算管理 | 个性化 / 学习 |
| 触发时机 | 每次 LLM 调用 | 会话开始时 |
| 容量限制 | Token 窗口 | 数据库无限 |
长期记忆的核心是 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 │
└─────────────────┘
检索到的长期记忆以结构化 XML 标签注入到 system prompt 中,每条记忆附带相关度分数:
<long_term_memory> 以下是你对该用户的长期记忆: 1. [FACT] 用户是Java后端开发者 (相关度: 0.92) 2. [PREFERENCE] 偏好代码示例 (相关度: 0.88) 3. [PROCEDURE] Docker部署已解决 (相关度: 0.75) </long_term_memory>
长期记忆按语义类型分为四类,不同类型的记忆有不同的生命周期和检索权重:
| 类型 | 含义 | 示例 | 特征 |
|---|---|---|---|
| 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 是拦截器链中 order=30 的拦截器,它将 ContextEngineeringService 以 Pipeline Interceptor 的形式集成到请求处理流程中。
拦截器链中的位置 (beforeModel 阶段):
Request ──→ [order=10 拦截器] ──→ [order=20 拦截器]
│
▼
┌──────────────────────────┐
│ ContextEditingInterceptor │
│ (order=30) │
│ │
│ 1. 获取当前 messages │
│ 2. 调用 ContextEngineering│
│ Service.apply() │
│ 3. 替换为裁剪后的消息 │
└──────────────────────────┘
│
▼
[order=40+ 拦截器] ──→ LLM
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 │ └─────────────────────────────────────────────────┘
access_count + last_accessed_at 字段追踪记忆活跃度。衰减策略:relevance_score *= decay_factor^(days_since_access)。清理策略:定期 Job 删除 score < threshold 且 access_count < N 的记忆。不做物理删除:标记为 archived 允许手动恢复,防止误删重要记忆。
chatModel 为 null → 直接截断;2. LLM 调用失败 → 保留首尾各 1000 字符 + 中间省略标记;3. 生成后检查摘要长度是否合理(不应比原文更长)。不做语义验证——用另一个 LLM 验证摘要质量成本太高,不划算。
长期记忆系统让 Agent 能够跨会话持久化用户的偏好、关键事实和重要决策。核心理念:对话不应该是"从零开始"的——Agent 应当像优秀的人类助手一样,记住你是谁、你喜欢什么、你上次说了什么。
长期记忆系统架构:
┌──────────┐ ┌───────────────────────────┐
│ 用户消息 │ ──→ 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: true | memory-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 和 _namespace 由 AbstractLlmAgent 从 SecurityContext 注入,LLM 无需感知也无法伪造,防止跨用户/跨 Agent 记忆泄露。
shouldSkipExtraction() 方法在调用 LLM 提取前进行多层过滤:
| 维度 | 上下文工程 (Context) | 记忆管理 (Memory) | 长期记忆 (Long-term) |
|---|---|---|---|
| 作用域 | 单次 LLM 调用 | 单个会话 | 跨所有会话 |
| 存储位置 | 内存 | MySQL chat_message | MySQL ah_long_term_memory |
| 数据粒度 | 原始对话消息 | 原始对话消息 | 提炼后的知识条目 |
| 操作方式 | 裁剪/压缩消息列表 | 删除/摘要旧消息 | LLM 提取 + 主动工具调用 |
| 主要目的 | 不超 Token 限制 | 控制数据库存储量 | 用户个性化 |
| 核心类 | ContextEngineeringService | MemoryCleanupService | LongTermMemoryService |
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
memory_save/memory_search 主动读写记忆,支持子串匹配去重,适合精准场景。安全方面,_userId/_namespace 由框架从 SecurityContext 注入,LLM 无法伪造。存储层使用自研的 MysqlDatabaseStore(替代原生 H2 实现),通过 ReadWriteLock 保证并发安全,INSERT ... ON DUPLICATE KEY UPDATE 实现 upsert 语义。
DatabaseStore 使用 id TEXT PRIMARY KEY(MySQL 不支持 TEXT 作主键)和 MERGE INTO(H2 专有语法)。MysqlDatabaseStore 改为 VARCHAR(255) 主键和 INSERT ... ON DUPLICATE KEY UPDATE,实现 MySQL 兼容。这是适配生产级数据库的典型案例。