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

超预算视图裁剪时,被淘汰文本提炼关键词概要插回发送视图头部,补偿无声丢弃的早期主题
摘要以 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, "裁剪污染了内存全量");
}