From f71f49bda141ecdc73dd454641b5ad13afbdb750 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 10 May 2026 19:16:16 +0800 Subject: [PATCH] fix(agent): dedup repetition output + mark INCOMPLETE instead of NORMAL --- .../agent/graph/NodeStreamingChatHelper.java | 47 ++++++++++++++++++- .../mate/agent/graph/node/ReasoningNode.java | 40 ++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index d7f4c83b..078353bb 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -302,8 +302,8 @@ public class NodeStreamingChatHelper { * (removed at 42d406ff for being brittle on legitimate long-form * content), just the cheap specific check that catches this loop. */ - private static final int CONTENT_REPEAT_MIN_PERIOD = 24; - private static final int CONTENT_REPEAT_MAX_PERIOD = 240; + public static final int CONTENT_REPEAT_MIN_PERIOD = 24; + public static final int CONTENT_REPEAT_MAX_PERIOD = 240; private static final int CONTENT_REPEAT_MAX_OCCURRENCES = 4; /** * Re-scan every N chars of new content. Smaller = faster reaction, @@ -1627,6 +1627,49 @@ public class NodeStreamingChatHelper { } } + /** + * Collapse a content buffer's trailing run of verbatim repeats to a + * single copy. Used to clean up the persisted final answer after + * {@link #hasRepeatingSuffix} fires — the streamed text already + * contains the duplicates (SSE chunks can't be unsent), but the + * DB-persisted message and the IM channel reply should show ONE + * clean copy of the looping unit, not a wall. + * + *

Algorithm: find the smallest period in {@code [minPeriod, + * maxPeriod]} where the buffer ends with that unit repeated 2+ + * times consecutively, then return everything up to (and including) + * the FIRST copy of that unit. Conservative — if no period yields + * 2+ consecutive matches, returns the buffer unchanged. + * + *

Public for unit-testing alongside {@link #hasRepeatingSuffix}. + */ + public static String dedupTrailingRepeats(String content, int minPeriod, int maxPeriod) { + if (content == null || content.isEmpty()) return content; + int len = content.length(); + if (minPeriod <= 0 || maxPeriod < minPeriod) return content; + int periodCap = Math.min(maxPeriod, len / 2); + for (int p = minPeriod; p <= periodCap; p++) { + int unitStart = len - p; + // Walk backward as far as the unit keeps matching. + int copies = 1; + int blockStart = unitStart - p; + while (blockStart >= 0 + && content.regionMatches(blockStart, content, unitStart, p)) { + copies++; + blockStart -= p; + } + if (copies >= 2) { + // Keep prefix + ONE copy. The first copy starts at + // (blockStart + p) since the loop walked back one step + // past the last match. + int firstCopyStart = blockStart + p; + int trimEnd = firstCopyStart + p; + return content.substring(0, trimEnd); + } + } + return content; + } + /** * Detect whether {@code accum} ends with the same {@code period}-sized * unit repeated at least {@code minOccurrences} times consecutively, 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 7c7bacf4..5a1d3eed 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 @@ -489,6 +489,46 @@ public class ReasoningNode implements NodeAction { return builder.build(); } + if (result.partial() && "content_repetition".equals(result.errorMessage())) { + // Reasoning loop: the helper disposed the stream because the + // model emitted the same paragraph 4+ times in a row (qwen3.6 + // / deepseek-r1 self-arguing pattern). The streamed text + // already showed the duplicates to the user — we can't unsend + // SSE chunks — but the persisted finalAnswer should be ONE + // clean copy so the IM channel reply and any page-reload + // history don't show the wall of repetition. Skip + // FinalAnswerNode's evidence validation: the answer is + // already truncated, applying validateAnswer on top would + // double-stamp warnings on something the user already knows + // is incomplete. + String rawContent = result.text() != null ? result.text() : ""; + String dedupedAnswer = NodeStreamingChatHelper.dedupTrailingRepeats( + rawContent, + NodeStreamingChatHelper.CONTENT_REPEAT_MIN_PERIOD, + NodeStreamingChatHelper.CONTENT_REPEAT_MAX_PERIOD); + log.warn("[ReasoningNode] Content-repetition cap hit (raw={} chars → deduped={} chars); " + + "INCOMPLETE", + rawContent.length(), dedupedAnswer.length()); + var builder = reasonOutput() + .needsToolCall(false) + .shouldSummarize(false) + .finalAnswer(dedupedAnswer.isEmpty() + ? "(模型反复输出同一段内容,已自动截断。请尝试重新生成或换个问法。)" + : dedupedAnswer) + .llmCallCount(nextLlmCallCount) + .finishReason(FinishReason.INCOMPLETE) + // contentStreamed=true because the user already saw + // the looping text in their bubble; persisting again + // via streamedContent would replay it. + .contentStreamed(true) + .thinkingStreamed(result.thinking() != null && !result.thinking().isEmpty()) + .mergeUsage(state, result); + if (result.thinking() != null && !result.thinking().isEmpty()) { + builder.finalThinking(result.thinking()); + } + return builder.build(); + } + // Fatal error:直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK, // 不走 LimitExceededNode(后者会再发一次 LLM 调用,语义不对且对认证/配额错误会再失败)。 // ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize → finalAnswerNode,