新增: 上下文裁剪淘汰补偿摘要
超预算视图裁剪时,被淘汰文本提炼关键词概要插回发送视图头部,补偿无声丢弃的早期主题 摘要以 user 角色注入:system 角色会被兼容层折叠进带 cache_control 的顶层段,纯视图每轮再生成会持续改写缓存指纹 纯视图语义不落库,被淘汰原文存储态不变;压缩失败兜底摘要与淘汰摘要共用提炼核心
This commit is contained in:
@@ -7,7 +7,9 @@
|
|||||||
#![cfg(test)]
|
#![cfg(test)]
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::context_helpers::{ContextConfig, TokenEstimator, PROTECT_COUNT};
|
use crate::context_helpers::{
|
||||||
|
ContextConfig, EVICTION_SUMMARY_MAX_CHARS, TokenEstimator, PROTECT_COUNT,
|
||||||
|
};
|
||||||
use crate::provider::ToolCall;
|
use crate::provider::ToolCall;
|
||||||
|
|
||||||
fn cfg(max_tokens: u32) -> ContextConfig {
|
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||||
@@ -994,3 +996,160 @@ fn phase1_append_after_dirty_stays_dirty_until_save() {
|
|||||||
mgr.advance_persisted_count(2);
|
mgr.advance_persisted_count(2);
|
||||||
assert!(!mgr.needs_full_rewrite());
|
assert!(!mgr.needs_full_rewrite());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 视图裁剪补偿摘要(淘汰区头部 user 概要,防无声丢失)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_inserts_summary_message_at_view_head() {
|
||||||
|
// 触发淘汰 → 视图头部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题),
|
||||||
|
// 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。
|
||||||
|
let mut mgr = ContextManager::new(cfg(200));
|
||||||
|
for i in 0..20 {
|
||||||
|
if i % 2 == 0 {
|
||||||
|
mgr.push(ChatMessage::user(&format!(
|
||||||
|
"审批流程与审批门禁需要适配 第{}条消息内容足够长确保淘汰文本合计超过一百字符", i
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
mgr.push(ChatMessage::assistant(&format!(
|
||||||
|
"第{}条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑", i
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
|
assert!(trimmed, "超预算应触发裁剪");
|
||||||
|
|
||||||
|
// 视图头部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题)
|
||||||
|
let head = msgs.first().expect("视图不应为空");
|
||||||
|
assert!(
|
||||||
|
matches!(head.role, MessageRole::User),
|
||||||
|
"视图头部应为 user 摘要, 实际 {:?}",
|
||||||
|
head.role
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
head.content.starts_with("[早期对话概要] 高频主题: "),
|
||||||
|
"摘要格式不符, 实际 {}",
|
||||||
|
head.content
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
head.content.contains("审批"),
|
||||||
|
"摘要应含被淘汰区高频主题词'审批', 实际 {}",
|
||||||
|
head.content
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
head.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
|
||||||
|
"摘要应 ≤ {} 字符, 实际 {}",
|
||||||
|
EVICTION_SUMMARY_MAX_CHARS,
|
||||||
|
head.content.chars().count()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保护消息完好:末条(i=19 assistant)保留;摘要 user 与首轮真实 user 相邻,由
|
||||||
|
// sanitize 的 ensure_sequence_legal 合并(摘要在前,换行接原文),故 msgs[1] 为 i=1 的 assistant
|
||||||
|
assert_eq!(
|
||||||
|
msgs.last().unwrap().content,
|
||||||
|
"第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑",
|
||||||
|
"保护区最新消息被误裁"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(msgs[1].role, MessageRole::Assistant),
|
||||||
|
"摘要与首轮 user 合并后,第二条应为 assistant, 实际 {:?}",
|
||||||
|
msgs[1].role
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msgs[0].content.contains("审批流程与审批门禁需要适配"),
|
||||||
|
"合并不应丢失被保留的首轮真实 user 文本, 实际 {}",
|
||||||
|
msgs[0].content
|
||||||
|
);
|
||||||
|
|
||||||
|
// 纯视图:摘要不进内存全量,被淘汰原文存储态不变
|
||||||
|
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
|
||||||
|
assert!(
|
||||||
|
mgr.all_messages_clone()
|
||||||
|
.iter()
|
||||||
|
.all(|m| !m.content.starts_with("[早期对话概要]")),
|
||||||
|
"补偿摘要不应写入存储(纯发送视图)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_tag(m: &ChatMessage) -> &'static str {
|
||||||
|
match m.role {
|
||||||
|
MessageRole::System => "system",
|
||||||
|
MessageRole::User => "user",
|
||||||
|
MessageRole::Assistant => "assistant",
|
||||||
|
MessageRole::Tool => "tool",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_eviction_output_unchanged_no_summary() {
|
||||||
|
// 预算内 → 输出与改动前一致:消息序列逐条原样,无补偿摘要插入(回归护栏)。
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("第一条问题"));
|
||||||
|
mgr.push(ChatMessage::assistant("第一条回复"));
|
||||||
|
mgr.push(ChatMessage::user("第二条问题"));
|
||||||
|
mgr.push(ChatMessage::assistant("第二条回复"));
|
||||||
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
|
assert!(!trimmed);
|
||||||
|
assert_eq!(msgs.len(), 4);
|
||||||
|
let pair: Vec<(&str, &str)> = msgs
|
||||||
|
.iter()
|
||||||
|
.map(|m| (role_tag(m), m.content.as_str()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
pair,
|
||||||
|
vec![
|
||||||
|
("user", "第一条问题"),
|
||||||
|
("assistant", "第一条回复"),
|
||||||
|
("user", "第二条问题"),
|
||||||
|
("assistant", "第二条回复"),
|
||||||
|
],
|
||||||
|
"预算内输出应与历史序列逐条一致"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_tiny_text_no_summary() {
|
||||||
|
// 被淘汰内容极短(单字消息,合计 <100 字符)→ 不值得产摘要,不插补偿摘要消息。
|
||||||
|
// 20 条消息(约 5 token/条),cfg(40) 触发裁剪,淘汰前缀合计仅二十余字符。
|
||||||
|
let mut mgr = ContextManager::new(cfg(40));
|
||||||
|
for i in 0..20 {
|
||||||
|
if i % 2 == 0 {
|
||||||
|
mgr.push(ChatMessage::user(&format!("问{}", i)));
|
||||||
|
} else {
|
||||||
|
mgr.push(ChatMessage::assistant(&format!("答{}", i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
|
assert!(trimmed, "超预算应触发裁剪");
|
||||||
|
assert!(
|
||||||
|
msgs.iter()
|
||||||
|
.all(|m| !m.content.starts_with("[早期对话概要]")),
|
||||||
|
"极短淘汰文本不应产生补偿摘要, 实际 {:?}",
|
||||||
|
msgs.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_no_user_text_no_summary() {
|
||||||
|
// 被淘汰区无任何 user 消息(仅 assistant 长文本)→ 提炼核心只认 user → 无关键词 → 不插摘要。
|
||||||
|
// 布局:10 条 assistant 长文本(淘汰候选)+ 6 条短 user(保护区),预算卡在淘汰 4 条 assistant。
|
||||||
|
let mut mgr = ContextManager::new(cfg(150));
|
||||||
|
for i in 0..10 {
|
||||||
|
mgr.push(ChatMessage::assistant(&format!(
|
||||||
|
"这是一条没有主题价值的 assistant 长文本第{}条,用于撑爆预算但提炼不出关键词", i
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for i in 0..6 {
|
||||||
|
mgr.push(ChatMessage::user(&format!("问{}", i)));
|
||||||
|
}
|
||||||
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
|
assert!(trimmed, "超预算应触发裁剪");
|
||||||
|
assert!(
|
||||||
|
msgs.iter()
|
||||||
|
.all(|m| !m.content.starts_with("[早期对话概要]")),
|
||||||
|
"无可提炼关键词(无 user)时不应插补偿摘要"
|
||||||
|
);
|
||||||
|
assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量");
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
mod sanitize;
|
mod sanitize;
|
||||||
|
|
||||||
use crate::context_helpers::{
|
use crate::context_helpers::{
|
||||||
classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||||
};
|
};
|
||||||
// 重导出:保持 `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 等调用方零变更)。
|
||||||
@@ -179,7 +179,8 @@ impl ContextManager {
|
|||||||
/// 构建发送给 LLM 的消息列表
|
/// 构建发送给 LLM 的消息列表
|
||||||
///
|
///
|
||||||
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
|
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
|
||||||
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。
|
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条),并在
|
||||||
|
/// 被淘汰区头部插入关键词补偿摘要(role=system,纯视图易变区,不落库不改存储)。
|
||||||
/// 返回 (消息列表, 是否发生了裁剪)。
|
/// 返回 (消息列表, 是否发生了裁剪)。
|
||||||
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||||
let budget = self.budget_limit();
|
let budget = self.budget_limit();
|
||||||
@@ -235,10 +236,27 @@ impl ContextManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
|
let evicted: Vec<ChatMessage> = self.messages[..trim_end]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| t.message.clone())
|
.map(|t| t.message.clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
let mut msgs: Vec<ChatMessage> = self.messages[trim_end..]
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.message.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
|
||||||
|
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送
|
||||||
|
// 视图最前部(淘汰区头部、保护消息之前)。角色必须 user:role=system 会被
|
||||||
|
// anthropic_compat 折叠进带 cache_control 的顶层 system 段,而本摘要纯视图
|
||||||
|
// 每轮即时再生成(淘汰区单调增长致内容逐轮漂移),落 system 即逐轮改写缓存
|
||||||
|
// 指纹使全部前缀缓存失效;连续 role 由既有 sanitize/协议层自愈兜底。摘要只进
|
||||||
|
// messages 易变区,严禁拼进 system_prompt/任何稳定段;纯视图语义:不落库、
|
||||||
|
// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。
|
||||||
|
// 极短/空淘汰文本提炼为空 → 跳过(不值得插)。
|
||||||
|
if let Some(text) = build_eviction_summary_text(&evicted) {
|
||||||
|
msgs.insert(0, ChatMessage::user(text));
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||||
|
|||||||
@@ -185,20 +185,15 @@ pub fn classify_group(msg: &ChatMessage) -> MessageGroup {
|
|||||||
// 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM)
|
// 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。
|
/// 从消息提取高频关键词(提炼核心,纯函数无 LLM)— 关键词摘要/淘汰补偿摘要共用。
|
||||||
///
|
|
||||||
/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心
|
|
||||||
/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要
|
|
||||||
/// 塞回首条,作为续接锚点。
|
|
||||||
///
|
///
|
||||||
/// 策略(纯启发式,无 tokenizer 依赖):
|
/// 策略(纯启发式,无 tokenizer 依赖):
|
||||||
/// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。
|
/// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。
|
||||||
/// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。
|
/// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。
|
||||||
/// - 去停用词(常见虚词/助词,中英混合)。
|
/// - 去停用词(常见虚词/助词,中英混合)。
|
||||||
/// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。
|
/// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。
|
||||||
/// - 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。
|
/// - 空消息/无可提取词 → 空 Vec,调用方判空跳过。
|
||||||
pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
|
pub fn extract_top_keywords(msgs: &[ChatMessage], top_n: usize) -> Vec<String> {
|
||||||
const TOP_N: usize = 10;
|
|
||||||
const MIN_WORD_LEN: usize = 2;
|
const MIN_WORD_LEN: usize = 2;
|
||||||
|
|
||||||
// 中英混合停用词表(高频虚词/助词/常见无主题意义词)。
|
// 中英混合停用词表(高频虚词/助词/常见无主题意义词)。
|
||||||
@@ -239,7 +234,7 @@ pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if freq.is_empty() {
|
if freq.is_empty() {
|
||||||
return String::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut entries: Vec<(String, u32)> = freq.into_iter().collect();
|
let mut entries: Vec<(String, u32)> = freq.into_iter().collect();
|
||||||
@@ -250,17 +245,82 @@ pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
|
|||||||
.then_with(|| a.0.cmp(&b.0))
|
.then_with(|| a.0.cmp(&b.0))
|
||||||
});
|
});
|
||||||
|
|
||||||
let keywords: Vec<&str> = entries.iter().take(TOP_N).map(|(w, _)| w.as_str()).collect();
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.take(top_n)
|
||||||
|
.map(|(w, _)| w)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。
|
||||||
|
///
|
||||||
|
/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心
|
||||||
|
/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要
|
||||||
|
/// 塞回首条,作为续接锚点。提炼核心见 [`extract_top_keywords`]。
|
||||||
|
/// 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。
|
||||||
|
pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
|
||||||
|
const TOP_N: usize = 10;
|
||||||
|
let keywords = extract_top_keywords(msgs, TOP_N);
|
||||||
if keywords.is_empty() {
|
if keywords.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}",
|
"[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}",
|
||||||
keywords.join("、")
|
keywords.join("、")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图裁剪淘汰补偿摘要 — 从被淘汰消息提炼一短段主题概要(纯函数)。
|
||||||
|
///
|
||||||
|
/// 背景:build_for_request 超预算按淘汰单元裁剪发送视图时,被淘汰内容从此对 LLM 不可见
|
||||||
|
/// 且无任何补偿,与压缩路径(有摘要补偿)行为不一致。本函数把被淘汰文本提炼成
|
||||||
|
/// 「[早期对话概要] 高频主题: …」一短段,调用方将其作为 role=user 消息插回发送视图
|
||||||
|
/// 被淘汰区头部(role=system 会被 anthropic_compat 折叠进带 cache_control 的顶层
|
||||||
|
/// system 段,而本摘要每轮再生成,会持续改写缓存指纹)。
|
||||||
|
///
|
||||||
|
/// **缓存铁律**:产出只进 messages 列表(易变区),严禁拼进 system_prompt 或任何
|
||||||
|
/// 稳定段/缓存指纹相关结构。build_for_request 为纯视图:每次请求即时生成,不落库、
|
||||||
|
/// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。
|
||||||
|
///
|
||||||
|
/// 策略:
|
||||||
|
/// - user/assistant 取全文,tool 结果仅取首行(大段 JSON/日志无主题价值);
|
||||||
|
/// - 被淘汰文本合计过短(< 100 字符)不值得产摘要 → None;
|
||||||
|
/// - 经 [`extract_top_keywords`] 提炼,空/无有效文本 → None;
|
||||||
|
/// - 硬截断至 [`EVICTION_SUMMARY_MAX_CHARS`] 字符(按 char 边界,防汉字切半)。
|
||||||
|
pub fn build_eviction_summary_text(msgs: &[ChatMessage]) -> Option<String> {
|
||||||
|
/// 被淘汰内容合计低于此字符数则不值得产摘要(仅淘汰一两条寒暄无需补偿)。
|
||||||
|
const EVICTION_SUMMARY_MIN_CHARS: usize = 100;
|
||||||
|
const KEYWORD_TOP_N: usize = 10;
|
||||||
|
|
||||||
|
// 收集被淘汰文本:user/assistant 取全文,tool 取首行;合计字符数做"值得与否"闸门。
|
||||||
|
let feed: Vec<ChatMessage> = msgs
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let mut m = m.clone();
|
||||||
|
if matches!(m.role, MessageRole::Tool) {
|
||||||
|
m.content = m.content.lines().next().unwrap_or("").to_string();
|
||||||
|
}
|
||||||
|
m
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let total_chars: usize = feed.iter().map(|m| m.content.chars().count()).sum();
|
||||||
|
if total_chars < EVICTION_SUMMARY_MIN_CHARS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let keywords = extract_top_keywords(&feed, KEYWORD_TOP_N);
|
||||||
|
if keywords.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut text = format!("[早期对话概要] 高频主题: {}", keywords.join("、"));
|
||||||
|
// 硬上限截断(按 char 计数,多字节汉字不会切出非法边界)。
|
||||||
|
if text.chars().count() > EVICTION_SUMMARY_MAX_CHARS {
|
||||||
|
text = text.chars().take(EVICTION_SUMMARY_MAX_CHARS).collect();
|
||||||
|
}
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
|
||||||
/// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。
|
/// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。
|
||||||
///
|
///
|
||||||
/// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request);
|
/// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request);
|
||||||
@@ -553,6 +613,13 @@ pub struct EvictionUnit {
|
|||||||
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
||||||
pub const PROTECT_COUNT: usize = 6;
|
pub const PROTECT_COUNT: usize = 6;
|
||||||
|
|
||||||
|
/// 淘汰补偿摘要的硬上限(字符数)。
|
||||||
|
///
|
||||||
|
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
|
||||||
|
/// 头部(补偿无声丢弃的早期主题,对齐压缩路径的"裁剪有补偿"语义)。摘要过长会吃掉
|
||||||
|
/// 本次裁剪腾出的预算,故硬截断到此上限(≈百 token 量级,相对淘汰收益可忽略)。
|
||||||
|
pub const EVICTION_SUMMARY_MAX_CHARS: usize = 300;
|
||||||
|
|
||||||
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
|
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
|
||||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||||
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||||
@@ -805,7 +872,7 @@ impl WorkingContext {
|
|||||||
self.version.goals_changed_at = self.version.current_turn;
|
self.version.goals_changed_at = self.version.current_turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 压缩后重锚定:历史消息已被摘要替换,从 T3 JSON 卡片(字段 topics/decisions/key_files,
|
/// 压缩后重锚定:历史消息已被摘要替换,从压缩摘要 JSON 卡片(字段 topics/decisions/key_files,
|
||||||
/// decisions 元素形如 {"summary": "..."})恢复决策至上限 5 条(每条 decision 文本截 120 字符,
|
/// decisions 元素形如 {"summary": "..."})恢复决策至上限 5 条(每条 decision 文本截 120 字符,
|
||||||
/// question/reason 留空);随后调 version.reset_all(json_card, 自增后的 current_turn)。
|
/// question/reason 留空);随后调 version.reset_all(json_card, 自增后的 current_turn)。
|
||||||
/// Active 目标保留不丢(压缩不应丢目标)。current_turn 自增 1。
|
/// Active 目标保留不丢(压缩不应丢目标)。current_turn 自增 1。
|
||||||
@@ -900,6 +967,66 @@ mod tests {
|
|||||||
// token 频次 1 也应入榜(去停用词后剩余的有意义词)。
|
// token 频次 1 也应入榜(去停用词后剩余的有意义词)。
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 视图裁剪淘汰补偿摘要纯函数(build_eviction_summary_text)测试 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_summary_short_text_returns_none() {
|
||||||
|
// 合计 < 100 字符 → 不值得产摘要。
|
||||||
|
let msgs = vec![ChatMessage::user("短文本"), ChatMessage::assistant("另一条")];
|
||||||
|
assert_eq!(build_eviction_summary_text(&msgs), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_summary_no_user_returns_none() {
|
||||||
|
// 无 user 消息 → 提炼核心不认 assistant → 无关键词 → None(即使合计超 100 字符)。
|
||||||
|
let long = "这是一段没有用户主题的 assistant 长文本,重复三次以超过一百字符的最短闸门验证。".repeat(3);
|
||||||
|
assert!(long.chars().count() > 100);
|
||||||
|
let msgs = vec![ChatMessage::assistant(&long), ChatMessage::assistant(&long)];
|
||||||
|
assert_eq!(build_eviction_summary_text(&msgs), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_summary_formats_prefix_and_keyword() {
|
||||||
|
// 合计须过 100 字符闸门:user 文本重复 10 次(~70 字符)+ assistant(~37 字符)。
|
||||||
|
let user_text = "审批流程配置 ".repeat(10);
|
||||||
|
let msgs = vec![
|
||||||
|
ChatMessage::user(user_text.trim()),
|
||||||
|
ChatMessage::assistant("好的,已了解审批流程诉求,这里是一段足够长的回复用于通过最短字符闸门检查。"),
|
||||||
|
];
|
||||||
|
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
|
||||||
|
assert!(text.starts_with("[早期对话概要] 高频主题: "), "实际 {}", text);
|
||||||
|
assert!(text.contains("审批"), "应含高频词, 实际 {}", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_summary_tool_result_takes_first_line() {
|
||||||
|
// tool 结果取首行:多行 JSON 不整体进提炼,首行计入闸门字符数,摘要仍可产出。
|
||||||
|
// user 文本重复 3 次(~110 字符)+ tool 首行(~24 字符)合计过 100 闸门。
|
||||||
|
let tool = ChatMessage::tool_result("tc1", "{\"result\":\"第一行头部\"}\n第二行\n第三行");
|
||||||
|
let user_text = "预算闸门验证消息,这条 user 消息携带足够字符数确保超过下限触发提炼。".repeat(3);
|
||||||
|
let text = build_eviction_summary_text(&[ChatMessage::user(&user_text), tool])
|
||||||
|
.expect("应产出摘要");
|
||||||
|
assert!(text.starts_with("[早期对话概要] 高频主题: "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_summary_hard_capped_at_max_chars() {
|
||||||
|
// 12 条纯标识符消息(每个标识符 39 字符,freq 均等且无中文干扰)→ top-10 全为长词,
|
||||||
|
// 总长超 300 → 硬截断到 EVICTION_SUMMARY_MAX_CHARS。
|
||||||
|
let msgs: Vec<ChatMessage> = (0..12)
|
||||||
|
.map(|i| {
|
||||||
|
ChatMessage::user(&format!("long_identifier_{:02}_for_truncation_probe", i))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
|
||||||
|
assert_eq!(
|
||||||
|
text.chars().count(),
|
||||||
|
EVICTION_SUMMARY_MAX_CHARS,
|
||||||
|
"超长时应精确截断到硬上限, 实际 {}",
|
||||||
|
text.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ──
|
// ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user