前言:Token账单,AI时代最真实的焦虑
2026年,我见过很多团队经历同一个轨迹:兴奋地接入大模型API,做出Demo,用户开始用,然后------月底收到账单,CEO一脸懵。
一个日活1万的AI客服,每用户每天平均5次对话,每次输入500 Token + 输出200 Token。按Claude Sonnet价格算:
- 输入:10000 × 5 × 500 = 2500万Token/天 ≈ 约¥75/天
- 输出:10000 × 5 × 200 = 1000万Token/天 ≈ 约¥150/天
- 合计:约¥225/天,一个月约¥6750
这还只是模型调用费用,不含服务器、存储、带宽。当用户增长到10万时,这个数字直接变成6.75万/月。
本文整理了我和团队在实际项目中验证过的6款开源工具和配套方案,每款工具都附有真实的节省幅度和踩坑记录,拿来即用。
一、先找到你的"Token黑洞"
在谈优化工具之前,必须先做诊断。Token浪费通常藏在以下几个地方,不同的黑洞需要不同的工具来填。
典型的Token浪费模式
python
# 诊断脚本:统计你的API调用中Token的分布情况
import anthropic
from dataclasses import dataclass, field
from typing import List
import statistics
@dataclass
class TokenUsageRecord:
input_tokens: int
output_tokens: int
prompt_preview: str # Prompt前100字,用于分类
class TokenDiagnostics:
"""
使用前的注意事项:
这个工具需要在你现有的API调用中插入,
建议先在测试环境跑一周,积累足够的样本再分析。
不要在生产环境直接保存完整的prompt内容(可能含敏感信息),
这里只保存前100字用于分类。
"""
def __init__(self):
self.records: List[TokenUsageRecord] = []
self.client = anthropic.Anthropic()
def tracked_create(self, **kwargs) -> anthropic.types.Message:
"""包装原有的client.messages.create,自动记录Token使用"""
response = self.client.messages.create(**kwargs)
# 从响应中提取Token使用量
# 踩坑:usage字段在不同版本的SDK中位置可能不同
usage = response.usage
# 提取Prompt预览(只取第一条user消息的前100字)
prompt_preview = ""
for msg in kwargs.get("messages", []):
if msg.get("role") == "user":
content = msg.get("content", "")
if isinstance(content, str):
prompt_preview = content[:100]
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
prompt_preview = block.get("text", "")[:100]
break
break
record = TokenUsageRecord(
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
prompt_preview=prompt_preview
)
self.records.append(record)
return response
def generate_report(self) -> str:
"""生成Token使用分析报告"""
if not self.records:
return "没有记录数据"
input_tokens = [r.input_tokens for r in self.records]
output_tokens = [r.output_tokens for r in self.records]
report = f"""
=== Token使用诊断报告 ===
样本数量:{len(self.records)} 次调用
【输入Token】
- 平均:{statistics.mean(input_tokens):.0f}
- 中位数:{statistics.median(input_tokens):.0f}
- 最大:{max(input_tokens)}
- 最小:{min(input_tokens)}
【输出Token】
- 平均:{statistics.mean(output_tokens):.0f}
- 中位数:{statistics.median(output_tokens):.0f}
- 最大:{max(output_tokens)}
【异常检测】
输入Token > 均值2倍的调用数:{sum(1 for t in input_tokens if t > statistics.mean(input_tokens) * 2)}
(这些调用值得重点检查,可能是Prompt过长或上下文未清理)
输出Token > 1000的调用数:{sum(1 for t in output_tokens if t > 1000)}
(检查是否max_tokens设置过大或输出冗余)
"""
return report
通过诊断,我在实际项目中发现的最常见的三个黑洞:
- 重复的System Prompt:每次调用都传入同一份2000字的System Prompt,占总成本的60%+
- 无限增长的对话历史:多轮对话中,把所有历史消息都塞进去,导致第20轮对话时输入Token是第1轮的20倍
- 冗余的输出:max_tokens设置远大于实际需要,模型输出了大量"请注意..."之类的废话
二、6款工具实测
工具1:Anthropic Prompt Caching(官方缓存)
节省幅度:60%-90%(适用于System Prompt重复场景)
这是我目前见过ROI最高的单一优化手段,没有之一。
原理:把不经常变化的内容(System Prompt、知识库、文档)标记为可缓存,Anthropic服务端会把这部分内容的KV缓存保存一段时间(约5分钟)。缓存命中时,这部分Token的费用降低90%。
python
import anthropic
client = anthropic.Anthropic()
# 这是你的知识库或System Prompt(假设有5000字)
KNOWLEDGE_BASE = """
[你的产品文档、FAQ、规则手册等静态内容...]
这里是5000字的静态内容,每次调用都传入但内容完全不变。
""" * 50 # 模拟长内容
def answer_question_with_caching(user_question: str) -> str:
"""
使用Prompt Caching的正确姿势
踩坑记录1:cache_control必须放在内容的"断点"处,
即你希望缓存截止的位置之后。
把cache_control放错位置会导致缓存失效。
踩坑记录2:内容必须超过1024个Token才能被缓存。
如果你的静态内容比较短,可以考虑把多个静态部分合并。
踩坑记录3:缓存TTL(生存时间)约为5分钟(Anthropic官方说明)。
如果你的调用间隔超过5分钟,缓存会失效,需要重建。
对于高频场景(每分钟多次调用),缓存效益非常高。
"""
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=500,
system=[
{
"type": "text",
"text": KNOWLEDGE_BASE,
"cache_control": {"type": "ephemeral"} # 标记此部分为可缓存
# "ephemeral"是目前唯一支持的类型,代表短期缓存
},
{
"type": "text",
"text": "你是一名专业的客服代表,根据上方知识库回答用户问题,如果知识库没有答案请如实说明。"
# 注意:这部分没有cache_control,所以每次都会重新计算
# 但它很短,成本可以忽略不计
}
],
messages=[{"role": "user", "content": user_question}]
)
# 检查缓存命中情况(调试用)
if hasattr(response.usage, 'cache_read_input_tokens'):
cache_read = response.usage.cache_read_input_tokens
cache_write = getattr(response.usage, 'cache_creation_input_tokens', 0)
regular_input = response.usage.input_tokens
print(f"Token使用:普通输入={regular_input}, 缓存写入={cache_write}, 缓存读取={cache_read}")
# 估算节省的费用
if cache_read > 0:
saved_fraction = cache_read / (cache_read + regular_input + cache_write)
print(f"此次调用Token节省约:{saved_fraction:.1%}")
return response.content[0].text
# 实测数据(我们的客服系统):
# 未启用缓存前:平均每次调用 4500 input tokens
# 启用缓存后(高频时段):平均每次调用 4000 cache_read + 500 real_input
# cache_read的费用是普通input的10%
# 实际降本:(4000 * 0.9) / 4500 ≈ 80% 的成本节省
工具2:LLMLingua(微软开源,Prompt压缩)
节省幅度:40%-70%(适用于长文档检索场景)
LLMLingua是微软研究院开源的Prompt压缩工具,核心思路是:用一个小模型先"预读"你的Prompt,识别并删除对任务没有价值的内容(冗余词、无关句子),然后把压缩后的Prompt发给大模型。
bash
# 安装
pip install llmlingua
python
from llmlingua import PromptCompressor
# 初始化压缩器(第一次运行会下载约1.5GB的小模型,需要稳定网络)
# 踩坑:llm_lingua_model_name指定的模型需要能在本地运行,
# 如果服务器没有GPU,需要改用CPU版本(设置device_map="cpu"),
# 速度会慢3-5倍,但功能正常。
compressor = PromptCompressor(
model_name="microsoft/llmlingua-2-xlm-roberta-large-meetingbank",
use_llmlingua2=True, # 使用第二代,效果更好
device_map="cuda" # 有GPU用cuda,没有用cpu
)
# 示例:压缩一段从RAG检索到的长文档
long_retrieved_context = """
【检索到的文档片段1】
根据我们的服务协议第3.2条款,用户在购买产品后享有7天无理由退款权利。
退款申请需要通过官方App提交,提交后我们的客服团队将在3个工作日内处理。
退款金额将原路退回,信用卡退款通常需要5-7个工作日到账,
支付宝/微信支付退款通常在24小时内到账。
需要注意的是,定制化商品、已拆封的电子产品和食品类商品不在无理由退款范围内。
如果商品存在质量问题,则不受7天限制,我们提供365天的质量问题退换货服务。
【检索到的文档片段2】
关于会员积分:用户每消费1元获得1个积分。
积分可以在结算时抵扣,100积分=1元。
积分有效期为2年,到期未使用自动清零。
会员等级分为:普通会员(0-999积分)、银牌会员(1000-4999积分)、
金牌会员(5000-19999积分)、钻石会员(20000积分以上)。
不同等级享受不同折扣:普通无折扣、银牌95折、金牌9折、钻石88折。
[... 更多检索到的文档片段,总共可能有3000-5000 Token ...]
"""
user_question = "我买的手机想退货,能退吗?"
# 压缩文档
compressed = compressor.compress_prompt(
context=long_retrieved_context.split("\n\n"), # 按段落分割
instruction="回答用户关于退款政策的问题",
question=user_question,
target_token=500, # 目标压缩到500 Token
# 踩坑:target_token只是"目标",不是精确值。
# 如果设得太低,压缩可能损失关键信息,导致回答不准确。
# 建议先设为原始长度的30-40%,测试回答质量后再调整。
condition_compare=True,
iterative_size=200
)
print(f"原始长度:{len(long_retrieved_context.split())} 词")
print(f"压缩后长度:{len(compressed['compressed_prompt'].split())} 词")
print(f"压缩比:{compressed['ratio']:.1%}")
print(f"\n压缩后的Prompt:\n{compressed['compressed_prompt'][:500]}...")
# 然后用压缩后的内容调用大模型
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=300,
messages=[{
"role": "user",
"content": f"基于以下信息回答问题:\n{compressed['compressed_prompt']}\n\n问题:{user_question}"
}]
)
注意事项:LLMLingua的压缩质量与原文的冗余程度强相关。对于已经非常精炼的文本(如法律条文、数学公式),过度压缩可能导致信息失真,建议只对RAG检索到的大段文档使用,不要对关键指令压缩。
工具3:对话历史截断(自研方案)
节省幅度:50%-80%(适用于多轮对话场景)
这是最简单但最容易被忽视的优化。在多轮对话中,把所有历史消息原封不动地传入是最常见的Token浪费。
python
from typing import List, Dict
import tiktoken # OpenAI开源的Token计数库,对大多数模型有参考价值
class SmartContextManager:
"""
智能上下文管理器
为什么不直接用"只保留最近N条消息"的简单截断:
简单截断会切断对话的逻辑连贯性。
比如用户在第1条消息说了"我的订单号是12345",
第10条消息问"帮我查一下状态",
如果截断了第1条,模型不知道订单号是多少。
正确的做法是:保留摘要 + 保留最近几轮完整对话。
"""
def __init__(self, max_tokens: int = 4000, summary_threshold: int = 3000):
self.max_tokens = max_tokens
self.summary_threshold = summary_threshold # 超过此值时触发摘要
self.messages: List[Dict] = []
self.summary: str = "" # 历史对话的压缩摘要
self.client = anthropic.Anthropic()
# 用于估算Token数的编码器(cl100k_base适用于大多数现代模型)
try:
self.encoder = tiktoken.get_encoding("cl100k_base")
except:
self.encoder = None # 如果tiktoken不可用,退化到按字符数估算
def _count_tokens(self, text: str) -> int:
"""估算文本的Token数"""
if self.encoder:
return len(self.encoder.encode(text))
else:
# 粗略估算:中文约1.5字/token,英文约0.75词/token
return max(len(text) // 2, 1)
def _total_context_tokens(self) -> int:
"""计算当前上下文的总Token数"""
total = self._count_tokens(self.summary)
for msg in self.messages:
content = msg.get("content", "")
if isinstance(content, str):
total += self._count_tokens(content)
return total
def add_message(self, role: str, content: str) -> None:
"""添加消息,超出阈值时自动触发摘要"""
self.messages.append({"role": role, "content": content})
if self._total_context_tokens() > self.summary_threshold:
self._compress_old_messages()
def _compress_old_messages(self) -> None:
"""
将旧消息压缩为摘要
策略:保留最近4条消息(2轮对话)的完整内容,
对更早的消息生成摘要,替换原始内容。
为什么保留最近2轮:
用户的最新意图和上下文通常在最近的对话中,
更早的内容大多是历史背景,适合压缩。
"""
if len(self.messages) <= 4:
return # 消息太少,不需要压缩
# 分离旧消息和新消息
old_messages = self.messages[:-4]
recent_messages = self.messages[-4:]
# 为旧消息生成摘要
old_messages_text = "\n".join([
f"{msg['role'].upper()}: {msg['content']}"
for msg in old_messages
])
summary_prompt = f"""
请将以下对话历史压缩成一段简洁的摘要(100字以内)。
摘要应保留:用户的核心需求、已确认的关键信息(如订单号、用户信息)、
已解决和未解决的问题。
对话历史:
{old_messages_text}
摘要(只输出摘要内容,不要加前缀):
"""
response = self.client.messages.create(
model="claude-sonnet-4-6", # 用便宜的模型生成摘要即可
max_tokens=200,
messages=[{"role": "user", "content": summary_prompt}]
)
new_summary = response.content[0].text
# 合并新旧摘要
if self.summary:
self.summary = f"[历史摘要]\n{self.summary}\n\n[新增摘要]\n{new_summary}"
else:
self.summary = new_summary
# 只保留最近4条消息
self.messages = recent_messages
tokens_after = self._total_context_tokens()
print(f"上下文压缩完成,当前约 {tokens_after} Token")
def build_messages_for_api(self) -> List[Dict]:
"""构建发送给API的消息列表"""
if not self.summary:
return self.messages
# 将摘要作为第一条user消息注入
# 注意:这里用了一个小技巧,通过assistant的确认来让模型"接受"摘要
context_messages = [
{
"role": "user",
"content": f"[对话背景摘要,请在回答时参考]\n{self.summary}"
},
{
"role": "assistant",
"content": "好的,我已了解之前的对话背景,请继续。"
}
] + self.messages
return context_messages
# 使用示例
manager = SmartContextManager(max_tokens=4000, summary_threshold=2000)
# 模拟多轮对话
conversations = [
("user", "你好,我的订单号是ORD-2026-78901,想查询一下状态"),
("assistant", "您好!我查到您的订单ORD-2026-78901,目前状态是'已发货',预计明天下午送达。"),
("user", "好的,那如果我想修改收货地址还来得及吗?"),
("assistant", "很抱歉,订单已发货,无法修改收货地址。建议您联系快递员或到附近快递站取件。"),
# ... 更多对话
]
for role, content in conversations:
manager.add_message(role, content)
if role == "user":
messages = manager.build_messages_for_api()
print(f"当前发送给API的消息数:{len(messages)}")
工具4:模型分层路由
节省幅度:40%-60%(适用于混合复杂度的查询场景)
核心思路:不是所有问题都需要最聪明最贵的模型。把查询按复杂度分级,简单问题用便宜模型,复杂问题才动用高级模型。
python
class ModelRouter:
"""
智能模型路由器
成本对比(2026年5月参考价,以每百万Token计):
- Claude Haiku 4.5:输入 ¥0.8 / 输出 ¥4
- Claude Sonnet 4.6:输入 ¥18 / 输出 ¥90
- Claude Opus 4.6:输入 ¥112 / 输出 ¥560
价格差距高达10-140倍,分层路由的节省空间非常大。
踩坑:分类本身也要花Token。
这里的分类器要尽量简单快速,
不然分类费用可能比节省的费用还高。
"""
MODELS = {
"fast": "claude-haiku-4-5-20251001", # 最快最便宜
"standard": "claude-sonnet-4-6", # 性价比最高
"advanced": "claude-opus-4-6" # 最强但最贵
}
def __init__(self):
self.client = anthropic.Anthropic()
self.routing_stats = {"fast": 0, "standard": 0, "advanced": 0}
def classify_query(self, query: str) -> str:
"""
快速分类查询复杂度
这个分类用最便宜的模型,且严格限制输出长度
"""
classification_prompt = f"""
将以下用户查询分类为:fast、standard 或 advanced。
规则:
- fast:简单FAQ、单一事实查询、简短翻译(如"你们几点开门"、"退款要几天")
- standard:需要一定分析的查询、多步骤问题、中等长度的写作任务
- advanced:复杂推理、代码生成、需要深度专业知识、法律/医疗/金融建议
查询:{query}
只输出一个词:fast、standard 或 advanced。
"""
response = self.client.messages.create(
model=self.MODELS["fast"], # 用最便宜的模型做分类
max_tokens=5, # 只需要输出一个词,限制到5个Token
temperature=0,
messages=[{"role": "user", "content": classification_prompt}]
)
level = response.content[0].text.strip().lower()
if level not in ["fast", "standard", "advanced"]:
level = "standard" # 分类失败时默认用标准模型
return level
def route_and_answer(self, query: str, system_prompt: str = "") -> tuple[str, str]:
"""
路由查询并获取答案
返回:(答案, 使用的模型级别)
"""
level = self.classify_query(query)
self.routing_stats[level] += 1
model = self.MODELS[level]
kwargs = {
"model": model,
"max_tokens": 500 if level == "fast" else 1000 if level == "standard" else 2000,
"messages": [{"role": "user", "content": query}]
}
if system_prompt:
kwargs["system"] = system_prompt
response = self.client.messages.create(**kwargs)
return response.content[0].text, level
def get_cost_report(self) -> str:
total = sum(self.routing_stats.values())
if total == 0:
return "暂无数据"
return f"""
路由统计报告:
- Fast(Haiku):{self.routing_stats['fast']} 次({self.routing_stats['fast']/total:.1%})
- Standard(Sonnet):{self.routing_stats['standard']} 次({self.routing_stats['standard']/total:.1%})
- Advanced(Opus):{self.routing_stats['advanced']} 次({self.routing_stats['advanced']/total:.1%})
"""
工具5:输出长度控制
节省幅度:20%-40%(几乎适用于所有场景)
这是最容易实施、最常被忽视的优化。输出Token通常比输入贵3-5倍,而很多系统让模型"自由发挥"输出长度,产生了大量无效的冗余内容。
python
# 常见的输出冗余类型和解决方案
class OutputOptimizer:
def build_concise_system_prompt(self, role: str, task: str) -> str:
"""
在System Prompt中加入明确的输出简洁指令
踩坑:只说"简洁"效果不佳,模型对"简洁"的理解因人而异。
需要给出具体约束:字数限制、禁止特定模式、格式要求。
"""
return f"""
你是{role}。{task}
【输出格式要求 - 严格遵守】
1. 直接回答问题,不要以"好的"、"当然"、"非常感谢您的问题"等开头
2. 不要在答案末尾加"希望这个回答对您有帮助"等套话
3. 如无必要,不使用markdown格式(用户在纯文本环境中)
4. 回答控制在{200}字以内,除非问题本身需要更长的解释
5. 不要重复用户的问题
"""
def add_length_constraint(self, prompt: str, max_chars: int) -> str:
"""在Prompt末尾追加字数约束"""
return f"{prompt}\n\n请将回答控制在{max_chars}字以内。"
工具6:批量处理(Anthropic Batch API)
节省幅度:50%(适用于非实时批量任务)
对于不需要实时响应的任务(如批量数据处理、离线标注、定期报告生成),Anthropic的Batch API提供50%的折扣,且可以同时处理最多10万条请求。
python
import anthropic
import json
client = anthropic.Anthropic()
def process_batch_requests(items: list[dict]) -> list[dict]:
"""
使用Batch API批量处理请求
适用场景:
- 数据集的批量分类/标注
- 定时报告生成
- 离线的文档摘要处理
不适用场景:
- 实时用户对话(Batch API有延迟,通常1-24小时才能完成)
- 需要流式输出的场景
踩坑:Batch API的结果不保证顺序,必须用custom_id来对应请求和结果。
在下面的代码中,我们用商品ID作为custom_id,确保结果能正确对应。
"""
# 构建批量请求
requests = []
for item in items:
requests.append({
"custom_id": f"item_{item['id']}", # 必须唯一,用于结果对应
"params": {
"model": "claude-haiku-4-5-20251001", # Batch通常用便宜模型
"max_tokens": 200,
"messages": [{
"role": "user",
"content": f"为以下商品生成50字营销描述:{item['name']},特点:{item['features']}"
}]
}
})
# 提交批处理任务
batch = client.beta.messages.batches.create(requests=requests)
print(f"批处理任务已提交,ID:{batch.id}")
print(f"任务状态:{batch.processing_status}")
# 轮询等待完成(实际生产中建议用webhook或定时任务,而不是阻塞等待)
import time
while True:
batch = client.beta.messages.batches.retrieve(batch.id)
if batch.processing_status == "ended":
break
print(f"处理中...已完成:{batch.request_counts.succeeded}/{len(requests)}")
time.sleep(30) # 每30秒检查一次
# 获取结果
results = []
for result in client.beta.messages.batches.results(batch.id):
if result.result.type == "succeeded":
results.append({
"id": result.custom_id.replace("item_", ""),
"description": result.result.message.content[0].text
})
else:
print(f"请求 {result.custom_id} 失败:{result.result.error}")
return results
三、组合方案:实测降本80%的完整配置
以下是我们在生产环境中实际部署的组合方案,综合了以上6种工具:
| 优化手段 | 适用场景 | 节省幅度 | 实施难度 |
|---|---|---|---|
| Prompt Caching | System Prompt重复 | 60-90% | 低 |
| 对话历史截断 | 多轮对话 | 50-80% | 中 |
| 模型分层路由 | 混合复杂度查询 | 40-60% | 中 |
| 输出长度控制 | 所有场景 | 20-40% | 低 |
| LLMLingua压缩 | RAG/长文档场景 | 40-70% | 高 |
| Batch API | 离线批量任务 | 50% | 低 |
重要提示:这些手段的节省幅度不是简单相加的,实际组合效果取决于你的具体业务场景和调用模式。建议从"实施难度低"的手段开始,每实施一项都要测量实际效果,而不是一次性全部上。
总结
Token成本优化不是"省小钱",在AI产品规模化的过程中,它往往决定了产品是否能盈利。
从我的实战经验来看,大多数团队在没有做任何优化的情况下,至少有50%的Token是被浪费的。而这些浪费,往往可以通过本文介绍的工具,在不降低用户体验的前提下消除。
最后记住一个原则:先测量,再优化,再验证。不要凭感觉判断哪里浪费最多,用诊断工具找到真正的黑洞,才能把钱花在刀刃上。