Skip to content

fix(core): size compression side-query maxOutputTokens to available window - #7962

Open
zambalee wants to merge 3 commits into
QwenLM:mainfrom
zambalee:fix-compression-sidequery-output-budget
Open

fix(core): size compression side-query maxOutputTokens to available window#7962
zambalee wants to merge 3 commits into
QwenLM:mainfrom
zambalee:fix-compression-sidequery-output-budget

Conversation

@zambalee

@zambalee zambalee commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #7960.

Summary

chatCompressionService.ts's compression side-query always requested a fixed maxOutputTokens: COMPACT_MAX_OUTPUT_TOKENS (20,000), regardless of how much of the context window was already consumed by the prompt being compressed. On smaller --max-model-len deployments this can push promptTokens + 20000 past the model's context window, so the backend rejects the request with a 400 before the model ever generates anything — which chatCompressionService.ts then reports as COMPRESSION_FAILED_EMPTY_SUMMARY, indistinguishable from the model genuinely producing an empty summary.

Fix

  • New computeCompactionOutputBudget(window, promptTokens) dynamically sizes the requested output budget to min(COMPACT_MAX_OUTPUT_TOKENS, max(0, window - promptTokens - outputClampMargin(window))), sharing the same window-scaled safety margin (outputClampMargin, from tokenLimits.ts) already used by the main-turn's clampOutputTokensToWindow — so the two request-construction sites that decide "how much output can we ask for" no longer drift apart with two different margin policies.
  • If the computed budget falls below COMPACT_MIN_OUTPUT_TOKENS, the compression bails out early with the existing CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED status instead of sending a request that's guaranteed to 400.

Verification

  • Reproduced the original 400s directly against a self-hosted vLLM backend, using the exact real conversation histories from two independently-failed sessions (two different self-hosted models, same --max-model-len 65536 deployment). With the old fixed-20000 request: both reproduce the 400 BadRequestError. With the new dynamically-computed budget: both succeed (200, non-empty well-formed <state_snapshot>, completion tokens well under the computed budget in both cases).
  • Also ran a full end-to-end CLI session (not just direct backend calls) against the same deployment after this fix (combined with a separate, already-reported fix for Main-turn output-token clamp can under-count CJK-heavy new content by ~chars/4, occasionally overflowing the context window by a few tokens #7961): 27 conversation turns / ~53.8 minutes / ~1.12M tokens processed, zero recurrence of this failure mode.
  • chatCompressionService.test.ts: 94 tests passing, including 4 new tests covering computeCompactionOutputBudget directly (one reproducing the exact real-world numbers from the incident: 43,549 estimated vs 50,951 true prompt tokens).

Screenshot / Demo

N/A — no user-facing change. This is an internal request-construction fix (how maxOutputTokens is computed for the compression side-query); there is no new command, flag, or visible CLI behavior to demonstrate. See the Verification section above for before/after reproduction results.

Notes for reviewers

  • COMPACT_OUTPUT_SAFETY_MARGIN is kept as an exported constant for reference/back-compat but is no longer used in the budget computation itself (superseded by the window-scaled outputClampMargin).
  • This PR is submitted as two squashed commits from my working history (initial fix + a same-day follow-up correcting my own initial fixed-margin value to the window-scaled one) — happy to squash further if preferred.

Zamba Lee added 2 commits July 29, 2026 03:11
…indow

The compaction side-query always requested a fixed maxOutputTokens
(COMPACT_MAX_OUTPUT_TOKENS=20_000) regardless of how much of the context
window the prompt itself already consumed. On smaller-window deployments
(e.g. self-hosted vLLM with --max-model-len 65536) this can push
promptTokens + 20_000 past the model's context length, causing the backend
to reject the request with a 400 before generation even starts — surfaced
identically to a genuinely empty model summary as
COMPRESSION_FAILED_EMPTY_SUMMARY.

Add computeCompactionOutputBudget() to size maxOutputTokens dynamically
from the actual remaining window, and bail out early with the existing
COMPRESSION_FAILED_OUTPUT_TRUNCATED status (instead of sending a request
guaranteed to fail) when the remaining budget is too small to be useful.

Reproduced via direct curl payloads against a self-hosted backend using
the real compression prompt and a real failed session's history at
increasing token-count tiers; confirmed root cause and fix by observing
the 400 only appears once promptTokens approaches the window ceiling.
…low-up)

computeCompactionOutputBudget's flat COMPACT_OUTPUT_SAFETY_MARGIN (1_000)
was not enough headroom for the caller's estimatedPromptTokens to be
off from the backend's true tokenizer count. A real production case
(KAT-Coder-V2.5-Dev, 2026-07-28, verified live via direct curl against
the deployment) fed a 43,549-token hard-tier-rescue estimate into this
function while the true prompt was 50,951 tokens (a 7,402-token gap far
past the flat margin), computing a budget generous enough to reproduce
the exact 400-then-empty-summary failure this function exists to
prevent.

Switch to `outputClampMargin(window)` (max(10_000, 5% of window)) —
the same window-scaled margin already used by the main-turn clamp
(clampOutputTokensToWindow) — so both request-construction sites share
one safety-margin policy instead of drifting apart. Verified against
the real regression by re-sending the failed session's exact history
directly to the backend with the new budget: 200 OK, well-formed
non-empty <state_snapshot>, finish_reason=stop.
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 28, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — the linked issue has one of the more thorough reproductions I've seen on this repo.

Template: the body doesn't follow the repo template headings (## What this PR does, ## Reviewer Test Plan, ## Risk & Scope, etc.), but the content covers everything those sections ask for — motivation, verification steps, and linked issue. Not blocking on heading names when the substance is there.

Problem: observed bug, well-evidenced. Issue #7960 includes curl-level reproduction against a real vLLM backend with exact token counts from two independently-failed sessions (43,549 estimated vs 50,951 true prompt tokens on a 65,536 window). The 400 is structurally guaranteed when promptTokens + 20,000 > window — this isn't theoretical.

Direction: aligned. The main-turn request path already got this treatment in #6556 (clampOutputTokensToWindow / outputClampMargin); the compression side-query was the remaining call site still using a fixed maxOutputTokens with no window awareness. CHANGELOG confirms #6556 shipped the main-turn clamp; this closes the gap on the compression path.

Size: 128 production lines (chatCompressionService.ts: 123+/5-), 214 test lines (chatCompressionService.test.ts: 168+/46-). Well within bounds.

Approach: the scope is right — one new pure function (computeCompactionOutputBudget), one early bail-out, one line change at the call site, and a hoist of contextLimit so the force/hard-tier path can also use it. Reusing outputClampMargin from tokenLimits.ts keeps both request-construction sites on the same margin policy. The test changes are mostly scaling up toy window values (1,000 → 100,000+) so the new budget gate doesn't short-circuit existing test paths, plus four new unit tests for the budget function itself. No unrelated changes.

Risk: no elevated risk signals — no high-risk paths matched.

Moving on to code review. 🔍

中文说明

感谢贡献!关联 issue 的复现非常详尽。

模板:PR 正文没有使用仓库模板的标题格式,但内容覆盖了模板要求的所有信息——动机、验证步骤、关联 issue。内容到位,不因标题格式阻塞。

问题:已观测到的 bug,证据充分。Issue #7960 包含对真实 vLLM 后端的 curl 级复现,附带两次独立失败会话的精确 token 数(65,536 窗口下,客户端估计 43,549 vs 后端真实 50,951 prompt tokens)。当 promptTokens + 20,000 > window 时 400 错误是结构性的——不是理论问题。

方向:对齐。主请求路径已在 #6556 中通过 clampOutputTokensToWindow / outputClampMargin 解决了同类问题;压缩 side-query 是唯一仍使用固定 maxOutputTokens 的调用点。CHANGELOG 确认 #6556 已发布主请求的窗口钳制;本 PR 补齐压缩路径。

规模:128 行生产代码,214 行测试代码。在合理范围内。

方案:范围恰当——一个新的纯函数、一个提前退出、调用处一行改动、以及 contextLimit 的提升。复用 tokenLimits.tsoutputClampMargin 保持两个请求构造点的 margin 策略一致。测试改动主要是放大 toy 窗口值以避免新的预算检查短路现有测试路径,外加四个新的单元测试。无无关改动。

