生产级 LLM 应用的 Token 预算策略
大多数团队发现他们上下文管理问题的方式都如出一辙:一个在演示中表现良好的生产级智能体,在对话进行 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 的片段,其累积速度远快于多轮对话历史。
摘要优于截断
当对话历史超出了你的中等优先级预算时,正确的做法是总结摘要,而不是截断。截断是具有破坏性的——你会丢失以后可能会被引用的信息。而摘要则是对信息进行压缩。
在生产环境中行之有效的模式如下:
- 每隔 N 轮对话(通常为 8-12 轮),触发一次后台摘要生成任务。
- 将早于 N 轮的原始历史记录替换为结构化的摘要。
- 将该摘要作为系统消息(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。这直接转化为更低的延迟,并且在你控制的基础设施上,意味着更低的计算成本。
为了利用这一点:
-
将静态内容放在前面:系统提示词、示例和模板内容应出现在动态内容之前。缓存键是基于前缀的——对早期 token 的任何更改都会使之后所有内容的缓存失效。
-
分离静态与动态内容:不要将模板内容与每个请求特有的内容交织在一起。尽可能保持稳定的前缀越长越好。
-
一致性路由:在通过 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 预算管理的实践方法:
- 先进行埋点监测 —— 在优化之前,按来源准确统计 Token 数量
- 设置明确的预算层级 —— 保护关键内容,优先处理其余部分
- 大规模进行摘要处理 —— 在 N 轮对话后,使用结构化摘要压缩对话历史
- 针对缓存进行结构化 —— 将稳定内容放在前面,保持路由的一致性
- 检索而非堆积 —— 针对大型知识库使用 RAG
- 监控利 用率 —— 在进入失败区域之前进行报警,而不是在之后
在生产环境中构建可靠多轮对话智能体的团队,最终都采用了这一技术栈的某种变体。具体的数值会有所不同 —— 你理想的摘要间隔和预算分配取决于你的使用场景 —— 但其底层的原则是通用的。上下文并非免费,将其视为无限,最终会导致你需要去调试那些在 15 轮对话之后才出现的幻觉。
