修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离
- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条) - queue 加 conversationId 字段,按会话精准 drain - regenerate/editMessage 只清本会话排队消息 - newConversation 保留旧会话排队消息 - AiError 只清出错会话的队列项
This commit is contained in:
@@ -153,6 +153,13 @@ fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool {
|
||||
|
||||
/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
|
||||
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
|
||||
///
|
||||
/// **退化检测(BUG-2026-07-07/P0-1)**:实测 9357c27c 会话 seq0 system 消息内容为
|
||||
/// 同一短语重复 41 次的 1879 字符废文本(LLM 退化输出 / repetition degeneration)。
|
||||
/// 原因:某些情况下 LLM(compress 用的 Standard 智力模型)会陷入重复循环,产出形如
|
||||
/// "现在更新项目信息:...让我更新项目描述和技术栈。" × N 的退化输出,被当作合法摘要
|
||||
/// 塞进 active system 消息,污染每一轮 prompt。此处检测后返空,调用方据空值报错降级
|
||||
/// 走关键词摘要兜底(extract_keyword_summary),不让退化输出污染上下文。
|
||||
fn clean_summary(raw: &str) -> String {
|
||||
let t = raw.trim();
|
||||
// 去掉可能的整体代码围栏(LLM 偶尔把整段包成 ```markdown ... ```)
|
||||
@@ -162,9 +169,66 @@ fn clean_summary(raw: &str) -> String {
|
||||
.unwrap_or(t)
|
||||
.trim_start_matches('\n');
|
||||
let t = t.strip_suffix("```").unwrap_or(t).trim();
|
||||
// 退化检测:若文本含同一片段重复 ≥5 次,判定为 LLM 退化输出,返空让调用方降级。
|
||||
// 阈值 5 容忍正常排比(如列表项),只拦死循环式拼接(实测 41 次远超阈值)。
|
||||
if is_degenerated_repetition(t) {
|
||||
tracing::warn!(
|
||||
len = t.len(),
|
||||
"[ai] 压缩摘要疑似 LLM 退化重复输出,丢弃降级走关键词兜底"
|
||||
);
|
||||
return String::new();
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
/// 检测 LLM 退化重复输出:文本中是否存在某一长片段(≥8 字符)重复 ≥5 次。
|
||||
///
|
||||
/// 实测样本:`"我已经了解了项目结构。" + "现在更新项目信息:...让我更新项目描述和技术栈。" × 41`
|
||||
/// 即「开头有引导句 + 后接同一片段重复 N 次」形态。
|
||||
///
|
||||
/// 检测策略:退化输出的重复片段在文本中点附近必然是周期性的,故取**文本中部**一段作候选,
|
||||
/// 检查它在全文出现次数。取中点而非前缀,是因为退化输出常带不同前缀引导句
|
||||
/// ("我已经了解了项目结构。"),前缀取样会漏检。
|
||||
///
|
||||
/// **UTF-8 安全**:中文字符占 3 字节,按字节切片会切断字符致 panic。改按 `char_indices`
|
||||
/// 取字符边界,确保片段始终在完整字符边界上。
|
||||
fn is_degenerated_repetition(text: &str) -> bool {
|
||||
if text.len() < 40 {
|
||||
return false; // 太短不可能退化
|
||||
}
|
||||
const MIN_FRAGMENT_CHARS: usize = 8; // 最少 8 个字符(非字节)
|
||||
const REPEAT_THRESHOLD: usize = 5;
|
||||
// 收集字符边界位置(每个字符起始字节偏移),用于安全切片
|
||||
let char_boundaries: Vec<usize> = std::iter::once(0)
|
||||
.chain(text.char_indices().map(|(i, _)| i))
|
||||
.collect();
|
||||
let char_count = char_boundaries.len() - 1;
|
||||
if char_count < MIN_FRAGMENT_CHARS * REPEAT_THRESHOLD {
|
||||
return false; // 字符太少不可能重复 5 次
|
||||
}
|
||||
// 从文本中点取样候选片段(退化输出中段必然是周期性重复)
|
||||
let mid_char = char_count / 2;
|
||||
// 候选片段长度(字符数):从 MIN_FRAGMENT_CHARS 到 40 逐步试
|
||||
let max_frag_chars = 40.min(char_count / REPEAT_THRESHOLD);
|
||||
for frag_chars in (MIN_FRAGMENT_CHARS..=max_frag_chars).rev() {
|
||||
// 取中点附近的 frag_chars 个字符作候选(按字符边界切片)
|
||||
let start_char = mid_char.saturating_sub(frag_chars / 2);
|
||||
let end_char = (start_char + frag_chars).min(char_count);
|
||||
let start_byte = char_boundaries[start_char];
|
||||
let end_byte = char_boundaries[end_char];
|
||||
let fragment = &text[start_byte..end_byte];
|
||||
if fragment.chars().count() < MIN_FRAGMENT_CHARS {
|
||||
continue;
|
||||
}
|
||||
// 用子串计数(不依赖正则,避免引入依赖)
|
||||
let count = text.matches(fragment).count();
|
||||
if count >= REPEAT_THRESHOLD {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -184,6 +248,38 @@ mod tests {
|
||||
assert_eq!(clean_summary(""), "");
|
||||
}
|
||||
|
||||
// ── BUG-2026-07-07/P0-1: LLM 退化重复输出检测 ──
|
||||
#[test]
|
||||
fn clean_summary_rejects_degenerated_repetition() {
|
||||
// 实测样本(9357c27c seq0):同一短语重复 41 次
|
||||
let fragment = "现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。";
|
||||
let degenerated = format!("我已经了解了项目结构。{}", fragment.repeat(41));
|
||||
// 退化输出应返空(调用方据空值报错降级走关键词兜底)
|
||||
assert_eq!(clean_summary(°enerated), "", "重复拼接的退化输出应被丢弃");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_summary_keeps_normal_summary_with_lists() {
|
||||
// 正常摘要(含列表项重复词)不应被误判为退化
|
||||
let normal = "## 意图\n- 整理文档\n## 决策\n- 使用方案A\n- 使用方案A\n- 使用方案A\n## 约束\n不超 100 字";
|
||||
assert_eq!(clean_summary(normal), normal, "正常摘要不应被误判");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_degenerated_repetition_detects_5x_repeat() {
|
||||
// 5 次重复阈值边界测试
|
||||
let fragment = "这是一个测试片段abcdef";
|
||||
assert!(is_degenerated_repetition(&fragment.repeat(5)));
|
||||
assert!(is_degenerated_repetition(&fragment.repeat(41)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_degenerated_repetition_ignores_short_text() {
|
||||
// 短文本不触发(即使重复)
|
||||
assert!(!is_degenerated_repetition("短文本短文本短文本短文本"));
|
||||
assert!(!is_degenerated_repetition("ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_prompt_returns_template_per_lang() {
|
||||
// 中文模板(默认)
|
||||
|
||||
Reference in New Issue
Block a user