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 a3a5701a..a6a3d058 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -926,9 +926,18 @@ public class AgentGraphBuilder { // capability from reasoningEffort == null. boolean supportsReasoningEffort = primaryModelConfig != null && ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort(); + // Honor the model's configured output cap. Passing 0 here made the + // node fall back to its 16384 default, so the user-configured + // maxTokens never took effect and strict local servers (vLLM's + // max_model_len pre-check) rejected the request outright. + int configuredMaxOutputTokens = (primaryModelConfig != null + && primaryModelConfig.getMaxTokens() != null + && primaryModelConfig.getMaxTokens() > 0) + ? primaryModelConfig.getMaxTokens() : 0; ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, - streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService, + streamingHelper, conversationWindowManager, streamTracker, + configuredMaxOutputTokens, wikiContextService, skillCatalogRenderer, toolDisclosureService, progressLedgerService); reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan); reasoningNode.setAutoDemotedTools(autoDemotedTools); 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 4186f8a2..053b4223 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 @@ -376,6 +376,27 @@ public class ReasoningNode implements NodeAction { this.autoDemotedTools = autoDemotedTools == null ? Set.of() : autoDemotedTools; } + /** Floor for the window-aware output clamp — an answer needs at least this much room. */ + private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512; + + /** + * Output cap actually sent to the provider. Strict local servers (vLLM) + * statically reject {@code max_tokens >= max_model_len}, so when the + * effective context window is known and smaller than the configured / + * default output cap, clamp to half the window (leaving the other half + * for the prompt). No-op when the window is unknown or already larger. + */ + int effectiveMaxOutputTokens() { + int window = (prefixBudgetPlan != null) ? prefixBudgetPlan.effectiveMaxTokens() : 0; + if (window > 0 && maxOutputTokens >= window) { + int clamped = Math.max(MIN_CLAMPED_OUTPUT_TOKENS, window / 2); + log.info("[ReasoningNode] max_tokens {} ≥ 模型窗口 {},钳制为 {}(窗口一半)以避免服务端拒绝", + maxOutputTokens, window, clamped); + return clamped; + } + return maxOutputTokens; + } + public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort, NodeStreamingChatHelper streamingHelper, ConversationWindowManager conversationWindowManager, @@ -1234,11 +1255,11 @@ public class ReasoningNode implements NodeAction { default -> 16384; }; builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens); - builder.maxTokens(budgetTokens + maxOutputTokens); + builder.maxTokens(budgetTokens + effectiveMaxOutputTokens()); builder.temperature(1.0); log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens); } else { - builder.maxTokens(maxOutputTokens); + builder.maxTokens(effectiveMaxOutputTokens()); if (thinkingOn && !isClaudeModel) { log.debug("[ReasoningNode] Anthropic protocol model {} does not support thinking, skipping", currentModel); } @@ -1253,7 +1274,7 @@ public class ReasoningNode implements NodeAction { // DashScope rejects max_tokens above its 8192 ceiling with a 400 that // the failover layer misreads as "model not found"; clamp so a // DashScope-backed model never overflows the provider limit. - int effectiveMaxTokens = maxOutputTokens; + int effectiveMaxTokens = effectiveMaxOutputTokens(); if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel && effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) { log.debug("[ReasoningNode] Clamping max_tokens {} -> {} for DashScope-backed model", diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputClampTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputClampTest.java new file mode 100644 index 00000000..fedd2ae7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputClampTest.java @@ -0,0 +1,56 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentToolSet; +import vip.mate.agent.context.PrefixBudgetPlan; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Window-aware output-cap clamp: strict local servers (vLLM) statically + * reject {@code max_tokens >= max_model_len}, so the cap sent to the + * provider must shrink when the known context window is smaller than the + * configured / default output cap. + */ +class ReasoningNodeOutputClampTest { + + private static ReasoningNode nodeWithWindow(Integer windowTokens) { + @SuppressWarnings("deprecation") + ReasoningNode node = new ReasoningNode(null, + AgentToolSet.fromCallbacks(List.of(), List.of()), null); + if (windowTokens != null) { + node.setPrefixBudgetPlan(new PrefixBudgetPlan( + true, windowTokens, PrefixBudgetPlan.Profile.COMPACT, + 0, 0, 0, 0, 0, 0, windowTokens / 4)); + } + return node; + } + + @Test + @DisplayName("default 16384 cap on an 8k window clamps to half the window") + void defaultCapClampsOnSmallWindow() { + // Deprecated ctor leaves maxOutputTokens at the 16384 default. + assertEquals(4096, nodeWithWindow(8192).effectiveMaxOutputTokens()); + } + + @Test + @DisplayName("large window leaves the cap untouched") + void largeWindowKeepsCap() { + assertEquals(16384, nodeWithWindow(128000).effectiveMaxOutputTokens()); + } + + @Test + @DisplayName("no budget plan (tests / legacy graphs) keeps previous behavior") + void noPlanKeepsCap() { + assertEquals(16384, nodeWithWindow(null).effectiveMaxOutputTokens()); + } + + @Test + @DisplayName("tiny window clamps no lower than the 512-token floor") + void tinyWindowRespectsFloor() { + assertEquals(512, nodeWithWindow(600).effectiveMaxOutputTokens()); + } +}