压缩策略
当对话历史增长到超过模型上下文窗口时,RAF 通过压缩策略自动裁剪消息列表。本章详解三种内置策略的工作原理、使用方式和扩展方法。
压缩在生命周期中的位置
Phase 1: Pre-invocation → 组装完整消息列表
Phase 1.5: Compression → 检查 token 预算 → 压缩消息 ← 本章重点
Phase 2: LLM 调用 → 使用压缩后的消息
ICompressionStrategy trait
pub trait ICompressionStrategy: Send + Sync {
fn name(&self) -> &str;
fn compress(
&self,
messages: Vec<ChatMessage>,
budget: usize, // 目标 token 预算
counter: &dyn ITokenCounter, // token 计数器
) -> Result<Vec<ChatMessage>>;
}
基于 Token 预算的压缩准备
压缩机制的触发条件十分严格——只有同时配置了压缩策略和 Token 计数器才会启用。这一设计确保了只有当开发者明确需要压缩能力时,消息列表才会被修改。
触发压缩需要满足以下条件:压缩策略已设置、Token 计数器已配置、当前消息的 Token 数超过了模型元数据提供的 `input_budget()`。`ModelMetadata.input_budget()` 的计算方式为 `context_window_tokens - max_output_tokens`,即上下文窗口扣除最大输出后剩余的输入预算。具体的 `IChatClient` 实现(如 `ChatClient`)负责提供准确的 `model_metadata()`。
如果压缩失败,框架不会中断流程——而是使用原始消息继续执行,并通过 tracing 记录警告日志。这确保了压缩策略的 bug 不会导致 Agent 完全不可用。
SlidingWindowStrategy(滑动窗口)
最简单的压缩策略——仅保留最近的 N 条消息,丢弃更早的消息。
pub struct SlidingWindowStrategy {
pub max_messages: usize, // 保留的非系统消息最大数量
}
工作原理
1. 分离系统消息(始终保留)和非系统消息 2. 如果非系统消息 ≤ `max_messages`,不做任何操作 3. 否则,只保留最近的 `max_messages` 条非系统消息
impl ICompressionStrategy for SlidingWindowStrategy {
fn compress(
&self,
messages: Vec<ChatMessage>,
_budget: usize, // 不关心 token 预算
_counter: &dyn ITokenCounter, // 不进行 token 计数
) -> Result<Vec<ChatMessage>> {
// 分离 system/non-system
// 保留最近 max_messages 条
// system + recent → 返回
}
}
使用方式
use rust_agent_framework::compression::SlidingWindowStrategy;
let agent = AgentBuilder::new("agent")
.chat_client(client)
.with_compression_strategy(Arc::new(SlidingWindowStrategy::new(20)))
.build()?;
**适用场景**:简单交互、不需要复杂预算计算的场景。
**局限**:不关心实际 token 数。如果单条消息很大(如注入的代码上下文),仍可能超出预算。
TokenBudgetStrategy(Token 预算)
更精细的策略——基于实际 token 数量进行压缩。遵循两层淘汰机制:
1. 先尝试淘汰旧的工具调用组(替换为摘要) 2. 如果仍超预算,从最早的消息开始逐个移除
pub struct TokenBudgetStrategy {
/// 工具结果淘汰阈值比例
/// 默认 0.5:当 token 使用达到预算的 50% 时开始淘汰工具结果组
pub tool_result_eviction_threshold: f64,
}
工作原理
flowchart TD
Start[收到消息列表 + 预算] --> Check{当前 Token ≤ 预算?}
Check --> |是| Done[直接返回]
Check --> |否| Separate[分离 System 消息]
Separate --> Phase1[Phase 1: 工具结果淘汰]
Phase1 --> CheckEvict{Token 超淘汰阈值?}
CheckEvict --> |是| Evict[替换旧工具调用组为摘要]
Evict --> Phase2
CheckEvict --> |否| Phase2
Phase2[Phase 2: 截断] --> Trunc{Token ≤ 预算?}
Trunc --> |否| Remove[移除最早的非 System 消息]
Remove --> Trunc
Trunc --> |是| Final[System + 剩余消息 → 返回]
Phase 1: 工具结果淘汰
将旧的 `Assistant(tool_calls) + Tool(result)` 消息组替换为摘要消息:
// 原始消息组:
// Assistant("好的,我来读取文件", tool_calls=[read_file("main.rs")])
// Tool("文件内容: fn main() {}")
// 替换为摘要:
// Assistant("[Earlier tool calls: 1 call(s) were made and completed]")
这大大减少了 token 消耗,同时保留了"曾经调用过工具"的语义信息。
Phase 2: 逐条截断
从最早的非 System 消息开始移除,直到总 token 数符合预算。
使用方式
use rust_agent_framework::compression::TokenBudgetStrategy;
let agent = AgentBuilder::new("agent")
.chat_client(client)
.with_compression_strategy(Arc::new(
TokenBudgetStrategy::new()
.with_eviction_threshold(0.6) // 达到 60% 预算时开始淘汰工具结果
))
.with_token_counter(Arc::new(EstimateCounter::new()))
.build()?;
**适用场景**:需要精确控制 token 消耗、对话历史较长的场景。
**局限**:依赖 `ITokenCounter` 的计数的准确性;截断可能丢失重要的上下文信息。
CompressionPipeline(压缩管道)
链式组合多个压缩策略,按顺序应用:
pub struct CompressionPipeline {
strategies: Vec<Box<dyn ICompressionStrategy>>,
}
工作原理
impl ICompressionStrategy for CompressionPipeline {
fn compress(&self, mut messages: Vec<ChatMessage>, budget: usize, counter: &dyn ITokenCounter)
-> Result<Vec<ChatMessage>>
{
for strategy in &self.strategies {
// 如果已符合预算,跳过后续策略
if counter.count_tokens(&messages) <= budget {
break;
}
messages = strategy.compress(messages, budget, counter)?;
}
Ok(messages)
}
}
推荐组合
use rust_agent_framework::compression::{CompressionPipeline, SlidingWindowStrategy, TokenBudgetStrategy};
let pipeline = CompressionPipeline::new()
.add_strategy(Box::new(SlidingWindowStrategy::new(100))) // 粗粒度:消息数量限制
.add_strategy(Box::new(TokenBudgetStrategy::new())); // 细粒度:精确 token 控制
let agent = AgentBuilder::new("agent")
.chat_client(client)
.with_compression_strategy(Arc::new(pipeline))
.with_token_counter(Arc::new(EstimateCounter::new()))
.build()?;
**执行顺序**:`SlidingWindow` 先做粗粒度裁剪,`TokenBudget` 再做精确的 token 级控制。如果 `SlidingWindow` 后已符合预算,`TokenBudget` 被跳过。
ITokenCounter trait
压缩策略需要准确的 Token 计数。`ITokenCounter` 提供两种计数方法:
pub trait ITokenCounter: Send + Sync {
fn count_tokens(&self, messages: &[ChatMessage]) -> usize;
fn count_text_tokens(&self, text: &str) -> usize;
}
EstimateCounter(估算计数器)
默认实现,不需要额外依赖。基于启发式规则:
- 每 token ≈ 4 个字符 - 每条消息格式开销 ≈ 4 tokens(角色标签、分隔符) - 工具调用额外开销(名称、参数) - 助手响应引导 token ≈ 3
impl ITokenCounter for EstimateCounter {
fn count_tokens(&self, messages: &[ChatMessage]) -> usize {
let mut total = 0;
for msg in messages {
total += 4; // 格式开销
total += self.estimate(&msg.content); // 内容
// ... 工具调用开销 ...
}
total += 3; // assistant priming
total
}
fn estimate(&self, text: &str) -> usize {
(text.len() as f32 / self.chars_per_token).ceil() as usize
}
}
**特点**:快速、无外部依赖、估算偏保守(略高估),适用于不需要精确控制的场景。
TiktokenCounter(精确计数器)
需要启用 `tiktoken` feature:
rust-agent-framework = { git = "...", features = ["tiktoken"] }
#[cfg(feature = "tiktoken")]
pub struct TiktokenCounter {
encoding: Option<tiktoken_rs::CoreBPE>,
fallback: EstimateCounter, // tiktoken 不可用时回退
}
TiktokenCounter 使用 OpenAI 的 tiktoken 库进行字节对编码(BPE)级别的 Token 计数,比估算计数器精确得多。当模型编码不可用时自动回退到 `EstimateCounter`,确保计数始终可用。
压缩效果验证
let counter = EstimateCounter::new();
let strategy = TokenBudgetStrategy::new();
let original = vec![
ChatMessage::system("你是一个助手"),
ChatMessage::user("问题 1"), ChatMessage::assistant("回答 1"),
ChatMessage::user("问题 2"), ChatMessage::assistant("回答 2"),
// ... 50 条历史消息 ...
];
let budget = 500; // 目标预算
let before = counter.count_tokens(&original);
let compressed = strategy.compress(original.clone(), budget, &counter)?;
let after = counter.count_tokens(&compressed);
println!("压缩前: {} tokens, {} 条消息", before, original.len());
println!("压缩后: {} tokens, {} 条消息", after, compressed.len());
println!("压缩比: {:.1}%", (1.0 - after as f64 / before as f64) * 100.0);
自定义压缩策略
实现 `ICompressionStrategy` trait:
use rust_agent_core::{ICompressionStrategy, ChatMessage, ITokenCounter, Result, MessageRole};
/// 保留最近 N 轮对话(每轮 = user + assistant 对)
pub struct RoundWindowStrategy {
pub max_rounds: usize,
}
impl ICompressionStrategy for RoundWindowStrategy {
fn name(&self) -> &str { "RoundWindow" }
fn compress(
&self,
messages: Vec<ChatMessage>,
_budget: usize,
_counter: &dyn ITokenCounter,
) -> Result<Vec<ChatMessage>> {
let mut system_msgs = Vec::new();
let mut rounds: Vec<Vec<ChatMessage>> = Vec::new();
let mut current_round = Vec::new();
for msg in messages {
match msg.role {
MessageRole::System => system_msgs.push(msg),
MessageRole::User => {
if !current_round.is_empty() {
rounds.push(std::mem::take(&mut current_round));
}
current_round.push(msg);
}
_ => current_round.push(msg),
}
}
if !current_round.is_empty() {
rounds.push(current_round);
}
// 只保留最近 max_rounds 轮
let keep_from = rounds.len().saturating_sub(self.max_rounds);
let mut result = system_msgs;
for round in rounds.into_iter().skip(keep_from) {
result.extend(round);
}
Ok(result)
}
}
// 使用
let agent = AgentBuilder::new("agent")
.chat_client(client)
.with_compression_strategy(Arc::new(RoundWindowStrategy { max_rounds: 5 }))
.with_token_counter(Arc::new(EstimateCounter::new()))
.build()?;
声明式配置(YAML / JSON)
`PromptAgentData` 支持直接在 Agent 声明中配置压缩,由 `DeclAgentBuilder` 接入 `AgentBuilder`:
kind: prompt
name: long-context-agent
compression:
kind: sliding_window
windowSize: 30
tokenCounter:
kind: estimate
支持的 `compression.kind`:
| kind | 对应 Rust 类型 | 主要参数 | |------|---------------|---------| | `sliding_window` | `SlidingWindowStrategy` | `windowSize` | | `token_budget` | `TokenBudgetStrategy` | `toolResultEvictionThreshold`(可选) |
`tokenCounter.kind` 目前仅 `estimate`(`EstimateCounter`)。未配置 `tokenCounter` 但配置了 `compression` 时,自动使用 estimate。
---
最佳实践
1. **始终同时配置策略和计数器**:没有计数器的压缩策略会被跳过。 2. **使用估算计数器节省依赖**:大多数场景下 `EstimateCounter` 足够准确。 3. **管道组合**:先粗后细——`SlidingWindow` 控制消息数量,`TokenBudget` 控制精确 token。 4. **保守估算**:`EstimateCounter` 有意略高估,确保不超出上下文窗口。 5. **在生产环境启用精确计数**:使用 `tiktoken` feature 获得精确的 token 计数,避免低估导致的截断。
下一步
关于 Agent 引擎的深入内容到此结束。如需了解工作流编排、Web 搜索、RAG 等扩展能力,请参考各扩展 crate 的文档。