feat(agent): 小上下文降级档——紧凑/底线档收紧注入、压缩触发比自适应、prefix 分块统计与预超限快速失败

This commit is contained in:
matevip 2026-07-03 18:53:05 +08:00
parent bf0d64e46a
commit 36d1f1027d
4 changed files with 93 additions and 4 deletions

View File

@ -3,6 +3,7 @@ package vip.mate.agent.context;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
@ -118,6 +119,18 @@ public class ConversationWindowManager {
private final MemoryManager memoryManager;
private final ConversationService conversationService;
/**
* Optional adaptive compaction trigger for small context windows.
* Setter-injected so the many direct test constructions keep the plain
* configured ratio (null previous behavior).
*/
private PrefixBudgetPlanner prefixBudgetPlanner;
@Autowired(required = false)
public void setPrefixBudgetPlanner(PrefixBudgetPlanner prefixBudgetPlanner) {
this.prefixBudgetPlanner = prefixBudgetPlanner;
}
/**
* Optional spill store, injected via setter so unit tests and the two
* existing 3-arg constructor callers in tests stay source-compatible.
@ -251,7 +264,12 @@ public class ConversationWindowManager {
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
int triggerThreshold = (int) (effectiveMax * properties.getCompactTriggerRatio());
// Small windows compact later (higher trigger ratio): summarizing at
// 75% of an 8k window throws away room it cannot afford to lose.
double triggerRatio = prefixBudgetPlanner != null
? prefixBudgetPlanner.compactTriggerRatioFor(effectiveMax, properties.getCompactTriggerRatio())
: properties.getCompactTriggerRatio();
int triggerThreshold = (int) (effectiveMax * triggerRatio);
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD;

View File

@ -30,6 +30,9 @@ import vip.mate.config.PrefixBudgetProperties;
@EnableConfigurationProperties(PrefixBudgetProperties.class)
public class PrefixBudgetPlanner {
/** COMPACT-profile ceiling for the wiki relevance injection (~one page). */
static final int COMPACT_WIKI_TOKEN_CAP = 2000;
private final PrefixBudgetProperties properties;
private final ConversationWindowProperties windowProperties;
@ -66,10 +69,21 @@ public class PrefixBudgetPlanner {
sum = 1.0;
}
// Profile-specific wiki clamps: knowledge-base reference pages are the
// most dispensable block on a small window the wiki tools stay
// callable, only the automatic pre-injection shrinks. COMPACT caps it
// at roughly one page; MINIMAL disables it outright.
int wikiTokens = (int) (injectionBudget * shares.getWiki() / sum);
wikiTokens = switch (profile) {
case NORMAL -> wikiTokens;
case COMPACT -> Math.min(wikiTokens, COMPACT_WIKI_TOKEN_CAP);
case MINIMAL -> 0;
};
PrefixBudgetPlan plan = new PrefixBudgetPlan(
true, effectiveMax, profile, injectionBudget,
(int) (injectionBudget * shares.getMemory() / sum),
(int) (injectionBudget * shares.getWiki() / sum),
wikiTokens,
(int) (injectionBudget * shares.getSkill() / sum),
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
(int) (injectionBudget * shares.getLedger() / sum),

View File

@ -749,6 +749,27 @@ public class ReasoningNode implements NodeAction {
Prompt prompt = new Prompt(promptMessages, options);
// Prefix accounting: how much of the window the never-trimmed prefix
// (system prompt + runtime context + wiki + skill catalog + ledger)
// and the advertised tool schemas consume. Logged on the turn's first
// call so a small-window overflow is diagnosable per block instead of
// surfacing as an opaque provider 400.
int prefixEstimateTokens = TokenEstimator.estimateTokens(nonHistoryPrefix);
int toolSchemaEstimateTokens = TokenEstimator.estimateToolsTokens(activeCallbacks);
if (accessor.llmCallCount() == 0) {
log.info("[ReasoningNode] Prefix accounting conv={}: window={} tokens, prefix={} "
+ "(system+context+wiki+skills+ledger), toolSchemas={}, history={}",
conversationId, loopContextWindowTokens(), prefixEstimateTokens,
toolSchemaEstimateTokens, TokenEstimator.estimateTokens(messages));
}
// The prefix cannot be compacted (history compaction is the only lever),
// so a prefix that alone exceeds the window makes the request doomed
// fail fast with the same PROMPT_TOO_LONG shape a provider rejection
// would produce instead of sending it. Gated on budgeting being active
// (an estimation false-positive must not block requests otherwise).
boolean prefixOverflow = prefixBudgetPlan != null && prefixBudgetPlan.enabled()
&& prefixEstimateTokens + toolSchemaEstimateTokens > prefixBudgetPlan.effectiveMaxTokens();
// ======= LLM 调用区域 =======
// nextLlmCallCount 在首次 streamCall 之前计算
// 所有退出路径正常stoppedfatal errorCancellationException都必须写回此值
@ -778,7 +799,19 @@ public class ReasoningNode implements NodeAction {
NodeStreamingChatHelper.StreamResult result;
try {
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
if (prefixOverflow) {
String overflowMessage = "Prompt 前缀估算 " + (prefixEstimateTokens + toolSchemaEstimateTokens)
+ " tokens(注入块 " + prefixEstimateTokens + " + 工具 schema " + toolSchemaEstimateTokens
+ ")已超过模型上下文窗口 " + prefixBudgetPlan.effectiveMaxTokens()
+ " tokens,历史压缩无法解决——请精简 Agent 身份 prompt、减少绑定工具/技能,"
+ "或换用更大窗口的模型";
log.error("[ReasoningNode] {}", overflowMessage);
result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false,
0, 0, false, overflowMessage,
NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0);
} else {
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
}
// PTL 处理结构化压缩后重试复用 nonHistoryPrefix 保证重试
// Prompt 仍带 wiki / runtime context早期的 tail-only 路径会把

View File

@ -44,11 +44,35 @@ class PrefixBudgetPlannerTest {
}
@Test
@DisplayName("4k window → MINIMAL profile")
@DisplayName("4k window → MINIMAL profile, wiki injection disabled outright")
void minimalProfile() {
PrefixBudgetPlan plan = planner.plan(4096, 500, 500);
assertEquals(PrefixBudgetPlan.Profile.MINIMAL, plan.profile());
assertEquals(Math.max(0, (int) (4096 * 0.15) - 1000), plan.injectionBudgetTokens());
assertEquals(0, plan.wikiTokens());
}
@Test
@DisplayName("COMPACT profile caps the wiki injection at roughly one page")
void compactCapsWiki() {
properties.getShares().setWiki(1.0);
properties.getShares().setMemory(0.0);
properties.getShares().setSkill(0.0);
properties.getShares().setExtensionCatalog(0.0);
properties.getShares().setLedger(0.0);
PrefixBudgetPlan plan = planner.plan(20000, 0, 0);
assertEquals(PrefixBudgetPlan.Profile.COMPACT, plan.profile());
assertTrue(plan.injectionBudgetTokens() > PrefixBudgetPlanner.COMPACT_WIKI_TOKEN_CAP);
assertEquals(PrefixBudgetPlanner.COMPACT_WIKI_TOKEN_CAP, plan.wikiTokens());
}
@Test
@DisplayName("plan carries an independent tool-schema budget from toolSchemaRatio")
void toolSchemaBudget() {
PrefixBudgetPlan plan = planner.plan(16384, 0, 0);
assertEquals((int) (16384 * 0.25), plan.toolSchemaBudgetTokens());
properties.setEnabled(false);
assertEquals(Integer.MAX_VALUE, planner.plan(16384, 0, 0).toolSchemaBudgetTokens());
}
@Test