mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(agent): context compression upgrade, 429 retry, iteration limit & repetition fixes
This commit is contained in:
parent
250a5f6d46
commit
ec7ea038e7
@ -315,7 +315,7 @@ public class AgentGraphBuilder {
|
||||
.addEdge(PlanStateKeys.DIRECT_ANSWER_NODE, StateGraph.END);
|
||||
|
||||
return graph.compile(CompileConfig.builder()
|
||||
.recursionLimit(maxIterations * 3 + 10)
|
||||
.recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300)
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
throw new MateClawException("Plan-Execute StateGraph 编译失败: " + e.getMessage());
|
||||
@ -424,7 +424,7 @@ public class AgentGraphBuilder {
|
||||
.addEdge(MateClawStateKeys.FINAL_ANSWER_NODE, StateGraph.END);
|
||||
|
||||
return graph.compile(CompileConfig.builder()
|
||||
.recursionLimit(maxIterations * 3 + 10)
|
||||
.recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300)
|
||||
.withLifecycleListener(new ReActLifecycleListener())
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
|
||||
@ -44,7 +44,7 @@ public abstract class BaseAgent {
|
||||
protected String systemPrompt;
|
||||
|
||||
/** 最大工具调用迭代次数 */
|
||||
protected int maxIterations = 10;
|
||||
protected int maxIterations = 25;
|
||||
|
||||
/** 模型名称 */
|
||||
protected String modelName;
|
||||
|
||||
@ -15,19 +15,33 @@ import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.prompt.PromptLoader;
|
||||
import vip.mate.config.ConversationWindowProperties;
|
||||
import vip.mate.memory.spi.MemoryManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 会话历史上下文窗口管理器
|
||||
* 会话历史上下文窗口管理器(Hermes 风格升级版)
|
||||
* <p>
|
||||
* 在消息注入 StateGraph 之前,检测 token 是否超出模型上下文窗口,
|
||||
* 若超出则将较早的消息通过 LLM 压缩为摘要,保留最近 N 轮原始消息。
|
||||
* 四阶段压缩策略:
|
||||
* <ol>
|
||||
* <li>Soft Trim — 裁剪旧工具结果为 head+tail</li>
|
||||
* <li>Hard Clear — 替换所有旧工具结果为占位符</li>
|
||||
* <li>Pre-Prune — 喂给摘要 LLM 前清理工具输出(减少摘要输入 token)</li>
|
||||
* <li>LLM 结构化摘要 — Goal/Progress/Decisions/Files/NextSteps 模板,支持迭代更新</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* 关键特性:
|
||||
* <ul>
|
||||
* <li>迭代摘要更新:多轮压缩时将旧摘要 + 新轮次合并,信息不丢失</li>
|
||||
* <li>动态 Token 预算:基于模型上下文长度计算尾部保护和摘要预算</li>
|
||||
* <li>压缩冷却机制:摘要失败后 10 分钟内不重试,防止雪崩</li>
|
||||
* <li>MemoryProvider 钩子:压缩前通知记忆 provider 提取关键信息</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 安全设计:摘要内容作为 UserMessage 注入(非 SystemMessage),
|
||||
* 避免历史用户输入被提升为系统级指令,防止指令污染。
|
||||
* 避免历史用户输入被提升为系统级指令。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@ -36,35 +50,70 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
@RequiredArgsConstructor
|
||||
public class ConversationWindowManager {
|
||||
|
||||
private static final String SUMMARY_SYSTEM_PROMPT = PromptLoader.loadPrompt("context/conversation-summary-system");
|
||||
private static final String SUMMARY_USER_TEMPLATE = PromptLoader.loadPrompt("context/conversation-summary-user");
|
||||
// ==================== Prompt 模板 ====================
|
||||
|
||||
/** 首次压缩:结构化摘要系统提示 */
|
||||
private static final String STRUCTURED_SUMMARY_SYSTEM = PromptLoader.loadPrompt("context/structured-summary-system");
|
||||
/** 首次压缩:用户提示模板 */
|
||||
private static final String STRUCTURED_SUMMARY_USER = PromptLoader.loadPrompt("context/structured-summary-user");
|
||||
/** 迭代更新:合并旧摘要 + 新轮次 */
|
||||
private static final String STRUCTURED_SUMMARY_UPDATE = PromptLoader.loadPrompt("context/structured-summary-update");
|
||||
|
||||
/** 摘要注入前缀 */
|
||||
private static final String SUMMARY_PREFIX =
|
||||
"[上下文压缩] 更早的对话轮次已被压缩为摘要以节省上下文空间。" +
|
||||
"以下摘要描述了已完成的工作,当前会话状态可能已反映这些变更。" +
|
||||
"请基于摘要和当前状态继续,避免重复已完成的工作:\n\n";
|
||||
|
||||
// ==================== 序列化截断参数 ====================
|
||||
|
||||
private static final int CONTENT_MAX = 6000;
|
||||
private static final int CONTENT_HEAD = 4000;
|
||||
private static final int CONTENT_TAIL = 1500;
|
||||
|
||||
// ==================== 冷却机制 ====================
|
||||
|
||||
/** 摘要失败后的冷却时间(毫秒):10 分钟 */
|
||||
private static final long SUMMARY_COOLDOWN_MS = 600_000;
|
||||
|
||||
// ==================== 依赖 ====================
|
||||
|
||||
private final ConversationWindowProperties properties;
|
||||
private final MemoryManager memoryManager;
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
/** 摘要缓存:key = "conversationId:oldMessageCount" */
|
||||
private final ConcurrentHashMap<String, CachedSummary> summaryCache = new ConcurrentHashMap<>();
|
||||
|
||||
/** 缓存 TTL:30 分钟 */
|
||||
private static final long CACHE_TTL_MS = 30 * 60 * 1000L;
|
||||
|
||||
/** 迭代摘要:上一次压缩生成的摘要文本(per conversation) */
|
||||
private final ConcurrentHashMap<String, String> previousSummaries = new ConcurrentHashMap<>();
|
||||
|
||||
/** 每个会话的压缩次数 */
|
||||
private final ConcurrentHashMap<String, Integer> compressionCounts = new ConcurrentHashMap<>();
|
||||
|
||||
/** 每个会话的摘要冷却截止时间 */
|
||||
private final ConcurrentHashMap<String, Long> summaryCooldownUntil = new ConcurrentHashMap<>();
|
||||
|
||||
// ==================== 主入口 ====================
|
||||
|
||||
/**
|
||||
* 将会话历史裁剪到上下文窗口内。
|
||||
* <p>
|
||||
* 预算计算包含 systemPrompt + 历史消息 + 当前用户消息,
|
||||
* 确保最终拼接后不超出模型上下文窗口。
|
||||
*
|
||||
* @param messages 已转换的 Spring AI 消息列表(不含当前用户消息)
|
||||
* @param systemPrompt 系统提示词文本
|
||||
* @param currentUserMessage 当前用户输入(纳入窗口预算计算,但不会拼入返回结果)
|
||||
* @param currentUserMessage 当前用户输入(纳入窗口预算计算,但不拼入返回结果)
|
||||
* @param maxInputTokens 模型最大输入 token(0 或 null 使用全局默认)
|
||||
* @param chatModel 用于生成摘要的 ChatModel
|
||||
* @param conversationId 会话 ID(用于缓存)
|
||||
* @return 裁剪后的消息列表,可能包含摘要前缀
|
||||
* @param conversationId 会话 ID(用于缓存和迭代摘要)
|
||||
* @param agentId Agent ID(用于 MemoryProvider 钩子)
|
||||
* @return 裁剪后的消息列表
|
||||
*/
|
||||
public List<Message> fitToWindow(List<Message> messages, String systemPrompt,
|
||||
String currentUserMessage,
|
||||
Integer maxInputTokens, ChatModel chatModel,
|
||||
String conversationId) {
|
||||
String conversationId, Long agentId) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return messages;
|
||||
}
|
||||
@ -82,47 +131,57 @@ public class ConversationWindowManager {
|
||||
return messages;
|
||||
}
|
||||
|
||||
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}) > {} 触发阈值 (max={}), conversationId={}",
|
||||
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}) > {} 触发阈值 (max={}), conv={}",
|
||||
totalTokens, systemTokens, currentMsgTokens, historyTokens,
|
||||
triggerThreshold, effectiveMax, conversationId);
|
||||
|
||||
// 清理过期缓存
|
||||
evictExpiredEntries();
|
||||
|
||||
// 可用于历史的 token 预算 = max - system - currentMsg - 安全余量
|
||||
int reservedTokens = systemTokens + currentMsgTokens + (int) (effectiveMax * 0.05);
|
||||
int historyBudget = effectiveMax - reservedTokens;
|
||||
|
||||
return compactMessages(messages, historyBudget, chatModel, conversationId);
|
||||
// 尾部保护 token 预算:阈值的 20%(与 Hermes 一致)
|
||||
int tailTokenBudget = (int) (triggerThreshold * 0.20);
|
||||
|
||||
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel, conversationId, agentId);
|
||||
}
|
||||
|
||||
private List<Message> compactMessages(List<Message> messages, int historyBudget,
|
||||
ChatModel chatModel, String conversationId) {
|
||||
// 计算保留多少条最近消息
|
||||
int preserveCount = calculatePreserveCount(messages);
|
||||
/**
|
||||
* 向后兼容:不传 agentId 的旧签名(agentId = null,不触发 Memory 钩子)
|
||||
*/
|
||||
public List<Message> fitToWindow(List<Message> messages, String systemPrompt,
|
||||
String currentUserMessage,
|
||||
Integer maxInputTokens, ChatModel chatModel,
|
||||
String conversationId) {
|
||||
return fitToWindow(messages, systemPrompt, currentUserMessage,
|
||||
maxInputTokens, chatModel, conversationId, null);
|
||||
}
|
||||
|
||||
// 如果消息总数不够拆分,尝试逐步减少保留数
|
||||
if (preserveCount >= messages.size()) {
|
||||
// 消息太少无法拆分,尝试保留最少 2 条
|
||||
preserveCount = Math.min(2, messages.size());
|
||||
if (preserveCount >= messages.size()) {
|
||||
log.debug("[ConversationWindow] 消息数 {} 无法拆分,跳过压缩", messages.size());
|
||||
return messages;
|
||||
}
|
||||
// ==================== 核心压缩逻辑 ====================
|
||||
|
||||
private List<Message> compactMessages(List<Message> messages, int historyBudget,
|
||||
int tailTokenBudget, ChatModel chatModel,
|
||||
String conversationId, Long agentId) {
|
||||
// 动态计算尾部保护边界(替代固定 preserveRecentPairs)
|
||||
int headEnd = 0; // 头部保护:暂不保护(system prompt 已在外部计算)
|
||||
int tailStart = findTailBoundary(messages, headEnd, tailTokenBudget);
|
||||
|
||||
if (tailStart <= headEnd) {
|
||||
log.debug("[ConversationWindow] 消息数不足以拆分,跳过压缩");
|
||||
return messages;
|
||||
}
|
||||
|
||||
int splitPoint = messages.size() - preserveCount;
|
||||
List<Message> oldMessages = new ArrayList<>(messages.subList(0, splitPoint)); // 可变副本
|
||||
List<Message> recentMessages = messages.subList(splitPoint, messages.size());
|
||||
List<Message> oldMessages = new ArrayList<>(messages.subList(headEnd, tailStart));
|
||||
List<Message> recentMessages = messages.subList(tailStart, messages.size());
|
||||
|
||||
// ═══ Phase 1: Soft Trim — 裁剪工具结果(head+tail),避免不必要的 LLM 摘要 ═══
|
||||
// ═══ Phase 1: Soft Trim — 裁剪旧工具结果 ═══
|
||||
int softTrimmed = softTrimToolResults(oldMessages);
|
||||
if (softTrimmed > 0) {
|
||||
int afterTrimTokens = TokenEstimator.estimateTokens(oldMessages) + TokenEstimator.estimateTokens(recentMessages);
|
||||
log.info("[ConversationWindow] Soft trim: {} tool results trimmed, tokens now={}, budget={}",
|
||||
log.info("[ConversationWindow] Phase 1 Soft trim: {} tool results trimmed, tokens={}, budget={}",
|
||||
softTrimmed, afterTrimTokens, historyBudget);
|
||||
if (afterTrimTokens <= historyBudget) {
|
||||
// Soft trim 够了,跳过 LLM 摘要
|
||||
List<Message> result = new ArrayList<>(oldMessages);
|
||||
result.addAll(recentMessages);
|
||||
return result;
|
||||
@ -133,7 +192,7 @@ public class ConversationWindowManager {
|
||||
int hardCleared = hardClearToolResults(oldMessages);
|
||||
if (hardCleared > 0) {
|
||||
int afterClearTokens = TokenEstimator.estimateTokens(oldMessages) + TokenEstimator.estimateTokens(recentMessages);
|
||||
log.info("[ConversationWindow] Hard clear: {} tool results replaced with placeholder, tokens now={}, budget={}",
|
||||
log.info("[ConversationWindow] Phase 2 Hard clear: {} replaced, tokens={}, budget={}",
|
||||
hardCleared, afterClearTokens, historyBudget);
|
||||
if (afterClearTokens <= historyBudget) {
|
||||
List<Message> result = new ArrayList<>(oldMessages);
|
||||
@ -142,7 +201,31 @@ public class ConversationWindowManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Phase 3: LLM 摘要(原有逻辑,仅在 Phase 1+2 不够时执行) ═══
|
||||
// ═══ Phase 2.5: MemoryProvider 钩子 — 压缩前提取关键信息 ═══
|
||||
String memoryExtraContext = "";
|
||||
if (agentId != null && memoryManager != null) {
|
||||
try {
|
||||
String preserved = memoryManager.onPreCompress(agentId, oldMessages);
|
||||
if (preserved != null && !preserved.isBlank()) {
|
||||
memoryExtraContext = preserved;
|
||||
log.debug("[ConversationWindow] MemoryProvider onPreCompress contributed {} chars", preserved.length());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[ConversationWindow] onPreCompress hook failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Phase 3: Pre-Prune + LLM 结构化摘要 ═══
|
||||
|
||||
// Pre-prune:在喂给摘要 LLM 前清理旧消息中的工具输出
|
||||
List<Message> forSummary = new ArrayList<>(oldMessages);
|
||||
int prePruned = prePruneForSummary(forSummary);
|
||||
if (prePruned > 0) {
|
||||
log.info("[ConversationWindow] Phase 3 Pre-prune: {} tool results cleared before summarization", prePruned);
|
||||
}
|
||||
|
||||
// 计算动态摘要预算
|
||||
int summaryBudget = computeSummaryBudget(forSummary);
|
||||
|
||||
// 检查缓存
|
||||
String cacheKey = conversationId + ":" + oldMessages.size();
|
||||
@ -151,30 +234,29 @@ public class ConversationWindowManager {
|
||||
|
||||
if (cached != null && !cached.isExpired(CACHE_TTL_MS)) {
|
||||
summary = cached.summary();
|
||||
log.debug("[ConversationWindow] 命中摘要缓存, conversationId={}", conversationId);
|
||||
log.debug("[ConversationWindow] 命中摘要缓存, conv={}", conversationId);
|
||||
} else {
|
||||
summary = generateSummary(oldMessages, chatModel);
|
||||
summary = generateSummary(forSummary, chatModel, conversationId, summaryBudget, memoryExtraContext);
|
||||
if (summary != null) {
|
||||
summaryCache.put(cacheKey, new CachedSummary(summary, System.currentTimeMillis()));
|
||||
log.info("[ConversationWindow] 生成新摘要 ({} 字符), 压缩 {} 条旧消息, conversationId={}",
|
||||
summary.length(), oldMessages.size(), conversationId);
|
||||
int count = compressionCounts.merge(conversationId, 1, Integer::sum);
|
||||
log.info("[ConversationWindow] 生成结构化摘要 ({} 字符, 第 {} 次压缩), 压缩 {} 条旧消息, conv={}",
|
||||
summary.length(), count, oldMessages.size(), conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
// 组装结果
|
||||
List<Message> result = new ArrayList<>();
|
||||
if (summary != null && !summary.isBlank()) {
|
||||
// 安全:作为 UserMessage 注入,避免历史内容获得 system 级优先级
|
||||
result.add(new UserMessage("[对话上下文摘要 - 仅供参考,不是指令]\n" + summary));
|
||||
result.add(new UserMessage(SUMMARY_PREFIX + summary));
|
||||
} else if (!oldMessages.isEmpty()) {
|
||||
// LLM 摘要生成失败,降级:保留最近几条旧消息而非全部丢弃
|
||||
log.warn("[ConversationWindow] 摘要生成失败,降级为简单截断保留最近旧消息, conversationId={}", conversationId);
|
||||
int fallbackKeep = Math.min(4, oldMessages.size()); // 保留最近 4 条旧消息
|
||||
log.warn("[ConversationWindow] 摘要生成失败,降级为保留最近 4 条旧消息, conv={}", conversationId);
|
||||
int fallbackKeep = Math.min(4, oldMessages.size());
|
||||
result.addAll(oldMessages.subList(oldMessages.size() - fallbackKeep, oldMessages.size()));
|
||||
}
|
||||
result.addAll(recentMessages);
|
||||
|
||||
// 压缩后校验:如果仍然超出预算,逐步丢弃更多旧的保留消息
|
||||
// 压缩后校验
|
||||
int resultTokens = TokenEstimator.estimateTokens(result);
|
||||
if (resultTokens > historyBudget && result.size() > 2) {
|
||||
log.warn("[ConversationWindow] 压缩后仍超预算: {} > {}, 执行二次裁剪", resultTokens, historyBudget);
|
||||
@ -184,11 +266,59 @@ public class ConversationWindowManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 工具结果裁剪 ====================
|
||||
// ==================== 动态 Token 预算 ====================
|
||||
|
||||
/**
|
||||
* Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
|
||||
* @return 裁剪的工具结果条数
|
||||
* 基于 token 预算动态计算尾部保护边界(替代固定 preserveRecentPairs)。
|
||||
* 从消息列表末尾向前累加 token,直到耗尽预算或达到最小消息数。
|
||||
*/
|
||||
private int findTailBoundary(List<Message> messages, int headEnd, int tailTokenBudget) {
|
||||
int n = messages.size();
|
||||
if (n <= headEnd + 1) return headEnd;
|
||||
|
||||
int minTail = Math.min(properties.getProtectLastMinMessages(), n - headEnd - 1);
|
||||
// 兼容旧配置:如果 protectLastMinMessages 未设置但 preserveRecentPairs 有值
|
||||
int pairsBased = properties.getPreserveRecentPairs() * 2;
|
||||
if (pairsBased > minTail) {
|
||||
minTail = Math.min(pairsBased, n - headEnd - 1);
|
||||
}
|
||||
|
||||
int softCeiling = (int) (tailTokenBudget * 1.5);
|
||||
int accumulated = 0;
|
||||
int cutIdx = n;
|
||||
|
||||
for (int i = n - 1; i >= headEnd; i--) {
|
||||
int msgTokens = TokenEstimator.estimateTokens(messages.get(i));
|
||||
if (accumulated + msgTokens > softCeiling && (n - i) >= minTail) {
|
||||
break;
|
||||
}
|
||||
accumulated += msgTokens;
|
||||
cutIdx = i;
|
||||
}
|
||||
|
||||
// 确保至少保留 minTail 条
|
||||
int fallbackCut = n - minTail;
|
||||
if (cutIdx > fallbackCut) {
|
||||
cutIdx = fallbackCut;
|
||||
}
|
||||
|
||||
return Math.max(cutIdx, headEnd + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算摘要字数预算:被压缩内容 token 的 20%,不低于 500、不超过 3000。
|
||||
*/
|
||||
private int computeSummaryBudget(List<Message> turnsToSummarize) {
|
||||
int contentTokens = TokenEstimator.estimateTokens(turnsToSummarize);
|
||||
int budget = (int) (contentTokens * properties.getSummaryBudgetRatio());
|
||||
return Math.max(properties.getSummaryBudgetFloor(),
|
||||
Math.min(budget, properties.getSummaryBudgetCeiling()));
|
||||
}
|
||||
|
||||
// ==================== 工具结果处理 ====================
|
||||
|
||||
/**
|
||||
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
|
||||
*/
|
||||
private int softTrimToolResults(List<Message> messages) {
|
||||
int trimmed = 0;
|
||||
@ -218,8 +348,7 @@ public class ConversationWindowManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard clear:将所有工具结果替换为占位符。
|
||||
* @return 替换的工具结果条数
|
||||
* Phase 2 - Hard clear:将所有旧工具结果替换为占位符。
|
||||
*/
|
||||
private int hardClearToolResults(List<Message> messages) {
|
||||
int cleared = 0;
|
||||
@ -235,9 +364,137 @@ public class ConversationWindowManager {
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3 Pre-prune:在 LLM 摘要前,将工具输出替换为占位符(减少摘要输入 token)。
|
||||
*/
|
||||
private int prePruneForSummary(List<Message> messages) {
|
||||
int pruned = 0;
|
||||
for (int i = 0; i < messages.size(); i++) {
|
||||
if (messages.get(i) instanceof ToolResponseMessage trm) {
|
||||
boolean hasSubstantial = trm.getResponses().stream()
|
||||
.anyMatch(r -> r.responseData() != null && r.responseData().length() > 200);
|
||||
if (hasSubstantial) {
|
||||
List<ToolResponseMessage.ToolResponse> placeholders = trm.getResponses().stream()
|
||||
.map(r -> new ToolResponseMessage.ToolResponse(r.id(), r.name(),
|
||||
"[旧工具输出已清理以节省上下文空间]"))
|
||||
.toList();
|
||||
messages.set(i, ToolResponseMessage.builder().responses(placeholders).build());
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
// ==================== LLM 摘要生成(结构化 + 迭代更新) ====================
|
||||
|
||||
/**
|
||||
* 生成结构化摘要。支持首次压缩和迭代更新两种模式。
|
||||
* 包含冷却机制:LLM 调用失败后 10 分钟内不重试。
|
||||
*/
|
||||
private String generateSummary(List<Message> oldMessages, ChatModel chatModel,
|
||||
String conversationId, int summaryBudget,
|
||||
String memoryExtraContext) {
|
||||
// 冷却检查
|
||||
if (isInSummaryCooldown(conversationId)) {
|
||||
log.info("[ConversationWindow] 摘要在冷却中,跳过 LLM 调用, conv={}", conversationId);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String conversationText = serializeForSummary(oldMessages);
|
||||
|
||||
// 如果 MemoryProvider 有额外上下文,追加到对话文本中
|
||||
if (memoryExtraContext != null && !memoryExtraContext.isBlank()) {
|
||||
conversationText += "\n\n[Memory Provider 补充上下文]\n" + memoryExtraContext;
|
||||
}
|
||||
|
||||
String previousSummary = previousSummaries.get(conversationId);
|
||||
String systemPrompt;
|
||||
String userPrompt;
|
||||
|
||||
if (previousSummary != null) {
|
||||
// 迭代更新模式:旧摘要 + 新轮次
|
||||
systemPrompt = STRUCTURED_SUMMARY_SYSTEM;
|
||||
userPrompt = STRUCTURED_SUMMARY_UPDATE
|
||||
.replace("{previous_summary}", previousSummary)
|
||||
.replace("{conversation}", conversationText)
|
||||
.replace("{summary_budget}", String.valueOf(summaryBudget));
|
||||
log.debug("[ConversationWindow] 使用迭代更新模式(第 {} 次压缩), conv={}",
|
||||
compressionCounts.getOrDefault(conversationId, 0) + 1, conversationId);
|
||||
} else {
|
||||
// 首次压缩
|
||||
systemPrompt = STRUCTURED_SUMMARY_SYSTEM
|
||||
.replace("{summary_budget}", String.valueOf(summaryBudget));
|
||||
userPrompt = STRUCTURED_SUMMARY_USER
|
||||
.replace("{conversation}", conversationText);
|
||||
log.debug("[ConversationWindow] 使用首次压缩模式, conv={}", conversationId);
|
||||
}
|
||||
|
||||
List<Message> promptMessages = new ArrayList<>();
|
||||
promptMessages.add(new SystemMessage(systemPrompt));
|
||||
promptMessages.add(new UserMessage(userPrompt));
|
||||
|
||||
ChatOptions options = DashScopeChatOptions.builder()
|
||||
.withMaxToken(properties.getSummaryMaxTokens())
|
||||
.build();
|
||||
|
||||
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
|
||||
if (response != null && response.getResult() != null
|
||||
&& response.getResult().getOutput() != null) {
|
||||
String summary = response.getResult().getOutput().getText();
|
||||
if (summary != null && !summary.isBlank()) {
|
||||
// 成功:保存摘要供下次迭代更新,清除冷却
|
||||
previousSummaries.put(conversationId, summary);
|
||||
clearSummaryCooldown(conversationId);
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
log.warn("[ConversationWindow] LLM 摘要返回空结果, conv={}", conversationId);
|
||||
setSummaryCooldown(conversationId);
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("[ConversationWindow] LLM 摘要生成失败(进入 {} 秒冷却): {}, conv={}",
|
||||
SUMMARY_COOLDOWN_MS / 1000, e.getMessage(), conversationId);
|
||||
setSummaryCooldown(conversationId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 消息序列化(智能截断) ====================
|
||||
|
||||
/**
|
||||
* 将消息列表序列化为摘要 LLM 可消化的文本格式。
|
||||
* 长内容做 head+tail 截断,比简单截断保留更多信息。
|
||||
*/
|
||||
private String serializeForSummary(List<Message> messages) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Message msg : messages) {
|
||||
String role = switch (msg) {
|
||||
case UserMessage ignored -> "[USER]";
|
||||
case SystemMessage ignored -> "[SYSTEM]";
|
||||
case AssistantMessage ignored -> "[ASSISTANT]";
|
||||
case ToolResponseMessage ignored -> "[TOOL RESULT]";
|
||||
default -> "[OTHER]";
|
||||
};
|
||||
|
||||
String text = msg.getText();
|
||||
if (text != null && text.length() > CONTENT_MAX) {
|
||||
text = text.substring(0, CONTENT_HEAD)
|
||||
+ "\n...[截断 " + text.length() + " 字符]...\n"
|
||||
+ text.substring(text.length() - CONTENT_TAIL);
|
||||
}
|
||||
|
||||
sb.append(role).append(": ").append(text != null ? text : "").append("\n\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 二次裁剪:从前往后移除消息直到 token 预算满足。
|
||||
* 至少保留最后 2 条消息(最近一轮对话)。
|
||||
*/
|
||||
private List<Message> trimToFit(List<Message> messages, int budget) {
|
||||
int startIndex = 0;
|
||||
@ -255,77 +512,17 @@ public class ConversationWindowManager {
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算应保留的最近消息条数。
|
||||
* 保留 N 轮对话(每轮 = user + assistant = 2 条),至少保留 2 条。
|
||||
*/
|
||||
private int calculatePreserveCount(List<Message> messages) {
|
||||
int pairCount = properties.getPreserveRecentPairs();
|
||||
int preserveCount = pairCount * 2;
|
||||
return Math.max(2, Math.min(preserveCount, messages.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 LLM 生成会话摘要,使用 summaryMaxTokens 约束输出长度。
|
||||
* 失败时返回 null(降级为朴素截断)。
|
||||
*/
|
||||
private String generateSummary(List<Message> oldMessages, ChatModel chatModel) {
|
||||
try {
|
||||
StringBuilder conversationText = new StringBuilder();
|
||||
for (Message msg : oldMessages) {
|
||||
String role = switch (msg) {
|
||||
case UserMessage ignored -> "用户";
|
||||
case SystemMessage ignored -> "系统";
|
||||
default -> "助手";
|
||||
};
|
||||
String text = msg.getText();
|
||||
// 单条消息截断避免摘要 prompt 本身过长
|
||||
if (text != null && text.length() > 2000) {
|
||||
text = text.substring(0, 2000) + "...[已截断]";
|
||||
}
|
||||
conversationText.append(role).append(": ").append(text).append("\n\n");
|
||||
}
|
||||
|
||||
String userPrompt = SUMMARY_USER_TEMPLATE
|
||||
.replace("{conversation}", conversationText.toString());
|
||||
|
||||
List<Message> promptMessages = new ArrayList<>();
|
||||
promptMessages.add(new SystemMessage(SUMMARY_SYSTEM_PROMPT));
|
||||
promptMessages.add(new UserMessage(userPrompt));
|
||||
|
||||
// 使用 summaryMaxTokens 约束摘要输出长度
|
||||
ChatOptions options = DashScopeChatOptions.builder()
|
||||
.withMaxToken(properties.getSummaryMaxTokens())
|
||||
.build();
|
||||
|
||||
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
|
||||
if (response != null && response.getResult() != null
|
||||
&& response.getResult().getOutput() != null) {
|
||||
return response.getResult().getOutput().getText();
|
||||
}
|
||||
log.warn("[ConversationWindow] LLM 摘要返回空结果");
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.warn("[ConversationWindow] LLM 摘要生成失败,降级为朴素截断: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// ==================== PTL 紧急压缩 ====================
|
||||
|
||||
/**
|
||||
* PTL (Prompt Too Long) 恢复用的紧急压缩。
|
||||
* <p>
|
||||
* 当 LLM 返回 context_length_exceeded 错误时,由 Node 层调用此方法
|
||||
* 对消息列表做更激进的裁剪(保留最近 2 轮 + 朴素截断,不调用 LLM 摘要)。
|
||||
*
|
||||
* @param messages 原始消息列表
|
||||
* @return 压缩后的消息列表,如果无法压缩返回 null
|
||||
* 不调用 LLM 摘要,直接丢弃较旧消息,只保留最近 4 条。
|
||||
*/
|
||||
public List<Message> compactForRetry(List<Message> messages) {
|
||||
if (messages == null || messages.size() <= 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 紧急模式:不调用 LLM 摘要,直接丢弃较旧消息,只保留最近 2 对 (4 条)
|
||||
int preserveCount = Math.min(4, messages.size());
|
||||
int splitPoint = messages.size() - preserveCount;
|
||||
|
||||
@ -339,16 +536,27 @@ public class ConversationWindowManager {
|
||||
return recentMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期缓存条目
|
||||
*/
|
||||
// ==================== 冷却机制 ====================
|
||||
|
||||
private boolean isInSummaryCooldown(String conversationId) {
|
||||
Long until = summaryCooldownUntil.get(conversationId);
|
||||
return until != null && System.currentTimeMillis() < until;
|
||||
}
|
||||
|
||||
private void setSummaryCooldown(String conversationId) {
|
||||
summaryCooldownUntil.put(conversationId, System.currentTimeMillis() + SUMMARY_COOLDOWN_MS);
|
||||
}
|
||||
|
||||
private void clearSummaryCooldown(String conversationId) {
|
||||
summaryCooldownUntil.remove(conversationId);
|
||||
}
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
private void evictExpiredEntries() {
|
||||
summaryCache.entrySet().removeIf(entry -> entry.getValue().isExpired(CACHE_TTL_MS));
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存条目
|
||||
*/
|
||||
record CachedSummary(String summary, long createdAt) {
|
||||
boolean isExpired(long ttlMs) {
|
||||
return System.currentTimeMillis() - createdAt > ttlMs;
|
||||
|
||||
@ -15,6 +15,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@ -97,9 +98,9 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
// ==================== 重试配置 ====================
|
||||
|
||||
private static final int MAX_RETRIES = 3;
|
||||
private static final long BACKOFF_BASE_MS = 1000;
|
||||
private static final long BACKOFF_CAP_MS = 10_000;
|
||||
private static final int MAX_RETRIES = 5;
|
||||
private static final long BACKOFF_BASE_MS = 3000;
|
||||
private static final long BACKOFF_CAP_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 判断错误是否可重试(基于状态码/异常类型)
|
||||
@ -246,8 +247,16 @@ public class NodeStreamingChatHelper {
|
||||
boolean broadcast, int attempt) {
|
||||
if (attempt > 0) {
|
||||
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
|
||||
// 加入 jitter 防止雷群效应(Hermes 风格)
|
||||
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
|
||||
delay = Math.min(delay, BACKOFF_CAP_MS);
|
||||
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
|
||||
phase, attempt, MAX_RETRIES, delay, conversationId);
|
||||
// 广播给前端:用户可见的重试倒计时
|
||||
if (broadcast) {
|
||||
broadcastDelta(conversationId, "warning",
|
||||
buildDeltaJson("⏱️ 请求频率受限,等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
|
||||
}
|
||||
try {
|
||||
Thread.sleep(delay);
|
||||
} catch (InterruptedException ie) {
|
||||
|
||||
@ -83,6 +83,11 @@ public class RepetitionDetector {
|
||||
}
|
||||
|
||||
if (count >= MIN_REPEATS) {
|
||||
// 排除装饰性重复(代码缩进、ASCII 图表、Markdown 分隔线常见)
|
||||
if (isDecorativePattern(pattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
repetitionDetected = true;
|
||||
log.warn("[RepetitionDetector] Detected degenerate repetition: " +
|
||||
"pattern length={}, repeats={}, pattern preview=\"{}\"",
|
||||
@ -95,6 +100,46 @@ public class RepetitionDetector {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 pattern 是否为装饰性字符(不应判定为退化重复)。
|
||||
* <p>
|
||||
* 排除场景:
|
||||
* <ul>
|
||||
* <li>纯空白/缩进:{@code " "}(代码缩进)</li>
|
||||
* <li>单一重复字符:{@code "────────"} {@code "════════"} {@code "--------"} {@code "********"}(分隔线、表格边框)</li>
|
||||
* <li>Box Drawing 字符族:{@code "┌──────┐"} {@code "│ │"}(ASCII 图表)</li>
|
||||
* </ul>
|
||||
*/
|
||||
private boolean isDecorativePattern(String pattern) {
|
||||
if (pattern.isBlank()) {
|
||||
return true; // 纯空白
|
||||
}
|
||||
|
||||
// 统计不同的非空白字符种类
|
||||
long distinctNonWhitespace = pattern.chars()
|
||||
.filter(c -> !Character.isWhitespace(c))
|
||||
.distinct()
|
||||
.count();
|
||||
|
||||
// 只有 1-2 种不同的非空白字符 → 装饰性(如 "────────" 或 "│ │")
|
||||
if (distinctNonWhitespace <= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否全部是 Box Drawing / 装饰字符
|
||||
boolean allDecorative = pattern.chars().allMatch(c ->
|
||||
Character.isWhitespace(c)
|
||||
|| isBoxDrawing(c)
|
||||
|| "─━│┃┄┅┆┇┈┉┊┋═║╌╍╎╏╔╗╚╝╠╣╦╩╬├┤┬┴┼┌┐└┘".indexOf(c) >= 0
|
||||
|| "-=_*+|#~<>".indexOf(c) >= 0);
|
||||
return allDecorative;
|
||||
}
|
||||
|
||||
private boolean isBoxDrawing(int codePoint) {
|
||||
// Unicode Box Drawing block: U+2500 – U+257F
|
||||
return codePoint >= 0x2500 && codePoint <= 0x257F;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置检测器状态
|
||||
*/
|
||||
|
||||
@ -341,13 +341,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
|
||||
// 上下文窗口管理:裁剪超出模型 context window 的历史(含当前消息预算)
|
||||
if (conversationWindowManager != null) {
|
||||
Long parsedAgentId = null;
|
||||
try { parsedAgentId = Long.valueOf(agentId); } catch (Exception ignored) {}
|
||||
historyMessages = conversationWindowManager.fitToWindow(
|
||||
historyMessages,
|
||||
systemPrompt != null ? systemPrompt : "",
|
||||
userMessage,
|
||||
maxInputTokens,
|
||||
chatModel,
|
||||
conversationId);
|
||||
conversationId,
|
||||
parsedAgentId);
|
||||
}
|
||||
|
||||
List<Message> messages = new ArrayList<>(historyMessages);
|
||||
|
||||
@ -38,8 +38,8 @@ public class ObservationDispatcher implements EdgeAction {
|
||||
return FINAL_ANSWER_NODE;
|
||||
}
|
||||
|
||||
// 1. 迭代超限检查
|
||||
if (currentIteration >= maxIterations) {
|
||||
// 1. 迭代超限检查(maxIterations=0 表示不限制)
|
||||
if (maxIterations > 0 && currentIteration >= maxIterations) {
|
||||
log.warn("[ObservationDispatcher] Max iterations ({}) reached at iteration {}, " +
|
||||
"routing to limitExceededNode", maxIterations, currentIteration);
|
||||
return LIMIT_EXCEEDED_NODE;
|
||||
|
||||
@ -52,9 +52,10 @@ public class ReasoningDispatcher implements EdgeAction {
|
||||
return FINAL_ANSWER_NODE;
|
||||
}
|
||||
|
||||
// 3. LLM 调用次数超限 — 仅拦截继续循环(工具调用/总结)的路径
|
||||
// 3. LLM 调用次数超限 — 仅拦截继续循环(工具调用/总结)的路径(maxIterations=0 不限制)
|
||||
int llmCallCount = accessor.llmCallCount();
|
||||
int llmCallLimit = accessor.maxIterations() * LLM_CALL_MULTIPLIER;
|
||||
int maxIter = accessor.maxIterations();
|
||||
int llmCallLimit = maxIter > 0 ? maxIter * LLM_CALL_MULTIPLIER : Integer.MAX_VALUE;
|
||||
if (llmCallCount >= llmCallLimit) {
|
||||
log.warn("[ReasoningDispatcher] LLM call count limit reached ({}/{}), " +
|
||||
"routing to limitExceededNode instead of continuing loop",
|
||||
|
||||
@ -71,6 +71,24 @@ public class ObservationNode implements NodeAction {
|
||||
// 合并为单条观察记录
|
||||
String combinedObservation = String.join("\n---\n", processedObservations);
|
||||
|
||||
// Budget Pressure Warning(Hermes 风格):接近上限时注入警告到工具结果中
|
||||
// LLM 下一轮 reasoning 时能看到,从而主动收束,而非被硬性截断
|
||||
if (maxIterations > 0) {
|
||||
int progress = (int) ((double) nextIteration / maxIterations * 100);
|
||||
if (progress >= 90) {
|
||||
combinedObservation += "\n\n[⚠️ 预算警告] 当前迭代 " + nextIteration + "/" + maxIterations +
|
||||
",仅剩 " + (maxIterations - nextIteration) + " 步。" +
|
||||
"请立即提供最终回答,不要再调用工具(除非绝对必要)。";
|
||||
log.info("[ObservationNode] Budget WARNING injected: {}/{} ({}%)",
|
||||
nextIteration, maxIterations, progress);
|
||||
} else if (progress >= 70) {
|
||||
combinedObservation += "\n\n[📊 预算提示] 当前迭代 " + nextIteration + "/" + maxIterations +
|
||||
",剩余 " + (maxIterations - nextIteration) + " 步。请开始整合已有信息,准备给出回答。";
|
||||
log.info("[ObservationNode] Budget caution injected: {}/{} ({}%)",
|
||||
nextIteration, maxIterations, progress);
|
||||
}
|
||||
}
|
||||
|
||||
// 手动累加观察历史(OBSERVATION_HISTORY 使用 REPLACE 策略,以便 SummarizingNode 可清空)
|
||||
List<String> existingHistory = accessor.observationHistory();
|
||||
List<String> updatedHistory = new ArrayList<>(existingHistory);
|
||||
|
||||
@ -245,13 +245,16 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
|
||||
// 上下文窗口管理:裁剪超出模型 context window 的历史(含当前消息预算)
|
||||
if (conversationWindowManager != null) {
|
||||
Long parsedAgentId = null;
|
||||
try { parsedAgentId = Long.valueOf(agentId); } catch (Exception ignored) {}
|
||||
historyMessages = conversationWindowManager.fitToWindow(
|
||||
historyMessages,
|
||||
systemPrompt != null ? systemPrompt : "",
|
||||
userMessage,
|
||||
maxInputTokens,
|
||||
chatModel,
|
||||
conversationId);
|
||||
conversationId,
|
||||
parsedAgentId);
|
||||
}
|
||||
|
||||
List<Message> messages = new ArrayList<>(historyMessages);
|
||||
|
||||
@ -57,11 +57,12 @@ public final class MateClawStateAccessor {
|
||||
}
|
||||
|
||||
public int maxIterations() {
|
||||
return state.value(MAX_ITERATIONS, 10);
|
||||
return state.value(MAX_ITERATIONS, 25);
|
||||
}
|
||||
|
||||
public boolean isLimitReached() {
|
||||
return iterationCount() >= maxIterations();
|
||||
int max = maxIterations();
|
||||
return max > 0 && iterationCount() >= max; // max=0 表示不限制
|
||||
}
|
||||
|
||||
// ===== 工具调用 =====
|
||||
|
||||
@ -21,6 +21,20 @@ public class ConversationWindowProperties {
|
||||
/** 压缩后保留最近 N 轮对话(user+assistant 算一轮) */
|
||||
private int preserveRecentPairs = 5;
|
||||
|
||||
/** 摘要自身最大 token 数 */
|
||||
/** 摘要自身最大 token 数(仅作 LLM maxToken 参数上限,实际预算由动态计算) */
|
||||
private int summaryMaxTokens = 800;
|
||||
|
||||
// ==================== 动态压缩配置(Hermes 风格) ====================
|
||||
|
||||
/** 尾部保护的最小消息数(即使 token 预算用完也至少保留这么多) */
|
||||
private int protectLastMinMessages = 10;
|
||||
|
||||
/** 摘要 token 预算占被压缩内容的比例 (0-1)。被压缩内容越多,摘要越长。 */
|
||||
private double summaryBudgetRatio = 0.20;
|
||||
|
||||
/** 摘要 token 预算上限(字数,非 token) */
|
||||
private int summaryBudgetCeiling = 3000;
|
||||
|
||||
/** 摘要 token 预算下限(字数) */
|
||||
private int summaryBudgetFloor = 500;
|
||||
}
|
||||
|
||||
@ -108,7 +108,7 @@ public class MemoryNudgeService {
|
||||
.replace("{transcript}", transcript)
|
||||
.replace("{existing_memories}", existingMemories.isBlank() ? "(none)" : existingMemories);
|
||||
|
||||
// 5. Call LLM
|
||||
// 5. Call LLM (with rate limit retry)
|
||||
String llmResponse;
|
||||
try {
|
||||
ChatModel chatModel = buildChatModel();
|
||||
@ -116,8 +116,11 @@ public class MemoryNudgeService {
|
||||
new SystemMessage(systemPrompt),
|
||||
new UserMessage(userPrompt)
|
||||
));
|
||||
ChatResponse response = chatModel.call(prompt);
|
||||
llmResponse = response.getResult().getOutput().getText();
|
||||
llmResponse = callLlmWithRetry(chatModel, prompt, 2);
|
||||
if (llmResponse == null) {
|
||||
log.warn("[Nudge] LLM returned null after retries for agent={}", agentId);
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Nudge] LLM call failed for agent={}: {}", agentId, e.getMessage());
|
||||
return;
|
||||
@ -193,6 +196,38 @@ public class MemoryNudgeService {
|
||||
}
|
||||
}
|
||||
|
||||
private String callLlmWithRetry(ChatModel chatModel, Prompt prompt, int maxRetries) {
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
ChatResponse response = chatModel.call(prompt);
|
||||
if (response != null && response.getResult() != null
|
||||
&& response.getResult().getOutput() != null) {
|
||||
return response.getResult().getOutput().getText();
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
if (attempt < maxRetries && isRateLimitError(e)) {
|
||||
long delay = 5000L * (attempt + 1);
|
||||
log.info("[Nudge] Rate limited, waiting {}ms before retry ({}/{})",
|
||||
delay, attempt + 1, maxRetries);
|
||||
try { Thread.sleep(delay); } catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
throw e instanceof RuntimeException re ? re : new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isRateLimitError(Exception e) {
|
||||
String msg = e.getMessage();
|
||||
return msg != null && (msg.contains("429") || msg.contains("rate_limit")
|
||||
|| msg.contains("速率限制") || msg.contains("Too Many Requests"));
|
||||
}
|
||||
|
||||
private boolean isInCooldown(Long agentId) {
|
||||
Instant lastRun = lastNudgeTimes.get(agentId);
|
||||
if (lastRun == null) return false;
|
||||
|
||||
@ -120,8 +120,11 @@ public class MemorySummarizationService {
|
||||
new SystemMessage(systemPrompt),
|
||||
new UserMessage(userPrompt)
|
||||
));
|
||||
ChatResponse response = chatModel.call(prompt);
|
||||
llmResponse = response.getResult().getOutput().getText();
|
||||
llmResponse = callLlmWithRetry(chatModel, prompt, 2);
|
||||
if (llmResponse == null) {
|
||||
log.warn("[Memory] LLM returned null after retries for agent={}, conv={}", agentId, conversationId);
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Memory] LLM call failed for agent={}, conv={}: {}",
|
||||
agentId, conversationId, e.getMessage());
|
||||
@ -247,6 +250,42 @@ public class MemorySummarizationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带轻量重试的 LLM 调用:遇到 429 时等待后重试,避免后台任务因限流直接放弃。
|
||||
* Spring AI RetryTemplate 已处理第一层重试,此方法作为二次保护。
|
||||
*/
|
||||
private String callLlmWithRetry(ChatModel chatModel, Prompt prompt, int maxRetries) {
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
ChatResponse response = chatModel.call(prompt);
|
||||
if (response != null && response.getResult() != null
|
||||
&& response.getResult().getOutput() != null) {
|
||||
return response.getResult().getOutput().getText();
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
if (attempt < maxRetries && isRateLimitError(e)) {
|
||||
long delay = 5000L * (attempt + 1);
|
||||
log.info("[Memory] Rate limited, waiting {}ms before retry ({}/{})",
|
||||
delay, attempt + 1, maxRetries);
|
||||
try { Thread.sleep(delay); } catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
throw e instanceof RuntimeException re ? re : new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isRateLimitError(Exception e) {
|
||||
String msg = e.getMessage();
|
||||
return msg != null && (msg.contains("429") || msg.contains("rate_limit")
|
||||
|| msg.contains("速率限制") || msg.contains("Too Many Requests"));
|
||||
}
|
||||
|
||||
private boolean isInCooldown(Long agentId) {
|
||||
Instant lastRun = lastRunTimes.get(agentId);
|
||||
if (lastRun == null) return false;
|
||||
|
||||
@ -45,6 +45,14 @@ spring:
|
||||
repository:
|
||||
jdbc:
|
||||
initialize-schema: embedded
|
||||
# Spring AI Retry 配置:429/503/529 归为可重试(TransientAiException),启用指数退避
|
||||
retry:
|
||||
max-attempts: 5
|
||||
on-http-codes: 429, 503, 529
|
||||
backoff:
|
||||
initial-interval: 3000
|
||||
multiplier: 3
|
||||
max-interval: 60000
|
||||
# 禁用 Spring AI MCP Client 自动配置(由 McpClientManager 自行管理生命周期)
|
||||
mcp:
|
||||
client:
|
||||
|
||||
@ -10,7 +10,7 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
|
||||
KEY (id)
|
||||
VALUES (1000000001, 'MateClaw Assistant', 'Default AI assistant with ReAct mode and tool calling', 'react',
|
||||
'You are MateClaw, an intelligent AI assistant. You can help users answer questions, analyze data, and execute tasks. Please respond professionally and in a friendly manner.',
|
||||
NULL, 10, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
|
||||
NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
|
||||
|
||||
-- Default Agent: Task Planner (Plan-Execute mode)
|
||||
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
|
||||
@ -24,7 +24,7 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
|
||||
KEY (id)
|
||||
VALUES (1000000003, 'StateGraph ReAct', 'StateGraph-based ReAct Agent with explicit reasoning loops and tool calling', 'react',
|
||||
'You are an intelligent assistant based on the StateGraph architecture. You can use tools to help users solve problems. Please respond professionally and in a friendly manner.',
|
||||
NULL, 10, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
|
||||
NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
|
||||
|
||||
-- ==================== Local Model Providers (displayed first) ====================
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ ON DUPLICATE KEY UPDATE username=VALUES(username), password=VALUES(password), ni
|
||||
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
|
||||
VALUES (1000000001, 'MateClaw Assistant', 'Default AI assistant with ReAct mode and tool calling', 'react',
|
||||
'You are MateClaw, an intelligent AI assistant. You can help users answer questions, analyze data, and execute tasks. Please respond professionally and in a friendly manner.',
|
||||
NULL, 10, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
|
||||
NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
-- Default Agent: Task Planner (Plan-Execute mode)
|
||||
@ -23,7 +23,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agen
|
||||
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
|
||||
VALUES (1000000003, 'StateGraph ReAct', 'StateGraph-based ReAct Agent with explicit reasoning loops and tool calling', 'react',
|
||||
'You are an intelligent assistant based on the StateGraph architecture. You can use tools to help users solve problems. Please respond professionally and in a friendly manner.',
|
||||
NULL, 10, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
|
||||
NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
-- ==================== Local Model Providers (displayed first) ====================
|
||||
|
||||
@ -9,7 +9,7 @@ ON DUPLICATE KEY UPDATE username=VALUES(username), password=VALUES(password), ni
|
||||
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
|
||||
VALUES (1000000001, 'MateClaw Assistant', '默认 AI 助手,基于 ReAct 模式,支持工具调用', 'react',
|
||||
'你是 MateClaw,一个智能 AI 助手。你可以帮助用户回答问题、分析数据、执行任务。请用中文回复,保持专业、友好的态度。',
|
||||
NULL, 10, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
|
||||
NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
-- 默认 Agent:任务规划助手(Plan-Execute 模式)
|
||||
@ -23,7 +23,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agen
|
||||
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
|
||||
VALUES (1000000003, 'StateGraph ReAct', '基于 StateGraph 的 ReAct Agent,支持显式推理循环和工具调用', 'react',
|
||||
'你是基于 StateGraph 架构的智能助手。你可以使用工具来帮助用户解决问题。请用中文回复,保持专业、友好的态度。',
|
||||
NULL, 10, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
|
||||
NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
-- ==================== 本地模型 Provider(优先展示) ====================
|
||||
|
||||
@ -10,7 +10,7 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
|
||||
KEY (id)
|
||||
VALUES (1000000001, 'MateClaw Assistant', '默认 AI 助手,基于 ReAct 模式,支持工具调用', 'react',
|
||||
'你是 MateClaw,一个智能 AI 助手。你可以帮助用户回答问题、分析数据、执行任务。请用中文回复,保持专业、友好的态度。',
|
||||
NULL, 10, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
|
||||
NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
|
||||
|
||||
-- 默认 Agent:任务规划助手(Plan-Execute 模式)
|
||||
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
|
||||
@ -24,7 +24,7 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
|
||||
KEY (id)
|
||||
VALUES (1000000003, 'StateGraph ReAct', '基于 StateGraph 的 ReAct Agent,支持显式推理循环和工具调用', 'react',
|
||||
'你是基于 StateGraph 架构的智能助手。你可以使用工具来帮助用户解决问题。请用中文回复,保持专业、友好的态度。',
|
||||
NULL, 10, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
|
||||
NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
|
||||
|
||||
-- ==================== 本地模型 Provider(优先展示) ====================
|
||||
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
你是上下文压缩助手。将以下对话轮次压缩为结构化交接摘要,供后续助手继续任务。
|
||||
|
||||
使用以下结构:
|
||||
|
||||
## 目标
|
||||
[用户要完成什么]
|
||||
|
||||
## 约束与偏好
|
||||
[用户偏好、编码风格、重要决策]
|
||||
|
||||
## 进展
|
||||
### 已完成
|
||||
[已完成的工作——包含具体文件路径、执行的命令、获得的结果]
|
||||
### 进行中
|
||||
[当前正在进行的工作]
|
||||
### 阻塞
|
||||
[遇到的阻塞或问题]
|
||||
|
||||
## 关键决策
|
||||
[重要的技术决策及原因]
|
||||
|
||||
## 相关文件
|
||||
[读取、修改或创建的文件——每个附简要说明]
|
||||
|
||||
## 下一步
|
||||
[继续工作需要做什么]
|
||||
|
||||
## 关键上下文
|
||||
[不显式保留就会丢失的具体值、错误消息、配置详情]
|
||||
|
||||
目标约 {summary_budget} 字。要具体——包含文件路径、命令输出、错误消息和实际值。
|
||||
只输出摘要正文,不要前缀或额外说明。
|
||||
@ -0,0 +1,23 @@
|
||||
你正在更新一个上下文压缩摘要。上一次压缩生成了以下摘要,之后发生了新的对话轮次。
|
||||
|
||||
## 旧摘要
|
||||
{previous_summary}
|
||||
|
||||
## 新增轮次
|
||||
{conversation}
|
||||
|
||||
请用同样的结构更新摘要。保留所有仍然相关的信息,添加新进展,
|
||||
将"进行中"的已完成项移到"已完成",仅删除明显过时的信息。
|
||||
|
||||
使用以下结构:
|
||||
|
||||
## 目标
|
||||
## 约束与偏好
|
||||
## 进展(已完成 / 进行中 / 阻塞)
|
||||
## 关键决策
|
||||
## 相关文件
|
||||
## 下一步
|
||||
## 关键上下文
|
||||
|
||||
目标约 {summary_budget} 字。要具体——包含文件路径、命令输出、错误消息和实际值。
|
||||
只输出摘要正文,不要前缀或额外说明。
|
||||
@ -0,0 +1,5 @@
|
||||
以下是需要压缩的对话轮次:
|
||||
|
||||
{conversation}
|
||||
|
||||
请生成结构化交接摘要。
|
||||
Loading…
Reference in New Issue
Block a user