LLM Token Budget 工程实践:给每个请求设上限,让成本和质量都在掌控中

引子:一个账单故事

我们的 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=4000budget_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 是最常见的懒惰写法。这有两个问题:

  1. 对短输出接口,2048 远超实际需求,网关层可能预留过多配额
  2. 对长输出接口,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)。这会导致:

  1. 即使你的实际使用量只有 200 token,系统认为你"占用"了 16K
  2. 并发请求多时,配额被大量"虚占",正常请求反而被限流
  3. 实际用量统计出现偏差,成本归因报告不准

解决方案:务必在每次请求时显式设置 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

观察几个反直觉的点:

  1. 情感分类的 P99 只有 45 token,设 max_tokens=64 完全够用,不需要 512
  2. 结构化 JSON 抽取的 P99 是 580,设 580 会导致 1% 截断,建议留 30% 余量到 768
  3. 长文摘要需要动态预算,固定值在长输入时要么截断要么浪费

六、小结

token budget 工程的核心原则:

  1. 每个请求都设 max_tokens,无论如何都不要依赖默认值
  2. 按接口角色分层定义预算,不要全局统一一个值
  3. 处理 finish_reason="length",截断响应不能直接使用
  4. Agent 需要全局预算,单步限制无法防止总量失控
  5. 监控预算使用率分布,定期校准而不是设一次就不管

最后一个思路:预算不是越紧越好,也不是越松越好。它是一个你对业务的承诺------你承诺这个接口的输出不超过 N token,同时你也承诺为用户提供足够质量的响应。找到这个平衡点,才是 token budget 工程的本质。


本文测试代码基于真实生产场景整理,token 分布数据来自内部 500 次采样统计。代码已简化以突出核心逻辑。

相关推荐
卷无止境1 小时前
终端里的AI辅助,一场正在发生的编程效率变革
后端·python
程序员爱钓鱼2 小时前
Go 编程实战:Map——使用 Key-Value 管理键值数据
后端·rust·go
程序员爱钓鱼2 小时前
Rust Trait Object详解:dyn Trait与动态分发
后端·面试·rust
To_OC10 小时前
Next.js + Redis 构建笔记系统:Redis Hash 实战与性能优化
redis·后端·next.js
fthux10 小时前
招聘季实测:我用 TraeWork 搭了一套 AI 简历初筛系统
人工智能·ai编程·trae
小虎AI生活12 小时前
WorkBuddy + Canva 可画 MCP 技术解析:从"图"到"活稿"的范式与实操
ai编程
北斗落凡尘12 小时前
LangGraph 入门实战(11)--输出模式
后端·python·langchain
MomentYY13 小时前
RAG 索引维护:文档改了,知识库要不要重建?
人工智能·agent·ai编程
李燚13 小时前
HITL 源码:8 种人机协同模式的设计(第85篇-E71)
ai·agent·ai编程·模式·rag·eino·hitl