优化: 上下文压缩缓存友好化与召回句柄
- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退 - 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配 - BP3 断点机制+缓存命中率观测日志,调参有据 - 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费 - 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗 - namespace 引用跨重启内容哈希懒回填
This commit is contained in:
+139
-15
@@ -20,9 +20,11 @@
|
||||
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
||||
|
||||
mod sanitize;
|
||||
pub mod archive;
|
||||
|
||||
use crate::context_helpers::{
|
||||
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||
TRIM_TARGET_RATIO,
|
||||
};
|
||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||
@@ -78,6 +80,16 @@ pub struct ContextManager {
|
||||
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
|
||||
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
|
||||
needs_full_rewrite: bool,
|
||||
/// 压缩可召回:本次压缩事件的存档目标(调用方读到 KV 开后注入,compress_old_messages
|
||||
/// 一次性消费)。None = 本事件不存档(开关关/未注入),零行为变更。
|
||||
compress_recall: Option<archive::RecallTarget>,
|
||||
/// 最近一次压缩成功落盘的存档路径(take 语义,供调用方在压缩摘要尾部拼召回句柄;
|
||||
/// 每次 compress_old_messages 按事件重置,防跨事件残留误报)。
|
||||
archive_path: Option<std::path::PathBuf>,
|
||||
/// 缓存友好上下文总开关(跨 crate 注入:settings 在 src-tauri 侧,df-ai 不可直读,
|
||||
/// 调用方读 KV `df-ai-cache-friendly-context` 后经 [`Self::set_cache_friendly`] 注入)。
|
||||
/// 默认 false = 旧行为(贪心最小裁剪 + system 锚点原样发送),未注入路径零行为变更。
|
||||
cache_friendly: bool,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
@@ -91,6 +103,9 @@ impl ContextManager {
|
||||
pending_topic_marker: None,
|
||||
persisted_msg_count: 0,
|
||||
needs_full_rewrite: false,
|
||||
compress_recall: None,
|
||||
archive_path: None,
|
||||
cache_friendly: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +168,8 @@ impl ContextManager {
|
||||
// 全量重写空列表也会清 DB)。
|
||||
self.persisted_msg_count = 0;
|
||||
self.needs_full_rewrite = true;
|
||||
self.compress_recall = None;
|
||||
self.archive_path = None;
|
||||
}
|
||||
|
||||
/// 消息数量
|
||||
@@ -196,10 +213,10 @@ impl ContextManager {
|
||||
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -208,15 +225,12 @@ impl ContextManager {
|
||||
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
||||
let units = self.build_eviction_units(protect_start);
|
||||
|
||||
let mut removed: u64 = 0;
|
||||
let mut trim_end = 0;
|
||||
for unit in &units {
|
||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
||||
break;
|
||||
}
|
||||
removed += unit.token_sum as u64;
|
||||
trim_end = unit.end;
|
||||
}
|
||||
// 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
|
||||
let (trim_end, removed) = if self.cache_friendly {
|
||||
self.block_trim_target(&units, available)
|
||||
} else {
|
||||
self.greedy_trim_target(&units, available)
|
||||
};
|
||||
|
||||
if trim_end == 0 {
|
||||
tracing::warn!(
|
||||
@@ -229,9 +243,9 @@ impl ContextManager {
|
||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
||||
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -264,14 +278,93 @@ impl ContextManager {
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
let sanitized = Self::sanitize_messages(msgs);
|
||||
let view = self.anchors_to_user(Self::sanitize_messages(msgs));
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// 贪心最小裁剪:累计淘汰单元直到塞下硬预算即停(历史行为,开关关路径)。
|
||||
///
|
||||
/// 缺陷:对话每增长一条消息就重新越过预算一点,裁剪边界随之前移一个单元,
|
||||
/// 发送视图首条每轮都变,前缀缓存从第 0 字节起永远 miss。
|
||||
fn greedy_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
|
||||
let mut removed: u64 = 0;
|
||||
let mut trim_end = 0;
|
||||
for unit in units {
|
||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
||||
break;
|
||||
}
|
||||
removed += unit.token_sum as u64;
|
||||
trim_end = unit.end;
|
||||
}
|
||||
(trim_end, removed)
|
||||
}
|
||||
|
||||
/// 块状裁剪:一次裁到目标水位 `TRIM_TARGET_RATIO × available`,且边界吸附轮边界。
|
||||
///
|
||||
/// 为什么块状:两次裁剪之间裁剪边界不动 → 被淘汰集不变 → 视图前缀逐字节稳定,
|
||||
/// 前缀缓存每轮命中;裁剪频度由水位差(块大小)决定而非逐条爬行。
|
||||
/// 轮边界:单元 end 处下一条未淘汰消息 role=User 即轮起点,整问整答(含工具链)
|
||||
/// 同生同死,淘汰摘要按轮覆盖语义完整,且轮起点是稳定地标。
|
||||
/// 病态回退(单轮体量 > 块,扫到保护区仍无两条件齐备的边界):取满足硬预算
|
||||
/// available 的最后单元边界(安全底线);连硬预算都不可得(保护区自身超限)则
|
||||
/// 尽力裁到末单元,对齐贪心路径。
|
||||
fn block_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
|
||||
let target = (available as f32 * TRIM_TARGET_RATIO) as u32;
|
||||
let mut removed: u64 = 0;
|
||||
let mut hard = (0usize, 0u64);
|
||||
let mut last = (0usize, 0u64);
|
||||
for unit in units {
|
||||
removed += unit.token_sum as u64;
|
||||
last = (unit.end, removed);
|
||||
let remaining = self.history_tokens.saturating_sub(removed as u32);
|
||||
if remaining <= available {
|
||||
hard = (unit.end, removed);
|
||||
}
|
||||
// 目标水位隐含硬预算已满足(target < available),轮吸附与水位同时判定
|
||||
if remaining <= target
|
||||
&& self
|
||||
.messages
|
||||
.get(unit.end)
|
||||
.is_some_and(|next| matches!(next.message.role, MessageRole::User))
|
||||
{
|
||||
return (unit.end, removed);
|
||||
}
|
||||
}
|
||||
if hard.0 > 0 {
|
||||
hard
|
||||
} else {
|
||||
last
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送视图出口整形(sanitize 之后):总开关开时把存储态 role=System 消息
|
||||
/// (压缩摘要/话题标记/警示等锚点)映射为 role=User 发送。
|
||||
///
|
||||
/// 为什么:anthropic_compat 把存储态全部 role=System 折叠进顶层 system(带
|
||||
/// cache_control),每次锚点事件都改写顶层缓存指纹,system 断点与消息断点同时失配。
|
||||
/// 转 user 后顶层 system 回到纯 system_prompt 稳定段;连续 user 由
|
||||
/// anthropic_compat::merge_consecutive_users 合并(协议要求严格交替),user 不触发
|
||||
/// 三元组/首条规则。纯视图:存储态仍 System,压缩定位(has_compressible_messages
|
||||
/// 的 System 排除)与前端展示语义不变。调用方组装层加入的 system_prompt 不经本
|
||||
/// 函数(不在 storage),最终视图中它独占 role=System 首条。
|
||||
fn anchors_to_user(&self, view: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
if !self.cache_friendly {
|
||||
return view;
|
||||
}
|
||||
view.into_iter()
|
||||
.map(|mut m| {
|
||||
if matches!(m.role, MessageRole::System) {
|
||||
m.role = MessageRole::User;
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
|
||||
|
||||
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
||||
@@ -668,6 +761,11 @@ impl ContextManager {
|
||||
///
|
||||
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
||||
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
|
||||
// 压缩可召回:目标一次性消费(防跨事件残留),路径按本事件重置。压缩标记后原文
|
||||
// 从 LLM 上下文隔离,此刻原文在手,追加写入会话存档供摘要句柄召回(read_file);
|
||||
// 失败仅置空路径(调用方降级为无句柄),不影响压缩本身。
|
||||
let recall = self.compress_recall.take();
|
||||
self.archive_path = None;
|
||||
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
|
||||
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
|
||||
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
|
||||
@@ -694,10 +792,36 @@ impl ContextManager {
|
||||
// newly_compressed 为空,状态未变,无需全量重写)。
|
||||
if !newly_compressed.is_empty() {
|
||||
self.needs_full_rewrite = true;
|
||||
if let Some(target) = recall {
|
||||
match archive::append_archive(&target, &newly_compressed) {
|
||||
Some(p) => self.archive_path = Some(p),
|
||||
None => tracing::warn!("[ai] 压缩存档写入失败,本轮摘要无召回句柄(压缩不受影响)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
newly_compressed
|
||||
}
|
||||
|
||||
/// 注入本次压缩事件的存档目标(压缩可召回,`df-ai-compress-recall` 开时由调用方在
|
||||
/// `compress_old_messages` 之前调用;开关关不调用)。一次性消费:压缩标记时落盘,
|
||||
/// 路径经 [`Self::take_archive_path`] 取走。
|
||||
pub fn set_compress_recall(&mut self, data_dir: &std::path::Path, conv_id: &str) {
|
||||
self.compress_recall = Some(archive::RecallTarget::new(data_dir, conv_id));
|
||||
}
|
||||
|
||||
/// 取走最近一次压缩成功落盘的存档路径(Some = 本次压缩可召回,调用方据此在摘要尾部
|
||||
/// 拼句柄行)。take 语义:取后即空,防跨压缩事件残留误报。
|
||||
pub fn take_archive_path(&mut self) -> Option<std::path::PathBuf> {
|
||||
self.archive_path.take()
|
||||
}
|
||||
|
||||
/// 注入缓存友好上下文总开关(每请求由 src-tauri 调用方读 KV 后在 `build_for_request`
|
||||
/// 前调用,持续语义:注入值保持到下次注入)。true = 块状裁剪 + system 锚点 view 层
|
||||
/// 转 user;false = 两者旁路,行为与旧路径逐字节等价。
|
||||
pub fn set_cache_friendly(&mut self, enabled: bool) {
|
||||
self.cache_friendly = enabled;
|
||||
}
|
||||
|
||||
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
|
||||
pub fn is_compressing(&self) -> bool {
|
||||
self.is_compressing
|
||||
|
||||
Reference in New Issue
Block a user