风险:无升级风险信号。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 051ec23536b22423c0dead23feadd8d3106c432c · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal: given the problem (fixed 20,000-token maxOutputTokens can overflow the window on small deployments), I would compute min(COMPACT_MAX_OUTPUT_TOKENS, window - promptTokens - margin) before the side-query, reuse outputClampMargin from tokenLimits.ts for consistency with the main-turn clamp (#6556), bail out early if the budget is too small, and pass the dynamic value at the call site. Two files: the service and its test.

The PR does exactly this. computeCompactionOutputBudget is a three-line pure function with the right formula, the early bail-out uses the existing COMPRESSION_FAILED_OUTPUT_TRUNCATED status, and the contextLimit hoist out of the !force branch is necessary so the force/hard-tier path also gets the dynamic budget. No simpler path comes to mind.

Two non-blocking observations:

  1. COMPACT_OUTPUT_SAFETY_MARGIN is exported but never read. The PR body calls it "reference/back-compat", but it's a new constant — there's nothing to be back-compatible with. If it exists only to document the historical flat margin, a comment on computeCompactionOutputBudget would serve the same purpose without adding a dead export. Not worth a round-trip.

  2. Truncation guard still checks >= COMPACT_MAX_OUTPUT_TOKENS, not the dynamic budget. When the budget is below 20,000 (the whole point of this PR on small windows), a model that hits the budget cap produces a truncated summary the guard won't catch (15,000 >= 20,000 → false). This is a pre-existing heuristic limitation — the TODO at line 614 already says the proper fix is finish_reason === 'length' — and the PR strictly improves the status quo (a truncated summary vs a guaranteed 400). Worth tracking as a follow-up, not a blocker here.

Everything else is clean: the outputClampMargin reuse keeps both request-construction sites on one margin policy, the test scaling (toy windows 1,000 → 100,000+) is well-commented and necessary, and the four new computeCompactionOutputBudget unit tests include the exact real-world numbers from the incident.

Testing

Final CI results for 051ec23 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

CI has not run yet (fork PRs require a maintainer to approve the workflow run). No check results to quote. The author reports 94 tests passing locally including 4 new ones — this is the author's claim, not verified evidence.

The sandboxed verification lanes (@qwen-code /verify, @qwen-code /tmux) require the PR author to have write access, which this fork contributor does not. To settle the behavioural claim (that the dynamic budget actually prevents the 400 on a 65,536-token window), a maintainer could check out the branch in a disposable container and reproduce against a small-window OpenAI-compatible endpoint, or verify the arithmetic by hand: computeCompactionOutputBudget(65536, 43549)65536 - 43549 - max(10000, 3277) = 65536 - 43549 - 10000 = 11987, and 43549 + 11987 = 55536 ≤ 65536 ✓ (vs the old fixed 20,000: 43549 + 20000 = 63549, and with the true backend count of 50,951: 50951 + 20000 = 70951 > 65536 ✗).

中文说明

代码审查:PR 的方案与我的独立提案完全一致——在 side-query 前动态计算输出预算,复用 outputClampMargin 保持与主请求路径一致的 margin 策略,预算不足时提前退出。没有更简单的路径。

两个非阻塞观察:(1) COMPACT_OUTPUT_SAFETY_MARGIN 是新导出但从未被读取的常量,注释即可替代;(2) 截断守卫仍检查 >= COMPACT_MAX_OUTPUT_TOKENS 而非动态预算——这是已有的启发式限制(TODO 已注明需要 finish_reason),本 PR 严格优于现状。

测试:CI 尚未运行(fork PR 需维护者批准工作流)。作者报告 94 个测试通过(含 4 个新测试),这是作者声明,非验证证据。沙箱验证通道需要作者有写权限,本 PR 作者没有。维护者可在一次性容器中检出分支并针对小窗口端点复现,或手动验证算术。

Qwen Code · qwen3.8-max-preview

Reviewed at 051ec23536b22423c0dead23feadd8d3106c432c · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal fix for a well-reproduced bug; two non-blocking nits (dead export, truncation guard heuristic).

This is the kind of PR that's easy to review because it does exactly one thing and does it the way the codebase already does it elsewhere. The main-turn request path got window-aware output sizing in #6556; the compression side-query was the last call site still firing a fixed 20,000-token maxOutputTokens regardless of how full the window already is. On a 65,536-token deployment with a 50K-token prompt, that's a guaranteed 400 — and the issue's curl-level reproduction with real session data proves it happens in practice, not just in theory.

The implementation is three lines of arithmetic in a pure function, reusing outputClampMargin so both request-construction sites share one margin policy. The early bail-out below COMPACT_MIN_OUTPUT_TOKENS is the right call — sending a side-query with 500 tokens of output budget would produce garbage. The test scaling (toy windows → realistic sizes) is mechanical but necessary, and the four new unit tests include the exact production numbers from the incident.

Non-blocking nits for a follow-up: the COMPACT_OUTPUT_SAFETY_MARGIN export is dead code (new constant, nothing reads it), and the truncation guard should eventually check against the dynamic budget rather than the fixed ceiling — but that's the pre-existing finish_reason TODO, not this PR's problem to solve.

CI note: the fork PR's CI workflow completed with action_required (never approved to run). Branch protection will still require CI green before merge. The code review and hand-verified arithmetic are the basis for this approval.

中文说明

这是一个干净的单点修复:主请求路径在 #6556 中已实现窗口感知的输出大小调整,压缩 side-query 是最后一个仍使用固定 20,000 token maxOutputTokens 的调用点。在 65,536 token 部署中,50K prompt + 20,000 输出 = guaranteed 400。Issue 的 curl 级复现证明了这是实际问题。

实现是三行算术的纯函数,复用 outputClampMargin 保持两个请求构造点的 margin 策略一致。低于 COMPACT_MIN_OUTPUT_TOKENS 时提前退出是正确的——500 token 的输出预算只能产生垃圾。测试缩放是机械但必要的,四个新单元测试包含了事故的真实生产数据。

非阻塞建议:COMPACT_OUTPUT_SAFETY_MARGIN 导出是死代码;截断守卫最终应检查动态预算而非固定上限——但那是已有的 finish_reason TODO,不是本 PR 的问题。

CI 说明:fork PR 的 CI 工作流以 action_required 完成(未被批准运行)。分支保护仍要求 CI 通过才能合并。代码审查和手动验证的算术是本次批准的依据。

Qwen Code · qwen3.8-max-preview

Reviewed at 051ec23536b22423c0dead23feadd8d3106c432c · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@yiliang114

Copy link
Copy Markdown
Collaborator

⚠️ Failed to process this request. Please re-mention the bot to retry.

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /verify

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Sandboxed verification not started — the PR author does not have write access to this repository, and /verify executes the author's code on a maintainer runner. Use @qwen-code /triage for the static review instead.

沙箱验证未启动 —— 该 PR 作者不具备本仓库写权限,而 /verify 会在维护者 runner 上执行作者的代码。请改用 @qwen-code /triage 进行静态评审。

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification round — built and ran a real backend locally

The triage bot approved this on static review and explicitly left the behavioural claim unverified ("a maintainer could check out the branch in a disposable container and reproduce against a small-window OpenAI-compatible endpoint"). I did that.

Verdict: fix-then-merge. The fix is real and I can prove it end to end. But the bail-out gate it adds fires far more often than intended, and I can reproduce it turning a working compaction into a hard failure on four separate paths — including the reactive overflow recovery and the hard-tier rescue on the default 1M-token window. One line fixes it; I verified that too.

Harness

Isolated worktree at 051ec23 (PR head) vs base 0c0ca5f, against a fake OpenAI-compatible backend that enforces exactly the vLLM rule from the issue: reject with 400 when prompt_tokens + max_tokens > max_model_len, before generating anything.

Everything on the request-construction path is shipped code — ChatCompressionService.compress()runSideQuery()BaseLlmClient.generateText(stream)OpenAIContentGenerator → openai SDK → real HTTP. Only Config's wiring accessors are stubbed, to point a real generator at localhost and pin contextWindowSize.


1. The fix works — confirmed at the wire ✅

Scenario A replays #7960's exact numbers (65,536 window, client-side estimate 43,549, true backend count 50,978):

max_tokens sent prompt + output backend
base 0c0ca5f 20,000 70,978 400compress() throws
PR 051ec23 11,987 62,965 200COMPRESSED

That settles it: the dynamic budget prevents the 400, on the real request path.

Small factual note: on base the 400 propagates as a thrown error out of compress() rather than landing on COMPRESSION_FAILED_EMPTY_SUMMARY. The issue's "the two are indistinguishable" framing may depend on the caller/provider path — worth knowing if the status-split follow-up gets picked up.


2. Blocker — the bail-out gate over-fires ⚠️

wire A/B

The gate feeds estimatedPromptTokens = originalTokenCount + pendingToolResultTokenCount into computeCompactionOutputBudget. But originalTokenCount is GeminiChat.lastPromptTokenCount — the main-turn prompt size, which carries the core system prompt and the full tool declarations. The side-query sends neither. Measured on this branch:

  • getCoreSystemPrompt()5,872 tok vs getCompressionPrompt()896 tok
  • tool declarations ≈ 4,072 tok for just 8 built-ins (a real session registers far more, plus MCP)

So the gate starts from a number ~9K+ too large, then subtracts another outputClampMargin ≥ 10,000 on top. Four reproduced consequences (all: base compresses successfully, PR sends zero requests and returns COMPRESSION_FAILED_OUTPUT_TRUNCATED):

window originalTokenCount real side-query prompt base PR
B auto/manual, client over-counts 65,536 55,000 43,958 200 → COMPRESSED (→12,142) budget 536 → bail
C reactive overflow recovery 65,536 70,000 43,958 200 → COMPRESSED (→27,142) budget 0 → bail
D 32K deployment, auto tier 32,768 28,000 12,031 200 → COMPRESSED (→17,137) budget 0 → bail
E 1M window, hard-tier rescue 1,000,000 977,000 940,021 200 → COMPRESSED (→38,215) budget 0 → bail

Two of these are structural, not edge cases:

  • C — reactive overflow recovery. geminiChat.ts:2646 passes originalTokenCountOverride = contextOverflow.actualTokens ?? contextOverflow.limitTokens ?? contextWindowSize. Whenever contextWindowSize matches the backend's real limit — the normal case, and exactly the small-window deployment this issue is about — every one of those branches is ≥ window by construction (that's why the main turn overflowed). So window − promptTokens − margin ≤ −10,000 → budget 0 → the last-ditch recovery bails before sending anything. It is the only thing standing between the user and a dead session.
  • E — 1,000,000-token window (the shipping default for the qwen3-max class). The largest originalTokenCount still compressible is 948,00029,000 tokens below the hard threshold of 977,000. The hard-tier rescue's whole operating range is inside the dead band.

