跳到主要内容

生产级 LLM 应用的 Token 预算策略

· 阅读需 12 分钟
Tian Pan
Software Engineer

大多数团队发现他们上下文管理问题的方式都如出一辙:一个在演示中表现良好的生产级智能体,在对话进行 15 轮后开始出现幻觉。日志显示 JSON 格式正确,模型返回了 200 状态码,且没有人修改代码。变化的是累积效应——工具结果、检索到的文档和对话历史悄无声息地填满了上下文窗口,直到模型需要在 80,000 个相关性参差不齐的 Token 上进行推理。

上下文溢出(Context overflow)是显而易见的故障模式,但“上下文腐化”(context rot)则更具隐蔽性。研究表明,在达到限制之前,LLM 的性能就已经开始下降。随着上下文的增加,模型会出现“中间迷失”效应(lost-in-the-middle effect):注意力集中在输入的开头和结尾,而中间的内容则变得不可靠。埋藏在 30 轮对话中第 12 轮的指令可能会实际上消失。模型不会报错——它只是悄悄地忽略了它们。

这不是通过升级到百万级 Token 上下文窗口就能解决的问题。大窗口只是推迟了问题,而不是消除了问题。在容量利用率达到 60-70% 时,即使是宣称支持 1M+ Token 的模型,性能也会开始下降。真实的生产系统需要明确的 Token 预算策略。

为什么 Token 成本的累积速度比你预想的快

假设一个生产级支持智能体拥有 3,000 Token 的系统提示词。每次查询增加 4,000 Token 的检索文档,500 Token 的对话历史(每轮都在增长),以及每步 200 Token 的工具调用结果。到第 8 轮时,每次调用的 Token 数将达到 12,000+。

按每 1K 输入 Token 0.05 美元计算,每月 100 万次 API 调用意味着仅系统提示词一项每月就要花费 15 万美元。加上检索和历史记录,在计算输出 Token 之前,你的花费将超过 50 万美元。不追踪这一指标的团队通常会被他们的第一份全额发票吓到。

累积问题在多智能体工作流中更加严重。一个拥有 20 次 LLM 调用的流水线,如果每个步骤都将结果传递给下一个步骤,在生成最终答案之前可能会累积 50,000+ Token。每一个中间步骤都要计费。每一个中间步骤都会影响质量。

解决方法不是随意削减上下文——那会导致其自身的问题。解决方法是将 Token 预算视为一等公民的工程关注点,并采用深思熟虑的分配策略。

使用特定模型的 Tokenizer,而非近似值

在管理 Token 预算之前,你需要准确的计数。“每个 Token 4 个字符”的启发式方法错误率极高,足以影响生产环境。

