新增: 上下文裁剪淘汰补偿摘要

超预算视图裁剪时,被淘汰文本提炼关键词概要插回发送视图头部,补偿无声丢弃的早期主题
摘要以 user 角色注入:system 角色会被兼容层折叠进带 cache_control 的顶层段,纯视图每轮再生成会持续改写缓存指纹
纯视图语义不落库,被淘汰原文存储态不变;压缩失败兜底摘要与淘汰摘要共用提炼核心
This commit is contained in:
lxy
2026-10-01 02:41:04 +08:00
parent 3e239a108f
commit 9b89610e38
3 changed files with 320 additions and 16 deletions
+139 -12
View File
@@ -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<String> {
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<String> {
/// 被淘汰内容合计低于此字符数则不值得产摘要(仅淘汰一两条寒暄无需补偿)。
const EVICTION_SUMMARY_MIN_CHARS: usize = 100;
const KEYWORD_TOP_N: usize = 10;
// 收集被淘汰文本:user/assistant 取全文,tool 取首行;合计字符数做"值得与否"闸门。
let feed: Vec<ChatMessage> = 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<ChatMessage> = (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]