//! 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` — `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, lang: &str, conv_id: &str, llm_concurrency: &LlmConcurrency, ) -> Result { 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,压缩在 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 = 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(°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() { // 中文模板(默认) 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)); } }