【上下文压缩机制修复】 - engine.ts 新增 lastRealInputTokens 记录 LLM 返回的真实输入 token,压缩判断取 max(估算值, 真实值),避免估算偏低导致不压缩但 API 413 - 修复 effectiveContextWindow 缺少默认值导致 compressionThreshold 变 NaN、压缩永不触发的 bug(添加 ?? 128_000 兜底) - compressMessages 保留区从固定 10 条改为按 token 预算动态截断(50% 上下文窗口) - 二次截断 charsPerToken 从 2 调整为 1.0,与 CJK_TOKEN_RATIO 一致 - 压缩后重置 lastRealInputTokens,避免跨迭代污染 - 每个 run 开始时重置 lastRealInputTokens 【前端 Token 显示修复】 - 区分"累计消耗"和"上下文占用"语义——之前 totalTokens(累计) / contextWindow(单次窗口) 得出无意义百分比 - TokenUsage.tsx 上下文占用改用 lastInputTokens,新增压缩节省行(绿色,仅当 > 0 时显示) - agent-store.ts TokenUsage 接口新增 lastInputTokens 和 lastCompressedSaved 字段,4 处初始值统一更新 - useAgentStream.ts usage 事件 lastInputTokens 替换不累加,compressed 事件通过 streamEvent 接收 savedTokens - handlers.ts onCompressed 同时发 toast + streamEvent,解决"压缩触发但前端 token 显示不降"缺陷 - 旧数据兼容使用 ?? 0,保证历史会话加载不崩溃 【MCP 工具就绪竞态修复】 - 修复输入框永久显示"工具加载中"的竞态条件:MCP initialize 几乎立即 resolve(connectServer 不 await),tools:ready 事件在前端监听器注册前已发出 - main.ts 维护 toolsReady 标志 + 注册 tools:isReady IPC handler 查询当前状态 - preload.ts 暴露 tools.isReady() 方法 - App.tsx 注册 onReady 监听器后立即查询 isReady(),无论事件是否错过都能恢复正确状态 【记忆系统加固】 - consolidator.ts 新增 runningPromise + waitForCompletion(35s),before-quit 等待固化完成,防止退出时异步 consolidate 数据丢失 - 固化到 MEMORY.md 的同时写入 semantic_memories 表,解决双轨存储无交叉验证问题 - manager.ts tokenize 按中英文标点切分子句后再做 bigram,优化中文分词 - 清理正则冗余括号 【SOUL.md 降级处理】 - context-builder.ts SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户 - 新增 fallbackRoleNotified 去重标志,仅首次降级通知,避免每次发消息都弹 toast - SOUL.md 恢复内容时重置标志 【Token 估算调整】 - token-estimator.ts CJK_TOKEN_RATIO 从 1.5 调整为 1.0 【MCP 异步初始化】 - main.ts MCP 完成后广播 tools:ready 事件,前端 UI 据以控制输入框可用性 - preload.ts + global.d.ts 暴露 tools.onReady() 监听器 - App.tsx + ChatInput.tsx toolsReady 状态控制输入框 【版本号】 - package.json + package-lock.json 从 0.3.16 升级到 0.3.18
104 lines
4.2 KiB
TypeScript
104 lines
4.2 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;
|
||
|
||
/**
|
||
* 估算多条消息的总 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;
|
||
}>): number {
|
||
let total = 0;
|
||
for (const msg of messages) {
|
||
total += estimateStringTokens(msg.content);
|
||
if (msg.reasoningContent) total += estimateStringTokens(msg.reasoningContent);
|
||
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;
|
||
}
|