fix(agent): dedup repetition output + mark INCOMPLETE instead of NORMAL

This commit is contained in:
matevip 2026-05-10 19:16:16 +08:00
parent fb0ae2b6cc
commit f71f49bda1
2 changed files with 85 additions and 2 deletions

View File

@ -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.
*
* <p>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.
*
* <p>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,

View File

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