跳到主要内容

没人调校的 max_tokens 旋钮:将输出截断作为成本杠杆

· 阅读需 12 分钟
Tian Pan
Software Engineer

检查你代码库中每一次 LLM 调用里的 max_tokens 参数。如果你和大多数团队一样,这个参数要么没设置,要么设成了模型的最大值,或者是半年前随便选的一个像 4096 这样的整数,之后就再也没动过。它是 API 请求中一个显眼的预算旋钮,却在默默地为你从未使用过的冗余买单。

在中等商业模型上,输出 token 的成本大约是输入 token 的四倍,而在昂贵模型上甚至高达八倍。生成步骤的经济效益完全是失衡的:你在 max_tokens 中留下的每一分未使用的余量,都是你可能需要支付的成本;而且由于解码是顺序进行的,你生成的每一个 token 都会线性地增加你的 P50 延迟。然而,大多数生产系统都将此参数视为安全阀——设置得高高的,然后忘掉它,继续开发。

这就是在浪费钱。如果你测量各个路由的实际输出长度分布,并根据这些分布的形状(而不是盲目采用默认值)来设置 max_tokens,你可以在不产生任何可察觉的质量变化的情况下,将典型生产工作负载的输出 token 支出削减 20–40%。这不是什么提示词工程技巧,也不是更换模型。这是一种预算校准练习,而且几乎没人这么做。

为什么 max_tokens 被当作安全阀而非预算

默认的思维模型是这样的:max_tokens 是为了防止生成失控。如果模型在你要求摘要时开始胡言乱语写起小说,这个上限能救你一命。只要把它设得足够高,保证不会意外截断合法的回答,那就没问题了。人们往往意识不到成本的影响,因为大多数团队认为输出成本就是“模型生成的任何内容”,这受限于模型的自然停止行为,而不是上限。

这种思维模型有两个缺陷。

首先,上限对于容量规划至关重要,而不仅仅是为了防止失控。供应商根据你声明的 max_tokens 预留 KV 缓存和核算槽位,某些计费方案甚至是根据预留量而非实际生成量收费。即使计费纯粹基于生成的 token,你的速率限制(rate limit)计算也会使用声明的上限。一个请求 4096 tokens 但只生成 120 tokens 的调用,在某些供应商那里会更激进地占用你每分钟 token 数(TPM)的配额——并且它占用了更多的推理调度槽位,这在负载较高时会损害尾部延迟。

其次,更重要的一点是,大多数人从不检查实际的输出长度分布。他们假设模型“大约就是它需要的那么啰嗦”,把 max_tokens 设在舒适区之上,然后就完事了。但如果你为一个特定路由(例如摘要端点或结构化提取调用)采样一周的生产流量,其分布几乎总是极度右偏的。P50 只是 max_tokens 的一小部分。P95 仍然远低于它。P99 可能接近它,但只有 P99.9 才会被截断,而这本就是你应该以不同方式处理的边缘情况。

上限设在 4096,而你实际的 P99 只有 680。你在为一个根本不存在的“长尾”做预算。

校准方法论

第一步是摆脱“一刀切”的心态。max_tokens 应该根据路由、任务、调用类型来设置,而不是根据应用。总结支持工单的调用与生成代码审查或返回 JSON 分类标签的调用,具有完全不同的输出分布。用同一个共享默认值处理它们,就像让所有端点共享相同的超时时间,然后纳闷为什么 P99 延迟这么糟糕。

对于每个路由,收集具有代表性的输出 token 计数样本。大多数成熟的 LLM 客户端库都会在响应元数据中提供 completion_tokens。将其与请求 ID 和路由标识符一起记录下来。一周的生产流量通常足够了;如果路由是高流量的,一天通常也够了。

然后观察分布:

  • P50, P95, P99, P99.9, max。这五个数字几乎能告诉你需要的一切。
  • 形状。它是单峰且紧凑的(如结构化提取),还是具有长尾(如开放式生成)?紧凑的分布可以激进地设置上限。长尾分布则需要处理续写处理(continuation handling)。
  • 已知上限。对于结构化输出或分类标签,通常有一个理论最大值——最长的有效枚举值,或最宽的 JSON 模式。使用那个值,而不是靠猜。

对于大多数路由,将 max_tokens 设置为大约 P99 加上一点缓冲。在长尾确实重要的情况下(如法律摘要、截断不可接受的长篇生成),要么将其设得更高,要么投入精力实现续写模式——但不要两者并行。

这种方法之所以奏效,是因为长尾部分正在为自己买单。如果长尾部分确实代表了需要容纳的合法生成内容,那么将上限设为 P99 会导致约 1% 的请求被截断——你可以通过续写调用来处理这 1%,代价是这 1% 的请求多出一次往返。如果长尾只是没有价值的模型废话,设置上限则能彻底将其移除,为你节省 token。

当截断是一项功能而非 Bug 时

这种填补 max_tokens 的本能源于一种合理的恐惧:在生产环境中,截断的响应对用户是可见的,通常会破坏下游解析,甚至在底层答案没问题的情况下看起来也是坏掉的。没有人想发布一个会在句子中途断掉的聊天机器人。

但截断是一种信号,而不是失败。当 API 返回 finish_reason: "length"(或 Anthropic 上的 stop_reason: "max_tokens")时,它正是在确切地告诉你哪些调用需要继续。这是你可以利用的信息。

处理它的三种方式:

优雅延续。 保持对话上下文,将截断的响应作为助手回合附加,并发出“从你停止的地方继续”之类的后续提示词。模型会接上思路。这适用于散文、结构化文本和流式传输场景。在成本方面,你只需为截断的 token 支付一次费用,为延续的 token 支付一次费用,以及一些重复的提示词开销。如果你的校准是正确的,这种情况大约发生在 1% 的请求中,因此延续模式的分摊成本与通过限制其他 99% 请求所节省的成本相比是微不足道的。

结构化延续。 对于 JSON 或代码生成,原始的“继续”通常会破坏架构 (schema)。相反,解析部分输出,识别最后一个完整的结构元素(关闭的对象、完成的函数),并重新提示只要求缺失的部分。OpenAI 社区已经针对结构化输出中的 finish_reason: length 广泛记录了这种模式。这需要更多工程量,但很健壮。

回退层。 对于延续成本高昂或复杂的路径,检测截断并在输出上限更高或显式设置了更大 max_tokens 的模型上重试。这种情况比应有的要少见——大多数团队只是预先填补所有内容。

核心见解是,在 1% 的尾部情况下优雅地处理截断,几乎总是比为 100% 的请求预留尾部预算更便宜。唯一不成立的情况是延续本身成本极高(具有复杂状态的链式工具调用),或者单个截断的响应会导致下游级联故障。这两种情况都很狭窄且可识别——它们是例外,而非默认情况。

没人谈论的延迟故事

输出 token 不仅成本更高,而且还主导了延迟。生成是序列化的:每个 token 都依赖于前一个,而每个输出 token 的时间 (TPOT) 乘以生成的 token 数量等于你大部分的响应时间。一个以每秒 50 个 token 生成 2000 个 token 的路径需要 40 秒的生成时间。同样的路径校准到 600 个 token 则只需要 12 秒。

削减输出长度是大多数 LLM 系统中最大的单一延迟杠杆,比提示词压缩更有效,比在同一梯队中选择模型更有效,甚至比缓存更有效。常见的建议“通过削减 50% 的输出 token 来减少延迟,大约可以削减 50% 的延迟”大致是正确的,因为在给定的负载下 TPOT 基本上是恒定的。

当你激进地校准 max_tokens 时:

  • p99 延迟下降,因为最坏情况下的生成时间缩短了。
  • p50 延迟也会下降,因为模型有时会生成到上限,而原本它会在更低的点自然停止——但只有在上限接近自然长度时才会如此。
  • 吞吐量提高,因为当声明的预留减少时,推理调度器可以将更多并发请求打包到同一个 GPU 池中。

