fix(agent): 推理节点尊重模型配置的 maxTokens 输出上限,并按真实窗口钳制,修复严格本地服务端的 max_tokens 预检拒绝

This commit is contained in:
matevip 2026-07-03 19:11:37 +08:00
parent 727adcd24b
commit 35142508db
3 changed files with 90 additions and 4 deletions

View File

@ -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);

View File

@ -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",

View File

@ -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());
}
}