跳到主要内容

没人调校的 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 广泛记录了这种模式。这需要更多工程量,但很健壮。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates