修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离

- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条)
- queue 加 conversationId 字段,按会话精准 drain
- regenerate/editMessage 只清本会话排队消息
- newConversation 保留旧会话排队消息
- AiError 只清出错会话的队列项
This commit is contained in:
lxy
2026-07-20 00:19:50 +08:00
parent 42efb31bbf
commit e9e3578d26
59 changed files with 2875 additions and 1330 deletions
+96
View File
@@ -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(&degenerated), "", "重复拼接的退化输出应被丢弃");
}
#[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() {
// 中文模板(默认)