From 63fe0a637549448671d7ee8b16c7dc9aa6a13d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 03:31:50 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E6=8B=86context.rs?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E5=BA=93(strategy=E8=87=AA=E5=BA=95=E5=90=91?= =?UTF-8?q?=E4=B8=8A=C2=B7=E7=BA=AF=E5=87=BD=E6=95=B0=E6=8A=BD=E7=A6=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 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文件原子操作 --- crates/df-ai/src/context.rs | 180 ++----------------------- crates/df-ai/src/context_helpers.rs | 195 ++++++++++++++++++++++++++++ crates/df-ai/src/lib.rs | 1 + 3 files changed, 209 insertions(+), 167 deletions(-) create mode 100644 crates/df-ai/src/context_helpers.rs diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs index 122f84f..fcb3809 100644 --- a/crates/df-ai/src/context.rs +++ b/crates/df-ai/src/context.rs @@ -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::*; diff --git a/crates/df-ai/src/context_helpers.rs b/crates/df-ai/src/context_helpers.rs new file mode 100644 index 0000000..44aa262 --- /dev/null +++ b/crates/df-ai/src/context_helpers.rs @@ -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_"; diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 0d80243..37b7ce4 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -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;