背景:v0.5.4 多模态增强(多轮图片记忆 + 总开关 + DeepSeek vision)发布后的 全量全方位复检,重点核查新增功能的跨模块边界。 复检通过项(无回归确认): - 多轮图片记忆 × 五家 adapter:Anthropic(base64 块)/ OpenAI(image_url parts)/ Ollama(纯 base64 数组)/ Agnes / MiMo 均正确处理恢复的历史 images - 多轮图片记忆 × 引擎运行时压缩:摘要请求仅含文本(图片不进摘要调用); toKeep 消息的 images 原样保留;孤立 tool 消息配对逻辑不受影响 - 多轮图片记忆 × 摘要服务:maybeSummarize 用截断文本 transcript,无图片干扰 - history 组装 slice(0,-1) × 图片恢复无冲突(当前消息 images 走独立通道) - 编辑重发/重新生成路径与图片恢复同源(attachments.preview),行为一致 修复项: - P1 token 估算器完全忽略 images(estimateMessagesTokens): 带 10 张图的消息被按纯文本估算。影响:压缩 keepBudget 严重低估 → 压缩后实际 token 仍超 80% 阈值 → 反复触发压缩循环(每轮多一次 LLM 摘要调用);上下文占用显示严重失真。修复:每张图按 1000 tokens 计入 (1024px 压缩图在主流 Provider 约 700~1500 视觉 token,取保守上界) - 文案清理:ChatInput 附件按钮 Tooltip 硬编码"DeepSeek 不支持图片"改为 按拒绝原因区分(开关未开启 vs 当前模型不支持);openai-format.ts 共享层 注释更新(DeepSeek vision 已支持,非 vision 模型才丢弃) 测试(243 → 245 用例): - 新增 token 估算图片用例 ×2:带 images 消息按每张 1000 tokens 计入 / 无 images 字段消息行为不变(向后兼容) 验证: lint 0 / typecheck 双工程 0 / test:electron 245 全过 / build 成功
125 lines
4.9 KiB
TypeScript
125 lines
4.9 KiB
TypeScript
/**
|
||
* Token 估算工具 — 跨 Provider 通用
|
||
*
|
||
* 策略:智能字符估算,区分中文字符与 ASCII 字符
|
||
* - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token
|
||
* - ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
|
||
* - 其他 Unicode(emoji 等):1 字符 ≈ 1 token
|
||
*
|
||
* v0.3.18 修复: CJK 系数从 1.5 调整为 1.0
|
||
* 实测 DeepSeek/GLM 等 BPE tokenizer 对中文约 0.6-0.8 token/字,
|
||
* 原系数 1.5 导致中文场景估算偏高约 2 倍,80% 阈值实际在 40-50% 就触发压缩,
|
||
* 造成上下文过早丢失。1.0 仍保守(留 ~25% 安全裕度),但更接近真实值。
|
||
*
|
||
* 对比旧的 `length / 2` 方案:
|
||
* - 中文场景:估算准确度从 ~50% 提升到 ~90%
|
||
* - 英文场景:从偏低变为接近真实
|
||
* - 混合场景:更贴近实际 token 消耗
|
||
*
|
||
* 仍为估算值(无 tiktoken 依赖),但留了 80% 触发阈值的缓冲。
|
||
*/
|
||
|
||
// 中日韩统一表意文字 + 全角标点 + 日文假名 + 韩文谚文
|
||
const CJK_REGEX =
|
||
/[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/;
|
||
|
||
/**
|
||
* L-17 修复: 提取魔法系数为命名常量,便于统一调整
|
||
* v0.3.18 修复: CJK_TOKEN_RATIO 从 1.5 调整为 1.0,更贴近 BPE 实际值
|
||
* @see project_memory.md — Token estimation coefficients
|
||
*/
|
||
const CJK_TOKEN_RATIO = 1.0; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token(保守,实测 0.6-0.8)
|
||
const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
|
||
const OTHER_TOKEN_RATIO = 1; // 其他 Unicode(emoji 等):1 字符 ≈ 1 token
|
||
const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范)
|
||
|
||
/**
|
||
* 估算字符串的 token 数
|
||
* @param text 待估算的字符串(可为 null/undefined,视为 0 token)
|
||
* @returns 估算的 token 数
|
||
*/
|
||
export function estimateStringTokens(text: string | null | undefined): number {
|
||
if (!text || text.length === 0) return 0;
|
||
|
||
let cjkCount = 0;
|
||
let asciiCount = 0;
|
||
let otherCount = 0;
|
||
|
||
for (const ch of text) {
|
||
if (CJK_REGEX.test(ch)) {
|
||
cjkCount++;
|
||
} else if (ch.charCodeAt(0) < 128) {
|
||
asciiCount++;
|
||
} else {
|
||
otherCount++;
|
||
}
|
||
}
|
||
|
||
// L-17 修复: 使用命名常量替代魔法数字
|
||
return Math.ceil(
|
||
cjkCount * CJK_TOKEN_RATIO + asciiCount * ASCII_TOKEN_RATIO + otherCount * OTHER_TOKEN_RATIO,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* #50 修复: tool_call 结构开销({"id":"","name":"","arguments":""} 等结构字符,参考 OpenAI 规范)
|
||
*/
|
||
const TOOL_CALL_OVERHEAD_TOKENS = 8;
|
||
|
||
/**
|
||
* v0.5.5: 单张图片的 token 估算
|
||
*
|
||
* 多模态图片(vision 类模型)按视觉 token 计费:1024px 压缩图在主流
|
||
* Provider(OpenAI/Anthropic/DeepSeek vision)约 700~1500 tokens,取保守
|
||
* 上界 1000。此前估算器完全忽略 images——带 10 张图的消息被按纯文本
|
||
* 估算,压缩 keepBudget 严重低估,导致压缩后实际 token 仍超限、反复
|
||
* 触发压缩循环;上下文占用显示也严重失真。
|
||
*/
|
||
const IMAGE_TOKEN_ESTIMATE = 1000;
|
||
|
||
/**
|
||
* 估算多条消息的总 token 数
|
||
*
|
||
* 每条消息额外加 4 token 的结构性开销(role、分隔符等,参考 OpenAI 规范)
|
||
*
|
||
* @param messages 消息列表(content 可为 null,对应仅有 tool_calls 的 assistant 消息)
|
||
* @returns 估算的 token 数
|
||
*/
|
||
export function estimateMessagesTokens(
|
||
messages: Array<{
|
||
content: string | null;
|
||
reasoningContent?: string;
|
||
toolCalls?: Array<{ id?: string; name?: string; args: Record<string, unknown> }>;
|
||
toolCallId?: string;
|
||
images?: Array<{ url: string }>;
|
||
}>,
|
||
): number {
|
||
let total = 0;
|
||
for (const msg of messages) {
|
||
total += estimateStringTokens(msg.content);
|
||
if (msg.reasoningContent) total += estimateStringTokens(msg.reasoningContent);
|
||
// v0.5.5: 图片按视觉 token 估算(多轮图片记忆场景,防压缩预算低估)
|
||
if (msg.images) {
|
||
total += msg.images.length * IMAGE_TOKEN_ESTIMATE;
|
||
}
|
||
if (msg.toolCalls) {
|
||
for (const tc of msg.toolCalls) {
|
||
// #50 修复: OpenAI tokenizer 会将 tool_call 的完整结构(id、name、args)都计入 token
|
||
// 之前仅估算 args,忽略 id(通常 24 字符 call_xxx)和 name(通常 5-20 字符),导致每个 tool_call 少算 5-10 tokens
|
||
total += estimateStringTokens(tc.id ?? '');
|
||
total += estimateStringTokens(tc.name ?? '');
|
||
total += estimateStringTokens(JSON.stringify(tc.args ?? {}));
|
||
// 结构开销({"id":"","name":"","arguments":""} 等结构字符)
|
||
total += TOOL_CALL_OVERHEAD_TOKENS;
|
||
}
|
||
}
|
||
// #50 修复: tool 消息的 tool_call_id 字段也计入 token
|
||
if (msg.toolCallId) {
|
||
total += estimateStringTokens(msg.toolCallId);
|
||
}
|
||
// L-17 修复: 使用命名常量替代魔法数字
|
||
total += MSG_OVERHEAD_TOKENS;
|
||
}
|
||
return total;
|
||
}
|