diff --git a/crates/df-ai/src/context/manager_tests.rs b/crates/df-ai/src/context/manager_tests.rs index 292e958..617aa6b 100644 --- a/crates/df-ai/src/context/manager_tests.rs +++ b/crates/df-ai/src/context/manager_tests.rs @@ -7,7 +7,9 @@ #![cfg(test)] use super::*; -use crate::context_helpers::{ContextConfig, TokenEstimator, PROTECT_COUNT}; +use crate::context_helpers::{ + ContextConfig, EVICTION_SUMMARY_MAX_CHARS, TokenEstimator, PROTECT_COUNT, +}; use crate::provider::ToolCall; fn cfg(max_tokens: u32) -> ContextConfig { @@ -994,3 +996,160 @@ fn phase1_append_after_dirty_stays_dirty_until_save() { mgr.advance_persisted_count(2); assert!(!mgr.needs_full_rewrite()); } + +// ============================================================ +// 视图裁剪补偿摘要(淘汰区头部 user 概要,防无声丢失) +// ============================================================ + +#[test] +fn eviction_inserts_summary_message_at_view_head() { + // 触发淘汰 → 视图头部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题), + // 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。 + let mut mgr = ContextManager::new(cfg(200)); + for i in 0..20 { + if i % 2 == 0 { + mgr.push(ChatMessage::user(&format!( + "审批流程与审批门禁需要适配 第{}条消息内容足够长确保淘汰文本合计超过一百字符", i + ))); + } else { + mgr.push(ChatMessage::assistant(&format!( + "第{}条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑", i + ))); + } + } + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(trimmed, "超预算应触发裁剪"); + + // 视图头部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题) + let head = msgs.first().expect("视图不应为空"); + assert!( + matches!(head.role, MessageRole::User), + "视图头部应为 user 摘要, 实际 {:?}", + head.role + ); + assert!( + head.content.starts_with("[早期对话概要] 高频主题: "), + "摘要格式不符, 实际 {}", + head.content + ); + assert!( + head.content.contains("审批"), + "摘要应含被淘汰区高频主题词'审批', 实际 {}", + head.content + ); + assert!( + head.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS, + "摘要应 ≤ {} 字符, 实际 {}", + EVICTION_SUMMARY_MAX_CHARS, + head.content.chars().count() + ); + + // 保护消息完好:末条(i=19 assistant)保留;摘要 user 与首轮真实 user 相邻,由 + // sanitize 的 ensure_sequence_legal 合并(摘要在前,换行接原文),故 msgs[1] 为 i=1 的 assistant + assert_eq!( + msgs.last().unwrap().content, + "第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑", + "保护区最新消息被误裁" + ); + assert!( + matches!(msgs[1].role, MessageRole::Assistant), + "摘要与首轮 user 合并后,第二条应为 assistant, 实际 {:?}", + msgs[1].role + ); + assert!( + msgs[0].content.contains("审批流程与审批门禁需要适配"), + "合并不应丢失被保留的首轮真实 user 文本, 实际 {}", + msgs[0].content + ); + + // 纯视图:摘要不进内存全量,被淘汰原文存储态不变 + assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量"); + assert!( + mgr.all_messages_clone() + .iter() + .all(|m| !m.content.starts_with("[早期对话概要]")), + "补偿摘要不应写入存储(纯发送视图)" + ); +} + +fn role_tag(m: &ChatMessage) -> &'static str { + match m.role { + MessageRole::System => "system", + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::Tool => "tool", + } +} + +#[test] +fn no_eviction_output_unchanged_no_summary() { + // 预算内 → 输出与改动前一致:消息序列逐条原样,无补偿摘要插入(回归护栏)。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("第一条问题")); + mgr.push(ChatMessage::assistant("第一条回复")); + mgr.push(ChatMessage::user("第二条问题")); + mgr.push(ChatMessage::assistant("第二条回复")); + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(!trimmed); + assert_eq!(msgs.len(), 4); + let pair: Vec<(&str, &str)> = msgs + .iter() + .map(|m| (role_tag(m), m.content.as_str())) + .collect(); + assert_eq!( + pair, + vec![ + ("user", "第一条问题"), + ("assistant", "第一条回复"), + ("user", "第二条问题"), + ("assistant", "第二条回复"), + ], + "预算内输出应与历史序列逐条一致" + ); +} + +#[test] +fn eviction_tiny_text_no_summary() { + // 被淘汰内容极短(单字消息,合计 <100 字符)→ 不值得产摘要,不插补偿摘要消息。 + // 20 条消息(约 5 token/条),cfg(40) 触发裁剪,淘汰前缀合计仅二十余字符。 + let mut mgr = ContextManager::new(cfg(40)); + for i in 0..20 { + if i % 2 == 0 { + mgr.push(ChatMessage::user(&format!("问{}", i))); + } else { + mgr.push(ChatMessage::assistant(&format!("答{}", i))); + } + } + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(trimmed, "超预算应触发裁剪"); + assert!( + msgs.iter() + .all(|m| !m.content.starts_with("[早期对话概要]")), + "极短淘汰文本不应产生补偿摘要, 实际 {:?}", + msgs.iter().map(|m| format!("{:?}", m.role)).collect::>() + ); + assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量"); +} + +#[test] +fn eviction_no_user_text_no_summary() { + // 被淘汰区无任何 user 消息(仅 assistant 长文本)→ 提炼核心只认 user → 无关键词 → 不插摘要。 + // 布局:10 条 assistant 长文本(淘汰候选)+ 6 条短 user(保护区),预算卡在淘汰 4 条 assistant。 + let mut mgr = ContextManager::new(cfg(150)); + for i in 0..10 { + mgr.push(ChatMessage::assistant(&format!( + "这是一条没有主题价值的 assistant 长文本第{}条,用于撑爆预算但提炼不出关键词", i + ))); + } + for i in 0..6 { + mgr.push(ChatMessage::user(&format!("问{}", i))); + } + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(trimmed, "超预算应触发裁剪"); + assert!( + msgs.iter() + .all(|m| !m.content.starts_with("[早期对话概要]")), + "无可提炼关键词(无 user)时不应插补偿摘要" + ); + assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量"); +} diff --git a/crates/df-ai/src/context/mod.rs b/crates/df-ai/src/context/mod.rs index 86bc4bb..c0fadc4 100644 --- a/crates/df-ai/src/context/mod.rs +++ b/crates/df-ai/src/context/mod.rs @@ -22,7 +22,7 @@ mod sanitize; use crate::context_helpers::{ - classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT, + build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT, }; // 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等 // 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。 @@ -179,7 +179,8 @@ impl ContextManager { /// 构建发送给 LLM 的消息列表 /// /// `sys_tokens` 为调用方已估算好的 system prompt token 数。 - /// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。 + /// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条),并在 + /// 被淘汰区头部插入关键词补偿摘要(role=system,纯视图易变区,不落库不改存储)。 /// 返回 (消息列表, 是否发生了裁剪)。 pub fn build_for_request(&self, sys_tokens: u32) -> (Vec, bool) { let budget = self.budget_limit(); @@ -235,10 +236,27 @@ impl ContextManager { ); } - let msgs: Vec = self.messages[trim_end..] + let evicted: Vec = self.messages[..trim_end] .iter() .map(|t| t.message.clone()) .collect(); + let mut msgs: Vec = self.messages[trim_end..] + .iter() + .map(|t| t.message.clone()) + .collect(); + + // 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩 + // 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送 + // 视图最前部(淘汰区头部、保护消息之前)。角色必须 user:role=system 会被 + // anthropic_compat 折叠进带 cache_control 的顶层 system 段,而本摘要纯视图 + // 每轮即时再生成(淘汰区单调增长致内容逐轮漂移),落 system 即逐轮改写缓存 + // 指纹使全部前缀缓存失效;连续 role 由既有 sanitize/协议层自愈兜底。摘要只进 + // messages 易变区,严禁拼进 system_prompt/任何稳定段;纯视图语义:不落库、 + // 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。 + // 极短/空淘汰文本提炼为空 → 跳过(不值得插)。 + if let Some(text) = build_eviction_summary_text(&evicted) { + msgs.insert(0, ChatMessage::user(text)); + } tracing::info!( "context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)", diff --git a/crates/df-ai/src/context_helpers.rs b/crates/df-ai/src/context_helpers.rs index e6f3b67..c8a23ad 100644 --- a/crates/df-ai/src/context_helpers.rs +++ b/crates/df-ai/src/context_helpers.rs @@ -185,20 +185,15 @@ pub fn classify_group(msg: &ChatMessage) -> MessageGroup { // 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM) // ============================================================ -/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。 -/// -/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心 -/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要 -/// 塞回首条,作为续接锚点。 +/// 从消息提取高频关键词(提炼核心,纯函数无 LLM)— 关键词摘要/淘汰补偿摘要共用。 /// /// 策略(纯启发式,无 tokenizer 依赖): /// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。 /// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。 /// - 去停用词(常见虚词/助词,中英混合)。 /// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。 -/// - 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。 -pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String { - const TOP_N: usize = 10; +/// - 空消息/无可提取词 → 空 Vec,调用方判空跳过。 +pub fn extract_top_keywords(msgs: &[ChatMessage], top_n: usize) -> Vec { const MIN_WORD_LEN: usize = 2; // 中英混合停用词表(高频虚词/助词/常见无主题意义词)。 @@ -239,7 +234,7 @@ pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String { } if freq.is_empty() { - return String::new(); + return Vec::new(); } let mut entries: Vec<(String, u32)> = freq.into_iter().collect(); @@ -250,17 +245,82 @@ pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String { .then_with(|| a.0.cmp(&b.0)) }); - let keywords: Vec<&str> = entries.iter().take(TOP_N).map(|(w, _)| w.as_str()).collect(); + entries + .into_iter() + .take(top_n) + .map(|(w, _)| w) + .collect() +} + +/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。 +/// +/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心 +/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要 +/// 塞回首条,作为续接锚点。提炼核心见 [`extract_top_keywords`]。 +/// 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。 +pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String { + const TOP_N: usize = 10; + let keywords = extract_top_keywords(msgs, TOP_N); if keywords.is_empty() { return String::new(); } - format!( "[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}", keywords.join("、") ) } +/// 视图裁剪淘汰补偿摘要 — 从被淘汰消息提炼一短段主题概要(纯函数)。 +/// +/// 背景:build_for_request 超预算按淘汰单元裁剪发送视图时,被淘汰内容从此对 LLM 不可见 +/// 且无任何补偿,与压缩路径(有摘要补偿)行为不一致。本函数把被淘汰文本提炼成 +/// 「[早期对话概要] 高频主题: …」一短段,调用方将其作为 role=user 消息插回发送视图 +/// 被淘汰区头部(role=system 会被 anthropic_compat 折叠进带 cache_control 的顶层 +/// system 段,而本摘要每轮再生成,会持续改写缓存指纹)。 +/// +/// **缓存铁律**:产出只进 messages 列表(易变区),严禁拼进 system_prompt 或任何 +/// 稳定段/缓存指纹相关结构。build_for_request 为纯视图:每次请求即时生成,不落库、 +/// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。 +/// +/// 策略: +/// - user/assistant 取全文,tool 结果仅取首行(大段 JSON/日志无主题价值); +/// - 被淘汰文本合计过短(< 100 字符)不值得产摘要 → None; +/// - 经 [`extract_top_keywords`] 提炼,空/无有效文本 → None; +/// - 硬截断至 [`EVICTION_SUMMARY_MAX_CHARS`] 字符(按 char 边界,防汉字切半)。 +pub fn build_eviction_summary_text(msgs: &[ChatMessage]) -> Option { + /// 被淘汰内容合计低于此字符数则不值得产摘要(仅淘汰一两条寒暄无需补偿)。 + const EVICTION_SUMMARY_MIN_CHARS: usize = 100; + const KEYWORD_TOP_N: usize = 10; + + // 收集被淘汰文本:user/assistant 取全文,tool 取首行;合计字符数做"值得与否"闸门。 + let feed: Vec = msgs + .iter() + .map(|m| { + let mut m = m.clone(); + if matches!(m.role, MessageRole::Tool) { + m.content = m.content.lines().next().unwrap_or("").to_string(); + } + m + }) + .collect(); + let total_chars: usize = feed.iter().map(|m| m.content.chars().count()).sum(); + if total_chars < EVICTION_SUMMARY_MIN_CHARS { + return None; + } + + let keywords = extract_top_keywords(&feed, KEYWORD_TOP_N); + if keywords.is_empty() { + return None; + } + + let mut text = format!("[早期对话概要] 高频主题: {}", keywords.join("、")); + // 硬上限截断(按 char 计数,多字节汉字不会切出非法边界)。 + if text.chars().count() > EVICTION_SUMMARY_MAX_CHARS { + text = text.chars().take(EVICTION_SUMMARY_MAX_CHARS).collect(); + } + Some(text) +} + /// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。 /// /// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request); @@ -553,6 +613,13 @@ pub struct EvictionUnit { /// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次) pub const PROTECT_COUNT: usize = 6; +/// 淘汰补偿摘要的硬上限(字符数)。 +/// +/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图 +/// 头部(补偿无声丢弃的早期主题,对齐压缩路径的"裁剪有补偿"语义)。摘要过长会吃掉 +/// 本次裁剪腾出的预算,故硬截断到此上限(≈百 token 量级,相对淘汰收益可忽略)。 +pub const EVICTION_SUMMARY_MAX_CHARS: usize = 300; + /// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。 /// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。 pub const TOOL_MISSING_PREFIX: &str = "tool_missing_"; @@ -805,7 +872,7 @@ impl WorkingContext { self.version.goals_changed_at = self.version.current_turn; } - /// 压缩后重锚定:历史消息已被摘要替换,从 T3 JSON 卡片(字段 topics/decisions/key_files, + /// 压缩后重锚定:历史消息已被摘要替换,从压缩摘要 JSON 卡片(字段 topics/decisions/key_files, /// decisions 元素形如 {"summary": "..."})恢复决策至上限 5 条(每条 decision 文本截 120 字符, /// question/reason 留空);随后调 version.reset_all(json_card, 自增后的 current_turn)。 /// Active 目标保留不丢(压缩不应丢目标)。current_turn 自增 1。 @@ -900,6 +967,66 @@ mod tests { // token 频次 1 也应入榜(去停用词后剩余的有意义词)。 } + // ── 视图裁剪淘汰补偿摘要纯函数(build_eviction_summary_text)测试 ── + + #[test] + fn eviction_summary_short_text_returns_none() { + // 合计 < 100 字符 → 不值得产摘要。 + let msgs = vec![ChatMessage::user("短文本"), ChatMessage::assistant("另一条")]; + assert_eq!(build_eviction_summary_text(&msgs), None); + } + + #[test] + fn eviction_summary_no_user_returns_none() { + // 无 user 消息 → 提炼核心不认 assistant → 无关键词 → None(即使合计超 100 字符)。 + let long = "这是一段没有用户主题的 assistant 长文本,重复三次以超过一百字符的最短闸门验证。".repeat(3); + assert!(long.chars().count() > 100); + let msgs = vec![ChatMessage::assistant(&long), ChatMessage::assistant(&long)]; + assert_eq!(build_eviction_summary_text(&msgs), None); + } + + #[test] + fn eviction_summary_formats_prefix_and_keyword() { + // 合计须过 100 字符闸门:user 文本重复 10 次(~70 字符)+ assistant(~37 字符)。 + let user_text = "审批流程配置 ".repeat(10); + let msgs = vec![ + ChatMessage::user(user_text.trim()), + ChatMessage::assistant("好的,已了解审批流程诉求,这里是一段足够长的回复用于通过最短字符闸门检查。"), + ]; + let text = build_eviction_summary_text(&msgs).expect("应产出摘要"); + assert!(text.starts_with("[早期对话概要] 高频主题: "), "实际 {}", text); + assert!(text.contains("审批"), "应含高频词, 实际 {}", text); + } + + #[test] + fn eviction_summary_tool_result_takes_first_line() { + // tool 结果取首行:多行 JSON 不整体进提炼,首行计入闸门字符数,摘要仍可产出。 + // user 文本重复 3 次(~110 字符)+ tool 首行(~24 字符)合计过 100 闸门。 + let tool = ChatMessage::tool_result("tc1", "{\"result\":\"第一行头部\"}\n第二行\n第三行"); + let user_text = "预算闸门验证消息,这条 user 消息携带足够字符数确保超过下限触发提炼。".repeat(3); + let text = build_eviction_summary_text(&[ChatMessage::user(&user_text), tool]) + .expect("应产出摘要"); + assert!(text.starts_with("[早期对话概要] 高频主题: ")); + } + + #[test] + fn eviction_summary_hard_capped_at_max_chars() { + // 12 条纯标识符消息(每个标识符 39 字符,freq 均等且无中文干扰)→ top-10 全为长词, + // 总长超 300 → 硬截断到 EVICTION_SUMMARY_MAX_CHARS。 + let msgs: Vec = (0..12) + .map(|i| { + ChatMessage::user(&format!("long_identifier_{:02}_for_truncation_probe", i)) + }) + .collect(); + let text = build_eviction_summary_text(&msgs).expect("应产出摘要"); + assert_eq!( + text.chars().count(), + EVICTION_SUMMARY_MAX_CHARS, + "超长时应精确截断到硬上限, 实际 {}", + text.chars().count() + ); + } + // ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ── #[test]