ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型调用成本越来越高怎么办?从Token统计到缓存复用,搭建LLM成本管理系统

大模型调用成本越来越高怎么办?从Token统计到缓存复用,搭建LLM成本管理系统

一、引言:Token 账单正在成为新的"云成本黑洞"

过去两年,大部分团队把 LLM 当作一个"调用即付费"的外部 API:模型能力在涨、单价在跌,似乎不需要精打细算。

但真实账单往往给出相反的结论——单 token 价格下降了 90%,总支出却涨了 10 倍。这不是幻觉,而是典型的 Jevons 悖论:成本下降激发了更多场景,而每个场景的 token 消耗量远超预期。

我们经历过一个非常典型的案例:某团队 2023 年用 GPT-3.5 做客服问答,月账单 3000 美元;2024 年迁移到更强也更便宜的新模型,还上线了 RAG 与 Agent 编排,结果月账单直接冲到 40000 美元。模型单价降了,业务量和单次请求的 token 量却涨了二十多倍。成本问题的本质,从来不是单价,而是"消费者是谁、花了多少、值不值"这三个问题无人回答。

成本失控通常来自四个结构性原因:

  1. 输入 token 通胀。RAG 拼接知识库、Agent 携带工具 Schema、多轮对话携带完整历史,一次请求的输入轻松从 500 token 涨到 20 万 token。更隐蔽的是,很多团队每轮对话都把整段知识库原文塞进去,而不是检索后的 Top-K 片段。
  2. Agent 循环放大。一次任务可能触发 10~30 次模型调用,且每次都要重放全部上下文,成本近似于 O(n²) 增长。一个"帮我订机票"的 Agent,如果中间失败重试三次,成本就是单一对话请求的几十倍。
  3. 无法归因。财务只看到一张总账单,研发不知道哪个业务线、哪个用户、哪个 Prompt 版本烧掉了钱。出了异常只能靠猜,优化也无从下手。
  4. 缺乏控制。没有预算熔断,一次死循环重试或一个爬虫式的恶意请求,就能在几小时内刷掉一个月的预算。

真正的问题不在于"模型太贵",而在于缺少一套可观测、可归因、可优化的成本管理系统。本文从 Token 计量这一最小闭环出发,逐层做到缓存复用与预算熔断,给出一套可以在 1~2 周内落地的工程方案。

二、核心原理:成本模型、计量口径与缓存层次

2.1 定价模型:不止是 input/output 两个价格

主流厂商的计费维度已经细化到四类,忽略任何一项都会导致估算失真:

维度典型倍率说明
输入 token1x常规 prompt
输出 token3~5x生成成本最高,是优化重点
缓存写入1.25x前缀缓存首次写入
缓存读取0.1x命中前缀缓存,折扣最大
Batch API0.5x异步批处理,延迟换成本

这里要强调一个经常被忽略的事实:输出 token 的单价是输入的 3~5 倍。这意味着压缩 1000 个输入 token,和让模型少写 250 个输出 token,是等价的收益。很多团队把所有精力花在压缩 prompt(输入),却对输出 token 毫无约束,比如要求模型输出冗长的 JSON、每轮都复述一遍完整推理过程。真正有效的做法是双向优化:输入侧做检索裁剪,输出侧限制max_tokens、约束 JSON Schema、禁止无意义的客套话。

因此单个请求的成本应为:

cost = fresh_input × p_in + cached_input × p_cache_read + cache_write × p_cache_write + output × p_out

举个具体数字:假设gpt-4o输入 2.50 美元/1M、输出 10.00 美元/1M、缓存读取 1.25 美元/1M。一个 10000 token 输入的请求,如果全部命中缓存,输入成本从 0.025 美元降到 0.0125 美元;如果一个对话有 20 轮、每轮重放历史,未命中缓存的输入成本会累积到 0.5 美元——仅仅是输入,就已经超过很多团队的单次预算预期。

还有一个容易被漏算的成本:失败的请求也是要计费的。超时、被限流、返回格式错误导致业务层重试,只要模型产生了 token,就要付钱。我们见过一个团队因为解析 JSON 失败就重试,实际 token 消耗是成功请求的 3 倍。所以计量管道必须把status(成功/失败/重试次数)也记录进去。

2.2 Token 统计的三种口径,以及它们为什么对不上

工程上能拿到 token 数的位置有三处:

  • 本地估算:用tiktoken(OpenAI 系)或官方count_tokens接口(Anthropic 系)在发送前预计算。
  • 响应回传:response.usage字段,是唯一的"权威口径",但只能在请求结束后拿到。
  • 网关计量:在代理层解析请求体与响应体,用于跨厂商统一。

