Files
DevFlow/src-tauri/src/commands/ai/compress.rs
T

449 lines
20 KiB
Rust

//! F-15 上下文压缩 — LLM 摘要公共函数
//!
//! 阶段1 基础(本文件):仅暴露 `compress_via_llm` 函数,供阶段2 IPC
//! (`ai_chat_compress_context`)与阶段3 agentic loop 自动压缩共用。
//!
//! 模式严格对齐 [`super::title`] 的 `generate_title_via_llm`:非流式
//! `provider.complete()` + `LlmConcurrency` 双层 Semaphore 限流 + select_model_id
//! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`,
//! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。
use df_ai::context_helpers::CompressedSummary;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
use df_ai::router::{
is_likely_image_model, select_model_id, Modality, TaskRequirements,
};
use df_storage::models::AiProviderRecord;
use crate::state::LlmConcurrency;
use super::prompt::compress_prompt;
/// 调 LLM 非流式把 active 消息压缩为四段式摘要。
///
/// - 输入:`provider`(经 [`super::secret::build_provider_for`] 构造,含 keyring 解析)
/// + `provider_config`(取 `default_model` 兜底 + `model_configs` 路由池)
/// + `active_msgs`(待压缩的 active 消息,来自 [`ContextManager::compress_old_messages`]
/// 的返回或阶段2 IPC 取全段 active)
/// + `lang`("zh"/"en",与系统提示词语言一致,影响 `compress_prompt` 选模板)
/// - 输出:`Result<String, String>` — `Ok` 为 LLM 生成的摘要文本(已 trim);
/// `Err` 为 provider 调用失败(底层 `anyhow::Error` 转 String,调用方降级不阻塞)。
///
/// 模型选择:`select_model_id(TaskRequirements{无 tool_use})`,
/// 池空/无匹配兜底 `default_model`(与 `generate_title_via_llm` 一致)。
///
/// 限流:复用 `LlmConcurrency` 双层 Semaphore(压缩属独立 LLM 调用,纳入全局 + 单对话限流,
/// 不与主 stream_llm 抢资源失控)。
///
/// 注意:本函数只负责"喂 LLM 出摘要文本",不改 ContextManager 状态(标 compressed +
/// 插摘要 system 由调用方做,职责分离,便于单元测试与多调用点复用)。
//
// 调用方:agentic/mod.rs:807(agentic loop 自动压缩)+ commands/chat.rs:1102
// (ai_chat_compress_context IPC)。
pub(crate) async fn compress_via_llm(
provider: &dyn LlmProvider,
provider_config: &AiProviderRecord,
active_msgs: Vec<ChatMessage>,
lang: &str,
conv_id: &str,
llm_concurrency: &LlmConcurrency,
) -> Result<CompressedSummary, String> {
if active_msgs.is_empty() {
return Err("无可压缩消息(active 消息为空)".to_string());
}
// 阶段2(占位配对完整性):压缩段含未闭合占位(占位 tool_result 无对应 tool_call 头)→
// 跳过该三元组不压缩,返 Err 让调用方降级(保留原状)。理由:压缩 LLM 摘要可能破坏占位
// content/__PENDING__ 标记或拆散占位 result 与(已丢的)头的关系,使下游 sanitize 无法识别
// 占位 → orphan 触发 400。保守跳过:含未闭合占位的段不压缩(降级走 build_for_request 裁剪
// 或关键词兜底),占位配对完整性由 sanitize step3.5 + 出口断言兜住。
//
// 检测:占位 tool_result(content 带 __PENDING__ 标记)的 tool_call_id 不在任何 assistant
// 头 tool_calls 内 → 未闭合占位 → 返 Err 跳过压缩。
if compress_segment_has_unclosed_placeholder(&active_msgs) {
return Err("压缩段含未闭合占位 tool_result,跳过压缩保留原状(防破坏占位配对致 400 orphan)".to_string());
}
// MED-1214(CR): 治毒——active_msgs 过 sanitize_messages(畸形 tool 配对自愈),
// 防 compress 带毒(assistant tool_calls 缺 result / 孤儿 tool_result 无 tool_calls)触发 provider 400。
// 同 build_for_request 语义:仅发送视图剔毒,不改 ContextManager 持久化。
let active_count = active_msgs.len();
let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);
// 路由选模型:无 tool_use(纯文本摘要)。
// None(池空/无匹配)→ 兜底 default_model(行为不变)。
let compress_req = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: false,
estimated_context: 0,
tier: None,
};
let model = select_model_id(&compress_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone());
// 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。
if is_likely_image_model(&model, &provider_config.model_configs) {
return Err(format!(
"压缩模型 {} 是图像模型,文本任务不可用(检查 provider default_model 应为文本模型)",
model
));
}
// 四段式 system prompt + active 消息原文,喂 complete() 非流式出摘要。
let mut prompt = vec![ChatMessage::system(compress_prompt(lang))];
prompt.extend(active_msgs);
let request = CompletionRequest {
model,
messages: prompt,
temperature: Some(0.2),
max_tokens: Some(1024),
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
// LLM 并发限流(压缩属独立调用,纳入双层 Semaphore)
// F-09 B 批5: per_conv 改 HashMap<conv_id>,压缩在 loop 内针对本对话,用 conv_id 共享限流槽。
let _global_permit = llm_concurrency.acquire_global().await;
// compress-permit-try: per_conv 改非阻塞 try。loop 入口已持 1 个 per_conv permit,
// set_per_conv(1) 后 per_conv Semaphore 仅 1 permit 被入口占满,此处阻塞 acquire 将永久等待
// → is_compressing 永真 → 卡死。改 try_acquire_per_conv:拿不到(None)提前返 Err 降级
// (maybe_auto_compress 走失败兜底分支:关键词摘要 + set_compressing(false),下轮再试),非崩溃。
let _per_conv_permit = match llm_concurrency.try_acquire_per_conv(conv_id).await {
Some(p) => p,
None => {
tracing::warn!(
conv_id = %conv_id,
active_count,
"[ai] 压缩 per_conv permit 已耗尽(loop 入口占用),降级返回走兜底分支,下轮再试"
);
return Err(format!(
"压缩 per_conv 并发限流槽已耗尽(active {} 条,loop 入口占用;降级走兜底,下轮再试)",
active_count
));
}
};
// F-15 卡死根治(2026-06-21 诊断):provider.complete 原无超时,LLM hang → agentic loop
// (agentic/mod.rs:780)await 永不返 → run_agentic_loop 永挂 → generating 卡死
// (set_compressing(true) 已置 agentic/mod.rs:753,false 永不到达)。
// 加 60s timeout 兜底(对齐 ai_approve 超时):超时返 Err → 调用方 Err 分支
// (agentic/mod.rs:818-830)set_compressing(false) 复位 + 关键词摘要降级,loop 正常继续不卡死。
let resp = tokio::time::timeout(
std::time::Duration::from_secs(60),
provider.complete(request),
)
.await
.map_err(|_| format!("LLM 压缩调用超时(60s,active {} 条,可能 provider hang;降级关键词裁剪)", active_count))?
.map_err(|e| format!("LLM 压缩调用失败: {} (active {} 条,已 sanitize 治毒;若仍失败查 provider 原文 tool 配对)", e, active_count))?;
let summary = clean_summary(&resp.text);
if summary.is_empty() {
return Err("LLM 压缩返回空摘要".to_string());
}
// T3: 解析四段式输出为 JSON 卡片 + NL 摘要
// 从现有 ## Intent / ## Decisions / ## Files / ## Constraints 格式解析
let json_card = parse_sections_to_json(&summary);
Ok(CompressedSummary {
json_card,
nl_summary: summary,
})
}
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
///
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
/// → 未闭合占位。压缩此类段会破坏占位配对(摘要改写 content / 拆散关系),使下游 sanitize
/// 无法识别占位 → orphan 触发 400。故调用方检测到未闭合占位即跳过压缩(返 Err 降级)。
///
/// 检测逻辑:收集段内所有 assistant 头的 tool_call.id;占位 tool_result 的 id 不在内 → 未闭合。
/// 已闭合占位(头在)可正常压缩(配对完整,摘要不影响头关系)。
fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool {
use std::collections::HashSet;
let head_ids: HashSet<&str> = msgs
.iter()
.filter(|m| matches!(m.role, df_ai::provider::MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter_map(|c| {
// 排除 TOOL_MISSING_PREFIX 占位头(非真实头,由出口断言自愈补的临时闭合头)
if c.id.starts_with(df_ai::context_helpers::TOOL_MISSING_PREFIX) {
None
} else {
Some(c.id.as_str())
}
})
.collect();
msgs.iter().any(|m| {
matches!(m.role, df_ai::provider::MessageRole::Tool)
&& df_ai::context_helpers::is_pending_placeholder(&m.content)
&& m.tool_call_id
.as_deref()
.is_some_and(|id| !head_ids.contains(id))
})
}
/// 从四段式 Markdown 摘要中解析出 JSON 卡片
/// 段标题: ## Intent / ## Decisions / ## Files / ## Constraints
fn parse_sections_to_json(summary: &str) -> String {
let mut topics = Vec::new();
let mut decisions = Vec::new();
let mut key_files = Vec::new();
let mut current_section: Option<&str> = None;
for line in summary.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## ") {
current_section = trimmed.strip_prefix("## ");
continue;
}
let text = trimmed.trim_start_matches("- ").trim();
if text.is_empty() {
continue;
}
match current_section {
Some("Intent") | Some("意图") => {
topics.push(text.to_string());
}
Some("Decisions") | Some("决策") => {
decisions.push(serde_json::json!({"summary": text}));
}
Some("Files") | Some("文件") => {
key_files.push(serde_json::json!({"change": text}));
}
_ => {}
}
}
serde_json::json!({
"topics": topics,
"decisions": decisions,
"key_files": key_files,
"parsed_from": "four_section_summary",
}).to_string()
}
/// 在文本中提取标记间的内容
#[allow(dead_code)]
fn extract_between<'a>(text: &'a str, start_marker: &str, end_marker: &str) -> Option<&'a str> {
let start = text.find(start_marker)?;
let after_start = &text[start + start_marker.len()..];
let end = after_start.find(end_marker)?;
Some(after_start[..end].trim())
}
/// 清理 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 ... ```)
let t = t
.strip_prefix("```markdown")
.or_else(|| t.strip_prefix("```"))
.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::*;
#[test]
fn clean_summary_trims_and_strips_fences() {
assert_eq!(clean_summary(" hello "), "hello");
assert_eq!(clean_summary("```markdown\n## 意图\nfoo\n```"), "## 意图\nfoo");
assert_eq!(clean_summary("```\n## 决策\nbar\n```"), "## 决策\nbar");
// 保留内部 markdown 结构(摘要本就是结构化)
assert_eq!(clean_summary("## 意图\n- a\n- b"), "## 意图\n- a\n- b");
}
#[test]
fn clean_summary_empty_stays_empty() {
assert_eq!(clean_summary(" "), "");
assert_eq!(clean_summary(""), "");
}
// ── BUG-2026-07-07/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() {
// 中文模板(默认)
let zh = compress_prompt("zh");
assert!(zh.contains("意图"));
assert!(zh.contains("决策"));
assert!(zh.contains("文件"));
assert!(zh.contains("约束"));
// 英文模板
let en = compress_prompt("en");
assert!(en.contains("Intent"));
assert!(en.contains("Decisions"));
assert!(en.contains("Files"));
assert!(en.contains("Constraints"));
// 未知语言降级中文
assert_eq!(compress_prompt("fr"), compress_prompt("zh"));
}
// ── 阶段2 占位配对完整性:compress_segment_has_unclosed_placeholder ──
#[test]
fn unclosed_placeholder_detected_when_no_head() {
// 占位 tool_result(带标记)无对应 tool_call 头 → 未闭合 → 应跳过压缩
let msgs = vec![
df_ai::provider::ChatMessage::user("问题"),
df_ai::provider::ChatMessage::tool_result(
"call_pending",
"需要用户审批,等待确认__PENDING__:call_pending",
),
];
assert!(
compress_segment_has_unclosed_placeholder(&msgs),
"无头的占位 tool_result 应判为未闭合占位"
);
}
#[test]
fn closed_placeholder_not_flagged() {
// 占位 tool_result 有对应 tool_call 头 → 已闭合 → 不跳过(可正常压缩)
let msgs = vec![
df_ai::provider::ChatMessage::user("问题"),
df_ai::provider::ChatMessage::assistant_with_tools(
"调",
vec![df_ai::provider::ToolCall::new(
"call_pending",
"write_file",
"{}",
)],
),
df_ai::provider::ChatMessage::tool_result(
"call_pending",
"需要用户审批,等待确认__PENDING__:call_pending",
),
];
assert!(
!compress_segment_has_unclosed_placeholder(&msgs),
"有头的占位 tool_result 是已闭合,不应跳过压缩"
);
}
#[test]
fn non_placeholder_orphan_not_flagged() {
// 普通 tool_result(非占位)无头 → 不算"未闭合占位"(普通 orphan 由 sanitize 处理,
// 不触发压缩跳过;占位保护语义专属于审批挂起占位)
let msgs = vec![
df_ai::provider::ChatMessage::user("问题"),
df_ai::provider::ChatMessage::tool_result("orphan_id", "普通结果"),
];
assert!(
!compress_segment_has_unclosed_placeholder(&msgs),
"非占位 orphan 不应触发压缩跳过(仅占位专享保护)"
);
}
#[test]
fn no_tool_messages_not_flagged() {
// 纯文本段(无 tool_result)→ 永不触发跳过
let msgs = vec![
df_ai::provider::ChatMessage::user("问题1"),
df_ai::provider::ChatMessage::assistant("回答1"),
df_ai::provider::ChatMessage::user("问题2"),
];
assert!(!compress_segment_has_unclosed_placeholder(&msgs));
}
}