引子:一个账单故事
我们的 AI 总结功能上线第三天,成本告警炸了。
复盘之后发现:有几条用户上传的长文档,每次请求的输出 token 数超过 4000,而我们并没有设置任何 max_tokens 限制。模型默认往长了生成,单次请求成本是预期的 6 倍。更糟的是,有个 Agent loop 在某个工具返回异常数据后开始反复重试,三分钟消耗了我们一天的 token 配额。
那之后,"token budget" 成了我们 AI 系统的核心工程约束之一。
这篇文章聊的就是这件事:在 LLM 请求层面设置 token 预算,以及这个看起来简单的操作背后藏着的 5 个工程陷阱。
一、token budget 到底是什么
先对齐概念。在 LLM API 调用里,"token budget" 可以指三个不同层次的事:
1. 单次请求输出上限(max_tokens / max_completion_tokens)
这是最基础的一层,控制模型这次调用最多生成多少 token。每个主流 API 都支持:
python
# 国产大模型兼容接口(DeepSeek / Qwen)
response = client.chat.completions.create(
model="deepseek-chat",
messages=[...],
max_tokens=512, # 旧参数,仍兼容
# max_completion_tokens=512, # 新参数,在推理模型中区分 reasoning 和 output
)
# DeepSeek API(直接调用)
response = client.messages.create(
model="deepseek-reasoner",
max_tokens=1024,
messages=[...],
)
注意:max_tokens 只限制输出,不影响输入 token 数。设得太小会导致截断(finish_reason="length"),设得太大是浪费------你按实际使用量付费,但声明的上限如果远超实际,部分网关会在请求阶段预留配额。
2. 推理思考预算(budget_tokens)
部分推理模型支持 budget_tokens 参数,用来控制模型在给出最终答案前可以"思考"多少 token:
python
# 以某推理增强模型为例
response = client.messages.create(
model="deepseek-reasoner",
max_tokens=16000,
# 不同 SDK/接口暴露方式不同,原理一致:
# 限制推理链条最多消耗多少 token
messages=[...],
)
这个参数值得单独关注:思考 token 也是输出 token,按输出价格计费,且一旦模型"进入"推理链就很难提前截断。我们的实测:对于摘要类任务,budget_tokens=4000 和 budget_tokens=16000 的答案质量几乎没差异,但费用相差 4 倍。
3. Agent 循环总预算(Task Budget)
这是最新也最重要的层次。当 Agent 需要多轮工具调用时,你需要的不是单次限制,而是整个任务的 token 上限:
python
# Agent 任务级预算:在 loop 入口设置总 token 上限
# 让 Agent 自主决定在思考、工具调用、输出之间如何分配
MAX_TASK_TOKENS = 50_000
used_tokens = 0
while not task_done:
remaining = MAX_TASK_TOKENS - used_tokens
if remaining < 500:
raise AgentBudgetError("任务 token 预算耗尽")
response = call_llm(max_tokens=min(2048, remaining))
used_tokens += response.usage.total_tokens
三者的关系:max_tokens 控制单次响应,budget_tokens 控制思考深度,Task Budget 控制整个任务流程的总消耗。
二、为什么不设预算会出问题
不设 max_tokens 不等于"让模型自由发挥"------它等于"让模型按默认上限发挥"。不同模型的默认 max_tokens 差异很大:
| 模型 | 默认 max_tokens(不显式设置时) | 上下文窗口 |
|---|---|---|
| DeepSeek-V3 | 4,096 | 128K |
| Qwen-Max | 8,192 | 128K |
| GLM-4 | 4,096 | 128K |
| DeepSeek-R1 | 8,000 | 64K |
问题不在于"默认值太大",而在于你的业务逻辑对每个接口的预期输出长度是有定义的:
- 一个标题生成接口:期望输出 20~50 token
- 一个代码补全接口:期望输出 200~800 token
- 一个报告生成接口:期望输出 2000~5000 token
这些边界,模型不知道,只有你知道。不设上限,就是在说"我不在乎它输出多长"------而 LLM 倾向于输出它认为"完整"的内容,这个长度往往超过你的需要。
更严重的是 Agent 场景。
一个没有 token 预算的 Agent 循环可以无限调用工具、无限追加上下文,直到触发 context window 上限报错------或者把你的账单打穿。我们见过的真实案例:一个爬虫 Agent 因为目标页面返回异常,进入了"重试 → 解析失败 → 再试"的死循环,三分钟消耗了 200 万 token。
三、5 个生产陷阱
陷阱 1:设了 max_tokens 但忘记处理 finish_reason="length"
设置输出上限之后,响应有可能被截断。截断的 JSON 是坏数据,截断的代码是危险的代码。
python
import json
def call_with_budget(prompt: str, max_tokens: int = 512) -> dict:
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
choice = response.choices[0]
finish_reason = choice.finish_reason
content = choice.message.content
# 关键:必须检查 finish_reason
if finish_reason == "length":
# 不能直接用这个输出
raise TokenBudgetExceededError(
f"Response truncated at {max_tokens} tokens. "
f"Content so far: {content[:100]}..."
)
return {
"content": content,
"finish_reason": finish_reason,
"usage": {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
}
}
class TokenBudgetExceededError(Exception):
pass
对于不同接口类型,finish_reason="length" 的处理策略不同:
- 摘要 / 分类接口 :可以考虑重试(用更大的
max_tokens或先压缩输入) - 结构化输出接口:必须重试,残缺 JSON 不能用
- 流式对话接口:可以提示用户"回答被截断,是否继续"
陷阱 2:用固定值而不是按请求类型分层设置
把所有接口都设成同一个 max_tokens=2048 是最常见的懒惰写法。这有两个问题:
- 对短输出接口,2048 远超实际需求,网关层可能预留过多配额
- 对长输出接口,2048 可能导致频繁截断
正确做法:按接口角色分层定义预算。
python
from enum import Enum
from dataclasses import dataclass
class RequestRole(Enum):
CLASSIFICATION = "classification" # 分类、打标
EXTRACTION = "extraction" # 信息抽取
SUMMARIZATION = "summarization" # 摘要
GENERATION = "generation" # 生成(代码、报告)
AGENT_STEP = "agent_step" # Agent 单步
AGENT_TASK = "agent_task" # Agent 整任务
@dataclass
class TokenBudgetPolicy:
max_output_tokens: int
budget_tokens: int | None = None # thinking budget,仅推理模型
warn_threshold: float = 0.9 # 使用率超过此值时告警
on_truncate: str = "raise" # raise / retry / accept
# 按角色定义预算策略
BUDGET_POLICIES: dict[RequestRole, TokenBudgetPolicy] = {
RequestRole.CLASSIFICATION: TokenBudgetPolicy(
max_output_tokens=64,
on_truncate="raise", # 分类不该截断
),
RequestRole.EXTRACTION: TokenBudgetPolicy(
max_output_tokens=512,
on_truncate="retry",
),
RequestRole.SUMMARIZATION: TokenBudgetPolicy(
max_output_tokens=1024,
on_truncate="retry",
),
RequestRole.GENERATION: TokenBudgetPolicy(
max_output_tokens=4096,
on_truncate="accept", # 生成类可接受部分输出
),
RequestRole.AGENT_STEP: TokenBudgetPolicy(
max_output_tokens=2048,
budget_tokens=4000,
on_truncate="raise",
),
RequestRole.AGENT_TASK: TokenBudgetPolicy(
max_output_tokens=8192,
budget_tokens=16000,
on_truncate="raise",
),
}
def apply_budget(role: RequestRole, params: dict) -> dict:
policy = BUDGET_POLICIES[role]
params["max_tokens"] = policy.max_output_tokens
if policy.budget_tokens:
params.setdefault("thinking", {})["budget_tokens"] = policy.budget_tokens
return params
陷阱 3:网关预留配额与实际使用量的差值导致限流误判
这是 LiteLLM 文档里专门提到的一个隐患(原文:"TPM limits are enforced by reserving tokens before the call and reconciling against real usage after it. When a request omits max_tokens / max_completion_tokens...")。
原理: 网关在请求发出前需要"预留"token 配额,以防止超限。如果你不设置 max_tokens,网关无法知道这次请求最多会用多少 token,只能按模型的绝对上限来预留(比如 16K)。这会导致:
- 即使你的实际使用量只有 200 token,系统认为你"占用"了 16K
- 并发请求多时,配额被大量"虚占",正常请求反而被限流
- 实际用量统计出现偏差,成本归因报告不准
解决方案:务必在每次请求时显式设置 max_tokens,哪怕是一个宽松的上限。
python
# 不推荐:不设 max_tokens,网关按模型上限预留
response = client.chat.completions.create(
model="deepseek-chat",
messages=[...],
# 无 max_tokens
)
# 推荐:即使不确定输出长度,也设一个合理上限
response = client.chat.completions.create(
model="deepseek-chat",
messages=[...],
max_tokens=2048, # 业务可接受的最大输出
)
陷阱 4:Agent loop 没有总预算,只有单步限制
每步都设了 max_tokens=1024,但 Agent 可以跑 50 步------这样总消耗可以到 50K,完全没有全局约束。
这是 Agent 系统最常见的成本失控来源。
正确做法:在 Agent 入口层维护一个 token 计数器,超过总预算就中断任务。
python
import dataclasses
from typing import Any
@dataclasses.dataclass
class AgentBudget:
total_token_budget: int
used_tokens: int = 0
step_count: int = 0
max_steps: int = 30
@property
def remaining_tokens(self) -> int:
return self.total_token_budget - self.used_tokens
@property
def utilization(self) -> float:
return self.used_tokens / self.total_token_budget
def check(self, estimated_next_step_tokens: int = 500) -> None:
"""在每步开始前调用,超预算则抛出异常"""
if self.step_count >= self.max_steps:
raise AgentBudgetError(
f"Exceeded max steps ({self.max_steps}). "
f"Used {self.used_tokens} tokens total."
)
if self.remaining_tokens < estimated_next_step_tokens:
raise AgentBudgetError(
f"Token budget exhausted: used {self.used_tokens}/{self.total_token_budget}. "
f"Steps completed: {self.step_count}"
)
def consume(self, tokens: int) -> None:
self.used_tokens += tokens
self.step_count += 1
class AgentBudgetError(Exception):
pass
async def run_agent_with_budget(
task: str,
total_budget: int = 50_000,
max_steps: int = 20,
) -> dict:
budget = AgentBudget(
total_token_budget=total_budget,
max_steps=max_steps,
)
messages = [{"role": "user", "content": task}]
result = None
while True:
try:
# 每步开始前检查预算
budget.check(estimated_next_step_tokens=800)
except AgentBudgetError as e:
return {
"status": "budget_exceeded",
"error": str(e),
"partial_result": result,
"usage": {
"total_tokens": budget.used_tokens,
"steps": budget.step_count,
}
}
# 每步的 max_tokens 也受剩余预算约束
step_max_tokens = min(2048, budget.remaining_tokens)
response = await call_llm(
model="deepseek-chat",
messages=messages,
max_tokens=step_max_tokens,
)
# 消费这步的 token
used = response.usage.prompt_tokens + response.usage.completion_tokens
budget.consume(used)
# 处理响应...
if response.choices[0].finish_reason == "stop":
result = response.choices[0].message.content
break
# 继续工具调用循环...
return {
"status": "success",
"result": result,
"usage": {
"total_tokens": budget.used_tokens,
"steps": budget.step_count,
"budget_utilization": f"{budget.utilization:.1%}",
}
}
陷阱 5:预算设置是静态的,但用户请求是动态的
你把摘要接口的 max_tokens 定为 512,然后某个用户传来了一篇 8 万字的学术论文------512 token 的摘要要么截断,要么质量极差。
纯静态预算无法应对输入规模的变化。需要基于输入动态调整输出预算:
python
import tiktoken
def estimate_input_tokens(text: str, model: str = "deepseek-chat") -> int:
"""估算输入 token 数"""
try:
# DeepSeek 使用 cl100k_base tokenizer
enc = tiktoken.get_encoding("cl100k_base")
return len(enc.encode(text))
except Exception:
# 粗估:中文约 1.5 char/token,英文约 0.75 char/token
return int(len(text) * 0.6)
def dynamic_max_tokens(
input_text: str,
base_max: int = 512,
ratio: float = 0.15, # 输出最多是输入的 15%
hard_cap: int = 4096, # 绝对上限
min_tokens: int = 128, # 保证最低质量的下限
) -> int:
"""根据输入长度动态计算输出预算"""
input_tokens = estimate_input_tokens(input_text)
# 按比例计算:输入越长,允许的摘要也更长
proportional = int(input_tokens * ratio)
# 在 [min, base_max * 2, hard_cap] 范围内取合理值
dynamic = max(min_tokens, min(proportional, base_max * 2))
return min(dynamic, hard_cap)
# 使用示例
def summarize(text: str) -> str:
max_tokens = dynamic_max_tokens(text)
response = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": "请对以下内容进行摘要。"},
{"role": "user", "content": text},
],
max_tokens=max_tokens,
)
return response.choices[0].message.content
动态预算的调参原则:
ratio参数需要根据业务场景实测校准,不存在通用值- 设定
hard_cap是为了防止异常长输入把成本推到无法接受的区间 - 记录实际使用量分布,定期复盘是否需要调整
ratio
四、监控:预算使用率才是关键指标
设了预算之后,监控什么?
不是 token 总用量(那只是成本指标),而是 预算使用率分布。
python
from prometheus_client import Histogram, Counter
# 预算使用率:实际输出 / 设定上限
token_budget_utilization = Histogram(
"llm_token_budget_utilization",
"Ratio of actual output tokens to max_tokens budget",
["model", "role", "endpoint"],
buckets=[0.1, 0.3, 0.5, 0.7, 0.8, 0.9, 0.95, 1.0],
)
# 截断次数
token_truncation_total = Counter(
"llm_token_truncation_total",
"Number of responses truncated by max_tokens",
["model", "role"],
)
def track_token_usage(
model: str,
role: str,
endpoint: str,
max_tokens: int,
actual_output_tokens: int,
finish_reason: str,
):
utilization = actual_output_tokens / max_tokens if max_tokens > 0 else 0
token_budget_utilization.labels(
model=model, role=role, endpoint=endpoint
).observe(utilization)
if finish_reason == "length":
token_truncation_total.labels(model=model, role=role).inc()
看分布,不看均值。 如果你的摘要接口输出 token 的 P95 只有预算的 30%,说明预算设得太宽松,可以降;如果 P90 已经贴近 100%,说明截断风险高,应该上调或引入动态预算。
告警规则建议:
yaml
# Prometheus alerting rules
groups:
- name: llm_token_budget
rules:
# 截断率超过 5% 说明预算设置不合理
- alert: HighTokenTruncationRate
expr: |
rate(llm_token_truncation_total[5m])
/ rate(llm_request_total[5m]) > 0.05
for: 2m
labels:
severity: warning
annotations:
summary: "LLM 请求截断率过高 ({{ $value | humanizePercentage }})"
description: "{{ $labels.role }} / {{ $labels.model }} 截断率异常,检查 max_tokens 设置"
# 预算使用率中位数低于 20% 说明预算太浪费
- alert: OverAllocatedTokenBudget
expr: |
histogram_quantile(0.5,
rate(llm_token_budget_utilization_bucket[30m])
) < 0.20
for: 10m
labels:
severity: info
annotations:
summary: "Token 预算分配过于宽松"
description: "{{ $labels.role }} 的预算使用率中位数为 {{ $value | humanizePercentage }},考虑收紧预算"
五、实测:不同场景的预算校准数据
我们对几个典型场景做了 500 次采样,以下是实测分布(输出 token 数,P50 / P90 / P99):
| 场景 | P50 | P90 | P99 | 建议 max_tokens |
|---|---|---|---|---|
| 单句情感分类 | 12 | 28 | 45 | 64 |
| 关键信息抽取(结构化 JSON) | 180 | 340 | 580 | 768 |
| 短文摘要(<1K 字输入) | 210 | 380 | 520 | 768 |
| 长文摘要(>5K 字输入) | 480 | 820 | 1200 | 1536(动态) |
| 代码生成(函数级) | 280 | 650 | 980 | 1024 |
| 报告生成 | 1800 | 3200 | 4600 | 5120(动态) |
| Agent 单步(工具调用) | 120 | 380 | 720 | 1024 |
观察几个反直觉的点:
- 情感分类的 P99 只有 45 token,设
max_tokens=64完全够用,不需要 512 - 结构化 JSON 抽取的 P99 是 580,设 580 会导致 1% 截断,建议留 30% 余量到 768
- 长文摘要需要动态预算,固定值在长输入时要么截断要么浪费
六、小结
token budget 工程的核心原则:
- 每个请求都设
max_tokens,无论如何都不要依赖默认值 - 按接口角色分层定义预算,不要全局统一一个值
- 处理
finish_reason="length",截断响应不能直接使用 - Agent 需要全局预算,单步限制无法防止总量失控
- 监控预算使用率分布,定期校准而不是设一次就不管
最后一个思路:预算不是越紧越好,也不是越松越好。它是一个你对业务的承诺------你承诺这个接口的输出不超过 N token,同时你也承诺为用户提供足够质量的响应。找到这个平衡点,才是 token budget 工程的本质。
本文测试代码基于真实生产场景整理,token 分布数据来自内部 500 次采样统计。代码已简化以突出核心逻辑。