三者对不上是常态,因为:工具调用(tool call)的参数与结果会被重复计入、图片按 patch 折算 token、推理模型的思考 token 通常计入输出、system prompt 与函数定义有额外的封装开销。结论:以usage为账本,以本地估算为预算闸门,两者偏差超过 15% 时告警。

这里有个实战细节值得展开。我们上线计量系统后,发现本地tiktoken估算和厂商usage的偏差在纯文本场景下通常只有 2%~5%,但在下面几种场景会飙到 20% 以上:

  1. 带 tool/function calling 的请求。厂商会在 system 层注入函数定义模板,指令里还有角色包装,本地估算只算了messages,漏掉了这层。
  2. 多模态请求。一张高分辨率图片折算的 token 数取决于厂商的 patch 算法,本地几乎不可能算准。
  3. 推理模型(reasoning model)。思考 token 单独计费,且不计入标准completion_tokens,需要单独读取对应字段。
  4. 流式响应。部分 SDK 在流式模式下最后一个 chunk 才带usage,需要开启stream_options={"include_usage": True},否则永远拿不到准确数字。

所以正确做法是:预算闸门用本地估算(宁可高估,防止超支),账本用厂商usage,两者定期做偏差回归,一旦某模型的偏差持续超过阈值,就说明该模型的计费规则变了,需要更新估算器。

2.3 缓存复用的四个层次

缓存是降本杠杆最大的一环,但需要分层设计:

  • L1 精确缓存:对(model, messages, temperature, top_p)做哈希,命中即返回。零误判,适合事实性问答、SQL 生成等确定性场景。
  • L2 语义缓存:用 embedding 做近邻检索,相似度超过阈值即复用。能吃掉"换个说法问同一件事"的流量,但存在误判风险。
  • L3 前缀缓存(Provider 侧 Prompt Caching):由厂商维护 KV Cache。要求前缀完全逐字节一致,且通常有 1024 token 的最小长度门槛与 5 分钟~1 小时的 TTL。
  • L4 上下文工程:历史摘要、滑动窗口、结构化裁剪。它不叫缓存,但收益往往比前三层加起来还大。

四层的关系可以这样理解:L1/L2 是"业务级缓存",直接替代一次模型调用,收益是 100%;L3 是"引擎级缓存",只降低输入单价到 10%,但胜在无副作用、可普遍适用;L4 是"根治手段",从源头减少要处理的 token。

L2 语义缓存的阈值选择是最大的坑。阈值设 0.95 太严,命中率低;设 0.85 太松,会把"如何退款"和"如何取消订单"混为一谈,造成答非所问。工程上建议:先设 0.92,把每次命中都记录到日志,人工抽样检查 200 条,再逐步放宽。对于金融、医疗等高风险场景,直接放弃 L2,只用 L1+L3。

L3 前缀缓存有一个反直觉的约束:前缀必须从第一个 token 开始连续一致。也就是说,如果你把动态内容(比如时间戳、“当前用户是张三”)放在 prompt 最前面,那么整段前缀就永远无法命中。正确做法是"静态在前、动态在后":system prompt、工具定义、知识库说明放最前,用户的具体问题放最后。这一个排序的改动,在我们一个项目里把前缀缓存命中率从 8% 拉到了 73%。

三、实战:搭建最小可用的 LLM 成本管理系统

整体架构分四层:统一调用入口 → 计量管道 → 存储与看板 → 预算熔断。下面给出关键模块的实现。

3.1 统一入口与 Token 计量

所有模型调用必须经过同一个 Client,禁止业务代码直接import openai。

# cost_tracker.pyimporttime,json,hashlibfromtypingimportCallable# 单价:美元 / 1M tokens,务必用配置文件维护并定期与官方价目表对账PRICING={"gpt-4o":{"in":2.50,"out":10.00,"cache_read":1.25},"gpt-4o-mini":{"in":0.15,"out":0.60,"cache_read":0.075},"claude-sonnet":{"in":3.00,"out":15.00,"cache_write":3.75,"cache_read":0.30},}defestimate_cost(model:str,usage:dict)->float:"""根据一次响应的 usage 计算成本(美元)。 关键点: 1. fresh_input 必须减去 cached,否则命中缓存的部分会被重复计费; 2. cache_write / cache_read 只在支持的厂商存在,用 get 兜底; 3. 单价都是"每 1M token",最后统一除以 1e6。 """p=PRICING[model]in_tok=usage.get("prompt_tokens",0)out_tok=usage.get("completion_tokens",0)cached=(usage.get("prompt_tokens_details")or{}).get("cached_tokens",0)fresh=max(in_tok-cached,0)write=usage.get("cache_creation_input_tokens",0)return(fresh*p["in"]+cached*p.get("cache_read",p["in"])+write*p.get("cache_write",p["in"]*1.25)+out_tok*p["out"])/1_000_000

上面这个函数只解决"算一次成本",但真正要落地的成本系统,还需要把每次调用记录下来、可查询、可按维度聚合。为此我们再加一个薄薄的 Tracker:

importsqlite3classCostTracker:def__init__(self,db_path="cost.db"):self.conn=sqlite3.connect(db_path)self.conn.execute(""" CREATE TABLE IF NOT EXISTS calls ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, model TEXT, tenant TEXT, feature TEXT, prompt_version TEXT, fresh_in INTEGER, cached_in INTEGER, cache_write INTEGER, out INTEGER, cost REAL, latency_ms INTEGER, status TEXT, cache_layer TEXT )""")defrecord(self,*,model,usage,tenant,feature,prompt_version,latency_ms,status="ok",cache_layer=None):cost=estimate_cost(model,usage)self.conn.execute("INSERT INTO calls (ts,model,tenant,feature,prompt_version,""fresh_in,cached_in,cache_write,out,cost,latency_ms,status,""cache_layer) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",(time.time(),model,tenant,feature,prompt_version,usage.get("prompt_tokens",0)-(usage.get("prompt_tokens_details")or{}).get("cached_tokens",0),(usage.get("prompt_tokens_details")or{}).get("cached_tokens",0),usage.get("cache_creation_input_tokens",0),usage.get("completion_tokens",0),cost,latency_ms,status,cache_layer),)self.conn.commit()returncost

这里四个业务标签(tenant、feature、prompt_version、cache_layer)是归因的核心。没有它们,你只能看到总账;有了它们,你才能回答"哪个客户、哪个功能、哪个 prompt 版本最烧钱"这种真正能指导优化的问题。

实战中踩过的坑:早期我们只用feature一个维度,后来发现某些大客户的功能成本高得不正常,加了tenant之后才发现是两个大客户在疯狂调用同一个功能。如果不是按租户拆分,这笔账会一直被平均掉。

3.2 L1 精确缓存与 L2 语义缓存

精确缓存实现简单,用内存 + Redis 两级即可。注意 key 必须包含所有影响输出的参数:

defcache_key(model,messages,temperature,top_p,tools=None):payload=json.dumps({"model":model,"messages":messages,"temperature":temperature,"top_p":top_p,"tools":tools,},sort_keys=True,ensure_ascii=False)returnhashlib.sha256(payload.encode()).hexdigest()

温度必须进 key。一个常见错误是缓存了temperature=0.7的结果,却给temperature=0的确定性请求返回,导致本该稳定的输出变得随机。同理,tools、response_format、max_tokens都要进 key。

语义缓存的实现依赖 embedding + 向量检索,核心是阈值与淘汰策略:

defsemantic_lookup(query_embedding,index,threshold=0.92):# index 通常用 faiss / pgvector / redis-vectorhits=index.search(query_embedding,k=1)ifhitsandhits[0].score>=threshold:returnhits[0].cached_answerreturnNone

语义缓存的三个坑:(1)用错的 embedding 模型会让相似度不可比,必须固定版本;(2)缓存值要考虑时效性,“今天天气"这类问题必须按小时失效;(3)必须加一个"缓存旁路”,当用户明确要求"重新生成"时跳过缓存。

3.3 前缀缓存:排序决定命中率

前缀缓存的收益最大也最容易被浪费。核心原则只有一条:把逐字节稳定的内容放在最前,把动态内容放在最后。对比下面两种写法:

# 错误:动态内容在最前,导致所有请求前缀都不同 "当前时间:2024-06-01 10:23:01。用户问题:如何退款?" "当前时间:2024-06-01 10:23:02。用户问题:如何退款?" # 正确:静态内容(system、工具定义、知识库)在前,动态在后 [system: 你是一个客服助手...(1500 token,稳定)] [tools: 退款/查询/取消的 schema(800 token,稳定)] [user: 当前时间 2024-06-01 10:23:02,问题:如何退款?]

同时要注意,前缀长度要超过厂商的最小门槛(很多是 1024 token 或 2048 token),否则不会触发缓存。如果你的 system prompt 只有 300 token,可以通过补充更完整的角色说明、few-shot 示例来"凑"到门槛以上——这些内容本身也能提升输出质量,一举两得。

3.4 预算熔断:防止一次事故烧掉一个月

预算是最后一道防线,必须在调用前检查、调用后扣减。粒度建议按"租户 + 天"和"全局 + 小时"双维度:

defcheck_budget(tenant,estimated_tokens,model):# 预估本次成本(用本地估算,宁可高估)est_cost=estimate_cost(model,{"prompt_tokens":estimated_tokens["in"],"completion_tokens":estimated_tokens["out"],})ifspend_today(tenant)+est_cost>DAILY_LIMIT[tenant]:raiseBudgetExceeded(f"{tenant}日预算超限")ifspend_last_hour("__global__")+est_cost>GLOBAL_HOURLY_LIMIT:raiseBudgetExceeded("全局小时预算超限")

熔断之上还应有"限速",因为熔断是慢反馈(要等预算接近耗尽),而限速是快反馈。对单个用户的 QPS 做限制,可以拦住爬虫式滥用。我们建议三层防护:QPS 限速(毫秒级)→ 单次max_tokens上限(请求级)→ 日/小时预算熔断(小时后级)。

3.5 看板:只展示能指导决策的四个指标

看板不要做成"财务报表",而要聚焦四个指标:

  1. 成本 / 请求(按 feature 分组):判断哪个功能在恶化。
  2. 缓存命中率(分 L1/L2/L3):判断缓存是否真的在生效。
  3. 平均输出 token 数:输出失控是最常见的隐形浪费。
  4. 失败重试率:重试造成的重复计费往往被忽略。

其中"缓存命中率"和"平均输出 token"是最灵敏的两个先行指标。当它们任一恶化时,账单通常会在一两天后反映出来——这就是可观测性带来的提前量。

四、常见问题 FAQ

Q1:本地估算和厂商 usage 差得太多,怎么办?
先确认是否漏算了 tool schema、图片、system 封装。如果某个模型偏差持续超过 15%,就把该模型的偏差系数固化进估算器(比如估算值 × 1.18),并设置定期回归任务。

Q2:语义缓存会不会导致答非所问?
会。所以高风险场景禁用 L2,一般场景也必须加人工抽检和"重新生成"旁路。永远不要把 L2 用在需要精确数字、法律条款、医疗建议的场景。

Q3:前缀缓存写入了,为什么还是没命中?
90% 的情况是前缀里有动态内容(时间戳、用户 ID、session ID)。剩下 10% 是前缀长度不足门槛,或两次请求之间超过了 TTL。用"前缀 diff"工具打印两次请求的前 N 个 token,一眼就能看出来。

Q4:Batch API 便宜一半,为什么不用?
因为它是异步的,不满足实时交互场景。正确的策略是分流:实时对话走同步 API,离线批处理(日报生成、批量打标、数据清洗)走 Batch API,能省一半。很多团队把所有流量都走同步,白白多花了一倍钱。

Q5:要不要为每个业务线单独设置预算?
要,而且强烈建议。全局预算只能防止"整个公司破产",无法防止"某个功能烧光所有人的配额"。按 feature 设预算,才能在事故初期就精准拦截。

Q6:成本优化会不会牺牲质量?
会,所以每一次优化都要配一个质量回归集。压缩 prompt、减少检索片段、降低max_tokens,都可能让效果下降。没有质量护栏的成本优化是危险的,最终往往会因为返工而花更多钱。

五、踩坑与优化建议

  • 踩坑一:只统计不归因。一张总账单没有任何优化价值。上线第一天就要打上 tenant/feature/version 标签。
  • 踩坑二:缓存 key 漏参数。温度、工具、输出格式任一漏掉都会产生错误复用。建议用"白名单"方式显式列出进 key 的字段。
  • 踩坑三:把动态内容放在 prompt 开头。这一条就让很多团队的前缀缓存命中率停在个位数。
  • 踩坑四:没有输出上限。一个死循环的 Agent 可以无限生成,max_tokens是最便宜的保险。
  • 踩坑五:忽略重试成本。重试要计入成本,且要限制重试次数与指数退避,否则故障期间成本会指数级放大。

优化优先级建议按 ROI 排序:上下文工程(L4)→ 前缀缓存排序(L3)→ 精确缓存(L1)→ 输出约束 → Batch 分流(L2 语义缓存放最后,因为风险最高)。大多数团队做完前两项,成本就能降 40%~60%。

六、总结

大模型成本管理不是一个"省钱技巧",而是一套工程能力:统一入口保证可计量,业务标签保证可归因,分层缓存保证可优化,预算熔断保证可控。这四件事缺一不可。

不要一开始就追求大而全。

更多硬核网安与AI工具包,请扫码获取完整源码!
最务实的路径是:第一周接入统一 Client + usage 计量 + 业务标签,先把账算清楚;第二周加 L1 缓存与前缀缓存排序,通常就能看到明显下降;之后再逐步补齐语义缓存、Batch 分流与预算熔断。先看见,再优化,最后才是控制——顺序错了,任何一步都会变成盲人摸象。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进