2026-10-27 · 深度笔记

Token 计量与成本控制:上下文悄悄膨胀的几条路径与堵法

账单突然翻倍不一定是模型涨价了,更可能是上下文悄悄变大了。这篇列出我实测过的六条膨胀路径和对应的堵法,以及怎么在代码里卡住预算。

云主机AI工程成本提示工程

有段时间我的 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,工具一多就爆炸。 优化方式:

五、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% 以上,效果几乎没变。

也就是说,先优化上下文结构,再考虑换模型。前者是数量级的差异,后者是个位数百分比。

我现在保留的几个数

这些是相对我的业务定的,不是通用标准:

指标我的阈值超了怎么办
单次请求输入 token8000硬拦截
单次请求输出 token1500截断
系统提示 token3000CI 拦截
日均 token 总量基线 × 2告警

最后那条"基线 × 2"是我最常用的告警规则。绝对值会随业务波动失真,但相对基线的突变几乎总是出了问题。 上面那次就是它发现的。

各家的 token 计费口径、缓存定价和上下文上限都在调整,代码里的数字要定期核对。

接着读