优化: 上下文压缩缓存友好化与召回句柄
- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退 - 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配 - BP3 断点机制+缓存命中率观测日志,调参有据 - 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费 - 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗 - namespace 引用跨重启内容哈希懒回填
This commit is contained in:
@@ -44,10 +44,10 @@ fn estimate_message_counts_parts_tokens() {
|
||||
mm_tokens,
|
||||
text_tokens
|
||||
);
|
||||
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
|
||||
// base64 改按图地板计(~1600,与 provider 端按图计费同口径),不随字符串长度膨胀
|
||||
assert!(
|
||||
mm_tokens > 500,
|
||||
"大 base64 应贡献可观 token,实际 {}",
|
||||
mm_tokens >= 1600,
|
||||
"大 base64 应按图地板计 token,实际 {}",
|
||||
mm_tokens
|
||||
);
|
||||
|
||||
@@ -72,8 +72,10 @@ fn short_history_no_trim() {
|
||||
|
||||
#[test]
|
||||
fn over_budget_trims_old() {
|
||||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
|
||||
let mut mgr = ContextManager::new(cfg(200));
|
||||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区。
|
||||
// 预算取值须容纳「补偿摘要+保护区 6 条」(中文按 1.2 token/字计约 230)再留裁剪空间,
|
||||
// 否则保护区被挤压、最新消息也被压,测的就不再是「裁旧保新」语义。
|
||||
let mut mgr = ContextManager::new(cfg(400));
|
||||
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
||||
for i in 0..20 {
|
||||
if i % 2 == 0 {
|
||||
@@ -1234,3 +1236,244 @@ fn push_system_anchor_avoids_dangling_tool_call_head() {
|
||||
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 缓存友好上下文(W1 块状裁剪 + W2 system 锚点 view 层转 user)
|
||||
// ============================================================
|
||||
|
||||
/// 推入一个完整工具轮 [user 提问, assistant 带 tool_call, tool_result, assistant 小结]。
|
||||
/// 淘汰单元随之分为「user 独立单元 + 三元组单元」,轮起点(user)是轮吸附锚点。
|
||||
fn push_tool_round(mgr: &mut ContextManager, tag: &str, filler: &str) {
|
||||
mgr.push(ChatMessage::user(&format!("问题{}{}", tag, filler)));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"",
|
||||
vec![ToolCall::new(format!("tc{}", tag), "read_file", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result(format!("tc{}", tag), "结果"));
|
||||
mgr.push(ChatMessage::assistant(&format!("小结{}", tag)));
|
||||
}
|
||||
|
||||
/// 视图中真实历史的 token 量:排除尾部追加的淘汰补偿摘要(它不计入裁剪水位)。
|
||||
fn view_history_tokens(msgs: &[ChatMessage]) -> u64 {
|
||||
let est = TokenEstimator::default();
|
||||
msgs.iter()
|
||||
.filter(|m| !m.content.starts_with("[早期对话概要]"))
|
||||
.map(|m| est.estimate_message(m) as u64)
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_trim_lands_below_target_water_level() {
|
||||
// W1:开关开后超预算裁剪一次裁到 T×available 水位以下(贪心只裁到刚好塞下 available)。
|
||||
let mut mgr = ContextManager::new(cfg(1000));
|
||||
for i in 0..40 {
|
||||
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
|
||||
if i % 2 == 0 {
|
||||
mgr.push(ChatMessage::user(&text));
|
||||
} else {
|
||||
mgr.push(ChatMessage::assistant(&text));
|
||||
}
|
||||
}
|
||||
assert!(mgr.history_tokens() > 1000, "前置:历史须超预算");
|
||||
mgr.set_cache_friendly(true);
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed, "超预算应触发裁剪");
|
||||
|
||||
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
|
||||
let remaining = view_history_tokens(&msgs);
|
||||
assert!(
|
||||
remaining <= target,
|
||||
"裁后水位应落在目标 {} 以下, 实际 {}",
|
||||
target,
|
||||
remaining
|
||||
);
|
||||
// 保护区最新消息不被动(摘要追加在尾部,末条真实消息是其前一条)
|
||||
let last_real = msgs
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| !m.content.starts_with("[早期对话概要]"))
|
||||
.expect("视图非空");
|
||||
assert!(
|
||||
last_real.content.contains("第39条"),
|
||||
"保护区最新消息被误裁, 实际末条: {}",
|
||||
last_real.content
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_trim_snaps_boundary_to_turn_start() {
|
||||
// W1 轮边界吸附:水位在某单元边界达标但下一条不是 user 时继续扫,直到边界吸附轮起点
|
||||
// (整问整答同生同死)。3 个工具轮 + 2 条过渡 + 6 条保护区,len=20 且 protect_start=14
|
||||
// 恰为保护区起点;按纯水位会在过渡轮中间收手(首条为 assistant),吸附后首条必是 user。
|
||||
let mut mgr = ContextManager::new(cfg(300));
|
||||
let filler = "这一轮的体量用来把历史顶过预算线撑出裁剪空间";
|
||||
for r in 0..3 {
|
||||
push_tool_round(&mut mgr, &r.to_string(), filler);
|
||||
}
|
||||
mgr.push(ChatMessage::user(&format!("过渡问题A{}", filler)));
|
||||
mgr.push(ChatMessage::assistant(&format!("过渡回复B{}", filler)));
|
||||
for i in 0..6 {
|
||||
let text = format!("保护区{}需要足够长的文本来撑量", i);
|
||||
if i % 2 == 0 {
|
||||
mgr.push(ChatMessage::user(&text));
|
||||
} else {
|
||||
mgr.push(ChatMessage::assistant(&text));
|
||||
}
|
||||
}
|
||||
assert!(mgr.history_tokens() > 300, "前置:历史须超预算");
|
||||
mgr.set_cache_friendly(true);
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed, "超预算应触发裁剪");
|
||||
|
||||
let first = msgs.first().expect("视图非空");
|
||||
assert!(
|
||||
matches!(first.role, MessageRole::User),
|
||||
"裁剪边界应吸附轮起点(下一条未淘汰消息是 user), 实际首条 role={:?}",
|
||||
first.role
|
||||
);
|
||||
let target = (300u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
|
||||
assert!(
|
||||
view_history_tokens(&msgs) <= target,
|
||||
"吸附同时仍应满足目标水位"
|
||||
);
|
||||
assert_eq!(
|
||||
has_head(&msgs),
|
||||
has_tail(&msgs),
|
||||
"轮吸附不得劈开工具三元组"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_trim_pathological_falls_back_to_hard_budget() {
|
||||
// W1 病态回退:单轮体量 > 块,扫到保护区仍无「水位+轮边界」齐备的边界(保护区首条
|
||||
// 是 system 锚点,堵死末轮三元组边界的轮吸附)→ 回退取满足硬预算的最后单元边界,
|
||||
// 硬预算安全底线不破;且回退比贪心裁得更深(贪心在首个硬预算边界即收手)。
|
||||
let mut mgr = ContextManager::new(cfg(400));
|
||||
let filler = "这一轮的提问体量远超块大小用来制造病态回退场景".repeat(10);
|
||||
for r in 0..3 {
|
||||
push_tool_round(&mut mgr, &r.to_string(), &filler);
|
||||
}
|
||||
// 保护区首条 system 锚点:末轮三元组边界 next 非 user,轮吸附不可达
|
||||
mgr.push(ChatMessage::system("[早期锚点]"));
|
||||
for i in 0..5 {
|
||||
let text = format!("保护区{}需要足够长的文本来撑量", i);
|
||||
if i % 2 == 0 {
|
||||
mgr.push(ChatMessage::user(&text));
|
||||
} else {
|
||||
mgr.push(ChatMessage::assistant(&text));
|
||||
}
|
||||
}
|
||||
assert!(mgr.history_tokens() > 400, "前置:历史须超预算");
|
||||
mgr.set_cache_friendly(true);
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed, "病态回退仍应触发裁剪");
|
||||
|
||||
let remaining = view_history_tokens(&msgs);
|
||||
assert!(
|
||||
remaining <= 400,
|
||||
"硬预算安全底线:裁后不得超过 available, 实际 {}",
|
||||
remaining
|
||||
);
|
||||
let first = msgs.first().expect("视图非空");
|
||||
assert!(
|
||||
first.content.contains("[早期锚点]"),
|
||||
"回退应取满足硬预算的最后单元边界(裁到保护区起点), 实际首条: {}",
|
||||
first.content
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_friendly_off_keeps_greedy_min_trim() {
|
||||
// W1 开关关:走原贪心最小裁剪,裁到刚好塞下硬预算即停(高于目标水位),逐字节等价旧路径。
|
||||
let mut mgr = ContextManager::new(cfg(1000));
|
||||
for i in 0..40 {
|
||||
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
|
||||
if i % 2 == 0 {
|
||||
mgr.push(ChatMessage::user(&text));
|
||||
} else {
|
||||
mgr.push(ChatMessage::assistant(&text));
|
||||
}
|
||||
}
|
||||
// 不注入(字段默认 false)= 开关关
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed, "超预算应触发裁剪");
|
||||
let remaining = view_history_tokens(&msgs);
|
||||
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
|
||||
assert!(
|
||||
remaining <= 1000,
|
||||
"贪心裁后应塞下硬预算, 实际 {}",
|
||||
remaining
|
||||
);
|
||||
assert!(
|
||||
remaining > target,
|
||||
"贪心只裁到刚好塞下(高于目标水位 {}), 实际 {}",
|
||||
target,
|
||||
remaining
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_anchor_view_becomes_user_when_cache_friendly_on() {
|
||||
// W2:开关开,存储态 system 锚点在发送视图转 user;存储不动;最终视图(组装层
|
||||
// system_prompt 在下标 0,不经 build_for_request)中 system 独占首条。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问"));
|
||||
mgr.push(ChatMessage::assistant("答"));
|
||||
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
|
||||
mgr.set_cache_friendly(true);
|
||||
let (msgs, _t) = mgr.build_for_request(0);
|
||||
assert!(!msgs.is_empty());
|
||||
assert!(
|
||||
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
|
||||
"开时视图不得残留 system: {:?}",
|
||||
msgs.iter().map(role_tag).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
mgr.all_messages_clone()
|
||||
.iter()
|
||||
.any(|m| matches!(m.role, MessageRole::System)),
|
||||
"存储态 system 锚点不应被改写(纯视图)"
|
||||
);
|
||||
let mut final_view = vec![ChatMessage::system("SYS_PROMPT")];
|
||||
final_view.extend(msgs);
|
||||
assert!(
|
||||
matches!(final_view[0].role, MessageRole::System),
|
||||
"组装层首条 system_prompt 不转"
|
||||
);
|
||||
assert_eq!(
|
||||
final_view
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::System))
|
||||
.count(),
|
||||
1,
|
||||
"最终视图应只有组装层首条 system"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_anchor_head_insert_still_converted() {
|
||||
// W2:IPC 压缩路径 insert_at(0) 的存储首位 system 锚点,最终视图在下标 1(>0),同样转 user。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问"));
|
||||
mgr.insert_at(0, ChatMessage::system("[摘要]"));
|
||||
mgr.set_cache_friendly(true);
|
||||
let (msgs, _t) = mgr.build_for_request(0);
|
||||
assert!(
|
||||
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
|
||||
"存储首位的 system 锚点最终视图下标 > 0,应转 user"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_anchor_stays_system_when_cache_friendly_off() {
|
||||
// W2 开关关:不转换,视图与存储角色一致(旧行为逐字节等价)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问"));
|
||||
mgr.push(ChatMessage::assistant("答"));
|
||||
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
|
||||
let (msgs, _t) = mgr.build_for_request(0);
|
||||
assert!(
|
||||
msgs.iter().any(|m| matches!(m.role, MessageRole::System)),
|
||||
"关时 system 锚点应原样进视图"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user