Skip to content

fix(core): guard against CJK-driven char/4 under-count in output clamp - #7963

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
zambalee:fix-maintext-clamp-cjk-undercount
Jul 30, 2026
Merged

fix(core): guard against CJK-driven char/4 under-count in output clamp#7963
wenshao merged 1 commit into
QwenLM:mainfrom
zambalee:fix-maintext-clamp-cjk-undercount

Conversation

@zambalee

@zambalee zambalee commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #7961.

Summary

clampOutputTokensToWindow's steady-state and recovery-path branches compute the remaining output budget using estimatePromptTokens(...), which estimates new content (not yet covered by the API's own authoritative lastPromptTokenCount) with a flat chars / 4 heuristic. That heuristic under-counts CJK-dense text — a single CJK character is very often close to one token by itself, nowhere near 4 chars/token — so when the new content since the last confirmed count is CJK-heavy, the computed remaining budget comes out slightly too generous, and the real request can end up a handful of tokens over the window, triggering the same class of 400 the clamp is meant to prevent.

Fix

  • Added a conservative parameter (default false, backward compatible) to estimatePromptTokens(). When true, only the new-content term (estimateContentTokens([userMessage])) is inflated by CONSERVATIVE_NEW_CONTENT_SAFETY_FACTOR = 1.5 — the API-authoritative lastPromptTokenCount/lastOutputTokenCount terms are left untouched, since they don't need padding.
  • Passed conservative: true at the two call sites that feed clampOutputTokensToWindow (the steady-state promptTokensForClamp computation and the recovery-path countBasedRecoveryEstimate). Left the two earlier "cheap gate / should we compact at all" call sites unchanged — under-counting there is already documented as safe (it only makes compaction trigger a little earlier, never skips it).

