diff --git a/.gitignore b/.gitignore index ee0f6448..7ae1acfc 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ mateclaw-server/src/main/resources/static/ # mateclaw local runtime data (H2 DB, logs, etc. - do not commit) mateclaw-server/data/ +/data/ # VitePress build output and cache (do not commit) docs/.vitepress/cache/ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 7a25d007..ec040ddf 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -125,6 +125,7 @@ public class AgentGraphBuilder { private final vip.mate.llm.chatgpt.ChatGPTResponsesClient chatGPTResponsesClient; private final WikiContextService wikiContextService; private final vip.mate.workspace.core.service.WorkspaceService workspaceService; + private final vip.mate.llm.cache.AnthropicCacheOptionsFactory anthropicCacheOptionsFactory; /** * 根据 AgentEntity 构建完整的 Agent 实例 @@ -463,9 +464,24 @@ public class AgentGraphBuilder { /** * 构建运行时 ChatModel(不包装为 ChatClient) - * 用于 StateGraph 节点直接调用 + * 用于 StateGraph 节点直接调用。使用注入的共享 {@link #retryTemplate} 作为 Spring AI + * 内层重试策略。 */ public ChatModel buildRuntimeChatModel(ModelConfigEntity runtimeModel) { + return buildRuntimeChatModel(runtimeModel, this.retryTemplate); + } + + /** + * 构建运行时 ChatModel,并指定自定义的 Spring AI {@link RetryTemplate}。 + *

+ * 用于调用方(如 Wiki 消化管线)已经有自己的外层重试策略, + * 希望绕过 Spring AI 内层重试、独占重试控制权的场景:传入 + * {@code RetryTemplate.builder().maxAttempts(1).build()} 即可把内层降级为"只跑一次"。 + *

+ * DashScope 和 OpenAI-ChatGPT 分支不走 Spring AI 的 RetryTemplate 接口, + * 本参数对它们无效(它们各自有内部重试或直通)。 + */ + public ChatModel buildRuntimeChatModel(ModelConfigEntity runtimeModel, RetryTemplate retryOverride) { ModelProviderEntity provider = modelProviderService.getProviderConfig(runtimeModel.getProvider()); ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel()); @@ -490,7 +506,7 @@ public class AgentGraphBuilder { return OpenAiChatModel.builder() .openAiApi(api) .defaultOptions(options) - .retryTemplate(retryTemplate) + .retryTemplate(retryOverride) .observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP)) .build(); } @@ -501,7 +517,7 @@ public class AgentGraphBuilder { return AnthropicChatModel.builder() .anthropicApi(api) .defaultOptions(options) - .retryTemplate(retryTemplate) + .retryTemplate(retryOverride) .observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP)) .build(); } @@ -981,6 +997,11 @@ public class AgentGraphBuilder { builder.maxTokens(4096); } } + // RFC-014: 接入 Anthropic prompt caching(spring-ai 1.1.4 一等支持) + // 通过 cacheOptions 配置 system / tools / conversation history 自动打 cache_control, + // 多轮对话场景可节省 50–75% 输入 token 成本。 + builder.cacheOptions(anthropicCacheOptionsFactory.build()); + return builder.internalToolExecutionEnabled(false).build(); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java b/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java index f3661a55..b664186c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java @@ -9,6 +9,12 @@ import java.time.format.DateTimeFormatter; *

* 参考 Claude Code 的 prependUserContext 模式:将时间信息作为首条 meta UserMessage 注入, * 而非修改 System Prompt,以保持 prompt cache 命中率。 + *