Same shape on 32,768, the deployment class next door to the one in the issue: the compressible range ends at 20,768, 7,085 tokens below the auto threshold of 27,853 — so auto-compaction bails every time it fires.

coverage + arithmetic


3. Test coverage — the behaviour change itself is unpinned

Mutation matrix over the 94-test suite, one mutant at a time:

mutant outcome
M1 window-scaled margin → flat COMPACT_OUTPUT_SAFETY_MARGIN killed
M2 call site un-wired: maxOutputTokens back to fixed COMPACT_MAX_OUTPUT_TOKENS SURVIVED — 94/94 still pass
M3 delete the COMPACT_MIN_OUTPUT_TOKENS bail-out branch SURVIVED — 94/94 still pass
M4 drop the Math.max(0, …) floor killed
M5 drop the COMPACT_MAX_OUTPUT_TOKENS ceiling killed
M6 margin floor 10,000 → 2,000 killed

Undoing the entire fix at the call site leaves the suite green. Running the PR's suite against the base source agrees: only 4 of 94 tests fail, and all four are computeCompactionOutputBudget unit tests — the pure function is well covered, the wiring and the new branch are not.

Also worth a look: the test titled "uses a window-scaled margin (not a flat constant): a larger window gets a larger absolute margin" does not catch M1. Both of its budgets clamp to COMPACT_MAX_OUTPUT_TOKENS, so expect(small).toBeLessThanOrEqual(large) is 20000 <= 20000 — it passes with a flat margin too.

Two tests close both gaps (I wrote and verified them: pass on head, kill M2/M3 respectively):

  • T1compress() on a 65,536 window with originalTokenCount: 43_549 asserts runSideQuery receives maxOutputTokens === 11_987, not 20,000.
  • T2 — with a budget below the floor, asserts runSideQuery is never called and the status is COMPRESSION_FAILED_OUTPUT_TRUNCATED.

4. Suggested change — one line, verified

Size the budget against the payload that is actually about to be sent. It's already computed two lines above the gate:

// packages/core/src/services/chatCompressionService.ts, replacing
//   const estimatedPromptTokens = originalTokenCount + pendingToolResultTokenCount;
const estimatedPromptTokens =
  estimateContentTokens(slim.slimmedHistory, slimmingConfig.imageTokenEstimate) +
  1_000; // compression system prompt + kick-off turn, per the comment at line ~853

slim.slimmedHistory already includes the pending tool result and already has images/documents stripped, so it is the side-query prompt. The outputClampMargin stays exactly as it is — that margin is what absorbs chars/4 tokenizer error, which is the real lesson from the incident.

Re-ran all five wire scenarios with this applied: A 200 ✓ · B 200 ✓ · C 200 ✓ · D 200 ✓ · E 200 ✓ — every one COMPRESSED, none over the window. 93/94 of the PR's own tests still pass; the one failure is a fixture artifact ("does not deep-clone full history while compressing" puts a 1 MiB tool output — ≈262K estimated tokens — inside a declared 100K window, so refusing it is arguably the correct new behaviour, but the fixture would need adjusting).


5. Confirmed, non-blocking

  • COMPACT_OUTPUT_SAFETY_MARGIN is dead. Agreeing with the bot: git show 0c0ca5f confirms it's introduced by this PR, so there is nothing to be back-compatible with, and nothing reads it — only comments mention it.
  • The truncation guard still compares against COMPACT_MAX_OUTPUT_TOKENS rather than compressionOutputBudget. Pre-existing finish_reason TODO; not this PR's to fix.

