优化: 上下文压缩缓存友好化与召回句柄
- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退 - 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配 - BP3 断点机制+缓存命中率观测日志,调参有据 - 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费 - 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗 - namespace 引用跨重启内容哈希懒回填
This commit is contained in:
@@ -62,6 +62,46 @@ pub fn anthropic_cache_enabled() -> bool {
|
|||||||
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
|
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 实现
|
// Provider 实现
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -147,10 +187,21 @@ impl AnthropicCompatProvider {
|
|||||||
// 构建非 system 消息(保留顺序,合并连续 tool_result)
|
// 构建非 system 消息(保留顺序,合并连续 tool_result)
|
||||||
let mut messages: Vec<serde_json::Value> = Vec::new();
|
let mut messages: Vec<serde_json::Value> = Vec::new();
|
||||||
let mut pending_tool_results: 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 {
|
match m.role {
|
||||||
MessageRole::System => continue,
|
MessageRole::System => {
|
||||||
|
if src_idx > 0 {
|
||||||
|
msgs_after_anchor = Some(0);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
MessageRole::Tool => {
|
MessageRole::Tool => {
|
||||||
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
|
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
|
||||||
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
|
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
|
||||||
@@ -173,7 +224,7 @@ impl AnthropicCompatProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageRole::User => {
|
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)。
|
// 多模态 user 消息 → content blocks 数组(text/image)。
|
||||||
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
|
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
|
||||||
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
|
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
|
||||||
@@ -192,9 +243,12 @@ impl AnthropicCompatProvider {
|
|||||||
} else {
|
} else {
|
||||||
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
|
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
|
||||||
}
|
}
|
||||||
|
if let Some(k) = &mut msgs_after_anchor {
|
||||||
|
*k += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MessageRole::Assistant => {
|
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();
|
let mut content: Vec<serde_json::Value> = Vec::new();
|
||||||
if !m.content.is_empty() {
|
if !m.content.is_empty() {
|
||||||
content.push(serde_json::json!({ "type": "text", "text": m.content }));
|
content.push(serde_json::json!({ "type": "text", "text": m.content }));
|
||||||
@@ -218,10 +272,13 @@ impl AnthropicCompatProvider {
|
|||||||
content.push(serde_json::json!({ "type": "text", "text": "" }));
|
content.push(serde_json::json!({ "type": "text", "text": "" }));
|
||||||
}
|
}
|
||||||
messages.push(serde_json::json!({ "role": "assistant", "content": content }));
|
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
|
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
|
||||||
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
|
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
|
||||||
@@ -240,10 +297,12 @@ impl AnthropicCompatProvider {
|
|||||||
// (正常会话首条本就是 user),远优于丢弃上下文。
|
// (正常会话首条本就是 user),远优于丢弃上下文。
|
||||||
Self::ensure_leading_user(&mut messages);
|
Self::ensure_leading_user(&mut messages);
|
||||||
|
|
||||||
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control,
|
// 消息级缓存断点(开关开):BP2 打在历史段稳定边界,使 system 之后的对话历史
|
||||||
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。
|
// 也可命中(否则只缓存 system,长会话历史段全价重算);BP3(缓存友好开关,默认开)
|
||||||
|
// 加打摘要锚点链之后第一条,BP2 因裁剪边界/压缩失效时 [system+摘要链] 小前缀仍命中。
|
||||||
if anthropic_cache_enabled() {
|
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| {
|
let tools = req.tools.map(|defs| {
|
||||||
@@ -328,34 +387,61 @@ impl AnthropicCompatProvider {
|
|||||||
fn flush_tool_results(
|
fn flush_tool_results(
|
||||||
messages: &mut Vec<serde_json::Value>,
|
messages: &mut Vec<serde_json::Value>,
|
||||||
pending: &mut Vec<serde_json::Value>,
|
pending: &mut Vec<serde_json::Value>,
|
||||||
|
msgs_after_anchor: &mut Option<usize>,
|
||||||
) {
|
) {
|
||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
|
let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
|
||||||
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
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 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
|
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
|
||||||
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
|
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
|
||||||
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
|
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
|
||||||
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
|
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
|
||||||
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
|
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
|
||||||
///
|
///
|
||||||
|
/// BP3 服务于 BP2 频繁失效的场景:BP2 随裁剪边界移动/压缩事件/新近度豁免改写而
|
||||||
|
/// 迁位,前缀反复全价重算;BP3 前缀只含 [system+摘要链+1 条],失效源仅压缩事件,
|
||||||
|
/// 为该小前缀保底命中。与 BP2 同条(短序列)时跳过,BP2 优先。
|
||||||
|
///
|
||||||
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
|
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
|
||||||
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。
|
/// 断点预算:Anthropic 单请求最多 4 个 cache_control——system 1 + BP2 1 + BP3 1 = 3,
|
||||||
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) {
|
/// 余 1 备用。
|
||||||
|
fn apply_message_cache_breakpoint(
|
||||||
|
messages: &mut [serde_json::Value],
|
||||||
|
after_anchor: Option<usize>,
|
||||||
|
) {
|
||||||
let n = messages.len();
|
let n = messages.len();
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let last_is_user =
|
let last_is_user =
|
||||||
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("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 bp2_idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
|
||||||
let msg = &mut messages[idx];
|
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" });
|
let cache_control = serde_json::json!({ "type": "ephemeral" });
|
||||||
match msg.get("content").cloned() {
|
match msg.get("content").cloned() {
|
||||||
Some(serde_json::Value::Array(mut blocks)) => {
|
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,
|
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
|
||||||
reasoning_tokens: 0,
|
reasoning_tokens: 0,
|
||||||
};
|
};
|
||||||
|
log_cache_usage(&usage);
|
||||||
AttemptOutcome::Ok(CompletionResponse {
|
AttemptOutcome::Ok(CompletionResponse {
|
||||||
text,
|
text,
|
||||||
model: resp.model,
|
model: resp.model,
|
||||||
@@ -874,6 +961,12 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
if let Some(err) = chunk.error.as_mut() {
|
if let Some(err) = chunk.error.as_mut() {
|
||||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
*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));
|
chunks.push(Ok(chunk));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1258,7 +1351,7 @@ mod tests {
|
|||||||
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
|
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
|
||||||
serde_json::json!({ "role": "user", "content": "q2" }),
|
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
|
// 前一条 assistant 的末块带 cache_control
|
||||||
let a1 = &messages[1];
|
let a1 = &messages[1];
|
||||||
let blocks = a1["content"].as_array().unwrap();
|
let blocks = a1["content"].as_array().unwrap();
|
||||||
@@ -1275,7 +1368,7 @@ mod tests {
|
|||||||
serde_json::json!({ "role": "assistant", "content": "a1" }),
|
serde_json::json!({ "role": "assistant", "content": "a1" }),
|
||||||
serde_json::json!({ "role": "user", "content": "q2" }),
|
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 数组");
|
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
|
||||||
assert_eq!(blocks.len(), 1);
|
assert_eq!(blocks.len(), 1);
|
||||||
assert_eq!(blocks[0]["type"], "text");
|
assert_eq!(blocks[0]["type"], "text");
|
||||||
@@ -1289,7 +1382,7 @@ mod tests {
|
|||||||
let mut messages = vec![
|
let mut messages = vec![
|
||||||
serde_json::json!({ "role": "user", "content": "only" }),
|
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");
|
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
|
||||||
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
|
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
|
||||||
}
|
}
|
||||||
@@ -1298,10 +1391,152 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cache_breakpoint_empty_messages_noop() {
|
fn cache_breakpoint_empty_messages_noop() {
|
||||||
let mut messages: Vec<serde_json::Value> = vec![];
|
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());
|
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 占位,保留上下文)----------
|
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
||||||
|
|
||||||
/// 辅助:构造 assistant(tool_use) 消息
|
/// 辅助:构造 assistant(tool_use) 消息
|
||||||
|
|||||||
@@ -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("第二批回复"), "第二批原文已追加");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,10 +44,10 @@ fn estimate_message_counts_parts_tokens() {
|
|||||||
mm_tokens,
|
mm_tokens,
|
||||||
text_tokens
|
text_tokens
|
||||||
);
|
);
|
||||||
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
|
// base64 改按图地板计(~1600,与 provider 端按图计费同口径),不随字符串长度膨胀
|
||||||
assert!(
|
assert!(
|
||||||
mm_tokens > 500,
|
mm_tokens >= 1600,
|
||||||
"大 base64 应贡献可观 token,实际 {}",
|
"大 base64 应按图地板计 token,实际 {}",
|
||||||
mm_tokens
|
mm_tokens
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,8 +72,10 @@ fn short_history_no_trim() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn over_budget_trims_old() {
|
fn over_budget_trims_old() {
|
||||||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
|
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区。
|
||||||
let mut mgr = ContextManager::new(cfg(200));
|
// 预算取值须容纳「补偿摘要+保护区 6 条」(中文按 1.2 token/字计约 230)再留裁剪空间,
|
||||||
|
// 否则保护区被挤压、最新消息也被压,测的就不再是「裁旧保新」语义。
|
||||||
|
let mut mgr = ContextManager::new(cfg(400));
|
||||||
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
||||||
for i in 0..20 {
|
for i in 0..20 {
|
||||||
if i % 2 == 0 {
|
if i % 2 == 0 {
|
||||||
@@ -1234,3 +1236,244 @@ fn push_system_anchor_avoids_dangling_tool_call_head() {
|
|||||||
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
|
"悬空头应保持末条,其 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
@@ -20,9 +20,11 @@
|
|||||||
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
||||||
|
|
||||||
mod sanitize;
|
mod sanitize;
|
||||||
|
pub mod archive;
|
||||||
|
|
||||||
use crate::context_helpers::{
|
use crate::context_helpers::{
|
||||||
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||||
|
TRIM_TARGET_RATIO,
|
||||||
};
|
};
|
||||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||||
@@ -78,6 +80,16 @@ pub struct ContextManager {
|
|||||||
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
|
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
|
||||||
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
|
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
|
||||||
needs_full_rewrite: bool,
|
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 {
|
impl ContextManager {
|
||||||
@@ -91,6 +103,9 @@ impl ContextManager {
|
|||||||
pending_topic_marker: None,
|
pending_topic_marker: None,
|
||||||
persisted_msg_count: 0,
|
persisted_msg_count: 0,
|
||||||
needs_full_rewrite: false,
|
needs_full_rewrite: false,
|
||||||
|
compress_recall: None,
|
||||||
|
archive_path: None,
|
||||||
|
cache_friendly: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +168,8 @@ impl ContextManager {
|
|||||||
// 全量重写空列表也会清 DB)。
|
// 全量重写空列表也会清 DB)。
|
||||||
self.persisted_msg_count = 0;
|
self.persisted_msg_count = 0;
|
||||||
self.needs_full_rewrite = true;
|
self.needs_full_rewrite = true;
|
||||||
|
self.compress_recall = None;
|
||||||
|
self.archive_path = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 消息数量
|
/// 消息数量
|
||||||
@@ -196,10 +213,10 @@ impl ContextManager {
|
|||||||
|
|
||||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||||
if self.history_tokens <= available {
|
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)
|
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||||
return (
|
return (
|
||||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -208,15 +225,12 @@ impl ContextManager {
|
|||||||
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
||||||
let units = self.build_eviction_units(protect_start);
|
let units = self.build_eviction_units(protect_start);
|
||||||
|
|
||||||
let mut removed: u64 = 0;
|
// 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
|
||||||
let mut trim_end = 0;
|
let (trim_end, removed) = if self.cache_friendly {
|
||||||
for unit in &units {
|
self.block_trim_target(&units, available)
|
||||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
} else {
|
||||||
break;
|
self.greedy_trim_target(&units, available)
|
||||||
}
|
};
|
||||||
removed += unit.token_sum as u64;
|
|
||||||
trim_end = unit.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
if trim_end == 0 {
|
if trim_end == 0 {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -229,9 +243,9 @@ impl ContextManager {
|
|||||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||||
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
||||||
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
// 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 (
|
return (
|
||||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -264,14 +278,93 @@ impl ContextManager {
|
|||||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||||
trim_end, removed
|
trim_end, removed
|
||||||
);
|
);
|
||||||
let sanitized = Self::sanitize_messages(msgs);
|
let view = self.anchors_to_user(Self::sanitize_messages(msgs));
|
||||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||||
(
|
(
|
||||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||||
true,
|
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`] 子模块,保持 ContextManager::xxx 调用路径)──
|
||||||
|
|
||||||
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
||||||
@@ -668,6 +761,11 @@ impl ContextManager {
|
|||||||
///
|
///
|
||||||
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
||||||
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
|
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)周期性
|
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
|
||||||
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
|
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
|
||||||
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
|
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
|
||||||
@@ -694,10 +792,36 @@ impl ContextManager {
|
|||||||
// newly_compressed 为空,状态未变,无需全量重写)。
|
// newly_compressed 为空,状态未变,无需全量重写)。
|
||||||
if !newly_compressed.is_empty() {
|
if !newly_compressed.is_empty() {
|
||||||
self.needs_full_rewrite = true;
|
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
|
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 压缩正在进行中,触发方应跳过本轮压缩。
|
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
|
||||||
pub fn is_compressing(&self) -> bool {
|
pub fn is_compressing(&self) -> bool {
|
||||||
self.is_compressing
|
self.is_compressing
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use crate::provider::{ChatMessage, MessageRole};
|
|||||||
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
|
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TokenEstimator {
|
pub struct TokenEstimator {
|
||||||
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
|
/// 非 CJK 字符(拉丁/数字/符号)→ token 转换系数(默认 0.35,约 3 字符/token)
|
||||||
pub chars_ratio: f32,
|
pub chars_ratio: f32,
|
||||||
/// 每条消息固定开销(role 标记 + 格式)
|
/// 每条消息固定开销(role 标记 + 格式)
|
||||||
pub per_message_overhead: u32,
|
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)会致含图
|
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
|
||||||
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
|
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
|
||||||
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
|
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
|
||||||
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
|
// 注意单位是 token 不是字符:地板必须在字符折扣之后直接加 token,若混进字符计数
|
||||||
// 会被 0.35 稀释成 560,保底名存实亡。
|
// 会被 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 {
|
impl TokenEstimator {
|
||||||
/// 估算单条消息的 token 数(保守估计)
|
/// 估算单条消息的 token 数(保守估计)
|
||||||
///
|
///
|
||||||
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
/// 文本按 CJK/其余分档计数;图片(base64 与 url)统一按 [`IMAGE_TOKEN_FLOOR`] 计,
|
||||||
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
|
/// 不随 base64 字符串长度膨胀。
|
||||||
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
|
|
||||||
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
|
|
||||||
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
||||||
let mut char_count = msg.content.chars().count();
|
// namespace 引用(Tool 消息)特殊入账:按 ref 尾段携带的原文字节数保守视作字符
|
||||||
// 939① 估算入账修正:Tool 消息 content 为 namespace 引用时,按 ref 尾段携带的
|
// 并按 plain 比率计(多为代码/JSON 输出);中文引用会低估,方向可接受:ref 体量
|
||||||
// 原文字节数入账(字节保守视作字符,中文会 3x 高估——方向安全:宁可高估触发
|
// 本就由 namespace 上限闸门控,此处入账只为预算可见性。
|
||||||
// 裁剪/压缩,不可漏估致预算门失效,后者曾致 provider 超限 400)。旧格式/已淘汰
|
let mut content_tokens = if matches!(msg.role, crate::provider::MessageRole::Tool) {
|
||||||
// 内容(无 ?len= 尾段)按字面字符保守计,行为同前。
|
match crate::namespace_store::parse_ref_content_len(&msg.content) {
|
||||||
if matches!(msg.role, crate::provider::MessageRole::Tool) {
|
Some(n) => (n as f32 * self.chars_ratio).ceil() as u32,
|
||||||
if let Some(n) = crate::namespace_store::parse_ref_content_len(&msg.content) {
|
None => weighted_char_tokens(&msg.content, self.chars_ratio),
|
||||||
char_count = n;
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
|
weighted_char_tokens(&msg.content, self.chars_ratio)
|
||||||
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
|
};
|
||||||
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
|
// 图片地板按 token 直接累加,生效于字符折扣之外(折进字符计数会被 0.35 稀释,
|
||||||
let mut url_image_floor_tokens: u32 = 0;
|
// 1600 保底失效,预算裁剪漏触发 → provider 超限 400)。
|
||||||
|
let mut image_floor_tokens: u32 = 0;
|
||||||
if let Some(parts) = &msg.parts {
|
if let Some(parts) = &msg.parts {
|
||||||
for p in parts {
|
for p in parts {
|
||||||
match p {
|
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, .. } => {
|
crate::provider::ContentPart::Image { base64, url, .. } => {
|
||||||
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
// base64 与 url 语义等价(都是一张图),同用地板;有 base64 时
|
||||||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
// url 为空(构造端二选一),不重复计
|
||||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
|
if base64.is_some() || url.is_some() {
|
||||||
if let Some(b) = base64 {
|
image_floor_tokens =
|
||||||
char_count += b.chars().count();
|
image_floor_tokens.saturating_add(IMAGE_TOKEN_FLOOR as u32);
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
|
let mut total = content_tokens + image_floor_tokens + self.per_message_overhead;
|
||||||
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
|
|
||||||
|
|
||||||
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
||||||
if let Some(ref calls) = msg.tool_calls {
|
if let Some(ref calls) = msg.tool_calls {
|
||||||
@@ -113,9 +139,9 @@ impl TokenEstimator {
|
|||||||
total
|
total
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 估算纯文本字符串的 token 数(用于 system prompt)
|
/// 估算纯文本字符串的 token 数(用于 system prompt),CJK 分档同 [`Self::estimate_message`]
|
||||||
pub fn estimate_text(&self, text: &str) -> u32 {
|
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 个完整用户轮次)
|
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
||||||
pub const PROTECT_COUNT: usize = 6;
|
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 摘要插回发送视图
|
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
|
||||||
@@ -1755,8 +1789,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn url_image_token_floor_survives_ratio_discount() {
|
fn url_image_token_floor_survives_ratio_discount() {
|
||||||
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
|
// 图地板语义是"每图至少 ~1600 token",必须生效于字符折扣之外:
|
||||||
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
|
// 若地板折进字符计数再乘比率,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
|
||||||
let est = TokenEstimator::default();
|
let est = TokenEstimator::default();
|
||||||
let msg = ChatMessage::user_parts(
|
let msg = ChatMessage::user_parts(
|
||||||
"t",
|
"t",
|
||||||
@@ -1764,10 +1798,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let tokens = est.estimate_message(&msg);
|
let tokens = est.estimate_message(&msg);
|
||||||
assert!(
|
assert!(
|
||||||
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
|
tokens >= IMAGE_TOKEN_FLOOR as u32,
|
||||||
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
|
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
|
||||||
tokens,
|
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})");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
|
|||||||
.filter(|_| full_path.contains("?len="))
|
.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 条目
|
/// namespace 条目
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NamespaceEntry {
|
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 是否存在
|
/// 检查 key 是否存在
|
||||||
pub fn contains(&self, full_path: &str) -> bool {
|
pub fn contains(&self, full_path: &str) -> bool {
|
||||||
parse_namespace_key(full_path)
|
parse_namespace_key(full_path)
|
||||||
@@ -563,4 +602,47 @@ mod tests {
|
|||||||
// 旧格式/无尾段 → None(调用方保守按字面计)
|
// 旧格式/无尾段 → None(调用方保守按字面计)
|
||||||
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user