Repository navigation
Description
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
不需要特殊环境,任意开启自动压缩(默认开启)的长会话即可复现。
- 使用一个 1M 上下文窗口的模型(本例:hy4,context 1,000,000 / max_tokens 65,536),正常对话若干轮。
- 对话直到发生自动压缩(历史中出现 role=compactionSummary 的消息)。
- 打开 .minimax/v2/sessions/<日期>/<会话>/messages.jsonl,读取该 compactionSummary 消息的 tokensBefore。
- 按 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 - 两者几乎相等(差值即最后一回合的增量)。
本机 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 校准),因此用户看到的百分比与真正触发压缩的
数字不是同一个。
【建议】
- 触发判定以上游 usage 锚定为主(下一回合占用 ≈ 上一回合上游用量 + 其后新增消息估算),
Math.max 改为以锚点为主、无锚点时才用本地全量重算; - provider 支持时用其自身的 count_tokens 端点取真值(anthropic-messages / responses / 智谱 / Kimi 的
count_tokens body 仓库里已有:packages/agent-modules/context-manager/src/count-tokens-body.ts); - 远程不可用时退回本地 BPE,并在该次判定里标记 source=local_estimate,计数失败必须 fail-open,不得中断对话;
- 图片/视频计价统一到按缩放后像素的估算口径,不要再用固定 4,800/张;
- 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
had77guai commentedon Nov 11, 2026
[#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 数。三件事同时成立:
o200k_base实测 0.58 token/字符);而 assistant 的thinking块被本地计费、provider 侧却整块丢弃——这是最大的一项误差来源。PROVIDER_USAGE_ANCHORED),因此用户看到的百分比和真正触发压缩的数字不是同一个;且触发瞬间的真实占用无法从落盘数据反推(压缩前历史已被compactionSummary替换)。同时,这套上游计数能力仓库里已经有了,只是压缩链路没用它:
HttpRemoteTokenCounter(支持/v1/messages/count_tokens、/v1/responses/input_tokens、/v1/tokenizers/estimate-token-count等)、ContextUsageAnchorState(已持有上游返回的绝对用量)、以及@mavis/context-manager里为远程计数器预留的注入缝隙。因此本 issue 的诉求是两件事:
count_tokens兜底,本地 BPE 仅作最后降级)。2. 现状:压缩触发是怎么算的
2.1 调用链
路径全部位于发布包内:
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:该文件自己的注释已经写明了问题(L31–36):
以及 L6–11:
2.3 测量:整段历史全量重算 + 与上游锚点取 max
@mavis/local-runtime-v2/dist/service/turn-system/compaction/execution/local-context-footprint.js:关键点:
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.jsL12–38:以本机最常见的组合(
contextWindow = 1,000,000,maxTokens = 65,536)为例:providerInputLimitoutputReserveautomaticTriggerAt(压缩触发线)@mavis/local-runtime-v2/dist/service/turn-system/compaction/automatic-context-compactor.jsL96–99 即以此为判据:即:本地计数越过 887,740 → 立刻压缩。
2.5 候选是否「装得下」也用同一把本地尺子
compaction/execution/checkpoint-provider.jsL257–262:session.fits()/session.measure()(compact-context.jsL136、L544)都走这里。也就是说,压缩过程中「这份历史放不放得进模型」的判断,同样由本地 BPE 决定,而不是由上游的实际限制决定。3. 实测证据
3.1 触发点精确命中本地计数线
本机
2026-11的会话里记录到 22 次会话级自动压缩(另有 4 份内部历史快照副本.history-mutation-*,不重复计数)。压缩摘要中的tokensBefore(= 触发瞬间的本地计数)与按 §2.4 公式算出的触发线对比如下:hy4(context 1,000,000 / output 65,536 → 触发线 887,740)tokensBeforeMiniMax-M3.1-Flash(context 512,000 / output 128,000 → 触发线 364,800)tokensBefore结论:除
791,003这 1 例外,所有事件的tokensBefore都紧贴「本地计数触发线」,差值恰好是最后一回合的增量(831,359那条对应的是 1M 窗口档,同样紧贴其触发线)。这从行为上确认:决定「要不要压缩」的那个数字,就是本地 tokenizer 的输出,与上游返回的真实用量无关。3.1.1 线下的那一次:recovery 路径(代码路径说明 + 一处待确认)
hy4的791,003比触发线低 96,737,没有越过线却发生了压缩。代码里只有一条路径能解释它——automatic-context-compactor.jsL96:input.recovery表示上一轮请求被上游拒绝(超窗 / 请求过大)。此时不管本地计数是多少,都会直接触发压缩。这正是本地计数偏低时的兜底路径:先被上游拒 → 再由恢复路径补一次压缩。于是同一个缺陷有两个方向的代价:
3.2 本地计数 vs 上游真实用量
测量方法(相邻轮差值口径):对
.minimax/v2/sessions/2026/11下 886 个会话,逐回合计算「本地计数器对该回合实际发出的那份请求的增量」与「上游 usage 的增量」:只取同一 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。thinking(错误口径)thinking(正确口径)分模型(排除
thinking后):hy4MiniMax-M3.1-Flash单会话手工复核(
2026/11/10/00-52-44-880-…,hy4):3.3 后果:压缩被提前触发
按 §2.4 的触发线反推触发瞬间上游的真实占用:
hy4(窗口 1,000,000,触发线 887,740)MiniMax-M3.1-Flash(窗口 512,000,触发线 364,800)hy4触发时上游真实占用约 85.5%(折算值,仅作量级参考;触发瞬间的真实值无法从落盘数据反推),而MiniMax-M3.1-Flash因为 512K 窗口下输出预留占比更大,触发线本身只到窗口的 71.3%,所以两者的「提前量」不可直接比较。共同的问题是触发线按窗口固定比例计算、不随实际偏差自适应。代价是:3.4 偏差为什么会偏、往哪偏
thinking块estimateMessage把thinking按正文计费shouldDropPortableThinking对custom_provider:*/minimax_api整块丢弃。修掉这一项,本机中位从 1.110 降到 1.038 —— 这是最大的一项误差来源estimateTextTokensUpperBound()退化为 UTF-8 字节数(1 字节 1 token)o200k_base实测约 0.58 token/字符hy4与M3.1-Flash差异的主因——两者差异主要来自thinking长度(见上)estimateImageBlockTokens()(还带 MiniMax-M 专用公式)——两处口径不一致JSON.stringify(tools)本地计数count_tokens专门校准 tools 的原因)3.5 界面上的数字和触发的数字不是同一个
展示链路(对话里的「上下文窗口」气泡):
context-usage/context-usage.jsL103–115→ 有上游 usage 时用上游,没有才退回本地估计;tools 分量还会用
HttpRemoteTokenCounter远程校准。触发链路(§2.3):全量本地 BPE,上游真值只做尾部锚点且被
Math.max压制。所以同一个会话里,气泡显示的「已用 X%」与真正触发压缩的水位不是同一个数(例如气泡显示 62% 时,压缩实际发生在按本地计数算出的另一个更高的水位上)。用户既看不懂数字,也没法预判压缩何时到来。
4. 诉求
A. 压缩触发判定改用上游 token 计数
期望:判定「上下文有多大」时,以上游返回的 token 数为权威口径。
建议的优先级(从高到低,全部是仓库里已有的能力,不需要新造轮子):
上游 usage 锚定(首选,零额外网络开销)
ContextUsageAnchorState(compaction/execution/usage-anchor.js)已经记录了上一回合上游返回的absoluteTokens。把它从「尾部锚点」提升为整段上下文的主口径:下一回合的真实占用 ≈ 上一回合上游用量 + 其后新增消息的估算值。现有
Math.max(fresh, hint)应改为以 hint 为主、仅在无锚点时才用本地全量重算,即inputTokens = hint ?? fresh。远程
count_tokens(次选,provider 支持时使用)HttpRemoteTokenCounter已支持以下端点,均为上游自己的分词器,是真正的 ground truth:anthropic-messages<baseUrl>/v1/messages/count_tokensminimax-responses/generic-responses<baseUrl>/v1/responses/input_tokenszhipu-tokenizer/api/paas/v4/tokenizerkimi-estimate-token-count/v1/tokenizers/estimate-token-count在下一回合发出之前用同一份 payload(system prompt + tools + 历史)向该端点要一次真实计数,用它替换本地估算。
本地 BPE(仅作最后降级)
远程端点不可用 / 超时 / 返回异常时,退回当前
BpeTokenEstimator,并在该次判定里显式标记source: 'local_estimate',以便日志与 UI 能区分「精确值」和「估计值」。同时要修的两处口径(否则换掉计分器后仍会前后不一):
estimateImageBlockTokens()(按缩放后像素、含 MiniMax-M 专用公式)那套,不要再用固定的 4,800。session.fits()/session.measure()(checkpoint-provider.jsL257–262)必须与触发判定用同一口径,否则会出现「触发了压缩,但压缩用的历史又判定装不下」的自相矛盾。边界要求:
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 观察:这只是补偿计数误差、不是修复:它把「提前压缩」换成「用更少的上下文跑更早的压缩」,窗口利用率仍低于设计值。真正的修复是让判定改用上游 token 数。
B. 把「距离压缩还剩多少 token」暴露出来,并在对话窗口右下角用环形指示器呈现
期望:用户在对话窗口右下角(与左下角的工作指示器同一栏的右侧)能常驻看到一个环形进度指示器,表示当前上下文距离触发自动压缩还剩多少 token。
建议语义:
实现要点:
当前占用(上游口径)、压缩触发线(automaticTriggerAt)、上下文窗口(contextWindow)。前两个目前都没有送到 UI:
automaticTriggerAt/providerInputLimit是在运行时内部每回合算出来的(provider-budget.js),压缩完成后也只回报tokensBefore / tokensAfter / serializedBytesBefore / serializedBytesAfter(context-compaction.jsL519–524)。需要把它们加进会话状态并随会话事件推给渲染进程。strokeDasharray进度环),当前被用作「上下文窗口」气泡的触发按钮(30×30 圆角按钮,ratio= 已用比例,aria-label=message.item.context_usage_aria= 「上下文已使用 {{pct}}%」),挂在消息条目区域(i18n 命名空间为message.item.*),点击才展开明细。本需求是同一套视觉件换一个口径并换个位置:
usedTokens / contextWindowTokens)triggerAt − usedTokens),并可同时表达占用百分比context-window-progress进度条、context_usage_expand_aria/context_usage_collapse_aria、以及分类明细(SYSTEM_PROMPT / MEMORY / TOOLS / SKILLS / MESSAGES / OTHER),新指示器不需要另起炉灶。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. 验收标准
thinking、按相邻轮差值口径,本地/上游比值中位数应从 1.038 收敛到 1.02 以内;且thinking不再计入本地估值。fallbackReason(现有remote-token-counter-failures.js已有该机制)。contextWindow与automaticTriggerAt已随会话状态下发到渲染进程。/compact与自动压缩使用同一计分口径。6. 复现与度量(本机可复现)
A. 确认触发变量是本地计数
.minimax/v2/sessions/<日期>/<会话>/messages.jsonl。role == "compactionSummary"的消息,读它的tokensBefore。automaticTriggerAt(contextWindow = 1,000,000、maxTokens = 65,536→887,740)。B. 比对本地估值与上游真值
对每个带 usage 的 assistant 回合:
其中
est()复刻@mavis/context-manager/dist/token-estimator.js的口径即可(o200k_base + 每条消息 +4 + 图片 4800 + 长 ASCII 走字节兜底)。本机2026/11全量结果见 §3.2。7. 相关代码位置索引
packages/agent-modules/context-manager/src/token-estimator.ts@mavis/context-manager/dist/token-estimator.jspackages/agent-modules/context-manager/src/provider-budget.ts@mavis/context-manager/dist/provider-budget.jspackages/agent-modules/context-manager/src/manager.ts@mavis/context-manager/dist/manager.jscount_tokens请求体构造(仓库内已有)packages/agent-modules/context-manager/src/count-tokens-body.ts@mavis/context-manager/dist/count-tokens-body.jsshouldStart)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.jsMath.max取值处)packages/local-runtime-v2/src/service/turn-system/compaction/execution/local-context-footprint.ts.../dist/.../execution/local-context-footprint.jspackages/local-runtime-v2/src/service/turn-system/compaction/execution/usage-anchor.ts.../dist/.../execution/usage-anchor.jsfits/measure)packages/local-runtime-v2/src/service/turn-system/compaction/execution/checkpoint-provider.ts.../dist/.../execution/checkpoint-provider.jspackages/local-runtime-v2/src/service/turn-system/compaction/algorithm/compact-context.ts.../dist/.../algorithm/compact-context.js@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.../dist/.../context-usage/context-usage.js.../dist/.../context-usage/token-counting/image-detection.js8. 与已有 issue 的关系(提交前已检索)
本 issue 的症状此前有人报过,但根因定位 + 偏差量化 + 桌面端余量可视化尚未有人提。逐条说明,避免被误判为重复:
not_planned,2026-10-19 批量清理积压)completed,2026-09-18)token-estimator.ts的estimateContextTokens。但它修的是云端/legacyContextManager路径在压缩后触发器卡死的一个具体缺陷,且未涉及本地计数本身的偏差幅度。桌面端local-runtime-v2是另一套实现(local-context-footprint.ts的Math.max(fresh, hint)),本 issue 的结论与数据均针对该路径contextManagement.compactionTriggerAt)completed)completed,2026-09-20,fork 贡献)Context ▕██████░░▏ 77% left)。本 issue 的界面诉求是桌面端对话窗口右下角常驻,CLI 已有 ≠ 桌面端已有completed)usage字段判断剩余空间」——本 issue 正是这一建议的量化落地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+cacheWritevs 本地 footprint,比值应在 1.04–1.28,出现 2.55 才是真 bug)在本机复核。结论是:他的判据成立,并直接推翻了我初版里最大的两个数字(§3.2 的两张表与 §3.3 的「≈70%」)——原因是我的算法把 provider 根本不接收的thinking计进了本地侧。本文已按 §3.2 的相邻轮差值口径全部改正。thinking(我初版的口径)thinking(正确口径)误差来源:
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 的三点回应
hy4中位 1.038、M3.1-Flash1.016,与他 6 个会话的 1.044–1.278 同向同量级。我这边样本更多(1,707 对 vs 6 会话),只改变尾部分布,不改变中位。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 是一个真实的实现缺陷,但不是「压缩提前」的主因,两者应分开修、分开验收。修复优先级建议(按改动成本排序)
estimateMessage的投影口径:让本地计价与projectAgentMessagesForModel对齐(先剔除thinking)。纯正确性修复,改动最小,可先做。estimateTextTokensUpperBound只作用于超长 run([Bug]: the oversized-run token fallback applies to the whole text, so one long run inflates the entire message #599),其余走编码器。count_tokens,本地 BPE 仅作降级。—— 和蔼的77
had77guai commentedon Nov 11, 2026
补充:压缩提前触发的根因已定位(provider 计费口径与本地估算口径不一致)
上一条评论提到「偏差来自本地 BPE 估算」,方向对但没说清机制。补充一次实测,结论可以直接落地:决定压缩时机的本地计数,把 provider 根本不计入窗口的
thinking算了进去。1. 现象:触发时上游真实占用远低于触发线
我从本机
local_runtime_token_usage里取出每次压缩前一条上游回报的input + cacheRead + cacheWrite,与压缩摘要里的tokensBefore(本地计数)对比。全部 105 次有上游锚点的压缩事件:minimax/MiniMax-M3.1-Flash(anthropic-messages)custom_provider/hy4(openai-completions兼容端点)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 无条件按正文计费:而生产 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时发出,且放进独立字段:实测本机
hy4会话里 thinking 块的thinkingSignature值就是reasoning_content。3. 直接验证:兼容端点的
prompt_tokens不计reasoning_content对
hy4所用的openai-completions端点做三组对照(同一模型、同一max_tokens,仅 assistant 消息不同):prompt_tokensreasoning_contentcontent即:该端点统计 assistant 的
content,完全不统计reasoning_content。而本地估算仍按全文给 thinking 计费 → 每回合累积的这部分就是纯粹的虚高,且随会话变长线性放大 —— 这解释了为什么
hy4的比值随会话推进从 1.37 一路升到 3.65(每回合的增量偏差只有约 1.16,但本地计数是累计的)。4. 由此得出的结论与建议
estimateMessage必须按目标协议的计费口径计价:对openai-completions这类「thinking 走独立字段、且端点不计入prompt_tokens」的协议,thinking 不应计入本地 input 估算;anthropic-messages则应计入。现状是一律按 o200k 正文计,等于把「不占上游窗口」的内容算成了「占窗口」。contextManagement.compactionTriggerAt的提醒:它与内置触发线取min,只能让压缩更早,无法修复本问题(本地计数虚高 1.75 倍时,88.8% 的线在真实 50% 就已越过)。在计数口径修好之前,用户侧只能靠「减少 thinking 产出」或「换用会计费 thinking 的协议」来缓解。had77guai commentedon Nov 11, 2026
补充二:图片口径确认成立;另附完整归因(其中三项属 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对图片按固定值计费:而同仓库的 Context Usage 链路用的是按缩放后像素估算的
estimateImageBlockTokens()(context-usage/token-counting/image-detection.js,28px patch,含 MiniMax-M 专用公式)。这是 MiniMax Code 内部两条链路口径不一致,不是 provider 差异:同一个仓库里已经有按像素的成熟实现,展示链路在用,触发链路没用。图片密集的会话因此会被本地计数顶高——#93「加了两张截图就自动压缩」正是这个形态。
我从 base64 图片头解析出真实宽高,用
image-detection.js的同一套公式重算。仅各会话压缩后保留部分就已有 137 张内联图片:单张极值:
一张窄长截图(1920×89,按像素 276)在本地被当成 4,800 token。
2. 完整归因:一笔账(含口径差异归属)
取压缩前 3 分钟的完整历史快照(
.history-mutation-*,时间戳与压缩事件对齐),逐项还原「本地计数 898,576 vs 上游真实 372,791」的 525,785 差额:reasoning_content)<system-reminder>注入块在该端点被整块丢弃后三项里有两项(注入块被丢弃、thinking 不计费)是 provider 自己的行为,MiniMax Code 无法也不应该去改变它们;但它们共同说明一件事:本地估算器无论怎么调参,都不可能对任意 provider 都正确——「某段内容算不算窗口」完全由 provider 决定。
主因随会话内容而变,这也解释了为什么不同报告的现象不一致:图片密集的会话(例如 #93)图片是主因;长思考、工具密集的长会话则是 thinking 与注入块是主因。本机 177 个发生过压缩的会话中只有 32 个含内联图片,其余 145 个完全没有图片。
3. 结论与建议(按优先级)
count_tokens为权威口径,本地估算退化为降级兜底。这一条同时解决 thinking、注入块、分词器三项差异,且不会因为将来换 provider 而再次失效。estimateImageBlockTokens()(按像素,含 MiniMax-M 公式)。这是零新增代码路径的复用,且是本 issue 中唯一确属 MiniMax Code 的口径缺陷。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相比。image-detection.js的 28px patch 公式。openai-completions端点发仅 assistant 消息不同的请求(无 thinking / 带reasoning_content/ 同样文本放进content),比较prompt_tokens。UncleMoc commentedon Nov 11, 2026
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:
token-estimator.ts,VISUAL_TOKEN_STAND_IN = 4_800per image) — 1,924,800 tokensimage-detection.ts,estimateImageBlockTokens, pixel-based with the MiniMax-M formula) — 598,440 tokensThat 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-Flashoveranthropic-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 largerhy4spread — our store has nohy4traffic, 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 callsestimateMessage, which uses the flat 4,800 stand-in — so the two chains really do disagree, as you say.UncleMoc commentedon Nov 11, 2026
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)againstusage.input + usage.cacheRead + usage.cacheWritefor the same assistant message) gives:One detail worth recording for anyone re-running this: the accumulated message count has to include the
compactionSummarymessage 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
thinkingcorrection — it depends on the provider, and on this machine it does not apply. The drop you describe isshouldDropPortableThinkinginoutbound-message-normalizer.ts:All of our sessions are on
provider: "minimax"withapi: "anthropic-messages"— the hosted path, not the BYOK tree — soshouldDropPortableThinkingis false, and the thinking block is kept (same-model messages are returned untouched; a siblinganthropic-messagesmodel replays the native block).We measured it rather than inferring it. Same arithmetic, counting vs. excluding the thinking blocks:
Dropping thinking moves every session below 1, i.e. the provider counts those tokens and the local estimate should too. So for
minimax+anthropic-messagesthe published numbers are the right ones, and the local estimate is genuinely above what the provider reports — which is what #600 is about. Oncustom_provider:*/minimax_apiyour 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:
[A-Za-z0-9]{4097,}run — 0.72%, not 0.84%;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 —
ContextManagerresolves through the injected counter first, andlocal-runtime/src/context/remote-token-counter.tstreats 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.