Housekeeping

  • chatCompressionService.test.ts94/94 pass on the head (confirms the author's number).
  • Adjacent suites — 500/500 pass (chatCompressionService + geminiChat + tokenLimits + postCompactAttachments).
  • eslint clean, prettier --check clean on both changed files.

Thanks for the unusually thorough issue write-up — the reproduction numbers in it are what made this harness cheap to build.

中文说明

维护者本地验证轮次 —— 搭建真实后端实测

triage bot 是静态审查通过的,并明确指出行为层面的结论未经验证("维护者可在一次性容器中检出分支并针对小窗口端点复现")。我做了这件事。

结论:修复后再合并。 修复本身是真实有效的,我能端到端证明它。但 PR 新增的提前退出(bail-out)判定触发得远比预期频繁,我在四条不同路径上复现出「原本能成功的压缩变成硬失败」,其中一条在默认的 100 万 token 窗口上是无条件发生的。一行代码即可修复,我也一并验证了。

测试环境

隔离 worktree,PR head 051ec23 vs base 0c0ca5f,对接一个假的 OpenAI 兼容后端,严格执行 issue 中的 vLLM 规则:当 prompt_tokens + max_tokens > max_model_len 时在生成前直接返回 400

请求构造路径上的每一行都是仓库代码 —— ChatCompressionService.compress()runSideQuery()BaseLlmClient.generateText(stream)OpenAIContentGenerator → openai SDK → 真实 HTTP。仅 stub 了 Config 的接线方法,用来把真实 generator 指向 localhost 并固定 contextWindowSize

1. 修复有效 —— 已在网络层确认 ✅

场景 A 重放 #7960 的精确数字(65,536 窗口,客户端估算 43,549,后端真实计数 50,978):

发出的 max_tokens prompt + output 后端
base 0c0ca5f 20,000 70,978 400 —— compress() 抛异常
PR 051ec23 11,987 62,965 200COMPRESSED

结论明确:动态预算确实在真实请求路径上避免了 400。

一点事实补充:在 base 上,400 是以抛出异常的形式从 compress() 传出的,并没有落到 COMPRESSION_FAILED_EMPTY_SUMMARY。issue 中"两者不可区分"的说法可能依赖具体的调用方/provider 路径 —— 如果后续要做状态拆分,这点值得注意。

2. 阻塞项 —— 提前退出判定触发过度 ⚠️

该判定把 estimatedPromptTokens = originalTokenCount + pendingToolResultTokenCount 传给 computeCompactionOutputBudget。但 originalTokenCountGeminiChat.lastPromptTokenCount,即主请求的 prompt 大小,其中包含 core system prompt 和完整的工具声明 —— 而 side-query 两者都不发送。本分支上实测:

  • getCoreSystemPrompt()5,872 tokgetCompressionPrompt()896 tok
  • 工具声明 ≈ 4,072 tok(仅 8 个内置工具;真实会话注册的远不止,还有 MCP)

也就是说判定起点就偏大约 9K+,然后又额外减去 ≥ 10,000 的 outputClampMargin。复现出四个后果(均为:base 压缩成功,PR 一个请求都不发并返回 COMPRESSION_FAILED_OUTPUT_TRUNCATED):

窗口 originalTokenCount 真实 side-query prompt base PR
B auto/manual,客户端高估 65,536 55,000 43,958 200 → COMPRESSED (→12,142) 预算 536 → 退出
C 反应式溢出恢复 65,536 70,000 43,958 200 → COMPRESSED (→27,142) 预算 0 → 退出
D 32K 部署,auto 档 32,768 28,000 12,031 200 → COMPRESSED (→17,137) 预算 0 → 退出
E 100 万窗口,hard 档兜底 1,000,000 977,000 940,021 200 → COMPRESSED (→38,215) 预算 0 → 退出

其中两条是结构性的,不是边缘情况:

  • C —— 反应式溢出恢复。 geminiChat.ts:2646 传入 originalTokenCountOverride = contextOverflow.actualTokens ?? contextOverflow.limitTokens ?? contextWindowSize。只要 contextWindowSize 与后端真实上限一致(常规情形,也正是本 issue 所述的小窗口部署),这三个分支按定义都 ≥ window(正是因为溢出才走到这里),于是 window − promptTokens − margin ≤ −10,000 → 预算 0 → 这个最后的兜底恢复在发送任何请求前就退出了。它是用户与「会话彻底卡死」之间唯一的屏障。
  • E —— 100 万 token 窗口(qwen3-max 一类模型的出厂默认值)。 仍可压缩的最大 originalTokenCount948,000,比 hard 阈值 977,000 低 29,000。也就是说 hard 档兜底的整个工作区间都落在死区里。

32,768 窗口(与 issue 所述部署类型相邻的一档)情况相同:可压缩区间止于 20,768,比 auto 阈值 27,853 低 7,085 —— 自动压缩每次触发都会直接退出。

3. 测试覆盖 —— 行为变更本身没有测试锁定

对 94 个测试逐一施加变异(mutation):

变异 结果
M1 窗口缩放 margin → 扁平 COMPACT_OUTPUT_SAFETY_MARGIN 被杀死
M2 调用点还原:maxOutputTokens 改回固定 COMPACT_MAX_OUTPUT_TOKENS 存活 —— 94/94 仍通过
M3 删除 COMPACT_MIN_OUTPUT_TOKENS 提前退出分支 存活 —— 94/94 仍通过
M4 去掉 Math.max(0, …) 下限 被杀死
M5 去掉 COMPACT_MAX_OUTPUT_TOKENS 上限 被杀死
M6 margin 下限 10,000 → 2,000 被杀死

在调用点把整个修复撤销,测试套件依然全绿。用 PR 的测试跑 base 源码也印证了这点:94 个里只有 4 个失败,且全部是 computeCompactionOutputBudget 的纯函数单测 —— 纯函数覆盖良好,接线和新分支没有覆盖。

另外值得一看:标题为 "uses a window-scaled margin (not a flat constant)" 的测试并不能捕获 M1。它的两个预算都被 clamp 到 COMPACT_MAX_OUTPUT_TOKENS,因此 expect(small).toBeLessThanOrEqual(large) 实际是 20000 <= 20000 —— 用扁平 margin 一样能通过。

两个测试可以补上这两个缺口(我已编写并验证:在 head 上通过,分别杀死 M2/M3):

  • T1 —— 65,536 窗口、originalTokenCount: 43_549 时,断言 runSideQuery 收到的是 maxOutputTokens === 11_987 而非 20,000。
  • T2 —— 预算低于下限时,断言 runSideQuery 从未被调用且状态为 COMPRESSION_FAILED_OUTPUT_TRUNCATED

4. 建议修改 —— 一行,已验证

真正即将发送的载荷来计算预算。它在判定上方两行就已经算好了:

// packages/core/src/services/chatCompressionService.ts,替换
//   const estimatedPromptTokens = originalTokenCount + pendingToolResultTokenCount;
const estimatedPromptTokens =
  estimateContentTokens(slim.slimmedHistory, slimmingConfig.imageTokenEstimate) +
  1_000; // 压缩 system prompt + kick-off 轮次,见第 853 行附近注释

slim.slimmedHistory 已包含 pending tool result,且已剥离图片/文档 —— 它就是 side-query 的 prompt。outputClampMargin 完全保持不变:吸收 chars/4 分词误差正是它的职责,也正是本次事故的真正教训。

应用后重跑全部五个网络层场景:A 200 ✓ · B 200 ✓ · C 200 ✓ · D 200 ✓ · E 200 ✓ —— 全部 COMPRESSED,无一超窗。PR 自带测试 93/94 通过;唯一失败是 fixture 造成的("does not deep-clone full history while compressing" 在声明为 100K 的窗口里放了 1 MiB 工具输出 ≈ 26.2 万估算 token,因此拒绝它可以说才是正确的新行为,但该 fixture 需要相应调整)。

5. 已确认的非阻塞项

  • COMPACT_OUTPUT_SAFETY_MARGIN 是死代码。 同意 bot 的判断:git show 0c0ca5f 确认它由本 PR 引入,因此不存在向后兼容对象,且无任何代码读取 —— 只有注释提到它。
  • 截断守卫仍与 COMPACT_MAX_OUTPUT_TOKENS 比较而非 compressionOutputBudget。这是既有的 finish_reason TODO,不属于本 PR 范畴。

其他检查

  • chatCompressionService.test.ts —— head 上 94/94 通过(印证作者的数字)。
  • 相邻测试套件 —— 500/500 通过chatCompressionService + geminiChat + tokenLimits + postCompactAttachments)。
  • 两个改动文件 eslintprettier --check 均干净。

