Files
metona-ai-desktop/electron/harness/utils/token-estimator.ts
T
thzxx 22028c91c3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m41s
CI / 产物编译验证 (push) Successful in 10m6s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
fix: v0.5.5 全量复检修复 — 图片 token 估算缺失(压缩预算失真)+ 过时文案清理
背景: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 成功
2026-08-21 23:09:36 +08:00

125 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Token 估算工具 — 跨 Provider 通用
*
* 策略:智能字符估算,区分中文字符与 ASCII 字符
* - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token
* - ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
* - 其他 Unicodeemoji 等):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; // 其他 Unicodeemoji 等):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 压缩图在主流
* ProviderOpenAI/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;
}