不同的模型使用不同的 Tokenizer:

  • OpenAI GPT-4o:使用 cl100k_base 编码的 tiktoken(较新的 gpt-4o-2024-11-20 使用 o200k_base
  • Anthropic Claude:使用 count_tokens() 终端节点——它是免费的且不消耗速率限制
  • Google Gemini:使用官方 SDK 中的 count_tokens() 方法(基于 SentencePiece)
  • Meta Llama:使用 Llama 仓库中精确的 HuggingFace Tokenizer

使用 tiktoken 来计算 Claude 的 Token 可能会产生 30-50% 的误差。这个误差范围会耗尽你的安全缓冲并导致意外的截断。

准确的计数还需要考虑消息格式的开销。在 OpenAI Chat Completions API 中,每条消息会为 ChatML 包装器增加约 4 个 Token。函数调用模式(schema)会增加更多。生产环境的代码应该始终进行精确计算:

import tiktoken

def count_chat_tokens(messages: list[dict], model: str = "gpt-4o") -> int:
enc = tiktoken.encoding_for_model(model)
total = 3 # completion primer overhead
for msg in messages:
total += 4 # per-message wrapper
for key, value in msg.items():
total += len(enc.encode(str(value)))
return total

显式预留输出 Token。如果你的模型最多可以生成 2,000 个 Token 的输出,请将你的上下文预算设置为 context_limit - 2000。永远不要让输入占满整个窗口。

按优先级而非时间顺序分配预算

上下文管理的幼稚做法是按时间顺序:保留所有内容直到达到上限,然后丢弃最旧的内容。这种做法会失败,因为最旧的内容通常包含你的系统提示词、初始约束和关键指令。静默丢弃这些内容会破坏智能体的行为。

相反,应定义明确的预算层级:

层级内容预算
受保护 (Protected)系统提示词、当前查询始终包含
高优先级 (High priority)当前工具结果、最新的检索文档约占窗口的 30%
中优先级 (Medium priority)最近的对话历史(最近 5 轮)约占窗口의 25%
低优先级 (Low priority)较旧的对话历史剩余部分

当你接近限制时,请从下往上进行压缩或丢弃。永远不要触动受保护的内容。

这种分层方法还能揭示预算的实际去向。大多数团队一旦开始监测,就会发现 RAG 检索是他们最大的变量成本——而非对话历史。在典型的聊天应用中,每次查询检索 8 个各 512 Token 的片段,其累积速度远快于多轮对话历史。

摘要优于截断

当对话历史超出了你的中等优先级预算时,正确的做法是总结摘要,而不是截断。截断是具有破坏性的——你会丢失以后可能会被引用的信息。而摘要则是对信息进行压缩。

在生产环境中行之有效的模式如下:

  1. 每隔 N 轮对话(通常为 8-12 轮),触发一次后台摘要生成任务。
  2. 将早于 N 轮的原始历史记录替换为结构化的摘要。
  3. 将该摘要作为系统消息(system message)插入到上下文的开头。

结构化摘要的效果显著优于原始拼接。与其逐字逐句地保留对话,不如存储具有语义价值的残留信息:

用户上下文:
- 正在构建一个用于日志解析的 Rust CLI 工具
- 拥有约 3 年的 Rust 经验
- 在 macOS 上运行,目标生产环境为 Linux

已做出的决定:
- 使用 clap 进行参数解析(因 structopt 已弃用而拒绝使用)
- 通过 tokio 实现异步 I/O,而非阻塞读取
- 输出格式:输出到 stdout 的 JSON

当前任务:实现支持正则匹配的 --filter 标志

这 80 个 token 的摘要替代了原本 2,000 个 token 的对话。模型保留了进行连贯后续对话所需的一切信息。2025 年的一项针对生产环境对话智能体的分析发现,针对 8-12 轮对话生成的结构化摘要可以将每轮对话的 token 使用量减少 40-60%,且准确性损失微乎其微。

权衡之处在于:每个摘要周期会增加一次额外的 LLM 调用。对于大多数应用而言,这是一项值得的投资——在几轮对话之后,每轮输入成本的降低将超过摘要生成的开销。

结构化上下文以利用 KV 缓存

现代 LLM 推理会缓存为跨请求重复的前缀计算的键值张量(key-value tensors)。当多个请求共享相同的前缀——相同的系统提示词、相同的 Few-shot 示例——就会命中缓存,从而避免重新计算这些 token。这直接转化为更低的延迟,并且在你控制的基础设施上,意味着更低的计算成本。

为了利用这一点:

  1. 将静态内容放在前面:系统提示词、示例和模板内容应出现在动态内容之前。缓存键是基于前缀的——对早期 token 的任何更改都会使之后所有内容的缓存失效。

  2. 分离静态与动态内容:不要将模板内容与每个请求特有的内容交织在一起。尽可能保持稳定的前缀越长越好。

  3. 一致性路由:在通过 vLLM 或类似工具自托管的基础设施上,共享相同前缀的请求应路由到同一个 GPU 工作节点。跨节点的缓存复用是有限的。

在读取密集型工作负载(如文档问答、客户支持)中使用前缀感知路由的生产团队通常报告输入 token 计算量减少了 60-80%。Thomson Reuters 的机器学习团队记录了通过缓存附加在每个查询前的法律背景模板,使法律文档摘要的成本降低了 60%。

对于托管 API(如 OpenAI、Anthropic),前缀缓存是自动的——但你仍需要优化提示词结构,使可缓存的前缀保持稳定。在提示词早期放置时间戳或针对每个请求的 ID 会导致缓存失效。

构建 Token 预算中间件层

上述策略并非孤立存在——它们需要系统地应用。正确的架构是建立一个 Token 预算中间件,在上下文到达模型之前对其进行拦截:

class TokenBudgetManager:
def __init__(self, model: str, context_limit: int, output_reserve: int = 2000):
self.model = model
self.budget = context_limit - output_reserve

def prepare_context(
self,
system_prompt: str,
current_query: str,
history: list[dict],
retrieved_docs: list[str],
) -> list[dict]:
messages = []

# 受保护内容:始终包含
protected_tokens = count_tokens(system_prompt + current_query, self.model)
remaining = self.budget - protected_tokens

# 高优先级:最近检索到的文档
doc_tokens = 0
included_docs = []
for doc in retrieved_docs:
t = count_tokens(doc, self.model)
if doc_tokens + t < remaining * 0.35:
included_docs.append(doc)
doc_tokens += t

# 中等优先级:最近的历史记录
history_tokens = 0
included_history = []
for turn in reversed(history[-10:]): # 从最近的开始
t = count_tokens_for_message(turn, self.model)
if history_tokens + t < remaining * 0.30:
included_history.insert(0, turn)
history_tokens += t

# 低优先级:更早的摘要化历史
# (摘要是在后台计算的,作为系统消息注入)

return build_messages(system_prompt, included_docs, included_history, current_query)

这大约 50 行的基础设施代码可以防止整类隐性上下文失效问题。生产工程团队一致认为,实施显式的预算中间件是他们对 LLM 系统所做的最高杠杆率的可靠性改进之一。

何时选择检索而非累积

对于知识密集型任务,问题不在于“如何将所有内容塞进上下文”,而在于“如何只检索相关内容”。将大型文档塞进上下文代价昂贵,且“迷失在中间”(lost-in-the-middle)效应意味着检索质量会随着文档数量的增加而下降。

在实践中,盈亏平衡点是:如果源材料大于 10-15K 个 token,检索几乎总是比全上下文包含更具成本效益,且通常更准确。对于 5K 个 token 以下的材料,全量包含更简单,且能避免检索错误。

来自生产部署的一个细微差别是:检索和长上下文模型是相辅相成的。检索将范围缩小到最相关的分块;随后,能力强大的长上下文模型可以对比纯 RAG 稍多一些的上下文进行推理,从而捕捉到检索可能遗漏的细微差别。在处理复杂的推理任务时,这种结合优于任何单一方法。

监控 Token 预算利用率并设置告警

Token 预算需要可观测性。你至少应跟踪:

  • 每个请求的输入 Token 数:平均值和 P95,按来源细分(系统提示词、检索结果、历史记录、工具运行结果)
  • 上下文利用率:预算被消耗的比例
  • 摘要触发率:压缩历史记录的频率
  • 缓存命中率:对于前缀缓存,命中缓存的请求比例

在以下情况设置告警:

  • 平均输入 Token 超过预算的 70%(接近上下文腐化区域)
  • P95 输入 Token 超过预算的 90%(接近溢出)
  • 摘要触发率激增(预示对话长度分布发生了变化)

这些指标经常会揭示出令人意外的情况。团队通常会发现,某个单一工具 —— 例如代码执行结果或大型检索文档 —— 占据了总输入 Token 支出的 40%。修复这一个来源往往比优化整个对话历史流水线更有效果。

总结

Token 预算管理的实践方法:

  1. 先进行埋点监测 —— 在优化之前,按来源准确统计 Token 数量
  2. 设置明确的预算层级 —— 保护关键内容,优先处理其余部分
  3. 大规模进行摘要处理 —— 在 N 轮对话后,使用结构化摘要压缩对话历史
  4. 针对缓存进行结构化 —— 将稳定内容放在前面,保持路由的一致性
  5. 检索而非堆积 —— 针对大型知识库使用 RAG
  6. 监控利用率 —— 在进入失败区域之前进行报警,而不是在之后

在生产环境中构建可靠多轮对话智能体的团队,最终都采用了这一技术栈的某种变体。具体的数值会有所不同 —— 你理想的摘要间隔和预算分配取决于你的使用场景 —— 但其底层的原则是通用的。上下文并非免费,将其视为无限,最终会导致你需要去调试那些在 15 轮对话之后才出现的幻觉。

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