感谢这份异常详尽的 issue 说明 —— 里面的复现数字正是让这套测试环境搭建成本很低的原因。

…load

Maintainer review on PR QwenLM#7962 found the bail-out gate over-fired: it fed
originalTokenCount (the main-turn prompt size, which includes the core
system prompt + full tool declarations the side-query never sends) into
computeCompactionOutputBudget, understating the real headroom by ~9K+
tokens. This turned working compactions into hard failures on four
reproduced paths, two of them structural rather than edge cases — the
reactive overflow-recovery path and the 1M-token hard-tier rescue, whose
entire operating range fell inside the resulting dead band.

Size the budget from slim.slimmedHistory instead — the payload actually
about to be sent, already computed two lines above the gate — leaving
outputClampMargin as the sole absorber of chars/4 estimation error, which
is the real lesson from the original incident.

Adds the two tests the mutation-testing pass flagged as missing (M2/M3):
one pinning the dynamic budget value end-to-end, one pinning the bail-out
path when the real payload leaves no room. Also widens the deep-clone
regression test's synthetic tool-output payload down from 1 MiB (which the
new history-based estimate would now correctly reject as over-budget on
that test's 100K window) to 200 KB, which still exercises the same
getHistory-not-called assertion without tripping the budget gate.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen precheck requires maintainer approval before automated triage/review.

Head SHA: 35e748352f4ba8d176c34c4ca4c1c45e09210e65

Reason:

  • prompt_injection:system_prompt

A maintainer with write access can inspect the PR and manually request a run with @qwen-code /triage or @qwen-code /review. A new push requires a fresh precheck.

@zambalee

Copy link
Copy Markdown
Contributor Author

Thanks for the extremely thorough verification — the wire-level A/B/C/D/E matrix and the mutation-testing pass made the actual defect obvious. Pushed a fix at 35e7483:

  • estimatedPromptTokens now comes from estimateContentTokens(slim.slimmedHistory, ...) + 1_000 (the payload actually about to be sent) instead of originalTokenCount + pendingToolResultTokenCount (the main-turn's prompt size, which includes the system prompt + tool declarations the side-query never sends). outputClampMargin is untouched — it stays the sole absorber of chars/4 estimation error, per your point about the real lesson from the incident.
  • Added the two tests from the mutation report: one pins computeCompactionOutputBudget's output end-to-end (asserts maxOutputTokens === 11_987 on the scenario-A numbers, not 20_000), the other pins the bail-out path when the real payload leaves no room. Re-running your M2/M3 mutations against this version — reverting the call-site wiring or deleting the bail-out branch — now fails, so the wiring itself is covered.
  • Adjusted the "does not deep-clone full history" fixture: shrunk the synthetic tool-output payload from 1 MiB to 200 KB (was going to correctly get bailed out as over-budget on that test's 100K window under the new history-based estimate, which isn't what that test is exercising) rather than widening the window, since widening it would have also skipped past the auto-compact threshold gate earlier in compress().

chatCompressionService.test.ts — 96/96 passing (94 prior + 2 new). Also reran the adjacent suites you flagged (geminiChat, tokenLimits, postCompactAttachments) — 502/502 passing. eslint --max-warnings 0 and prettier --check clean on both changed files.

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — round 2 (35e7483), built and run against a real backend locally

Re-ran the round-1 harness against the new commit, plus a new experiment the last round didn't cover. Thanks for turning R1 around so fast — and I want to be straight about one thing up front: 35e7483 implements the fix I suggested, and my suggestion was wrong. It trades the over-count I found for an under-count that, on CJK conversations, leaves the original bug fully intact.

Verdict: fix-then-merge. Details and a tested alternative below.


What 35e7483 genuinely fixes

The R1 blocker is gone. On the reactive overflow-recovery path (geminiChat.ts:2646 passes actualTokens ?? limitTokens ?? contextWindowSize, all >= window by construction), the budget gate no longer sees a poisoned count:

arm outcome
051ec23 (R1 head) bailed, 0 requests — the R1 blocker
35e7483 (this head) HTTP 200, compressed

Same for the other three over-count paths I listed. originalTokenCount is out of the gate, so they're all fixed. Suite is 96/96, prettier --check and eslint --max-warnings 0 clean on both files. No disagreement there.

One bookkeeping note: the PR body's Verification section (the live vLLM repro, "43,549 estimated vs 50,951 true") describes the original commits — that run exercised originalTokenCount, which 35e7483 no longer feeds to the gate. The section is worth re-running or re-wording before merge so it still matches the code.


Finding 1 (blocking) — for CJK histories the fix is a no-op; the original 400 comes back verbatim

estimateContentTokens is chars/4. Measured against the real Qwen3-8B tokenizer (tokenizer.json, 11.4 MB, via hf-mirror):

script real chars/token vs chars/4
Chinese 1.80 under-counts 2.22×
English 4.50 over-counts 1.12×

So on a 50K-token Chinese history the gate is handed a ~22.5K "prompt size", min(20000, 65536 − 23532 − 10000) clamps straight back to 20,000 — the exact fixed constant this PR exists to replace — and vLLM rejects the request before generating anything.

I built a fill-level sweep on a 65,536 window. The rescue band is where base 400s but the window still has room for a valid summary; that band is the entire point of #7960. History is calibrated so the backend's true prompt count hits each target, so the English and Chinese rows are the same size on the wire and differ only in script.

wire-level A/B across the rescue band

Compactions rescued across the band: base 0/10 → this PR 1/10. For Chinese, 0 of 5 — the PR's 400 is byte-identical to base's: same max_tokens=20000, same total, same error string.

To be fair to the PR: below the band (I also swept 30K / 40K / 45K) all three arms compress successfully, so this isn't a regression against main anywhere I could find — it's that the fix doesn't reach the cases it was written for. And these fixtures are single-script; a real session mixes ASCII code with CJK prose, so the error lands between the two rows above and scales with the CJK fraction.

estimateContentTokens' own doc comment calls this out:

char/4 is a conservative lower bound (real tokenizers vary ±30%); using it to TRIGGER compaction earlier is safe (false-positive), using it to SKIP compaction is not.

The gate does exactly the latter — it both skips compaction and sizes a wire field.

Worth flagging: your own #7963 fixes this same chars/4 CJK under-count for the output clamp with a 1.5× conservative inflation. #7962 introduces a new chars/4 consumer with no inflation at all — and per the measurement above, even 1.5× wouldn't cover the 2.22× gap.

Finding 2 (blocking) — the 1.12× English over-count bails out compactions that would have fit

Above ~46K the English over-count pushes the computed budget under COMPACT_MIN_OUTPUT_TOKENS and compaction is skipped entirely. At true=50,939 the correct budget is 65,536 − 50,939 − 10,000 = 4,597 — comfortably over the 2,000 floor, and the summary in my fixture needs 168 tokens. Instead: no request, no summary, COMPRESSION_FAILED_OUTPUT_TRUNCATED.

This is a better diagnosis than base's 400 (which is the PR's stated secondary goal, and real value), but the session is equally stuck.

