输出 Token 限制与升级设计
默认使用模型声明的输出限制,除非用户或环境配置了
max_tokens。仅当响应仍然达到MAX_TOKENS限制时,才会使用升级和多轮恢复机制。
问题
每个 API 请求都会根据 max_tokens 预留固定比例的 GPU 槽位。较低的默认值可以减少槽位预留,但也更容易导致正常的大型响应被截断。在文件写入工作流中,这会产生不完整的工具调用参数,并迫使调度器拒绝部分写入。
解决方案
默认使用模型声明的输出限制。当响应被截断(模型达到 max_tokens 限制)时:
- 升级到模型的完整输出限制(当当前限制较低时,以 64K 为下限)
- 如果仍然被截断,通过将部分响应保留在历史记录中并注入继续消息来进行恢复,最多尝试 3 次
- 如果恢复次数耗尽,则回退到工具调度器的截断指导
这优先保证了大型生成和文件编辑任务的正确性。需要较低预留的运维人员仍然可以设置 QWEN_CODE_MAX_OUTPUT_TOKENS,系统会尊重该显式值。
架构
请求 (max_tokens = 用户/环境值或模型输出限制)
│
▼
┌─────────────────────────┐
│ 响应被截断? │──── 否 ──▶ 完成 ✓
│ (MAX_TOKENS) │
└───────────┬──────────────┘
│ 是
▼
┌──────────────────────────────────────────────────┐
│ 第 1 层:升级到模型输出限制 │
│ ┌────────────────────────────────────────────┐ │
│ │ 从历史记录中弹出部分响应 │ │
│ │ 重试 (isContinuation: false → 重置 UI) │ │
│ │ 以 max(64K, 模型输出限制) 重新发送 │ │
│ └────────────────────────────────────────────┘ │
└───────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────┐
│ 仍然被截断? │──── 否 ──▶ 完成 ✓
│ (MAX_TOKENS) │
└───────────┬──────────────┘
│ 是
▼
┌──────────────────────────────────────────────────┐
│ 第 2 层:多轮恢复(最多 3 次) │
│ ┌────────────────────────────────────────────┐ │
│ │ 将部分响应保留在历史记录中 │ │
│ │ 推送用户消息:"直接继续..." │ │
│ │ 重试 (isContinuation: true → 保留 UI 缓冲区)│ │
│ │ 使用更新后的历史记录重新发送 │ │
│ │ 模型从上次中断处继续 │ │
│ └──────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ 成功? │── 是 ──▶ 完成 ✓ │
│ └──────┬──────┘ │
│ │ 否(仍然被截断) │
│ ▼ │
│ 尝试次数 < 3? ── 是 ──▶ 循环回到 ↑ │
└───────────┬──────────────────────────────────────┘
│ 否(已耗尽)
▼
┌──────────────────────────────────────────────────┐
│ 第 3 层:工具调度器回退 │
│ ┌────────────────────────────────────────────┐ │
│ │ 拒绝被截断的 Edit/Write 工具调用 │ │
│ │ 返回指导:"你必须拆分为更小的部分——先写骨架, │ │
│ │ 然后增量编辑。" │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘Token 限制确定
有效的 max_tokens 按以下优先级顺序解析:
| 优先级 | 来源 | 值(已知模型) | 值(未知模型) | 升级行为 |
|---|---|---|---|---|
| 1(最高) | 用户配置 (samplingParams.max_tokens) | min(userValue, modelLimit) | userValue | 不升级 |
| 2 | 环境变量 (QWEN_CODE_MAX_OUTPUT_TOKENS) | min(envValue, modelLimit) | envValue | 不升级 |
| 3(最低) | 模型/默认输出限制 | modelLimit | DEFAULT_OUTPUT_TOKEN_LIMIT = 32K | 升级到模型限制(64K 下限)+ 恢复 |
“已知模型”是指在 OUTPUT_PATTERNS 中有明确条目的模型(通过 hasExplicitOutputLimit() 检查)。对于已知模型,有效值始终限制在模型声明的输出限制内,以避免 API 错误。未知模型(自定义部署、自托管端点)会直接透传用户的值,因为后端可能支持更大的限制。
此逻辑在三个内容生成器中实现:
DefaultOpenAICompatibleProvider.applyOutputTokenLimit()— OpenAI 兼容提供商DashScopeProvider— 从默认提供商继承applyOutputTokenLimit()AnthropicContentGenerator.buildSamplingParameters()— Anthropic 提供商
升级机制
升级逻辑位于 geminiChat.ts 中,放置在主重试循环之外。这是有意为之:
- 重试循环处理瞬态错误(速率限制、无效流、内容验证)
- 截断不是错误——它是被提前截断的成功响应
- 升级流中的错误应直接传播给调用者,而不应被重试逻辑捕获
升级步骤 (geminiChat.ts)
1. 流成功完成 (lastError === null)
2. 最后一个 chunk 的 finishReason === MAX_TOKENS
3. 守卫检查通过:
- maxTokensEscalated === false(防止无限升级)
- hasUserMaxTokensOverride === false(尊重用户意图)
4. 计算升级后的限制:max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output'))
5. 从聊天历史中弹出部分模型响应
6. 产生 RETRY 事件 (isContinuation: false) → UI 丢弃部分输出并重置缓冲区
7. 使用 maxOutputTokens: escalatedLimit 重新发送相同请求恢复步骤 (geminiChat.ts)
如果升级后的响应也被截断(finishReason === MAX_TOKENS),恢复循环最多运行 MAX_OUTPUT_RECOVERY_ATTEMPTS (3) 次:
1. 部分模型响应已在历史记录中(由 processStreamResponse 推送)
2. 推送恢复用户消息:OUTPUT_RECOVERY_MESSAGE
3. 产生 RETRY 事件 (isContinuation: true) → UI 保留文本缓冲区以继续
4. 使用更新后的历史记录重新发送(模型看到其部分输出 + 恢复指令)
5. 如果仍然被截断且还有剩余尝试次数,则循环回到步骤 1
6. 如果恢复尝试抛出异常(空响应、网络错误):
- 从历史记录中弹出悬空的恢复消息
- 跳出恢复循环RETRY 时的状态清理 (turn.ts)
当 Turn 类接收到 RETRY 事件时,它会清除累积的状态以防止不一致:
pendingToolCalls— 清除以避免重复的工具调用,以防第一个被截断的响应包含已完成的工具调用,并在升级后的响应中重复pendingCitations— 清除以避免重复的引用finishReason— 重置为undefined,以便使用新响应的 finish reason
isContinuation 标志会传递给 UI,以便 UI 决定是重置文本缓冲区(升级)还是保留它们(恢复)。
常量
定义在 geminiChat.ts 和 tokenLimits.ts 中:
| 常量 | 值 | 用途 |
|---|---|---|
ESCALATED_MAX_TOKENS | 64,000 | 当模型限制较低时,作为升级的下限 |
MAX_OUTPUT_RECOVERY_ATTEMPTS | 3 | 升级后的最大多轮恢复尝试次数 |
有效的升级后限制为 max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output')):
| 模型 | 升级后限制 |
|---|---|
| Claude Opus 4.6 | 131,072 (128K) |
| GPT-5 / o-series | 131,072 (128K) |
| Qwen3.x | 65,536 (64K) |
| 未知模型 | 64,000(下限) |
设计决策
为什么不使用 8K 默认值?
- 8K 默认值是一种槽位预留/容量优化,而非正确性要求。它以正确性(大型响应被截断)为代价换取后端吞吐量(请求会根据
max_tokens预留成比例的 GPU 槽位,因此较低的值会减少过度预留)。 - 大型文件生成和编辑工具调用合理地可能超过 8K,因此 8K 默认值会将正常请求变成 截断 → 升级 的往返过程(在最坏的情况下,还会变成重试循环)。
- Claude Code 保留了相同的 8K 上限,但将其置于功能标志(
tengu_otk_slot_v1)之后,该标志默认对第三方提供商关闭(“未在 Bedrock/Vertex 上验证”)——即其对非第一方服务的默认行为正是“使用模型声明的限制”。Qwen Code 的提供商均为第三方 / OpenAI 兼容 / 自托管,因此匹配该默认关闭行为是安全的选择;假设低默认值对所有后端都安全则不然。 - 容量权衡并未丢失,只是变成了可选:在容量受限的自托管后端上,运维人员可以设置
QWEN_CODE_MAX_OUTPUT_TOKENS(例如8000)来恢复较低的每请求预留。故意不重新引入 GrowthBook 风格的功能标志——Qwen Code 没有此类基础设施,且环境变量已满足需求。
为什么升级到模型限制而不是固定的 64K?
- 具有更高输出限制的模型(Claude Opus 128K、GPT-5 128K)被不必要地限制在 64K
- 使用模型的实际限制可以捕获绝大多数长输出,而无需进行第二次重试
ESCALATED_MAX_TOKENS(64K) 作为未知模型的下限,此时tokenLimit()返回默认的 32K
为什么使用多轮恢复而不是渐进式升级?
- 渐进式升级(例如 16K -> 32K -> 64K)需要每次重新生成完整响应
- 多轮恢复保留部分响应并让模型继续,从而节省 token 和延迟
- 与重新生成大型响应相比,恢复消息的成本很低(每个约 40 个 token)
- 3 次尝试的限制可防止无限循环,同时覆盖大多数实际情况
为什么升级在重试循环之外?
- 截断是成功的情况,而不是错误
- 升级流中的错误(速率限制、网络故障)应直接传播,而不是使用错误的参数被静默重试
- 保持重试循环专注于其原始目的(瞬态错误恢复)
- 恢复错误被单独捕获,以避免中止整个对话