Verification

  • Reproduced independently on 3 separate samples, including one from a different CLI client entirely (OpenCode) hitting the same self-hosted backend — same "~1 token over window" signature each time.
  • tokenEstimation.test.ts: added a conservative mode test suite (3 tests), including one reproducing the real CJK-undercount scenario with a synthetic Chinese-heavy functionResponse.
  • geminiChat.test.ts: 2 existing tests' expected maxOutputTokens bumped by 1 (19999→19998, 49722→49721) to reflect the inflation on tiny messages (e.g. "hi"), with comments explaining why.
  • Full end-to-end CLI verification (combined with the fix in fix(core): size compression side-query maxOutputTokens to available window #7962): 27 turns / ~53.8 min / ~1.12M tokens, zero recurrence.

Screenshot / Demo

N/A — no user-facing change. This adjusts an internal token-budget safety margin (estimatePromptTokens's conservative mode); there is no new command, flag, or visible CLI behavior to demonstrate. See the Verification section above for the reproduction cases and updated test expectations.

Notes for reviewers

  • The 1.5x factor is a starting point, not derived from a rigorous CJK-tokenization ratio study — happy to tune it (or switch to a proper tokenizer-based estimate) based on maintainer input.

estimatePromptTokens's steady-state branch uses char/4 to estimate the
size of newly-added content (e.g. tool results just appended to history).
char/4 is a flat ratio that does not account for CJK text density, and two
independent real production failures (both 2026-07-28, on
KAT-Coder-V2.5-Dev and Nex-N2-mini) traced back to this: large CJK-dense
tool results (parallel read_file calls returning design docs) were
under-counted by 39-54%, so clampOutputTokensToWindow computed a
maxOutputTokens that was too large, and prompt + max_tokens exceeded the
window by ~1 token in production, triggering a 400 that propagates past
reactive compression (which correctly detects it has no room left, but
can't undo an already-oversized request).

Add an opt-in `conservative` parameter to estimatePromptTokens that
inflates only the new-content term by 1.5x (ceil'd) — calibrated against
the two observed under-count ratios with headroom, applied only at the
two call sites that feed clampOutputTokensToWindow (where under-counting
is dangerous), left off at the cheap-gate/compaction-trigger call sites
(where the existing code comments already document under-counting as
safe — it only makes compaction trigger earlier, never skip it).
@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

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: headings differ from the repo template (Summary/Fix/Verification vs What this PR does/Why it's needed/Reviewer Test Plan), but the content is thorough and covers everything the template asks for — not worth a round-trip. ✓

Problem: observed bug, well-evidenced. The linked issue (#7961) describes three independent reproductions of the ~1-token window overflow on CJK-heavy content, including one from a different CLI client (OpenCode) against the same backend. A maintainer confirmed the root cause on the issue thread. The code itself acknowledges the risk in the clampOutputTokensToWindow docstring ("a fresh chars/4 estimate under-counts CJK and tool-heavy prompts, which is the one way a residual 400 could return") — this PR closes that documented gap on the steady-state and recovery paths.

Direction: aligned. This fixes a real 400-error class that the existing clamp was built to prevent (#5950). The first-send path already has FIRST_SEND_CLAMP_OVERHEAD_PAD = 20,000 for exactly this reason; the steady-state path lacked an equivalent. CHANGELOG: no direct reference, but the area (token management / output clamp) is clearly within scope.

Size: 26 production lines (2 in geminiChat.ts, 24 in tokenEstimation.ts) + 80 test lines. Well under any threshold.

Approach: the scope is tight — one new parameter with a safe default, applied at exactly the two call sites that feed maxOutputTokens to the backend. The two "cheap gate" compaction-trigger call sites and the chatCompressionService call site are correctly left unchanged. No unrelated changes, no drive-by refactors. The 1.5x factor is acknowledged as a starting point rather than a rigorously derived constant, which is honest — and the docstring documents the observed 39-54% under-count it covers.

Risk: geminiChat.ts is a high-risk path (correlated with post-merge reverts in this repo's history). The change here is two lines adding a parameter to existing calls, so the blast radius is small, but I'm flagging it for reviewer awareness and requiring full CI evidence before approval.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板:标题与仓库模板不同(Summary/Fix/Verification vs What this PR does/Why it's needed/Reviewer Test Plan),但内容详尽,覆盖了模板要求的所有内容——不值得为此打回。✓

问题:已观测到的 bug,证据充分。关联 issue(#7961)描述了三次独立的 CJK 密集内容窗口溢出复现,包括来自不同 CLI 客户端(OpenCode)的复现。维护者在 issue 线程中确认了根因。代码本身在 clampOutputTokensToWindow 文档字符串中已承认此风险——本 PR 在稳态和恢复路径上关闭了这个已记录的缺口。

方向:对齐。修复了现有 clamp 本应防止的真实 400 错误类别(#5950)。首次发送路径已有 FIRST_SEND_CLAMP_OVERHEAD_PAD = 20,000;稳态路径缺少等效填充。

规模:26 行生产代码 + 80 行测试代码。远低于任何阈值。

方案:范围精准——一个新参数(安全默认值),仅应用于向后台发送 maxOutputTokens 的两个调用点。两个"廉价门控"压缩触发调用点和 chatCompressionService 调用点正确地保持不变。无无关改动。1.5x 系数被诚实地标注为起点而非严格推导的常数。

风险:geminiChat.ts 是高风险路径(与本仓库历史中的合并后回退相关)。此处改动仅为两行参数添加,影响范围小,但标记以供审阅者注意,并在批准前要求完整 CI 证据。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at ff05f788690d9fc4bf7e4bd897c08d703d68899f · 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 (char/4 under-counts CJK text in the output clamp path, causing ~1-token window overflows), I would add a conservative multiplier to the new-content estimate at the two clamp call sites, keep it backward compatible with a default-off parameter, and leave the compaction-trigger call sites unchanged (under-counting there is safe — it triggers compaction earlier). I'd put the constant in tokenEstimation.ts alongside the existing CHARS_PER_TOKEN.

Comparison with the diff: the PR matches this almost exactly. The implementation is clean and minimal:

  • The conservative parameter defaults to false — all existing callers are unaffected. The two clamp call sites in geminiChat.ts pass true via inline comments (/* conservative= */ true), which is readable.
  • The multiplier (1.5x, ceil'd) applies only to the new-content term (estimateContentTokens([userMessage])), not to the API-authoritative lastPromptTokenCount/lastOutputTokenCount. This is the right decomposition — the API counts are ground truth and don't need padding.
  • All five downstream consumers of estimatePromptTokens are accounted for: the two clamp call sites (changed), the two compaction-trigger call sites in geminiChat.ts (unchanged, correct), and the chatCompressionService call site (unchanged, correct).
  • The exported CONSERVATIVE_NEW_CONTENT_SAFETY_FACTOR constant has a thorough docstring explaining the 1.5x choice with reference to the observed 39-54% under-count. The comment is long but documents a genuinely non-obvious "why" — appropriate per project conventions.

No correctness bugs, no security concerns, no convention violations found. The test changes are well-structured: the geminiChat.test.ts expectation bumps (19999→19998, 49722→49721) are correctly explained by the 1.5x inflation on tiny messages (char/4("hi") = 1 token → ceil(1 * 1.5) = 2), and the new conservative-mode test suite in tokenEstimation.test.ts covers the default-off behavior, the selective inflation, and a realistic CJK-heavy scenario.

CI Test Evidence

Ubuntu unit tests pass. macOS/Windows tests and integration tests were skipped (not failed) — this is the repo's normal CI routing for fork PRs.

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success

Not verified: the author's E2E claim (27 turns / ~53.8 min / ~1.12M tokens, zero recurrence) is self-reported and not independently re-run here. The unit tests pin the mathematical correctness of the conservative mode (removing it would fail the new tests), but the end-to-end behavioral claim — that 1.5x is the right factor to prevent real 400s on CJK-heavy content without materially eating into the output budget — rests on the author's reproduction. Sandboxed verification would settle this: @qwen-code /verify — that the conservative factor actually prevents the window overflow on CJK-dense tool output is a behavioral claim the unit tests model but do not exercise end-to-end. This is a sponsored run (fork author lacks write access); a maintainer triggers it, and the resulting report should be read with the same skepticism as the fork's own CI logs.

中文说明

独立方案: 给定问题(char/4 在输出 clamp 路径中低估 CJK 文本,导致约 1 token 的窗口溢出),我会在两个 clamp 调用点添加保守乘数,保持向后兼容(默认关闭),并不改动压缩触发调用点。

与 diff 的比较: PR 几乎完全匹配此方案。实现干净且最小化:conservative 参数默认 false,乘数仅应用于 new-content 项而非 API 权威计数,所有五个下游消费者均已核实。未发现正确性 bug、安全问题或规范违反。

CI 证据: Ubuntu 单元测试通过。macOS/Windows 测试和集成测试被跳过(非失败)——这是 fork PR 的正常 CI 路由。

未验证: 作者的 E2E 声明(27 轮 / ~53.8 分钟 / ~112 万 tokens,零复发)为自我报告,未在此独立重跑。单元测试固定了 conservative 模式的数学正确性,但端到端行为声明依赖作者的复现。沙箱验证可以解决此问题:@qwen-code /verify——这是一次赞助运行(fork 作者无写入权限),由维护者触发。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal fix for a confirmed bug; the only reservation is that the 1.5x factor is empirically chosen rather than derived, and the E2E evidence is author-reported.

This is exactly the kind of PR I like to see. The problem is real (three independent reproductions, maintainer-confirmed root cause), the fix is surgical (one parameter, two call sites, backward compatible), and the author was honest about what the 1.5x constant is — a starting point covering the observed 39-54% under-count, not a number pulled from a tokenizer study. The docstring on the constant is the right kind of comment: it explains why this number and not another, with references to the specific production failures.

My independent proposal matched the PR's approach almost exactly. I looked for a simpler path and didn't find one — the alternative (a proper tokenizer-based estimate) would be a much larger change with its own dependency concerns, and the author already flags it as a possible follow-up. The diff carries nothing beyond what the stated goal needs.

The geminiChat.ts high-risk path flag is noted, but the actual change there is two lines adding a parameter to existing calls — about as low-risk as a touch to that file gets. The unit tests pin the math correctly, and the existing test expectation bumps are well-explained.

Non-blocking nits for the author's awareness: the 6th positional parameter on estimatePromptTokens is getting unwieldy — an options object might be worth considering in a follow-up if more parameters accumulate. And the 1.5x factor is worth revisiting if a lightweight tokenizer-based estimate becomes feasible.

LGTM, approving. ✅

中文说明

置信度:4/5 —— 干净、最小化的修复,针对已确认的 bug;唯一保留意见是 1.5x 系数为经验选择而非推导得出,E2E 证据为作者自我报告。

这正是我希望看到的 PR。问题真实(三次独立复现,维护者确认根因),修复精准(一个参数,两个调用点,向后兼容),作者对 1.5x 常数的定位诚实——覆盖观测到的 39-54% 低估的起点,而非来自分词器研究的数字。

我的独立方案与 PR 方案几乎完全一致。我寻找了更简路径但未找到——替代方案(基于分词器的估算)会是更大的改动。diff 不包含超出目标所需的任何内容。

geminiChat.ts 高风险路径标记已注意,但实际改动仅为两行参数添加——对该文件来说风险最低。单元测试正确固定了数学逻辑。

非阻塞建议:estimatePromptTokens 的第 6 个位置参数开始变得笨拙——如果继续积累参数,可考虑在后续 PR 中使用选项对象。1.5x 系数在轻量级分词器估算可行时值得重新审视。

LGTM,批准。✅

Qwen Code · qwen3.8-max-preview

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

@yiliang114

Copy link
Copy Markdown
Collaborator

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

@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.

Reviewed — no blockers. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.7-max via Qwen Code /review

Comment on lines +65 to +67
/**
* Multiplier applied to the char/4 estimate of NEWLY-added content when
* `conservative` is set. char/4 is documented (see file header) as varying

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.

[Suggestion] The JSDoc block for estimatePromptTokens is orphaned — CONSERVATIVE_NEW_CONTENT_SAFETY_FACTOR and its own JSDoc were inserted between the function's doc comment and its declaration. TypeScript attaches only the immediately preceding /** */ to a declaration, so estimatePromptTokens loses its JSDoc (including the important WARNING about using the estimate to trigger earlier, never to skip).

Move the constant declaration (with its JSDoc) above the function's JSDoc so the function's documentation stays adjacent to its declaration.

— qwen3.7-max via Qwen Code /review

Comment on lines +73 to +74
* reads) by 39-54% — beyond the documented band. 1.5x covers both observed
* cases with headroom without materially eating into the output budget for

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.

[Suggestion] The comment claims 1.5× "covers both observed cases with headroom," but the documented upper bound is 54% under-count (real = char/4 × 1.54). The 1.5× factor leaves a ~2.6% residual gap on the new-content term — for a large CJK tool result (char/4 ≈ 10K tokens), this is ~400 tokens of overflow that the reactive handler catches but the proactive clamp doesn't prevent.

Consider bumping to 1.6× (covers 54% with headroom) or correcting the comment to name what covers the 54% case.

— qwen3.7-max via Qwen Code /review

@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

Local maintainer verification — real build + real tokenizer

/verify declined this PR (fork author without write access), so I ran the verification locally instead. Verified at head ff05f78869 against base 0c0ca5fed0, in two isolated worktrees with independent node_modules.

Verdict: merge-ready. The fix is real and load-bearing — it converts a reproducible window overflow into a fitting request, is monotonically safe (it can only ever shrink maxOutputTokens, never grow it), and introduces zero regressions across the full packages/core suite. Three follow-ups below, one of which I'd suggest folding into this PR because it is a two-line comment edit.


1. End-to-end: does it actually stop the overflow?

I built a harness that drives the real GeminiChat.sendMessageStream path with a CJK-dense read_file tool result, captures the maxOutputTokens the production code actually puts on the wire, and then checks the invariant the clamp exists to guarantee:

truePromptTokens + maxOutputTokens ≤ contextWindow

The true token counts come from the real Qwen3 tokenizer (Qwen/Qwen3-8B tokenizer.json, vocab 151669, via tokenizers 0.22.2) — measured offline and embedded, since the fixtures are deterministic and tokenize exactly linearly on repeat. Nothing about the clamp arithmetic, the estimate, or the outgoing request is simulated.

window invariant A/B

scenario base maxOut PR maxOut base PR
A — 36,000 CJK chars, anchor 88K 24,056 19,548 400 (+984 over) OK (−3,524) ✅
B — 72,000 CJK chars, anchor 70K 32,000 24,048 400 (+10,928) 400 (+2,976) ⚠️
C — 9,000 CJK chars, anchor 100K 18,806 17,673 OK OK
D — 39,600 English chars, anchor 88K 23,156 18,198 OK OK (−4,958 budget)
E — tiny message (ceiling-bound) 32,000 32,000 OK OK
F — first-send / resumed, 198K CJK chars history 32,000 32,000 400 (+11,428) 400 (+11,428)

A is the money row: base sends a request that is 984 tokens over the window — the exact prompt + max_tokens > window 400 this clamp exists to prevent — and the PR makes it fit. That is the fix doing its job on the real send path, not on paper.

Auto-compaction is stubbed to NOOP in the harness (same as the PR's own tests) so the clamp is measured in isolation.


2. Is 1.5 the right constant?

The block comment on CONSERVATIVE_NEW_CONTENT_SAFETY_FACTOR claims char/4 under-counts CJK "by 39-54%" and that "1.5x covers both observed cases with headroom". I measured that claim directly against the real tokenizer:

tokenizer ground truth

  • The PR's own test fixture ('设计文档章节内容。'.repeat(2000)) is under-counted by 55.0% — char/4 says 4,500 tokens, the real answer is 10,000. The factor needed to make the estimate an upper bound is 2.22×. At 1.5× the estimate is still 3,250 tokens short.
  • Realistic Chinese prose: 2.30× needed. Japanese 2.64×. Korean 2.80×.
  • Read the comment's "54%" either way — as est = 0.46 × real (needs 2.17×) or as real = 1.54 × est (needs 1.54×) — 1.5× does not cover it with headroom under either reading. The comment overstates what the constant does.
  • Conversely, char/4 already over-counts English (0.87–0.93×), so a blanket 1.5× makes the English estimate ~72% larger than the truth.

This is why row B still 400s and row D loses 4,958 tokens of output budget (−21%) for nothing.

I'd suggest not simply raising the constant — that would deepen the English tax. A density-aware term is both tighter and free for ASCII. Charging CJK codepoints at ~1.4 chars/token (measured: Chinese 1.73, Japanese 1.50, Korean 1.42) instead of 4 gives 0.80×–1.29× of the real count across the same corpus, versus 0.54×–1.73× for char/4 × 1.5 — and leaves English at exactly today's value:

// ~5 lines, no new deps, no effect on ASCII-only content
const cjk = countCjkCodepoints(text);
return Math.ceil(cjk / 1.4 + (text.length - cjk) / 4);

Happy for this to be a follow-up — it does not block the PR.


3. Are the tests load-bearing?

mutation matrix

mutation result
M0 pristine PR head 278 passed
M1 PR tests vs. base source (both files reverted) 4 failed — 2 new unit tests + both updated geminiChat expectations
M2 remove only the steady-state call site (geminiChat.ts:2357) 2 failed → killed ✅
M3 remove only the recovery-path call site (geminiChat.ts:3072) 278 passedsurvived ⚠️

The third new unit test (defaults to non-conservative) passes both ways — that is a correct regression guard, not a vacuous test.

M3 is a genuine coverage gap. Half of the production change is unverified: the one recovery-path assertion (geminiChat.test.ts:10368) expects 4_000, which is MIN_CLAMPED_OUTPUT_TOKENS — the floor is hit with and without the flag, so the assertion cannot distinguish them. countBasedRecoveryEstimate appears nowhere else in the repo, so no other suite covers it either. A recovery case sized so the clamp lands between the floor and the ceiling would close this.


4. Regressions, gates, scope

  • Full packages/core suite, both arms: PR 18132 passed / 6 failed, base 18129 passed / 6 failed (the +3 is exactly the 3 new unit tests). The failing sets are byte-identical between the two arms — all 11 failing testcases are local-environment artifacts (turndown absent from the donor node_modules, @qwen-code/qwen-code-core dist not built, and the known memoryLifecycle machine artifact). Zero PR-attributable failures.
  • eslint clean, prettier --check clean on all 4 changed files. tsc --noEmit clean apart from the same environmental turndown error present on base.
  • Risk is one-directional. Math.ceil(x * 1.5) ≥ x, so the estimate only ever grows and maxOutputTokens only ever shrinks, bounded below by the 4,000 floor. No configuration can produce a larger request than today, so this cannot introduce new 400s. The worst case is more MAX_TOKENS truncation near the compaction threshold, which the existing escalation-on-truncation retry already handles.
  • Scope note (not a regression): two other estimates that feed clampOutputTokensToWindow are still raw char/4 and are untouched by this PR — the first-send fallback (effectiveTokens + FIRST_SEND_CLAMP_OVERHEAD_PAD, geminiChat.ts:2359) and walkRecoveryEstimate (geminiChat.ts:3081-3085, a whole-history walk, so the largest CJK exposure of the four). Scenario F shows a resumed CJK session overflowing identically on both arms via the first-send branch. The PR is correctly scoped to what 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 reports; I mention it so the bug class isn't assumed closed.

Recommendation

Merge. The fix is verified end-to-end on the real send path, monotonically safe, and regression-free.

Follow-ups, in priority order:

  1. In this PR (2-line comment edit): soften the CONSERVATIVE_NEW_CONTENT_SAFETY_FACTOR comment — "1.5x covers both observed cases with headroom" is not supported by the real tokenizer. Something like "1.5× substantially raises the payload size required to overflow; it is not a full correction for CJK density" matches what the constant actually does.
  2. Follow-up: a recovery-path test that isn't floor-bound, to kill the M3 mutant.
  3. Follow-up: density-aware estimation instead of a blanket factor, which fixes row B and refunds row D.
Reproduction notes
  • Worktrees: pr7963-verify @ ff05f78869 and detached @ 0c0ca5fed0; node_modules APFS-cloned from a known-good tree; every node_modules/@qwen-code/* entry asserted to realpath inside its own worktree before any run (otherwise the "base" arm silently compiles PR source).
  • Harness: a describe block appended to packages/core/src/core/geminiChat.test.ts in both trees so the two arms run byte-identical test code against different production source. Run with --silent=false (this package sets silent: true in vitest.config.ts, which swallows console.log).
  • Tokenizer: Qwen/Qwen3-8B tokenizer.json (11,422,654 bytes) + tokenizers 0.22.2. Both fixtures verified exactly linear on repeat ('设计文档章节内容。' ×2000 = 18,000 chars = 10,000 tokens; the English unit = 14.002 tokens/unit at n=600), so realTokens = units × tokensPerUnit is exact, not interpolated.
  • The functionResponse JSON wrapper adds a few more real tokens on top of the counted output string; omitting it makes every verdict above conservative in the PR's favour.
中文版本

维护者本地验证 —— 真实构建 + 真实分词器

/verify 拒绝了本 PR(fork 作者无仓库写权限),因此我在本地完成了验证。验证对象为 head ff05f78869,对照 base 0c0ca5fed0,在两个各自独立 node_modules 的隔离 worktree 中进行。

结论:可以合并。 该修复是真实且起作用的——它把一个可复现的上下文窗口溢出请求变成了合规请求;变更方向单调安全(只会缩小 maxOutputTokens,绝不会放大);在完整的 packages/core 测试套件中零回归。下面列出三项后续事项,其中第一项建议直接并入本 PR,因为它只是两行注释的修改。

1. 端到端:是否真的阻止了溢出?

我构建了一个测试装置,驱动真实的 GeminiChat.sendMessageStream 路径,输入一个 CJK 密集的 read_file 工具结果,捕获生产代码实际发到线上的 maxOutputTokens,然后校验钳位逻辑本应保证的不变式:

真实提示词 token 数 + maxOutputTokens ≤ 上下文窗口

其中"真实" token 数来自真实的 Qwen3 分词器Qwen/Qwen3-8Btokenizer.json,词表 151669,通过 tokenizers 0.22.2)——离线测得后内嵌,因为样本是确定性的且在重复时严格线性分词。钳位算术、估算值、以及发出的请求本身没有任何一处是模拟的。

场景 base maxOut PR maxOut base PR
A —— 36,000 CJK 字符,锚定 88K 24,056 19,548 400(超 +984) OK(−3,524)✅
B —— 72,000 CJK 字符,锚定 70K 32,000 24,048 400(+10,928) 400(+2,976)⚠️
C —— 9,000 CJK 字符,锚定 100K 18,806 17,673 OK OK
D —— 39,600 英文字符,锚定 88K 23,156 18,198 OK OK(预算 −4,958)
E —— 极小消息(受上限约束) 32,000 32,000 OK OK
F —— 首次发送 / 恢复会话,历史 198K CJK 字符 32,000 32,000 400(+11,428) 400(+11,428)

A 行是关键证据:base 发出的请求超出窗口 984 个 token——正是本钳位逻辑要防止的那类 prompt + max_tokens > window 400 错误——而 PR 使其合规。这是修复在真实发送路径上生效,而非纸面推演。

装置中自动压缩被打桩为 NOOP(与 PR 自带测试一致),以便单独测量钳位行为。

2. 1.5 这个常量取值合适吗?

CONSERVATIVE_NEW_CONTENT_SAFETY_FACTOR 的注释声称 char/4 对 CJK 低估 "39-54%",且 "1.5x covers both observed cases with headroom"(1.5 倍有余量地覆盖了两个已观测案例)。我用真实分词器直接检验了这一说法:

  • PR 自己的测试样本'设计文档章节内容。'.repeat(2000))被低估 55.0%——char/4 给出 4,500 token,真实值是 10,000。要让估算成为上界,所需系数是 2.22×。在 1.5× 下,估算仍然少了 3,250 个 token
  • 真实中文技术文档:需要 2.30×;日文 2.64×;韩文 2.80×
  • 注释里的 "54%" 无论按哪种读法——est = 0.46 × real(需 2.17×)或 real = 1.54 × est(需 1.54×)——1.5× 都无法"有余量地覆盖"。该注释夸大了这个常量的实际作用。
  • 反过来,char/4 对英文本就是高估的(0.87–0.93×),所以一刀切的 1.5× 会让英文估算比真实值高出约 72%

这正是 B 行仍然 400、而 D 行白白损失 4,958 个输出预算 token(−21%)的原因。

我建议不要简单调大该常量——那只会加重对英文的惩罚。按字符密度区分的估算既更贴合,对 ASCII 又完全无损。把 CJK 码点按约 1.4 字符/token 计费(实测:中文 1.73、日文 1.50、韩文 1.42),在同一语料上得到真实值的 0.80×–1.29×,而 char/4 × 1.5 是 0.54×–1.73×;同时英文保持与今天完全一致:

// 约 5 行,无新依赖,对纯 ASCII 内容零影响
const cjk = countCjkCodepoints(text);
return Math.ceil(cjk / 1.4 + (text.length - cjk) / 4);

这个可以作为后续项,不阻塞本 PR。

3. 测试是否真正起作用?

变异 结果
M0 原始 PR head 278 通过
M1 PR 的测试跑在 base 源码上(两个源文件都还原) 4 个失败 —— 2 个新增单测 + 2 个被更新的 geminiChat 断言
M2 只移除稳态调用点(geminiChat.ts:2357 2 个失败 → 已杀死 ✅
M3 只移除恢复路径调用点(geminiChat.ts:3072 278 通过存活 ⚠️

第三个新增单测(defaults to non-conservative)两边都通过——这是正确的回归护栏,不是空测试。

M3 是真实的覆盖缺口。 生产变更有一半未被验证:唯一的恢复路径断言(geminiChat.test.ts:10368)期望值是 4_000,即 MIN_CLAMPED_OUTPUT_TOKENS 下限——加不加该标志都会触到下限,因此断言无法区分两者。countBasedRecoveryEstimate 在仓库其他地方均未出现,所以也没有别的套件覆盖它。补一个让钳位结果落在下限与上限之间的恢复场景即可闭合。

4. 回归、门禁与范围

  • 完整 packages/core 套件,两侧对比: PR 18132 通过 / 6 失败,base 18129 通过 / 6 失败(相差的 3 个正好是新增的 3 个单测)。两侧的失败集合完全一致——全部 11 个失败用例均为本地环境产物(供体 node_modulesturndown@qwen-code/qwen-code-core 的 dist 未构建,以及已知的 memoryLifecycle 机器产物)。没有任何可归因于 PR 的失败。
  • 4 个改动文件的 eslintprettier --check 均通过。tsc --noEmit 除了 base 上同样存在的环境性 turndown 报错外无其他问题。
  • 风险是单向的。 Math.ceil(x * 1.5) ≥ x,因此估算只增不减、maxOutputTokens 只减不增,且下有 4,000 的地板兜底。任何配置都不可能产生比今天更大的请求,所以不可能引入新的 400。最坏情况是在接近压缩阈值时更容易触发 MAX_TOKENS 截断,而现有的截断升级重试已经覆盖了这一点。
  • 范围说明(非回归): 还有另外两处喂给 clampOutputTokensToWindow 的估算仍是裸 char/4,本 PR 未触及——首次发送兜底(effectiveTokens + FIRST_SEND_CLAMP_OVERHEAD_PADgeminiChat.ts:2359)以及 walkRecoveryEstimategeminiChat.ts:3081-3085,它遍历整个历史,因此是四处中 CJK 暴露面最大的一处)。场景 F 展示了一个恢复的 CJK 会话通过首次发送分支在两侧同样溢出。本 PR 的范围与 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 所报告的问题是吻合的;我在此指出只是为了避免误以为这一类缺陷已经彻底关闭。

建议

合并。 修复已在真实发送路径上端到端验证,方向单调安全,且无回归。

后续事项,按优先级:

  1. 在本 PR 内(改 2 行注释): 弱化 CONSERVATIVE_NEW_CONTENT_SAFETY_FACTOR 的注释——"1.5x covers both observed cases with headroom" 得不到真实分词器的支持。改成类似 "1.5× 大幅提高了触发溢出所需的载荷体量;它并不是对 CJK 密度的完整修正" 更符合该常量的实际作用。
  2. 后续: 补一个不受下限约束的恢复路径测试,杀死 M3 变异体。
  3. 后续: 用按密度区分的估算替代一刀切系数,可修好 B 行并把 D 行的预算还回去。
复现说明
  • worktree:pr7963-verify @ ff05f78869 与 detached @ 0c0ca5fed0node_modules 由已知健康的树 APFS 克隆而来;每个 node_modules/@qwen-code/* 条目在任何运行前都断言其 realpath 落在各自 worktree 内(否则 "base" 一侧会悄悄编译 PR 的源码)。
  • 装置:把一个 describe 块追加到两棵树各自的 packages/core/src/core/geminiChat.test.ts,使两侧运行逐字节相同的测试代码、面向不同的生产源码。运行时需加 --silent=false(该包在 vitest.config.ts 中设了 silent: true,会吞掉 console.log)。
  • 分词器:Qwen/Qwen3-8Btokenizer.json(11,422,654 字节)+ tokenizers 0.22.2。两个样本均已验证在重复时严格线性('设计文档章节内容。' ×2000 = 18,000 字符 = 10,000 token;英文单元在 n=600 时为 14.002 token/单元),因此 realTokens = units × tokensPerUnit 是精确值而非插值。
  • functionResponse 的 JSON 外壳会在所统计的 output 字符串之外再增加若干真实 token;忽略它使上述所有判定都偏向于对 PR 有利的保守方向

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@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. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 30, 2026
Merged via the queue into QwenLM:main with commit eb28b30 Jul 30, 2026
75 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.2.

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.

Main-turn output-token clamp can under-count CJK-heavy new content by ~chars/4, occasionally overflowing the context window by a few tokens

4 participants