优化: 上下文压缩缓存友好化与召回句柄

- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退
- 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配
- BP3 断点机制+缓存命中率观测日志,调参有据
- 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费
- 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗
- namespace 引用跨重启内容哈希懒回填
This commit is contained in:
lxy
2026-10-11 22:27:08 +08:00
parent c7d0ed939c
commit 6f5c6b7532
6 changed files with 1006 additions and 77 deletions
+253 -18
View File
@@ -62,6 +62,46 @@ pub fn anthropic_cache_enabled() -> bool {
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
/// BP3 摘要链尾断点开关(缓存友好上下文总开关在本模块的投影,默认开)。
///
/// 本模块不读 src-tauri KV(跨 crate),上层读 settings `df-ai-cache-friendly-context`
/// 后经 [`set_bp3_enabled`] 同步(同 [`set_anthropic_cache_enabled`] 先例)。
/// 关时消息级只打 BP2,断点布局与历史逐字节一致。BP3 仍内嵌于 anthropic 缓存总开关
/// 之下(它发的也是 cache_control),两层正交。
static BP3_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 设置 BP3 摘要链尾断点开关(上层读 settings 后同步)。
pub fn set_bp3_enabled(enabled: bool) {
BP3_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
/// 读取 BP3 开关(`convert_request` 组装消息级断点用)。
pub fn bp3_enabled() -> bool {
BP3_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
/// 缓存命中率观测日志(usage 就绪点每轮一条,供后续水位调参取数)。
///
/// anthropic 语义:input_tokens 为未缓存输入,cache_read/creation 独立计数,
/// 故命中率分母 = read + creation + input;三者全零(端点未回 usage)跳过不报。
fn log_cache_usage(usage: &TokenUsage) {
let total_input = usage.prompt_cache_hit_tokens as u64
+ usage.prompt_cache_miss_tokens as u64
+ usage.prompt_tokens as u64;
if total_input == 0 {
return;
}
let hit_rate = usage.prompt_cache_hit_tokens as f64 / total_input as f64;
let hit_pct = format!("{:.1}%", hit_rate * 100.0);
tracing::info!(
input_tokens = usage.prompt_tokens,
cache_read_input_tokens = usage.prompt_cache_hit_tokens,
cache_creation_input_tokens = usage.prompt_cache_miss_tokens,
hit_rate = %hit_pct,
"Anthropic prompt cache 命中率观测"
);
}
// ============================================================
// Provider 实现
// ============================================================
@@ -147,10 +187,21 @@ impl AnthropicCompatProvider {
// 构建非 system 消息(保留顺序,合并连续 tool_result)
let mut messages: Vec<serde_json::Value> = Vec::new();
let mut pending_tool_results: Vec<serde_json::Value> = Vec::new();
// 摘要锚点 = index>0 的 System(存储态:压缩摘要/话题标记/催办;首条是组装层
// system_prompt,归 BP1 顶层 system 非锚点)。记录「末锚点之后的消息条数」而非
// 下标:取用时按 len 反推目标,此前的 merge/补首条等数组增删天然自校正。
// 锚点以存储态 System 识别——view 层 System→user 转换落地后锚点以 user 形态
// 到达,此处识别不到,BP3 自然旁路(不加断点),无副作用。
let mut msgs_after_anchor: Option<usize> = None;
for m in req.messages.iter() {
for (src_idx, m) in req.messages.iter().enumerate() {
match m.role {
MessageRole::System => continue,
MessageRole::System => {
if src_idx > 0 {
msgs_after_anchor = Some(0);
}
continue;
}
MessageRole::Tool => {
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
@@ -173,7 +224,7 @@ impl AnthropicCompatProvider {
}
}
MessageRole::User => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 多模态 user 消息 → content blocks 数组(text/image)。
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
@@ -192,9 +243,12 @@ impl AnthropicCompatProvider {
} else {
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
}
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
}
}
MessageRole::Assistant => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
let mut content: Vec<serde_json::Value> = Vec::new();
if !m.content.is_empty() {
content.push(serde_json::json!({ "type": "text", "text": m.content }));
@@ -218,10 +272,13 @@ impl AnthropicCompatProvider {
content.push(serde_json::json!({ "type": "text", "text": "" }));
}
messages.push(serde_json::json!({ "role": "assistant", "content": content }));
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
}
}
}
}
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
@@ -240,10 +297,12 @@ impl AnthropicCompatProvider {
// (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages);
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control,
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。
// 消息级缓存断点(开关开):BP2 打在历史段稳定边界,使 system 之后的对话历史
// 也可命中(否则只缓存 system,长会话历史段全价重算);BP3(缓存友好开关,默认开)
// 加打摘要锚点链之后第一条,BP2 因裁剪边界/压缩失效时 [system+摘要链] 小前缀仍命中。
if anthropic_cache_enabled() {
Self::apply_message_cache_breakpoint(&mut messages);
let after_anchor = if bp3_enabled() { msgs_after_anchor } else { None };
Self::apply_message_cache_breakpoint(&mut messages, after_anchor);
}
let tools = req.tools.map(|defs| {
@@ -328,34 +387,61 @@ impl AnthropicCompatProvider {
fn flush_tool_results(
messages: &mut Vec<serde_json::Value>,
pending: &mut Vec<serde_json::Value>,
msgs_after_anchor: &mut Option<usize>,
) {
if pending.is_empty() {
return;
}
let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
if let Some(k) = msgs_after_anchor {
*k += 1;
}
}
/// 消息级缓存断点:在「历史段稳定边界」消息的末个 content block 上打 cache_control。
/// 消息级缓存断点:BP2 打在「历史段稳定边界」的末个 content block 上,BP3(可选)
/// 加打「摘要锚点链之后第一条」——`after_anchor` 为锚点链后的消息条数
/// (Some(0)=锚点收尾无后继,不打;None=无锚点,只打 BP2)。
///
/// 断点落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// BP2 落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
///
/// BP3 服务于 BP2 频繁失效的场景:BP2 随裁剪边界移动/压缩事件/新近度豁免改写而
/// 迁位,前缀反复全价重算;BP3 前缀只含 [system+摘要链+1 条],失效源仅压缩事件,
/// 为该小前缀保底命中。与 BP2 同条(短序列)时跳过,BP2 优先。
///
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) {
/// 断点预算:Anthropic 单请求最多 4 个 cache_control——system 1 + BP2 1 + BP3 1 = 3,
/// 余 1 备用。
fn apply_message_cache_breakpoint(
messages: &mut [serde_json::Value],
after_anchor: Option<usize>,
) {
let n = messages.len();
if n == 0 {
return;
}
let last_is_user =
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
let idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
let msg = &mut messages[idx];
let bp2_idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
if let Some(k) = after_anchor {
if k > 0 && k <= n {
let bp3_idx = n - k;
if bp3_idx != bp2_idx {
Self::mark_cache_control(&mut messages[bp3_idx]);
}
}
}
Self::mark_cache_control(&mut messages[bp2_idx]);
}
/// 单条消息打 cache_control:string content 升级为单 text block 数组再标;
/// array(blocks) 标末块;其余形态(异常)不动。
fn mark_cache_control(msg: &mut serde_json::Value) {
let cache_control = serde_json::json!({ "type": "ephemeral" });
match msg.get("content").cloned() {
Some(serde_json::Value::Array(mut blocks)) => {
@@ -729,6 +815,7 @@ impl LlmProvider for AnthropicCompatProvider {
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
reasoning_tokens: 0,
};
log_cache_usage(&usage);
AttemptOutcome::Ok(CompletionResponse {
text,
model: resp.model,
@@ -874,6 +961,12 @@ impl LlmProvider for AnthropicCompatProvider {
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
}
// 流式收尾(message_stop 终态帧)即 usage 就绪点,输出命中率观测日志
if chunk.finished {
if let Some(u) = &chunk.usage {
log_cache_usage(u);
}
}
chunks.push(Ok(chunk));
}
}
@@ -1258,7 +1351,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
// 前一条 assistant 的末块带 cache_control
let a1 = &messages[1];
let blocks = a1["content"].as_array().unwrap();
@@ -1275,7 +1368,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text");
@@ -1289,7 +1382,7 @@ mod tests {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "only" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
@@ -1298,10 +1391,152 @@ mod tests {
#[test]
fn cache_breakpoint_empty_messages_noop() {
let mut messages: Vec<serde_json::Value> = vec![];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
assert!(messages.is_empty());
}
/// BP3 落「锚点链后首条」(按锚点后条数反推下标),与 BP2 各占一条互不覆盖。
#[test]
fn cache_breakpoint_bp3_marks_first_message_after_anchor_chain() {
// 模拟 [锚点链][q0][a0][q1][a1][q2] 转换后的数组:锚点后 5 条
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(5));
// BP3 → idx0(q0);BP2(末条 user,取 n-2)→ idx3(a1)
for idx in [0usize, 3] {
let blocks = messages[idx]["content"].as_array().unwrap();
assert_eq!(
blocks.last().unwrap()["cache_control"]["type"],
"ephemeral",
"idx{} 应带断点",
idx
);
}
assert_eq!(messages[4]["content"].as_str(), Some("q2"), "末条 user 不打点");
}
/// BP3 与 BP2 同条(短序列)→ 跳过 BP3,BP2 优先,断点预算不重复占。
#[test]
fn cache_breakpoint_bp3_skipped_when_colliding_with_bp2() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
// 锚点后 2 条 → BP3 目标 idx1,与 BP2(n-2=1)同条 → 跳过
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(2));
let blocks = messages[1]["content"].as_array().unwrap();
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
assert!(messages[0]["content"].as_str().is_some(), "q0 应保持字符串简写");
}
/// 锚点收尾无后继(after_anchor=0)→ 不打 BP3,BP2 照常落点。
#[test]
fn cache_breakpoint_bp3_zero_after_anchor_noop() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(0));
// BP2(末条 user → n-2=0)照常标 q0
let blocks = messages[0]["content"].as_array().expect("BP2 应照常落点");
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
}
// ---------- BP3 开关门(convert 级,进程级开关用例须持 convert_lock) ----------
/// 构造 BP3 开关用例的请求:首条组装层 system_prompt + (可选)锚点链 + 历史段。
/// 转换后数组恒为 [q0, a0, q1](锚点折叠进顶层 system)。
fn bp3_req(with_anchor: bool) -> CompletionRequest {
let mut messages = vec![
ChatMessage::system("sys-prompt"),
ChatMessage::user("q0"),
ChatMessage::assistant("a0"),
ChatMessage::user("q1"),
];
if with_anchor {
messages.insert(1, ChatMessage::system("## 压缩摘要"));
messages.insert(2, ChatMessage::system("## 话题标记"));
}
CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages,
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
}
}
/// BP3 开 + 有锚点 → 断点落摘要链后第一条(q0),BP2 照常落历史尾(a0)。
#[test]
fn bp3_enabled_with_anchor_marks_first_message_after_chain() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
// BP3:锚点链后首条 q0(原字符串升级为 blocks + cache_control)
let q0_blocks = body.messages[0]["content"].as_array().expect("BP3 应标在锚点链后首条 q0");
assert_eq!(q0_blocks[0]["text"], "q0");
assert_eq!(q0_blocks[0]["cache_control"]["type"], "ephemeral");
// BP2 照常:末条 user 前相邻一条 a0
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
// 末条 user 保持字符串简写
assert_eq!(body.messages[2]["content"].as_str(), Some("q1"));
set_anthropic_cache_enabled(false);
set_bp3_enabled(true); // 复位到进程默认(默认开),防污染其他并行用例
}
/// BP3 关 → 断点布局与历史一致(仅 BP2,锚点链后首条不打点)。
#[test]
fn bp3_disabled_keeps_legacy_breakpoint_layout() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(false);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"BP3 关,q0 保持字符串简写"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
/// 无锚点(仅组装层首条 system)→ 不加 BP3,仅 BP2。
#[test]
fn bp3_no_anchor_no_extra_breakpoint() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(false));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"无锚点应不加 BP3"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息
+180
View File
@@ -0,0 +1,180 @@
//! 压缩可召回存档:被压缩原文 JSONL 落盘 + 召回句柄拼装。
//!
//! 压缩把原文从 LLM 上下文隔离(is_active 白名单过滤),摘要必有损。本模块在压缩标记时
//! 把本次被压原文追加写入 `<data_dir>/context-archive/<conv_id>-archive.jsonl`
//! (JSON Lines,一行一条 {role, content}),调用方在压缩摘要尾部拼召回句柄行,
//! LLM 需要原文时经 read_file 回读。会话一文件、追加写,目录不存在则创建;
//! 会话删除级联清理留待后续挂点。存档只落盘不进上下文,token 估算零影响;
//! 不与 tool_offload 复用(那是 src-tauri 侧机制,跨 crate 不混用)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::provider::ChatMessage;
/// 存档目录名(data_dir 下)
const ARCHIVE_DIR: &str = "context-archive";
/// 一次压缩事件的存档目标(会话维度)。调用方读到开关开(`df-ai-compress-recall`)后经
/// [`super::ContextManager::set_compress_recall`] 注入,压缩标记时一次性消费。
pub(super) struct RecallTarget {
data_dir: PathBuf,
conv_id: String,
}
impl RecallTarget {
pub(super) fn new(data_dir: &Path, conv_id: &str) -> Self {
Self {
data_dir: data_dir.to_path_buf(),
conv_id: conv_id.to_string(),
}
}
/// 存档文件路径:`<data_dir>/context-archive/<conv_id>-archive.jsonl`
fn file(&self) -> PathBuf {
self.data_dir
.join(ARCHIVE_DIR)
.join(format!("{}-archive.jsonl", self.conv_id))
}
}
/// 把本次被压原文追加写入会话存档(JSONL 追加语义,不覆盖既有行)。
/// 返回存档路径供句柄拼装;空消息/IO 失败返 None(调用方降级为无句柄,压缩本身不受影响)。
pub(super) fn append_archive(target: &RecallTarget, msgs: &[ChatMessage]) -> Option<PathBuf> {
if msgs.is_empty() {
return None;
}
let path = target.file();
let dir = path.parent()?;
if let Err(e) = std::fs::create_dir_all(dir) {
tracing::warn!(error = %e, dir = %dir.display(), "[archive] 存档目录创建失败,本轮无召回句柄");
return None;
}
let mut file = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档文件打开失败,本轮无召回句柄");
return None;
}
};
for m in msgs {
let line = serde_json::json!({ "role": &m.role, "content": m.content });
if let Err(e) = writeln!(file, "{line}") {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档行写入失败,本轮无召回句柄");
return None;
}
}
Some(path)
}
/// 召回句柄行:LLM 按此路径 read_file 回读被压原文。
pub fn recall_line(path: &Path) -> String {
format!("早期历史全文已存档: `{}`(read_file 可召回)", path.display())
}
/// 摘要尾部拼召回句柄行(开关开且存档落盘成功时用;开关关调用方直接用原摘要)。
pub fn with_recall_line(summary: &str, path: &Path) -> String {
format!("{}\n{}", summary, recall_line(path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
use tempfile::TempDir;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 灌 2 条可压 + 1 条保护区消息,注入召回目标后压缩 [0..2),返回存档路径。
fn compress_with_recall(tmp: &TempDir, conv_id: &str) -> PathBuf {
let mut m = mgr();
m.push(ChatMessage::user("早期问题"));
m.push(ChatMessage::assistant("早期回复"));
m.push(ChatMessage::user("近期问题"));
m.set_compress_recall(tmp.path(), conv_id);
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "前置:应压缩 2 条");
m.take_archive_path().expect("存档应落盘")
}
/// 压缩时存档文件创建,且每行含被压原文的 role+content。
#[test]
fn compress_with_recall_archives_original_text() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-a");
assert_eq!(
path,
tmp.path().join("context-archive").join("conv-a-archive.jsonl")
);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines = text.lines();
let first: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(first["role"], "user");
assert_eq!(first["content"], "早期问题");
let second: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(second["role"], "assistant");
assert_eq!(second["content"], "早期回复");
assert!(lines.next().is_none(), "不应有多余行");
}
/// 开关开:摘要尾部含召回句柄行(含存档路径与 read_file 指引);路径一次性消费。
#[test]
fn recall_enabled_summary_gets_handle_line() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-b");
let summary = "## 意图\n- 测试主题";
let with_handle = with_recall_line(summary, &path);
assert!(with_handle.starts_with(summary), "句柄拼在摘要尾部,原摘要在前");
let handle = recall_line(&path);
assert!(with_handle.ends_with(&handle));
assert!(handle.contains(&path.display().to_string()));
assert!(handle.contains("read_file"));
}
/// 开关关(不注入召回目标):不落盘、无句柄路径可拼。
#[test]
fn recall_off_no_archive_no_handle() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("问题"));
m.push(ChatMessage::assistant("回复"));
m.push(ChatMessage::user("近期"));
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
assert!(m.take_archive_path().is_none(), "开关关不应有存档路径");
assert!(
!tmp.path().join("context-archive").exists(),
"开关关不应创建存档目录"
);
}
/// 二次压缩追加不覆盖(JSONL 追加语义):两批原文都在,行数累计。
#[test]
fn second_compress_appends_not_overwrites() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("第一批"));
m.push(ChatMessage::assistant("第一批回复"));
m.push(ChatMessage::user("第二批"));
m.push(ChatMessage::assistant("第二批回复"));
m.push(ChatMessage::user("近期"));
m.set_compress_recall(tmp.path(), "conv-c");
let _ = m.compress_old_messages(2);
// 第二次压缩事件:调用方重新注入(开关可能已变),幂等跳过已压缩,只压新批
m.set_compress_recall(tmp.path(), "conv-c");
let second = m.compress_old_messages(4);
assert_eq!(second.len(), 2, "第二次只压新激活的 2 条");
let path = m.take_archive_path().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 4, "两次压缩累计 4 行,不覆盖");
assert!(text.contains("第一批回复"), "第一批原文仍在");
assert!(text.contains("第二批回复"), "第二批原文已追加");
}
}
+248 -5
View File
@@ -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 锚点应原样进视图"
);
}
+139 -15
View File
@@ -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
+104 -39
View File
@@ -26,7 +26,7 @@ use crate::provider::{ChatMessage, MessageRole};
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
#[derive(Debug, Clone)]
pub struct TokenEstimator {
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
/// 非 CJK 字符(拉丁/数字/符号)→ token 转换系数(默认 0.35,约 3 字符/token)
pub chars_ratio: f32,
/// 每条消息固定开销(role 标记 + 格式)
pub per_message_overhead: u32,
@@ -44,57 +44,83 @@ impl Default for TokenEstimator {
}
}
// url 模式图片 token 地板(Anthropic 保守 ~1600/图,OpenAI 按 tile;URL 长度估严重低估会致预算裁剪不触发)。
/// CJK(中日韩)字符 token 比率:1 字约 1~1.5 token,取 1.2 略保守。
/// 此前统一按 0.35 计,中文低估 2~3 倍,长中文会话预算门形同虚设
///(估 3 万实 9 万,裁剪不触发直顶 provider 上限)。
const CJK_TOKEN_RATIO: f32 = 1.2;
// 图片 token 地板(base64 与 url 两模式同用,Anthropic 保守 ~1600/图,OpenAI 按 tile)。
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
// 注意单位是 token 不是字符:地板必须在字符折扣之后直接加 token,若混进字符计数
// 会被 0.35 稀释成 560,保底名存实亡。
const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
// base64 同用地板(此前按字符 0.35 计):provider 端图片按分辨率/张数计费,与 base64
// 字符串长度无关,25 万字符按 0.35 计 8.7 万 token 是纯高估,致含图消息过度裁剪;
// 两模式语义等价(都是一张图),成本口径单一(DRY)。
const IMAGE_TOKEN_FLOOR: usize = 1600;
/// CJK 字符判定:中日韩文字/假名/谚文/CJK 标点/全角形式。
fn is_cjk(ch: char) -> bool {
matches!(ch as u32,
0x3000..=0x303F // CJK 标点
| 0x3040..=0x30FF // 日文假名
| 0x3400..=0x4DBF // 表意文字扩展 A
| 0x4E00..=0x9FFF // CJK 基本区
| 0xAC00..=0xD7AF // 韩文谚文
| 0xF900..=0xFAFF // 兼容表意文字
| 0xFF00..=0xFFEF // 全角形式
)
}
/// 分档字符 token 计数:CJK 按 [`CJK_TOKEN_RATIO`],其余按调用方给定比率。
fn weighted_char_tokens(s: &str, plain_ratio: f32) -> u32 {
let (mut cjk, mut plain) = (0usize, 0usize);
for ch in s.chars() {
if is_cjk(ch) { cjk += 1 } else { plain += 1 }
}
(cjk as f32 * CJK_TOKEN_RATIO + plain as f32 * plain_ratio).ceil() as u32
}
impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计)
///
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
/// 文本按 CJK/其余分档计数;图片(base64 与 url)统一按 [`IMAGE_TOKEN_FLOOR`] 计,
/// 不随 base64 字符串长度膨胀。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let mut char_count = msg.content.chars().count();
// 939① 估算入账修正:Tool 消息 content 为 namespace 引用时,按 ref 尾段携带的
// 原文字节数入账(字节保守视作字符,中文会 3x 高估——方向安全:宁可高估触发
// 裁剪/压缩,不可漏估致预算门失效,后者曾致 provider 超限 400)。旧格式/已淘汰
// 内容(无 ?len= 尾段)按字面字符保守计,行为同前。
if matches!(msg.role, crate::provider::MessageRole::Tool) {
if let Some(n) = crate::namespace_store::parse_ref_content_len(&msg.content) {
char_count = n;
// namespace 引用(Tool 消息)特殊入账:按 ref 尾段携带的原文字节数保守视作字符
// 并按 plain 比率计(多为代码/JSON 输出);中文引用会低估,方向可接受:ref 体量
// 本就由 namespace 上限闸门控,此处入账只为预算可见性。
let mut content_tokens = if matches!(msg.role, crate::provider::MessageRole::Tool) {
match crate::namespace_store::parse_ref_content_len(&msg.content) {
Some(n) => (n as f32 * self.chars_ratio).ceil() as u32,
None => weighted_char_tokens(&msg.content, self.chars_ratio),
}
}
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
let mut url_image_floor_tokens: u32 = 0;
} else {
weighted_char_tokens(&msg.content, self.chars_ratio)
};
// 图片地板按 token 直接累加,生效于字符折扣之外(折进字符计数会被 0.35 稀释,
// 1600 保底失效,预算裁剪漏触发 → provider 超限 400)。
let mut image_floor_tokens: u32 = 0;
if let Some(parts) = &msg.parts {
for p in parts {
match p {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
crate::provider::ContentPart::Text { text } => {
content_tokens = content_tokens
.saturating_add(weighted_char_tokens(text, self.chars_ratio));
}
crate::provider::ContentPart::Image { base64, url, .. } => {
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
if let Some(b) = base64 {
char_count += b.chars().count();
} else if url.is_some() {
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
url_image_floor_tokens = url_image_floor_tokens
.saturating_add(URL_IMAGE_TOKEN_FLOOR as u32);
// base64 与 url 语义等价(都是一张图),同用地板;有 base64 时
// url 为空(构造端二选一),不重复计
if base64.is_some() || url.is_some() {
image_floor_tokens =
image_floor_tokens.saturating_add(IMAGE_TOKEN_FLOOR as u32);
}
}
}
}
}
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
let mut total = content_tokens + image_floor_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
if let Some(ref calls) = msg.tool_calls {
@@ -113,9 +139,9 @@ impl TokenEstimator {
total
}
/// 估算纯文本字符串的 token 数(用于 system prompt)
/// 估算纯文本字符串的 token 数(用于 system prompt),CJK 分档同 [`Self::estimate_message`]
pub fn estimate_text(&self, text: &str) -> u32 {
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
weighted_char_tokens(text, self.chars_ratio)
}
}
@@ -656,6 +682,14 @@ pub struct EvictionUnit {
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
pub const PROTECT_COUNT: usize = 6;
/// 块状裁剪目标水位(× available):超预算时一次裁到该水位以下,而非贪心的刚好塞下。
///
/// 块大小 = (1 − T) × available,随模型窗口自适应缩放。两次裁剪之间裁剪边界不动 →
/// 被淘汰集不变 → 淘汰摘要内容不变(关键词提炼确定性)→ 发送视图前缀逐字节稳定,
/// 前缀缓存每轮命中。默认 0.8:块 ≈ 19K tokens,稳定窗口 2–9 轮,估算误差 ±15%
/// 被水位差吸收,不会刚裁完就再触。
pub const TRIM_TARGET_RATIO: f32 = 0.8;
/// 淘汰补偿摘要的硬上限(字符数)。
///
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
@@ -1755,8 +1789,8 @@ mod tests {
#[test]
fn url_image_token_floor_survives_ratio_discount() {
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
// 图地板语义是"每图至少 ~1600 token",必须生效于字符折扣之外:
// 若地板折进字符计数再乘比率,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
let est = TokenEstimator::default();
let msg = ChatMessage::user_parts(
"t",
@@ -1764,10 +1798,41 @@ mod tests {
);
let tokens = est.estimate_message(&msg);
assert!(
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
tokens >= IMAGE_TOKEN_FLOOR as u32,
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
tokens,
URL_IMAGE_TOKEN_FLOOR
IMAGE_TOKEN_FLOOR
);
}
#[test]
fn base64_image_counts_as_floor_not_chars() {
// base64 不按字符计:provider 端图片按张数/分辨率计费,25 万字符按 0.35
// 计 8.7 万 token 是纯高估致过度裁剪。改地板后与图的真实成本同量级。
let est = TokenEstimator::default();
let big_base64 = "iVBORw0KGgoAAAANS".repeat(15000); // ~25.5 万字符
let msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64)],
);
let tokens = est.estimate_message(&msg);
// 期望构成:地板 1600 + "t"(1 plain 字符 ceil 后 1) + 每消息开销 4
assert_eq!(
tokens,
IMAGE_TOKEN_FLOOR as u32 + 1 + est.per_message_overhead,
"base64 图消息应恰为地板+文本+开销,实际 {}",
tokens
);
}
#[test]
fn cjk_chars_cost_more_than_latin() {
// 中文按 1.2 token/字计:同字符数下中文估值须显著高于拉丁,预算门不再低估 2~3 倍
let est = TokenEstimator::default();
let zh = est.estimate_text("预算控制测试"); // 6 CJK 字符
let en = est.estimate_text("abcdef"); // 6 拉丁字符
assert_eq!(zh, 8, "6 CJK 字应计 8 token(6×1.2=7.2 ceil)");
assert_eq!(en, 3, "6 拉丁字符应计 3 token(6×0.35=2.1 ceil)");
assert!(zh > en * 2, "CJK 估值({zh})须显著高于同长度拉丁({en})");
}
}
+82
View File
@@ -74,6 +74,11 @@ pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
.filter(|_| full_path.contains("?len="))
}
/// 从引用路径提取工具名段(懒回填重建条目元数据用)。
pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next()
}
/// namespace 条目
#[derive(Debug, Clone)]
pub struct NamespaceEntry {
@@ -280,6 +285,40 @@ impl NamespaceStore {
))
}
/// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。
/// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程
/// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰)
/// 不进返回值,调用方维持淘汰占位语义。
/// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。
/// 返回 引用路径 → 原文。
pub fn backfill_from_messages(
&mut self,
missed_refs: &[String],
message_contents: &[String],
) -> HashMap<String, String> {
let mut recovered: HashMap<String, String> = HashMap::new();
if missed_refs.is_empty() {
return recovered;
}
let wanted: HashMap<&str, &String> = missed_refs.iter()
.filter_map(|p| parse_namespace_key(p).map(|k| (k, p)))
.collect();
for content in message_contents {
let key = simple_hash(content);
if let Some(path) = wanted.get(key.as_str()) {
// 同一引用可能在多条历史消息出现,找回一次即可
if recovered.contains_key(path.as_str()) {
continue;
}
if let Some(tool) = parse_ref_tool_name(path) {
self.store(tool, content);
}
recovered.insert((*path).clone(), content.clone());
}
}
recovered
}
/// 检查 key 是否存在
pub fn contains(&self, full_path: &str) -> bool {
parse_namespace_key(full_path)
@@ -563,4 +602,47 @@ mod tests {
// 旧格式/无尾段 → None(调用方保守按字面计)
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None);
}
// ── 跨重启懒回填(跨会话召回设计 缺口3) ──
#[test]
fn backfill_recovers_original_by_content_hash() {
// 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回
let mut hot = NamespaceStore::new(1 << 20);
let original = "目录列表内容\n".repeat(100);
let path = hot.store("list_directory", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&[original.clone(), "无关消息".to_string()],
);
assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str()));
assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读");
}
#[test]
fn backfill_without_original_stays_empty() {
let mut hot = NamespaceStore::new(1 << 20);
let path = hot.store("read_file", &"真原文".repeat(500));
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&["无关A".to_string(), "无关B".to_string()],
);
assert!(recovered.is_empty(), "无对应原文不应误填");
assert_eq!(cold.read_only(&path), None);
assert!(cold.is_empty());
}
#[test]
fn backfill_duplicate_refs_recover_once() {
// 同一引用出现在多条历史消息:找回一次,store 只建一条
let mut hot = NamespaceStore::new(1 << 20);
let original = "z".repeat(3000);
let path = hot.store("grep", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]);
assert_eq!(recovered.len(), 1);
assert_eq!(cold.len(), 1);
}
}