重构: 拆context.rs核心库(strategy自底向上·纯函数抽离)
- 新建 df-ai/context_helpers.rs(195行): 纯函数/类型/常量(TokenEstimator/ContextConfig/MessageGroup/TrackedMessage/classify_group/EvictionUnit/PROTECT_COUNT/TOOL_MISSING_PREFIX) - context.rs 1332→1178行: 保留ContextManager struct+impl, pub use重导出(外部agentic.rs/mod.rs路径不变) - lib.rs: pub mod context_helpers Rust impl块约束: ContextManager方法未动一个字符, 纯函数搬迁零行为变更 主代兜底: cargo check --workspace 0 + test df-ai 119 + grep抽离项/pub use印证 strategy: 自底向上核心库优先, 单批1-2文件原子操作
This commit is contained in:
@@ -7,162 +7,23 @@
|
||||
//!
|
||||
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
|
||||
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
|
||||
//!
|
||||
//! 纯函数 / 数据类型 / 常量(TokenEstimator / ContextConfig / MessageGroup /
|
||||
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
|
||||
//! TOOL_MISSING_PREFIX)已抽至 [`crate::context_helpers`],本模块 `use` 复用,
|
||||
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
|
||||
//! 本文件仅保留 `ContextManager` 结构体及其 `impl`(Rust impl 块不可跨文件)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::context_helpers::{classify_group, PROTECT_COUNT, TOOL_MISSING_PREFIX};
|
||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
|
||||
pub use crate::context_helpers::{
|
||||
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
|
||||
};
|
||||
|
||||
use crate::provider::{ChatMessage, MessageRole, ToolCall};
|
||||
|
||||
// ============================================================
|
||||
// Token 估算器(零依赖粗估)
|
||||
// ============================================================
|
||||
|
||||
/// Token 粗估器 — 字符级近似计数,无 tokenizer 依赖
|
||||
///
|
||||
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenEstimator {
|
||||
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
|
||||
pub chars_ratio: f32,
|
||||
/// 每条消息固定开销(role 标记 + 格式)
|
||||
pub per_message_overhead: u32,
|
||||
/// 每个 tool_call 的额外开销(name + arguments JSON 结构)
|
||||
pub per_tool_call_overhead: u32,
|
||||
}
|
||||
|
||||
impl Default for TokenEstimator {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
chars_ratio: 0.35,
|
||||
per_message_overhead: 4,
|
||||
per_tool_call_overhead: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenEstimator {
|
||||
/// 估算单条消息的 token 数(保守估计)
|
||||
///
|
||||
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
||||
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
|
||||
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
|
||||
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
|
||||
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
||||
let mut char_count = msg.content.chars().count();
|
||||
if let Some(parts) = &msg.parts {
|
||||
for p in parts {
|
||||
match p {
|
||||
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
|
||||
crate::provider::ContentPart::Image { base64, url, .. } => {
|
||||
// CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
|
||||
if let Some(b) = base64 {
|
||||
char_count += b.chars().count();
|
||||
} else if let Some(u) = url {
|
||||
char_count += u.chars().count();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
|
||||
let mut total = content_tokens + self.per_message_overhead;
|
||||
|
||||
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
||||
if let Some(ref calls) = msg.tool_calls {
|
||||
for call in calls {
|
||||
total += self.per_tool_call_overhead;
|
||||
total += (call.function.name.chars().count() as f32 * self.chars_ratio).ceil() as u32;
|
||||
total += (call.function.arguments.chars().count() as f32 * self.chars_ratio).ceil() as u32;
|
||||
}
|
||||
}
|
||||
|
||||
// tool_call_id 开销(role=Tool 时有)
|
||||
if msg.tool_call_id.is_some() {
|
||||
total += 3;
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
/// 估算纯文本字符串的 token 数(用于 system prompt)
|
||||
pub fn estimate_text(&self, text: &str) -> u32 {
|
||||
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上下文窗口配置
|
||||
// ============================================================
|
||||
|
||||
/// 上下文窗口配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextConfig {
|
||||
/// 窗口上限 token 数(默认 128_000)
|
||||
pub max_tokens: u32,
|
||||
/// 输出预留 token 数(窗口中留给模型生成的部分,默认 8_192)
|
||||
pub output_reserve: u32,
|
||||
/// 安全系数 0.0~1.0(默认 0.85,留 15% 余量)
|
||||
pub safety_ratio: f32,
|
||||
}
|
||||
|
||||
impl Default for ContextConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_tokens: 128_000,
|
||||
output_reserve: 8_192,
|
||||
safety_ratio: 0.85,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextConfig {
|
||||
/// 预算上限 = (max_tokens - output_reserve) × safety_ratio
|
||||
pub fn budget_limit(&self) -> u32 {
|
||||
(self.max_tokens.saturating_sub(self.output_reserve) as f32 * self.safety_ratio) as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息分组(淘汰时保持工具调用三元组原子性)
|
||||
// ============================================================
|
||||
|
||||
/// 消息在逻辑上的分组标签,用于淘汰时保持原子性
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageGroup {
|
||||
/// 普通 User / Assistant 文本消息(可独立淘汰)
|
||||
Standalone,
|
||||
/// Assistant 带 tool_calls,是三元组的头
|
||||
ToolCallHead,
|
||||
/// Tool 结果消息,是三元组的尾
|
||||
ToolResultTail,
|
||||
}
|
||||
|
||||
/// 带有 token 缓存和分组信息的消息条目
|
||||
///
|
||||
/// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
|
||||
/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问,
|
||||
/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。
|
||||
pub struct TrackedMessage {
|
||||
pub message: ChatMessage,
|
||||
pub token_count: u32,
|
||||
pub group: MessageGroup,
|
||||
}
|
||||
|
||||
fn classify_group(msg: &ChatMessage) -> MessageGroup {
|
||||
match msg.role {
|
||||
MessageRole::Tool => MessageGroup::ToolResultTail,
|
||||
MessageRole::Assistant => {
|
||||
if msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) {
|
||||
MessageGroup::ToolCallHead
|
||||
} else {
|
||||
MessageGroup::Standalone
|
||||
}
|
||||
}
|
||||
_ => MessageGroup::Standalone,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上下文管理器
|
||||
// ============================================================
|
||||
@@ -182,13 +43,6 @@ pub struct ContextManager {
|
||||
is_compressing: bool,
|
||||
}
|
||||
|
||||
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
||||
const PROTECT_COUNT: usize = 6;
|
||||
|
||||
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
|
||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||
const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
|
||||
impl ContextManager {
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
Self {
|
||||
@@ -734,14 +588,6 @@ impl ContextManager {
|
||||
// ContextManager 的私有辅助如需新增放在这里。)
|
||||
}
|
||||
|
||||
/// 淘汰单元:连续消息范围 [..end) + token 总和
|
||||
///
|
||||
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。
|
||||
pub struct EvictionUnit {
|
||||
pub end: usize,
|
||||
pub token_sum: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
195
crates/df-ai/src/context_helpers.rs
Normal file
195
crates/df-ai/src/context_helpers.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分
|
||||
//!
|
||||
//! 职责:
|
||||
//! - Token 粗估器(字符级近似,无 tokenizer 依赖)
|
||||
//! - 上下文窗口配置与预算计算
|
||||
//! - 消息分组(淘汰时保持工具调用三元组原子性)的数据类型与分类纯函数
|
||||
//! - 淘汰单元 / 跟踪消息条目等数据类型
|
||||
//!
|
||||
//! 抽离原因:context.rs 的 `ContextManager` impl 块不可跨文件,但其中依赖的
|
||||
//! 纯函数 / 数据类型 / 常量无 `self` 依赖,可独立成模块供 context.rs `use` 复用,
|
||||
//! 降低单文件体积。零行为变化:仅搬迁,逻辑原样保留。
|
||||
//!
|
||||
//! context.rs 通过 `pub use` 重导出本模块的 `TokenEstimator` / `ContextConfig` 等,
|
||||
//! 保持 `df_ai::context::TokenEstimator` 等历史路径对外可见(零调用方变更)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::provider::{ChatMessage, MessageRole};
|
||||
|
||||
// ============================================================
|
||||
// Token 估算器(零依赖粗估)
|
||||
// ============================================================
|
||||
|
||||
/// Token 粗估器 — 字符级近似计数,无 tokenizer 依赖
|
||||
///
|
||||
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenEstimator {
|
||||
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
|
||||
pub chars_ratio: f32,
|
||||
/// 每条消息固定开销(role 标记 + 格式)
|
||||
pub per_message_overhead: u32,
|
||||
/// 每个 tool_call 的额外开销(name + arguments JSON 结构)
|
||||
pub per_tool_call_overhead: u32,
|
||||
}
|
||||
|
||||
impl Default for TokenEstimator {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
chars_ratio: 0.35,
|
||||
per_message_overhead: 4,
|
||||
per_tool_call_overhead: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenEstimator {
|
||||
/// 估算单条消息的 token 数(保守估计)
|
||||
///
|
||||
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
||||
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
|
||||
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
|
||||
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
|
||||
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
||||
let mut char_count = msg.content.chars().count();
|
||||
if let Some(parts) = &msg.parts {
|
||||
for p in parts {
|
||||
match p {
|
||||
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
|
||||
crate::provider::ContentPart::Image { base64, url, .. } => {
|
||||
// CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
|
||||
if let Some(b) = base64 {
|
||||
char_count += b.chars().count();
|
||||
} else if let Some(u) = url {
|
||||
char_count += u.chars().count();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
|
||||
let mut total = content_tokens + self.per_message_overhead;
|
||||
|
||||
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
||||
if let Some(ref calls) = msg.tool_calls {
|
||||
for call in calls {
|
||||
total += self.per_tool_call_overhead;
|
||||
total += (call.function.name.chars().count() as f32 * self.chars_ratio).ceil() as u32;
|
||||
total += (call.function.arguments.chars().count() as f32 * self.chars_ratio).ceil() as u32;
|
||||
}
|
||||
}
|
||||
|
||||
// tool_call_id 开销(role=Tool 时有)
|
||||
if msg.tool_call_id.is_some() {
|
||||
total += 3;
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
/// 估算纯文本字符串的 token 数(用于 system prompt)
|
||||
pub fn estimate_text(&self, text: &str) -> u32 {
|
||||
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上下文窗口配置
|
||||
// ============================================================
|
||||
|
||||
/// 上下文窗口配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextConfig {
|
||||
/// 窗口上限 token 数(默认 128_000)
|
||||
pub max_tokens: u32,
|
||||
/// 输出预留 token 数(窗口中留给模型生成的部分,默认 8_192)
|
||||
pub output_reserve: u32,
|
||||
/// 安全系数 0.0~1.0(默认 0.85,留 15% 余量)
|
||||
pub safety_ratio: f32,
|
||||
}
|
||||
|
||||
impl Default for ContextConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_tokens: 128_000,
|
||||
output_reserve: 8_192,
|
||||
safety_ratio: 0.85,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextConfig {
|
||||
/// 预算上限 = (max_tokens - output_reserve) × safety_ratio
|
||||
pub fn budget_limit(&self) -> u32 {
|
||||
(self.max_tokens.saturating_sub(self.output_reserve) as f32 * self.safety_ratio) as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息分组(淘汰时保持工具调用三元组原子性)
|
||||
// ============================================================
|
||||
|
||||
/// 消息在逻辑上的分组标签,用于淘汰时保持原子性
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageGroup {
|
||||
/// 普通 User / Assistant 文本消息(可独立淘汰)
|
||||
Standalone,
|
||||
/// Assistant 带 tool_calls,是三元组的头
|
||||
ToolCallHead,
|
||||
/// Tool 结果消息,是三元组的尾
|
||||
ToolResultTail,
|
||||
}
|
||||
|
||||
/// 带有 token 缓存和分组信息的消息条目
|
||||
///
|
||||
/// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
|
||||
/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问,
|
||||
/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。
|
||||
pub struct TrackedMessage {
|
||||
pub message: ChatMessage,
|
||||
pub token_count: u32,
|
||||
pub group: MessageGroup,
|
||||
}
|
||||
|
||||
/// 按消息角色与 tool_calls 判定其逻辑分组(淘汰单元的原子性基础)
|
||||
///
|
||||
/// 纯函数:无 `self` 依赖,仅依据 `msg.role` 与 `msg.tool_calls` 分类。
|
||||
pub fn classify_group(msg: &ChatMessage) -> MessageGroup {
|
||||
match msg.role {
|
||||
MessageRole::Tool => MessageGroup::ToolResultTail,
|
||||
MessageRole::Assistant => {
|
||||
if msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) {
|
||||
MessageGroup::ToolCallHead
|
||||
} else {
|
||||
MessageGroup::Standalone
|
||||
}
|
||||
}
|
||||
_ => MessageGroup::Standalone,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 淘汰单元
|
||||
// ============================================================
|
||||
|
||||
/// 淘汰单元:连续消息范围 [..end) + token 总和
|
||||
///
|
||||
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。
|
||||
pub struct EvictionUnit {
|
||||
pub end: usize,
|
||||
pub token_sum: u32,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 常量
|
||||
// ============================================================
|
||||
|
||||
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
||||
pub const PROTECT_COUNT: usize = 6;
|
||||
|
||||
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
|
||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod ai_tools;
|
||||
pub mod anthropic_compat;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
pub mod coordinator;
|
||||
pub mod model_fetch;
|
||||
pub mod model_probe;
|
||||
|
||||
Reference in New Issue
Block a user