Skip to content

[Bug]: 自动上下文压缩按本地 BPE 计数提前触发,触发时上游真实用量仅占窗口约 70% #598

Open

Description

@had77guai

Product or interface

Desktop app

Version

3.1.14

Platform

Windows

OS version and architecture

Windows 11 x64 (10.0.26300.9818)

Issue area

Other

Desktop log upload ID (optional)

No response

Steps to reproduce

不需要特殊环境,任意开启自动压缩(默认开启)的长会话即可复现。

  1. 使用一个 1M 上下文窗口的模型(本例:hy4,context 1,000,000 / max_tokens 65,536),正常对话若干轮。
  2. 对话直到发生自动压缩(历史中出现 role=compactionSummary 的消息)。
  3. 打开 .minimax/v2/sessions/<日期>/<会话>/messages.jsonl,读取该 compactionSummary 消息的 tokensBefore。
  4. 按 packages/agent-modules/context-manager/src/provider-budget.ts 的公式算触发线:
    automaticTriggerAt = min(providerInputLimit, floor((contextWindow - min(maxTokens, floor(contextWindow/4))) * 0.95))
    本例 = min(932,416, floor((1,000,000-65,536)*0.95)) = 887,740
  5. 两者几乎相等(差值即最后一回合的增量)。

本机 2026-11 实测 22 次会话级自动压缩,21 次的 tokensBefore 紧贴该线
(+173 / +1,000 / +1,164 / +1,968 / +2,107 / +2,663 / +2,908 / +3,024 / … / +24,727),
另有 1 次(hy4 的 791,003)落在线下 —— 说明没有越过线却仍发生了压缩。

Expected and actual behavior

【期望】
压缩触发线应以「上游 API 真实返回的 token 数」为准;会话即将被压缩时,用户能看到还剩多少 token。

【实际】
决定「要不要压缩」的数字,是用本地 gpt-tokenizer(GPT-4o 的 o200k_base)把整段历史重新数一遍得到的。

根因(packages/local-runtime-v2/src/service/turn-system/compaction/execution/local-context-footprint.ts):
const hintTokens = match.absoluteTokens + estimator.estimateMessages(trailing);
return { ...fresh, inputTokens: Math.max(fresh.inputTokens, hintTokens) };
上游 usage 锚点(usage-anchor.ts)已经握着真实用量 absoluteTokens,但它只用于锚定尾部新增消息,
整段历史的量仍由本地 BPE 决定,并且用 Math.max 兜底 —— 本地值永远不输。

【量化偏差】对 885 个会话、22,333 个带 usage 的回合,逐回合比对「本地估值」与「上游
input+cacheRead+cacheWrite」:
hy4 n=17,506 中位 1.27 P90 1.73 P99 2.31 最大 3.88
MiniMax-M3.1-Flash n=4,827 中位 1.03 P90 1.05 P99 1.10 最大 1.20合计 n=22,333 中位 1.19 P90 1.67 —— 98.2% 的回合为高估
即中位高估 19%,尾部可达数倍。

【后果】1M 窗口会话在本地计数887,740 时压缩,按中位比值折算,上游真实占用约 699,000 —— 只有窗口的
70%,还剩三成没用就被压缩了。代价是一次额外的 LLM 摘要请求(延迟+费用),以及一段本可保留的活上下文被
摘要替换。反方向同样存在:中文正文会被低估(o200k_base 实测 0.58 token/字符),此时请求先被上游拒绝,
再走 automatic-context-compactor.ts 里 input.recovery 的兜底路径压缩,过程被硬打断。

【口径不一致】界面上的「上下文窗口」气泡读的是另一条链路(context-usage.js,totalCountSource =
PROVIDER_USAGE_ANCHORED,tools 分量还做了远程 count_tokens 校准),因此用户看到的百分比与真正触发压缩的
数字不是同一个。

【建议】

  1. 触发判定以上游 usage 锚定为主(下一回合占用 ≈ 上一回合上游用量 + 其后新增消息估算),
    Math.max 改为以锚点为主、无锚点时才用本地全量重算;
  2. provider 支持时用其自身的 count_tokens 端点取真值(anthropic-messages / responses / 智谱 / Kimi 的
    count_tokens body 仓库里已有:packages/agent-modules/context-manager/src/count-tokens-body.ts);
  3. 远程不可用时退回本地 BPE,并在该次判定里标记 source=local_estimate,计数失败必须 fail-open,不得中断对话;
  4. 图片/视频计价统一到按缩放后像素的估算口径,不要再用固定 4,800/张;
  5. checkpoint 的 fits()/measure() 必须与触发判定同口径。

完整分析(含逐条代码引用、测量方法与复现脚本)见附件 Issue-03 文档。

Redacted error summary

N/A —— 非报错类问题,未产生异常堆栈。

Screenshots

No response

Before submitting

  • I have searched existing issues.
  • I have included my version and removed sensitive information.