节省成本是头条数字,但延迟的改进通常是让校准工作在产品团队中落地的原因。用户感知延迟;财务感知成本。你可以向双方推销这项工作。

校准失效的场景

这不一把万能锤。在以下几种情况下,激进的 max_tokens 校准是错误的选择:

推理模型。 具有扩展思考能力的模型(Claude 的思考预算、OpenAI 的推理 token)在发出可见输出之前会消耗隐藏的 token。max_tokens 与思考预算以提供商特定的方式进行交互,限制太紧可能会截断推理阶段并产生混乱的答案。对于这些模型,请使用提供商显式的思考预算参数,并仅对可见部分设置 max_tokens

用户边生成边阅读的流式 UI。 如果产品模式是“模型现场撰写文档”,那么截断是非常明显的。延续模式虽然有效,但会引入明显的停顿。对于这些路径,较松的上限加上系统提示词中显式的长度指令,可能比激进的校准更好。

智能体工具循环。 当输出是函数调用时,token 是廉价的,成本由迭代次数主导,而不是每次的长度。校准迭代次数和工具参数,而不是总上限。

冷启动路径。 流量较小或行为不断变化的路径(新功能、A/B 测试变体)没有稳定的分布来进行校准。使用保守的默认值,并在拥有流量后再重新评估。

对于其他所有内容——摘要、提取、分类、问答、批处理、评估流水线——校准几乎总能带来收益。

如何在不破坏系统的前提下发布

要避免的失败模式是在一个 PR 中将一百个端点的 max_tokens 从 4096 切换到 600,然后在周一发现有 0.8% 的客服工单因为 JSON 解析器报错。更稳妥的发布流程:

  1. 先进行埋点。 在每次调用中记录 completion_tokensfinish_reason,并按路由(route)打标签。暂时不要更改任何内容。
  2. 计算每个路由的分布。 将上周的数据导出到 notebook 中;观察这五个数值(分位数)。
  3. 影子测试新的限制。 挑选一个路由,将 max_tokens 设置为你建议的值,并监控截断率。预计数值应接近你的校准目标(如果你选择了 p99,则约为 1%)。
  4. 在扩大范围之前增加续写处理器(continuation handler)。 即使你现在还不需要它,也要做好准备。
  5. 将截断率作为每个路由的一等指标(first-class metric)放入仪表盘。 如果该指标漂移到(比如)3% 以上,则发出告警。这种漂移是输出分布发生变化的信号——可能是提示词更改、模型变更或用户行为变化——这需要重新校准。

这种模式与任何调优练习如出一辙:测量、更改一处、验证,然后推广。不同之处在于回报。只需一个下午,对生产 LLM 系统中流量最大的前十个路由进行校准,就能可靠地削减 20–40% 的输出开销,并显著降低尾部延迟。很少有手段能如此廉价。

预算一直都在

做这项工作时,一个令人不安的发现是,有多少冗余一直就摆在显而易见的地方。没有人是有意超支的;他们只是从未制定过预算。max_tokens 被当作了一个边界,而不是一个拨盘,而且这个边界是由最初编写集成代码的人设置的——通常是模型默认值,往往与实际用例毫无关系。

同样的动态也出现在 LLM 系统的其他地方:填充了未使用历史记录的上下文窗口、检索返回的块(chunks)多于模型所能关注的、以错误的粒度缓存的嵌入(embeddings)。LLM 的成本工程主要在于发现那些被隐式设置的预算并使其显式化。max_tokens 是最容易修复的一个,因为分布数据已经存在于你的日志中——你只需要去看一眼。

下次当你盯着不断增长的 LLM 账单时,在尝试提示词压缩或模型路由之前,先拉取前三个路由的 completion_tokens 分布。如果 p99 明显低于你当前的上限,你已经知道该怎么做了。

References:Let's stay in touch and Follow me for more thoughts and updates