diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 14d4faa4..ab186d3d 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -313,3 +313,38 @@ vector-store: collectionname: LocalKnowledge api-key: use-tls: false + +--- # 聊天内存配置 +chat: + memory: + # 是否启用长期记忆 + enabled: true + + # ========== 内存管理策略 ========== + # message: 固定消息数量(使用 LangChain4j 原生 MessageWindowChatMemory,不支持 Token 管理和摘要) + # token: 基于 Token 数量(仅截断,不摘要,Token 超限时直接截断旧消息) + # hybrid: 混合策略(摘要 + 截断,Token 达到阈值时先摘要压缩,超限时再截断) + strategy: message + + # ========== 通用配置(所有策略生效)========== + # 预留给回复的 Token 数 + reserved-for-reply: 2000 + # 是否保护系统消息不被截断 + preserve-system-messages: true + + # ========== message 策略配置 ========== + # 仅当 strategy=message 时生效,控制保留的消息数量 + max-messages: 20 + + # ========== token/hybrid 策略配置 ========== + # 最大 Token 数(null 则根据模型自动获取) + max-tokens: null + + # ========== hybrid 策略专属配置 ========== + # 以下配置仅当 strategy=hybrid 时生效,token 策略不支持摘要 + # 触发摘要的 Token 使用比例(0.7 = 70%) + summarize-token-ratio: 0.7 + # 触发摘要的最小消息数 + summarize-threshold: 10 + # 摘要模型策略: current(当前模型) / smart(智能映射) / custom(自定义) + summarizer-strategy: current diff --git "a/ruoyi-modules/ruoyi-chat/docs/\350\201\212\345\244\251\344\270\212\344\270\213\346\226\207\347\256\241\347\220\206\345\256\236\347\216\260\346\226\207\346\241\243.md" "b/ruoyi-modules/ruoyi-chat/docs/\350\201\212\345\244\251\344\270\212\344\270\213\346\226\207\347\256\241\347\220\206\345\256\236\347\216\260\346\226\207\346\241\243.md" new file mode 100644 index 00000000..18dbf939 --- /dev/null +++ "b/ruoyi-modules/ruoyi-chat/docs/\350\201\212\345\244\251\344\270\212\344\270\213\346\226\207\347\256\241\347\220\206\345\256\236\347\216\260\346\226\207\346\241\243.md" @@ -0,0 +1,843 @@ +# 聊天上下文管理实现文档 + +## 概述 +基于 LangChain4j 的 ChatMemory 机制,实现了一套智能的聊天上下文管理系统。该系统支持三种顶层策略和两种压缩策略,可根据不同场景灵活选择。 + +**三种顶层策略**: + +| 策略 | 功能 | 适用场景 | +|------|------|----------| +| `message` | 固定消息数量(原生滑动窗口) | 简单对话、固定轮次 | +| `token` | Token 超限时截断旧消息 | 通用场景,成本敏感 | +| `hybrid` | Token 达到阈值时摘要压缩,超限时截断 | 长对话,保留语义 | + +**两种压缩策略**(仅 token/hybrid 使用): + +| 策略 | 功能 | 触发条件 | +|------|------|----------| +| `SummarizationStrategy` | 摘要压缩 | Token 达到比例阈值(仅 hybrid) | +| `TruncationStrategy` | Token 截断 | Token 超限 | + +## 架构设计 + +### 1. 整体框架 +``` +用户消息 → ChatServiceFacade → ChatMemoryFactory + │ + ▼ + ┌─────────────────────────┐ + │ strategy 配置判断 │ + └───────────┬─────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ + strategy=message strategy=token strategy=hybrid + │ │ │ + ▼ ▼ ▼ + MessageWindowChatMemory TokenBasedChatMemory TokenBasedChatMemory + (LangChain4j原生) (仅截断) (摘要+截断) + │ │ │ + ▼ └───────────┬───────────┘ + 固定消息数量 │ + 不走策略框架 ▼ + PersistentChatMemoryStore + │ + ▼ + 数据库查询历史消息 + │ + ▼ + Token 计数检查 + │ + ┌─────────┴─────────┐ + ▼ ▼ + Token 未超限 Token 超限 + │ │ + ▼ ▼ + 直接返回消息 CompressionStrategyManager + │ + ┌─────────┴─────────┐ + │ │ + hybrid: 摘要 → 截断 token: 仅截断 +``` + +### 2. 职责划分 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ChatServiceFacade │ +│ 职责:路由协调、上下文构建、响应处理 │ +│ 方法: │ +│ - createChatMemory(memoryId, model) → 委托给 ChatMemoryFactory │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ChatMemoryFactory │ +│ 职责: │ +│ - 创建 ChatMemory 实例 │ +│ - 自动创建摘要模型(根据配置策略) │ +│ - 智能映射轻量级摘要模型 │ +│ - 处理未知模型回退逻辑 │ +│ 方法: │ +│ - create(memoryId, model) → 自动创建摘要模型 │ +│ - create(memoryId, model, summarizer) → 手动指定摘要模型 │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2. 策略模式架构 + +``` + ┌──────────────────────────┐ + │ MemoryCompressionStrategy│ (接口) + └────────────┬─────────────┘ + │ + ┌────────────────┴────────────────┐ + │ │ + ▼ ▼ + ┌───────────────┐ ┌─────────────────┐ + │Summarization │ │ Truncation │ + │ Strategy │ │ Strategy │ + ├───────────────┤ ├─────────────────┤ + │优先级: 50 │ │优先级: 100 │ + │可组合: true │ │可组合: false │ + │触发: 达到比例 │ │触发: Token超限 │ + └───────────────┘ └─────────────────┘ +``` + +### 3. 核心组件 + +#### A. ModelTokenLimits (模型 Token 限制映射) +- **文件**: `org.ruoyi.service.chat.impl.memory.ModelTokenLimits` +- **职责**: 维护 100+ 主流 AI 模型的 Token 限制信息 +- **支持模型**: + - OpenAI: GPT-4.1, GPT-4o, o1/o3/o4-mini 系列 + - DeepSeek: V3, R1 系列 + - 智谱: GLM-5, GLM-4.5, GLM-5.1 系列 + - 通义千问: Qwen3, Qwen-max/plus 系列 + - Claude: Claude 4, Claude 3.5/3.7 系列 + - Google: Gemini 2.5 Pro/Flash 系列 + - 字节豆包: Doubao 1.5 系列 + - xAI: Grok 3 系列 + - Ollama 本地模型: llama3, mistral, qwen2 等 + +```java +// 获取模型的 Token 限制 +int limit = ModelTokenLimits.getLimit("gpt-4o"); // 返回 128000 + +// 获取输入 Token 上限(预留回复空间) +int inputLimit = ModelTokenLimits.getInputLimit("gpt-4o", 2000); // 返回 126000 + +// 检查模型是否已知 +boolean known = ModelTokenLimits.isKnownModel("gpt-4o"); // 返回 true +``` + +#### B. TokenCounter (Token 计数器) +- **文件**: `org.ruoyi.service.chat.impl.memory.TokenCounter` +- **职责**: 估算文本和消息的 Token 数量 +- **估算规则**: + - 中文: 约 2 字符 = 1 token + - 英文: 约 4 字符 = 1 token + - 每条消息固定开销: 4 tokens(格式标记) + - 对话总开销: 3 tokens + +```java +TokenCounter counter = new TokenCounter(); + +// 计算文本 Token 数 +int tokens = counter.countTokens("你好世界 Hello World"); + +// 计算消息列表 Token 数 +int total = counter.countMessages(messages); + +// 估算指定 Token 预算下可容纳的消息数量 +int maxMsgs = counter.estimateMaxMessages(8000, 50); +``` + +#### C. TokenBasedChatMemory (Token 窗口内存) +- **文件**: `org.ruoyi.service.chat.impl.memory.TokenBasedChatMemory` +- **职责**: 核心内存管理实现,支持策略框架 + +```java +// token 策略(仅截断) +TokenBasedChatMemory memory = TokenBasedChatMemory.builder() + .memoryId(sessionId) + .maxTokens(128000) // 最大 Token 数 + .tokenCounter(tokenCounter) // Token 计数器 + .store(persistentStore) // 持久化存储 + .summarizeTokenRatio(1.0) // 设为 1.0,永不触发摘要 + .summarizer(null) // 不使用摘要模型 + .preserveSystemMessages(true) // 保护系统消息 + .reservedForReply(2000) // 预留回复空间 + .strategyManager(strategyManager) // 策略管理器 + .build(); + +// hybrid 策略(摘要 + 截断) +TokenBasedChatMemory memory = TokenBasedChatMemory.builder() + .memoryId(sessionId) + .maxTokens(128000) + .tokenCounter(tokenCounter) + .store(persistentStore) + .summarizeTokenRatio(0.7) // 70% 时触发摘要 + .summarizeThreshold(10) // 最少 10 条消息才摘要 + .summarizer(llmModel) // 摘要模型 + .preserveSystemMessages(true) + .reservedForReply(2000) + .strategyManager(strategyManager) + .build(); +``` + +#### D. CompressionStrategyManager (策略管理器) +- **文件**: `org.ruoyi.service.chat.impl.memory.strategy.CompressionStrategyManager` +- **职责**: 管理多个压缩策略,按优先级执行 + +```java +// 执行压缩(按优先级依次尝试策略) +CompressionResult result = strategyManager.execute(context); + +// 获取可用策略列表 +List strategies = strategyManager.getAvailableStrategies(); +``` + +#### E. MemoryCompressionStrategy (压缩策略接口) +- **文件**: `org.ruoyi.service.chat.impl.memory.strategy.MemoryCompressionStrategy` +- **职责**: 定义压缩策略的抽象行为 + +```java +public interface MemoryCompressionStrategy { + String getName(); // 策略名称 + boolean needsCompression(CompressionContext context); // 是否需要压缩 + CompressionResult compress(CompressionContext context); // 执行压缩 + default int getPriority() { return 100; } // 优先级 + default boolean isComposable() { return false; } // 是否可组合 +} +``` + +#### F. ChatMemoryFactory (内存工厂) +- **文件**: `org.ruoyi.service.chat.impl.memory.ChatMemoryFactory` +- **职责**: + - 根据配置和模型创建合适的 ChatMemory 实例 + - 自动创建摘要模型(根据配置策略) + - 智能映射轻量级摘要模型 + - API 地址修正(智谱 GLM 等需要特殊路径) + +```java +// 创建内存(自动创建摘要模型) +ChatMemory memory = chatMemoryFactory.create(sessionId, chatModelVo); + +// 创建内存(手动指定摘要模型) +ChatMemory memory = chatMemoryFactory.create(sessionId, chatModelVo, summarizerModel); + +// 获取模型 Token 限制 +int limit = chatMemoryFactory.getModelTokenLimit("gpt-4o"); +``` + +**摘要模型策略**(由 ChatMemoryFactory 内部处理): +- `current`: 使用当前对话模型(默认) +- `smart`: 智能映射到轻量级模型 +- `custom`: 使用自定义模型 + +**API 地址修正**: +- 智谱 GLM 系列:添加 `/api/paas/v4/` 路径 +- 千问 Qwen 系列:添加 `/compatible-mode/v1/` 路径 +- 摘要模型创建时自动处理此修正 + +### 4. 配置体系 + +#### ChatMemoryProperties +配置文件前缀:`chat.memory` +```yaml +chat: + memory: + # 是否启用长期记忆 + enabled: true + + # ========== 内存管理策略 ========== + # message: 固定消息数量(使用 LangChain4j 原生 MessageWindowChatMemory,不支持 Token 管理和摘要) + # token: 基于 Token 数量(仅截断,不摘要,Token 超限时直接截断旧消息) + # hybrid: 混合策略(摘要 + 截断,Token 达到阈值时先摘要压缩,超限时再截断) + strategy: message + + # ========== 通用配置(所有策略生效)========== + # 预留给回复的 Token 数 + reserved-for-reply: 2000 + # 是否保护系统消息不被截断 + preserve-system-messages: true + + # ========== message 策略配置 ========== + # 仅当 strategy=message 时生效,控制保留的消息数量 + max-messages: 20 + + # ========== token/hybrid 策略配置 ========== + # 最大 Token 数(null 则根据模型自动获取) + max-tokens: null + + # ========== hybrid 策略专属配置 ========== + # 以下配置仅当 strategy=hybrid 时生效,token 策略不支持摘要 + # 触发摘要的 Token 使用比例(0.7 = 70%) + summarize-token-ratio: 0.7 + # 触发摘要的最小消息数 + summarize-threshold: 10 + # 摘要模型策略: current(当前模型) / smart(智能映射) / custom(自定义) + summarizer-strategy: current +``` + +#### 三种顶层策略对比 + +| strategy | 创建的实例 | 压缩策略框架 | Token 管理 | 摘要功能 | 适用场景 | +|----------|-----------|-------------|-----------|---------|---------| +| `message` | MessageWindowChatMemory | ❌ 不使用 | ❌ 不考虑 | ❌ 不支持 | 简单对话、固定轮次 | +| `token` | TokenBasedChatMemory | ✅ 使用 | ✅ 根据模型自动 | ❌ 仅截断 | 通用场景,成本敏感 | +| `hybrid` | TokenBasedChatMemory | ✅ 使用 | ✅ 根据模型自动 | ✅ 摘要+截断 | 长对话、需要保留语义 | + +> **注意**: +> - `message` 策略使用 LangChain4j 原生的 `MessageWindowChatMemory`,不走自定义的策略框架 +> - `token` 策略仅使用截断策略,Token 超限时直接丢弃旧消息,不调用 LLM 摘要 +> - `hybrid` 策略使用摘要+截断,Token 达到阈值时先摘要压缩保留语义,超限时再截断兜底 + +## 策略触发机制 + +### 两种策略对比 + +| 策略 | 优先级 | 触发条件 | 截断依据 | 系统消息 | +|------|--------|----------|----------|----------| +| **摘要策略** | 50 (最高) | Token 达到比例阈值 | Token(摘要压缩) | 保留 | +| **截断策略** | 100 (默认) | Token 超限 | Token(精确截断) | 保留 | + +> **注意**: 如需固定消息数量的滑动窗口,请使用 `strategy=message` 配置 LangChain4j 原生的 `MessageWindowChatMemory`。 + +### 策略适用场景 + +| 策略 | 适用场景 | 说明 | +|------|----------|------| +| **SummarizationStrategy** | 长对话、需要保留语义 | 提前压缩,保留对话要点 | +| **TruncationStrategy** | Token 精确控制 | 按 Token 限制精确截断 | + +### 原生滑动窗口配置 + +如需固定消息数量的滑动窗口,使用 LangChain4j 原生的 `MessageWindowChatMemory`: + +```yaml +chat: + memory: + strategy: message # 使用原生滑动窗口 + max-messages: 20 # 窗口大小 +``` + +**原生滑动窗口特性**: +- ✅ 真正的滑动窗口:`add()` 时自动移除超出窗口的旧消息 +- ✅ 固定消息数量限制 +- ✅ 性能高效:O(1) 插入/删除 +- ❌ 不支持 Token 限制 +- ❌ 不支持摘要压缩 + +### 触发流程图 + +#### hybrid 策略(摘要 + 截断) + +``` +Token 使用情况: + 0% ────────────────────── 70% ────────────────────── 100% + │ │ │ + 正常 触发摘要 触发截断 + (保留语义) (强制兜底) + +执行顺序: + 1. SummarizationStrategy (优先级50) + └─ Token >= 比例阈值 → 执行摘要压缩 + └─ 压缩后检查是否超限 + + 2. TruncationStrategy (优先级100) + └─ 如果仍超限 → 执行截断 + └─ 保证最终不超限 +``` + +#### token 策略(仅截断) + +``` +Token 使用情况: + 0% ────────────────────────────────────────────── 100% + │ │ + 正常 触发截断 + (直接丢弃旧消息) + +执行顺序: + 1. TruncationStrategy (优先级100) + └─ Token 超限 → 执行截断 + └─ 保证最终不超限 +``` + +### 详细触发条件 + +#### 摘要策略 (SummarizationStrategy) +```java +// 触发条件(无需超限,达到比例即可) +return context.getSummarizer() != null // 有摘要模型 + && context.getMessages().size() > threshold // 消息数足够 + && context.getUsageRatio() >= summarizeTokenRatio; // Token 达到比例阈值 +``` + +#### 截断策略 (TruncationStrategy) +```java +// 触发条件(必须超限) +return context.isOverLimit(); // Token > 有效上限 +``` + +### 生产环境示例 + +#### hybrid 策略示例(摘要 + 截断) + +以 GLM-4.5-AIR (131072 tokens) 为例: + +``` +maxTokens = 131072 (自动获取) +reservedForReply = 2000 +有效上限 = 129072 tokens + +摘要触发阈值 = 129072 × 70% = 90,350 tokens + +当 Token 达到 90,350 且消息数 > 10 时: + → 触发摘要压缩(提前介入,保留语义) + → 摘要后通常 < 129072,无需截断 + +如果摘要后仍超限: + → 触发截断策略(强制兜底) +``` + +#### token 策略示例(仅截断) + +以 GPT-4o (128000 tokens) 为例: + +``` +maxTokens = 128000 (自动获取) +reservedForReply = 2000 +有效上限 = 126000 tokens + +当 Token > 126000 时: + → 触发截断策略 + → 从旧消息开始丢弃,直到 Token 在限制内 +``` + +## 降级机制 + +### 多层保障架构 + +#### hybrid 策略(四层保障) + +``` +Token 超限/达到阈值 + │ + ▼ +┌───────────────────────────────┐ +│ CompressionStrategyManager │ +│ (策略框架) │ +│ │ +│ 1. SummarizationStrategy │ ← 第1层:摘要压缩 +│ 2. TruncationStrategy │ ← 第2层:Token 截断 +│ 3. 检查结果是否仍超限 │ +│ 4. 超限则执行 forceTruncate() │ ← 第3层:强制截断 +└───────────────────────────────┘ + │ + ├─► 成功且不超限 → 返回 + │ + └─► 失败/仍超限 → forceTruncate() 强制截断 + │ + ▼ + ┌─────────────────────┐ + │ emergencyTruncate() │ ← 第4层:紧急截断 + │ (TokenBasedChatMemory)│ + │ 最终兜底,确保不超限 │ + └─────────────────────┘ +``` + +#### token 策略(三层保障) + +``` +Token 超限 + │ + ▼ +┌───────────────────────────────┐ +│ CompressionStrategyManager │ +│ (策略框架) │ +│ │ +│ 1. TruncationStrategy │ ← 第1层:Token 截断 +│ 2. 检查结果是否仍超限 │ +│ 3. 超限则执行 forceTruncate() │ ← 第2层:强制截断 +└───────────────────────────────┘ + │ + ├─► 成功且不超限 → 返回 + │ + └─► 失败/仍超限 → forceTruncate() 强制截断 + │ + ▼ + ┌─────────────────────┐ + │ emergencyTruncate() │ ← 第3层:紧急截断 + │ 最终兜底,确保不超限 │ + └─────────────────────┘ +``` + +### 保障机制对比 + +| 层级 | hybrid 策略 | token 策略 | +|------|-------------|------------| +| 第1层 | SummarizationStrategy(摘要压缩) | TruncationStrategy(截断) | +| 第2层 | TruncationStrategy(截断) | forceTruncate()(强制截断) | +| 第3层 | forceTruncate()(强制截断) | emergencyTruncate()(紧急截断) | +| 第4层 | emergencyTruncate()(紧急截断) | - | + +### 边界保护 + +| 检查点 | 保护措施 | 说明 | +|--------|----------|------| +| effectiveMaxTokens | `Math.max(1, effective)` | 确保返回值 >= 1,避免负数/零 | +| getUsageRatio() | 除零保护 | 当 effectiveMax <= 0 时返回 1.0 | +| 系统消息超长 | 仅返回系统消息 | 系统消息占用全部空间时的降级 | + +### 降级场景示例 + +| 场景 | 处理方式 | +|------|----------| +| reservedForReply >= maxTokens | effectiveMaxTokens = 1,视为已满,仅保留系统消息 | +| 所有策略执行后仍超限 | forceTruncate() 强制截断到限制内 | +| 策略框架不可用 | emergencyTruncate() 直接截断 | +| 系统消息占用全部空间 | 只返回系统消息 | + +## 摘要机制详解 + +### 摘要逻辑 + +**只摘要前半部分的消息,保留后半部分完整**: + +``` +原消息: [Msg1, Msg2, Msg3, Msg4, Msg5, Msg6, Msg7, Msg8, Msg9, Msg10] + + ↓ 分割(一半摘要,一半保留) + +待摘要: [Msg1, Msg2, Msg3, Msg4, Msg5] → 生成摘要 +保留: [Msg6, Msg7, Msg8, Msg9, Msg10] → 不处理 + + ↓ 合并结果 + +[摘要消息, Msg6, Msg7, Msg8, Msg9, Msg10] → 6 条消息 +``` + +### 设计原因 + +| 方案 | 问题 | +|------|------| +| 摘要全部 | 丢失最近对话的完整上下文,AI 可能无法理解当前话题 | +| 摘要前半 | 保留最近对话的完整性,压缩早期对话为摘要 ✓ | + +**最近的消息最重要**,所以保留后半部分不处理。 + +### 摘要模型策略 + +系统支持三种摘要模型策略,可通过配置选择: + +| 策略 | 配置值 | 说明 | 适用场景 | +|------|--------|------|----------| +| **当前模型** | `current` (默认) | 使用当前对话模型进行摘要 | 追求摘要质量,成本不敏感 | +| **智能映射** | `smart` | 自动映射到轻量级模型 | 追求低成本,如 gpt-4o-mini、glm-4-flash | +| **自定义** | `custom` | 使用指定的自定义模型 | 有特定需求,需配合 `summarizer-custom-model` 配置 | + +```yaml +chat: + memory: + # 摘要模型策略: current(当前模型) / smart(智能映射) / custom(自定义) + summarizer-strategy: current + + # 自定义摘要模型(仅当 summarizer-strategy=custom 时生效) + # summarizer-custom-model: gpt-4o-mini +``` + +### 智能映射规则 + +当选择 `smart` 策略时,系统自动根据主模型选择合适的轻量级摘要模型: + +| 主模型 | 摘要模型 | 说明 | +|--------|----------|------| +| glm-5 | glm-5-flash | 智谱最新轻量版 | +| glm-4.5-air | glm-4.5-air | 保持原模型 | +| glm-4 | glm-4-flash | 智谱便宜版本 | +| gpt-4 | gpt-4o-mini | OpenAI 最便宜 | +| claude | claude-3-5-haiku | Anthropic 轻量版 | +| deepseek | deepseek-chat | 本身便宜 | +| qwen | qwen-turbo | 阿里轻量版 | +| doubao | doubao-1.5-lite | 字节轻量版 | + +## 工作流程示例 + +### hybrid 策略工作流程 + +#### 场景 1: 正常对话(Token 未达到阈值) + +``` +1. 用户发送消息 + ↓ +2. 从数据库查询历史消息 + ↓ +3. TokenCounter 计算当前 Token 数 + ↓ +4. Token 数 < 有效上限 × 比例阈值 + ↓ +5. 直接返回所有消息,不做处理 +``` + +#### 场景 2: Token 达到比例阈值(触发摘要) + +``` +1. 用户发送消息,Token 累积 + ↓ +2. Token 数 >= 有效上限 × 比例阈值(如 70%) + ↓ +3. SummarizationStrategy 触发 + ↓ +4. 调用 LLM 生成历史对话摘要(前半部分) + ↓ +5. 替换旧消息为摘要消息 + ↓ +6. 检查是否仍超限 + ↓ 是 +7. TruncationStrategy 触发,执行截断 +``` + +#### 场景 3: Token 直接超限(触发截断) + +``` +1. 用户发送消息,Token 累积 + ↓ +2. Token 数 > 有效上限 + ↓ +3. 检查摘要条件(比例、消息数等) + ↓ 不满足 +4. TruncationStrategy 触发 + ↓ +5. 从旧消息开始截断,直到 Token 在限制内 +``` + +### token 策略工作流程 + +#### 场景 1: 正常对话(Token 未超限) + +``` +1. 用户发送消息 + ↓ +2. 从数据库查询历史消息 + ↓ +3. TokenCounter 计算当前 Token 数 + ↓ +4. Token 数 < 有效上限 + ↓ +5. 直接返回所有消息,不做处理 +``` + +#### 场景 2: Token 超限(触发截断) + +``` +1. 用户发送消息,Token 累积 + ↓ +2. Token 数 > 有效上限 + ↓ +3. TruncationStrategy 触发 + ↓ +4. 从旧消息开始截断,直到 Token 在限制内 +``` + +### 通用兜底流程 + +#### 策略执行后仍超限(强制截断) + +``` +1. 用户发送消息,Token 累积 + ↓ +2. Token 数远超有效上限 + ↓ +3. 策略执行后仍超限 + ↓ +4. forceTruncate() 强制截断到限制内 + ↓ +5. 返回不超限的消息 +``` + +#### 无策略框架(紧急截断) + +``` +1. 用户发送消息,Token 累积 + ↓ +2. Token 数 > 有效上限 + ↓ +3. 无 StrategyManager 或策略失败 + ↓ +4. emergencyTruncate() 紧急截断 + ↓ +5. 保留系统消息 + 最近消息 + ↓ +6. 返回不超限的消息 +``` + +## 日志输出示例 + +### hybrid 策略日志 + +``` +# 创建内存 +创建 ChatMemory: strategy=hybrid, memoryId=12345 +[Hybrid内存] 创建混合策略内存: maxTokens=131072, summarizeTokenRatio=0.7 + +# 正常状态 +[Token内存管理] 会话=12345, 消息数=15, 当前Token=8500, Token上限=131072, 预留回复空间=2000 + +# Token 达到比例阈值,触发摘要 +[策略管理器] 执行策略: summarization +[摘要策略] 开始摘要: 原消息数=25, 待摘要=12, 保留=13 +[摘要策略] 生成摘要成功: 本次对话主要讨论了用户的项目需求... +[摘要策略] 完成: 原消息数=25 → 新消息数=14, Token: 90000 → 55000 +[策略管理器] 策略 summarization 执行成功, Token: 90000 → 55000 +[策略管理器] 压缩完成,已达到目标范围 + +# Token 超限,触发截断 +[策略管理器] 执行策略: truncation +[截断策略] 完成: 原消息数=30 → 截断后=18, Token: 130000 → 124500 + +# 策略执行后仍超限,强制截断兜底 +[策略管理器] 策略执行后仍超限 (5500 > 4000),执行强制截断兜底 +[强制截断] 完成: 原消息数=25 → 截断后=15, 系统消息=2 +``` + +### token 策略日志 + +``` +# 创建内存 +创建 ChatMemory: strategy=token, memoryId=12345 +[Token内存] 创建Token窗口内存: maxTokens=128000, 摘要=禁用 + +# 正常状态 +[Token内存管理] 会话=12345, 消息数=15, 当前Token=8500, Token上限=128000, 预留回复空间=2000 + +# Token 超限,触发截断 +[策略管理器] 执行策略: truncation +[截断策略] 完成: 原消息数=30 → 截断后=18, Token: 130000 → 124500 + +# 策略执行后仍超限,强制截断兜底 +[策略管理器] 策略执行后仍超限 (5500 > 4000),执行强制截断兜底 +[强制截断] 完成: 原消息数=25 → 截断后=15, 系统消息=2 + +# 无策略框架,紧急截断 +[Token内存管理] 无策略框架且超限,执行紧急截断 +[紧急截断] 完成: 原消息数=30 → 截断后=18, 系统消息=2 +``` + +## 性能考虑 + +### 当前方案 + +| 操作 | 说明 | +|------|------| +| **读取** | 每次对话都从数据库查询全部历史消息 | +| **写入** | 每条新消息单独 INSERT 到数据库 | +| **摘要** | 只在内存中处理,不更新数据库 | +| **截断** | 只在内存中处理,不更新数据库 | + +### 优化建议 + +| 场景 | 建议 | +|------|------| +| 当前规模(用户少) | 现有方案够用 | +| 中等规模 | 加 Redis 缓存 | +| 大规模 | Redis + 分页查询 + 异步摘要 | + +## 安全考虑 + +1. **Token 估算**: 使用中英文混合估算,不依赖外部 Tokenizer,性能高 +2. **摘要开销**: 摘要会增加 LLM API 调用,建议仅在长对话场景启用 +3. **系统消息保护**: 所有策略都会保留系统消息,确保 AI 角色设定不丢失 +4. **回复空间预留**: 预留 reservedForReply tokens 给 AI 回复,避免上下文占满 +5. **未知模型处理**: 回退到固定消息数量策略,避免使用默认 4096 导致截断过多 +6. **数据完整性**: 摘要和截断不修改数据库,保留完整历史记录 +7. **边界保护**: effectiveMaxTokens 保证 >= 1,避免负数/零导致的计算错误 +8. **多层兜底**: 策略失败时有多层降级机制,确保返回的消息永不超限 +9. **null 值过滤**: 流式响应中过滤 null 值,避免拼接成无效消息内容 + +## 关键修复记录 + +### P0 级别修复 + +| 问题 | 修复 | 文件 | +|------|------|------| +| effectiveMaxTokens 可能为负数/零 | 添加 `Math.max(1, effective)` 边界检查 | `CompressionContext.java` | + +### P1 级别修复 + +| 问题 | 修复 | 文件 | +|------|------|------| +| 策略执行后仍超限无保障 | 添加 `forceTruncate()` 强制截断兜底 | `CompressionStrategyManager.java` | +| 降级机制不完善 | 添加 `emergencyTruncate()` 紧急截断 | `TokenBasedChatMemory.java` | + +## 模型 Token 限制参考 + +| 模型系列 | 代表模型 | Token 限制 | +|----------|----------|------------| +| GPT-4.1 | gpt-4.1, gpt-4.1-mini | 1,047,576 | +| GPT-4o | gpt-4o, gpt-4o-mini | 128,000 | +| o 系列 | o1, o3, o4-mini | 200,000 | +| DeepSeek | deepseek-chat, deepseek-reasoner | 64,000 | +| GLM-5 | glm-5, glm-5.1 | 128,000 | +| GLM-4-long | glm-4-long | 1,024,000 | +| Qwen3 | qwen3, qwen3-235b | 128,000 | +| Qwen-long | qwen-long | 1,000,000 | +| Claude 4 | claude-4-opus, claude-4-sonnet | 200,000 | +| Claude 3.5 | claude-3.5-sonnet | 200,000 | +| Gemini 2.5 | gemini-2.5-pro, gemini-2.5-flash | 1,048,576 | +| Doubao 1.5 | doubao-1.5-pro, doubao-1.5-thinking | 256,000 | +| Grok 3 | grok-3, grok-3-mini | 131,072 | +| Llama 3.1/3.2 | llama3.1, llama3.2 | 131,072 | + +## 扩展指南 + +### 添加新模型 + +在 `ModelTokenLimits.java` 的 `TOKEN_LIMITS` Map 中添加: + +```java +TOKEN_LIMITS.put("new-model-name", 64000); +``` + +### 添加新的压缩策略 + +1. 实现 `MemoryCompressionStrategy` 接口: + +```java +@Component +public class MyStrategy implements MemoryCompressionStrategy { + @Override + public String getName() { return "my-strategy"; } + + @Override + public int getPriority() { return 80; } // 介于摘要和截断之间 + + @Override + public boolean needsCompression(CompressionContext context) { + // 自定义触发条件 + return true; + } + + @Override + public CompressionResult compress(CompressionContext context) { + // 自定义压缩逻辑 + return CompressionResult.success(...); + } +} +``` + +2. Spring 会自动注入到 `CompressionStrategyManager` + +### 自定义摘要模型映射 + +在 `ChatServiceFacade.getSmartSummarizerModel()` 中添加: + +```java +if (model.contains("new-model")) return "new-model-lite"; +``` + +### 自定义 Token 计数 + +实现 `TokenCounter` 接口或继承现有类,重写 `countTokens()` 方法。 diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 16e0750d..52fe2f32 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -4,7 +4,6 @@ import dev.langchain4j.agentic.AgenticServices; import dev.langchain4j.agentic.supervisor.SupervisorAgent; import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy; -import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.mcp.McpToolProvider; @@ -12,8 +11,8 @@ import dev.langchain4j.mcp.client.McpClient; import dev.langchain4j.mcp.client.transport.McpTransport; import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport; +import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; -import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; @@ -57,6 +56,7 @@ import org.ruoyi.observability.*; import org.ruoyi.service.chat.AbstractChatService; import org.ruoyi.service.chat.IChatMessageService; +import org.ruoyi.service.chat.impl.memory.ChatMemoryFactory; import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore; import org.ruoyi.service.knowledge.IKnowledgeInfoService; import org.ruoyi.service.retrieval.KnowledgeRetrievalService; @@ -86,8 +86,6 @@ @RequiredArgsConstructor public class ChatServiceFacade implements IChatService { - private static final Integer DEFAULT_MAX_MESSAGES = 20; - private final IChatModelService chatModelService; private final ChatServiceFactory chatServiceFactory; @@ -106,11 +104,13 @@ public class ChatServiceFacade implements IChatService { private final ToolProviderFactory toolProviderFactory; + private final ChatMemoryFactory chatMemoryFactory; + /** * 内存实例缓存,避免同一会话重复创建 - * Key: sessionId, Value: MessageWindowChatMemory实例 + * Key: sessionId, Value: ChatMemory实例 */ - private static final Map memoryCache = new ConcurrentHashMap<>(); + private static final Map memoryCache = new ConcurrentHashMap<>(); @@ -133,13 +133,15 @@ public SseEmitter sseChat(ChatRequest chatRequest) { throw new IllegalArgumentException("模型不存在: " + chatRequest.getModel()); } + // 先设置 chatModelVo,以便 buildContextMessages 中创建摘要模型时能获取到配置 + chatRequest.setChatModelVo(chatModelVo); + // 2. 构建上下文消息列表 List contextMessages = buildContextMessages(chatRequest); chatRequest.setEmitter(emitter); chatRequest.setUserId(userId); chatRequest.setTokenValue(tokenValue); - chatRequest.setChatModelVo(chatModelVo); chatRequest.setContextMessages(contextMessages); // 保存用户消息 @@ -396,18 +398,13 @@ public SseEmitter chat(ChatRequest chatRequest) { * 同一个会话ID会返回同一个内存实例,避免重复创建和消息丢失 * * @param memoryId 内存ID(会话ID) - * @return MessageWindowChatMemory实例 + * @param model 模型配置 + * @return ChatMemory实例 */ - private MessageWindowChatMemory createChatMemory(Object memoryId) { - // 先从缓存中获取 + private ChatMemory createChatMemory(Object memoryId, ChatModelVo model) { return memoryCache.computeIfAbsent(memoryId, key -> { try { - PersistentChatMemoryStore store = new PersistentChatMemoryStore(chatMessageService); - return MessageWindowChatMemory.builder() - .id(memoryId) - .maxMessages(DEFAULT_MAX_MESSAGES) - .chatMemoryStore(store) - .build(); + return chatMemoryFactory.create(memoryId, model); } catch (Exception e) { log.warn("创建聊天内存失败: {}", e.getMessage()); return null; @@ -462,7 +459,7 @@ private List buildContextMessages(ChatRequest chatRequest) { // 3. 从数据库查询历史对话消息(放在前面) if (chatRequest.getSessionId() != null) { - MessageWindowChatMemory memory = createChatMemory(chatRequest.getSessionId()); + ChatMemory memory = createChatMemory(chatRequest.getSessionId(), chatRequest.getChatModelVo()); if (memory != null) { List historicalMessages = memory.messages(); if (historicalMessages != null && !historicalMessages.isEmpty()) { @@ -516,12 +513,18 @@ protected StreamingChatResponseHandler createResponseHandler(Long userId, String @SneakyThrows @Override public void onPartialResponse(String partialResponse) { + // 过滤 null 值,避免拼接成 "nullnullnull..." + if (partialResponse == null) { + log.debug("收到 null 消息片段,已忽略"); + return; + } + // 将消息片段追加到缓冲区 messageBuffer.append(partialResponse); // 实时发送内容事件到客户端 SseMessageUtils.sendContent(userId, partialResponse); - log.debug("收到消息片段: {}", partialResponse); + log.debug("收到消息片段: {}", partialResponse); } @Override diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryConfig.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryConfig.java new file mode 100644 index 00000000..ed791e15 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryConfig.java @@ -0,0 +1,37 @@ +package org.ruoyi.service.chat.impl.memory; + +import lombok.RequiredArgsConstructor; +import org.ruoyi.service.chat.IChatMessageService; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * ChatMemory 配置类 + * + * @author yang + * @date 2026-04-27 + */ +@Configuration +@EnableConfigurationProperties(ChatMemoryProperties.class) +@RequiredArgsConstructor +public class ChatMemoryConfig { + + private final IChatMessageService chatMessageService; + + /** + * 持久化存储 Bean + */ + @Bean + public PersistentChatMemoryStore persistentChatMemoryStore() { + return new PersistentChatMemoryStore(chatMessageService); + } + + /** + * Token 计数器 Bean + */ + @Bean + public TokenCounter tokenCounter() { + return new TokenCounter(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryFactory.java new file mode 100644 index 00000000..2f2d9e24 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryFactory.java @@ -0,0 +1,333 @@ +package org.ruoyi.service.chat.impl.memory; + +import dev.langchain4j.memory.ChatMemory; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.store.memory.chat.ChatMemoryStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.service.chat.impl.memory.strategy.CompressionStrategyManager; +import org.springframework.stereotype.Component; + +/** + * ChatMemory 工厂 + * 根据配置创建不同策略的 ChatMemory 实例 + * + * @author yang + * @date 2026-04-27 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatMemoryFactory { + + private final ChatMemoryProperties properties; + private final PersistentChatMemoryStore persistentStore; + private final TokenCounter tokenCounter; + private final CompressionStrategyManager strategyManager; + + /** + * 创建 ChatMemory 实例 + * + * @param memoryId 内存 ID(通常是会话 ID) + * @param model 模型配置 + * @return ChatMemory 实例 + */ + public ChatMemory create(Object memoryId, ChatModelVo model) { + // 自动创建摘要模型 + ChatModel summarizer = createSummarizerModel(model); + return create(memoryId, model, summarizer); + } + + /** + * 创建 ChatMemory 实例(带摘要模型) + * + * @param memoryId 内存 ID + * @param model 模型配置 + * @param summarizer 用于摘要的 LLM 模型(可选) + * @return ChatMemory 实例 + */ + public ChatMemory create(Object memoryId, ChatModelVo model, ChatModel summarizer) { + if (!properties.getEnabled()) { + log.debug("长期记忆已禁用"); + return null; + } + + String strategy = properties.getStrategy(); + log.info("创建 ChatMemory: strategy={}, memoryId={}", strategy, memoryId); + + // 检查模型是否已知,未知模型回退到消息数量策略 + if (Boolean.TRUE.equals(properties.getFallbackToMessageStrategy()) + && ("token".equalsIgnoreCase(strategy) || "hybrid".equalsIgnoreCase(strategy))) { + if (model != null && model.getModelName() != null) { + int tokenLimit = ModelTokenLimits.getLimitOrUnknown(model.getModelName()); + if (tokenLimit == ModelTokenLimits.UNKNOWN_LIMIT) { + log.info("模型 [{}] 不在已知列表中,回退到固定消息数量策略 (maxMessages={})", + model.getModelName(), properties.getFallbackMaxMessages()); + return createFallbackMessageMemory(memoryId); + } + } + } + + return switch (strategy.toLowerCase()) { + case "message" -> createMessageBasedMemory(memoryId); + case "token" -> createTokenBasedMemory(memoryId, model, summarizer); + case "hybrid" -> createHybridMemory(memoryId, model, summarizer); + default -> { + log.warn("未知的内存策略: {}, 使用默认 token 策略", strategy); + yield createTokenBasedMemory(memoryId, model, summarizer); + } + }; + } + + /** + * 创建基于消息数量的内存(原有策略) + */ + private ChatMemory createMessageBasedMemory(Object memoryId) { + int maxMessages = properties.getMaxMessages(); + log.debug("创建消息窗口内存: maxMessages={}", maxMessages); + + return MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(maxMessages) + .chatMemoryStore(persistentStore) + .build(); + } + + /** + * 创建回退的消息数量内存(用于未知模型) + */ + private ChatMemory createFallbackMessageMemory(Object memoryId) { + int maxMessages = properties.getFallbackMaxMessages() != null + ? properties.getFallbackMaxMessages() + : 20; + log.debug("创建回退消息窗口内存: maxMessages={}", maxMessages); + + return MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(maxMessages) + .chatMemoryStore(persistentStore) + .build(); + } + + /** + * 创建基于 Token 的内存(仅截断,不摘要) + */ + private ChatMemory createTokenBasedMemory(Object memoryId, ChatModelVo model, ChatModel summarizer) { + int maxTokens = resolveMaxTokens(model); + int reservedForReply = properties.getReservedForReply(); + + log.info("[Token内存] 创建Token窗口内存: maxTokens={}, reservedForReply={}, 模型={}, 摘要=禁用", + maxTokens, reservedForReply, model != null ? model.getModelName() : "未知"); + + // 判断是否使用策略框架 + boolean useStrategyFramework = properties.getUseStrategyFramework() != null + ? properties.getUseStrategyFramework() : true; + + return TokenBasedChatMemory.builder() + .memoryId(memoryId) + .maxTokens(maxTokens) + .tokenCounter(tokenCounter) + .store(persistentStore) + .summarizeTokenRatio(1.0) // 设为 1.0,永不触发摘要 + .summarizeThreshold(Integer.MAX_VALUE) // 设为最大值,永不触发摘要 + .summarizer(null) // 不使用摘要模型 + .preserveSystemMessages(properties.getPreserveSystemMessages()) + .reservedForReply(reservedForReply) + .strategyManager(useStrategyFramework ? strategyManager : null) + .build(); + } + + /** + * 创建混合策略内存(Token + 摘要) + */ + private ChatMemory createHybridMemory(Object memoryId, ChatModelVo model, ChatModel summarizer) { + int maxTokens = resolveMaxTokens(model); + int reservedForReply = properties.getReservedForReply(); + double summarizeTokenRatio = properties.getSummarizeTokenRatio() != null + ? properties.getSummarizeTokenRatio() : 0.7; + int summarizeThreshold = properties.getSummarizeThreshold(); + + log.info("[Hybrid内存] 创建混合策略内存: maxTokens={}, summarizeTokenRatio={}, summarizeThreshold={}", + maxTokens, summarizeTokenRatio, summarizeThreshold); + + // 判断是否使用策略框架 + boolean useStrategyFramework = properties.getUseStrategyFramework() != null + ? properties.getUseStrategyFramework() : true; + + return TokenBasedChatMemory.builder() + .memoryId(memoryId) + .maxTokens(maxTokens) + .tokenCounter(tokenCounter) + .store(persistentStore) + .summarizeTokenRatio(summarizeTokenRatio) + .summarizeThreshold(summarizeThreshold) + .summarizer(summarizer) + .preserveSystemMessages(properties.getPreserveSystemMessages()) + .reservedForReply(reservedForReply) + .strategyManager(useStrategyFramework ? strategyManager : null) + .build(); + } + + /** + * 解析最大 Token 数 + * 优先使用配置值,否则根据模型自动获取 + */ + private int resolveMaxTokens(ChatModelVo model) { + // 优先使用配置值 + if (properties.getMaxTokens() != null && properties.getMaxTokens() > 0) { + return properties.getMaxTokens(); + } + + // 根据模型自动获取 + if (model != null && model.getModelName() != null) { + int modelLimit = ModelTokenLimits.getLimit(model.getModelName()); + int inputLimit = ModelTokenLimits.getInputLimit(model.getModelName(), properties.getReservedForReply()); + log.debug("模型 {} 的 Token 限制: {}, 输入限制: {}", model.getModelName(), modelLimit, inputLimit); + return inputLimit; + } + + // 默认值 + return 4096; + } + + /** + * 获取模型的完整 Token 限制(用于显示) + */ + public int getModelTokenLimit(String modelName) { + return ModelTokenLimits.getLimit(modelName); + } + + /** + * 创建用于摘要的 LLM 模型 + * 根据配置选择摘要模型策略: + * - current: 使用当前对话模型(默认,质量高) + * - smart: 智能映射到轻量级模型(成本低) + * - custom: 使用自定义模型 + * + * @param model 原始模型配置 + * @return 摘要模型实例,如果无法创建则返回 null + */ + private ChatModel createSummarizerModel(ChatModelVo model) { + log.debug("[摘要模型] 开始创建,model={}", model != null ? model.getModelName() : "null"); + + if (model == null || model.getApiKey() == null || model.getApiHost() == null) { + log.warn("[摘要模型] 创建失败:缺少必要参数"); + return null; + } + + try { + String originalModel = model.getModelName(); + String summarizerModelName; + String strategy = properties.getSummarizerStrategy(); + + // 根据配置选择摘要模型 + if ("smart".equals(strategy)) { + summarizerModelName = getSmartSummarizerModel(originalModel); + log.info("[摘要模型] 智能映射策略: {} → {}", originalModel, summarizerModelName); + } else if ("custom".equals(strategy)) { + summarizerModelName = properties.getSummarizerCustomModel(); + if (summarizerModelName == null || summarizerModelName.isEmpty()) { + log.warn("[摘要模型] 自定义模型未配置,回退到当前模型"); + summarizerModelName = originalModel; + } + log.info("[摘要模型] 自定义策略: 使用 {}", summarizerModelName); + } else { + summarizerModelName = originalModel; + log.info("[摘要模型] 当前模型策略: 使用 {}", summarizerModelName); + } + + String baseUrl = fixApiBaseUrl(model.getApiHost(), summarizerModelName); + + return OpenAiChatModel.builder() + .baseUrl(baseUrl) + .apiKey(model.getApiKey()) + .modelName(summarizerModelName) + .timeout(java.time.Duration.ofSeconds(60)) + .build(); + } catch (Exception e) { + log.warn("[摘要模型] 创建异常: {}", e.getMessage()); + return null; + } + } + + /** + * 智能映射摘要模型 + * 根据原模型自动选择性价比高的轻量级模型 + * + * @param originalModel 原始模型名称 + * @return 摘要模型名称 + */ + private String getSmartSummarizerModel(String originalModel) { + if (originalModel == null || originalModel.isEmpty()) { + return originalModel; + } + + String model = originalModel.toLowerCase(); + + // GLM 系列 → flash(智谱最便宜,有免费额度) + if (model.contains("glm-5")) return "glm-5-flash"; + if (model.contains("glm-4.5")) return "glm-4.5-air"; + if (model.contains("glm-4")) return "glm-4-flash"; + + // GPT 系列 → mini + if (model.contains("gpt-4")) return "gpt-4o-mini"; + if (model.contains("gpt-3.5")) return "gpt-3.5-turbo"; + + // Claude 系列 → haiku + if (model.contains("claude")) return "claude-3-5-haiku"; + + // Gemini → flash + if (model.contains("gemini")) return "gemini-2.0-flash"; + + // DeepSeek 本身便宜,保持原模型 + if (model.contains("deepseek")) return originalModel; + + // Qwen → turbo + if (model.contains("qwen")) return "qwen-turbo"; + + // Doubao → lite + if (model.contains("doubao")) return "doubao-1.5-lite"; + + // 其他模型保持原样 + return originalModel; + } + + /** + * 修正 API 地址(用于 OpenAI 兼容格式) + * 某些供应商的 API 地址需要特殊处理: + * - 智谱 GLM: 需要添加 /api/paas/v4/ 路径 + * - 千问 Qwen: 需要添加 /compatible-mode/v1 路径 + * + * @param baseUrl 原始 API 地址 + * @param modelName 模型名称(用于判断供应商) + * @return 修正后的 API 地址 + */ + public static String fixApiBaseUrl(String baseUrl, String modelName) { + if (baseUrl == null || baseUrl.isEmpty()) { + return baseUrl; + } + + // 智谱 GLM 系列:OpenAI 兼容格式需要完整路径 + if (modelName != null && modelName.toLowerCase().contains("glm")) { + if (!baseUrl.contains("/api/paas") && !baseUrl.contains("/v4")) { + baseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + baseUrl = baseUrl + "api/paas/v4/"; + log.debug("[API地址修正] 智谱 GLM: 添加路径 /api/paas/v4/"); + } + } + + // 千问 Qwen 系列:OpenAI 兼容格式需要完整路径 + if (modelName != null && modelName.toLowerCase().contains("qwen")) { + if (!baseUrl.contains("/compatible-mode")) { + baseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + baseUrl = baseUrl + "compatible-mode/v1/"; + log.debug("[API地址修正] 千问 Qwen: 添加路径 /compatible-mode/v1/"); + } + } + + return baseUrl; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryProperties.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryProperties.java index d50bf0b9..40a649a0 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryProperties.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ChatMemoryProperties.java @@ -2,7 +2,6 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; /** * 聊天长期记忆配置属性 @@ -12,7 +11,6 @@ * @date 2026/01/10 */ @Data -@Component @ConfigurationProperties(prefix = "chat.memory") public class ChatMemoryProperties { @@ -21,49 +19,89 @@ public class ChatMemoryProperties { */ private Boolean enabled = true; + /** + * 内存管理策略(默认 message) + * - message: 固定消息数量(使用 LangChain4j 原生 MessageWindowChatMemory) + * 不支持 Token 管理和摘要,适合简单场景 + * - token: 基于 Token 数量(仅截断,不摘要) + * Token 超限时直接截断旧消息,适合通用场景 + * - hybrid: 混合策略(摘要 + 截断) + * Token 达到阈值时先摘要压缩,超限时再截断,适合长对话场景 + */ + private String strategy = "message"; + /** * 消息窗口大小 - 最多保留的消息条数(默认20) - * 用于控制每次聊天请求中包含的历史消息数量 + * 仅当 strategy=message 时生效 */ private Integer maxMessages = 20; /** - * 是否启用消息持久化(默认启用) - * 关闭后消息仅保存在内存中,重启后丢失 + * 最大 Token 数 + * 仅当 strategy=token 或 hybrid 时生效 + * 如果为空,则根据模型自动获取 + */ + private Integer maxTokens; + + /** + * 预留给回复的 Token 数(默认 2000) + */ + private Integer reservedForReply = 2000; + + /** + * 摘要触发阈值 - Token 使用比例(默认 0.7,即 70%) + * 当 Token 使用量超过此比例时,对旧消息进行摘要 + * 例如: maxTokens=128000, 比例=0.7, 则 Token>89600 时触发摘要 + * 建议值: 0.6-0.8 (60%-80%) + */ + private Double summarizeTokenRatio = 0.7; + + /** + * 摘要触发阈值 - 消息数量(默认 10) + * 当消息数超过此值时才考虑摘要(避免消息太少时摘要无意义) */ - private Boolean persistenceEnabled = true; + private Integer summarizeThreshold = 10; /** - * 自动清理过期消息的时间间隔(天数,默认不清理) - * 设为 0 表示禁用自动清理 + * 是否保留系统消息(默认 true) + * 系统消息不会被截断 */ - private Integer autoCleanupDays = 0; + private Boolean preserveSystemMessages = true; /** - * 消息摘要是否启用(默认禁用) - * 启用后,超过消息窗口的旧消息会被摘要处理 + * 未知模型是否回退到消息数量策略(默认启用) + * 当模型不在 Token 限制列表中时,自动使用固定消息数量策略 + * 关闭后,未知模型将使用默认 Token 限制 (4096) */ - private Boolean summarizeEnabled = false; + private Boolean fallbackToMessageStrategy = true; /** - * 摘要缓冲区大小 - 触发摘要的消息数量阈值(默认50) + * 未知模型回退时的消息数量(默认20) + * 仅当 fallbackToMessageStrategy=true 时生效 */ - private Integer summarizeThreshold = 50; + private Integer fallbackMaxMessages = 20; /** - * 是否在日志中记录内存加载情况(默认启用,用于调试) + * 是否使用策略框架(默认启用) + * 启用后,使用 CompressionStrategyManager 进行压缩 + * 禁用后,使用原有硬编码逻辑 */ - private Boolean debugLoggingEnabled = true; + private Boolean useStrategyFramework = true; /** - * 数据库查询超时时间(毫秒,默认5000) + * 摘要模型策略(默认使用当前对话模型) + * - current: 使用当前对话模型进行摘要(质量高,成本高) + * - smart: 智能映射到轻量级模型(成本低,如 gpt-4o-mini、glm-4-flash) + * - custom: 使用自定义模型(需配置 summarizerCustomModel) */ - private Integer queryTimeoutMs = 5000; + private String summarizerStrategy = "current"; /** - * 最大并发内存访问数(默认100) + * 自定义摘要模型名称 + * 仅当 summarizerStrategy=custom 时生效 + * 例如: "gpt-4o-mini", "glm-4-flash", "qwen-turbo" */ - private Integer maxConcurrentMemories = 100; + private String summarizerCustomModel; /** * 获取格式化的配置信息 @@ -72,14 +110,18 @@ public class ChatMemoryProperties { public String toString() { return "ChatMemoryProperties{" + "enabled=" + enabled + + ", strategy='" + strategy + '\'' + ", maxMessages=" + maxMessages + - ", persistenceEnabled=" + persistenceEnabled + - ", autoCleanupDays=" + autoCleanupDays + - ", summarizeEnabled=" + summarizeEnabled + + ", maxTokens=" + maxTokens + + ", reservedForReply=" + reservedForReply + + ", summarizeTokenRatio=" + summarizeTokenRatio + ", summarizeThreshold=" + summarizeThreshold + - ", debugLoggingEnabled=" + debugLoggingEnabled + - ", queryTimeoutMs=" + queryTimeoutMs + - ", maxConcurrentMemories=" + maxConcurrentMemories + + ", preserveSystemMessages=" + preserveSystemMessages + + ", fallbackToMessageStrategy=" + fallbackToMessageStrategy + + ", fallbackMaxMessages=" + fallbackMaxMessages + + ", useStrategyFramework=" + useStrategyFramework + + ", summarizerStrategy='" + summarizerStrategy + '\'' + + ", summarizerCustomModel='" + summarizerCustomModel + '\'' + '}'; } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ModelTokenLimits.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ModelTokenLimits.java new file mode 100644 index 00000000..e75c5bde --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/ModelTokenLimits.java @@ -0,0 +1,774 @@ +package org.ruoyi.service.chat.impl.memory; + +import java.util.Map; + +/** + * 模型 Token 限制映射表 + * 维护常用模型的上下文 Token 限制 + * + * @author yang + * @date 2026-04-27 + */ +public final class ModelTokenLimits { + + private ModelTokenLimits() {} + + /** + * 模型名称 -> Token 限制映射 + * 按模型名称小写匹配 + */ + private static final Map TOKEN_LIMITS = Map.ofEntries( + // ========== OpenAI ========== + entry("gpt-4o", 128000), + entry("gpt-4o-mini", 128000), + entry("gpt-4o-2024-05-13", 128000), + entry("gpt-4o-2024-08-06", 128000), + entry("gpt-4o-2024-11-20", 128000), + entry("gpt-4-turbo", 128000), + entry("gpt-4-turbo-preview", 128000), + entry("gpt-4-0125-preview", 128000), + entry("gpt-4-1106-preview", 128000), + entry("gpt-4", 8192), + entry("gpt-4-32k", 32768), + entry("gpt-4-32k-0613", 32768), + entry("gpt-3.5-turbo", 16385), + entry("gpt-3.5-turbo-16k", 16384), + entry("gpt-3.5-turbo-0125", 16385), + entry("gpt-3.5-turbo-1106", 16385), + // OpenAI 新模型 (2024-2026) + // GPT-4.1 系列 (1M context) + entry("gpt-4.1", 1048576), + entry("gpt-4.1-mini", 1048576), + entry("gpt-4.1-nano", 1048576), + entry("gpt-4.1-2025-04-14", 1048576), + // GPT-4.5 + entry("gpt-4.5-turbo", 128000), + entry("gpt-4.5-preview", 128000), + // o 系列推理模型 (200K context) + entry("o1", 200000), + entry("o1-preview", 128000), + entry("o1-mini", 128000), + entry("o1-2024-12-17", 200000), + entry("o1-pro", 200000), + entry("o3", 200000), + entry("o3-mini", 200000), + entry("o3-mini-2025-01-31", 200000), + entry("o4-mini", 200000), + entry("o4-mini-deep-research", 200000), + // ChatGPT-4o + entry("chatgpt-4o-latest", 128000), + entry("chatgpt-4o-search-preview", 128000), + + // ========== DeepSeek ========== + entry("deepseek-chat", 64000), + entry("deepseek-coder", 64000), + entry("deepseek-reasoner", 64000), + // DeepSeek V3 系列 + entry("deepseek-v3", 128000), + entry("deepseek-v3-base", 128000), + entry("deepseek-v3-0324", 128000), + // DeepSeek R1 推理系列 + entry("deepseek-r1", 128000), + entry("deepseek-r1-distill-llama-70b", 128000), + entry("deepseek-r1-distill-qwen-32b", 128000), + entry("deepseek-r1-distill-qwen-14b", 128000), + entry("deepseek-r1-zero", 128000), + // DeepSeek V2 + entry("deepseek-v2", 128000), + entry("deepseek-v2-lite", 128000), + entry("deepseek-v2-chat", 128000), + entry("deepseek-v2.5", 128000), + // DeepSeek Coder V2 + entry("deepseek-coder-v2", 128000), + entry("deepseek-coder-v2-instruct", 128000), + // DeepSeek Janus (多模态) + entry("janus-1.3b", 8192), + entry("janus-7b", 8192), + + // ========== 智谱 AI ========== + // GLM-5 系列 (最新) + entry("glm-5", 131072), + entry("glm-5-plus", 131072), + entry("glm-5-flash", 131072), + entry("glm-5-long", 1048576), + entry("glm-5.1", 131072), + entry("glm-5.1-plus", 131072), + // GLM-4.5 系列 + entry("glm-4.5", 131072), + entry("glm-4.5-plus", 131072), + entry("glm-4.5-flash", 131072), + entry("glm-4.5-air", 131072), + entry("glm-4.5-airx", 131072), + // GLM-4 系列 + entry("glm-4", 128000), + entry("glm-4-plus", 128000), + entry("glm-4-flash", 128000), + entry("glm-4-long", 1048576), + entry("glm-4-air", 128000), + entry("glm-4-airx", 128000), + // GLM-Z 思考模型 + entry("glm-z1-air", 128000), + entry("glm-z1-airx", 128000), + entry("glm-z1-flash", 128000), + entry("glm-z2", 131072), + // GLM-3 + entry("glm-3-turbo", 4096), + + // ========== 通义千问 ========== + // Qwen3 系列 (最新) + entry("qwen3", 131072), + entry("qwen3-235b", 131072), + entry("qwen3-235b-instruct", 131072), + entry("qwen3-32b", 131072), + entry("qwen3-32b-instruct", 131072), + entry("qwen3-14b", 131072), + entry("qwen3-14b-instruct", 131072), + entry("qwen3-8b", 131072), + entry("qwen3-8b-instruct", 131072), + entry("qwen3-1.7b", 131072), + entry("qwen3-0.6b", 131072), + // Qwen2.5 系列 + entry("qwen2.5", 131072), + entry("qwen2.5-max", 131072), + entry("qwen2.5-plus", 131072), + entry("qwen2.5-turbo", 131072), + entry("qwen2.5-72b", 131072), + entry("qwen2.5-72b-instruct", 131072), + entry("qwen2.5-32b", 131072), + entry("qwen2.5-32b-instruct", 131072), + entry("qwen2.5-14b", 131072), + entry("qwen2.5-14b-instruct", 131072), + entry("qwen2.5-7b", 131072), + entry("qwen2.5-7b-instruct", 131072), + entry("qwen2.5-3b", 131072), + entry("qwen2.5-1.5b", 131072), + entry("qwen2.5-0.5b", 131072), + // Qwen2 系列 + entry("qwen2", 32768), + entry("qwen2-72b-instruct", 131072), + entry("qwen2-57b-a14b-instruct", 131072), + entry("qwen2-7b-instruct", 131072), + entry("qwen2-1.5b-instruct", 131072), + entry("qwen2-0.5b-instruct", 131072), + // Qwen 其他 + entry("qwen-max", 131072), + entry("qwen-max-longcontext", 30720), + entry("qwen-plus", 131072), + entry("qwen-turbo", 131072), + entry("qwen-long", 1048576), + entry("qwen-vl-max", 32768), + entry("qwen-vl-plus", 32768), + // QwQ 思考模型 + entry("qwq-32b", 131072), + entry("qwq-32b-preview", 131072), + entry("qwq-plus", 131072), + + // ========== 百度文心 ========== + // ERNIE 4.5 系列 (最新) + entry("ernie-4.5", 131072), + entry("ernie-4.5-turbo", 131072), + entry("ernie-4.5-8k", 8192), + // ERNIE 4.0 系列 + entry("ernie-4.0", 8192), + entry("ernie-4.0-8k", 8192), + entry("ernie-4.0-turbo", 8192), + entry("ernie-4.0-ultra", 8192), + // ERNIE 3.5 系列 + entry("ernie-3.5", 8192), + entry("ernie-3.5-8k", 8192), + // ERNIE Speed/X 系列 + entry("ernie-speed", 8192), + entry("ernie-speed-8k", 8192), + entry("ernie-speed-128k", 131072), + entry("ernie-lite", 8192), + entry("ernie-lite-8k", 8192), + entry("ernie-tiny", 8192), + entry("ernie-x1", 32768), + entry("ernie-character", 8192), + + // ========== 月之暗面 (Moonshot AI) ========== + // Kimi K2 系列 (最新) + entry("kimi-k2", 131072), + entry("kimi-k2-pro", 131072), + entry("kimi-k2-base", 131072), + // Moonshot V1 系列 + entry("moonshot-v1-8k", 8192), + entry("moonshot-v1-32k", 32768), + entry("moonshot-v1-128k", 131072), + // Kimi 其他 + entry("kimi", 131072), + entry("kimi-latest", 131072), + + // ========== 讯飞星火 ========== + // Spark 4.0 系列 (最新) + entry("spark-4.0-ultra", 8192), + entry("spark-4.0", 8192), + entry("spark-v4.0", 8192), + // Spark Max 系列 + entry("spark-max", 131072), + entry("spark-max-128k", 131072), + // Spark Pro 系列 + entry("spark-pro", 8192), + entry("spark-pro-128k", 131072), + // Spark Lite + entry("spark-lite", 4096), + // Spark V3.x + entry("spark-v3.5", 8192), + entry("spark-v3.0", 8192), + // Spark 其他 + entry("spark-general", 8192), + entry("spark-generalv2", 8192), + entry("spark-generalv3", 8192), + + // ========== Claude ========== + // Claude 4 系列 (最新, 1M context) + entry("claude-opus-4.7", 1048576), + entry("claude-opus-4.6", 1048576), + entry("claude-sonnet-4.6", 1048576), + entry("claude-opus-4", 1048576), + entry("claude-sonnet-4", 200000), + entry("claude-haiku-4", 200000), + entry("claude-4-opus", 1048576), + entry("claude-4-sonnet", 200000), + entry("claude-4-haiku", 200000), + // Claude 3.5 系列 (200K, 部分 1M beta) + entry("claude-3.5-sonnet", 200000), + entry("claude-3.5-haiku", 200000), + entry("claude-3-5-sonnet", 200000), + entry("claude-3-5-haiku", 200000), + // Claude 3 系列 + entry("claude-3-opus", 200000), + entry("claude-3-sonnet", 200000), + entry("claude-3-haiku", 200000), + + // ========== Google Gemini ========== + entry("gemini-pro", 32760), + entry("gemini-pro-vision", 16384), + entry("gemini-1.0-pro", 32760), + entry("gemini-1.5-pro", 1048576), + entry("gemini-1.5-flash", 1048576), + entry("gemini-1.5-flash-8b", 1048576), + entry("gemini-2.0-flash", 1048576), + entry("gemini-2.0-flash-lite", 1048576), + entry("gemini-2.5-pro", 1048576), + entry("gemini-2.5-flash", 1048576), + + // ========== 豆包 (字节跳动) ========== + // Doubao 1.5 系列 + entry("doubao-1.5-pro", 131072), + entry("doubao-1.5-pro-32k", 32768), + entry("doubao-1.5-pro-128k", 131072), + entry("doubao-1.5-lite", 131072), + entry("doubao-1.5-lite-32k", 32768), + entry("doubao-1.5-lite-128k", 131072), + // Doubao Pro 系列 + entry("doubao-pro-32k", 32768), + entry("doubao-pro-128k", 131072), + entry("doubao-pro-256k", 262144), + // Doubao Lite 系列 + entry("doubao-lite-4k", 4096), + entry("doubao-lite-32k", 32768), + entry("doubao-lite-128k", 131072), + // Doubao Seed + entry("doubao-seed", 131072), + entry("doubao-seed-1.5", 131072), + // Doubao 其他 + entry("doubao-character", 8192), + entry("doubao-vision", 131072), + + // ========== MiniMax ========== + // MiniMax 01 系列 (最新) + entry("minimax-text-01", 1048576), + entry("minimax-vision-01", 1048576), + // ABAB 7 系列 + entry("abab7-chat", 245000), + entry("abab7-chat-preview", 245000), + // ABAB 6.5 系列 + entry("abab6.5-chat", 245000), + entry("abab6.5s-chat", 245000), + entry("abab6.5g-chat", 245000), + entry("abab6.5t-chat", 245000), + // ABAB 5.5 系列 + entry("abab5.5-chat", 16384), + entry("abab5.5s-chat", 16384), + + // ========== 阶跃星辰 ========== + entry("step-1-8k", 8192), + entry("step-1-32k", 32768), + entry("step-1-128k", 131072), + entry("step-1-256k", 262144), + entry("step-1-flash", 8192), + entry("step-2-16k", 16384), + + // ========== Groq ========== + entry("llama-3.1-405b-reasoning", 131072), + entry("llama-3.1-70b-versatile", 131072), + entry("llama-3.1-8b-instant", 131072), + entry("llama-3.2-1b-preview", 131072), + entry("llama-3.2-3b-preview", 131072), + entry("llama-3.2-11b-vision-preview", 131072), + entry("llama-3.2-90b-vision-preview", 131072), + entry("llama-3.3-70b-versatile", 131072), + entry("mixtral-8x7b-32768", 32768), + entry("gemma2-9b-it", 8192), + + // ========== Ollama 本地模型 ========== + // Llama 4 系列 (最新) + entry("llama4", 1048576), + entry("llama4-scout", 1048576), + entry("llama4-maverick", 1048576), + // Llama 3.x 系列 + entry("llama3", 8192), + entry("llama3:70b", 8192), + entry("llama3.1", 131072), + entry("llama3.1:8b", 131072), + entry("llama3.1:70b", 131072), + entry("llama3.2", 131072), + entry("llama3.2:1b", 131072), + entry("llama3.2:3b", 131072), + entry("llama3.3", 131072), + entry("llama3.3:70b", 131072), + // Qwen 本地 + entry("qwen2.5:7b", 131072), + entry("qwen2.5:14b", 131072), + entry("qwen2.5:32b", 131072), + entry("qwen2.5:72b", 131072), + // DeepSeek 本地 + entry("deepseek-r1:7b", 131072), + entry("deepseek-r1:8b", 131072), + entry("deepseek-r1:14b", 131072), + entry("deepseek-r1:32b", 131072), + entry("deepseek-r1:70b", 131072), + // Mistral 系列 + entry("mistral", 32768), + entry("mistral:7b", 32768), + entry("mistral-large", 128000), + entry("mixtral", 32768), + entry("mixtral:8x7b", 32768), + entry("mixtral:8x22b", 65536), + // CodeLlama + entry("codellama", 16384), + entry("codellama:7b", 16384), + entry("codellama:13b", 16384), + entry("codellama:34b", 16384), + // Gemma 系列 + entry("gemma", 8192), + entry("gemma2", 8192), + entry("gemma2:9b", 8192), + entry("gemma2:27b", 8192), + entry("gemma3", 131072), + // Phi 系列 + entry("phi-3", 128000), + entry("phi-3-mini", 128000), + entry("phi-3-medium", 128000), + entry("phi-4", 16384), + // 其他本地模型 + entry("starcoder2", 16384), + entry("nomic-embed-text", 8192), + + // ========== 其他国产模型 ========== + // 零一万物 Yi + entry("yi-34b-chat", 4096), + entry("yi-large", 32768), + entry("yi-large-turbo", 32768), + entry("yi-lightning", 16384), + entry("yi-large-rag", 32768), + entry("yi-vision", 16384), + entry("yi-1.5", 32768), + entry("yi-1.5-9b", 32768), + entry("yi-1.5-34b", 32768), + // 百川 + entry("baichuan2", 4096), + entry("baichuan2-7b", 4096), + entry("baichuan2-13b", 4096), + entry("baichuan4", 131072), + entry("baichuan-4", 131072), + entry("baichuan-3", 131072), + // 书生 InternLM + entry("internlm2", 32768), + entry("internlm2-chat", 32768), + entry("internlm3", 32768), + entry("internlm3-chat", 32768), + // 澜舟科技孟子 + entry("mengzi", 4096), + entry("mengzi-gpt", 4096), + // 商汤日日新 + entry("sensechat", 8192), + entry("sensechat-5", 8192), + entry("sensechat-128k", 131072), + // 昆仑万维天工 + entry("skywork", 8192), + entry("skywork-13b", 8192), + entry("tiangong", 8192), + // 智源研究院 + entry("aquila", 2048), + entry("aquila2", 4096), + + // ========== Mistral AI ========== + entry("mistral-small", 128000), + entry("mistral-medium", 128000), + entry("mistral-large-2407", 128000), + entry("mistral-large-2411", 128000), + entry("codestral", 32768), + entry("pixtral-12b", 128000), + entry("pixtral-large", 128000), + + // ========== xAI Grok ========== + // Grok 3 系列 (最新) + entry("grok-3", 131072), + entry("grok-3-fast", 131072), + entry("grok-3-mini", 131072), + entry("grok-3-mini-fast", 131072), + // Grok 2 系列 + entry("grok-2", 131072), + entry("grok-2-1212", 131072), + entry("grok-2-vision", 131072), + entry("grok-2-vision-1212", 131072), + // Grok 1 + entry("grok-1", 8192), + entry("grok-beta", 131072), + + // ========== Cohere ========== + entry("command", 4096), + entry("command-light", 4096), + entry("command-r", 128000), + entry("command-r-plus", 128000), + entry("command-a", 128000), + entry("command-r7b", 128000), + entry("c4ai-aya-expanse", 8192), + + // ========== AI21 ========== + entry("jamba-1.5", 256000), + entry("jamba-1.5-mini", 256000), + entry("jamba-instruct", 256000), + entry("jurassic-2", 8192), + + // ========== Perplexity ========== + entry("sonar", 128000), + entry("sonar-pro", 128000), + entry("sonar-reasoning", 128000), + entry("sonar-reasoning-pro", 128000), + entry("llama-3.1-sonar-small", 127072), + entry("llama-3.1-sonar-large", 127072), + + // ========== Amazon Bedrock ========== + entry("amazon-titan-text", 8000), + entry("amazon-titan-text-express", 8000), + entry("amazon-nova-pro", 300000), + entry("amazon-nova-lite", 300000), + entry("amazon-nova-micro", 128000), + + // ========== 零一万物 Yi ========== + entry("yi-lightning-pro", 16384), + entry("yi-spark", 16384), + entry("yi-1.5-6b", 32768), + entry("yi-1.5-9b-chat-16k", 16384), + + // ========== 百川 ========== + entry("baichuan-2-turbo", 4096), + entry("baichuan2-turbo", 4096), + entry("baichuan-2-53b", 4096), + + // ========== 书生 InternLM ========== + entry("internlm2-20b", 32768), + entry("internlm2-chat-20b", 32768), + entry("internlm-xcomposer2", 32768), + entry("internlm-xcomposer2-4khd", 4096), + + // ========== 商汤日日新 ========== + entry("sensechat-32k", 32768), + entry("sensechat-256k", 262144), + entry("sensechat-vision", 8192), + + // ========== 昆仑万维天工 ========== + entry("skywork-13b-chat", 8192), + entry("tiangong-4k", 4096), + entry("tiangong-32k", 32768), + + // ========== 智源研究院 ========== + entry("aquila2-34b", 4096), + entry("aquila2-70b", 4096), + entry("aquilachat2-34b", 4096), + + // ========== 澜舟科技孟子 ========== + entry("mengzi-gpt-4", 4096), + entry("mengzi-luoyu", 4096), + + // ========== Groq 补充 ========== + entry("gemma2-27b-it", 8192), + entry("llama-3.3-70b-specdec", 8192), + entry("llama-guard-3-8b", 8192), + + // ========== Ollama 补充 ========== + entry("llama3.1:405b", 131072), + entry("qwen2.5:0.5b", 131072), + entry("qwen2.5:1.5b", 131072), + entry("deepseek-v2:16b", 128000), + entry("phi-3.5", 128000), + entry("phi-3.5-mini", 128000), + entry("llava", 4096), + entry("llava:7b", 4096), + entry("llava:13b", 4096), + + // ========== Mistral AI 补充 ========== + entry("mistral-large-2502", 128000), + entry("codestral-mamba", 256000), + entry("ministral-8b", 128000), + entry("ministral-3b", 128000), + + // ========== xAI Grok 补充 ========== + entry("grok-3-deep-research", 131072), + + // ========== 豆包补充 ========== + entry("doubao-1.5-thinking", 131072), + entry("doubao-1.5-thinking-pro", 131072), + entry("doubao-1.5-thinking-pro-32k", 32768), + entry("doubao-1.5-thinking-pro-128k", 131072), + + // ========== MiniMax 补充 ========== + entry("minimax-01", 1048576), + entry("speech-01", 8192), + entry("video-01", 8192), + + // ========== 阶跃星辰补充 ========== + entry("step-1v-8k", 8192), + entry("step-1v-32k", 32768), + entry("step-2-128k", 131072), + + // ========== 讯飞星火补充 ========== + entry("spark-4.0-ultra-128k", 131072), + entry("spark-4.0-pro", 8192), + entry("spark-4.0-pro-128k", 131072), + entry("spark-v4.0-ultra", 8192), + + // ========== 月之暗面补充 ========== + entry("kimi-k2-instruct", 131072), + entry("moonshot-v1-auto", 8192), + + // ========== 通义千问补充 ========== + entry("qwq-plus-latest", 131072), + entry("qwen2.5-omni-7b", 32768), + entry("qwen-vl-max-longcontext", 32768), + entry("qwen-vl-ocr", 8192), + entry("qwen-audio-chat", 8192), + entry("qwen2-audio", 8192), + entry("qwen2.5-math-72b", 4096), + entry("qwen2.5-math-7b", 4096), + entry("qwen2.5-coder-7b", 131072), + entry("qwen2.5-coder-32b", 131072), + + // ========== 智谱补充 ========== + entry("glm-4v", 8192), + entry("glm-4v-plus", 8192), + entry("glm-4v-flash", 8192), + entry("glm-4-audio", 8192), + + // ========== OpenAI 补充 ========== + entry("gpt-4.5-turbo-preview", 128000), + entry("gpt-4.5-turbo-2025-02-27", 128000), + entry("o1-pro-2025-03-19", 200000), + + // ========== 百度文心补充 ========== + entry("ernie-4.0-turbo-8k", 8192), + entry("ernie-4.0-turbo-128k", 131072), + entry("ernie-3.5-128k", 131072), + entry("ernie-x1-32k", 32768), + entry("ernie-x1-128k", 131072), + + // ========== Claude 补充 ========== + entry("claude-opus-4-20250514", 1048576), + entry("claude-sonnet-4-20250514", 200000), + entry("claude-haiku-4-20250514", 200000), + entry("claude-3-5-sonnet-20241022", 200000), + entry("claude-3-5-haiku-20241022", 200000), + entry("claude-3-opus-20240229", 200000), + entry("claude-3-sonnet-20240229", 200000), + entry("claude-3-haiku-20240307", 200000), + + // ========== Google Gemini 补充 ========== + entry("gemini-1.5-pro-002", 1048576), + entry("gemini-1.5-flash-002", 1048576), + entry("gemini-2.0-flash-exp", 1048576), + entry("gemini-2.5-pro-preview", 1048576), + entry("gemini-2.5-flash-preview", 1048576), + entry("gemini-2.0-flash-thinking-exp", 1048576), + entry("gemini-2.0-flash-thinking-exp-1219", 1048576), + entry("gemini-exp-1206", 1048576), + entry("gemini-embedding", 8192), + entry("text-embedding-gecko", 8192), + + // ========== 本地/开源模型补充 ========== + entry("solar-10.7b", 4096), + entry("solar-pro", 32768), + entry("yi-1.5-34b-chat", 4096), + entry("openchat-3.5", 8192), + entry("openchat-3.6", 8192), + entry("wizardlm-2-7b", 32000), + entry("wizardlm-2-8x22b", 64000), + entry("vicuna-7b", 4096), + entry("vicuna-13b", 4096), + entry("vicuna-33b", 4096), + entry("alpaca-7b", 2048), + entry("dolly-v2", 2048), + entry("falcon-7b", 2048), + entry("falcon-40b", 2048), + entry("falcon-180b", 2048), + entry("mpt-7b", 2048), + entry("mpt-30b", 8192), + entry("redpajama-incite", 2048), + entry("stablelm-base-alpha-7b", 4096), + entry("stablelm-zephyr-3b", 4096), + entry("pythia-12b", 2048), + entry("cerebras-gpt", 2048), + entry("xgen-7b", 8192), + entry("xgen-8k", 8192), + entry("open-llama-7b", 2048), + entry("open-llama-13b", 2048), + entry("stable-code-3b", 16384), + entry("replit-code", 4096), + entry("codegen2", 2048), + entry("polycoder", 2048), + entry("santacoder", 2048), + entry("incoder", 2048), + entry("opt-6.7b", 2048), + entry("opt-30b", 2048), + entry("opt-66b", 2048), + entry("bloom", 2048), + entry("bloomz", 2048), + entry("gpt-neox-20b", 2048), + entry("gpt-j-6b", 2048), + entry("gpt-2", 1024), + entry("gpt-2-large", 1024), + entry("gpt-2-xl", 1024), + + // ========== Embedding 模型 ========== + entry("text-embedding-ada-002", 8191), + entry("text-embedding-3-small", 8191), + entry("text-embedding-3-large", 8191), + entry("text-embedding-v1", 2048), + entry("text-embedding-v2", 2048), + entry("text-embedding-v3", 8192), + + // ========== 图像生成模型 ========== + entry("dall-e-2", 4000), + entry("dall-e-3", 4000), + entry("stable-diffusion-xl", 2048), + entry("midjourney", 2048) + ); + + /** + * 默认 Token 限制(保守值) + */ + private static final int DEFAULT_LIMIT = 4096; + + /** + * 表示模型未知的特殊值 + * 当模型不在已知列表中时返回此值,用于触发回退策略 + */ + public static final int UNKNOWN_LIMIT = -1; + + /** + * 检查模型是否在已知列表中 + * + * @param modelName 模型名称 + * @return 是否已知 + */ + public static boolean isKnownModel(String modelName) { + if (modelName == null || modelName.isEmpty()) { + return false; + } + + String name = modelName.toLowerCase(); + + // 1. 精确匹配 + if (TOKEN_LIMITS.containsKey(name)) { + return true; + } + + // 2. 模糊匹配 + for (String key : TOKEN_LIMITS.keySet()) { + if (name.contains(key)) { + return true; + } + } + + // 3. 根据名称特征推断(如 128k, 32k 等) + if (name.contains("128k") || name.contains("32k") || name.contains("16k") || name.contains("long")) { + return true; + } + + return false; + } + + /** + * 获取模型的 Token 限制(未知模型返回 UNKNOWN_LIMIT) + * 用于判断是否需要回退到固定消息数量策略 + * + * @param modelName 模型名称 + * @return Token 限制,未知模型返回 UNKNOWN_LIMIT (-1) + */ + public static int getLimitOrUnknown(String modelName) { + if (!isKnownModel(modelName)) { + return UNKNOWN_LIMIT; + } + return getLimit(modelName); + } + + /** + * 获取模型的 Token 限制 + * + * @param modelName 模型名称 + * @return Token 限制 + */ + public static int getLimit(String modelName) { + if (modelName == null || modelName.isEmpty()) { + return DEFAULT_LIMIT; + } + + String name = modelName.toLowerCase(); + + // 1. 精确匹配 + if (TOKEN_LIMITS.containsKey(name)) { + return TOKEN_LIMITS.get(name); + } + + // 2. 模糊匹配(处理带版本号或前缀的模型名) + for (Map.Entry entry : TOKEN_LIMITS.entrySet()) { + if (name.contains(entry.getKey())) { + return entry.getValue(); + } + } + + // 3. 根据名称特征推断 + if (name.contains("128k") || name.contains("128k")) { + return 128000; + } + if (name.contains("32k")) { + return 32768; + } + if (name.contains("16k")) { + return 16384; + } + if (name.contains("long")) { + return 100000; + } + + return DEFAULT_LIMIT; + } + + /** + * 获取输入 Token 上限(预留回复空间) + * + * @param modelName 模型名称 + * @param reservedForReply 预留给回复的 Token 数 + * @return 输入 Token 上限 + */ + public static int getInputLimit(String modelName, int reservedForReply) { + int limit = getLimit(modelName); + return Math.max(limit - reservedForReply, 1000); + } + + private static Map.Entry entry(String key, Integer value) { + return Map.entry(key, value); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/TokenBasedChatMemory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/TokenBasedChatMemory.java new file mode 100644 index 00000000..7711020f --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/TokenBasedChatMemory.java @@ -0,0 +1,356 @@ +package org.ruoyi.service.chat.impl.memory; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.memory.ChatMemory; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.store.memory.chat.ChatMemoryStore; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.service.chat.impl.memory.strategy.CompressionContext; +import org.ruoyi.service.chat.impl.memory.strategy.CompressionResult; +import org.ruoyi.service.chat.impl.memory.strategy.CompressionStrategyManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * 基于 Token 的聊天内存管理 + * 支持 Token 窗口限制和可选的摘要压缩 + * + * @author yang + * @date 2026-04-27 + */ +@Slf4j +public class TokenBasedChatMemory implements ChatMemory { + + /** + * 内存 ID(通常是会话 ID) + */ + private final Object memoryId; + + /** + * 最大 Token 数 + */ + private final int maxTokens; + + /** + * Token 计数器 + */ + private final TokenCounter tokenCounter; + + /** + * 持久化存储 + */ + private final ChatMemoryStore store; + + /** + * 触发摘要的 Token 使用比例(如 0.7 表示 70%) + */ + private final double summarizeTokenRatio; + + /** + * 触发摘要的消息数量阈值(避免消息太少时摘要无意义) + */ + private final int summarizeThreshold; + + /** + * 用于摘要的 LLM 模型(可选) + */ + private final ChatModel summarizer; + + /** + * 是否保留系统消息(不被截断) + */ + private final boolean preserveSystemMessages; + + /** + * 预留给回复的 Token 数 + */ + private final int reservedForReply; + + /** + * 压缩策略管理器(可选) + * 如果设置,优先使用策略管理器进行压缩 + */ + private final CompressionStrategyManager strategyManager; + + /** + * 构造函数 + */ + private TokenBasedChatMemory(Builder builder) { + this.memoryId = builder.memoryId; + this.maxTokens = builder.maxTokens; + this.tokenCounter = builder.tokenCounter != null ? builder.tokenCounter : new TokenCounter(); + this.store = builder.store; + this.summarizeTokenRatio = builder.summarizeTokenRatio; + this.summarizeThreshold = builder.summarizeThreshold; + this.summarizer = builder.summarizer; + this.preserveSystemMessages = builder.preserveSystemMessages; + this.reservedForReply = builder.reservedForReply; + this.strategyManager = builder.strategyManager; + } + + @Override + public Object id() { + return memoryId; + } + + @Override + public void add(ChatMessage message) { + List messages = new ArrayList<>(messages()); + messages.add(message); + store.updateMessages(memoryId, messages); + } + + @Override + public List messages() { + List messages = store != null ? store.getMessages(memoryId) : new ArrayList<>(); + + if (messages == null || messages.isEmpty()) { + return messages; + } + + int totalTokens = tokenCounter.countMessages(messages); + int effectiveMaxTokens = Math.max(1, maxTokens - reservedForReply); + + // 输出当前状态日志 + log.info("[Token内存管理] 会话={}, 消息数={}, 当前Token={}, Token上限={}, 预留回复空间={}", + memoryId, messages.size(), totalTokens, maxTokens, reservedForReply); + + // 使用策略管理器进行压缩 + if (strategyManager != null) { + CompressionContext context = buildCompressionContext(messages, totalTokens); + CompressionResult result = strategyManager.execute(context); + if (result.isSuccess() && result.getStrategyName() != null && !result.getStrategyName().equals("none")) { + log.info("[策略框架] 压缩成功: 策略={}, Token: {} → {}, 消息数: {} → {}", + result.getStrategyName(), result.getOriginalTokens(), result.getCompressedTokens(), + result.getOriginalMessageCount(), result.getCompressedMessageCount()); + + // 最终保障:检查是否仍超限 + List resultMessages = result.getMessages(); + int resultTokens = result.getCompressedTokens(); + if (resultTokens > effectiveMaxTokens) { + log.warn("[Token内存管理] 策略执行后仍超限,执行紧急截断"); + return emergencyTruncate(resultMessages, effectiveMaxTokens); + } + return resultMessages; + } else if (result.getErrorMessage() != null) { + log.warn("[策略框架] 压缩失败: {}", result.getErrorMessage()); + // 策略失败,执行紧急截断 + if (totalTokens > effectiveMaxTokens) { + log.warn("[Token内存管理] 策略失败且超限,执行紧急截断"); + return emergencyTruncate(messages, effectiveMaxTokens); + } + } + } + + // 无策略框架或策略未触发,检查是否需要截断 + if (totalTokens > effectiveMaxTokens) { + log.warn("[Token内存管理] 无策略框架且超限,执行紧急截断"); + return emergencyTruncate(messages, effectiveMaxTokens); + } + + return messages; + } + + /** + * 紧急截断 + * 当策略框架不可用或失败时,确保返回的消息不超限 + */ + private List emergencyTruncate(List messages, int maxTokens) { + if (messages == null || messages.isEmpty()) { + return messages; + } + + // 分离系统消息和普通消息 + List systemMessages = new ArrayList<>(); + List regularMessages = new ArrayList<>(); + + for (ChatMessage msg : messages) { + if (preserveSystemMessages && msg instanceof SystemMessage) { + systemMessages.add(msg); + } else { + regularMessages.add(msg); + } + } + + // 计算系统消息占用的 Token + int systemTokens = tokenCounter.countMessages(systemMessages); + int availableTokens = maxTokens - systemTokens; + + if (availableTokens <= 0) { + log.warn("[紧急截断] 系统消息已占用全部 Token 空间,仅保留系统消息"); + return systemMessages; + } + + // 从最新的消息开始保留 + List keptMessages = new ArrayList<>(); + int currentTokens = 0; + + for (int i = regularMessages.size() - 1; i >= 0; i--) { + ChatMessage msg = regularMessages.get(i); + int msgTokens = tokenCounter.countMessage(msg); + + if (currentTokens + msgTokens <= availableTokens) { + keptMessages.add(0, msg); + currentTokens += msgTokens; + } else { + break; + } + } + + // 合并结果 + List result = new ArrayList<>(systemMessages); + result.addAll(keptMessages); + + log.info("[紧急截断] 完成: 原消息数={} → 截断后={}, 系统消息={}", + messages.size(), result.size(), systemMessages.size()); + + return result; + } + + /** + * 构建压缩上下文 + */ + private CompressionContext buildCompressionContext(List messages, int totalTokens) { + return CompressionContext.builder() + .memoryId(memoryId) + .messages(messages) + .currentTokens(totalTokens) + .maxTokens(maxTokens) + .reservedForReply(reservedForReply) + .summarizer(summarizer) + .tokenCounter(tokenCounter) + .preserveSystemMessages(preserveSystemMessages) + .summarizeTokenRatio(summarizeTokenRatio) + .summarizeThreshold(summarizeThreshold) + .build(); + } + + @Override + public void clear() { + if (store != null) { + store.deleteMessages(memoryId); + } + } + + /** + * 创建构建器 + */ + public static Builder builder() { + return new Builder(); + } + + /** + * 从模型配置创建 + */ + public static TokenBasedChatMemory fromModel(Object memoryId, ChatModelVo model, + ChatMemoryStore store, ChatModel summarizer) { + int maxTokens = ModelTokenLimits.getLimit(model.getModelName()); + int inputLimit = ModelTokenLimits.getInputLimit(model.getModelName(), 2000); + + return builder() + .memoryId(memoryId) + .maxTokens(inputLimit) + .store(store) + .summarizer(summarizer) + .summarizeThreshold(30) + .preserveSystemMessages(true) + .reservedForReply(2000) + .build(); + } + + /** + * 从模型配置创建(带策略管理器) + */ + public static TokenBasedChatMemory fromModel(Object memoryId, ChatModelVo model, + ChatMemoryStore store, ChatModel summarizer, + CompressionStrategyManager strategyManager) { + int maxTokens = ModelTokenLimits.getLimit(model.getModelName()); + int inputLimit = ModelTokenLimits.getInputLimit(model.getModelName(), 2000); + + return builder() + .memoryId(memoryId) + .maxTokens(inputLimit) + .store(store) + .summarizer(summarizer) + .summarizeThreshold(30) + .preserveSystemMessages(true) + .reservedForReply(2000) + .strategyManager(strategyManager) + .build(); + } + + /** + * 构建器 + */ + public static class Builder { + private Object memoryId; + private int maxTokens = 4096; + private TokenCounter tokenCounter; + private ChatMemoryStore store; + private double summarizeTokenRatio = 0.7; + private int summarizeThreshold = 10; + private ChatModel summarizer; + private boolean preserveSystemMessages = true; + private int reservedForReply = 2000; + private CompressionStrategyManager strategyManager; + + public Builder memoryId(Object memoryId) { + this.memoryId = memoryId; + return this; + } + + public Builder maxTokens(int maxTokens) { + this.maxTokens = maxTokens; + return this; + } + + public Builder tokenCounter(TokenCounter tokenCounter) { + this.tokenCounter = tokenCounter; + return this; + } + + public Builder store(ChatMemoryStore store) { + this.store = store; + return this; + } + + public Builder summarizeTokenRatio(double summarizeTokenRatio) { + this.summarizeTokenRatio = summarizeTokenRatio; + return this; + } + + public Builder summarizeThreshold(int summarizeThreshold) { + this.summarizeThreshold = summarizeThreshold; + return this; + } + + public Builder summarizer(ChatModel summarizer) { + this.summarizer = summarizer; + return this; + } + + public Builder preserveSystemMessages(boolean preserveSystemMessages) { + this.preserveSystemMessages = preserveSystemMessages; + return this; + } + + public Builder reservedForReply(int reservedForReply) { + this.reservedForReply = reservedForReply; + return this; + } + + public Builder strategyManager(CompressionStrategyManager strategyManager) { + this.strategyManager = strategyManager; + return this; + } + + public TokenBasedChatMemory build() { + Objects.requireNonNull(memoryId, "memoryId 不能为空"); + return new TokenBasedChatMemory(this); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/TokenCounter.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/TokenCounter.java new file mode 100644 index 00000000..d1df89bf --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/TokenCounter.java @@ -0,0 +1,166 @@ +package org.ruoyi.service.chat.impl.memory; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import lombok.extern.slf4j.Slf4j; + +/** + * Token 计数器 + * 提供文本和消息的 Token 估算功能 + * + * @author yang + * @date 2026-04-27 + */ +@Slf4j +public class TokenCounter { + + /** + * 每条消息的固定格式开销(OpenAI 格式) + * <|start|>{role}\n{content}<|end|>\n + */ + private static final int MESSAGE_FORMAT_OVERHEAD = 4; + + /** + * 对话开始标记开销 + */ + private static final int CONVERSATION_OVERHEAD = 3; + + /** + * 中文平均每个字符的 Token 比例(约 2 字符 = 1 token) + */ + private static final double CHINESE_TOKEN_RATIO = 0.5; + + /** + * 英文平均每个字符的 Token 比例(约 4 字符 = 1 token) + */ + private static final double ENGLISH_TOKEN_RATIO = 0.25; + + /** + * 计算文本的 Token 数量(估算) + * + * @param text 文本内容 + * @return Token 数量 + */ + public int countTokens(String text) { + if (text == null || text.isEmpty()) { + return 0; + } + + int chineseChars = 0; + int otherChars = 0; + + for (char c : text.toCharArray()) { + if (isChineseChar(c)) { + chineseChars++; + } else { + otherChars++; + } + } + + // 混合估算 + return (int) Math.ceil(chineseChars * CHINESE_TOKEN_RATIO + otherChars * ENGLISH_TOKEN_RATIO); + } + + /** + * 计算单条消息的 Token 数量 + * + * @param message 消息 + * @return Token 数量 + */ + public int countMessage(ChatMessage message) { + int tokens = MESSAGE_FORMAT_OVERHEAD; + + // 角色名称 + tokens += countTokens(getRoleName(message)); + + // 消息内容 + tokens += countTokens(extractText(message)); + + // 名称字段(如果有) + if (message instanceof UserMessage userMessage && userMessage.name() != null) { + tokens += countTokens(userMessage.name()); + } + + return tokens; + } + + /** + * 从消息中提取文本内容 + */ + private String extractText(ChatMessage message) { + if (message instanceof AiMessage aiMessage) { + return aiMessage.text(); + } else if (message instanceof UserMessage userMessage) { + return userMessage.singleText(); + } else if (message instanceof SystemMessage systemMessage) { + return systemMessage.text(); + } else if (message instanceof ToolExecutionResultMessage toolMessage) { + return toolMessage.text(); + } + return ""; + } + + /** + * 计算消息列表的总 Token 数量 + * + * @param messages 消息列表 + * @return Token 总数 + */ + public int countMessages(Iterable messages) { + if (messages == null) { + return CONVERSATION_OVERHEAD; + } + + int total = CONVERSATION_OVERHEAD; + int count = 0; + + for (ChatMessage message : messages) { + total += countMessage(message); + count++; + } + + log.debug("Token 计数: {} 条消息, 约 {} tokens", count, total); + return total; + } + + /** + * 估算指定 Token 预算下可容纳的消息数量 + * + * @param maxTokens Token 预算 + * @param avgTokensPerMessage 每条消息平均 Token 数 + * @return 可容纳的消息数量 + */ + public int estimateMaxMessages(int maxTokens, int avgTokensPerMessage) { + if (avgTokensPerMessage <= 0) { + avgTokensPerMessage = 50; // 默认每条消息 50 tokens + } + return Math.max(1, (maxTokens - CONVERSATION_OVERHEAD) / (avgTokensPerMessage + MESSAGE_FORMAT_OVERHEAD)); + } + + /** + * 获取消息的角色名称 + */ + private String getRoleName(ChatMessage message) { + if (message instanceof SystemMessage) { + return "system"; + } else if (message instanceof UserMessage) { + return "user"; + } else if (message instanceof AiMessage) { + return "assistant"; + } else if (message instanceof ToolExecutionResultMessage) { + return "tool"; + } + return "user"; + } + + /** + * 判断是否为中文字符 + */ + private boolean isChineseChar(char c) { + Character.UnicodeScript script = Character.UnicodeScript.of(c); + return script == Character.UnicodeScript.HAN; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/CompressionContext.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/CompressionContext.java new file mode 100644 index 00000000..0a3273b2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/CompressionContext.java @@ -0,0 +1,112 @@ +package org.ruoyi.service.chat.impl.memory.strategy; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.model.chat.ChatModel; +import lombok.Builder; +import lombok.Data; +import org.ruoyi.service.chat.impl.memory.TokenCounter; + +import java.util.List; + +/** + * 压缩上下文 + * 包含压缩所需的所有输入信息 + * + * @author yang + * @date 2026-04-29 + */ +@Data +@Builder +public class CompressionContext { + + /** + * 内存 ID(通常是会话 ID) + */ + private Object memoryId; + + /** + * 原始消息列表 + */ + private List messages; + + /** + * 当前 Token 数量 + */ + private int currentTokens; + + /** + * Token 上限 + */ + private int maxTokens; + + /** + * 预留给回复的 Token 数 + */ + @Builder.Default + private int reservedForReply = 2000; + + /** + * 有效 Token 上限(maxTokens - reservedForReply) + * 添加边界检查,确保返回值 >= 1,避免负数或零导致的计算错误 + */ + public int getEffectiveMaxTokens() { + int effective = maxTokens - reservedForReply; + // 确保至少返回 1,避免负数或零导致的策略判断失效 + return Math.max(1, effective); + } + + /** + * Token 使用比例 + * 添加除零保护,当 effectiveMaxTokens 为 0 时返回 1.0(表示已满) + */ + public double getUsageRatio() { + int effectiveMax = maxTokens - reservedForReply; + if (effectiveMax <= 0) { + // 当预留空间超过或等于上限时,视为已满 + return 1.0; + } + return (double) currentTokens / effectiveMax; + } + + /** + * 是否超过 Token 限制 + */ + public boolean isOverLimit() { + return currentTokens > getEffectiveMaxTokens(); + } + + /** + * 超出的 Token 数量 + */ + public int getExcessTokens() { + return Math.max(0, currentTokens - getEffectiveMaxTokens()); + } + + /** + * 摘要模型(可选,用于摘要策略) + */ + private ChatModel summarizer; + + /** + * Token 计数器 + */ + private TokenCounter tokenCounter; + + /** + * 是否保留系统消息 + */ + @Builder.Default + private boolean preserveSystemMessages = true; + + /** + * 摘要触发阈值 - Token 使用比例 + */ + @Builder.Default + private double summarizeTokenRatio = 0.7; + + /** + * 摘要触发阈值 - 消息数量 + */ + @Builder.Default + private int summarizeThreshold = 10; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/CompressionResult.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/CompressionResult.java new file mode 100644 index 00000000..9f9d1c2b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/CompressionResult.java @@ -0,0 +1,119 @@ +package org.ruoyi.service.chat.impl.memory.strategy; + +import dev.langchain4j.data.message.ChatMessage; +import lombok.Builder; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 压缩结果 + * 包含压缩后的消息和元数据 + * + * @author yang + * @date 2026-04-29 + */ +@Data +@Builder +public class CompressionResult { + + /** + * 压缩后的消息列表 + */ + @Builder.Default + private List messages = new ArrayList<>(); + + /** + * 压缩后的 Token 数量 + */ + private int compressedTokens; + + /** + * 压缩前 Token 数量 + */ + private int originalTokens; + + /** + * 压缩前消息数量 + */ + private int originalMessageCount; + + /** + * 压缩后消息数量 + */ + private int compressedMessageCount; + + /** + * 使用的压缩策略名称 + */ + private String strategyName; + + /** + * 是否成功 + */ + @Builder.Default + private boolean success = true; + + /** + * 错误信息(如果失败) + */ + private String errorMessage; + + /** + * 压缩摘要(可选,用于记录压缩了哪些内容) + */ + private String compressionSummary; + + /** + * 被移除的消息数量 + */ + public int getRemovedCount() { + return originalMessageCount - compressedMessageCount; + } + + /** + * 节省的 Token 数量 + */ + public int getSavedTokens() { + return originalTokens - compressedTokens; + } + + /** + * Token 压缩比例 + */ + public double getCompressionRatio() { + if (originalTokens == 0) { + return 0; + } + return (double) getSavedTokens() / originalTokens; + } + + /** + * 创建失败结果 + */ + public static CompressionResult failure(String strategyName, String errorMessage) { + return CompressionResult.builder() + .strategyName(strategyName) + .success(false) + .errorMessage(errorMessage) + .build(); + } + + /** + * 创建成功结果 + */ + public static CompressionResult success(String strategyName, List messages, + int originalTokens, int compressedTokens, + int originalCount, int compressedCount) { + return CompressionResult.builder() + .strategyName(strategyName) + .success(true) + .messages(messages) + .originalTokens(originalTokens) + .compressedTokens(compressedTokens) + .originalMessageCount(originalCount) + .compressedMessageCount(compressedCount) + .build(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/CompressionStrategyManager.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/CompressionStrategyManager.java new file mode 100644 index 00000000..62be548e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/CompressionStrategyManager.java @@ -0,0 +1,208 @@ +package org.ruoyi.service.chat.impl.memory.strategy; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.service.chat.impl.memory.TokenCounter; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 压缩策略管理器 + * 管理多个压缩策略,按优先级执行 + * + * @author yang + * @date 2026-04-29 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CompressionStrategyManager { + + private final List strategies; + + /** + * 执行压缩 + * 按优先级依次尝试各策略,直到成功压缩到目标范围内 + * 如果所有策略执行后仍超限,强制执行截断作为最终兜底 + * + * @param context 压缩上下文 + * @return 压缩结果 + */ + public CompressionResult execute(CompressionContext context) { + List sortedStrategies = getSortedStrategies(); + + List currentMessages = context.getMessages(); + int currentTokens = context.getCurrentTokens(); + int originalCount = currentMessages.size(); + int originalTokens = currentTokens; + + List executedStrategies = new ArrayList<>(); + + for (MemoryCompressionStrategy strategy : sortedStrategies) { + // 更新上下文中的消息和 Token + CompressionContext updatedContext = CompressionContext.builder() + .memoryId(context.getMemoryId()) + .messages(currentMessages) + .currentTokens(currentTokens) + .maxTokens(context.getMaxTokens()) + .reservedForReply(context.getReservedForReply()) + .summarizer(context.getSummarizer()) + .tokenCounter(context.getTokenCounter()) + .preserveSystemMessages(context.isPreserveSystemMessages()) + .summarizeTokenRatio(context.getSummarizeTokenRatio()) + .summarizeThreshold(context.getSummarizeThreshold()) + .build(); + + // 检查是否需要压缩 + if (!strategy.needsCompression(updatedContext)) { + log.debug("[策略管理器] 策略 {} 不需要执行", strategy.getName()); + continue; + } + + log.info("[策略管理器] 执行策略: {}", strategy.getName()); + + // 执行压缩 + CompressionResult result = strategy.compress(updatedContext); + + if (result.isSuccess()) { + executedStrategies.add(strategy.getName()); + currentMessages = result.getMessages(); + currentTokens = result.getCompressedTokens(); + + log.info("[策略管理器] 策略 {} 执行成功, Token: {} → {}", + strategy.getName(), result.getOriginalTokens(), result.getCompressedTokens()); + + // 检查是否已达到目标 + if (currentTokens <= updatedContext.getEffectiveMaxTokens()) { + log.info("[策略管理器] 压缩完成,已达到目标范围"); + break; + } + + // 如果策略可组合,继续尝试下一个策略 + if (!strategy.isComposable()) { + log.info("[策略管理器] 策略 {} 不可组合,停止后续策略", strategy.getName()); + break; + } + } else { + log.warn("[策略管理器] 策略 {} 执行失败: {}", strategy.getName(), result.getErrorMessage()); + } + } + + // 最终保障:检查是否仍超限,如果是则强制截断 + int effectiveMaxTokens = context.getEffectiveMaxTokens(); + if (currentTokens > effectiveMaxTokens) { + log.warn("[策略管理器] 策略执行后仍超限 ({} > {}),执行强制截断兜底", + currentTokens, effectiveMaxTokens); + currentMessages = forceTruncate(currentMessages, effectiveMaxTokens, context); + currentTokens = context.getTokenCounter().countMessages(currentMessages); + executedStrategies.add("force-truncation"); + } + + // 构建最终结果 + return CompressionResult.builder() + .success(true) + .messages(currentMessages) + .originalTokens(originalTokens) + .compressedTokens(currentTokens) + .originalMessageCount(originalCount) + .compressedMessageCount(currentMessages.size()) + .strategyName(executedStrategies.isEmpty() ? "none" : executedStrategies.toString()) + .compressionSummary("执行策略: " + executedStrategies) + .build(); + } + + /** + * 强制截断作为最终兜底 + * 确保返回的消息永远不会超限 + */ + private List forceTruncate(List messages, int maxTokens, CompressionContext context) { + if (messages == null || messages.isEmpty()) { + return messages; + } + + boolean preserveSystem = context.isPreserveSystemMessages(); + TokenCounter tokenCounter = context.getTokenCounter(); + + // 分离系统消息和普通消息 + List systemMessages = new ArrayList<>(); + List regularMessages = new ArrayList<>(); + + for (ChatMessage msg : messages) { + if (preserveSystem && msg instanceof SystemMessage) { + systemMessages.add(msg); + } else { + regularMessages.add(msg); + } + } + + // 计算系统消息占用的 Token + int systemTokens = tokenCounter.countMessages(systemMessages); + int availableTokens = maxTokens - systemTokens; + + if (availableTokens <= 0) { + log.warn("[强制截断] 系统消息已占用全部 Token 空间,仅保留系统消息"); + return systemMessages; + } + + // 从最新的消息开始保留 + List keptMessages = new ArrayList<>(); + int currentTokens = 0; + + for (int i = regularMessages.size() - 1; i >= 0; i--) { + ChatMessage msg = regularMessages.get(i); + int msgTokens = tokenCounter.countMessage(msg); + + if (currentTokens + msgTokens <= availableTokens) { + keptMessages.add(0, msg); + currentTokens += msgTokens; + } else { + break; + } + } + + // 合并结果 + List result = new ArrayList<>(systemMessages); + result.addAll(keptMessages); + + log.info("[强制截断] 完成: 原消息数={} → 截断后={}, 系统消息={}", + messages.size(), result.size(), systemMessages.size()); + + return result; + } + + /** + * 获取指定策略 + * + * @param name 策略名称 + * @return 策略实例,不存在则返回 null + */ + public MemoryCompressionStrategy getStrategy(String name) { + return strategies.stream() + .filter(s -> s.getName().equals(name)) + .findFirst() + .orElse(null); + } + + /** + * 获取所有可用策略名称 + */ + public List getAvailableStrategies() { + return strategies.stream() + .map(MemoryCompressionStrategy::getName) + .toList(); + } + + /** + * 按优先级排序的策略列表 + */ + private List getSortedStrategies() { + return strategies.stream() + .sorted(Comparator.comparingInt(MemoryCompressionStrategy::getPriority)) + .toList(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/MemoryCompressionStrategy.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/MemoryCompressionStrategy.java new file mode 100644 index 00000000..51e26d64 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/MemoryCompressionStrategy.java @@ -0,0 +1,55 @@ +package org.ruoyi.service.chat.impl.memory.strategy; + + + +/** + * 内存压缩策略接口 + * 定义消息压缩的抽象行为,支持多种压缩算法 + * + * @author yang + * @date 2026-04-29 + */ +public interface MemoryCompressionStrategy { + + /** + * 获取策略名称 + * + * @return 策略名称(如 truncation, summarization) + */ + String getName(); + + /** + * 判断是否需要压缩 + * + * @param context 压缩上下文 + * @return true 表示需要压缩 + */ + boolean needsCompression(CompressionContext context); + + /** + * 执行压缩 + * + * @param context 压缩上下文 + * @return 压缩结果 + */ + CompressionResult compress(CompressionContext context); + + /** + * 获取策略优先级(数值越小优先级越高) + * 用于多策略组合时的执行顺序 + * + * @return 优先级(默认 100) + */ + default int getPriority() { + return 100; + } + + /** + * 是否支持与其他策略组合使用 + * + * @return true 表示可以组合 + */ + default boolean isComposable() { + return false; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/SummarizationStrategy.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/SummarizationStrategy.java new file mode 100644 index 00000000..dd6095b8 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/SummarizationStrategy.java @@ -0,0 +1,142 @@ +package org.ruoyi.service.chat.impl.memory.strategy; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.ChatModel; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.service.chat.impl.memory.TokenCounter; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 摘要策略 + * 当超过 Token 限制时,使用 LLM 对旧消息进行摘要压缩 + * + * @author yang + * @date 2026-04-29 + */ +@Slf4j +@Component +public class SummarizationStrategy implements MemoryCompressionStrategy { + + @Override + public String getName() { + return "summarization"; + } + + @Override + public int getPriority() { + return 50; // 高优先级,优先尝试摘要 + } + + @Override + public boolean isComposable() { + return true; // 可以与截断策略组合使用 + } + + @Override + public boolean needsCompression(CompressionContext context) { + // 条件:1. 有摘要模型 2. 消息数足够 3. Token 使用达到比例阈值 + // 摘要策略提前介入,在还没超限时就开始压缩,保留语义 + return context.getSummarizer() != null + && context.getMessages().size() > context.getSummarizeThreshold() + && context.getUsageRatio() >= context.getSummarizeTokenRatio(); + } + + @Override + public CompressionResult compress(CompressionContext context) { + List messages = context.getMessages(); + ChatModel summarizer = context.getSummarizer(); + TokenCounter tokenCounter = context.getTokenCounter(); + + if (summarizer == null) { + log.warn("[摘要策略] 摘要模型未配置,无法执行摘要"); + return CompressionResult.failure(getName(), "摘要模型未配置"); + } + + if (messages == null || messages.size() < context.getSummarizeThreshold()) { + log.debug("[摘要策略] 消息数 {} 小于阈值 {},跳过摘要", + messages != null ? messages.size() : 0, context.getSummarizeThreshold()); + return CompressionResult.failure(getName(), "消息数不足"); + } + + int originalTokens = context.getCurrentTokens(); + int originalCount = messages.size(); + + try { + // 分离系统消息和普通消息 + List systemMessages = new ArrayList<>(); + List regularMessages = new ArrayList<>(); + + for (ChatMessage msg : messages) { + if (msg instanceof SystemMessage) { + systemMessages.add(msg); + } else { + regularMessages.add(msg); + } + } + + // 选择要摘要的消息(前半部分) + int summarizeCount = regularMessages.size() / 2; + List toSummarize = regularMessages.subList(0, summarizeCount); + List toKeep = regularMessages.subList(summarizeCount, regularMessages.size()); + + log.info("[摘要策略] 开始摘要: 原消息数={}, 待摘要={}, 保留={}", + messages.size(), summarizeCount, toKeep.size()); + + // 构建摘要提示 + StringBuilder summaryPrompt = new StringBuilder(); + summaryPrompt.append("请用简洁的语言总结以下对话的关键信息,保留重要的上下文和用户偏好:\n\n"); + + for (ChatMessage msg : toSummarize) { + summaryPrompt.append(extractText(msg)).append("\n"); + } + + // 调用 LLM 生成摘要 + String summary = summarizer.chat(summaryPrompt.toString()); + log.info("[摘要策略] 生成摘要成功: {}...", + summary.substring(0, Math.min(100, summary.length()))); + + // 构建新的消息列表 + List result = new ArrayList<>(systemMessages); + result.add(SystemMessage.from("【历史对话摘要】" + summary)); + result.addAll(toKeep); + + int compressedTokens = tokenCounter.countMessages(result); + + log.info("[摘要策略] 完成: 原消息数={} → 新消息数={}, Token: {} → {}", + originalCount, result.size(), originalTokens, compressedTokens); + + return CompressionResult.success( + getName(), + result, + originalTokens, + compressedTokens, + originalCount, + result.size() + ); + + } catch (Exception e) { + log.error("[摘要策略] 执行失败: {}", e.getMessage(), e); + return CompressionResult.failure(getName(), e.getMessage()); + } + } + + /** + * 从消息中提取文本内容 + */ + private String extractText(ChatMessage message) { + if (message instanceof AiMessage aiMessage) { + return aiMessage.text(); + } else if (message instanceof UserMessage userMessage) { + return userMessage.singleText(); + } else if (message instanceof SystemMessage systemMessage) { + return systemMessage.text(); + } + return ""; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/TruncationStrategy.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/TruncationStrategy.java new file mode 100644 index 00000000..9c074079 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/memory/strategy/TruncationStrategy.java @@ -0,0 +1,115 @@ +package org.ruoyi.service.chat.impl.memory.strategy; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.service.chat.impl.memory.TokenCounter; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 截断策略 + * 当超过 Token 限制时,从旧消息开始截断,保留最近的消息 + * + * @author yang + * @date 2026-04-29 + */ +@Slf4j +@Component +public class TruncationStrategy implements MemoryCompressionStrategy { + + @Override + public String getName() { + return "truncation"; + } + + @Override + public int getPriority() { + return 100; // 默认优先级 + } + + @Override + public boolean needsCompression(CompressionContext context) { + // Token 超过限制时需要压缩 + return context.isOverLimit(); + } + + @Override + public CompressionResult compress(CompressionContext context) { + List messages = context.getMessages(); + int maxTokens = context.getEffectiveMaxTokens(); + boolean preserveSystem = context.isPreserveSystemMessages(); + TokenCounter tokenCounter = context.getTokenCounter(); + + if (messages == null || messages.isEmpty()) { + return CompressionResult.success(getName(), messages, 0, 0, 0, 0); + } + + int originalTokens = context.getCurrentTokens(); + int originalCount = messages.size(); + + // 分离系统消息和普通消息 + List systemMessages = new ArrayList<>(); + List regularMessages = new ArrayList<>(); + + for (ChatMessage msg : messages) { + if (preserveSystem && msg instanceof SystemMessage) { + systemMessages.add(msg); + } else { + regularMessages.add(msg); + } + } + + // 计算系统消息占用的 Token + int systemTokens = tokenCounter.countMessages(systemMessages); + int availableTokens = maxTokens - systemTokens; + + if (availableTokens <= 0) { + log.warn("[截断策略] 系统消息已占用全部 Token 空间,仅保留系统消息"); + return CompressionResult.success( + getName(), + systemMessages, + originalTokens, + systemTokens, + originalCount, + systemMessages.size() + ); + } + + // 从最新的消息开始保留(从后向前遍历) + List keptMessages = new ArrayList<>(); + int currentTokens = 0; + + for (int i = regularMessages.size() - 1; i >= 0; i--) { + ChatMessage msg = regularMessages.get(i); + int msgTokens = tokenCounter.countMessage(msg); + + if (currentTokens + msgTokens <= availableTokens) { + keptMessages.add(0, msg); // 添加到头部保持顺序 + currentTokens += msgTokens; + } else { + break; // 达到限制,停止 + } + } + + // 合并结果:系统消息 + 保留的普通消息 + List result = new ArrayList<>(systemMessages); + result.addAll(keptMessages); + + int compressedTokens = tokenCounter.countMessages(result); + + log.info("[截断策略] 完成: 原消息数={} → 截断后={}, Token: {} → {}", + originalCount, result.size(), originalTokens, compressedTokens); + + return CompressionResult.success( + getName(), + result, + originalTokens, + compressedTokens, + originalCount, + result.size() + ); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/memory/strategy/MemoryCompressionStrategyTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/memory/strategy/MemoryCompressionStrategyTest.java new file mode 100644 index 00000000..619fc564 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/memory/strategy/MemoryCompressionStrategyTest.java @@ -0,0 +1,383 @@ +package org.ruoyi.service.chat.impl.memory.strategy; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore; +import org.ruoyi.service.chat.impl.memory.ChatMemoryProperties; +import org.ruoyi.service.chat.impl.memory.TokenCounter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 内存压缩策略测试类 + * 测试两种策略:Token截断、摘要压缩 + * + * @author yang + * @date 2026-05-06 + */ +@Tag("dev") +class MemoryCompressionStrategyTest { + + private TokenCounter tokenCounter; + private TruncationStrategy truncationStrategy; + private SummarizationStrategy summarizationStrategy; + + @BeforeEach + void setUp() { + tokenCounter = new TokenCounter(); + truncationStrategy = new TruncationStrategy(); + summarizationStrategy = new SummarizationStrategy(); + } + + // ========== Token截断策略测试 ========== + + @Test + @DisplayName("Token截断策略 - Token超限时截断") + void testTruncation_ExceedTokenLimit() { + // 创建大量消息使Token超限 + List messages = createLargeMessages(50); + + int currentTokens = tokenCounter.countMessages(messages); + // 设置较小的Token限制 + int maxTokens = 2000; + + CompressionContext context = CompressionContext.builder() + .messages(messages) + .currentTokens(currentTokens) + .maxTokens(maxTokens) + .reservedForReply(500) + .tokenCounter(tokenCounter) + .preserveSystemMessages(true) + .build(); + + // 验证需要压缩 + assertTrue(context.isOverLimit()); + assertTrue(truncationStrategy.needsCompression(context)); + + // 执行压缩 + CompressionResult result = truncationStrategy.compress(context); + + // 验证结果 + assertTrue(result.isSuccess()); + // 新Token数应小于有效上限 + assertTrue(result.getCompressedTokens() <= context.getEffectiveMaxTokens()); + // 系统消息应保留 + assertTrue(hasSystemMessage(result.getMessages())); + } + + @Test + @DisplayName("Token截断策略 - Token未超限时不截断") + void testTruncation_BelowTokenLimit() { + List messages = createTestMessages(1, 10); + + CompressionContext context = CompressionContext.builder() + .messages(messages) + .currentTokens(tokenCounter.countMessages(messages)) + .maxTokens(10000) // Token充足 + .reservedForReply(2000) + .tokenCounter(tokenCounter) + .preserveSystemMessages(true) + .build(); + + // 验证不需要压缩 + assertFalse(context.isOverLimit()); + assertFalse(truncationStrategy.needsCompression(context)); + } + + @Test + @DisplayName("Token截断策略 - 系统消息占用全部空间时的降级") + void testTruncation_SystemMessagesExceedLimit() { + // 创建超长系统消息 + List messages = new ArrayList<>(); + String longSystemContent = "这是一个非常长的系统提示内容".repeat(1000); + messages.add(SystemMessage.from(longSystemContent)); + + // 设置极小的Token限制 + CompressionContext context = CompressionContext.builder() + .messages(messages) + .currentTokens(tokenCounter.countMessages(messages)) + .maxTokens(100) // 极小限制 + .reservedForReply(50) + .tokenCounter(tokenCounter) + .preserveSystemMessages(true) + .build(); + + CompressionResult result = truncationStrategy.compress(context); + + // 即使超限,系统消息也应保留 + assertTrue(result.isSuccess()); + assertTrue(hasSystemMessage(result.getMessages())); + } + + // ========== 摘要策略测试 ========== + + @Test + @DisplayName("摘要策略 - 达到比例阈值时触发摘要") + void testSummarization_TriggerAtRatio() { + List messages = createTestMessages(1, 20); + + int currentTokens = tokenCounter.countMessages(messages); + // 设置Token限制使使用率达到70% + int maxTokens = (int) (currentTokens / 0.7) + 100; + + // Mock摘要模型 + ChatModel mockSummarizer = mock(ChatModel.class); + when(mockSummarizer.chat(anyString())).thenReturn("这是对话摘要内容"); + + CompressionContext context = CompressionContext.builder() + .messages(messages) + .currentTokens(currentTokens) + .maxTokens(maxTokens) + .reservedForReply(2000) + .tokenCounter(tokenCounter) + .summarizer(mockSummarizer) + .summarizeThreshold(10) + .summarizeTokenRatio(0.7) + .preserveSystemMessages(true) + .build(); + + // 验证需要压缩(使用率达到70%) + assertTrue(context.getUsageRatio() >= 0.7); + assertTrue(summarizationStrategy.needsCompression(context)); + + // 执行压缩 + CompressionResult result = summarizationStrategy.compress(context); + + // 验证结果 + assertTrue(result.isSuccess()); + // 摘要后消息数应减少 + assertTrue(result.getCompressedMessageCount() < messages.size()); + // 系统消息应保留 + assertTrue(hasSystemMessage(result.getMessages())); + // 应包含摘要消息 + assertTrue(hasSummaryMessage(result.getMessages())); + } + + @Test + @DisplayName("摘要策略 - 消息数不足时不触发摘要") + void testSummarization_BelowThreshold() { + // 消息数小于阈值(10条) + List messages = createTestMessages(1, 5); + + CompressionContext context = CompressionContext.builder() + .messages(messages) + .currentTokens(tokenCounter.countMessages(messages)) + .maxTokens(1000) + .reservedForReply(200) + .tokenCounter(tokenCounter) + .summarizeThreshold(10) + .summarizeTokenRatio(0.7) + .build(); + + // 验证不需要压缩 + assertFalse(summarizationStrategy.needsCompression(context)); + } + + @Test + @DisplayName("摘要策略 - 无摘要模型时返回失败") + void testSummarization_NoSummarizer() { + List messages = createTestMessages(1, 20); + + CompressionContext context = CompressionContext.builder() + .messages(messages) + .currentTokens(tokenCounter.countMessages(messages)) + .maxTokens(1000) + .reservedForReply(200) + .tokenCounter(tokenCounter) + .summarizer(null) // 无摘要模型 + .summarizeThreshold(10) + .summarizeTokenRatio(0.7) + .build(); + + // 验证不需要压缩(无摘要模型) + assertFalse(summarizationStrategy.needsCompression(context)); + + // 执行压缩应返回失败 + CompressionResult result = summarizationStrategy.compress(context); + assertFalse(result.isSuccess()); + } + + // ========== 边界情况测试 ========== + + @Test + @DisplayName("边界检查 - reservedForReply超过maxTokens时有效上限为1") + void testBoundary_EffectiveMaxTokensMinimum() { + CompressionContext context = CompressionContext.builder() + .maxTokens(100) + .reservedForReply(200) // 超过maxTokens + .build(); + + // 有效上限应为1(最小值) + assertEquals(1, context.getEffectiveMaxTokens()); + // 使用率应为1.0(已满) + assertEquals(1.0, context.getUsageRatio()); + } + + @Test + @DisplayName("边界检查 - 空消息列表处理") + void testBoundary_EmptyMessages() { + List emptyMessages = new ArrayList<>(); + + CompressionContext context = CompressionContext.builder() + .messages(emptyMessages) + .currentTokens(0) + .maxTokens(10000) + .reservedForReply(2000) + .tokenCounter(tokenCounter) + .build(); + + // 所有策略对空消息都应正常处理 + CompressionResult truncResult = truncationStrategy.compress(context); + assertTrue(truncResult.isSuccess()); + assertEquals(0, truncResult.getCompressedMessageCount()); + } + + // ========== 辅助方法 ========== + + /** + * 创建测试消息 + * @param systemCount 系统消息数量 + * @param regularPairs 普通消息对数(每对包含用户消息和AI回复) + */ + private List createTestMessages(int systemCount, int regularPairs) { + List messages = new ArrayList<>(); + + for (int i = 0; i < systemCount; i++) { + messages.add(SystemMessage.from("系统提示" + i)); + } + + for (int i = 0; i < regularPairs; i++) { + messages.add(UserMessage.from("用户消息内容" + i)); + messages.add(AiMessage.from("AI回复内容" + i)); + } + + return messages; + } + + /** + * 创建大量消息(用于Token超限测试) + */ + private List createLargeMessages(int pairs) { + List messages = new ArrayList<>(); + messages.add(SystemMessage.from("系统提示")); + + for (int i = 0; i < pairs; i++) { + // 每条消息较长,增加Token数 + messages.add(UserMessage.from("这是第" + i + "条用户消息,内容比较长,用于测试Token限制".repeat(5))); + messages.add(AiMessage.from("这是第" + i + "条AI回复,内容也比较长,用于测试Token限制".repeat(5))); + } + + return messages; + } + + /** + * 检查消息列表是否包含系统消息 + */ + private boolean hasSystemMessage(List messages) { + return messages.stream().anyMatch(m -> m instanceof SystemMessage); + } + + /** + * 检查是否包含摘要消息 + */ + private boolean hasSummaryMessage(List messages) { + return messages.stream() + .filter(m -> m instanceof SystemMessage) + .anyMatch(m -> ((SystemMessage) m).text().contains("历史对话摘要")); + } + + // ========== Message 策略测试 ========== + + @Test + @DisplayName("Message策略 - 默认配置验证") + void testMessageStrategy_DefaultConfig() { + ChatMemoryProperties properties = new ChatMemoryProperties(); + + // 验证默认策略是 message + assertEquals("message", properties.getStrategy()); + assertEquals(20, properties.getMaxMessages()); + + System.out.println("[Message策略] 默认配置: strategy=" + properties.getStrategy() + ", maxMessages=" + properties.getMaxMessages()); + } + + @Test + @DisplayName("Message策略 - 滑动窗口自动移除旧消息") + void testMessageStrategy_SlidingWindow() { + int maxMessages = 5; + + // 使用 LangChain4j 原生的 MessageWindowChatMemory + InMemoryChatMemoryStore store = new InMemoryChatMemoryStore(); + MessageWindowChatMemory memory = MessageWindowChatMemory.builder() + .id("test-session") + .maxMessages(maxMessages) + .chatMemoryStore(store) + .build(); + + // 添加系统消息 + memory.add(SystemMessage.from("系统提示")); + + // 添加10条普通消息 + for (int i = 0; i < 10; i++) { + memory.add(UserMessage.from("用户消息" + i)); + memory.add(AiMessage.from("AI回复" + i)); + } + + // 获取消息列表 + List messages = memory.messages(); + + // 验证只保留最新的 maxMessages 条消息(包括系统消息) + assertEquals(maxMessages, messages.size()); + + // 验证系统消息被保留 + assertTrue(messages.stream().anyMatch(m -> m instanceof SystemMessage)); + + // 验证是最新的消息(消息8、9) + assertTrue(messages.stream() + .filter(m -> m instanceof UserMessage) + .anyMatch(m -> ((UserMessage) m).singleText().equals("用户消息9"))); + + System.out.println("[Message策略] 滑动窗口: 添加21条消息 → 保留" + messages.size() + "条消息 (maxMessages=" + maxMessages + ")"); + } + + @Test + @DisplayName("Message策略 - 不涉及Token管理") + void testMessageStrategy_NoTokenManagement() { + // 创建大量消息 + MessageWindowChatMemory memory = MessageWindowChatMemory.builder() + .id("test-session") + .maxMessages(10) + .chatMemoryStore(new InMemoryChatMemoryStore()) + .build(); + + // 添加超长消息(模拟大量Token) + String longContent = "这是一条非常长的消息内容".repeat(1000); + for (int i = 0; i < 10; i++) { + memory.add(UserMessage.from(longContent)); + } + + // Message策略不考虑Token,只按消息数量截断 + List messages = memory.messages(); + assertEquals(10, messages.size()); + + // 验证所有消息都是完整的长消息 + for (ChatMessage msg : messages) { + if (msg instanceof UserMessage) { + assertTrue(((UserMessage) msg).singleText().length() > 10000); + } + } + + System.out.println("[Message策略] 不涉及Token管理: 10条超长消息完整保留,每条长度>10000字符"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceTest.java new file mode 100644 index 00000000..baadfedb --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceTest.java @@ -0,0 +1,109 @@ +package org.ruoyi.service.chat.impl.provider; + +import dev.langchain4j.community.model.dashscope.QwenStreamingChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 千问模型测试类 + * 测试不同模型名称是否可用 + */ +class QianWenChatServiceTest { + + // 请替换为你的 API Key + private static final String API_KEY = System.getenv("DASHSCOPE_API_KEY"); + + // 测试不同的模型名称 + private static final String[] MODEL_NAMES = { + "qwen-turbo", + "qwen-plus", + "qwen-max", + "qwen3.6-flash", // 用户配置的模型名称 + "qwen3.6-plus" // 用户配置的模型名称 + }; + + @Test + void testQwenModels() throws InterruptedException { + if (API_KEY == null || API_KEY.isEmpty()) { + System.out.println("跳过测试: 未设置环境变量 DASHSCOPE_API_KEY"); + System.out.println("请设置环境变量: export DASHSCOPE_API_KEY=your-api-key"); + return; + } + + System.out.println("========================================"); + System.out.println("千问模型名称测试"); + System.out.println("========================================\n"); + + for (String modelName : MODEL_NAMES) { + testModel(modelName); + System.out.println(); + } + } + + private void testModel(String modelName) throws InterruptedException { + System.out.println(">>> 测试模型: " + modelName); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + + try { + StreamingChatModel model = QwenStreamingChatModel.builder() + .apiKey(API_KEY) + .modelName(modelName) + .build(); + + model.chat("你好,请说'测试成功'", new StreamingChatResponseHandler() { + private final StringBuilder buffer = new StringBuilder(); + + @Override + public void onPartialResponse(String partialResponse) { + if (partialResponse != null) { + buffer.append(partialResponse); + } + } + + @Override + public void onCompleteResponse(ChatResponse completeResponse) { + result.set(buffer.toString()); + latch.countDown(); + } + + @Override + public void onError(Throwable throwable) { + error.set(throwable); + latch.countDown(); + } + }); + + // 等待响应,最多 30 秒 + boolean completed = latch.await(30, TimeUnit.SECONDS); + + if (!completed) { + System.out.println(" 结果: 超时(30秒无响应)"); + return; + } + + if (error.get() != null) { + System.out.println(" 结果: 失败"); + System.out.println(" 错误: " + error.get().getMessage()); + } else { + System.out.println(" 结果: 成功 ✓"); + String response = result.get(); + if (response != null && response.length() > 0) { + System.out.println(" 响应: " + response.substring(0, Math.min(100, response.length())) + "..."); + } + } + + } catch (Exception e) { + System.out.println(" 结果: 异常"); + System.out.println(" 错误: " + e.getMessage()); + } + } +}