Activity

  1. had77guai commented on Nov 11, 2026

    @had77guai
    Author

    [#598 修订版] 上下文压缩的触发判定应改用上游返回的 token 数,并在对话窗口右下角展示「距离压缩还剩多少 token」

    类别:Feature / Bug · 上下文压缩触发判定(计数口径)+ 上下文余量可视化
    严重度:中(不报错,但压缩被提前触发、上下文被无谓丢弃,且用户对压缩时机完全无感知)
    环境:MiniMax Code 3.1.14(@mmx-agent/electron),Windows 11 x64
    模型:全部模型均受影响;偏差幅度随模型与内容类型而变(排除 thinking 后实测 hy4 中位偏高 3.8%、MiniMax-M3.1-Flash 中位偏高 1.6%)
    影响面:所有开启自动压缩的会话(默认开启,contextManagement.enabled 无需显式配置)
    提交者:和蔼的77
    撰写方式:MiniMax M3.1 Flash @ Qoder 平台,作者自行检查并撰写


    0. 关于本 issue(来意说明)

    我是和蔼的77,MiniMax 飞书群里的那位。

    本次 issue 我使用 MiniMax M3.1 Flash、在 Qoder 平台上面自行检查并撰写。没有使用 MiniMaxCode 而是 Qoder 的原因是:本次 issue 所述的问题,我曾在本地尝试修复并添加补丁,但补丁引入了恶性 bug,MiniMaxCode 暂时无法正常使用,因此改用 Qoder 完成本次检查与撰写。

    交给 agent 来自行书写而不是人来书写,确实有"不重视本问题、不尊重开发者"的嫌疑,但还请开发者团队谅解。我本地上面正在跑数个项目,属实是没有空来亲自撰写这个 issue。

    另外补充一点:如果不是 hy4 触发压缩的时机过于不好,而且自动压缩长期处于不可控、不可见的状态,积攒了非常多的负面情绪,我也不会在 MiniMax 飞书群里发牢骚。这个问题对我日常长会话的影响是实打实的,所以希望这次能推动到修复。


    1. 摘要(TL;DR)

    当前版本里,决定「要不要压缩」的那个数字,是用本地 gpt-tokenizer(GPT-4o 的 o200k_base 分词器)把整段历史重新数一遍得到的,而不是用上游 API 返回的真实 token 数。

    三件事同时成立:

    1. 本地 tokenizer 与上游真实用量存在系统性偏差(本机 1,707 个相邻回合实测:中位偏高 3.8%,P90 偏高 14.8%,最大 1.84 倍)。偏差方向随内容类型翻转:长 ASCII 段落被高估 3–8 倍(走 UTF-8 字节兜底),中文被低估(o200k_base 实测 0.58 token/字符);而 assistant 的 thinking 块被本地计费、provider 侧却整块丢弃——这是最大的一项误差来源。
    2. 偏差会把压缩提前:触发线是窗口的固定 88.8%(1M 窗口 = 887,740),本地计数越过即压缩。由于消息正文偏高、而每条消息的结构开销偏低,两个方向会互相抵消,因此不同会话的净偏差不同:有的在真实占用尚低时就被压缩,有的则先撞上游 4xx 再走 recovery 压缩。代价是一次额外的 LLM 摘要请求(延迟 + 费用),以及一段本可以继续保留的活上下文。
    3. 界面上看不到压缩时机。已有的「上下文窗口」气泡读的是另一套数字(PROVIDER_USAGE_ANCHORED),因此用户看到的百分比和真正触发压缩的数字不是同一个;且触发瞬间的真实占用无法从落盘数据反推(压缩前历史已被 compactionSummary 替换)。

    同时,这套上游计数能力仓库里已经有了,只是压缩链路没用它:HttpRemoteTokenCounter(支持 /v1/messages/count_tokens、/v1/responses/input_tokens、/v1/tokenizers/estimate-token-count 等)、ContextUsageAnchorState(已持有上游返回的绝对用量)、以及 @mavis/context-manager 里为远程计数器预留的注入缝隙。

    因此本 issue 的诉求是两件事:

    • A. 压缩触发判定改用上游 token 计数(上游 usage 锚定为主,远程 count_tokens 兜底,本地 BPE 仅作最后降级)。
    • B. 对话窗口右下角常驻一个环形指示器,显示「距离触发压缩还剩多少 token」。

    2. 现状:压缩触发是怎么算的

    2.1 调用链

    service/turn-system/agent-host/compaction/context-compaction.js      createAutomaticContextCompactionHook()
      └─ compaction/automatic-context-compactor.js                        AutomaticContextCompactor.probeBeforeLlm() / compactBeforeLlm()
           └─ compaction/execution/production-context-compactors.js       createContextCompactors()   ← 生产装配点
                ├─ compaction/automatic-context-compactor.js               prepareAutomaticCompaction()
                │    ├─ compaction/execution/local-context-footprint.js   createLocalContextFootprintMeasurer().measure()  ← 计数发生地
                │    └─ @mavis/context-manager/provider-budget.js          resolveCompactionTokenBudget()                    ← 阈值计算
                └─ compaction/algorithm/compact-context.js                 compactContext() → session.fits() / measurePair()
    

    路径全部位于发布包内:resources/app.asar → node_modules/@mavis/local-runtime-v2/dist/... 与 node_modules/@mavis/context-manager/dist/...。

    2.2 计分器:用 GPT-4o 的分词器数 MiniMax 的 token

    @mavis/context-manager/dist/token-estimator.js:

    // L49
    import { countTokens as countO200kBase } from 'gpt-tokenizer/model/gpt-4o';
    // L51-L54
    const MESSAGE_STRUCTURAL_OVERHEAD = 4;   // 每条消息固定 +4
    const VISUAL_TOKEN_STAND_IN = 4_800;     // 每张图/视频固定 +4800
    const MAX_EXACT_TOKENIZER_CHARS = 4_096; // 超过 4096 字符的长串走兜底

    该文件自己的注释已经写明了问题(L31–36):

    o200k_base is not the provider tokenizer, but for MiniMax / Messages-compatible endpoints it consistently over-estimates a touch (safe: earlier trigger)

    以及 L6–11:

    pi-agent-core's estimateTokens / estimateContextTokens use Math.ceil(chars / 4), which severely under-counts CJK text — Chinese characters consume 1–2 BPE tokens each … Under-reporting moves the compaction trigger to the right, past the point at which Messages-compatible providers reject the request

    2.3 测量:整段历史全量重算 + 与上游锚点取 max

    @mavis/local-runtime-v2/dist/service/turn-system/compaction/execution/local-context-footprint.js:

    // L11-L16:固定部分(系统提示词 + tools 声明)
    const fixedTokens = estimator.estimateMessages(fixedMessages);
    const measureFresh = (messages) => {
        const projected = projectAgentMessagesForModel(messages, options.model).messages;
        const inputTokens = fixedTokens + estimator.estimateMessages(projected);   // ← 全量本地重算
        ...
    };
    // L20-L33:与上游锚点比较,但取 max
    const hintTokens = match.absoluteTokens + estimator.estimateMessages(trailing);
    return { ...fresh, inputTokens: Math.max(fresh.inputTokens, hintTokens) };     // ← 本地值永远不输

    关键点:ContextUsageAnchorState(compaction/execution/usage-anchor.js)已经握着上游返回的绝对用量 absoluteTokens(= usage.totalTokens 或 input+output+cacheRead+cacheWrite),但这个真值只用来给「尾部新增消息」做锚点,整段历史的量仍然由本地 BPE 决定,并且用 Math.max 兜底——上游说「实际只用了 65 万」时,本地算出的 88 万照样胜出。

    2.4 阈值:与窗口绑死的固定比例

    @mavis/context-manager/dist/provider-budget.js L12–38:

    const outputReserve = Math.min(configuredOutput, Math.floor(input.contextWindow / 4));
    const outputReservedTrigger = Math.floor(((input.contextWindow - outputReserve) * 95) / 100);
    const builtInTrigger = Math.min(providerInputLimit, Math.max(1, outputReservedTrigger));

    以本机最常见的组合(contextWindow = 1,000,000,maxTokens = 65,536)为例:

    项 值
    providerInputLimit 932,416
    outputReserve 65,536
    automaticTriggerAt(压缩触发线) 887,740

    @mavis/local-runtime-v2/dist/service/turn-system/compaction/automatic-context-compactor.js L96–99 即以此为判据:

    shouldStart: input.recovery !== undefined ||
        footprint.inputTokens > automaticTriggerAt || ...

    即:本地计数越过 887,740 → 立刻压缩。

    2.5 候选是否「装得下」也用同一把本地尺子

    compaction/execution/checkpoint-provider.js L257–262:

    function estimateCheckpointInputTokens(context) {
        const estimator = createDefaultTokenEstimator();
        ...
        return estimator.estimateTextTokens(context.systemPrompt ?? '') + ... + estimator.estimateMessages(context.messages);
    }

    session.fits() / session.measure()(compact-context.js L136、L544)都走这里。也就是说,压缩过程中「这份历史放不放得进模型」的判断,同样由本地 BPE 决定,而不是由上游的实际限制决定。


    3. 实测证据

    3.1 触发点精确命中本地计数线

    本机 2026-11 的会话里记录到 22 次会话级自动压缩(另有 4 份内部历史快照副本 .history-mutation-*,不重复计数)。压缩摘要中的 tokensBefore(= 触发瞬间的本地计数)与按 §2.4 公式算出的触发线对比如下:

    hy4(context 1,000,000 / output 65,536 → 触发线 887,740)

    tokensBefore 与触发线之差
    887,913 +173
    888,904 +1,164
    888,740 +1,000
    889,708 +1,968
    889,847 +2,107
    890,403 +2,663
    898,576 +10,836
    905,065 +17,325
    912,467 +24,727
    791,003 −96,737(落在线下,见 §3.1.1)

    MiniMax-M3.1-Flash(context 512,000 / output 128,000 → 触发线 364,800)

    tokensBefore 与触发线之差
    365,018 +218
    365,483 +683
    366,316 / 366,316 +1,516
    366,536 +1,736
    367,708 +2,908
    367,824 +3,024
    376,661 +11,861
    379,413 +14,613
    381,318 +16,518
    383,566 +18,766
    831,359 落在 512K 线之上 466,559,但与 1M 窗口下的触发线 828,400 仅差 +2,959 ⇒ 该会话当时跑的是 1M 档窗口

    结论:除 791,003 这 1 例外,所有事件的 tokensBefore 都紧贴「本地计数触发线」,差值恰好是最后一回合的增量(831,359 那条对应的是 1M 窗口档,同样紧贴其触发线)。这从行为上确认:决定「要不要压缩」的那个数字,就是本地 tokenizer 的输出,与上游返回的真实用量无关。

    附注:tokensBefore 的差值分布也说明了单回合增量可以很大(最大 +24,727,对应一个带大块工具输出的回合),所以触发线附近的离散度是这个量级的正常现象。

    3.1.1 线下的那一次:recovery 路径(代码路径说明 + 一处待确认)

    hy4 的 791,003 比触发线低 96,737,没有越过线却发生了压缩。代码里只有一条路径能解释它——automatic-context-compactor.js L96:

    shouldStart: input.recovery !== undefined ||
        footprint.inputTokens > automaticTriggerAt || ...

    input.recovery 表示上一轮请求被上游拒绝(超窗 / 请求过大)。此时不管本地计数是多少,都会直接触发压缩。这正是本地计数偏低时的兜底路径:先被上游拒 → 再由恢复路径补一次压缩。

    ⚠️ 这一条是由代码路径推断,不是由本机 transcript 直接证实的:压缩摘要消息只带 role / summary / tokensBefore / timestamp 四个字段,不记录触发原因,且压缩前的历史已被替换、不再留在 messages.jsonl 里,所以无法从落盘数据反查 791,003 究竟是 recovery 触发还是用户手动 /compact。

    另外,上表中 379,413 / 383,566 / 381,318 这三个比 512K 线高出 1.5 万~1.9 万的事件不是 recovery 触发——它们在线上,只是那一回合的增量特别大。

    于是同一个缺陷有两个方向的代价:

    偏差方向 触发方式 用户可见后果
    高估(长 ASCII / 工具输出 / 日志) 计数越过触发线 → 提前压缩 窗口还剩三成没用就被压缩:多一次摘要请求(延迟 + 费用),且一段活上下文被摘要替换
    低估(中文正文为主) 计数没到线 → 请求被上游拒 → 走 recovery 路径压缩 用户先看到一次报错/重试,再看到压缩,过程被硬打断

    3.2 本地计数 vs 上游真实用量

    测量方法(相邻轮差值口径):对 .minimax/v2/sessions/2026/11 下 886 个会话,逐回合计算「本地计数器对该回合实际发出的那份请求的增量」与「上游 usage 的增量」:

    dLocal = Σ est(messages[prev_assistant .. this_assistant-1])   // 系统提示词与 tools 在差值中抵消
    dProv  = (input+cacheRead+cacheWrite)_this − (input+cacheRead+cacheWrite)_prev
    ratio  = dLocal / dProv
    

    只取同一 turn 内、前台(非 turn_task_bg_* 后台子任务)、上下文单调增长、增量 > 500 token 的相邻对,共 1,707 对。

    关键:必须按 provider 实际收到的内容计数。 对 custom_provider:* / minimax_api 来源,agent-core/dist/pi-turn-runner/outbound-message-normalizer.js 的 normalizeOutboundMessagesForModel → shouldDropPortableThinking 会把 assistant 的 thinking 块整块丢弃(不发往上游)。因此正确的本地估值应排除 thinking。

    口径 n P25 中位 P75 P90 最大 ≥2.55 的回合
    含 thinking(错误口径) 1,707 1.027 1.110 1.586 2.961 27.47 217
    排除 thinking(正确口径) 1,707 0.982 1.038 1.100 1.148 1.84 0

    分模型(排除 thinking 后):

    模型 对数 P25 中位 P90 最大
    hy4 1,455 0.982 1.038 1.148 1.84
    MiniMax-M3.1-Flash 252 0.997 1.016 1.094 1.23

    比值 = 本地估值 / 上游真实用量。中位 1.038 即本地高估约 3.8%;hy4 上 85% 的相邻回合 > 1(方向稳定偏高)。

    方法学说明:采用差值口径后,系统提示词与 tools 的计价在差值中完全消去,因此不再依赖 llm-call.json 的末态快照;本表只反映消息侧的计数偏差。固定部分(system + tools)本身是低估的(冷启动首轮实测:本地 19,913 vs provider 22,846),该部分不影响本表结论。

    单会话手工复核(2026/11/10/00-52-44-880-…,hy4):

    系统提示词 12,066 tok + tools 声明 7,847 tok = 固定 19,913 tok
    第 27 回合:本地请求估值 213,145   上游真实 input+cacheRead+cacheWrite 157,219   比值 1.356
    (该回合之前的 assistant 含 >50,000 字符的 thinking,provider 侧不计入——这正是「含 thinking」口径下比值虚高的原因)
    

    3.3 后果:压缩被提前触发

    按 §2.4 的触发线反推触发瞬间上游的真实占用:

    hy4(窗口 1,000,000,触发线 887,740)

    上游真实占用 占窗口
    触发时刻(本地计数) 887,740 88.8%
    触发时刻(上游真实,按中位比值 1.038 折算) ≈ 855,000 ≈ 85.5%

    MiniMax-M3.1-Flash(窗口 512,000,触发线 364,800)

    上游真实占用 占窗口
    触发时刻(本地计数) 364,800 71.3%
    触发时刻(上游真实,按中位比值 1.016 折算) ≈ 359,000 ≈ 70.1%
    按修正后的比值折算,hy4 触发时上游真实占用约 85.5%(折算值,仅作量级参考;触发瞬间的真实值无法从落盘数据反推),而 MiniMax-M3.1-Flash 因为 512K 窗口下输出预留占比更大,触发线本身只到窗口的 71.3%,所以两者的「提前量」不可直接比较。共同的问题是触发线按窗口固定比例计算、不随实际偏差自适应。代价是:
    • 多一次 LLM 摘要请求(时间 + 费用,且这次摘要本身也要走一遍 checkpoint 流程);
    • 一段本可继续保留的活上下文被摘要替换,用户想回头问的细节只能靠摘要找回;
    • 触发线是按窗口的固定百分比算的(887,740),偏差再大也不会被补偿——本地越不准,压缩就越早。

    3.4 偏差为什么会偏、往哪偏

    场景 本地算法 效果
    assistant 的 thinking 块 estimateMessage 把 thinking 按正文计费 虚高,且 provider 侧不计:shouldDropPortableThinking 对 custom_provider:* / minimax_api 整块丢弃。修掉这一项,本机中位从 1.110 降到 1.038 —— 这是最大的一项误差来源
    长 ASCII 段落(> 4096 字符且含长字母数字串) estimateTextTokensUpperBound() 退化为 UTF-8 字节数(1 字节 1 token) 高估 3–8 倍(#599 实测 base64 60 KB 为 8.0×)。实测 6000 字符 ASCII:字节兜底 6,000 vs 真实 1,879(3.19×)
    中文正文 o200k_base 实测约 0.58 token/字符 系统性低估(中文分词器普遍是 1–2 token/字)。但这并非 hy4 与 M3.1-Flash 差异的主因——两者差异主要来自 thinking 长度(见上)
    图片 / 视频 固定 4,800/张 与真实视觉 token 无关(实际取决于缩放后像素)。而同一个仓库的 Context Usage 链路用的是按 28px patch 精确估算的 estimateImageBlockTokens()(还带 MiniMax-M 专用公式)——两处口径不一致
    每条消息 固定 +4 结构性开销的近似值,与各家 API 的实际封装开销无关
    tools 声明 按 JSON.stringify(tools) 本地计数 与上游实际渲染开销不同(这也正是 Context Usage 链路要用远程 count_tokens 专门校准 tools 的原因)

    3.5 界面上的数字和触发的数字不是同一个

    • 展示链路(对话里的「上下文窗口」气泡):context-usage/context-usage.js L103–115

      const usedTokens = Math.max(0, Math.round((providerInputTokens ?? input.prepared.usedTokens) + ...));
      totalCountSource: providerInputTokens === undefined ? 'LOCAL_ESTIMATE' : 'PROVIDER_USAGE_ANCHORED',

      → 有上游 usage 时用上游,没有才退回本地估计;tools 分量还会用 HttpRemoteTokenCounter 远程校准。

    • 触发链路(§2.3):全量本地 BPE,上游真值只做尾部锚点且被 Math.max 压制。

    所以同一个会话里,气泡显示的「已用 X%」与真正触发压缩的水位不是同一个数(例如气泡显示 62% 时,压缩实际发生在按本地计数算出的另一个更高的水位上)。用户既看不懂数字,也没法预判压缩何时到来。


    4. 诉求

    A. 压缩触发判定改用上游 token 计数

    期望:判定「上下文有多大」时,以上游返回的 token 数为权威口径。

    建议的优先级(从高到低,全部是仓库里已有的能力,不需要新造轮子):

    1. 上游 usage 锚定(首选,零额外网络开销)
      ContextUsageAnchorState(compaction/execution/usage-anchor.js)已经记录了上一回合上游返回的 absoluteTokens。把它从「尾部锚点」提升为整段上下文的主口径:下一回合的真实占用 ≈ 上一回合上游用量 + 其后新增消息的估算值。
      现有 Math.max(fresh, hint) 应改为以 hint 为主、仅在无锚点时才用本地全量重算,即 inputTokens = hint ?? fresh。

    2. 远程 count_tokens(次选,provider 支持时使用)
      HttpRemoteTokenCounter 已支持以下端点,均为上游自己的分词器,是真正的 ground truth:

      适配器 端点
      anthropic-messages <baseUrl>/v1/messages/count_tokens
      minimax-responses / generic-responses <baseUrl>/v1/responses/input_tokens
      zhipu-tokenizer /api/paas/v4/tokenizer
      kimi-estimate-token-count /v1/tokenizers/estimate-token-count

      在下一回合发出之前用同一份 payload(system prompt + tools + 历史)向该端点要一次真实计数,用它替换本地估算。

    3. 本地 BPE(仅作最后降级)
      远程端点不可用 / 超时 / 返回异常时,退回当前 BpeTokenEstimator,并在该次判定里显式标记 source: 'local_estimate',以便日志与 UI 能区分「精确值」和「估计值」。

    同时要修的两处口径(否则换掉计分器后仍会前后不一):

    • 图片/视频的计价要统一到 estimateImageBlockTokens()(按缩放后像素、含 MiniMax-M 专用公式)那套,不要再用固定的 4,800。
    • session.fits() / session.measure()(checkpoint-provider.js L257–262)必须与触发判定用同一口径,否则会出现「触发了压缩,但压缩用的历史又判定装不下」的自相矛盾。

    边界要求:

    • 计数失败绝不能阻断对话。注意本地链路目前根本没有远程计数(远程计数器只挂在 Context Usage 展示链路上),所以引入后要自己保证 fail-open:云端/legacy 的 ContextManager 在计数失败时是 skip(context_manager_token_count_failed),本地链路要保持同等语义——失败时退回本地估计并继续,而不是抛错中断这一轮。
    • 远程计数请求要有超时(现有 HttpRemoteTokenCounter 默认 3s)、失败熔断(现有 RemoteTokenCounterFailures 已具备「同一 base url 端点缺失后本轮内跳过」能力)和结果缓存,不能每回合都打一次远程计数。
    • 保持 contextManagement.compactionTriggerAt 这个用户可配项的语义不变(比例或绝对 token 数),只是比较对象变了。

    修复落地前的临时缓解(可立即使用)

    contextManagement.compactionTriggerAt 已支持比例或绝对 token 数(见 #493 / #488),且取值越小越早压缩(resolveConfiguredCompactionTrigger 与内置触发线取 min)。按修正后的口径,本地计数相对上游的中位偏差只有 3.8%(P90 14.8%),因此不建议用这条配置去「补偿偏差」——默认 88.8% 的触发线本身是合理的。真正需要它的场景是「同一会话反复被提前压缩、且已确认是长 ASCII/blob 回合把计数顶过线」时,可临时下调到 0.85 观察:

    contextManagement:
      compactionTriggerAt: 0.85   # 写在 .minimax/config.yaml

    这只是补偿计数误差、不是修复:它把「提前压缩」换成「用更少的上下文跑更早的压缩」,窗口利用率仍低于设计值。真正的修复是让判定改用上游 token 数。

    B. 把「距离压缩还剩多少 token」暴露出来,并在对话窗口右下角用环形指示器呈现

    期望:用户在对话窗口右下角(与左下角的工作指示器同一栏的右侧)能常驻看到一个环形进度指示器,表示当前上下文距离触发自动压缩还剩多少 token。

    建议语义:

    状态 环形表现 提示(hover / aria)
    距压缩尚远 空心环 / 低占比弧 「距离压缩还剩 ≈ 512K token」
    接近压缩线 弧度随占用增长,变色(黄 → 橙 → 红) 「距离压缩还剩 ≈ 48K token」
    压缩中 环形动画 复用既有文案「上下文压缩中」

    实现要点:

    • 需要的三个数:当前占用(上游口径)、压缩触发线(automaticTriggerAt)、上下文窗口(contextWindow)。
      前两个目前都没有送到 UI:automaticTriggerAt / providerInputLimit 是在运行时内部每回合算出来的(provider-budget.js),压缩完成后也只回报 tokensBefore / tokensAfter / serializedBytesBefore / serializedBytesAfter(context-compaction.js L519–524)。需要把它们加进会话状态并随会话事件推给渲染进程。
    • 环形指示器本身已经有了,只是语义和位置都不对。渲染进程的上下文用量组件里已有一个 14×14 的环形 SVG(strokeDasharray 进度环),当前被用作「上下文窗口」气泡的触发按钮(30×30 圆角按钮,ratio = 已用比例,aria-label = message.item.context_usage_aria = 「上下文已使用 {{pct}}%」),挂在消息条目区域(i18n 命名空间为 message.item.*),点击才展开明细。
      本需求是同一套视觉件换一个口径并换个位置:
      现状 期望
      口径 已用比例(usedTokens / contextWindowTokens) 距压缩余量(triggerAt − usedTokens),并可同时表达占用百分比
      位置 消息条目内,hover/点击才可见 对话窗口右下角常驻(与左下角工作指示器同一栏右侧)
      数据源 已经是上游口径 必须是同一个上游口径,且与触发判定同源
    • 除此之外,还有一条现成的 context-window-progress 进度条、context_usage_expand_aria / context_usage_collapse_aria、以及分类明细(SYSTEM_PROMPT / MEMORY / TOOLS / SKILLS / MESSAGES / OTHER),新指示器不需要另起炉灶。
    • 建议的 i18n key(沿用现有命名空间):message.item.context_headroom_title / context_headroom_remaining 等,数值格式与既有 token_usage_percent({{pct}}% 已用(剩余 {{remaining}}%))保持一致。
    • 现有的「上下文窗口」气泡可以继续保留,但它与新的环形指示器必须读同一个数,否则就是第三套口径。

    C. 顺带建议(可选)

    context-usage/context-usage.js 已经会算出本地估算与上游真值的偏离(divergence.signedRate / absoluteRate),建议把这份偏离也一并打到日志里(类似现有 context_compaction_* 事件),这样「本地估算到底偏了多少」在生产环境里是可观测的,而不是只能靠本机复现。


    5. 验收标准

    • 触发判定所用的 token 数,在 provider 支持远程计数或返回 usage 时,等于上游口径;本地 BPE 仅在远程不可用时兜底,且该次判定可被日志区分。
    • 用 §3.2 的同一套度量脚本复测:排除 thinking、按相邻轮差值口径,本地/上游比值中位数应从 1.038 收敛到 1.02 以内;且 thinking 不再计入本地估值。
    • 1M 窗口会话的自动压缩,触发时上游真实占用(而非本地估值)应落在窗口的 85%–95% 区间,且该值可由日志或会话状态直接读出。
    • 图片/视频的计价在「触发判定」「checkpoint 装得下判定」「上下文用量展示」三处完全一致。
    • 远程计数端点不可用、超时、返回畸形 body 时,对话不受影响(fail-open),并在日志中留下 fallbackReason(现有 remote-token-counter-failures.js 已有该机制)。
    • 对话窗口右下角常驻环形指示器,能读出「距离自动压缩还剩 ≈ N token」;contextWindow 与 automaticTriggerAt 已随会话状态下发到渲染进程。
    • 环形指示器与「上下文窗口」气泡显示的是同一个口径的数字。
    • 手动 /compact 与自动压缩使用同一计分口径。

    6. 复现与度量(本机可复现)

    A. 确认触发变量是本地计数

    1. 找一个发生过自动压缩的会话:.minimax/v2/sessions/<日期>/<会话>/messages.jsonl。
    2. 找到 role == "compactionSummary" 的消息,读它的 tokensBefore。
    3. 对照 §2.4 的公式算 automaticTriggerAt(contextWindow = 1,000,000、maxTokens = 65,536 → 887,740)。
    4. 两者应当几乎相等(差值即最后一回合的增量)。

    B. 比对本地估值与上游真值

    对每个带 usage 的 assistant 回合:

    localRequest  = est(systemPrompt) + est(JSON.stringify(tools)) + Σ est(messages[0 .. i-1])
    providerCtx   = usage.input + usage.cacheRead + usage.cacheWrite
    ratio         = localRequest / providerCtx
    

    其中 est() 复刻 @mavis/context-manager/dist/token-estimator.js 的口径即可(o200k_base + 每条消息 +4 + 图片 4800 + 长 ASCII 走字节兜底)。本机 2026/11 全量结果见 §3.2。


    7. 相关代码位置索引

    下表右列是 GitHub 仓库内的源码路径(MiniMax-AI/minimax-code);左列是我在本机 3.1.14 桌面端发布包里读到的对应产物(resources/app.asar → node_modules/)。文中引用的 Lxx 行号以发布包的编译产物为准(桌面端只有 dist/),仓库 src/ 下的 TS 行号会不同。

    作用 仓库源码路径 桌面端发布包路径(本文行号基准)
    本地 BPE 计数器(偏差根源) packages/agent-modules/context-manager/src/token-estimator.ts @mavis/context-manager/dist/token-estimator.js
    触发阈值计算(触发线公式) packages/agent-modules/context-manager/src/provider-budget.ts @mavis/context-manager/dist/provider-budget.js
    为远程计数器预留的注入缝隙 packages/agent-modules/context-manager/src/manager.ts @mavis/context-manager/dist/manager.js
    count_tokens 请求体构造(仓库内已有) packages/agent-modules/context-manager/src/count-tokens-body.ts @mavis/context-manager/dist/count-tokens-body.js
    触发判定装配(shouldStart) packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.ts @mavis/local-runtime-v2/dist/service/turn-system/compaction/automatic-context-compactor.js
    上下文足迹测量(Math.max 取值处) packages/local-runtime-v2/src/service/turn-system/compaction/execution/local-context-footprint.ts 同上 .../dist/.../execution/local-context-footprint.js
    上游 usage 锚点 packages/local-runtime-v2/src/service/turn-system/compaction/execution/usage-anchor.ts 同上 .../dist/.../execution/usage-anchor.js
    checkpoint 容量判定(fits / measure) packages/local-runtime-v2/src/service/turn-system/compaction/execution/checkpoint-provider.ts 同上 .../dist/.../execution/checkpoint-provider.js
    压缩算法与候选降级 packages/local-runtime-v2/src/service/turn-system/compaction/algorithm/compact-context.ts 同上 .../dist/.../algorithm/compact-context.js
    上游 token 计数客户端(桌面端已有,CLI 开源仓库中暂无对应目录) —(不在本仓库) @mavis/local-runtime-v2/dist/service/turn-system/context-usage/token-counting/remote-token-counter.js
    计数端点适配器(/v1/messages/count_tokens 等) —(不在本仓库) .../dist/.../context-usage/token-counter-adapters/{registry,messages,responses,openai-chat-tokenizers}.js
    上下文用量(以上游 usage 为准的那条链路) —(不在本仓库) .../dist/.../context-usage/context-usage.js
    视觉 token 精确估算(按缩放后像素) —(不在本仓库) .../dist/.../context-usage/token-counting/image-detection.js

    8. 与已有 issue 的关系(提交前已检索)

    本 issue 的症状此前有人报过,但根因定位 + 偏差量化 + 桌面端余量可视化尚未有人提。逐条说明,避免被误判为重复:

    相关 issue 状态 与本 issue 的关系
    #93「才用了38%就开始自动压缩上下文了」 closed(not_planned,2026-10-19 批量清理积压) 同一症状。该 issue 场景是「两张截图」触发(图片 token 预估),版本为 3.0.46-inside / macOS;维护者在 2026-09-21 已回复「自动压缩不是单纯看已用百分比……阈值策略(尤其对图片的预估)在优化中」,关闭时明确写道:"this issue was filed more than a month ago against an older release, and the codebase has changed substantially since then. If it still reproduces on the latest version, please open a new issue with the current version and reproduction steps"。本 issue 即为该邀请下的重新提交:3.1.14 / Windows,且触发源不是图片,而是本地 BPE 计数器的系统性偏差
    #152「Context compaction trigger stays tripped after compaction — stale provider usage is trusted as the prefix sum」 closed(completed,2026-09-18) 该 issue 首次确立了「默认走本地 token estimator、provider usage 被当作 prefix ground truth」这一前提,并定位到 token-estimator.ts 的 estimateContextTokens。但它修的是云端/legacy ContextManager 路径在压缩后触发器卡死的一个具体缺陷,且未涉及本地计数本身的偏差幅度。桌面端 local-runtime-v2 是另一套实现(local-context-footprint.ts 的 Math.max(fresh, hint)),本 issue 的结论与数据均针对该路径
    #493 / #488(contextManagement.compactionTriggerAt) closed(completed) 该配置项已可用,本 issue §4.A「临时缓解」直接采用它作为补偿手段
    #217「Context-meter status line item」 closed(completed,2026-09-20,fork 贡献) 说明「上下文余量仪表」这一诉求已被认可并落地在 CLI 状态栏(Context ▕██████░░▏ 77% left)。本 issue 的界面诉求是桌面端对话窗口右下角常驻,CLI 已有 ≠ 桌面端已有
    #539「context window exceeds limit (2013) is terminal」 open 描述的是低估方向:本地估算放行了上游拒绝的请求(引 #460 的「50% of the rejected request's measured tokens/bytes, because the estimate had admitted a request the provider rejected」)。与本 issue 的高估→提前压缩方向互补,两者是同一计数缺陷的两个面
    #514 / #17 open / closed(completed) 低估方向的同族:请求先被上游拒绝、再由 recovery 路径补压缩。#17 当时已建议「发送请求前用上一轮的 usage 字段判断剩余空间」——本 issue 正是这一建议的量化落地
    #233「移动端看不到当前上下文使用情况」 open 提问类 issue,维护者答复「移动端 app 的上下文使用情况已收到,会在近期支持」。桌面端对话窗口内常驻的压缩余量指示仍无对应 issue
    #513「Context compaction shrinks the LLM context but not the rendered transcript」 open 桌面端压缩相关的另一族问题(视图层未剪枝、单次压缩阻塞 71.3s)。它引用了 tokens_before: 543285 但未质疑计数口径,与本 issue 不重叠

    结论:本 issue 不重复 #93(那是同一症状的旧版本、图片场景、已按积压清理关闭),而是按维护者在该 issue 中的邀请,在当前版本上重新提交,并补上此前缺失的三样东西:① 本地计数偏差的量化(1,707 个相邻回合;排除 thinking 后中位 1.038 / P90 1.148 / 最大 1.84);② 偏差的逐项来源(thinking 被本地计费而 provider 丢弃、>4096 字符整段走 UTF-8 字节兜底、图片固定 4,800、每条消息 +4)与 Math.max 结构性丢弃上游真值;③ 桌面端的「距压缩余量」可视化。


    9. 附:对 #599 / #600 的独立复核(@modacker)

    莫克在飞书上跟进了这条反馈,并在本 issue 之后 8 分钟提交了 #599 / #600。我按他在 #600 里给的判据(同轮 input+cacheRead+cacheWrite vs 本地 footprint,比值应在 1.04–1.28,出现 2.55 才是真 bug)在本机复核。结论是:他的判据成立,并直接推翻了我初版里最大的两个数字(§3.2 的两张表与 §3.3 的「≈70%」)——原因是我的算法把 provider 根本不接收的 thinking 计进了本地侧。本文已按 §3.2 的相邻轮差值口径全部改正。

    口径 n P25 中位 P75 P90 最大 ≥2.55 的回合
    含 thinking(我初版的口径) 1,707 1.027 1.110 1.586 2.961 27.47 217
    排除 thinking(正确口径) 1,707 0.982 1.038 1.100 1.148 1.84 0

    误差来源:packages/agent-core/dist/pi-turn-runner/outbound-message-normalizer.js 的 normalizeOutboundMessagesForModel → shouldDropPortableThinking,对 custom_provider:* / minimax_api 来源会把 assistant 的 thinking 块整块丢弃,不发往上游;而 token-estimator.ts 的 estimateMessage 仍按正文给它计费。我最初把两者直接相除,于是长 thinking 的回合出现 10×/27× 的假峰值——那不是估算器偏了,是我把没发出去的 token 也算进了本地侧。莫克那句「出现 2.55 才是真 bug」,恰好能筛出这个口径错误。

    对 #600 的三点回应

    1. 方向与量级一致:排除 thinking 后 hy4 中位 1.038、M3.1-Flash 1.016,与他 6 个会话的 1.044–1.278 同向同量级。我这边样本更多(1,707 对 vs 6 会话),只改变尾部分布,不改变中位。
    2. 他的「固定部分被低估」观察,我这边也复现了:用冷启动首轮对比,system+tools 本地估 19,913、provider 报 22,846,本地少算约 13%。这也是我改用差值口径的原因——差值把这块常数消掉,剩下的就是纯消息侧偏差。
    3. 他说的「ring 显示 ~35% 而压缩在 ~89% 触发」,机制上成立:ring 走 buildContextUsageMeasurement(PROVIDER_USAGE_ANCHORED),触发走 local-context-footprint.ts 的 Math.max(fresh, hint),两者不同源。本 issue §3.5 讲的就是这件事。

    对 #599 的回应:完全同意,我独立测到同一现象——6000 字符 ASCII 走 estimateTextTokensUpperBound() 得 6,000 vs 真实 1,879(3.19×)。他指出「兜底作用于整段文本而非仅长串」是正确定性,应改为只对超长 run 生效、其余照常走编码器。唯一需要修正的是他的样本量结论:他统计 2,489 会话中仅 21 个(0.84%)命中、聚合占 1.8%,我也独立确认——本机 886 个会话里只有 1 个存在 >4096 的字母数字 run,且该会话的峰值回合在剔除 thinking 后落到 1.84 以内。所以 #599 是一个真实的实现缺陷,但不是「压缩提前」的主因,两者应分开修、分开验收。

    修复优先级建议(按改动成本排序)

    1. 先修 estimateMessage 的投影口径:让本地计价与 projectAgentMessagesForModel 对齐(先剔除 thinking)。纯正确性修复,改动最小,可先做。
    2. estimateTextTokensUpperBound 只作用于超长 run([Bug]: the oversized-run token fallback applies to the whole text, so one long run inflates the entire message #599),其余走编码器。
    3. 触发判定改用上游 usage + 远程 count_tokens,本地 BPE 仅作降级。
    4. UI 暴露「距离压缩还剩多少 token」(§4.B)。

    —— 和蔼的77

  2. had77guai commented on Nov 11, 2026

    @had77guai
    Author

    补充:压缩提前触发的根因已定位(provider 计费口径与本地估算口径不一致)

    上一条评论提到「偏差来自本地 BPE 估算」,方向对但没说清机制。补充一次实测,结论可以直接落地:决定压缩时机的本地计数,把 provider 根本不计入窗口的 thinking 算了进去。

    1. 现象:触发时上游真实占用远低于触发线

    我从本机 local_runtime_token_usage 里取出每次压缩前一条上游回报的 input + cacheRead + cacheWrite,与压缩摘要里的 tokensBefore(本地计数)对比。全部 105 次有上游锚点的压缩事件:

    模型 / 协议 样本 本地计数 / 上游真实占用(中位) P90 最大
    minimax/MiniMax-M3.1-Flash(anthropic-messages) 45 1.07 1.10 1.12
    custom_provider/hy4(openai-completions 兼容端点) 10 1.75 3.65 3.65
    合计 105 1.06 1.37 3.65

    hy4 上 10 次压缩触发时,上游真实占 1M 窗口的比例为:25.0% / 37.3% / 46.2% / 46.6% / 48.7% / 51.7% / 51.8% / 63.3% / 65.0% / 66.2% —— 中位 51.7%,没有一次超过 70%,而同一时刻本地计数报的是 887,740–898,576(即触发线 887,740 附近)。

    同一台机器、同一时段,MiniMax-M3.1-Flash(anthropic-messages)的比值稳定在 1.07,说明这不是普遍现象,而是按协议分叉。

    2. 机制:两条协议对 assistant thinking 的处理不同

    本地估算(packages/agent-modules/context-manager/src/token-estimator.ts)对 thinking 无条件按正文计费:

    case 'assistant': {
        for (const block of assistant.content) {
            if (block.type === 'text') tokens += this.estimateTextTokens(block.text);
            else if (block.type === 'thinking') tokens += this.estimateTextTokens(block.thinking);
            ...

    而生产 footprint 走 projectAgentMessagesForModel()(agent-core/dist/pi-turn-runner/outbound-message-normalizer.js),其中 normalizeAssistantMessage() 对同 provider + 同 api + 同 model 的历史消息原样返回(if (sameModel) return message;),thinking 块被完整保留并计入本地计数。

    协议适配器(pi-ai/dist/providers/)对 thinking 的出站处理则相反:

    • anthropic.js:thinking 作为原生 content block 发出(无签名时降级为 text),必然计入 input tokens;

    • openai-completions.js:thinking 只在带 thinkingSignature 时发出,且放进独立字段:

      let signature = nonEmptyThinkingBlocks[0].thinkingSignature;
      if (signature && signature.length > 0) {
          assistantMsg[signature] = nonEmptyThinkingBlocks.map((b) => b.thinking).join("\n");
      }

      实测本机 hy4 会话里 thinking 块的 thinkingSignature 值就是 reasoning_content。

    3. 直接验证:兼容端点的 prompt_tokens 不计 reasoning_content

    对 hy4 所用的 openai-completions 端点做三组对照(同一模型、同一 max_tokens,仅 assistant 消息不同):

    请求 prompt_tokens
    A:assistant 无 thinking 1267
    B:assistant 带 4000 字符 reasoning_content 1267(增量 0)
    C:同样 4000 字符放进 assistant content 1769(增量 +502)

    即:该端点统计 assistant 的 content,完全不统计 reasoning_content。

    而本地估算仍按全文给 thinking 计费 → 每回合累积的这部分就是纯粹的虚高,且随会话变长线性放大 —— 这解释了为什么 hy4 的比值随会话推进从 1.37 一路升到 3.65(每回合的增量偏差只有约 1.16,但本地计数是累计的)。

    4. 由此得出的结论与建议

    1. estimateMessage 必须按目标协议的计费口径计价:对 openai-completions 这类「thinking 走独立字段、且端点不计入 prompt_tokens」的协议,thinking 不应计入本地 input 估算;anthropic-messages 则应计入。现状是一律按 o200k 正文计,等于把「不占上游窗口」的内容算成了「占窗口」。
    2. 本条与 §3.2 的关系:§3.2 用「相邻轮差值」测的是增量偏差(1.038–1.16),而压缩判定用的是累计计数;两者不矛盾——但会让人低估后果,请以本条的累计比值为准。
    3. 对 contextManagement.compactionTriggerAt 的提醒:它与内置触发线取 min,只能让压缩更早,无法修复本问题(本地计数虚高 1.75 倍时,88.8% 的线在真实 50% 就已越过)。在计数口径修好之前,用户侧只能靠「减少 thinking 产出」或「换用会计费 thinking 的协议」来缓解。
    4. 修复后建议把压缩触发瞬间的上游真实占用写进会话状态(现在只有本地估值),否则界面上的「上下文已用 X%」与触发点永远无法对齐 —— 即本 issue §4.B 的前提。
  3. had77guai commented on Nov 11, 2026

    @had77guai
    Author

    补充二:图片口径确认成立;另附完整归因(其中三项属 provider 侧行为,不计为 MiniMax Code 缺陷)

    @ronny 在飞书开发者群里判断「主要是图片的问题,图片我们用了一个固定的 token 大小,这个估算会比较失真」。我把这条转过来并做了实测:这个判断成立,而且这是本 issue 里唯一一条确属 MiniMax Code 自身缺陷的口径问题。其余几处口径差异属于 provider 侧行为,我在下面明确区分,避免误导归因。

    上一条评论已给出「触发点真实占用中位 51.7%」和「thinking 口径」两部分,这条补齐图片部分与完整归因账。

    1. 图片:本地固定 4,800/张,实际按像素只要几百(这一项是 MiniMax Code 的问题)

    packages/agent-modules/context-manager/src/token-estimator.ts 对图片按固定值计费:

    const VISUAL_TOKEN_STAND_IN = 4_800;   // 每张图/视频固定 +4800

    而同仓库的 Context Usage 链路用的是按缩放后像素估算的 estimateImageBlockTokens()(context-usage/token-counting/image-detection.js,28px patch,含 MiniMax-M 专用公式)。

    这是 MiniMax Code 内部两条链路口径不一致,不是 provider 差异:同一个仓库里已经有按像素的成熟实现,展示链路在用,触发链路没用。图片密集的会话因此会被本地计数顶高——#93「加了两张截图就自动压缩」正是这个形态。

    我从 base64 图片头解析出真实宽高,用 image-detection.js 的同一套公式重算。仅各会话压缩后保留部分就已有 137 张内联图片:

    固定 4,800/张 按像素估算 差
    137 张合计 657,600 188,379 +469,221

    单张极值:

    实际尺寸 按像素 本地固定 倍数
    324×46 24 4,800 200×
    366×45 28 4,800 171×
    350×525 247 4,800 19×
    563×768 588 4,800 8.2×
    1920×1054 2,622 4,800 1.8×

    一张窄长截图(1920×89,按像素 276)在本地被当成 4,800 token。

    2. 完整归因:一笔账(含口径差异归属)

    取压缩前 3 分钟的完整历史快照(.history-mutation-*,时间戳与压缩事件对齐),逐项还原「本地计数 898,576 vs 上游真实 372,791」的 525,785 差额:

    归因项 金额 占差额 归属
    thinking 按正文计费(该端点不计入 reasoning_content) 256,659 49% provider 计费口径差异,非 MiniMax Code 缺陷
    <system-reminder> 注入块在该端点被整块丢弃 98,286 19% provider 侧行为(其他 agent 平台同样如此),非 MiniMax Code 缺陷
    图片固定 4,800/张 vs 按像素 70,248 13% MiniMax Code 内部口径不一致(本 issue 诉求)
    分词器与结构开销差异(本地固定 o200k,中文上约高 32%) ~100,000 ~19% provider 分词器差异,非 MiniMax Code 缺陷

    后三项里有两项(注入块被丢弃、thinking 不计费)是 provider 自己的行为,MiniMax Code 无法也不应该去改变它们;但它们共同说明一件事:本地估算器无论怎么调参,都不可能对任意 provider 都正确——「某段内容算不算窗口」完全由 provider 决定。

    主因随会话内容而变,这也解释了为什么不同报告的现象不一致:图片密集的会话(例如 #93)图片是主因;长思考、工具密集的长会话则是 thinking 与注入块是主因。本机 177 个发生过压缩的会话中只有 32 个含内联图片,其余 145 个完全没有图片。

    3. 结论与建议(按优先级)

    1. 修复判定口径的权威来源,而不是在估算器里枚举 provider 规则。 本地估算对任意 provider 都不可能自洽(见上表后三项),所以触发判定应以上游 usage / 远程 count_tokens 为权威口径,本地估算退化为降级兜底。这一条同时解决 thinking、注入块、分词器三项差异,且不会因为将来换 provider 而再次失效。
    2. 先做纯内部一致性修复:图片计价对齐同仓库已有的 estimateImageBlockTokens()(按像素,含 MiniMax-M 公式)。这是零新增代码路径的复用,且是本 issue 中唯一确属 MiniMax Code 的口径缺陷。
    3. 把「压缩触发瞬间的上游真实占用」写进会话状态(现在只有本地估值),并输出「本地计数 vs 上游 usage」的差值作为回归指标,否则界面上的「已用 X%」与真实触发点永远无法对齐(实测两者可差 20 个百分点以上)。
    4. 用户侧提醒:contextManagement.compactionTriggerAt 修不了这个问题——它与内置触发线取 min,只能让压缩更早;本地计数虚高时,88.8% 那条线在真实占用 50% 左右就已经越过了。

    4. 复现方法(均不涉及任何密钥)

    • 触发点对比:读会话文件 messages.jsonl 中 role=compactionSummary 消息的 tokensBefore,与该会话 local_runtime_token_usage 里压缩前最后一条 usage 的 input + cache_read_input_tokens + cache_creation_input_tokens 相比。
    • 图片宽高:从 base64 图片头解析(PNG IHDR / JPEG SOF / GIF / WebP),套用 image-detection.js 的 28px patch 公式。
    • 计费对照:向同一 openai-completions 端点发仅 assistant 消息不同的请求(无 thinking / 带 reasoning_content / 同样文本放进 content),比较 prompt_tokens。
  4. UncleMoc commented on Nov 11, 2026

    @UncleMoc

    We hit the same problem independently — a different machine, a different model mix — and our numbers land on the same conclusion. Adding them here rather than opening a third thread, since #598 already carries the full analysis.

    Independent measurement. Our local session store: 2,355 sessions, 73 of which contain images, 401 images in total. The two chains in this repository disagree about what an image costs:

    • the compaction path (token-estimator.ts, VISUAL_TOKEN_STAND_IN = 4_800 per image) — 1,924,800 tokens
    • the display path (image-detection.ts, estimateImageBlockTokens, pixel-based with the MiniMax-M formula) — 598,440 tokens

    That is 1,326,360 tokens of difference, 3.22×, from 401 images. Per image the ratio runs from 1.6× (a 1920×1054 JPEG) to 18.8× (a narrow screenshot the pixel formula prices at 256).

    The image term is larger than the whole discrepancy we measured. In the six sessions where we compared the local footprint against the provider's own number on the same turn, the image term alone contributes 15,772–67,742 tokens per session, while the total local-minus-provider difference for those sessions is only +3,421 to +24,540 — and in the two sessions with no images at all it is negative (-22,201 and -26,925). The fixed 4,800 pushes the estimate up while the rest of the estimate sits below the provider's number; fixing the image term alone would flip those sessions from "over" to "under".

    Our ratio numbers, for comparison with yours. We measured the same turn-by-turn ratio on six sessions of our own, all MiniMax-M3.1-Flash over anthropic-messages: median 1.044, 1.076, 1.104, 1.164, 1.207, 1.278. That is consistent with your 1.07 for the same model and protocol. We did not reproduce the larger hy4 spread — our store has no hy4 traffic, which fits your finding that the deviation is protocol-dependent rather than universal.

    One correction to our own earlier analysis, for the record: we had initially ruled the image path out. We had checked the display chain (context-usage.ts → estimateImageBlockTokens) and concluded the model-specific branch was hit, so images looked fine. The compaction chain does not go through that code — it calls estimateMessage, which uses the flat 4,800 stand-in — so the two chains really do disagree, as you say.

  5. UncleMoc commented on Nov 11, 2026

    @UncleMoc

    Cross-check on our own numbers, and one correction to #599

    We re-ran our measurement end to end after reading the review in §9. Two things came out: our #600 numbers reproduce exactly, and one figure in #599 was wrong.

    #600 reproduces. Re-running the same six sessions with the same arithmetic (estimate(systemPrompt) + estimate(JSON.stringify(tools)) + Σ estimate(messages before this turn) against usage.input + usage.cacheRead + usage.cacheWrite for the same assistant message) gives:

    session turns ratio (local / provider), median tool-result tokens
    A 447 1.164 246,567
    B 258 1.207 190,497
    C 207 1.278 139,677
    D 183 1.076 109,119
    E 30 1.102 19,228
    F 395 1.044 200,515

    One detail worth recording for anyone re-running this: the accumulated message count has to include the compactionSummary message itself. All six of these sessions start with one (the summary is the context root), and leaving its tokens out shifts each ratio down by 0.02-0.05 — enough to look like a different result. With it included, every row matches the published figure.

    On the thinking correction — it depends on the provider, and on this machine it does not apply. The drop you describe is shouldDropPortableThinking in outbound-message-normalizer.ts:

    function isSourceQualifiedByokProvider(provider: string): boolean {
      return provider === 'minimax_api' || provider.startsWith('custom_provider:');
    }

    All of our sessions are on provider: "minimax" with api: "anthropic-messages" — the hosted path, not the BYOK tree — so shouldDropPortableThinking is false, and the thinking block is kept (same-model messages are returned untouched; a sibling anthropic-messages model replays the native block).

    We measured it rather than inferring it. Same arithmetic, counting vs. excluding the thinking blocks:

    session ratio with thinking ratio without thinking
    A 1.164 1.031
    B 1.207 1.098
    C 1.278 1.106
    D 1.076 0.854
    E 1.102 0.928
    F 1.044 0.850

    Dropping thinking moves every session below 1, i.e. the provider counts those tokens and the local estimate should too. So for minimax + anthropic-messages the published numbers are the right ones, and the local estimate is genuinely above what the provider reports — which is what #600 is about. On custom_provider:* / minimax_api your reading is the correct one, and we believe your numbers rather than our own for that case.

    We also checked the adjacent-turn difference form you used (fixed part cancels out), and it agrees: median 0.99-1.08 across our sessions, in the same range as your 1.038.

    Correction to #599: the sample size was wrong. We re-scanned and the numbers do not hold:

    • our store holds ~2,360 session transcripts as of this comment (it was 2,489 when we filed — the store is live and grows, so treat the absolute count as dated);
    • 17 transcripts contain a [A-Za-z0-9]{4097,} run — 0.72%, not 0.84%;
    • but the number that actually matters is smaller than both. Re-running the estimator over every text block in those files, only 4 sessions actually trip the fallback, and the inflation there is 89,548 tokens against 88,066 real tokens (1.02x) — the same 4 sessions we reported, so the mechanism and the reproduction stand;
    • restricted to 2026/11, the same directory you scanned: 1 hit in 158 transcripts (0.63%), against your 1 in 886. Same phenomenon, same order.

    The "1.8% of provider-reported usage in aggregate" figure was computed over a denominator that included sessions which never tripped the fallback. The honest statement is the narrower one: 4 sessions in 2,359, inflating those sessions by up to 166% of their real BPE count, and 0.72% of transcripts contain a run at all.

    None of this changes the conclusion in #599 — the fallback still applies the byte bound to the whole text instead of the oversized run, that is still a real defect, and there is still no test pinning it. We agree with your read that it should be fixed and accepted separately from the trigger-threshold work.

    No change to the ask in #600. The gap is real on the hosted path we measured, it is not an artefact of the counting convention, and the fix is the same one: take the provider's own number as the primary source for the compaction decision and keep the local estimate as the fallback when the provider cannot be asked. The v1 runtime already does this — ContextManager resolves through the injected counter first, and local-runtime/src/context/remote-token-counter.ts treats the provider endpoint as ground truth — while the v2 compaction path still recomputes the whole history locally. If that split is deliberate we would like to understand why.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdesktopMiniMax Code desktop appneeds-triageAwaiting maintainer assessment

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions