上个月有个做文档问答服务的同行拿着账单来找我,说他们服务的调用量只涨了三成,费用却翻了将近一倍。我让他把最近一周的请求日志导出来,按模型分组统计输入输出 token,问题五分钟就定位了:他们把一段两千多字的系统提示每次都完整带上,又新接了一个结构化输出功能,输出里的 JSON 字段名冗长,而输出 token 的单价通常比输入高好几倍。这类事在接大模型的团队里太常见,绝大多数人对 Token 计费的理解停留在“字数除以二”,对 tiktoken 这类分词工具的印象也只是“能数个数”。实际上 Token 的计量口径、编码表选择、消息模板开销、缓存命中规则,每一样都会实打实地改变账单。这篇就把 Token 计费与计算这条链路从底到上捋一遍,重点讲 tiktoken 在里面的位置、它能算准到什么程度、算不准的地方该怎么补,适合正在做成本优化或者准备对账的开发者参考。
1. 账单上的数字来自哪里:Token 计量口径拆解
1.1 分词器把文本切成了什么:BPE 的直观理解
要搞清楚计费,得先知道模型的“视角”。模型并不认识字符,也不认识单词,它看到的是一串整数 ID,每个 ID 对应词表里的一个条目。分词器就是做这个映射的东西,主流做法是 BPE(Byte Pair Encoding),思路朴素得很:先把文本按 UTF-8 拆到字节级别的基础单元,然后拿一份训练时统计出来的合并规则表,反复把出现频率高的相邻单元合并成一个新单元,直到没有可合并的为止。
打个比方,这就像快递分拣。单个字节是散件,合并规则是从历史数据里学出来的“这几种散件经常一起出现,打包成一个箱子更省事”。常见的英文单词token会被整体合并成一个条目,tokens可能是另一个条目,tokenization则可能被拆成token+ization两段。中文同理,高频汉字和常见双字词往往已经被合并进词表,生僻字、罕见组合就可能退回两三个字节的粒度。
这件事直接决定了计费的不均匀性。同一段意思,换个说法、换个语序、甚至加个空格,token 数都可能变化。所以“我这篇文章三千字,大概多少 token”这种问法本身只能给出区间,不可能给出精确值。想要精确值,只有真的跑一遍分词器。
1.2 输入、输出、缓存三种口径的价格差在哪
很多人第一次看价目表会愣一下:为什么输入和输出不是一个价?为什么还有个“缓存输入”的价格?这背后是实打实的成本结构差异。
输入是并行的。你给一段两万 token 的上下文,服务端可以一次性把所有位置的前向计算铺开,GPU 利用率高,单位 token 的算力开销相对低。输出是串行的,每生成一个 token 都要完整跑一次前向,还要维护 KV 缓存,而且生成长度不可预知,调度难度大得多。所以输出单价普遍是输入的三到五倍,这个比例在各类价目表里反复出现,不是随便定的。
缓存输入则是另一回事。如果你的请求前缀和之前的某次请求完全一致(系统提示、few-shot 示例、固定的知识片段都属于这类),服务端可以把那部分已经算过的中间状态直接复用,省掉重复的前向计算。省下来的成本让利一部分给用户,就是缓存价,通常是正常输入价的一折到五折。这里有个容易被忽略的点:缓存匹配是严格前缀匹配,前缀里任何一个字符变了,后面的缓存全部失效。所以提示词的排列顺序是有讲究的,稳定的内容放前面,每次变化的用户提问放最后。
我在实际项目里做过一次对照,把系统提示从“每请求拼接时间戳”改成“时间戳放最后”,缓存命中率从接近零跳到七成以上,单月成本降了差不多四成。这个改动的成本几乎为零,但需要你知道缓存的匹配规则才会想到。
1.3 中文、英文、代码、JSON 的 token 密度差异与实测方法
不管理论讲多少,落到预算上都绕不开一个问题:我这段内容到底多少 token。与其背结论,不如自己建一张密度表。方法很简单,准备几段有代表性的样本,跑分词器统计就行。
import tiktoken samples = { "英文散文": "The quick brown fox jumps over the lazy dog. " * 20, "中文散文": "分词器把文本切成子词单元,这一步直接决定了计费结果。" * 20, "Python代码": "def calc(x):\n return [i * 2 for i in x if i > 0]\n" * 20, "JSON数据": '{"user_id": 1024, "status": "active", "tags": ["a", "b"]}\n' * 20, "表格CSV": "id,name,amount,created_at\n1001,alpha,3200,2024-05-01\n" * 20, } for enc_name in ("cl100k_base", "o200k_base"): enc = tiktoken.get_encoding(enc_name) for label, text in samples.items(): n = len(enc.encode(text, disallowed_special=())) print(f"{enc_name:12s} {label:8s} 字符={len(text):6d} token={n:6d} 字符/token={len(text)/n:5.2f}")跑完之后你会得到一张很直观的表,我这边实测出来的典型区间大致是这样:
| 内容类型 | 字符/token 典型区间 | 说明 |
|---|---|---|
| 英文自然语言 | 3.5 ~ 4.5 | 单词边界清晰,合并充分,最“划算” |
| 中文自然语言 | 1.0 ~ 1.8 | 新一代编码表对中文压缩明显更好 |
| 源码 | 2.5 ~ 3.5 | 缩进、括号、下划线会打断合并 |
| 紧凑 JSON | 2.0 ~ 3.0 | 键名重复度高,长键名消耗大 |
| 带缩进的 JSON | 1.5 ~ 2.5 | 空格和换行同样计入 token |
| 随机字符串、ID、哈希 | 0.5 ~ 1.5 | 几乎无法合并,最“贵” |
几个我在实操中反复验证过的结论:中文里夹杂英文术语会让密度变差,因为中英切换处容易产生粒度很细的碎片;代码里长变量名和格式化缩进是隐形消耗,把 JSON 从缩进美化版换成紧凑版,token 常常能省两成;日志、UUID、base64 片段这类内容是最贵的,能不入上下文就别入。
提示:上面这张表只能用来做粗略估算,别写进代码里当常量用。不同模型代际的编码表差异很大,同一段中文在旧编码表和新编码表上能差出三到四成。
2. tiktoken 能干什么、干不了什么
2.1 一个纯本地的 BPE 实现,不联网也能算 token
tiktoken 的核心价值就一句话:把分词这件事放到你自己的机器上做,不消耗任何接口调用。它用 Rust 写核心、提供 Python 绑定,速度非常快,百万级字符的统计在秒级完成,因此可以放进请求链路里做实时预算判断,而不是只能事后统计。
它的基本形态是这样的:
import tiktoken enc = tiktoken.get_encoding("cl100k_base") ids = enc.encode("Token 计费这件事,算清楚比省下来更重要。") print(len(ids)) # token 数量 print(ids[:10]) # 前十个 token 的 ID print(enc.decode(ids)) # 还原成文本这段代码有几个点值得说清楚。get_encoding拿到的是一份编码表对象,里面包含词表和合并规则;encode返回整数列表,长度就是 token 数;decode能把 ID 还原回文本,这个能力在调试时特别有用——你可以把一段文本切出来的每个 token 打印出来看,直观感受哪些地方被切碎了。
我经常用下面这个小工具做排查,它对定位“为什么这段话这么贵”非常有效:
import tiktoken def show_tokens(text, enc_name="cl100k_base"): enc = tiktoken.get_encoding(enc_name) ids = enc.encode(text, disallowed_special=()) for i in ids: print(f"{i:8d} {enc.decode_single_token_bytes(i)!r}") print(f"总计 {len(ids)} 个 token") show_tokens("价格是 $1,299.00,有效期 2024-05-01 至 2024-06-01")数字和日期往往是“碎”得最厉害的地方,1299可能被切成好几段,日期里的连字符单独成 token。看过一次这种切分结果,你对“为什么格式化输出这么贵”就会有肌肉记忆。
2.2 编码表和模型代际的对应关系
tiktoken 库里内置了多套编码表,不同模型家族用不同的表。搞混了就会出现估算偏差,而且偏差幅度不小。
| 编码表名称 | 大致对应的模型代际 | 词表规模量级 | 中文友好度 |
|---|---|---|---|
r50k_base | 早期文本补全类模型 | 5 万 | 差 |
p50k_base | 早期代码类模型 | 5 万 | 差 |
cl100k_base | 第一代对话类、嵌入类模型 | 10 万 | 中等 |
o200k_base | 新一代多模态/推理类模型 | 20 万 | 较好 |
选表有两种方式:按名字直接指定,或者按模型名自动匹配。
enc = tiktoken.get_encoding("o200k_base") # 明确指定 enc = tiktoken.encoding_for_model("gpt-4o") # 按模型名匹配encoding_for_model看着省事,但有个必须处理的问题:它内部维护的是一张映射表,模型名不在表里就会直接抛KeyError。新模型上线、自建模型、第三方兼容接口的名字,都可能不在表里。稳妥的写法是自己包一层兜底:
import tiktoken def get_encoder(model_name: str): try: return tiktoken.encoding_for_model(model_name) except KeyError: # 未知模型统一退化到新一代编码表,误差可控 return tiktoken.get_encoding("o200k_base")这里有个原则要记住:编码表只能用来估算,不能用来断言账单。如果你接入的服务商用的不是同款分词方案(自研词表、不同合并规则的情况很常见),tiktoken 算出来的数只是近似值,偏差可能在一到两成。真正要对账,还是得拿服务端返回的用量字段为准。
2.3 首次运行的词表加载与离线环境处理
这是 tiktoken 最容易绊倒人的地方,我见过不止一个团队在容器里踩到。编码表的合并规则数据是打包成文件分发的,第一次调用get_encoding时,库会去拉取对应的词表文件并缓存到本地。在有外网出口的开发机上,这个过程无感;但在构建镜像的 CI 容器、内网服务器或者断网环境里,就会卡住然后超时失败,报错信息还不一定直观。
处理办法有两个,我在不同项目里都用过。
第一个是预热缓存。在构建镜像的阶段,多跑一步把所有会用到的编码表拉一遍并固化进镜像:
# 放在构建阶段执行一次,把词表文件落到缓存目录 import tiktoken for name in ("cl100k_base", "o200k_base"): tiktoken.get_encoding(name)第二个是指定缓存目录,让缓存落在可持久化的位置,容器重启不至于重来:
export TIKTOKEN_CACHE_DIR=/opt/app/.tiktoken还有一个细节值得注意:如果你的文本里可能出现词表里的特殊标记字符串(比如某些框架约定的控制符),encode默认会检查并抛ValueError: Encountered text corresponding to disallowed special token。这个设计本意是防止提示词注入,但在处理用户上传的原始文本时会误伤。处理方式是显式放宽:
ids = enc.encode(user_text, disallowed_special=())代价是你放弃了这层保护。我的做法是:内部可控文本(模板拼接、系统提示)保持默认严格模式,外部不可信输入走disallowed_special=(),同时在入口做一次控制符过滤。这样既不影响统计,也不至于把风险全放开。
3. 从一行文本到一次完整请求的 token 统计
3.1 单字符串计数的边界在哪
len(enc.encode(text))这一行能解决大部分单段文本的统计需求,但它只覆盖了“纯文本”这一种情况。一旦进入真实请求,token 的构成就复杂多了:角色标记、消息分隔、模板指令、工具定义、图片编码,全都算钱,而且都不会体现在你传给encode的那个字符串里。
我在做第一版成本预估的时候就吃过这个亏。当时按用户提问加系统提示的字符数做了个估算,算出来每次请求平均 1200 token,实际账单出来是 1600 左右,差了三分之一。多出来的部分全是模板开销和消息结构开销。
3.2 消息列表的固定开销与模板差异
对话类请求不是把几段文本简单拼起来,服务端会按模型约定的模板把消息序列化成带角色标记的结构,形如“用户标记 + 内容 + 结束标记 + 助手标记……”。这些标记同样要被分词、同样要计费。每多一条消息,就多一份固定开销。
社区里流传过一组经验公式,大致是每条消息固定加 3 到 4 个 token,整个请求再额外加 3 个 token 的引导开销,消息里带name字段的再加 1 个。我把它写成了一个可复用的函数:
import tiktoken def count_message_tokens(messages, model="gpt-4o", per_message=3, per_name=1, priming=3): enc = get_encoder(model) total = priming for msg in messages: total += per_message for key, value in msg.items(): if value is None: continue total += len(enc.encode(str(value), disallowed_special=())) if key == "name": total += per_name return total msgs = [ {"role": "system", "content": "你是一个严谨的技术文档助手,回答不超过 200 字。"}, {"role": "user", "content": "解释一下 BPE 分词的原理。"}, ] print(count_message_tokens(msgs))必须强调:这几个常数是经验值,不是协议规定。不同模型代际的模板结构不一样,常数会有出入,有些服务商干脆不用固定开销而用变长模板。所以这个函数给你的是一致性估算——同一套规则下比较不同方案谁更省,它非常好用;但别拿它去和服务端返回的精确值硬对,差个几十 token 很正常。我一般的做法是:估算值用于容量规划和方案对比,账单以服务端用量字段为准,两者之间留 10% 到 15% 的缓冲。
3.3 工具定义、图片和结构化输出带来的隐性消耗
三样东西最容易被漏算。
第一是工具定义。函数调用的 schema 是每次请求都要带的,而且往往是一大坨 JSON:
{ "name": "search_documents", "description": "在内部知识库中检索与查询相关的文档片段,返回最相关的前若干条。", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "检索关键词或自然语言问题"}, "top_k": {"type": "integer", "description": "返回条数,建议 3 到 10"} }, "required": ["query"] } }这段看着不长,实测在十万级词表下也要一百多个 token。挂十个工具就是一千多,而且每个请求都带。我见过一个项目挂了二十多个工具,光工具定义就占了总输入的六成。解法是按意图动态注入工具子集,只挂当前场景可能用到的三五个。
第二是图片。多模态输入按图像分块折算 token,分辨率越高、细节档位越高,消耗越大。一张高分辨率图片折算成 token 后,可能顶得上几千字的文本。如果图片只是用来做简单判断,把分辨率降下来往往能省掉大半,效果未必受影响。
第三是结构化输出。要求模型返回严格 JSON,意味着输出里要塞大量花括号、引号、字段名。字段名越长越费钱,因为它们是按原样生成的。把document_relevance_score改成score,单次请求看不多,百万次调用就很可观。另外,把输出格式约定交给专门的约束机制(而不是靠提示词反复叮嘱),能同时减少无效重试和多余的解释性文字,这一块省下来的往往比字段名优化还多。
3.4 长文档分片时的预算怎么分配
做检索增强的时候,大纲是:检索几段文档塞进去,然后让模型回答。这里的 token 预算分配很容易失衡。我的经验比例是先划出硬边界:系统提示 + 工具定义 + 历史对话占一个固定份额(尽量压到总窗口的两成以内),剩下八成分给检索片段和输出,其中输出至少要留出五百到一千 token 的余量,因为回答长度不受你控制。
分片粒度上,我倾向于按语义边界切(段落、标题层级),单一分片控在 300 到 500 token,检索时取 4 到 8 片。片段太碎会丢上下文,太大则挤压输出空间还浪费钱。有个检查方法我一直用:把所有分片拼起来之后,再跑一遍count_message_tokens,看剩下的余量够不够模型的典型输出长度。如果不够,宁可减少分片数,也不要指望模型“自己压缩”,后者往往会触发截断,导致回答半截断掉。
4. 把 token 换算成钱:成本模型与预算控制
4.1 百万 token 单价与单次请求成本
价目表通常以每百万 token 为单位标价,这个单位对单次请求来说太大了,直接把单价乘上去没有实感。换算一下更直观:如果输入价是每百万 token 3 元,那么一千 token 是三厘钱,一次四千 token 输入的请求大概一分二。看起来微不足道,但乘以日活和轮次就完全是另一个数量级——一个日调用十万次、每次输入四千输出的服务,输出单价按输入的十五倍算,一天下来就是几百块的量级。
所以我习惯在评审新功能时先问三个数:单次平均输入 token、单次平均输出 token、日调用量。这三个数一乘,就能判断要不要在设计阶段就做优化。
4.2 一个可复用的成本估算函数
我把计价和统计封在一起,方便做方案对比。价格用相对示例值,实际用的时候换成你手上价目表的数字:
import tiktoken # 单位:每百万 token 的示例价格,仅用于相对比较 PRICING = { "model-large": {"input": 20.0, "cached": 10.0, "output": 80.0}, "model-small": {"input": 1.5, "cached": 0.75, "output": 6.0}, } def estimate_cost(model, messages, expected_output_tokens, cached_prefix_tokens=0, enc_name="o200k_base"): enc = tiktoken.get_encoding(enc_name) input_tokens = 0 for m in messages: input_tokens += 3 for v in m.values(): input_tokens += len(enc.encode(str(v), disallowed_special=())) input_tokens += 3 p = PRICING[model] billable = max(input_tokens - cached_prefix_tokens, 0) cost = billable / 1e6 * p["input"] cost += cached_prefix_tokens / 1e6 * p["cached"] cost += expected_output_tokens / 1e6 * p["output"] return { "input_tokens": input_tokens, "billable_input": billable, "output_tokens": expected_output_tokens, "cost": round(cost, 6), } print(estimate_cost("model-large", msgs, 300, cached_prefix_tokens=800))这个函数最大的用处不是算绝对值,而是让你能在同一个坐标系里比较“加一段 few-shot 示例值不值”“换小模型省多少”“缓存命中后能省多少”。我在做方案评审时会把几套配置并排跑一遍,结果往表格里一放,讨论效率高很多。
| 方案 | 输入 token | 缓存命中 | 输出 token | 单次成本指数 |
|---|---|---|---|---|
| 大模型 + 完整系统提示 | 2400 | 0 | 400 | 100 |
| 大模型 + 完整系统提示 + 缓存 | 2400 | 1800 | 400 | 62 |
| 大模型 + 精简提示 + 缓存 | 1500 | 1200 | 300 | 38 |
| 小模型 + 精简提示 + 缓存 | 1500 | 1200 | 300 | 9 |
4.3 上下文窗口、截断与摘要的取舍
窗口上限不等于应该用满。把窗口塞到接近满载有三个代价:延迟上升、成本上升、以及模型对长上下文中间部分的注意力下降(业内常说的“中间遗忘”现象)。我在做长会话功能时踩过一次:为了保留完整历史,每轮都把全部对话带上,结果到第十五轮左右开始出现答非所问,成本也是线性增长。
后来改成滚动窗口加摘要:保留最近四轮原文,更早的压缩成一段三百字以内的摘要,摘要里只留决策、结论和待办。这个方案实施后,单次请求 token 从均值六千多降到两千出头,回答质量反而更稳。摘要的生成本身也花钱,但摊到每轮只有一两百 token,完全划得来。
截断策略上还有个细节:不要按字符数截断。中文一个字可能一到两个 token,英文一个字符不到半个 token,按字符截会让预算完全失控。正确做法是先分词,再按 token 数切,切的时候尽量在句号或段落边界断开:
def truncate_by_tokens(text, max_tokens, enc_name="o200k_base"): enc = tiktoken.get_encoding(enc_name) ids = enc.encode(text, disallowed_special=()) if len(ids) <= max_tokens: return text cut = enc.decode(ids[:max_tokens]) for sep in ("。", "\n", ";", ". "): pos = cut.rfind(sep) if pos > len(cut) * 0.7: return cut[:pos + len(sep)] return cut4.4 缓存命中对账单的实际影响
缓存只有在满足条件时才生效,条件通常包括:前缀长度达到一个门槛(常见是 1024 token 量级)、前缀内容逐字节一致、以及在一定时间窗口内重复出现。这意味着短提示的缓存意义不大,长系统提示、长知识片段、固定的工具定义才是缓存的主要受益对象。
我在做提示词结构设计时会刻意分成三段:稳定段(角色设定、规则、输出格式约定)、半稳定段(当前业务场景的领域知识)、动态段(用户问题、会话状态)。稳定段放最前面并且一个字都不改,半稳定段跟着,动态段在最后。并且给每段加上版本号注释,改的时候能清楚知道这次改动会击穿哪一层缓存。
有个反直觉的点:为了“让提示词更清晰”而给系统提示加动态的日期、随机 ID、当前用户昵称,会把缓存彻底打死,成本上升的幅度远比那点清晰度带来的收益大。这类动态信息一律挪到最后,或者干脆不放在提示里。
5. 估算值和账单对不上时的排查链路
5.1 第一层:确认编码表有没有选错
对账第一步永远是查编码表。我见过最典型的情况是:服务用的是新一代模型,代码里还在用旧编码表估算,中文内容的偏差能到三成以上,而且偏差方向固定(旧表算出来偏多),看上去像是“服务商多收了”。
排查方法很直接:拿同一段代表性文本,用两套编码表各算一遍,再拿服务端返回的实际用量做比对,看哪一套更接近。如果两套对不上,说明服务端用的可能是自研方案,那就得换思路——只用比例做估算,回去校准一个系数。
我一般的做法是维护一个“校准样本集”:二十条左右覆盖业务主要文本类型的样本,每次换模型、换服务商、换编码表版本时都跑一遍,记录估算值和实际值的比例。这个比例稳定下来之后,估算的可用性就高了。我们这套样本在某个项目里最终标定出的系数是 1.13,用了大半年都很稳。
5.2 第二层:确认服务端有没有追加隐藏内容
估算值明显小于实际值时,先怀疑“有东西你没算”。常见的隐藏来源有四个:服务端在消息模板里追加的默认系统指令、工具定义的序列化开销、多模态输入的分块折算、以及结构化输出模式下的额外约束指令。这些在请求体里看不到,但会计入用量。
定位方法是做对照实验:发一个只有一句“你好”的最小请求,看服务端返回的输入 token 是多少,这个数减去你自己算的,就是固定开销。然后再加一条工具定义、再加一张图,逐项测出差量。这个过程花不了半小时,但能让你后面所有估算都站得住。
5.3 第三层:确认是否有重复提交和重试
估算明明对得上,账单却更高,八成是重试。用户点了两次、前端超时重发、后端自动重试、消息队列重复消费,每一个都会让同一次请求算两遍钱。更麻烦的是流式场景:连接已经建立、模型已经开始生成,客户端断开后服务端通常不会立刻停止,已经生成的 token 照样计费。
我在排查这类问题时必看三个字段:请求唯一 ID、客户端发起时间、服务端完成时间。如果同一个 ID 出现两次,说明上游重复投递;如果时间跨度远大于正常延迟,说明有超时重试。另一个有效手段是给重试加幂等键,让服务端能识别重复请求(部分平台支持),或者干脆在客户端做去重和退避。
5.4 一次完整的对账复盘过程
说个具体的。有个项目月度账单比预估高了 47%,我按下面的顺序查了一遍,两个小时定位完。
第一步,按模型分组统计日志里的prompt_tokens和completion_tokens,算出各模型的占比。发现有个本该只在大模型上跑的链路,三成流量串到了另一个更贵的模型上——这是路由配置的问题,改配置就解决了三分之一。
第二步,对剩下的大模型流量做估算值与实际值比对,比例是 0.94,说明估算方法本身没问题,没吃编码表的亏。
第三步,看重复率。按用户 ID 加分钟级时间窗聚合,发现某个功能模块的重复请求率是 18%,全是前端超时重发。加了请求去重和超时时间调整后,这部分消失。
第四步,最后剩下的部分查缓存命中率,发现只有 12%。原因是系统提示里拼了当前时间戳,前缀每次都不一样。把时间戳挪到最后一行,命中率上到 76%。
四件事加起来,成本降到了原来的六成左右。整个排查过程没有用到任何高深技巧,就是按“编码表 → 隐藏开销 → 重复请求 → 缓存失效”这个顺序逐层剥离,每剥离一层记一次数。我把这套顺序整理成了一张检查表,后面遇到类似问题基本照着走。
| 层级 | 检查项 | 典型症状 | 处理动作 |
|---|---|---|---|
| 1 | 编码表是否匹配模型代际 | 估算偏差固定且方向一致 | 换表并重跑校准样本 |
| 2 | 服务端隐藏追加内容 | 最小请求的 token 远大于文本本身 | 做差量对照实验 |
| 3 | 工具定义与多模态折算 | 挂了工具之后成本陡增 | 动态注入工具子集 |
| 4 | 重复提交与超时重试 | 同一业务请求出现两次用量 | 幂等键 + 退避策略 |
| 5 | 缓存前缀被动态内容击穿 | 长提示但缓存命中极低 | 稳定内容前置、动态内容后置 |
6. 把 token 意识嵌进日常开发流程
6.1 日志里到底该记哪些字段
成本优化做得好不好,取决于你能不能从日志里把账算明白。我的经验是这七个字段必须落库:请求唯一 ID、模型标识、输入 token、输出 token、缓存命中 token、耗时、以及一个业务标签(哪个功能、哪个用户分层)。前六个是技术口径,最后一个是归因口径,没有它你会看到一堆数字但不知道该优化谁。
有了这套日志,很多问题不用等账单。我给项目加过一个简单的日报:按业务标签分组算每日成本和每千次调用成本,任何一个标签的单日成本环比涨超 20% 就告警。这条告警上线后抓到过两次提示词意外膨胀(有一次是有人往系统提示里贴了一大段规则文档),都是当天就修掉了。
另外建议把提示词版本号也记进去。提示词一改,token 结构就变了,有了版本号才能在成本突变时快速定位到是哪次改动。
6.2 提示词瘦身与输出长度约束
提示词瘦身不是简单地删字,而是删掉不产生行为的字。我的做法是先做一轮消融:把自以为重要的规则一条条删掉,跑同一组测试用例,看输出变化。实践里通常能发现有一半的规则写了但模型本来就知道,或者删掉之后表现完全没变化。一次性删掉三成长度、效果不变的情况很常见。
示例(few-shot)是另一个大头。四个示例改两个,往往效果持平;示例里的说明性文字往往可以砍掉,只留输入输出对。结构化输出里字段名精简、约束说明从提示词迁移到格式约束机制,这两项我在多个项目里做过,单项就能省一到两成的输出 token。
输出长度约束要写在提示词的最前面而不是最后面,并且给具体数字而不是“简洁一些”。实测“回答控制在 150 字以内,只给结论和一句理由”比“请简洁回答”平均少四成输出 token,而且因为输出短,延迟也跟着降。输出 token 单价是输入的好几倍,这一块的杠杆最大。
6.3 模型分级与降级路由
最后是最有效的一招:别什么都用最贵的模型。我一般的分层是——意图识别、字段抽取、格式转换、简单分类走小模型;需要多步推理、长上下文理解、复杂生成的走大模型;中间加一层置信度判断,小模型拿不准的升级到大模型。
具体实现上,先用小模型跑一遍并让它输出一个自评置信度(或者用输出格式的合规性作为信号),低于阈值就升级。这里的关键是阈值要拿真实样本调,别拍脑袋。我在一个抽取类任务上测过,小模型能覆盖 78% 的请求,剩下 22% 升级,整体成本降到原来的三成出头,端到端准确率只掉了不到一个百分点。
降级策略也要提前想好:大模型限流或者超时时,能不能降级到小模型并接受略差的结果,还是直接返回错误。这个决定要和产品一起定,不能等到线上出事才临时拍板。
自己踩过几次坑之后的体会是,Token 这件事最怕的不是贵,是不知道为什么贵。tiktoken 给了你一把能精确到单个 token 的尺子,但它量的是文本,量不到模板、量不到重试、量不到缓存规则。真正把成本控住的,是把估算、日志、对账、归因连成一条闭环,让每一个 token 的流向都能被解释。另外再分享一个小技巧:把show_tokens那个小函数做成一个命令行脚本挂在项目里,每次改提示词之前先跑一遍看看切分结果,很多时候你会看着那些被切得粉碎的数字和日期,突然明白钱花在哪了。