Finding 3 (non-blocking) — the two new tests can't distinguish the change they're named for

estimator ground truth and mutation matrix

M7 — reverting estimatedPromptTokens to R1's originalTokenCount + pendingToolResultTokenCount, i.e. undoing this round's entire change — fails 0 of 96 tests. The fixtures make the two formulas numerically identical:

  • "sizes maxOutputTokens from the real side-query payload, not originalTokenCount" — history estimate is 42,549 + 1,000 = 43,549, and the test passes originalTokenCount: 43_549. The comment says "deliberately mismatched", but they're equal, so both formulas yield 11,987.
  • "bails out … when the real payload leaves no budget" — new formula gives 60,000, old gives 55,000; both fall under COMPACT_MIN_OUTPUT_TOKENS, so both bail.

M2/M3 are genuinely killed now, as you said — but those pin the call-site wiring, which was already correct at 051ec23. To pin this round's change, one of the two fixtures needs originalTokenCount far from the history estimate (and ideally a CJK fixture).

Also M9 survives: swapping slim.slimmedHistory for the unslimmed sideQueryHistory fails nothing.

(M7's survival isn't a harness artifact — M8, a different edit to the very same expression, is killed by that same test. The anchor region is covered; it just isn't covered for the thing that changed.)


A tested direction

The estimate needs to be accurate, not merely conservative in one direction — the margin can't absorb a 2.22× error. Combining an API-authoritative anchor with a density-aware fallback rescues 10/10 band cells in both languages (third column in the first screenshot):

const MAIN_TURN_ONLY_OVERHEAD_TOKENS = 8_000; // measured below
const estimatedPromptTokens = Math.min(
  // originalTokenCount is the API-reported MAIN-turn count: accurate for any
  // script, but includes the system prompt + tool decls the side-query omits.
  originalTokenCount > 0
    ? Math.max(0, originalTokenCount - MAIN_TURN_ONLY_OVERHEAD_TOKENS)
    : Number.POSITIVE_INFINITY,
  // Density-aware fallback: correct on first send, and caps the synthetic
  // >= window value the reactive overflow path passes in.
  cjkAwareTokens(slim.slimmedHistory) + 1_000,
);

min() matters in both directions: the anchor keeps CJK honest, the history term stops the overflow path's synthetic count from bailing (I verified the anchor alone re-breaks that path — same failure as R1).

I measured the overhead constant with the real tokenizer: core system prompt 4,839 + 8 built-in tool declarations 3,669 − compression prompt 705 = 7,803. Real setups carry more tools, so a slightly-low constant errs conservative.

Two caveats, so this isn't taken as drop-in. It fails the two new tests (19,987 vs the expected 11,987, and the second no longer bails) — but both fixtures are pure-ASCII 'x'.repeat(...), whose real token count is nowhere near chars/4, so those expectations encode the assumption under review. And the 1.5/4.5 divisors are Qwen3-specific. Please treat this as evidence the band is reachable rather than a finished patch — the estimator choice is yours, and it should probably be shared with #7963 rather than invented twice.


Harness / reproduction

Fake OpenAI-compatible server enforcing vLLM's pre-generation check (prompt_tokens + max_tokens > max_model_len → 400), with prompt tokens counted by the real Qwen3-8B tokenizer over HTTP rather than a chars/4 approximation.

Unmocked path under test: ChatCompressionService.compress()runSideQuery()BaseLlmClient.generateText(stream)createOpenAIContentGenerator → openai SDK → real HTTP. Only three Config wiring accessors are patched (getContentGeneratorConfig / getContentGenerator / getBaseLlmClient) so a real Config from makeFakeConfig() points at localhost with a pinned contextWindowSize.

  • History is calibrated per cell with a separate tiny-max_tokens generateText probe, so calibration is identical in arms where compress() bails without sending.
  • originalTokenCount is derived as trueSideQueryPromptTokens + 7,803 (the measured main-turn-only overhead) rather than hardcoded, so the anchor tracks history size the way a real session does.
  • Arms: wt-base (0c0ca5f) · wt-r1 (051ec23) · wt-r2 (35e7483) · candidate patches, one worktree each, identical node_modules.
  • Every mutant proved it landed via git diff --stat before its suite run.
中文版本

维护者验证 —— 第 2 轮(35e7483),本地构建并对真实后端运行

先说清楚一点:35e7483 实现的正是我上一轮建议的方案,而我的建议是错的。 它把我发现的「高估」换成了「低估」,而后者对本仓库最大的用户群体伤害更大。

结论:修复后可合并(fix-then-merge)。

这个提交确实修好了什么

R1 的阻塞问题已解决。在反应式溢出恢复路径上(geminiChat.ts:2646 传入的 actualTokens ?? limitTokens ?? contextWindowSize 按构造必然 >= window):051ec23 静默跳过、零请求;35e7483 HTTP 200,压缩成功 ✅。我列出的另外三条高估路径同样已修复。96/96 测试通过,prettiereslint --max-warnings 0 均干净。

一点记录性的提醒:PR 描述中的 Verification 段落(真实 vLLM 复现,「43,549 估算 vs 50,951 真实」)描述的是最初那两个提交 —— 那次运行走的是 originalTokenCount,而 35e7483 已不再把它喂给闸门。合并前建议重跑或改写该段,使其与代码一致。

发现 1(阻塞)——对中文历史,该修复完全无效,原始 400 原样复现

estimateContentTokenschars/4。用真实 Qwen3-8B tokenizer 实测:中文 1.80 字符/token(低估 2.22×),英文 4.50(高估 1.12×)。

因此 5 万 token 的中文历史,闸门只看到约 2.25 万的「prompt 大小」,min(20000, 65536 − 23532 − 10000) 直接钳回 20,000 —— 正是本 PR 想要取代的那个固定常量,vLLM 随即在生成前拒绝请求。

65,536 窗口上做了填充度扫描。救援区间base 会 400、但窗口其实还放得下摘要的区间,也正是 #7960 的核心场景。历史经过校准,使后端真实 prompt 计数命中同一目标值,因此中英文行在链路上体积相同,只有文字种类不同。

该区间内成功压缩数:base 0/10 → 本 PR 1/10;中文 0/5 —— PR 的 400 与 base 逐字节相同:同样的 max_tokens=20000、同样的总数、同样的错误串。

也说句公道话:在该区间以下(我另外扫了 30K / 40K / 45K),三个分支都能成功压缩,因此我没有找到相对 main 的任何回退 —— 问题在于这个修复没能覆盖它本要解决的场景。另外这些夹具是单一字种;真实会话是 ASCII 代码与中文叙述混排,误差会落在上表两行之间,并随 CJK 占比变化。

estimateContentTokens 自己的注释已经写明:用它来提前「触发」压缩是安全的,用它来「跳过」压缩则不安全。而该闸门恰恰是后者。

另外值得一提:你自己的 #7963 正是在修 chars/4 对 CJK 的低估(对输出钳制加了 1.5× 保守膨胀)。#7962 却新增了一个完全没有膨胀的 chars/4 使用点 —— 而按上面的实测,即便 1.5× 也补不上 2.22× 的缺口。

发现 2(阻塞)——英文侧 1.12× 的高估会跳过本可成功的压缩

约 46K 以上,英文高估把预算压到 COMPACT_MIN_OUTPUT_TOKENS 之下,压缩被整个跳过。在 true=50,939 时,正确预算是 65,536 − 50,939 − 10,000 = 4,597,远高于 2,000 下限(我的样例摘要只需 168 token)。结果却是:不发请求、无摘要。这比 base 的 400 诊断性更好(也正是本 PR 的次要目标,确有价值),但会话同样卡住。

发现 3(非阻塞)——两个新测试无法区分它们所命名的改动

M7 —— 把 estimatedPromptTokens 还原为 R1 的 originalTokenCount + pendingToolResultTokenCount,即撤销本轮全部改动 —— 96 个测试零失败。 因为夹具让新旧公式数值完全相同:

  • "sizes maxOutputTokens from the real side-query payload…":历史估算 42,549 + 1,000 = 43,549,而测试传入 originalTokenCount: 43_549。注释写「deliberately mismatched」,实际两者相等,新旧公式都得 11,987
  • "bails out … when the real payload leaves no budget":新公式 60,000、旧公式 55,000,双双低于 COMPACT_MIN_OUTPUT_TOKENS,都会跳过。

M2/M3 现在确实被杀死了 —— 但它们钉住的是调用点接线,而那在 051ec23 时就已正确。要钉住本轮改动,需要让某个夹具的 originalTokenCount 明显偏离历史估算(最好再加一个 CJK 夹具)。另外 M9 存活:把 slim.slimmedHistory 换成未精简的 sideQueryHistory,无一测试失败。

(M7 的存活不是测试框架的假象 —— 对同一个表达式做另一处改动的 M8 会被同一个测试杀死。该处是有覆盖的,只是没覆盖到本轮真正改动的那一点。)

一个经过验证的方向

估算需要的是准确,而不只是单向保守 —— 安全边际吸收不了 2.22× 的误差。把 API 权威锚点字种感知回退min() 组合后,该区间中英文 10/10 全部救回(见第一张图第三列):

const MAIN_TURN_ONLY_OVERHEAD_TOKENS = 8_000;
const estimatedPromptTokens = Math.min(
  originalTokenCount > 0
    ? Math.max(0, originalTokenCount - MAIN_TURN_ONLY_OVERHEAD_TOKENS)
    : Number.POSITIVE_INFINITY,
  cjkAwareTokens(slim.slimmedHistory) + 1_000,
);

min() 两个方向都不可少:锚点保证 CJK 不被低估,历史项则限制住溢出路径传入的合成 >= window 值(我验证过:用锚点会让该路径重现 R1 的失败)。

开销常量用真实 tokenizer 实测:核心系统提示 4,839 + 8 个内置工具声明 3,669 − 压缩提示 705 = 7,803。真实环境工具更多,常量取偏小反而偏保守。

两点保留,以免被当成可直接套用的补丁。 它会让两个新测试失败(19,987 vs 期望的 11,987;第二个不再跳过)—— 但这两个夹具都是纯 ASCII 的 'x'.repeat(...),其真实 token 数与 chars/4 相差极远,因此这些期望值本身就编码了正在被审视的那个假设。而 1.5/4.5 两个除数是 Qwen3 专用的。请把它当作「该区间可被救回」的证据,而非成品补丁 —— 估算器怎么选由你决定,并且它大概率应当与 #7963 共用,而不是各写一份。

@wenshao

wenshao commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

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

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compression side-query's fixed maxOutputTokens can exceed context window on small-window deployments, causing 400 → COMPRESSION_FAILED_EMPTY_SUMMARY

4 participants