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

超预算视图裁剪时,被淘汰文本提炼关键词概要插回发送视图头部,补偿无声丢弃的早期主题
摘要以 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
+160 -1
View File
@@ -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::<Vec<_>>()
);
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, "裁剪污染了内存全量");
}
+21 -3
View File
@@ -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<ChatMessage>, bool) {
let budget = self.budget_limit();
@@ -235,10 +236,27 @@ impl ContextManager {
);
}
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
let evicted: Vec<ChatMessage> = self.messages[..trim_end]
.iter()
.map(|t| t.message.clone())
.collect();
let mut msgs: Vec<ChatMessage> = 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)",
+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]