优化: 上下文压缩缓存友好化与召回句柄
- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退 - 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配 - BP3 断点机制+缓存命中率观测日志,调参有据 - 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费 - 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗 - namespace 引用跨重启内容哈希懒回填
This commit is contained in:
@@ -26,7 +26,7 @@ use crate::provider::{ChatMessage, MessageRole};
|
||||
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenEstimator {
|
||||
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
|
||||
/// 非 CJK 字符(拉丁/数字/符号)→ token 转换系数(默认 0.35,约 3 字符/token)
|
||||
pub chars_ratio: f32,
|
||||
/// 每条消息固定开销(role 标记 + 格式)
|
||||
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)会致含图
|
||||
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
|
||||
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
|
||||
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
|
||||
// 注意单位是 token 不是字符:地板必须在字符折扣之后直接加 token,若混进字符计数
|
||||
// 会被 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 {
|
||||
/// 估算单条消息的 token 数(保守估计)
|
||||
///
|
||||
/// 多模态回归修正:`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 偏保守)。
|
||||
/// 文本按 CJK/其余分档计数;图片(base64 与 url)统一按 [`IMAGE_TOKEN_FLOOR`] 计,
|
||||
/// 不随 base64 字符串长度膨胀。
|
||||
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
||||
let mut char_count = msg.content.chars().count();
|
||||
// 939① 估算入账修正:Tool 消息 content 为 namespace 引用时,按 ref 尾段携带的
|
||||
// 原文字节数入账(字节保守视作字符,中文会 3x 高估——方向安全:宁可高估触发
|
||||
// 裁剪/压缩,不可漏估致预算门失效,后者曾致 provider 超限 400)。旧格式/已淘汰
|
||||
// 内容(无 ?len= 尾段)按字面字符保守计,行为同前。
|
||||
if matches!(msg.role, crate::provider::MessageRole::Tool) {
|
||||
if let Some(n) = crate::namespace_store::parse_ref_content_len(&msg.content) {
|
||||
char_count = n;
|
||||
// namespace 引用(Tool 消息)特殊入账:按 ref 尾段携带的原文字节数保守视作字符
|
||||
// 并按 plain 比率计(多为代码/JSON 输出);中文引用会低估,方向可接受:ref 体量
|
||||
// 本就由 namespace 上限闸门控,此处入账只为预算可见性。
|
||||
let mut content_tokens = if matches!(msg.role, crate::provider::MessageRole::Tool) {
|
||||
match crate::namespace_store::parse_ref_content_len(&msg.content) {
|
||||
Some(n) => (n as f32 * self.chars_ratio).ceil() as u32,
|
||||
None => weighted_char_tokens(&msg.content, self.chars_ratio),
|
||||
}
|
||||
}
|
||||
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
|
||||
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
|
||||
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
|
||||
let mut url_image_floor_tokens: u32 = 0;
|
||||
} else {
|
||||
weighted_char_tokens(&msg.content, self.chars_ratio)
|
||||
};
|
||||
// 图片地板按 token 直接累加,生效于字符折扣之外(折进字符计数会被 0.35 稀释,
|
||||
// 1600 保底失效,预算裁剪漏触发 → provider 超限 400)。
|
||||
let mut image_floor_tokens: u32 = 0;
|
||||
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::Text { text } => {
|
||||
content_tokens = content_tokens
|
||||
.saturating_add(weighted_char_tokens(text, self.chars_ratio));
|
||||
}
|
||||
crate::provider::ContentPart::Image { base64, url, .. } => {
|
||||
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
|
||||
if let Some(b) = base64 {
|
||||
char_count += b.chars().count();
|
||||
} 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);
|
||||
// base64 与 url 语义等价(都是一张图),同用地板;有 base64 时
|
||||
// url 为空(构造端二选一),不重复计
|
||||
if base64.is_some() || url.is_some() {
|
||||
image_floor_tokens =
|
||||
image_floor_tokens.saturating_add(IMAGE_TOKEN_FLOOR as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
|
||||
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
|
||||
let mut total = content_tokens + image_floor_tokens + self.per_message_overhead;
|
||||
|
||||
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
||||
if let Some(ref calls) = msg.tool_calls {
|
||||
@@ -113,9 +139,9 @@ impl TokenEstimator {
|
||||
total
|
||||
}
|
||||
|
||||
/// 估算纯文本字符串的 token 数(用于 system prompt)
|
||||
/// 估算纯文本字符串的 token 数(用于 system prompt),CJK 分档同 [`Self::estimate_message`]
|
||||
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 个完整用户轮次)
|
||||
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 摘要插回发送视图
|
||||
@@ -1755,8 +1789,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn url_image_token_floor_survives_ratio_discount() {
|
||||
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
|
||||
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
|
||||
// 图地板语义是"每图至少 ~1600 token",必须生效于字符折扣之外:
|
||||
// 若地板折进字符计数再乘比率,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
|
||||
let est = TokenEstimator::default();
|
||||
let msg = ChatMessage::user_parts(
|
||||
"t",
|
||||
@@ -1764,10 +1798,41 @@ mod tests {
|
||||
);
|
||||
let tokens = est.estimate_message(&msg);
|
||||
assert!(
|
||||
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
|
||||
tokens >= IMAGE_TOKEN_FLOOR as u32,
|
||||
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
|
||||
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})");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user