通过工程优化降低 token 使用量,同时保持质量
上个月,我审查了一个客服智能体,它在写出任何内容之前,每张工单就要消耗 18,000–42,000 个输入 token。用户的实际问题通常不到 300 个 token。其余都是“以防万一”的上下文:完整的对话历史、通过关键词搜索检索到的所有内部政策页面、原样复制的 JSON 个工具结果,以及一份在六个月里不断堆积、长达 900 个 token 的系统提示词。
解决办法不是“使用更小的模型”,也不是“降低 max_tokens ,然后寄希望于结果”。我们通过一些朴实的工程工作,将 token 用量中位数降低了约 68%,同时保持回答质量稳定:裁剪上下文、检索更有针对性的证据、总结状态、强制执行输出模式,以及如实测量 token 用量。
本文讨论的就是这类工作。不是一味追求提示词最短,也不是让模型表现变差的技巧,而是在不降低系统能力的前提下,减少 token 消耗的实用方法。
token 用量是架构问题
大多数团队把 token 成本当作模型定价问题。实际中,它通常是信息路由问题。
典型的生产环境 LLM 调用包含四类 token:
- 指令 token:系统提示词、开发者提示词、格式规则、安全约束。
- 上下文 token:聊天历史、检索到的文档、工具输出、数据库记录。
- 推理/任务 token:用户的实际请求和任务过程中的细节。
- 输出 token 数:生成的响应、JSON 对象、SQL 查询、摘要或工具调用。
当 token 用量失控时,罪魁祸首通常是上下文和输出。模型成本高,并不是因为用户提出了难题,而是因为应用把所有信息都塞进了提示词,然后又允许模型长篇大论。
一个有用的起步练习是按部分记录 token 用量,而不只是记录请求的总大小:
{
"request_id": "ticket_81291",
"model": "sonnet-4.6",
"tokens": {
"system": 742,
"history": 3890,
"retrieval": 12640,
"tool_results": 2110,
"user": 184,
"output": 931
},
"latency_ms": 4820
}
看到这份明细后,优化路径就会变得清晰。你不必抽象地争论 Claude Opus 4.8、Sonnet 4.6、GPT-5.5、Gemini 3、Fable 5,或 Qwen、DeepSeek 这样的开放模型哪个“最好”。你首先需要停止发送 12,000 个无关的检索 token。
先为每条业务路径设定 token 预算
我倾向于按产品业务路径分配预算,而不是设置全局预算。法律合同审查、编程智能体和常见问题机器人不应共用相同的 token 用量假设。
客服助手的预算示例:
| 途径 | 输入预算 | 输出预算 | 模型类别 | 备注 |
|---|---|---|---|---|
| 工单分类 | 600 | 50 | 低成本/快速模型 | 标签、紧急程度、语言 |
| 检索证据 | 1,500 | 0 | 嵌入/搜索 | 无需生成 |
| 起草回答 | 4,000 | 600 | 能力较强的中高档模型 | 使用选定的证据 |
| 总结对话 | 1,000 | 250 | 低成本/快速模型 | 更新记忆状态 |
| 升级处理分析 | 6,000 | 900 | 高能力模型 | 仅用于复杂情况 |
重点不在于这些数字是否普遍适用。它们是刻意设定的适度起始约束。真正有用的做法是要求每个阶段都说明其 token 消耗的必要性。
一个常见的陷阱是:如果你只设置 max_tokens 来限制输出,输入仍可能在不知不觉中膨胀。输出限制能防止回答过长,却无法阻止检索内容的堆砌。
在检索前裁剪上下文,而不是检索后
许多系统使用最新的用户消息来检索文档,却仍在最终提示词中包含完整的对话历史。这意味着旧的离题内容、先前失败的工具调用和过时的假设仍留在上下文中。
应改为维护一份精简的工作状态:
{
"user_goal": "Get refund for duplicate annual subscription charge",
"known_facts": [
"User paid twice on 2025-01-08",
"Invoice IDs: inv_1042 and inv_1077",
"Account email: user@example.com"
],
"constraints": [
"Refund policy allows duplicate-charge refunds within 90 days",
"User prefers email follow-up"
],
"open_questions": [
"Whether one charge has already been reversed"
],
"last_updated_turn": 12
}
这样,最终调用会包含:
- 精简的状态。
- 最新的用户消息。
- 仅包含相关的已检索政策片段。
- 如果需要保持语气连贯,也可以包含最近一两轮对话。
不要盲目纳入所有先前消息。聊天历史不是记忆,而是对话记录。记忆是继续执行任务所需的提炼状态。
实际中,我使用三个层次:
- 最近的对话轮次:最近 1–4 轮,具体取决于任务的敏感程度。
- 持续更新的摘要:稳定的事实和已作出的决定。
- 外部检索:文档、数据库记录、工具输出。
这通常优于直接堆入原始对话记录,因为模型看到的噪声更少,相互冲突的指令也更少。
检索应有选择,而不是用来装点上下文
检索增强生成往往起初是为了提升质量,后来却成了 token 浪费的来源。这种失败模式很容易识别:top-k 被设为 10 或 20,每个分块有 1,000 个 token,而最终提示词包含一堆只有模糊关联的文档。
更好的检索通常也更省成本。
实用的检索流程:
def build_context(query, user_state):
search_query = rewrite_for_search(query, user_state)
candidates = vector_search(
query=search_query,
top_k=30,
filters={"product": user_state["product"]}
)
reranked = rerank_cross_encoder(
query=search_query,
docs=candidates,
top_k=6
)
selected = []
token_budget = 1800
for doc in reranked:
snippet = extract_relevant_span(doc.text, query=search_query, max_tokens=300)
if token_count(selected) + token_count(snippet) <= token_budget:
selected.append({
"doc_id": doc.id,
"title": doc.title,
"text": snippet
})
return selected
关键细节:
- 检索的候选内容要多于计划纳入的内容。
- 在生成前重新排序。
- 提取相关片段,而不是传入完整分块。
- 严格执行检索 token 预算。
- 保留文档 ID,以便模型在内部引用或关联证据。
分块大小很重要。如果分块太小,模型会丢失上下文;如果太大,每次匹配都会带入无关文本。对于产品文档和政策页面,我通常从每个分块约 300–600 个 token 开始,并保留标题,然后根据失败案例进行调整。
塞入过多检索上下文时,实际会发生什么?模型往往会变得不够精确。它会试图协调内容相近却不相关的政策,吸收过时的附加条件,或回答问题的错误版本。减少 token 可以提升质量,因为这能移除干扰信息。
摘要是需要对准确性负责的压缩
摘要可以节省大量上下文,但草率的摘要很危险。如果摘要遗漏了某项约束,智能体可能会自信地做出错误操作。
相比叙述性文字,我更倾向于使用结构化摘要:
{
"task": "Debug failing OAuth callback in staging",
"environment": {
"service": "auth-api",
"branch": "release-2025-02-14",
"runtime": "node 20"
},
"confirmed": [
"Callback returns 302 to /login",
"Provider sends code and state",
"State validation passes"
],
"suspected": [
"Session cookie is not persisted after callback"
],
"rejected": [
"Provider credentials are invalid",
"Redirect URI mismatch"
],
"next_steps": [
"Inspect Set-Cookie attributes in staging response",
"Compare SameSite and Secure flags with production"
]
}
与以下做法相比,这样更节省 token,也更可靠:
用户正在调试 OAuth,并已尝试过多种方法……
有效的摘要步骤应保留:
- 用户目标。
- 确凿事实。
- 已作出的决定。
- 约束和偏好。
- 失败的尝试。
- 待解答的问题。
- ID、文件名、时间戳及其他精确标识。
除非确实无关,否则不要在摘要中省略精确值。“用户遇到了发票问题”虽然省 token,却毫无用处。“重复扣款: inv_1042 和 inv_1077 ,发生于 2025-01-08”才是你需要的压缩方式。
对于长时间运行的智能体,我还会保存摘要的版本。如果模型生成了不准确的摘要,你需要能够检查状态何时发生了偏移。
用模式约束输出,避免冗长赘述
输出端浪费的 token 多得令人意外。提示词写着“简洁回答”,模型却写出了开场白、注意事项、回顾,以及继续提供帮助的提议。
对于供机器处理的输出,应使用模式约束。不要随意要求“一个 JSON 响应”;应定义其结构,并拒绝任何不符合结构的输出。
分类调用示例:
{
"type": "object",
"additionalProperties": false,
"required": ["category", "priority", "needs_human", "reason"],
"properties": {
"category": {
"type": "string",
"enum": ["billing", "bug", "account", "how_to", "other"]
},
"priority": {
"type": "string",
"enum": ["low", "normal", "high", "urgent"]
},
"needs_human": {
"type": "boolean"
},
"reason": {
"type": "string",
"maxLength": 160
}
}
}
对于面向用户的响应,应指定格式并设置严格的长度上限:
Write the answer in this structure:
- First sentence: direct answer, max 25 words.
- Then 2-4 bullets with concrete steps.
- Do not include a greeting.
- Do not include "let me know if you need anything else."
- Stop after the final bullet.
这比“简短一些”更有效,因为它为模型规定了输出结构。
一个常见陷阱:过低的 max_tokens 可能会在思路尚未表达完整时截断有用的输出。模式约束或受限格式能在保留完整性的同时缩短输出。截断是最后的手段,不是格式策略。
谨慎使用停止序列
当生成内容有自然边界时,停止序列很有用。例如,如果模型正在为模板化文档生成一个章节,可以让它在下一个章节标记处停止。
{
"model": "gpt-5.5",
"messages": [
{
"role": "user",
"content": "Generate only the Risk Summary section for this review..."
}
],
"max_tokens": 500,
"stop": ["\n## Mitigations", "\n## Appendix"]
}
停止序列对开放式助手回复的作用较小,因为它可能截断有效内容。不同 API 和模型系列对停止序列的处理也有所不同,尤其是在涉及工具调用或结构化输出时。应将它们视为边界防护,而不是主要的压缩技术。
适合的用法:
- 生成已知模板中的一个章节。
- 防止模型继续生成示例。
- 在批处理中遇到分隔符时停止。
- 约束传统文本补全式提示词。
有风险的用法:
- 遇到常见标点符号就停止。
- 遇到可能出现在正常回答中的词语就停止。
- 用停止序列代替适当的输出模式。
- 假设 Claude、GPT、Gemini 和开放模型网关的行为完全相同。
将任务拆分并分配给成本更低的模型
并非每个步骤都需要最强的模型。在生产系统中,我经常将子任务路由到更小或成本更低的模型:
| 子任务 | 典型需求 | 适合的选择 |
|---|---|---|
| 语言检测 | 简单分类 | 小型或低成本模型 |
| 工单分类 | 结构化标签输出 | 小型或中型模型 |
| 查询改写 | 简短转换 | 小型或中型模型 |
| 文档重排序 | 专用重排序器或小型模型 | 尽可能采用非生成式方案 |
| 最终回答 | 推理与语气 | 能力更强的模型 |
| 政策例外处理 | 细微差别与风险 | 最强的模型 |
这正是多模型访问在实际运营中发挥作用的地方。你可能希望将 Sonnet 4.6 用于大多数智能体写作任务,将 GPT-5.5 用于复杂的推理路径,将 Gemini 3 用于某些多模态或长上下文工作流,在确实需要 1M 上下文窗口时使用 Fable 5,并将 Llama、Qwen、DeepSeek、MiniMax 或 Kimi 等开放模型用于受控的内部任务。工程上的重点是路由,而不是品牌忠诚。
其局限在于:编排开销确实存在。每增加一次模型调用,都会增加延迟、故障模式、日志复杂度,以及需要评估的范围。如果一个低成本分类器节省了 300 个 token,却增加了 600 毫秒延迟和另一条重试路径,那可能并不划算。
我喜欢的一种模式:
if route == "simple_faq":
model = "haiku-4.5"
elif route == "standard_support":
model = "sonnet-4.6"
elif route == "legal_or_financial_exception":
model = "opus-4.8"
else:
model = "sonnet-4.6"
具体名称会变化,但原则应保持不变:使用能够可靠达到任务质量要求的最便宜模型。
如实衡量输入和输出 token
当团队只关注每次请求的平均成本时,token 优化就会失败。你需要查看按路由和阶段细分的分布。
至少跟踪以下指标:
- 按提示词各部分统计的输入 token。
- 输出 token。
- 检索到的文档数量和检索 token。
- 模型名称和版本。
- 延迟百分位数。
- 缓存命中率(如果使用提示词缓存或上下文缓存)。
- 重试次数。
- 用户可感知的质量指标。
- 转人工处理率或纠正率。
有用的仪表板应展示 p50、p90 和 p99。token 相关问题往往出现在分布尾部。
日志行示例:
{
"route": "support.draft_answer",
"model": "sonnet-4.6",
"input_tokens": 3820,
"output_tokens": 514,
"retrieved_docs": 4,
"retrieval_tokens": 1320,
"latency_ms": 3100,
"cache_hit": false,
"quality": {
"user_thumb": "up",
"human_edited": false
}
}
对于“我们将 token 减少了 40%”这类说法,要谨慎看待。重试是否增加了?转人工处理是否增加了?用户是否因为回答过于简略而提出了更多后续问题?更便宜的模型是否生成了更多无效的 JSON,导致不得不发起修复调用?
真正应衡量的不是每次调用消耗的 token,而是每个成功完成的任务消耗的 token。
长上下文不是偷懒的理由
大上下文窗口确实很有用。Fable 5 的 1M 上下文规格、Gemini 在长上下文方面的优势,以及各类前沿模型的长上下文模式,让几年前还难以开展的工作流成为可能:全仓库代码分析、大型文档审阅、数小时的转录文本处理,以及内容密集的研究资料包分析。
但长上下文是一种能力,而不是默认架构。
以下情况下,长上下文调用可能适用:
- 答案依赖输入中相距较远的多个部分。
- 检索会遗漏跨文档关系。
- 任务具有探索性,你还不知道哪些内容重要。
- 遗漏细节的代价高于读取更多内容的成本。
以下情况下,它们通常会造成浪费:
- 用户提出的是范围很窄的事实性问题。
- 少量源文档片段已足够。
- 上下文包含重复或过时的信息。
- 模型被用作数据库筛选器。
一种常见模式是使用长上下文进行离线分析,并生成精简的产物用于在线服务。例如,完整分析一次政策文档库,然后生成结构化的政策映射、嵌入向量、摘要和测试用例。运行时的助手不应为每张工单重新读取整个文档库。
实用的优化工作流
当我接手一个 token 消耗很高的 LLM 系统时,通常会按以下顺序处理:
-
先建立监测
添加按部分统计的 token 用量。不要盲目优化。 -
限制检索上下文
为检索设置严格的 token 预算。进行重排序并提取文本片段。 -
用状态替代对话记录堆砌
保留最近的对话轮次和结构化记忆。 -
约束输出
对机器处理的输出使用模式约束,对面向用户的输出使用明确格式。 -
路由子任务
在可靠的情况下,将分类、改写和摘要任务交给成本更低的模型。 -
在任务层面评估质量
比较成功解决任务的成本,而不只是单次调用成本。 -
关注尾部情况
排查 p95 和 p99 的 token 用量峰值。这些峰值往往会暴露检索故障或失控的历史记录。
下面是我在服务中使用过的一项简单防护措施:
TOKEN_LIMITS = {
"support.classify": {"input": 800, "output": 80},
"support.retrieve": {"input": 500, "output": 0},
"support.draft": {"input": 4500, "output": 700},
"support.summarize": {"input": 1800, "output": 300},
}
def assert_token_budget(route, prompt_sections, max_output_tokens):
input_tokens = {
name: count_tokens(text)
for name, text in prompt_sections.items()
}
total_input = sum(input_tokens.values())
limits = TOKEN_LIMITS[route]
if total_input > limits["input"]:
raise TokenBudgetError(route, total_input, input_tokens)
if max_output_tokens > limits["output"]:
raise TokenBudgetError(route, max_output_tokens, {"output": max_output_tokens})
快速失败,总比一周后才发现某个糟糕的检索查询把预算都花在了无关的 PDF 上要好。
实用要点
- 减少 token 用量应从建立可观测性开始:按提示词部分、路由和模型记录输入与输出 token 数。
- 有选择性的检索优于一股脑塞入内容:进行重排序、提取文本片段,并严格执行上下文预算。
- 摘要应是结构化状态,而不是含糊的文字描述。保留准确的事实、ID、约束和失败的尝试。
- 相比单纯调低,输出模式能更安全地减少冗长赘述
max_tokens. - 停止序列是有用的边界防护措施,但不能替代结构化输出。
- 只有在新增的延迟和失败模式值得承担时,才将简单子任务路由到成本更低的模型。
- 用每个成功任务的 token 用量来衡量节省效果,而不是每次调用的 token 用量。
- 长上下文模型功能强大,但并不能免除对信息架构的需求。
一个 API 密钥即可使用 Claude Opus 5.5、Sonnet 5、Haiku 4.5 和 Fable 5.1,以及 GPT-6 个模型。按量付费,无需订阅。
获取 API 密钥 →