有段时间我的 AI 应用调用量没涨,但账单翻了倍。
查模型价格,没变;查调用量也没变——按次数算只涨了 8%。最后按 token 维度拉了张日报,才发现每次请求的输入 token 从 3200 涨到 9600。
排查下来是一个不起眼的改动:有人把检索的 top_k 从 3 改成了 8,chunk 长度也调大了。
这次之后我系统性地找了一遍"上下文膨胀"的路径,列了六条。没一条是用户输入变多——全是系统自己悄悄长胖的。## 先说 token 是怎么算的
不同模型的分词器不一样,同一段文本的 token 数可能差 30% 以上。粗略的换算:
| 文本类型 | 大致比例 |
|---|---|
| 英文 | 1 token ≈ 4 个字符 |
| 中文 | 1 token ≈ 1-1.5 个汉字 |
| 代码 | 1 token ≈ 3 个字符 |
中文是最贵的。 同样的信息量,中文 prompt 的 token 数可能是英文的两倍,这点我一开始完全没意识到。
精确计量要用官方分词器:
import tiktoken
def count_tokens(text: str, model: str) -> int:
try:
enc = tiktoken.encoding_for_model(model)
except KeyError:
enc = tiktoken.get_encoding("cl100k_base")
return len(enc.encode(text))
但要提醒一句:这只是估算值,最终计费会因缓存、批处理、多模态输入有差异,我一般留 10% 余量。
六条膨胀路径
一、检索侧:top_k 和 chunk 长度
最常见,也最容易一次改出三倍成本。
成本 ∝ top_k × chunk 长度
top_k 从 3 改 8、chunk 从 500 改 1000 字符,成本就是 5.3 倍。我现在的做法是在检索层直接限制 token 预算,而不是限制 top_k:
def build_context(chunks, budget_tokens=2000, model="gpt-4o"):
chosen, used = [], 0
for c in chunks: # 已按相关度排序
t = count_tokens(c["text"], model)
if used + t > budget_tokens:
break
chosen.append(c)
used += t
return chosen, used
这样即使有人把 top_k 调大了,预算也不会被突破。
二、聊天历史:只增不减
多轮对话最经典的坑。每轮把完整历史拼上去,历史越长输入 token 就越多——这是个平方增长的账。
第 1 轮 1000 token,第 10 轮 7000,第 20 轮 14000。20 轮的消耗可能是 5 轮的 3 倍多,但只多了一次有用输出。
解决办法是滚动窗口 + 摘要:
def build_messages(history, new_msg, model, keep_recent=6, summary_budget=500):
recent = history[-keep_recent:]
older = history[:-keep_recent]
summary = ""
if older:
# 摘要要缓存,不要每次重算
summary = get_or_make_summary(older, budget=summary_budget)
messages = []
if summary:
messages.append({"role": "system", "content": f"【历史摘要】{summary}"})
messages.extend(recent)
messages.append({"role": "user", "content": new_msg})
return messages
注意摘要要缓存。我最初每轮都把全部历史重新摘要一遍,结果"摘要"本身成了主要成本来源。还有一点:摘要要放在最前面。
三、few-shot 示例:写死了但没人维护
系统提示里的示例是最难清理的地方,因为它"看起来是必要的"。
我见过一个提示词里塞了 8 个完整对话示例,每个 800 token——光示例就 6400 token,比问题还多。
规则是:示例只留最能区分意图的两三个,其余靠更清晰的指令替代。而且要有人定期 review——我给系统提示加了个测试:
def assert_prompt_budget(messages, model, max_tokens=3000):
total = sum(count_tokens(m["content"], model) for m in messages)
assert total <= max_tokens, f"prompt 超预算: {total} > {max_tokens}"
这个断言放在 CI 里。有人往系统提示里加东西,CI 就红。
四、工具定义:schema 里的 description 也在算钱
这是最容易忽略的一条。工具(function calling)的定义是 JSON schema,整个 schema 都会进 prompt。
tools = [{
"type": "function",
"function": {
"name": "search_orders",
"description": "搜索订单,可按订单号、用户名、状态、创建时间筛选。",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单编号"},
"status": {"type": "string", "description": "订单状态"},
},
},
},
}]
description 里每多一个示例说明,就多一份计费。我数了一下,一个工具定义的 schema 平均 200-400 token,工具一多就爆炸。 优化方式:
- 删掉 description 里的例子和客套话
- 少用
enum列举所有取值,改成让模型自由判断 - 合并功能相近的工具,成本能降一半
- 工具数量控制在 10 个以内
五、RAG 返回的原文:塞进去的是全文还是片段
这个坑很隐蔽。检索层返回"文档全文",拼 prompt 时直接贴上去了。哪怕只切中 200 字,贴进去的也是 5000 字。
修法是在检索层就切好 fragment。切片应该发生在入库的时候。
六、重试时把错误和上一次响应也拼回去
自动重试的实现里,很多人会把上次的错误信息和完整输出重新拼进 prompt,让模型"知道错在哪"。
重试一次还好,重试三次就是三倍。而且第二次失败之后的 prompt 里又包含着第一次的 prompt,指数级膨胀。
# 改之前:retry_context 累积
messages = base + [bad_attempt_1, error_1, bad_attempt_2, error_2, ...]
# 改之后:只保留最后一次
messages = base + [last_attempt, last_error]
如果确实需要更丰富的上下文,摘要成两三句话就够了。
成本兜底:必须在代码里卡
上面六条都是"排查出来的"。更根本的做法是让超预算的请求发不出去。
class BudgetError(Exception):
pass
def guard_call(messages, tools=None, model="gpt-4o", max_input=8000,
max_output=1500, daily_cap=None):
# 第一层:单次请求的输入预算
total_in = sum(count_tokens(m["content"], model) for m in messages)
if tools:
total_in += count_tokens(json.dumps(tools), model)
if total_in > max_input:
raise BudgetError(f"输入超预算: {total_in} > {max_input}")
# 第二层:输出上限(max_tokens 本身就是一层防线)
max_out = min(max_output, model_max_output(model))
# 第三层:日累计
if daily_cap and get_daily_spend() > daily_cap:
raise BudgetError("今日预算用尽")
return total_in, max_out
第二层容易被忽略:很多人在调 API 时不设 max_tokens。不设上限意味着一次异常输出(比如模型陷入重复循环)能烧掉几百倍预期成本。
resp = client.chat.completions.create(
model=model,
messages=messages,
max_tokens=max_out, # 必须设
temperature=0.7,
)
我自己的教训是:一次模型异常输出了 8000 token 才停。如果 max_tokens 设成 1500,损失就是 1/5。
缓存:不只是省钱,也是稳定性
提示缓存(prompt caching)的原理是前缀完全相同的部分可以复用,成本能降到几十分之一。
但它有个苛刻条件:前缀必须逐字节一致,所以系统提示最前面不能放动态内容。
# 错:每次都不同,缓存永远不命中
messages = [{"role": "system", "content": f"当前时间:{now()} 用户:{user_id}"}]
# 对:动态内容放后面
messages = [
{"role": "system", "content": STATIC_SYSTEM_PROMPT},
{"role": "system", "content": f"当前时间:{now()} 用户:{user_id}"},
]
但这样一来,动态部分之后的内容就都缓存不到了。取舍很清楚:稳定的放最前面享受缓存,动态的放后面。
一个反直觉的结论
我原本以为成本控制主要是"换个便宜的模型",后来发现便宜的模型往往更费 token——它要更多来回才说得明白。
我做过一次对照:把主模型降一档,同时把 top_k 从 8 调回 3,总成本降了 60% 以上,效果几乎没变。
也就是说,先优化上下文结构,再考虑换模型。前者是数量级的差异,后者是个位数百分比。
我现在保留的几个数
这些是相对我的业务定的,不是通用标准:
| 指标 | 我的阈值 | 超了怎么办 |
|---|---|---|
| 单次请求输入 token | 8000 | 硬拦截 |
| 单次请求输出 token | 1500 | 截断 |
| 系统提示 token | 3000 | CI 拦截 |
| 日均 token 总量 | 基线 × 2 | 告警 |
最后那条"基线 × 2"是我最常用的告警规则。绝对值会随业务波动失真,但相对基线的突变几乎总是出了问题。 上面那次就是它发现的。
各家的 token 计费口径、缓存定价和上下文上限都在调整,代码里的数字要定期核对。