From 46f77c281b1cb3f6a5ac9d7d2acf483131b9d97e Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 12 Apr 2026 17:28:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20complete=20RFC-001=20=E2=80=94=20?= =?UTF-8?q?Anthropic=20thinking,=20iteration=20budget,=20UI=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vip/mate/agent/AgentGraphBuilder.java | 45 +++++++--- .../agent/graph/StateGraphReActAgent.java | 7 +- .../mate/agent/graph/node/ReasoningNode.java | 89 +++++++++++++++---- .../src/components/chat/MessageBubble.vue | 11 ++- mateclaw-ui/src/composables/chat/useChat.ts | 17 ++-- 5 files changed, 129 insertions(+), 40 deletions(-) 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 eb251f16..b1e8a1c7 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -37,6 +37,7 @@ import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Flux; +import vip.mate.agent.ThinkingLevelHolder; import vip.mate.agent.graph.StateGraphReActAgent; import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.graph.executor.ToolExecutionExecutor; @@ -940,18 +941,40 @@ public class AgentGraphBuilder { if (StringUtils.hasText(runtimeModel.getModelName())) { builder.model(runtimeModel.getModelName()); } - // Anthropic API does not allow temperature and top_p to be specified simultaneously. - // Prefer temperature; only fall back to top_p when temperature is absent. - if (runtimeModel.getTemperature() != null) { - builder.temperature(runtimeModel.getTemperature()); - } else if (runtimeModel.getTopP() != null) { - builder.topP(runtimeModel.getTopP()); - } - if (runtimeModel.getMaxTokens() != null) { - builder.maxTokens(runtimeModel.getMaxTokens()); + + // Extended thinking: 通过 ThinkingLevelHolder 获取请求级思考深度 + String thinkingLevel = ThinkingLevelHolder.get(); + boolean thinkingEnabled = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel); + + if (thinkingEnabled) { + // Anthropic thinking 模式下:temperature 必须为 1,不能设 top_p + // budget_tokens 根据级别映射 + int budgetTokens = switch (thinkingLevel.toLowerCase()) { + case "low" -> 4096; + case "medium" -> 8192; + case "high" -> 16384; + case "max" -> 32768; + default -> 16384; + }; + builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens); + // Thinking 模式要求 max_tokens 足够大(含 thinking tokens) + builder.maxTokens(Math.max(budgetTokens + 4096, + runtimeModel.getMaxTokens() != null ? runtimeModel.getMaxTokens() : 8192)); + // Anthropic thinking 模式要求 temperature=1 + builder.temperature(1.0); } else { - // Anthropic requires max_tokens; set a safe default - builder.maxTokens(4096); + // 非 thinking 模式:正常设置参数 + // Anthropic API does not allow temperature and top_p to be specified simultaneously. + if (runtimeModel.getTemperature() != null) { + builder.temperature(runtimeModel.getTemperature()); + } else if (runtimeModel.getTopP() != null) { + builder.topP(runtimeModel.getTopP()); + } + if (runtimeModel.getMaxTokens() != null) { + builder.maxTokens(runtimeModel.getMaxTokens()); + } else { + builder.maxTokens(4096); + } } return builder.internalToolExecutionEnabled(false).build(); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index e1213714..a9cf1bba 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -365,8 +365,11 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC inputs.put(WORKSPACE_BASE_PATH, workspaceBasePath != null ? workspaceBasePath : ""); inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。"); inputs.put(MESSAGES, messages); - // 迭代控制 - inputs.put(MAX_ITERATIONS, maxIterations); + // 迭代控制:深度思考模式允许更多迭代(思考需要更多轮工具调用) + String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get(); + boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel); + int effectiveMaxIterations = thinkingOn ? maxIterations + 5 : maxIterations; + inputs.put(MAX_ITERATIONS, effectiveMaxIterations); inputs.put(CURRENT_ITERATION, 0); // 初始化新字段 inputs.put(TOOL_CALL_COUNT, 0); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 3e15c0c0..62d7567d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -183,22 +183,7 @@ public class ReasoningNode implements NodeAction { log.info("[ReasoningNode] thinkingLevel={}, effectiveReasoningEffort={}, nodeDefault={}", ThinkingLevelHolder.get(), effectiveReasoning, this.reasoningEffort); - ChatOptions options; - if (StringUtils.hasText(effectiveReasoning)) { - OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder() - .toolCallbacks(toolCallbacks) - .reasoningEffort(effectiveReasoning) - .maxTokens(maxOutputTokens) - .build(); - oaiOpts.setInternalToolExecutionEnabled(false); - options = oaiOpts; - } else { - options = ToolCallingChatOptions.builder() - .toolCallbacks(toolCallbacks) - .internalToolExecutionEnabled(false) - .maxTokens(maxOutputTokens) - .build(); - } + ChatOptions options = buildChatOptions(effectiveReasoning); Prompt prompt = new Prompt(promptMessages, options); @@ -380,6 +365,62 @@ public class ReasoningNode implements NodeAction { streamTracker.broadcastObject(conversationId, "phase", GraphEventPublisher.phase(phase, extra).data()); } + /** + * 根据 ChatModel 类型构建合适的 ChatOptions。 + * - AnthropicChatModel → AnthropicChatOptions(支持 extended thinking) + * - 其他(OpenAI/DashScope)→ OpenAiChatOptions(支持 reasoningEffort) + */ + private ChatOptions buildChatOptions(String effectiveReasoning) { + // Anthropic 协议模型(AnthropicChatModel):MiniMax 也用此协议但不支持 thinking + if (chatModel instanceof org.springframework.ai.anthropic.AnthropicChatModel anthropicModel) { + org.springframework.ai.anthropic.AnthropicChatOptions.Builder builder = + org.springframework.ai.anthropic.AnthropicChatOptions.builder() + .toolCallbacks(toolCallbacks) + .internalToolExecutionEnabled(false); + + // 仅对真正的 Claude 模型启用 extended thinking(MiniMax 等走 Anthropic 协议但不支持) + String thinkingLevel = ThinkingLevelHolder.get(); + boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel); + String currentModel = getAnthropicModelName(anthropicModel); + boolean isClaudeModel = currentModel != null && currentModel.toLowerCase().contains("claude"); + + if (thinkingOn && isClaudeModel) { + int budgetTokens = switch (thinkingLevel.toLowerCase()) { + case "low" -> 4096; + case "medium" -> 8192; + case "high" -> 16384; + case "max" -> 32768; + default -> 16384; + }; + builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens); + builder.maxTokens(budgetTokens + maxOutputTokens); + builder.temperature(1.0); + log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens); + } else { + builder.maxTokens(maxOutputTokens); + if (thinkingOn && !isClaudeModel) { + log.debug("[ReasoningNode] Anthropic protocol model {} does not support thinking, skipping", currentModel); + } + } + return builder.build(); + } + + // OpenAI / DashScope / 其他 + // 始终使用 OpenAiChatOptions(而非 ToolCallingChatOptions), + // 因为 ToolCallingChatOptions 会丢失 OpenAI 特有参数(streamUsage 等), + // 导致 Kimi 等 OpenAI 兼容 API 响应异常或提前截断。 + OpenAiChatOptions.Builder oaiBuilder = OpenAiChatOptions.builder() + .toolCallbacks(toolCallbacks) + .maxTokens(maxOutputTokens); + if (StringUtils.hasText(effectiveReasoning)) { + oaiBuilder.reasoningEffort(effectiveReasoning); + } + OpenAiChatOptions oaiOpts = oaiBuilder.build(); + oaiOpts.setInternalToolExecutionEnabled(false); + oaiOpts.setStreamUsage(true); + return oaiOpts; + } + /** * 解析有效的 reasoningEffort。 * 优先级:ThinkingLevelHolder(请求级) > 构造时的 reasoningEffort(Agent/模型默认)。 @@ -403,4 +444,20 @@ public class ReasoningNode implements NodeAction { // 无请求级覆盖,使用构造时的默认值 return this.reasoningEffort; } + + /** + * 从 AnthropicChatModel 的 defaultOptions 中提取模型名称。 + * 用于判断是否为真正的 Claude 模型(vs MiniMax 等走 Anthropic 协议的非 Claude 模型)。 + */ + private String getAnthropicModelName(org.springframework.ai.anthropic.AnthropicChatModel model) { + try { + var options = model.getDefaultOptions(); + if (options instanceof org.springframework.ai.anthropic.AnthropicChatOptions aOpts) { + return aOpts.getModel(); + } + } catch (Exception e) { + log.debug("[ReasoningNode] Failed to extract Anthropic model name: {}", e.getMessage()); + } + return null; + } } diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 258d1c8b..86bbb2d9 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -362,9 +362,18 @@ const showThinkingPanel = computed(() => !!thinkingContent.value) // 思考耗时(生成结束后显示) const thinkingDuration = computed(() => { if (isGenerating.value) return '' + if (!thinkingContent.value) return '' + // 优先使用 segment 真实时间戳 + const segs = (props.message as any).segments || [] + const thinkSeg = segs.find((s: any) => s.type === 'thinking') + const contentSeg = segs.find((s: any) => s.type === 'content') + if (thinkSeg?.timestamp && contentSeg?.timestamp) { + const sec = Math.max(1, Math.round((contentSeg.timestamp - thinkSeg.timestamp) / 1000)) + return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s` + } + // 回退:从内容长度估算 const len = thinkingContent.value.length if (len < 50) return '' - // 粗略估计:每 100 字符约 1 秒 const sec = Math.max(1, Math.round(len / 100)) return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s` }) diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 83f7a5c4..30415583 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -286,21 +286,18 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (streamPhase.value !== 'summarizing_observations') { streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking' } - // 分段:追加到当前 thinking segment 或创建新的 + // 分段:所有 thinking 合并到一个 segment(不因 tool_call 中断而创建多个) const segs = currentSegments.value - let thinkSeg = segs.findLast((s: MessageSegment) => s.type === 'thinking' && s.status === 'running') + // 优先复用已有的 thinking segment(无论 running 还是 completed) + let thinkSeg = segs.find((s: MessageSegment) => s.type === 'thinking') if (!thinkSeg) { thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() } - // 修复:如果前面已有 content segment(模型先发 content 后发 thinking), - // 把 thinking 插入到第一个 content 之前,确保 thinking 在上方显示 - const firstContentIdx = segs.findIndex((s: MessageSegment) => s.type === 'content') - if (firstContentIdx >= 0 && !segs.some((s: MessageSegment) => s.type === 'tool_call')) { - segs.splice(firstContentIdx, 0, thinkSeg) - } else { - segs.push(thinkSeg) - } + // 插入到开头(thinking 始终在最上方) + segs.unshift(thinkSeg) flushSegmentsToMessage() } + // 新的 thinking 到来,重新设为 running + thinkSeg.status = 'running' thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '') } })