+ * RFC-014 协同要点:本类返回的内容必须始终包装为 {@code UserMessage} 注入, + * 严禁拼接进 SystemMessage —— 否则每次时间变化都会击穿 spring-ai 的 + * {@code AnthropicCacheStrategy.SYSTEM_AND_TOOLS / CONVERSATION_HISTORY} system cache。 + * 内容长度(约 70 字符)远小于 spring-ai 的 USER min-content-length 阈值(≥1024 字符), + * 因此不会被错误纳入对话 cache 块。新增调用点时请保持此约束。 */ public final class RuntimeContextInjector { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index bccc2b9b..95a5e6c8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -272,6 +272,9 @@ public class NodeStreamingChatHelper { AtomicReference errorRef = new AtomicReference<>(); AtomicInteger promptTokens = new AtomicInteger(0); AtomicInteger completionTokens = new AtomicInteger(0); + // RFC-014: Anthropic prompt cache 计数(其它 provider 永远为 0) + AtomicInteger cacheReadTokens = new AtomicInteger(0); + AtomicInteger cacheWriteTokens = new AtomicInteger(0); // 重复检测器:检测 LLM 退化输出(如不断重复同一句话) RepetitionDetector contentRepDetector = new RepetitionDetector(); @@ -342,6 +345,10 @@ public class NodeStreamingChatHelper { if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) { completionTokens.set(usage.getCompletionTokens().intValue()); } + // RFC-014: 反射抽取 Anthropic prompt cache 字段(DashScope/OpenAI 自然返回 0) + var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage); + if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens()); + if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens()); } }) .subscribe( @@ -378,7 +385,8 @@ public class NodeStreamingChatHelper { phase, contentAccum.length(), thinkingAccum.length(), toolCallAccumulators.size(), conversationId); return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators, - promptTokens.get(), completionTokens.get(), phase); + promptTokens.get(), completionTokens.get(), + cacheReadTokens.get(), cacheWriteTokens.get(), phase); } log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}", phase, conversationId); @@ -410,7 +418,9 @@ public class NodeStreamingChatHelper { buildDeltaJson("LLM 响应中断,使用已生成的部分内容继续")); } return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators, - promptTokens.get(), completionTokens.get(), phase, true, error.getMessage()); + promptTokens.get(), completionTokens.get(), + cacheReadTokens.get(), cacheWriteTokens.get(), + phase, true, error.getMessage()); } // ===== 无内容:分类错误并决定是否重试 ===== @@ -460,14 +470,16 @@ public class NodeStreamingChatHelper { // warning 已在 dispose 时广播,无需重复 } return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators, - promptTokens.get(), completionTokens.get(), phase, + promptTokens.get(), completionTokens.get(), + cacheReadTokens.get(), cacheWriteTokens.get(), phase, truncatedByRepetition, truncatedByRepetition ? "output_truncated_repetition" : null); } /** 组装 stopped partial 结果(用户主动停止,有已累积内容) */ private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum, List toolCallAccumulators, - int promptTok, int completionTok, String phase) { + int promptTok, int completionTok, + int cacheReadTok, int cacheWriteTok, String phase) { List finalToolCalls = buildFinalToolCalls(toolCallAccumulators); String fullContent = contentAccum.toString(); String fullThinking = thinkingAccum.toString(); @@ -487,13 +499,14 @@ public class NodeStreamingChatHelper { return new StreamResult(fullContent, fullThinking, assembledMessage, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, - true, null, ErrorType.NONE, true); + true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok); } /** 组装最终 StreamResult(成功或 partial) */ private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum, List toolCallAccumulators, int promptTok, int completionTok, + int cacheReadTok, int cacheWriteTok, String phase, boolean partial, String errorMsg) { List finalToolCalls = buildFinalToolCalls(toolCallAccumulators); String fullContent = contentAccum.toString(); @@ -522,7 +535,7 @@ public class NodeStreamingChatHelper { return new StreamResult(fullContent, fullThinking, assembledMessage, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, - partial, errorMsg, ErrorType.NONE); + partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok); } /** 构建纯错误 StreamResult(无任何内容) */ @@ -685,14 +698,18 @@ public class NodeStreamingChatHelper { /** 错误类型分类 */ ErrorType errorType, /** 用户主动停止(stopRequested)导致的提前返回 */ - boolean stopped + boolean stopped, + /** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0) */ + int cacheReadTokens, + /** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0) */ + int cacheWriteTokens ) { /** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */ public StreamResult(String text, String thinking, AssistantMessage assistantMessage, List toolCalls, boolean hasToolCalls, int promptTokens, int completionTokens) { this(text, thinking, assistantMessage, toolCalls, hasToolCalls, - promptTokens, completionTokens, false, null, ErrorType.NONE, false); + promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0); } /** 兼容 10-arg 调用点 */ @@ -701,7 +718,17 @@ public class NodeStreamingChatHelper { int promptTokens, int completionTokens, boolean partial, String errorMessage, ErrorType errorType) { this(text, thinking, assistantMessage, toolCalls, hasToolCalls, - promptTokens, completionTokens, partial, errorMessage, errorType, false); + promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0); + } + + /** 兼容 12-arg 调用点(pre-RFC-014) */ + public StreamResult(String text, String thinking, AssistantMessage assistantMessage, + List toolCalls, boolean hasToolCalls, + int promptTokens, int completionTokens, + boolean partial, String errorMessage, ErrorType errorType, + boolean stopped) { + this(text, thinking, assistantMessage, toolCalls, hasToolCalls, + promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0); } /** 是否有不可忽略的错误(无内容 + 有错误) */ diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/model/UsageDailyEntity.java b/mateclaw-server/src/main/java/vip/mate/dashboard/model/UsageDailyEntity.java index 3127b71b..6bcd3b47 100644 --- a/mateclaw-server/src/main/java/vip/mate/dashboard/model/UsageDailyEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/model/UsageDailyEntity.java @@ -18,6 +18,10 @@ public class UsageDailyEntity { private Long totalTokens; private Long promptTokens; private Long completionTokens; + /** RFC-014: Anthropic cache_read_input_tokens 累计 */ + private Long cacheReadTokens; + /** RFC-014: Anthropic cache_creation_input_tokens 累计 */ + private Long cacheWriteTokens; private Integer toolCallCount; private Integer errorCount; @TableField(fill = FieldFill.INSERT) diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/AdaptiveCacheStrategy.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/AdaptiveCacheStrategy.java new file mode 100644 index 00000000..4deace75 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/AdaptiveCacheStrategy.java @@ -0,0 +1,78 @@ +package vip.mate.llm.cache; + +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 自适应装饰器:包裹任一基础策略,根据真实命中率动态降级。 + * + *

规则: + *

+ * + *

这是有状态对象,建议作为 Spring 单例 bean。所有计数用原子类型,无锁、零分配热路径。

+ */ +@Slf4j +public final class AdaptiveCacheStrategy implements PromptCacheStrategy { + + private final PromptCacheStrategy delegate; + private final int missThreshold; + private final long coolDownMillis; + + private final AtomicInteger consecutiveMisses = new AtomicInteger(); + private final AtomicLong degradedUntilEpochMs = new AtomicLong(); + + public AdaptiveCacheStrategy(PromptCacheStrategy delegate, int missThreshold, long coolDownMillis) { + this.delegate = (delegate instanceof AdaptiveCacheStrategy) + ? ((AdaptiveCacheStrategy) delegate).delegate + : delegate; + this.missThreshold = Math.max(1, missThreshold); + this.coolDownMillis = Math.max(0, coolDownMillis); + } + + @Override + public CachedPlan plan(CachePlanContext ctx) { + return isDegraded() ? CachedPlan.none() : delegate.plan(ctx); + } + + @Override + public boolean shouldCache(CachePlanContext ctx) { + return !isDegraded() && delegate.shouldCache(ctx); + } + + /** 命中:清零并立刻恢复(如已降级)。 */ + public void recordHit() { + consecutiveMisses.set(0); + degradedUntilEpochMs.set(0); + } + + /** 未命中:累加;越过阈值则进入冷却期。 */ + public void recordMiss() { + int n = consecutiveMisses.incrementAndGet(); + if (n >= missThreshold) { + long until = System.currentTimeMillis() + coolDownMillis; + degradedUntilEpochMs.set(until); + log.warn("Prompt cache strategy degraded after {} consecutive misses; coolDown={}ms", + n, coolDownMillis); + } + } + + private boolean isDegraded() { + long until = degradedUntilEpochMs.get(); + if (until == 0) return false; + if (System.currentTimeMillis() >= until) { + // 冷却期已过:原子清零(CAS 防止并发覆写) + degradedUntilEpochMs.compareAndSet(until, 0); + consecutiveMisses.set(0); + return false; + } + return true; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/AnthropicCacheOptionsFactory.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/AnthropicCacheOptionsFactory.java new file mode 100644 index 00000000..b76e4c9b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/AnthropicCacheOptionsFactory.java @@ -0,0 +1,74 @@ +package vip.mate.llm.cache; + +import org.springframework.ai.anthropic.api.AnthropicCacheOptions; +import org.springframework.ai.anthropic.api.AnthropicCacheStrategy; +import org.springframework.ai.anthropic.api.AnthropicCacheTtl; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Map; + +/** + * 把 MateClaw 的 {@link CacheProperties} 翻译成 spring-ai 1.1.4 一等支持的 {@link AnthropicCacheOptions}。 + * + *

设计说明:spring-ai 已内置完整的 Anthropic prompt cache 框架({@code CacheEligibilityResolver} + + * {@code CacheBreakpointTracker}),它会按 {@link AnthropicCacheStrategy} 自动决策在 system / tools / + * conversation history 上挂 {@code cache_control}。我们只需配置好策略与 TTL/min-length 即可, + * 不必自己写 HTTP body 拦截器。

+ * + *

映射规则(对应 RFC-014 Change 1 的 SystemAndTailCacheStrategy 默认行为): + *

+ * + *

无状态、线程安全。

+ */ +@Component +public class AnthropicCacheOptionsFactory { + + /** token → 字符 的粗略乘子(英文 4 字符 ≈ 1 token;中文略低,但作为门槛足矣)。 */ + private static final int CHARS_PER_TOKEN_HEURISTIC = 4; + + private final CacheProperties props; + + public AnthropicCacheOptionsFactory(CacheProperties props) { + this.props = props; + } + + /** + * 构建启用了缓存的 {@link AnthropicCacheOptions};当 {@link CacheProperties#isEnabled()} 为 false + * 时返回 {@link AnthropicCacheOptions#DISABLED}(spring-ai 内置的 NONE 策略快速路径)。 + */ + public AnthropicCacheOptions build() { + if (!props.isEnabled()) { + return AnthropicCacheOptions.DISABLED; + } + + AnthropicCacheStrategy strategy = props.isIncludeToolsBlock() + ? AnthropicCacheStrategy.CONVERSATION_HISTORY + : AnthropicCacheStrategy.SYSTEM_ONLY; + + AnthropicCacheTtl ttl = (props.resolveCacheTtl() == CacheTtl.EXTENDED_1H) + ? AnthropicCacheTtl.ONE_HOUR + : AnthropicCacheTtl.FIVE_MINUTES; + + Map ttlMap = new EnumMap<>(MessageType.class); + ttlMap.put(MessageType.SYSTEM, ttl); + ttlMap.put(MessageType.USER, ttl); + + Map minLenMap = new EnumMap<>(MessageType.class); + int sysMinChars = Math.max(256, props.getMinPromptTokens() * CHARS_PER_TOKEN_HEURISTIC / 8); + minLenMap.put(MessageType.SYSTEM, sysMinChars); + + return AnthropicCacheOptions.builder() + .strategy(strategy) + .messageTypeTtl(ttlMap) + .messageTypeMinContentLengths(minLenMap) + .multiBlockSystemCaching(false) + .build(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/Breakpoint.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/Breakpoint.java new file mode 100644 index 00000000..0ae6f35c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/Breakpoint.java @@ -0,0 +1,14 @@ +package vip.mate.llm.cache; + +/** + * 一个缓存断点:序列化器据此决定在哪条消息(或 system / tools 段)上挂 cache_control。 + * + * @param messageIndex 在最终 messages 数组中的索引;对 SYSTEM_TAIL / TOOLS_TAIL 取 -1 + * @param kind 断点语义 + */ +public record Breakpoint(int messageIndex, BreakpointKind kind) { + public static Breakpoint systemTail() { return new Breakpoint(-1, BreakpointKind.SYSTEM_TAIL); } + public static Breakpoint toolsTail() { return new Breakpoint(-1, BreakpointKind.TOOLS_TAIL); } + public static Breakpoint memoryBlock(int idx) { return new Breakpoint(idx, BreakpointKind.MEMORY_BLOCK); } + public static Breakpoint messagesTail(int idx) { return new Breakpoint(idx, BreakpointKind.MESSAGES_TAIL); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/BreakpointKind.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/BreakpointKind.java new file mode 100644 index 00000000..e84b167b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/BreakpointKind.java @@ -0,0 +1,13 @@ +package vip.mate.llm.cache; + +/** + * 缓存断点的语义类型。 + *

断点位置用于序列化器决定把 {@code cache_control} 放在请求体的哪个内容块上。 + * 顺序与 Anthropic Messages API 的天然布局对齐:system → tools → memory/wiki → messages tail。

+ */ +public enum BreakpointKind { + SYSTEM_TAIL, + TOOLS_TAIL, + MEMORY_BLOCK, + MESSAGES_TAIL +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheDirectives.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheDirectives.java new file mode 100644 index 00000000..e7377551 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheDirectives.java @@ -0,0 +1,38 @@ +package vip.mate.llm.cache; + +import java.util.List; +import java.util.Map; + +/** + * 协议无关的"缓存指令包"。 + * + *

序列化器从 {@link CachedPlan} 推导出本对象后,由具体的请求拦截层 + * (如 {@code CachingChatModelDecorator} 注册到 RestClient 的 {@code ClientHttpRequestInterceptor}) + * 把指令落到具体协议的请求体上。

+ * + * @param breakpoints 最终要应用的断点(顺序敏感) + * @param httpHeaders 需要附加到 HTTP 请求的额外 header(如 Anthropic extended-cache-ttl beta header) + * @param ttl TTL 提示 + * @param protocol 生产此指令的协议;运行时拦截器据此选分支 + */ +public record CacheDirectives( + List breakpoints, + Map httpHeaders, + CacheTtl ttl, + vip.mate.llm.model.ModelProtocol protocol) { + + public CacheDirectives { + breakpoints = (breakpoints == null) ? List.of() : List.copyOf(breakpoints); + httpHeaders = (httpHeaders == null) ? Map.of() : Map.copyOf(httpHeaders); + if (ttl == null) ttl = CacheTtl.DEFAULT_5M; + if (protocol == null) throw new IllegalArgumentException("protocol must not be null"); + } + + public static CacheDirectives empty(vip.mate.llm.model.ModelProtocol protocol) { + return new CacheDirectives(List.of(), Map.of(), CacheTtl.DEFAULT_5M, protocol); + } + + public boolean isEmpty() { + return breakpoints.isEmpty(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CachePlanContext.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CachePlanContext.java new file mode 100644 index 00000000..3bf30d0c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CachePlanContext.java @@ -0,0 +1,65 @@ +package vip.mate.llm.cache; + +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.prompt.Prompt; +import vip.mate.llm.model.ModelProtocol; + +import java.util.List; + +/** + * 策略评估缓存方案时所需的只读上下文。 + * + *

不持有 {@link Prompt} 引用以外的可变状态;所有派生指标(system 长度、tool 数等)按需懒计算 + * 并由 record 字段缓存,避免重复扫描。

+ */ +public record CachePlanContext( + Prompt prompt, + ModelProtocol protocol, + int totalPromptTokens, + int turnCount, + int minPromptTokens, + int maxBreakpoints, + boolean includeToolsBlock, + CacheTtl ttl) { + + public CachePlanContext { + if (prompt == null) throw new IllegalArgumentException("prompt must not be null"); + if (protocol == null) throw new IllegalArgumentException("protocol must not be null"); + if (ttl == null) ttl = CacheTtl.DEFAULT_5M; + if (maxBreakpoints <= 0) maxBreakpoints = 4; + } + + /** 消息列表(不可变视图;上层不应修改)。 */ + public List messages() { + return prompt.getInstructions(); + } + + /** 是否存在 system 消息(用于决定是否打 SYSTEM_TAIL 断点)。 */ + public boolean hasSystemMessage() { + for (Message m : messages()) { + if (m.getMessageType() == MessageType.SYSTEM) return true; + } + return false; + } + + /** 系统消息的字符长度总和,用于估算是否值得缓存 system。 */ + public int systemCharLen() { + int n = 0; + for (Message m : messages()) { + if (m.getMessageType() == MessageType.SYSTEM) { + String t = m.getText(); + if (t != null) n += t.length(); + } + } + return n; + } + + /** 协议是否原生支持 cache_control 标记。DashScope/Ollama/Gemini 自有缓存机制,无需接入。 */ + public boolean protocolSupportsCacheControl() { + return switch (protocol) { + case ANTHROPIC_MESSAGES, OPENAI_CHATGPT -> true; + case OPENAI_COMPATIBLE, DASHSCOPE_NATIVE, GEMINI_NATIVE -> false; + }; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheProperties.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheProperties.java new file mode 100644 index 00000000..9fd6fb8f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheProperties.java @@ -0,0 +1,79 @@ +package vip.mate.llm.cache; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Prompt cache 总配置。 + * + *
+ * mateclaw:
+ *   llm:
+ *     cache:
+ *       enabled: true
+ *       min-prompt-tokens: 1024
+ *       max-breakpoints: 4
+ *       ttl: default              # default | extended-1h
+ *       include-tools-block: true
+ *       adaptive:
+ *         enabled: true
+ *         miss-threshold: 5
+ *         cool-down: 60s
+ * 
+ */ +@ConfigurationProperties(prefix = "mateclaw.llm.cache") +public class CacheProperties { + + /** 总开关;false 时全部走 NoOp。 */ + private boolean enabled = true; + + /** 累计 prompt token 低于此值则跳过缓存(避免 cache write 倒亏)。 */ + private int minPromptTokens = 1024; + + /** 单请求最多打几个 cache_control 断点(Anthropic 上限 4)。 */ + private int maxBreakpoints = 4; + + /** 默认 TTL;{@code extended-1h} 需要 Anthropic beta header。 */ + private Ttl ttl = Ttl.DEFAULT; + + /** 是否把工具 schema 段也作为一个断点(独立断点,避免与 system 共享)。 */ + private boolean includeToolsBlock = true; + + private final Adaptive adaptive = new Adaptive(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public int getMinPromptTokens() { return minPromptTokens; } + public void setMinPromptTokens(int minPromptTokens) { this.minPromptTokens = minPromptTokens; } + + public int getMaxBreakpoints() { return maxBreakpoints; } + public void setMaxBreakpoints(int maxBreakpoints) { this.maxBreakpoints = maxBreakpoints; } + + public Ttl getTtl() { return ttl; } + public void setTtl(Ttl ttl) { this.ttl = ttl; } + + public boolean isIncludeToolsBlock() { return includeToolsBlock; } + public void setIncludeToolsBlock(boolean includeToolsBlock) { this.includeToolsBlock = includeToolsBlock; } + + public Adaptive getAdaptive() { return adaptive; } + + public CacheTtl resolveCacheTtl() { + return ttl == Ttl.EXTENDED_1H ? CacheTtl.EXTENDED_1H : CacheTtl.DEFAULT_5M; + } + + public enum Ttl { DEFAULT, EXTENDED_1H } + + public static class Adaptive { + private boolean enabled = true; + private int missThreshold = 5; + /** 字符串解析在 application.yml 由 Spring Boot 自动转换;默认 60_000 ms。 */ + private long coolDownMs = 60_000L; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public int getMissThreshold() { return missThreshold; } + public void setMissThreshold(int missThreshold) { this.missThreshold = missThreshold; } + public long getCoolDownMs() { return coolDownMs; } + public void setCoolDownMs(long coolDownMs) { this.coolDownMs = coolDownMs; } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheSerializer.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheSerializer.java new file mode 100644 index 00000000..41f66ad2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheSerializer.java @@ -0,0 +1,27 @@ +package vip.mate.llm.cache; + +/** + * 把 {@link CachedPlan} 转换为协议特化的 {@link CacheDirectives}。 + * + *

sealed 锁定可选实现,避免反射式插件加载并支持穷尽 switch。

+ * + *

注意:本接口只决定"要打哪些断点 + 需要哪些 HTTP header",不直接修改底层 HTTP body。 + * 真正写 JSON 的工作由调用层完成;这样保证序列化器是纯函数,便于单元测试。

+ * + *

Anthropic 例外:spring-ai 1.1.4+ 已通过 {@code AnthropicCacheOptions} 提供一等支持, + * 由 {@link AnthropicCacheOptionsFactory} 直接装配到 {@code AnthropicChatOptions},不走本接口。 + * 本接口仅用于我们自有客户端(如 OpenAI Responses)需要手动装配缓存指令的协议。

+ */ +public sealed interface CacheSerializer + permits OpenAIResponsesCacheSerializer { + + /** 此序列化器服务的协议;调度方据此挑选实现。 */ + vip.mate.llm.model.ModelProtocol protocol(); + + /** + * @param plan 策略产出的方案(可能为空 → 返回 {@link CacheDirectives#empty} ) + * @param ctx 与策略相同的上下文,便于序列化器看 messages 大小决定是否缩减断点 + * @return 供 HTTP 拦截器消费的指令 + */ + CacheDirectives serialize(CachedPlan plan, CachePlanContext ctx); +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheTtl.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheTtl.java new file mode 100644 index 00000000..98e8e130 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheTtl.java @@ -0,0 +1,11 @@ +package vip.mate.llm.cache; + +/** + * Anthropic prompt cache 的存活时间。 + *

{@link #EXTENDED_1H} 需要 beta header {@code anthropic-beta: extended-cache-ttl-2025-04-11}, + * 可通过 application.yml 的 {@code mateclaw.llm.cache.ttl=extended-1h} 启用。

+ */ +public enum CacheTtl { + DEFAULT_5M, + EXTENDED_1H +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java new file mode 100644 index 00000000..dc94eea2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CacheUsageExtractor.java @@ -0,0 +1,91 @@ +package vip.mate.llm.cache; + +import org.springframework.ai.chat.metadata.Usage; + +import java.lang.reflect.Method; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * 从 Spring AI {@link Usage} 中提取 Anthropic 的 prompt cache token 计数。 + * + *

spring-ai 的高层 {@code Usage} 接口只暴露 {@code promptTokens} / {@code completionTokens}, + * 没有 cache 维度;但 {@link Usage#getNativeUsage()} 会返回 provider 的原生 usage 对象。 + * 对 Anthropic 而言是 {@code AnthropicApi.Usage} record,含 {@code cacheCreationInputTokens} + * 与 {@code cacheReadInputTokens}。

+ * + *

采用反射调用以避免: + *

    + *
  • 对 spring-ai 内部 record 形态的硬编码(未来字段重命名风险小)
  • + *
  • 对其它 provider(OpenAI 兼容、DashScope)的 ClassCastException
  • + *
+ * 反射结果按类缓存,热路径性能可接受。

+ * + *

不可变、线程安全。

+ */ +public final class CacheUsageExtractor { + + /** {@code (cacheReadTokens, cacheWriteTokens)};任一字段不可得时为 0。 */ + public record CacheTokens(int cacheReadTokens, int cacheWriteTokens) { + public static final CacheTokens EMPTY = new CacheTokens(0, 0); + + public boolean isEmpty() { return cacheReadTokens == 0 && cacheWriteTokens == 0; } + } + + /** 缓存 (Class, methodName) → reflected Method(命中失败时为标记 NULL_METHOD)。 */ + private static final ConcurrentMap METHOD_CACHE = new ConcurrentHashMap<>(); + private static final Method NULL_METHOD; + static { + try { + NULL_METHOD = Object.class.getMethod("toString"); + } catch (NoSuchMethodException e) { + throw new ExceptionInInitializerError(e); + } + } + + private CacheUsageExtractor() {} + + /** 从 spring-ai Usage 中尽力抽取 cache token;不支持的 provider 返回 EMPTY。 */ + public static CacheTokens extract(Usage usage) { + if (usage == null) return CacheTokens.EMPTY; + Object native_ = usage.getNativeUsage(); + if (native_ == null) return CacheTokens.EMPTY; + + int read = invokeIntAccessor(native_, "cacheReadInputTokens"); + int write = invokeIntAccessor(native_, "cacheCreationInputTokens"); + return (read == 0 && write == 0) ? CacheTokens.EMPTY : new CacheTokens(read, write); + } + + private static int invokeIntAccessor(Object target, String accessor) { + Class cls = target.getClass(); + String key = cls.getName() + "#" + accessor; + Method m = METHOD_CACHE.computeIfAbsent(key, k -> resolveAccessor(cls, accessor)); + if (m == NULL_METHOD) return 0; + try { + Object v = m.invoke(target); + if (v instanceof Number n) return n.intValue(); + return 0; + } catch (ReflectiveOperationException ignored) { + return 0; + } + } + + private static Method resolveAccessor(Class cls, String accessor) { + // 1) record-style getter: cacheReadInputTokens() + try { + Method m = cls.getMethod(accessor); + m.setAccessible(true); + return m; + } catch (NoSuchMethodException ignored) { + // 2) bean-style getter: getCacheReadInputTokens() + String beanName = "get" + Character.toUpperCase(accessor.charAt(0)) + accessor.substring(1); + try { + Method m = cls.getMethod(beanName); + m.setAccessible(true); + return m; + } catch (NoSuchMethodException ignored2) { + return NULL_METHOD; + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/CachedPlan.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/CachedPlan.java new file mode 100644 index 00000000..be264428 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/CachedPlan.java @@ -0,0 +1,35 @@ +package vip.mate.llm.cache; + +import java.util.List; +import java.util.SequencedCollection; + +/** + * 一次请求的缓存方案:要打哪些断点,TTL 多长。 + * + *

使用 JDK 21 {@link SequencedCollection} 保证断点的固定顺序(首尾稳定, + * 序列化器能直接按顺序写入),同时是不可变 record,便于在多线程间传递。

+ * + * @param breakpoints 断点序列;调用方应当按顺序遍历 + * @param ttl 缓存 TTL;{@code null} 等价 {@link CacheTtl#DEFAULT_5M} + */ +public record CachedPlan(SequencedCollection breakpoints, CacheTtl ttl) { + + public CachedPlan { + if (breakpoints == null) { + throw new IllegalArgumentException("breakpoints must not be null"); + } + } + + /** 空方案:不打任何断点(等价 NoOp)。 */ + public static CachedPlan none() { + return new CachedPlan(List.of(), CacheTtl.DEFAULT_5M); + } + + public boolean isEmpty() { + return breakpoints.isEmpty(); + } + + public int size() { + return breakpoints.size(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/LlmCacheAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/LlmCacheAutoConfiguration.java new file mode 100644 index 00000000..ef0bfd79 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/LlmCacheAutoConfiguration.java @@ -0,0 +1,34 @@ +package vip.mate.llm.cache; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * RFC-014 prompt cache 装配。 + * + *

暴露 {@link PromptCacheStrategy} 作为 Spring bean:当 {@code mateclaw.llm.cache.adaptive.enabled=true} + * 时用 {@link AdaptiveCacheStrategy} 包装基础策略 {@link SystemAndTailCacheStrategy}, + * 后者负责实际断点计算;自适应层根据 cache miss 率自动降级。

+ * + *

{@link AnthropicCacheOptionsFactory} 由 {@code @Component} 自动注册。

+ */ +@Configuration +@EnableConfigurationProperties(CacheProperties.class) +public class LlmCacheAutoConfiguration { + + @Bean + public PromptCacheStrategy promptCacheStrategy(CacheProperties props) { + if (!props.isEnabled()) { + return NoOpCacheStrategy.INSTANCE; + } + PromptCacheStrategy base = new SystemAndTailCacheStrategy(); + if (props.getAdaptive().isEnabled()) { + return new AdaptiveCacheStrategy( + base, + props.getAdaptive().getMissThreshold(), + props.getAdaptive().getCoolDownMs()); + } + return base; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/NoOpCacheStrategy.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/NoOpCacheStrategy.java new file mode 100644 index 00000000..73ec3ca3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/NoOpCacheStrategy.java @@ -0,0 +1,24 @@ +package vip.mate.llm.cache; + +/** + * 占位策略:永不打缓存断点。 + * + *

用于 {@link AdaptiveCacheStrategy} 在连续 cache miss 后降级, + * 或在 {@code mateclaw.llm.cache.enabled=false} 时全局短路。

+ */ +public final class NoOpCacheStrategy implements PromptCacheStrategy { + + public static final NoOpCacheStrategy INSTANCE = new NoOpCacheStrategy(); + + private NoOpCacheStrategy() {} + + @Override + public CachedPlan plan(CachePlanContext ctx) { + return CachedPlan.none(); + } + + @Override + public boolean shouldCache(CachePlanContext ctx) { + return false; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/OpenAIResponsesCacheSerializer.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/OpenAIResponsesCacheSerializer.java new file mode 100644 index 00000000..d965b1fc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/OpenAIResponsesCacheSerializer.java @@ -0,0 +1,33 @@ +package vip.mate.llm.cache; + +import vip.mate.llm.model.ModelProtocol; + +import java.util.Map; + +/** + * OpenAI Responses API 的 cache_control 序列化器。 + * + *

OpenAI Responses 协议使用与 Anthropic 同形的 {@code cache_control} 字段挂在 input 数组的 + * content block 上,无需额外 HTTP header。M2 阶段保留断点信息,由 + * {@code ChatGPTResponsesClient} 在 buildRequestBody 时按指令落字段(M3 完成)。

+ */ +public final class OpenAIResponsesCacheSerializer implements CacheSerializer { + + @Override + public ModelProtocol protocol() { + return ModelProtocol.OPENAI_CHATGPT; + } + + @Override + public CacheDirectives serialize(CachedPlan plan, CachePlanContext ctx) { + if (plan == null || plan.isEmpty()) { + return CacheDirectives.empty(ModelProtocol.OPENAI_CHATGPT); + } + // Responses API 没有 TTL 概念,全部按默认;headers 留空 + return new CacheDirectives( + plan.breakpoints().stream().toList(), + Map.of(), + CacheTtl.DEFAULT_5M, + ModelProtocol.OPENAI_CHATGPT); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/PromptCacheStrategy.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/PromptCacheStrategy.java new file mode 100644 index 00000000..5f9049fe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/PromptCacheStrategy.java @@ -0,0 +1,28 @@ +package vip.mate.llm.cache; + +/** + * Prompt 缓存策略 SPI。 + * + *

所有策略实现都被 sealed 锁定,便于 JDK 21 模式匹配 switch 穷尽分支、 + * 也避免运行期反射式插件加载带来的不可预期成本。

+ * + *

调用契约: + *

    + *
  1. 装饰层先调 {@link #shouldCache(CachePlanContext)} 判定是否值得缓存
  2. + *
  3. 若 true,调 {@link #plan(CachePlanContext)} 拿到 {@link CachedPlan}
  4. + *
  5. 由协议特化的序列化器把断点写到具体请求体
  6. + *

+ */ +public sealed interface PromptCacheStrategy + permits SystemAndTailCacheStrategy, NoOpCacheStrategy, AdaptiveCacheStrategy { + + CachedPlan plan(CachePlanContext ctx); + + default boolean shouldCache(CachePlanContext ctx) { + if (!ctx.protocolSupportsCacheControl()) { + return false; + } + // 短对话直接跳过:cache write 比常规调用贵 25%,单次性请求会倒亏 + return ctx.totalPromptTokens() >= ctx.minPromptTokens() && ctx.turnCount() >= 2; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/cache/SystemAndTailCacheStrategy.java b/mateclaw-server/src/main/java/vip/mate/llm/cache/SystemAndTailCacheStrategy.java new file mode 100644 index 00000000..23fd4b85 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/cache/SystemAndTailCacheStrategy.java @@ -0,0 +1,79 @@ +package vip.mate.llm.cache; + +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; + +import java.util.ArrayList; +import java.util.List; + +/** + * 默认策略:在 system / tools / messages 尾部最多打 4 个缓存断点(仿 hermes-agent {@code system_and_3})。 + * + *

断点选择规则(按优先级递减): + *

    + *
  1. {@link BreakpointKind#SYSTEM_TAIL} —— system 段长度足够时挂一个
  2. + *
  3. {@link BreakpointKind#TOOLS_TAIL} —— {@code includeToolsBlock=true} 且至少有 1 个工具
  4. + *
  5. {@link BreakpointKind#MESSAGES_TAIL} —— 倒数第 3 条 user/assistant 消息
  6. + *
  7. {@link BreakpointKind#MESSAGES_TAIL} —— 最后一条 user 消息(若与 #3 不同索引)
  8. + *

+ * + *

这是无状态的纯函数实现,单例线程安全;任何调度方都可直接复用。

+ */ +public final class SystemAndTailCacheStrategy implements PromptCacheStrategy { + + /** system 段至少 256 字符才值得打断点(cache write 摊销下限)。 */ + private static final int MIN_SYSTEM_CHARS = 256; + + @Override + public CachedPlan plan(CachePlanContext ctx) { + if (!shouldCache(ctx)) { + return CachedPlan.none(); + } + + List bps = new ArrayList<>(ctx.maxBreakpoints()); + + if (ctx.hasSystemMessage() && ctx.systemCharLen() >= MIN_SYSTEM_CHARS) { + bps.add(Breakpoint.systemTail()); + } + if (ctx.includeToolsBlock() && bps.size() < ctx.maxBreakpoints()) { + bps.add(Breakpoint.toolsTail()); + } + + // messages 尾部断点:倒数第 3 与最后 1(去重) + List msgs = ctx.messages(); + int lastUserIdx = lastIndexOfType(msgs, MessageType.USER); + int thirdLastTurnIdx = nthLastTurnIndex(msgs, 3); + + if (thirdLastTurnIdx >= 0 && bps.size() < ctx.maxBreakpoints()) { + bps.add(Breakpoint.messagesTail(thirdLastTurnIdx)); + } + if (lastUserIdx >= 0 && lastUserIdx != thirdLastTurnIdx + && bps.size() < ctx.maxBreakpoints()) { + bps.add(Breakpoint.messagesTail(lastUserIdx)); + } + + return bps.isEmpty() ? CachedPlan.none() : new CachedPlan(bps, ctx.ttl()); + } + + private static int lastIndexOfType(List msgs, MessageType type) { + for (int i = msgs.size() - 1; i >= 0; i--) { + if (msgs.get(i).getMessageType() == type) return i; + } + return -1; + } + + /** + * 找倒数第 n 个 user/assistant 消息的索引(system 与 tool 不计)。 + * n=1 → 最后一条;n=3 → 倒数第三条。 + */ + private static int nthLastTurnIndex(List msgs, int n) { + int seen = 0; + for (int i = msgs.size() - 1; i >= 0; i--) { + MessageType t = msgs.get(i).getMessageType(); + if (t == MessageType.USER || t == MessageType.ASSISTANT) { + if (++seen == n) return i; + } + } + return -1; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillHubClient.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillHubClient.java index d9a282a2..f48e2859 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillHubClient.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillHubClient.java @@ -93,6 +93,11 @@ public class SkillHubClient { /** * 获取 skill bundle 详情 + *

+ * 与 {@link #search} 共享同一套重试策略:408/429/5xx 状态码或 IO 异常时按 + * 指数退避(800/1600/3200ms)重试,最多 {@code httpRetries} 次。 + * 此外当 bundle 内容({@code content})为空时直接返回 null —— 空 bundle + * 重装会清空用户的 SKILL.md,是不可接受的"成功"。 */ public SkillBundle fetchBundle(String slug, String version) { String path = version != null && !version.isBlank() @@ -100,27 +105,54 @@ public class SkillHubClient { : "/api/v1/skills/" + slug; String url = properties.getBaseUrl() + path; - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(properties.getHttpTimeout())) - .GET() - .header("Accept", "application/json") - .header("User-Agent", "MateClaw/1.0") - .build(); + for (int attempt = 0; attempt <= properties.getHttpRetries(); attempt++) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(properties.getHttpTimeout())) + .GET() + .header("Accept", "application/json") + .header("User-Agent", "MateClaw/1.0") + .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + int status = response.statusCode(); - if (response.statusCode() == 200) { - return parseBundleResponse(response.body(), slug); + if (status == 200) { + SkillBundle bundle = parseBundleResponse(response.body(), slug); + if (bundle == null || bundle.content() == null || bundle.content().isBlank()) { + log.warn("Hub fetchBundle returned empty content for '{}'; treat as failure to avoid wiping local SKILL.md", slug); + return null; + } + return bundle; + } + + if (isRetryable(status) && attempt < properties.getHttpRetries()) { + log.warn("Hub fetchBundle attempt {} for '{}' failed with status {}, retrying...", attempt + 1, slug, status); + Thread.sleep(backoffMs(attempt)); + continue; + } + + log.warn("Hub fetchBundle failed for '{}': status {}", slug, status); + return null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } catch (Exception e) { + if (attempt < properties.getHttpRetries()) { + log.warn("Hub fetchBundle attempt {} for '{}' error: {}, retrying...", attempt + 1, slug, e.getMessage()); + try { + Thread.sleep(backoffMs(attempt)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return null; + } + } else { + log.error("Hub fetchBundle failed after {} attempts for '{}': {}", properties.getHttpRetries() + 1, slug, e.getMessage()); + } } - - log.warn("Hub fetchBundle failed for '{}': status {}", slug, response.statusCode()); - return null; - } catch (Exception e) { - log.error("Hub fetchBundle error for '{}': {}", slug, e.getMessage()); - return null; } + return null; } // ==================== 内部方法 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java index b40d49df..7bd33ea6 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java @@ -151,7 +151,8 @@ public class SkillInstaller { if (exists) { workspaceManager.cleanWorkspaceDataDirs(skillName); } - workspaceManager.initWorkspace(skillName, bundle.content()); + // 重装时 (exists=true) 覆写 SKILL.md;否则保留已有内容(向后兼容首次创建语义) + workspaceManager.initWorkspace(skillName, bundle.content(), exists); // 写入 references/ if (bundle.references() != null) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java index 6ccf3173..f4a9afa4 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java @@ -93,13 +93,26 @@ public class SkillWorkspaceManager { // ==================== 生命周期操作 ==================== /** - * 初始化 skill 工作区目录 + * 初始化 skill 工作区目录(兼容旧调用:overwrite=false) * * @param skillName skill 名称 * @param initialContent SKILL.md 初始内容(可为 null) * @return 创建的工作区路径 */ public Path initWorkspace(String skillName, String initialContent) { + return initWorkspace(skillName, initialContent, false); + } + + /** + * 初始化 skill 工作区目录 + * + * @param skillName skill 名称 + * @param initialContent SKILL.md 初始内容(可为 null) + * @param overwrite true 时无条件覆写 SKILL.md(用于重装 / 导出场景), + * false 时仅在 SKILL.md 不存在时写入(用于首次创建) + * @return 创建的工作区路径 + */ + public Path initWorkspace(String skillName, String initialContent, boolean overwrite) { Path workspaceDir = resolveConventionPath(skillName); try { Files.createDirectories(workspaceDir); @@ -107,14 +120,14 @@ public class SkillWorkspaceManager { Files.createDirectories(workspaceDir.resolve("scripts")); Path skillMd = workspaceDir.resolve("SKILL.md"); - if (!Files.exists(skillMd)) { + if (overwrite || !Files.exists(skillMd)) { String content = (initialContent != null && !initialContent.isBlank()) ? initialContent : buildDefaultSkillMd(skillName); Files.writeString(skillMd, content); } - log.info("Initialized skill workspace: {}", workspaceDir); + log.info("Initialized skill workspace: {} (overwrite={})", workspaceDir, overwrite); eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.CREATED, workspaceDir)); return workspaceDir; } catch (IOException e) { @@ -151,18 +164,11 @@ public class SkillWorkspaceManager { * 将数据库 skill 内容导出到工作区目录 */ public Path exportToWorkspace(String skillName, String skillContent) { - Path workspaceDir = initWorkspace(skillName, skillContent); + // 始终覆写 SKILL.md(initWorkspace 内部已写入),无需再做一次冗余 IO + Path workspaceDir = initWorkspace(skillName, skillContent, true); if (workspaceDir != null) { - try { - // 覆盖写入 SKILL.md - if (skillContent != null && !skillContent.isBlank()) { - Files.writeString(workspaceDir.resolve("SKILL.md"), skillContent); - } - log.info("Exported skill '{}' to workspace: {}", skillName, workspaceDir); - eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.EXPORTED, workspaceDir)); - } catch (IOException e) { - log.warn("Failed to export skill '{}': {}", skillName, e.getMessage()); - } + log.info("Exported skill '{}' to workspace: {}", skillName, workspaceDir); + eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.EXPORTED, workspaceDir)); } return workspaceDir; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java index 1a54c25c..312da9fa 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java @@ -40,6 +40,16 @@ public class WikiProperties { */ private int maxParallelChunks = 5; + /** + * 单个 chunk 内 phase B 阶段的 page 并行处理数上限。 + *

+ * RFC-012 follow-up #3:原实现 phase B 的 create / merge 循环逐页串行调用 LLM, + * 单 chunk N 个 page 就要串行 N 次 LLM 调用——一个卡超时整条流水线停摆。 + * 改为受此 Semaphore 控制的并行,默认 3。结合 maxParallelRawMaterials × maxParallelChunks + * × maxParallelPhaseBPages = 3 × 5 × 3 = 45 的理论最大并发,实际按 LLM 限流为准。 + */ + private int maxParallelPhaseBPages = 3; + /** 注入 agent prompt 的最大字符数 */ private int maxContextChars = 10000; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 547d2fa9..f9925c26 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -3,9 +3,12 @@ package vip.mate.wiki.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import vip.mate.common.result.R; import vip.mate.exception.MateClawException; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; @@ -19,6 +22,7 @@ import vip.mate.wiki.service.WikiKnowledgeBaseService; import vip.mate.wiki.service.WikiPageService; import vip.mate.wiki.service.WikiProcessingService; import vip.mate.wiki.service.WikiRawMaterialService; +import vip.mate.wiki.sse.WikiProgressBus; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -34,6 +38,7 @@ import java.util.Map; * * @author MateClaw Team */ +@Slf4j @Tag(name = "Wiki 知识库") @RestController @RequestMapping("/api/v1/wiki") @@ -47,6 +52,7 @@ public class WikiController { private final WikiDirectoryScanService scanService; private final WikiProperties properties; private final ApplicationEventPublisher eventPublisher; + private final WikiProgressBus progressBus; // ==================== Knowledge Base ==================== @@ -374,6 +380,59 @@ public class WikiController { )); } + /** + * RFC-012 M3:订阅指定 KB 的处理进度 SSE 流。 + *

+ * 客户端通过 {@code new EventSource('/api/v1/wiki/knowledge-bases/{kbId}/progress')} 订阅, + * 然后按事件名监听: + *

    + *
  • {@code raw.started} — 某个 raw material 进入处理
  • + *
  • {@code route.done} — phase A 完成、phase B 启动(此时 total 已确定)
  • + *
  • {@code chunk.done} — phase B 单页落地(带 done/total)
  • + *
  • {@code raw.completed} — raw material 处理完成(终态:completed/partial)
  • + *
  • {@code raw.failed} — raw material 处理失败
  • + *
+ *

+ * SSE 是 best-effort:服务端断线、客户端断线、代理切流都可能丢事件, + * 因此前端仍需保留 60s 兜底轮询 {@code GET .../processing-status} 作为真源。 + *

+ * Emitter 默认 30 分钟超时,足以覆盖最长的 raw 处理时间;超时后客户端 + * 自动重连(EventSource 默认行为)。 + */ + @RequireWorkspaceRole("viewer") + @Operation(summary = "订阅处理进度 SSE") + @GetMapping(value = "/knowledge-bases/{kbId}/progress", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribeProgress(@PathVariable Long kbId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + SseEmitter emitter = new SseEmitter(30L * 60 * 1000); // 30min + progressBus.subscribe(kbId, emitter); + + emitter.onCompletion(() -> { + progressBus.unsubscribe(kbId, emitter); + log.debug("[Wiki SSE] emitter completed: kbId={}", kbId); + }); + emitter.onTimeout(() -> { + progressBus.unsubscribe(kbId, emitter); + try { emitter.complete(); } catch (Exception ignore) { /* best-effort */ } + log.debug("[Wiki SSE] emitter timeout: kbId={}", kbId); + }); + emitter.onError(e -> { + progressBus.unsubscribe(kbId, emitter); + log.debug("[Wiki SSE] emitter error: kbId={}, cause={}", kbId, e.getMessage()); + }); + + // 立即发一个 hello 事件,确认连接已建立 + try { + emitter.send(SseEmitter.event().name(WikiProgressBus.EVENT_HEARTBEAT) + .data("{\"ts\":" + System.currentTimeMillis() + ",\"hello\":true}")); + } catch (Exception e) { + log.debug("[Wiki SSE] initial heartbeat send failed: {}", e.getMessage()); + } + + return emitter; + } + // ==================== Workspace Verification ==================== private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index 154bdb5e..30f68252 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -132,6 +132,38 @@ public class WikiPageService { .eq(WikiPageEntity::getSlug, slug)); } + /** + * 把 slug 规范化为 canonical 形式:去掉所有连字符 / 下划线 + 转小写。 + *

+ * 用于跨拼写匹配:{@code "shennong-bencao-jing"} 和 {@code "shen-nong-ben-cao-jing"} + * 都规范化为 {@code "shennongbencaojing"},被视为同一概念。LLM 在并行处理大文档时 + * 经常对同一概念给出不同 slug 拼写(按词分组 vs 按字分隔),这是兜底归一逻辑的基础。 + */ + public static String canonicalSlug(String slug) { + if (slug == null) return ""; + return slug.toLowerCase().replace("-", "").replace("_", ""); + } + + /** + * 按 canonical slug 在指定 KB 中查找已存在的 page。 + *

+ * 命中条件:现有 page 的 slug 经 {@link #canonicalSlug(String)} 后与给定 slug 的 + * canonical 形式相等。复用 {@link #listSummaries(Long)} 的 5 分钟缓存,命中后再 + * {@link #getBySlug(Long, String)} 拿完整 entity,避免额外全表扫描。 + * + * @return 第一个 canonical 匹配的 page;找不到返回 {@code null} + */ + public WikiPageEntity findByCanonicalSlug(Long kbId, String slug) { + String canonical = canonicalSlug(slug); + if (canonical.isEmpty()) return null; + for (WikiPageEntity p : listSummaries(kbId)) { + if (canonicalSlug(p.getSlug()).equals(canonical)) { + return getBySlug(kbId, p.getSlug()); + } + } + return null; + } + public WikiPageEntity getById(Long id) { return pageMapper.selectById(id); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index b4550487..43b5ba98 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -9,6 +9,7 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.retry.support.RetryTemplate; import org.springframework.stereotype.Service; import vip.mate.agent.AgentGraphBuilder; import vip.mate.agent.prompt.PromptLoader; @@ -18,6 +19,7 @@ import vip.mate.wiki.WikiProperties; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.sse.WikiProgressBus; import java.util.ArrayList; import java.util.List; @@ -48,6 +50,7 @@ public class WikiProcessingService { private final ModelConfigService modelConfigService; private final AgentGraphBuilder agentGraphBuilder; private final ObjectMapper objectMapper; + private final WikiProgressBus progressBus; /** 并行 chunk / 材料处理执行器(JDK 21 虚拟线程);Listener 跨包需要引用,故 public */ public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); @@ -61,7 +64,19 @@ public class WikiProcessingService { private static final class ProgressCounter { final AtomicInteger total = new AtomicInteger(0); final AtomicInteger done = new AtomicInteger(0); + /** Page-level 失败计数(chunk 内单页 create / merge 抛异常)。 + * 注:DuplicateKeyException 触发的 fallback-to-update 不算 failure, + * 内容仍合入了同 slug page。仅 LLM 调用爆炸、JSON 解析失败、内容为空等真失败才递增。 */ + final AtomicInteger failed = new AtomicInteger(0); final AtomicBoolean phaseBStarted = new AtomicBoolean(false); + /** + * 跨 chunk slug 抢占表:canonical slug → 第一个声明该概念的实际 slug。 + *

+ * 解决 LLM 在并行 chunk 中给同一概念起不同 slug 拼写(按词分组 vs 按字分隔)的问题。 + * 使用 {@link ConcurrentHashMap#computeIfAbsent} 实现原子抢占:先到的 chunk 把自己的 + * slug 注册为 winner,后到的 chunk 看到 winner 后会把内容写入 winner 对应的 page。 + */ + final ConcurrentHashMap slugClaims = new ConcurrentHashMap<>(); } private final ConcurrentHashMap progressCounters = new ConcurrentHashMap<>(); @@ -80,6 +95,12 @@ public class WikiProcessingService { * @param force 为 true 时忽略 content_hash 短路(RFC-012 Change 5),用于模型/提示词变更后的强制重跑 */ public void processRawMaterial(Long rawId, boolean force) { + // RFC-012 follow-up #3:消费续传标志(reprocess() 把 partial 改回 pending 之前打的标)。 + // 必须在 claimForProcessing 之前读,因为 claim 会把状态再改一次。 + // flag 只在内存中,server 重启会丢 → 重启后仍按 pending 走正常流程(退化为全量重跑, + // 功能不丢失只是性能回退)。 + boolean isPartialResume = rawService.consumePartialResumeFlag(rawId); + // CAS 式抢占:防止并发重复处理 if (!rawService.claimForProcessing(rawId)) { log.debug("[Wiki] Raw material {} already claimed or not pending, skipping", rawId); @@ -113,6 +134,10 @@ public class WikiProcessingService { progressCounters.put(rawId, new ProgressCounter()); rawService.updateProgress(rawId, "route", 0, 0); // UI 立即看到 indeterminate 滑条 + // RFC-012 M3:广播 raw.started(前端切到 indeterminate 进度条) + progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_STARTED, + java.util.Map.of("rawId", rawId, "phase", "route")); + try { // Phase 1: 获取文本内容 String textContent = rawService.getTextContent(raw); @@ -123,9 +148,19 @@ public class WikiProcessingService { } // Phase 2: 清除该材料之前生成的旧页面(仅独占+非手工页面) - int cleaned = pageService.deleteExclusiveBySourceRawId(kb.getId(), rawId); - if (cleaned > 0) { - log.info("[Wiki] Cleaned {} exclusive old pages for raw material {} before reprocessing", cleaned, rawId); + // RFC-012 follow-up #3:partial 状态走「续传」路径 —— 保留已生成的 page,让 + // route 阶段通过 existingPagesIndex 把它们归到 update 列表(phase B merge 覆盖 + // 当前 chunk 内容),失败的 slug 在 DB 里不存在,LLM 会放进 create 列表重跑。 + // isPartialResume 标记在 rawService.reprocess() 中写入 in-memory 集合,由 + // rawService.consumePartialResumeFlag() 在 claim 之前消费掉;无法通过 + // raw.getProcessingStatus() 判断是因为 claimForProcessing 已经把它改成 "processing"。 + if (isPartialResume) { + log.info("[Wiki] Partial resume for raw={}: keeping existing pages, LLM will merge new content into them via existingPagesIndex", rawId); + } else { + int cleaned = pageService.deleteExclusiveBySourceRawId(kb.getId(), rawId); + if (cleaned > 0) { + log.info("[Wiki] Cleaned {} exclusive old pages for raw material {} before reprocessing", cleaned, rawId); + } } // Phase 3: 构建已有页面索引(一次构建,所有 chunk 共用) @@ -146,16 +181,36 @@ public class WikiProcessingService { int totalChunks = result[2]; // Phase 3: 更新状态和计数 + // 读 page 级失败数(finally 之前读,finally 才 remove counter) + ProgressCounter pcFinal = progressCounters.get(rawId); + int failedPages = pcFinal != null ? pcFinal.failed.get() : 0; + + String finalStatus; + String finalDetail = null; if (totalPages == 0) { rawService.updateProcessingStatus(rawId, "failed", "No pages generated from LLM response"); - } else if (failedChunks > 0) { - // 部分成功:有些 chunk 失败但有些产出了页面 - rawService.updateProcessingStatus(rawId, "partial", - failedChunks + " of " + totalChunks + " chunks failed, " + totalPages + " pages generated"); + finalStatus = "failed"; + finalDetail = "No pages generated from LLM response"; + } else if (failedChunks > 0 || failedPages > 0) { + // 部分成功:chunk 整体失败 或 chunk 内有 page 失败 + // (M2 v2 follow-up:page 级失败原本被计入 completed,现在正确归 partial) + StringBuilder detail = new StringBuilder(); + if (failedChunks > 0) { + detail.append(failedChunks).append(" of ").append(totalChunks).append(" chunks failed"); + } + if (failedPages > 0) { + if (detail.length() > 0) detail.append("; "); + detail.append(failedPages).append(" page(s) failed"); + } + detail.append(", ").append(totalPages).append(" pages generated"); + finalDetail = detail.toString(); + rawService.updateProcessingStatus(rawId, "partial", finalDetail); + finalStatus = "partial"; // 【Review Bug 1】partial 不写 lastProcessedHash:partial 的语义就是"还有失败、需要再跑", // 写了会导致下次用户点"重新处理"被 hash 短路直接跳过,永远没机会修失败的 chunk。 } else { rawService.updateProcessingStatus(rawId, "completed", null); + finalStatus = "completed"; // RFC-012 Change 5:记录本次成功处理时的 hash,供下次短路判断 if (raw.getContentHash() != null) { rawService.setLastProcessedHash(rawId, raw.getContentHash()); @@ -165,6 +220,19 @@ public class WikiProcessingService { kbService.setPageCount(kb.getId(), pageCount); kbService.updateStatus(kb.getId(), "active"); + // RFC-012 M3:广播终态 + if ("failed".equals(finalStatus)) { + progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, + java.util.Map.of("rawId", rawId, "error", finalDetail == null ? "" : finalDetail)); + } else { + progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_COMPLETED, + java.util.Map.of( + "rawId", rawId, + "status", finalStatus, + "totalPages", totalPages, + "kbPageCount", pageCount)); + } + log.info("[Wiki] Processing completed for raw={}, kbId={}, generatedPages={}, totalPages={}", rawId, kb.getId(), totalPages, pageCount); @@ -172,6 +240,9 @@ public class WikiProcessingService { log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e); rawService.updateProcessingStatus(rawId, "failed", e.getMessage()); kbService.updateStatus(kb.getId(), "active"); + // RFC-012 M3:广播异常终态 + progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, + java.util.Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage())); } finally { // RFC-012 M2 v2 UI v2:写入最终进度并清理共享计数器 ProgressCounter pc = progressCounters.remove(rawId); @@ -422,8 +493,9 @@ public class WikiProcessingService { return 0; } - int created = 0; - int updated = 0; + // RFC-012 follow-up #3:phase B 现在并行执行,计数必须是 atomic + AtomicInteger created = new AtomicInteger(0); + AtomicInteger updated = new AtomicInteger(0); // ─── 收集 route 输出(仅 metadata,无 content) ─── List createMetas = new ArrayList<>(); @@ -453,46 +525,113 @@ public class WikiProcessingService { pc.total.addAndGet(totalPlanned); if (pc.phaseBStarted.compareAndSet(false, true)) { log.info("[Wiki] Progress: switching to phase-b for raw={}", rawId); + // RFC-012 M3:route 完成、phase-b 启动 → 通知前端确定进度(可显示 0/N) + progressBus.broadcast(kbId, WikiProgressBus.EVENT_ROUTE_DONE, + java.util.Map.of( + "rawId", rawId, + "phase", "phase-b", + "done", pc.done.get(), + "total", pc.total.get())); } rawService.updateProgress(rawId, "phase-b", pc.done.get(), pc.total.get()); } - // ─── 阶段 B-1:逐页 create(每页一次单独 LLM call,输入/输出都是单页规模) ─── + // RFC-012 follow-up #3:阶段 B 页级并发。每个 page 是独立的 LLM 调用,相互无依赖, + // 串行跑会让一个卡超时的 page 阻塞整个 chunk。受 maxParallelPhaseBPages Semaphore 约束, + // 复用虚拟线程池 WIKI_EXECUTOR。 + int parallelPages = Math.max(1, properties.getMaxParallelPhaseBPages()); + Semaphore pageSem = new Semaphore(parallelPages); + + // ─── 阶段 B-1:并行 create ─── + List> createFutures = new ArrayList<>(createMetas.size()); for (JsonNode meta : createMetas) { - try { - if (createOnePage(kb, raw, textContent, existingPagesIndex, meta)) { - created++; + final JsonNode metaRef = meta; + createFutures.add(CompletableFuture.runAsync(() -> { + try { + pageSem.acquire(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; } - } catch (RuntimeException e) { - log.warn("[Wiki] Phase B create page slug='{}' failed: {}", - meta.path("slug").asText(""), e.getMessage()); - } - // 无论成功失败都推进 done 计数,避免失败页卡死 UI 进度 - if (pc != null) { - int d = pc.done.incrementAndGet(); - rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); - } + boolean ok = false; + try { + try { + if (createOnePage(kb, raw, textContent, existingPagesIndex, metaRef)) { + created.incrementAndGet(); + } + // createOnePage 内部的 DuplicateKey / canonical / claim fallback 不抛异常 → ok=true。 + ok = true; + } catch (RuntimeException e) { + log.warn("[Wiki] Phase B create page slug='{}' failed: {}", + metaRef.path("slug").asText(""), e.getMessage()); + } + if (pc != null) { + int d = pc.done.incrementAndGet(); + if (!ok) pc.failed.incrementAndGet(); + rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, + java.util.Map.of( + "rawId", rawId, + "kind", "create", + "ok", ok, + "done", d, + "total", pc.total.get())); + } + } finally { + pageSem.release(); + } + }, WIKI_EXECUTOR)); } - // ─── 阶段 B-2:逐页 merge(每页一次单独 LLM call) ─── + // ─── 阶段 B-2:并行 merge ─── + List> mergeFutures = new ArrayList<>(updateSlugs.size()); for (String slug : updateSlugs) { - try { - if (mergeOnePage(kb, raw, textContent, slug)) { - updated++; + final String mergeSlug = slug; + mergeFutures.add(CompletableFuture.runAsync(() -> { + try { + pageSem.acquire(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; } - } catch (RuntimeException e) { - log.warn("[Wiki] Phase B merge page slug='{}' failed: {}", slug, e.getMessage()); - } - if (pc != null) { - int d = pc.done.incrementAndGet(); - rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); - } + boolean ok = false; + try { + try { + if (mergeOnePage(kb, raw, textContent, mergeSlug)) { + updated.incrementAndGet(); + } + ok = true; + } catch (RuntimeException e) { + log.warn("[Wiki] Phase B merge page slug='{}' failed: {}", mergeSlug, e.getMessage()); + } + if (pc != null) { + int d = pc.done.incrementAndGet(); + if (!ok) pc.failed.incrementAndGet(); + rawService.updateProgress(rawId, "phase-b", d, pc.total.get()); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, + java.util.Map.of( + "rawId", rawId, + "kind", "merge", + "ok", ok, + "done", d, + "total", pc.total.get())); + } + } finally { + pageSem.release(); + } + }, WIKI_EXECUTOR)); } + + // 等待本 chunk 的 create + merge 全部完成 + List> allFutures = new ArrayList<>(createFutures.size() + mergeFutures.size()); + allFutures.addAll(createFutures); + allFutures.addAll(mergeFutures); + CompletableFuture.allOf(allFutures.toArray(new CompletableFuture[0])).join(); // 单 chunk 完成时不写"done"——多 chunk 还在跑;最终"done"由 processRawMaterial 的 finally 写入 log.info("[Wiki] Two-phase digest applied: kbId={}, rawId={}, created={}, updated={}", - kbId, rawId, created, updated); - return created + updated; + kbId, rawId, created.get(), updated.get()); + return created.get() + updated.get(); } /** @@ -544,7 +683,44 @@ public class WikiProcessingService { return false; } - // 兜底:如果 slug 已存在(route 误判 / 并发),走 update 而不是 create + // 兜底 0:跨拼写 canonical 匹配(DB 已有 page,但 slug 拼写不同) + // 覆盖场景:之前上传的 raw 已经创建了同概念 page,本次 LLM 给了不同拼写 + WikiPageEntity existingByCanonical = pageService.findByCanonicalSlug(kbId, slug); + if (existingByCanonical != null && !existingByCanonical.getSlug().equals(slug)) { + String actualSlug = existingByCanonical.getSlug(); + pageService.updatePageByAi(kbId, actualSlug, content, pageSummary, rawId); + log.info("[Wiki] Phase B create slug='{}' canonical-matches existing '{}', updated", + slug, actualSlug); + return false; + } + + // 兜底 0.5:跨 chunk in-flight slug 抢占(同一 raw 的另一并发 chunk 已声明同概念) + // computeIfAbsent 是原子操作,先到的 chunk 把自己 slug 注册为 winner + ProgressCounter pcLocal = progressCounters.get(rawId); + String canonical = WikiPageService.canonicalSlug(slug); + if (pcLocal != null && !canonical.isEmpty()) { + // lambda 要求 effectively final,用 finalSlug 副本 + final String routedSlug = slug; + String winnerSlug = pcLocal.slugClaims.computeIfAbsent(canonical, k -> routedSlug); + if (!winnerSlug.equals(slug)) { + // 另一 chunk 先 claim 了同 canonical,但用了不同 slug 拼写 + WikiPageEntity winner = pageService.getBySlug(kbId, winnerSlug); + if (winner != null) { + // winner 已 INSERT 进 DB → 直接 update + pageService.updatePageByAi(kbId, winnerSlug, content, pageSummary, rawId); + log.info("[Wiki] Phase B create slug='{}' lost slug-claim race to '{}', updated", + slug, winnerSlug); + return false; + } + // winner claim 早于 INSERT(claim 是 in-memory,INSERT 是 DB IO) + // → 用 winnerSlug 继续走下面的 INSERT 路径,DuplicateKey fallback 会兜住实际 race + log.info("[Wiki] Phase B create slug='{}' redirects to in-flight winner '{}'", + slug, winnerSlug); + slug = winnerSlug; + } + } + + // 兜底 1:如果 slug 已存在(route 误判 / 上一次成功 INSERT),走 update 而不是 create WikiPageEntity existing = pageService.getBySlug(kbId, slug); if (existing != null) { pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId); @@ -552,9 +728,19 @@ public class WikiProcessingService { return false; // 不计入 created } String sourceRawIds = "[" + rawId + "]"; - pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds); - log.info("[Wiki] Phase B create page slug='{}' done (created)", slug); - return true; + try { + pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds); + log.info("[Wiki] Phase B create page slug='{}' done (created)", slug); + return true; + } catch (org.springframework.dao.DuplicateKeyException e) { + // 兜底 2:select-then-create 在并发下不是原子操作。当 N 个 chunk 同时 + // route 出相同 slug,只有第一个 INSERT 能成功,其余都会触发 H2/MySQL + // unique key violation。本次 chunk 的 LLM 输出仍有价值——降级为 update, + // 把内容合并进已存在的 page,而不是丢弃。 + pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId); + log.info("[Wiki] Phase B create page slug='{}' lost INSERT race -> updated existing", slug); + return false; // 不计入 created + } } /** @@ -700,9 +886,19 @@ public class WikiProcessingService { return sb.toString().trim(); } + /** + * RFC-012 follow-up #3:Wiki 调用自带重试层({@link #callLlmWithResilientRetry}), + * 所以用 maxAttempts=1 的 RetryTemplate 关掉 Spring AI 的内层重试,避免两层重试互相抵消 + * (内层默认 2-3 次 × 180s readTimeout = 一次"外层 attempt"消耗 360-540s, + * 让 wiki 的 maxTotalDurationMs=240s 被穿越,maxAttempts=5 永远到不了)。 + */ + private static final RetryTemplate WIKI_NO_RETRY = RetryTemplate.builder() + .maxAttempts(1) + .build(); + private ChatModel buildChatModel() { ModelConfigEntity defaultModel = modelConfigService.getDefaultModel(); - return agentGraphBuilder.buildRuntimeChatModel(defaultModel); + return agentGraphBuilder.buildRuntimeChatModel(defaultModel, WIKI_NO_RETRY); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index 063bc851..d2bb4781 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -18,6 +18,8 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /** * Wiki 原始材料服务 @@ -35,6 +37,17 @@ public class WikiRawMaterialService { private final ApplicationEventPublisher eventPublisher; private final DocumentExtractTool documentExtractTool; + /** + * RFC-012 follow-up #3:从 partial 状态触发的 reprocess 会在此 set 中打标, + * 供 {@link vip.mate.wiki.service.WikiProcessingService#processRawMaterial(Long, boolean)} + * 在 claim 之前消费,从而决定是否保留已生成的 exclusive page(续传语义)。 + *

+ * 内存态:server 重启会丢,但原 raw 的 status 已被 reprocess 改为 pending, + * 重启后按正常 pending 流程跑(退化为「不删旧页的全量重跑」,功能不丢失只是 + * 没有走 route 的 "update" 识别路径)。 + */ + private final Set partialResumeIds = ConcurrentHashMap.newKeySet(); + public List listByKbId(Long kbId) { List list = rawMapper.selectList( new LambdaQueryWrapper() @@ -227,7 +240,10 @@ public class WikiRawMaterialService { } /** - * 重新处理:重置状态为 pending 并发布事件 + * 重新处理:重置状态为 pending 并发布事件。 + *

+ * 如果之前状态是 {@code partial},把 rawId 加入 {@link #partialResumeIds}, + * 下游的 WikiProcessingService 会据此决定是否保留已生成的 exclusive page(续传语义)。 */ @Transactional public void reprocess(Long id) { @@ -235,12 +251,28 @@ public class WikiRawMaterialService { if (entity == null) { throw new IllegalArgumentException("Raw material not found: " + id); } + boolean wasPartial = "partial".equals(entity.getProcessingStatus()); entity.setProcessingStatus("pending"); entity.setErrorMessage(null); rawMapper.updateById(entity); + if (wasPartial) { + partialResumeIds.add(id); + log.info("[Wiki] Raw material queued for PARTIAL RESUME: id={} (existing pages will be kept)", id); + } else { + log.info("[Wiki] Raw material queued for reprocessing: id={}", id); + } eventPublisher.publishEvent(new WikiProcessingEvent(this, entity.getId(), entity.getKbId())); - log.info("[Wiki] Raw material queued for reprocessing: id={}", id); + } + + /** + * 消费 partial resume 标记:若存在则返回 true 并从 set 中移除(一次性)。 + *

+ * 必须在 {@link #claimForProcessing(Long)} 之前调用:claim 会把 status 改成 processing, + * 此时已无法区分 raw 原本是从 partial 还是从 failed/pending 过来的。 + */ + public boolean consumePartialResumeFlag(Long id) { + return partialResumeIds.remove(id); } @Transactional diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java b/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java new file mode 100644 index 00000000..55ed27ee --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java @@ -0,0 +1,116 @@ +package vip.mate.wiki.sse; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * RFC-012 M3:Wiki 处理进度事件总线。 + *

+ * 维护 {kbId → SseEmitter[]} 订阅表,把后端处理过程中的关键事件 + * (raw.started / chunk.done / raw.completed / raw.failed)实时推送给所有 + * 订阅者(一个 KB 可被多 Tab / 多用户同时打开)。 + *

+ * 设计要点: + *

    + *
  • 订阅者列表用 {@link CopyOnWriteArrayList},broadcast 时无需加锁;
  • + *
  • 事件是 best-effort,不持久化、不重发——客户端断线后由前端 60s 兜底 + * 拉取({@code GET .../processing-status} 走 DB)补齐;
  • + *
  • broadcast 时若某个 emitter send 失败,立即从订阅表中剔除并 complete。
  • + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WikiProgressBus { + + /** 事件名常量(前端按 EventSource.addEventListener 名称匹配) */ + public static final String EVENT_RAW_STARTED = "raw.started"; + public static final String EVENT_ROUTE_DONE = "route.done"; + public static final String EVENT_CHUNK_DONE = "chunk.done"; + public static final String EVENT_RAW_COMPLETED = "raw.completed"; + public static final String EVENT_RAW_FAILED = "raw.failed"; + public static final String EVENT_HEARTBEAT = "heartbeat"; + + private final ObjectMapper objectMapper; + + /** kbId → 订阅者列表 */ + private final ConcurrentHashMap> subscribers = new ConcurrentHashMap<>(); + + /** + * 订阅指定 KB 的处理进度事件。 + */ + public void subscribe(Long kbId, SseEmitter emitter) { + if (kbId == null || emitter == null) return; + subscribers.computeIfAbsent(kbId, k -> new CopyOnWriteArrayList<>()).add(emitter); + log.debug("[WikiProgress] subscribe kbId={}, total={}", kbId, subscribers.get(kbId).size()); + } + + /** + * 反订阅;通常由 emitter 的 onCompletion / onTimeout / onError 回调触发。 + */ + public void unsubscribe(Long kbId, SseEmitter emitter) { + if (kbId == null || emitter == null) return; + CopyOnWriteArrayList list = subscribers.get(kbId); + if (list != null) { + list.remove(emitter); + if (list.isEmpty()) subscribers.remove(kbId, list); + } + log.debug("[WikiProgress] unsubscribe kbId={}", kbId); + } + + /** + * 向 kb 下所有订阅者广播一个事件。本方法对内部失败完全静默—— + * 处理管线不应被订阅端 IO 影响。 + */ + public void broadcast(Long kbId, String eventName, Object data) { + if (kbId == null || eventName == null) return; + CopyOnWriteArrayList list = subscribers.get(kbId); + if (list == null || list.isEmpty()) return; + + String payload; + try { + payload = objectMapper.writeValueAsString(data); + } catch (Exception e) { + payload = "{\"message\":\"serialization_error\"}"; + } + + Iterator it = list.iterator(); + while (it.hasNext()) { + SseEmitter emitter = it.next(); + try { + emitter.send(SseEmitter.event().name(eventName).data(payload)); + } catch (IOException | IllegalStateException e) { + // 客户端早断、emitter 已 complete 等:直接踢出,避免后续广播继续踩雷 + list.remove(emitter); + try { emitter.complete(); } catch (Exception ignore) { /* best-effort */ } + log.debug("[WikiProgress] dropped emitter for kbId={}: {}", kbId, e.getMessage()); + } + } + } + + /** + * 仅供监控/测试:当前订阅者数(所有 KB 之和)。 + */ + public int totalSubscribers() { + int total = 0; + for (List list : subscribers.values()) total += list.size(); + return total; + } + + /** + * 心跳广播:在没有真实事件流动时定期发一个 heartbeat, + * 防止反向代理(如 nginx 默认 60s idle)切断长连接。由调用方按需触发。 + */ + public void heartbeat(Long kbId) { + broadcast(kbId, EVENT_HEARTBEAT, java.util.Map.of("ts", System.currentTimeMillis())); + } +} diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 44f21709..32b0e86c 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -113,6 +113,18 @@ mateclaw: plugin: enabled: true user-dir: ${user.home}/.mateclaw/plugins + # RFC-014: Anthropic prompt cache 标记 + llm: + cache: + enabled: true # 总开关;false 时全部走 NoOp + min-prompt-tokens: 1024 # 累计 prompt token 低于此值则跳过缓存(避免 cache write 倒亏) + max-breakpoints: 4 # 单请求最多打几个 cache_control 断点(Anthropic 上限 4) + ttl: DEFAULT # DEFAULT(5min) | EXTENDED_1H(需要 anthropic-beta header) + include-tools-block: true # 工具 schema 段是否独立打断点(CONVERSATION_HISTORY 策略) + adaptive: + enabled: true # 自适应降级(连续 miss 后短路到 NoOp,冷却后恢复) + miss-threshold: 5 + cool-down-ms: 60000 # MateClaw Agent 配置 mate: @@ -145,6 +157,7 @@ mate: max-chunk-size: 30000 # LLM 单次处理最大字符数(超过则分块) max-context-chars: 10000 # 注入 Agent prompt 的 Wiki 摘要最大字符数 max-pages-per-raw: 15 # 单个原始材料最多生成的 Wiki 页面数 + max-parallel-phase-b-pages: 3 # RFC-012 follow-up #3:单 chunk 内 phase B 阶段并行处理的 page 数 auto-process-on-upload: true # 上传原始材料后是否自动触发 AI 消化 upload-dir: ./data/wiki-uploads # 上传文件存储目录 max-scan-files: 500 # 目录扫描最大文件数 diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V9__usage_cache_tokens.sql b/mateclaw-server/src/main/resources/db/migration/h2/V9__usage_cache_tokens.sql new file mode 100644 index 00000000..87f45a4e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V9__usage_cache_tokens.sql @@ -0,0 +1,6 @@ +-- V9: Track Anthropic prompt cache token usage +-- RFC-014 Change 4: per-call cache_creation_input_tokens / cache_read_input_tokens +-- accumulated daily so the dashboard can show cache hit rate and cost savings. +-- (was originally numbered V8 but collided with V8__wiki_raw_progress.sql; renumbered to V9.) +ALTER TABLE mate_usage_daily ADD COLUMN IF NOT EXISTS cache_read_tokens BIGINT DEFAULT 0; +ALTER TABLE mate_usage_daily ADD COLUMN IF NOT EXISTS cache_write_tokens BIGINT DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V9__usage_cache_tokens.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V9__usage_cache_tokens.sql new file mode 100644 index 00000000..87f45a4e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V9__usage_cache_tokens.sql @@ -0,0 +1,6 @@ +-- V9: Track Anthropic prompt cache token usage +-- RFC-014 Change 4: per-call cache_creation_input_tokens / cache_read_input_tokens +-- accumulated daily so the dashboard can show cache hit rate and cost savings. +-- (was originally numbered V8 but collided with V8__wiki_raw_progress.sql; renumbered to V9.) +ALTER TABLE mate_usage_daily ADD COLUMN IF NOT EXISTS cache_read_tokens BIGINT DEFAULT 0; +ALTER TABLE mate_usage_daily ADD COLUMN IF NOT EXISTS cache_write_tokens BIGINT DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/schema.sql b/mateclaw-server/src/main/resources/db/schema.sql index 9f7a6316..1c51eb45 100644 --- a/mateclaw-server/src/main/resources/db/schema.sql +++ b/mateclaw-server/src/main/resources/db/schema.sql @@ -616,6 +616,8 @@ CREATE TABLE IF NOT EXISTS mate_usage_daily ( total_tokens BIGINT DEFAULT 0, prompt_tokens BIGINT DEFAULT 0, completion_tokens BIGINT DEFAULT 0, + cache_read_tokens BIGINT DEFAULT 0, -- RFC-014: anthropic cache_read_input_tokens + cache_write_tokens BIGINT DEFAULT 0, -- RFC-014: anthropic cache_creation_input_tokens tool_call_count INT DEFAULT 0, error_count INT DEFAULT 0, create_time DATETIME NOT NULL diff --git a/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt index 0f6769c4..78ad4f3a 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt @@ -18,7 +18,13 @@ ## metadata 格式(仅 `create` 数组使用) 每条 metadata 包含三个字段: -- `slug`:URL 安全的标识符(小写字母 + 连字符) +- `slug`:URL 安全的标识符(小写字母 + 连字符)。 + - **多音节中文词的拼音必须按整词分组、不要按字隔开**。 + - ✅ 正确:`shennong-bencao-jing`(神农 / 本草 / 经 三个词) + - ❌ 错误:`shen-nong-ben-cao-jing`(按字一隔,会被识别为另一概念) + - ✅ 正确:`zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍) + - ❌ 错误:`zhong-yao-qi-qing-pei-wu` + - **同一概念在不同段落必须用同一 slug**:选定一个 slug 就坚持用,不要换写法。 - `title`:人类可读的页面标题 - `summary`:一段话简短摘要(一两句话即可,让"单页生成助手"知道这一页要写什么) diff --git a/mateclaw-server/src/test/java/vip/mate/llm/cache/PromptCacheStrategyTest.java b/mateclaw-server/src/test/java/vip/mate/llm/cache/PromptCacheStrategyTest.java new file mode 100644 index 00000000..5252abec --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/cache/PromptCacheStrategyTest.java @@ -0,0 +1,144 @@ +package vip.mate.llm.cache; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import vip.mate.llm.model.ModelProtocol; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** RFC-014 prompt cache 策略与序列化器的核心单测。 */ +class PromptCacheStrategyTest { + + private static final SystemAndTailCacheStrategy STRATEGY = new SystemAndTailCacheStrategy(); + + private static CachePlanContext ctxFor(List msgs, ModelProtocol protocol, + int totalTokens, int turns) { + return new CachePlanContext(new Prompt(msgs), protocol, totalTokens, turns, + /*minPromptTokens=*/1024, /*maxBreakpoints=*/4, + /*includeToolsBlock=*/true, CacheTtl.DEFAULT_5M); + } + + @Test + @DisplayName("短对话不缓存:低于 minPromptTokens 直接 NoOp") + void shortConversationNotCached() { + var ctx = ctxFor(List.of(new SystemMessage("hi"), new UserMessage("hello")), + ModelProtocol.ANTHROPIC_MESSAGES, /*tokens=*/300, /*turns=*/2); + assertFalse(STRATEGY.shouldCache(ctx)); + assertTrue(STRATEGY.plan(ctx).isEmpty()); + } + + @Test + @DisplayName("不支持的协议不缓存:DashScope/OpenAI 兼容直接 NoOp") + void unsupportedProtocolNotCached() { + var ctx = ctxFor(List.of(new SystemMessage("a".repeat(2048))), + ModelProtocol.DASHSCOPE_NATIVE, /*tokens=*/4096, /*turns=*/3); + assertFalse(STRATEGY.shouldCache(ctx)); + } + + @Test + @DisplayName("长 system + 对话 → 至少一个 SYSTEM_TAIL 断点") + void longSystemEarnsBreakpoint() { + var msgs = List.of( + (Message) new SystemMessage("a".repeat(1024)), + new UserMessage("first question"), + new AssistantMessage("first answer"), + new UserMessage("second question"), + new AssistantMessage("second answer"), + new UserMessage("third question") + ); + var ctx = ctxFor(msgs, ModelProtocol.ANTHROPIC_MESSAGES, /*tokens=*/4096, /*turns=*/3); + var plan = STRATEGY.plan(ctx); + assertFalse(plan.isEmpty()); + assertTrue(plan.breakpoints().stream().anyMatch(b -> b.kind() == BreakpointKind.SYSTEM_TAIL)); + // tools 段、messages 倒数第三、最后 user 都应在内 + assertTrue(plan.size() >= 2); + assertTrue(plan.size() <= 4); + } + + @Test + @DisplayName("断点上限受 maxBreakpoints 约束") + void breakpointCountCapped() { + var msgs = List.of( + (Message) new SystemMessage("a".repeat(1024)), + new UserMessage("q1"), new AssistantMessage("a1"), + new UserMessage("q2"), new AssistantMessage("a2"), + new UserMessage("q3") + ); + var capped = new CachePlanContext(new Prompt(msgs), ModelProtocol.ANTHROPIC_MESSAGES, + /*tokens=*/4096, /*turns=*/3, 1024, /*maxBreakpoints=*/2, true, CacheTtl.DEFAULT_5M); + assertEquals(2, STRATEGY.plan(capped).size()); + } + + @Test + @DisplayName("AnthropicCacheOptionsFactory: enabled 状态下产出非 DISABLED 配置") + void anthropicFactoryEmitsCacheOptions() { + var props = new CacheProperties(); + props.setEnabled(true); + props.setIncludeToolsBlock(true); + props.setTtl(CacheProperties.Ttl.EXTENDED_1H); + var factory = new AnthropicCacheOptionsFactory(props); + var opts = factory.build(); + assertNotSame(org.springframework.ai.anthropic.api.AnthropicCacheOptions.DISABLED, opts); + assertEquals(org.springframework.ai.anthropic.api.AnthropicCacheStrategy.CONVERSATION_HISTORY, + opts.getStrategy()); + // ttl 映射到 SYSTEM/USER ONE_HOUR + var ttlMap = opts.getMessageTypeTtl(); + assertEquals(org.springframework.ai.anthropic.api.AnthropicCacheTtl.ONE_HOUR, + ttlMap.get(org.springframework.ai.chat.messages.MessageType.SYSTEM)); + } + + @Test + @DisplayName("AnthropicCacheOptionsFactory: disabled → DISABLED 单例") + void anthropicFactoryDisabledFastPath() { + var props = new CacheProperties(); + props.setEnabled(false); + var factory = new AnthropicCacheOptionsFactory(props); + assertSame(org.springframework.ai.anthropic.api.AnthropicCacheOptions.DISABLED, factory.build()); + } + + @Test + @DisplayName("AdaptiveCacheStrategy: 连续 miss 后降级,hit 后立即恢复") + void adaptiveDegradesAndRecovers() { + var inner = new SystemAndTailCacheStrategy(); + var adaptive = new AdaptiveCacheStrategy(inner, /*missThreshold=*/3, /*coolDownMs=*/60_000); + var ctx = ctxFor(List.of(new SystemMessage("a".repeat(1024)), + new UserMessage("q1"), new AssistantMessage("a1"), + new UserMessage("q2")), + ModelProtocol.ANTHROPIC_MESSAGES, 4096, 2); + + assertTrue(adaptive.shouldCache(ctx)); + adaptive.recordMiss(); adaptive.recordMiss(); + assertTrue(adaptive.shouldCache(ctx), "未达阈值不应降级"); + adaptive.recordMiss(); + assertFalse(adaptive.shouldCache(ctx), "达阈值应降级"); + adaptive.recordHit(); + assertTrue(adaptive.shouldCache(ctx), "命中后应立即恢复"); + } + + @Test + @DisplayName("CacheUsageExtractor: 从 record 风格的 native usage 抽取 cache 字段") + void cacheUsageExtractorReflectsRecordAccessor() { + // 用一个匿名 record 模拟 AnthropicApi.Usage 形态 + record FakeNativeUsage(Integer cacheReadInputTokens, Integer cacheCreationInputTokens) {} + var fakeUsage = new org.springframework.ai.chat.metadata.DefaultUsage( + 100, 50, 150, new FakeNativeUsage(40, 60)); + var tokens = CacheUsageExtractor.extract(fakeUsage); + assertEquals(40, tokens.cacheReadTokens()); + assertEquals(60, tokens.cacheWriteTokens()); + } + + @Test + @DisplayName("CacheUsageExtractor: 不支持的 native usage 返回 EMPTY") + void cacheUsageExtractorEmptyForUnknownProvider() { + var openAiLikeUsage = new org.springframework.ai.chat.metadata.DefaultUsage(100, 50, 150, "no cache here"); + var tokens = CacheUsageExtractor.extract(openAiLikeUsage); + assertSame(CacheUsageExtractor.CacheTokens.EMPTY, tokens); + } +} diff --git a/mateclaw-ui/public/logo/shuzhi_yiliao_logo.svg b/mateclaw-ui/public/logo/shuzhi_yiliao_logo.svg new file mode 100644 index 00000000..2e0b96f8 --- /dev/null +++ b/mateclaw-ui/public/logo/shuzhi_yiliao_logo.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue index e13ddc6a..c95a2118 100644 --- a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue @@ -152,30 +152,114 @@ const { t } = useI18n() const store = useWikiStore() const fileInput = ref(null) -// RFC-012 M2 v2 UI:当列表中存在 processing 的材料时,每 3s 轮询一次刷新进度 -// 处理完毕(无 processing 项)自动停止;组件卸载时也会清理 timer。 -let pollTimer: number | null = null +// RFC-012 M3:当列表中存在 processing 的材料时,优先订阅后端 SSE 实时进度流, +// 60s 兜底拉取 processingStatus / fetchRawMaterials 作为 SSE 断线降级(DB 是真源)。 +// 处理完毕(无 processing 项)自动断开 SSE + 停止兜底轮询;组件卸载时也会清理。 +let sse: EventSource | null = null +let fallbackTimer: number | null = null +let activeKbId: number | null = null + const hasProcessing = computed(() => store.rawMaterials.some(r => r.processingStatus === 'processing') ) + +function applyProgressEvent(payload: any) { + if (!payload || payload.rawId == null) return + const raw = store.rawMaterials.find(r => r.id === payload.rawId) + if (!raw) return + if (typeof payload.done === 'number') raw.progressDone = payload.done + if (typeof payload.total === 'number') raw.progressTotal = payload.total +} + +function openSse(kbId: number) { + closeSse() + activeKbId = kbId + // Vite 代理 /api → :18088;EventSource 走相对路径即可 + const es = new EventSource(`/api/v1/wiki/knowledge-bases/${kbId}/progress`) + sse = es + + es.addEventListener('raw.started', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data) + const raw = store.rawMaterials.find(r => r.id === data.rawId) + if (raw) { + raw.processingStatus = 'processing' + raw.progressDone = 0 + raw.progressTotal = 0 + } + } catch { /* ignore */ } + }) + es.addEventListener('route.done', (ev: MessageEvent) => { + try { applyProgressEvent(JSON.parse(ev.data)) } catch { /* ignore */ } + }) + es.addEventListener('chunk.done', (ev: MessageEvent) => { + try { applyProgressEvent(JSON.parse(ev.data)) } catch { /* ignore */ } + }) + es.addEventListener('raw.completed', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data) + const raw = store.rawMaterials.find(r => r.id === data.rawId) + if (raw) { + raw.processingStatus = data.status === 'partial' ? 'partial' : 'completed' + if (typeof data.totalPages === 'number') { + raw.progressDone = data.totalPages + raw.progressTotal = data.totalPages + } + } + // 完成事件后,再做一次轻量 list 拉取确保其他字段(pageCount 等)同步 + if (store.currentKB) store.fetchRawMaterials(store.currentKB.id) + } catch { /* ignore */ } + }) + es.addEventListener('raw.failed', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data) + const raw = store.rawMaterials.find(r => r.id === data.rawId) + if (raw) raw.processingStatus = 'failed' + if (store.currentKB) store.fetchRawMaterials(store.currentKB.id) + } catch { /* ignore */ } + }) + es.onerror = () => { + // 浏览器 EventSource 会自动重连;这里仅 log + // console.debug('Wiki SSE error/reconnect', kbId) + } +} + +function closeSse() { + if (sse) { + sse.close() + sse = null + } + activeKbId = null +} + watch( - hasProcessing, - (active) => { - if (active && pollTimer == null) { - pollTimer = window.setInterval(() => { - if (store.currentKB) store.fetchRawMaterials(store.currentKB.id) - }, 3000) - } else if (!active && pollTimer != null) { - clearInterval(pollTimer) - pollTimer = null + () => [hasProcessing.value, store.currentKB?.id] as const, + ([active, kbId]) => { + if (active && kbId != null) { + // SSE 主通道 + if (activeKbId !== kbId) openSse(kbId) + // 60s 兜底拉取 + if (fallbackTimer == null) { + fallbackTimer = window.setInterval(() => { + if (store.currentKB) store.fetchRawMaterials(store.currentKB.id) + }, 60000) + } + } else { + closeSse() + if (fallbackTimer != null) { + clearInterval(fallbackTimer) + fallbackTimer = null + } } }, { immediate: true } ) + onBeforeUnmount(() => { - if (pollTimer != null) { - clearInterval(pollTimer) - pollTimer = null + closeSse() + if (fallbackTimer != null) { + clearInterval(fallbackTimer) + fallbackTimer = null } })