fix(agent): decouple framework recursion limit from per-agent max_iterations

This commit is contained in:
matevip 2026-05-05 13:06:40 +08:00
parent c1c2525d0e
commit 80cb3c84eb
2 changed files with 99 additions and 12 deletions

View File

@ -240,14 +240,24 @@ public class AgentGraphBuilder {
// 内置搜索作为首选search 工具作为补充/兜底
log.info("内置搜索已开启 (provider={})search 工具保留作为补充通道", provider.getProviderId());
}
// Default 100 if DB row leaves max_iterations null; clamp per-agent overrides
// to the hard ceiling (BaseAgent.MAX_ITERATIONS_HARD_CEILING) so a misconfigured
// row can never push an unbounded loop. Effective range: 1..100.
// Default 100 if DB row leaves max_iterations null. Negative or zero is an
// explicit opt-in to "no soft cap" ObservationDispatcher already treats
// maxIterations<=0 as "do not enforce", so the agent runs until the LLM
// emits a final answer (or returnDirect short-circuits). Positive values
// are clamped to the hard ceiling so a misconfigured row can't skip the
// safety net unintentionally.
int rawMaxIter = entity.getMaxIterations() != null ? entity.getMaxIterations() : 100;
int maxIter = Math.max(1, Math.min(rawMaxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING));
if (maxIter != rawMaxIter) {
log.warn("Agent {} max_iterations={} clamped to {} (1..{})",
entity.getId(), rawMaxIter, maxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING);
int maxIter;
if (rawMaxIter <= 0) {
maxIter = 0;
log.info("Agent {} max_iterations={} → unlimited soft cap (LLM controls termination)",
entity.getId(), rawMaxIter);
} else {
maxIter = Math.min(rawMaxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING);
if (maxIter != rawMaxIter) {
log.warn("Agent {} max_iterations={} clamped to {} (1..{})",
entity.getId(), rawMaxIter, maxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING);
}
}
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled,
@ -474,13 +484,37 @@ public class AgentGraphBuilder {
.addEdge(PlanStateKeys.DIRECT_ANSWER_NODE, StateGraph.END);
return graph.compile(CompileConfig.builder()
.recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300)
.recursionLimit(frameworkRecursionLimit())
.build());
} catch (Exception e) {
throw new MateClawException("err.agent.plan_compile_failed", "Plan-Execute StateGraph 编译失败: " + e.getMessage());
}
}
/**
* Hard ceiling for the underlying graph framework's recursion guard.
* <p>
* The framework treats "recursion limit reached" as a normal completion
* it emits a {@code done} signal with no exception and no log. That makes
* it indistinguishable from a real final answer downstream, and is the
* mechanism by which a turn can silently stop mid-execution and persist
* only whatever partial content the accumulator happened to hold.
* <p>
* To avoid that class of bug, the recursion limit must be sized so it can
* <em>never</em> trip before the soft cap (ObservationDispatcher
* LimitExceededNode), which is the only path that produces a proper
* {@code finish_reason} and human-facing message. Sized for the maximum
* effective soft cap (DB hard ceiling + thinking-mode bonus) multiplied
* by 4 (each iteration is worst-case reasoning + summarizing + action +
* observation) plus a 100-step buffer for phase nodes, approval replays
* and tool-result chunking. Decoupled from the per-agent value so a small
* {@code max_iterations} can never accidentally re-introduce the silent
* killer.
*/
private static int frameworkRecursionLimit() {
return (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4 + 100;
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
@ -636,7 +670,7 @@ public class AgentGraphBuilder {
.addEdge(MateClawStateKeys.FINAL_ANSWER_NODE, StateGraph.END);
return graph.compile(CompileConfig.builder()
.recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300)
.recursionLimit(frameworkRecursionLimit())
.withLifecycleListener(new ReActLifecycleListener())
.build());
} catch (Exception e) {

View File

@ -193,6 +193,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
// Silent-termination guard (mirrors chatStructuredStream)
AtomicInteger lastIteration = new AtomicInteger(0);
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
return compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
@ -239,6 +243,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
if (hasFinalAnswer(output)
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
sawLegitimateExit.set(true);
}
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
@ -252,7 +264,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnComplete(() -> {
setState(AgentState.IDLE);
if (!sawLegitimateExit.get()) {
log.error("[{}] StateGraph replay stream completed WITHOUT a final answer / "
+ "limit_exceeded / finish_reason — likely framework-level silent "
+ "termination. conversationId={}, lastIteration={}, softCap={}",
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
}
})
.doOnError(e -> {
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
@ -294,6 +314,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// STREAMED_CONTENT REPLACE 策略每轮 ReasoningNode/SummarizingNode 覆写
// lastEmitted 跟踪已发送的值避免在 ActionNode/ObservationNode NodeOutput 上重复发送同一段内容
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
// Silent-termination guardrail: track the highest iteration / soft cap
// observed and whether the graph reached a legitimate exit (final answer
// or limit-exceeded node). If the framework completes the Flux without
// either signal we log.error in doOnComplete the graph framework
// historically treated its own recursion cap as a silent normal
// completion, which masked turns ending mid-execution. Decoupling the
// recursionLimit at compile time should keep this from firing, but the
// guard catches any future regression instead of letting it ship silent.
AtomicInteger lastIteration = new AtomicInteger(0);
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
return compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
@ -349,6 +380,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
// 4. Silent-termination guard inputs
lastIteration.set(output.state().value(CURRENT_ITERATION, 0));
lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0));
if (hasFinalAnswer(output)
|| Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false))
|| !output.state().<String>value(FINISH_REASON).orElse("").isBlank()) {
sawLegitimateExit.set(true);
}
return deltas;
})
// 流正常完成后追加内部 usage 事件
@ -363,7 +403,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnComplete(() -> {
setState(AgentState.IDLE);
if (!sawLegitimateExit.get()) {
log.error("[{}] StateGraph structured stream completed WITHOUT a final answer / "
+ "limit_exceeded / finish_reason — likely framework-level silent "
+ "termination (recursionLimit reached or upstream truncation). "
+ "conversationId={}, lastIteration={}, softCap={}",
agentName, conversationId, lastIteration.get(), lastSoftCap.get());
}
})
.doOnError(e -> {
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
@ -406,9 +455,13 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
inputs.put(MESSAGES, messages);
// 迭代控制深度思考模式允许更多迭代思考需要更多轮工具调用
// maxIterations<=0 表示软上限解除 LLM 自己决定何时收尾加分要短路
// 否则 thinking-on 会把"无限"误算成 5变成"5 步就停"
String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get();
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
int effectiveMaxIterations = thinkingOn ? maxIterations + 5 : maxIterations;
int effectiveMaxIterations = (maxIterations <= 0)
? 0
: (thinkingOn ? maxIterations + 5 : maxIterations);
inputs.put(MAX_ITERATIONS, effectiveMaxIterations);
inputs.put(CURRENT_ITERATION, 0);
// 初始化新字段