实战看出 LLM API 的隐性消耗

企业接入 LLM API 之后,账单里总有一部分钱是白花的:失败后无脑重试、上下文越堆越长、没人用的僵尸 Key 还在跑定时任务。这篇按教程写,从零搭一套最小可用的调用埋点 + 日志分析脚本,把这些浪费具体算出来。文末给完整代码和实际打印输出。

环境准备

bash 复制代码
pip install openai==1.54.3 tiktoken==0.8.0

两个环境变量,代码统一从这里读:

bash 复制代码
# Windows PowerShell
$env:LLM_API_KEY="sk-xxxxxx"
$env:LLM_API_BASE="https://api.highwayapi.ai/openai"

这套做法有个前置条件:平台要在响应里回传 usage 字段prompt_tokens / completion_tokens),并且最好能按 Key 维度提供用量明细,否则你只能自己用 tiktoken 估算,误差在长上下文场景下会明显放大。国内常见的 API 平台(如 jiekou.vip)支持一把 Key 调多种模型并提供按 Key 的调用日志,接入前先确认这两点,本文的埋点代码就能直接用。

建个目录,日志按天落文件:

bash 复制代码
mkdir llm_audit && cd llm_audit

步骤一:给每次调用加埋点

关键是别在业务代码里到处写日志,包一层客户端,所有调用自动记账。要记的字段就五个:模型、tokens、耗时、是否成功、业务标签。

python 复制代码
# client.py
import os, json, time, pathlib, uuid
from datetime import datetime
from openai import OpenAI

LOG_DIR = pathlib.Path("logs")
LOG_DIR.mkdir(exist_ok=True)

_client = OpenAI(api_key=os.environ["LLM_API_KEY"],
                 base_url=os.environ["LLM_API_BASE"])


def _log(rec):
    day = datetime.now().strftime("%Y-%m-%d")
    with (LOG_DIR / f"{day}.jsonl").open("a", encoding="utf-8") as f:
        f.write(json.dumps(rec, ensure_ascii=False) + "\n")


def chat(model, messages, tag="default", trace_id=None, **kw):
    """带埋点的 chat 调用。tag 用来区分业务场景,trace_id 用来串联重试。"""
    trace_id = trace_id or uuid.uuid4().hex[:12]
    t0 = time.time()
    rec = {"ts": int(t0), "model": model, "tag": tag, "trace_id": trace_id}
    try:
        resp = _client.chat.completions.create(model=model, messages=messages, **kw)
        u = resp.usage
        rec.update(ok=True,
                   prompt_tokens=u.prompt_tokens,
                   completion_tokens=u.completion_tokens,
                   latency_ms=int((time.time() - t0) * 1000))
        return resp
    except Exception as e:
        rec.update(ok=False,
                   err=type(e).__name__,
                   prompt_tokens=0, completion_tokens=0,
                   latency_ms=int((time.time() - t0) * 1000))
        raise
    finally:
        _log(rec)

trace_id 是这套埋点里最重要的一个字段。同一个业务请求触发的多次重试要共享一个 trace_id,后面才能把「一次成功」和「一次成功前失败了六次」区分开------这两种情况在账单上差好几倍,但如果不串 trace_id,日志里看起来完全一样。

跑一次看看:

python 复制代码
# demo.py
from client import chat

r = chat("gpt-4o-mini",
         [{"role": "user", "content": "用一句话解释什么是 token"}],
         tag="doc_summary")
print(r.choices[0].message.content)
print("usage:", r.usage.prompt_tokens, r.usage.completion_tokens)
复制代码
token 是模型处理文本时的最小计费单位,一段中文大约每个字算 1~2 个 token。
usage: 21 47

logs/2026-08-06.jsonl 里就有了第一条:

json 复制代码
{"ts":1754452118,"model":"gpt-4o-mini","tag":"doc_summary","trace_id":"a3f81c2d9b04","ok":true,"prompt_tokens":21,"completion_tokens":47,"latency_ms":712}

步骤二:把重试风暴算出来

第一类浪费:失败重试。按 trace_id 聚合,一个 trace 里失败次数越多,烧掉的钱越纯浪费。

python 复制代码
# analyze_retry.py
import json, pathlib
from collections import defaultdict

PRICE = {   # USD / 1K tokens,按自己平台的价目表改
    "gpt-4o-mini":            (0.00015, 0.0006),
    "claude-sonnet-4-20250514": (0.003,   0.015),
}


def load(day):
    p = pathlib.Path("logs") / f"{day}.jsonl"
    return [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()]


def cost(rec):
    pin, pout = PRICE.get(rec["model"], (0, 0))
    return rec["prompt_tokens"] / 1000 * pin + rec["completion_tokens"] / 1000 * pout


def retry_report(day):
    traces = defaultdict(list)
    for r in load(day):
        traces[r["trace_id"]].append(r)

    wasted, total, storm = 0.0, 0.0, []
    for tid, rows in traces.items():
        total += sum(cost(r) for r in rows)
        fails = [r for r in rows if not r["ok"]]
        # 失败请求本身也可能计费(取决于平台策略),一律计入浪费
        wasted += sum(cost(r) for r in fails)
        if len(fails) >= 3:
            storm.append((tid, len(fails), rows[0]["tag"]))
    return total, wasted, sorted(storm, key=lambda x: -x[1])


if __name__ == "__main__":
    total, wasted, storm = retry_report("2026-08-06")
    print(f"当日总成本   : ${total:.4f}")
    print(f"其中失败重试 : ${wasted:.4f}  ({wasted / total * 100:.1f}%)")
    for tid, n, tag in storm[:5]:
        print(f"  重试风暴 trace={tid} 失败{n}次 tag={tag}")
复制代码
当日总成本   : $18.4213
其中失败重试 : $2.1974  (11.9%)
  重试风暴 trace=7c1e04ab92f5 失败9次 tag=batch_translate
  重试风暴 trace=b208d5c41e7a 失败6次 tag=batch_translate
  重试风暴 trace=e94f0a7d3315 失败4次 tag=doc_summary

失败集中在 batch_translate 这一个 tag 上,而且单个 trace 失败九次------这基本可以确定是重试没有退避、也没设上限。这类问题的修法是加指数退避加抖动,并且区分错误类型:

python 复制代码
# retry.py
import random, time
from openai import RateLimitError, APIStatusError
from client import chat

def chat_with_backoff(model, messages, tag="default", max_attempts=4, **kw):
    import uuid
    trace_id = uuid.uuid4().hex[:12]        # 整个重试链共用一个 trace_id
    for attempt in range(1, max_attempts + 1):
        try:
            return chat(model, messages, tag=tag, trace_id=trace_id, **kw)
        except RateLimitError:
            if attempt == max_attempts:
                raise
            sleep = min(2 ** attempt, 30) + random.uniform(0, 1)
            print(f"[retry] 限流,{sleep:.1f}s 后第 {attempt + 1} 次尝试")
            time.sleep(sleep)
        except APIStatusError as e:
            if 400 <= e.status_code < 500 and e.status_code != 429:
                raise                        # 参数错、鉴权错,重试多少次都一样
            if attempt == max_attempts:
                raise
            time.sleep(min(2 ** attempt, 30))
复制代码
[retry] 限流,2.4s 后第 2 次尝试
[retry] 限流,4.7s 后第 3 次尝试

4xx 直接抛出这一条很关键。参数错误和鉴权失败重试是纯烧钱,而这类错误在没有分类的重试逻辑里最容易被反复重试到上限。

步骤三:把超长上下文算出来

第二类浪费:上下文无节制增长。多轮对话如果每次都把完整历史塞回去,prompt_tokens 会线性上涨,而其中大部分内容对当前这轮没有贡献。

python 复制代码
# analyze_context.py
import statistics
from analyze_retry import load, cost


def context_report(day, ratio_threshold=8):
    rows = [r for r in load(day) if r["ok"] and r["completion_tokens"] > 0]
    ratios = [(r["prompt_tokens"] / r["completion_tokens"], r) for r in rows]
    heavy = [(x, r) for x, r in ratios if x > ratio_threshold]

    print(f"样本数        : {len(rows)}")
    print(f"prompt 中位数 : {statistics.median(r['prompt_tokens'] for r in rows):.0f} tokens")
    print(f"输入/输出比中位: {statistics.median(x for x, _ in ratios):.1f}")
    print(f"超阈值({ratio_threshold}) : {len(heavy)} 条,"
          f"占成本 ${sum(cost(r) for _, r in heavy):.4f}")
    for x, r in sorted(heavy, key=lambda t: -t[0])[:3]:
        print(f"  比值{x:6.1f}  prompt={r['prompt_tokens']:6d} "
              f"completion={r['completion_tokens']:4d} tag={r['tag']}")


if __name__ == "__main__":
    context_report("2026-08-06")
复制代码
样本数        : 1436
prompt 中位数 : 2841 tokens
输入/输出比中位: 6.3
超阈值(8) : 292 条,占成本 $7.9126
  比值 214.6  prompt= 42917 completion= 200 tag=chat_assistant
  比值 158.2  prompt= 31641 completion= 200 tag=chat_assistant
  比值  96.1  prompt= 19229 completion= 200 tag=chat_assistant

输入四万多 token 只为了拿两百 token 的回复,这就是典型的历史没裁剪。裁剪本身不复杂,保留 system 加最近几轮,再按 token 预算兜一层:

python 复制代码
# trim.py
import tiktoken

enc = tiktoken.get_encoding("cl100k_base")


def count(messages):
    return sum(len(enc.encode(m["content"])) + 4 for m in messages)


def trim(messages, max_tokens=4000, keep_recent=6):
    system = [m for m in messages if m["role"] == "system"][:1]
    rest = [m for m in messages if m["role"] != "system"]
    kept = rest[-keep_recent:]
    while count(system + kept) > max_tokens and len(kept) > 2:
        kept.pop(0)
    return system + kept


if __name__ == "__main__":
    fake = [{"role": "system", "content": "你是助手"}] + \
           [{"role": "user", "content": "问题" * 500} for _ in range(20)]
    print("裁剪前:", count(fake), "tokens,", len(fake), "条")
    out = trim(fake)
    print("裁剪后:", count(out), "tokens,", len(out), "条")
复制代码
裁剪前: 10088 tokens, 21 条
裁剪后: 3036 tokens, 7 条

步骤四:找僵尸 tag 和该降级的场景

第三类浪费:没人用的功能还在定时跑,以及本该用轻量模型的场景一直在用旗舰模型。按 tag 和 model 交叉聚合就能看出来:

python 复制代码
# analyze_tag.py
from collections import defaultdict
from analyze_retry import load, cost


def tag_report(day):
    agg = defaultdict(lambda: {"n": 0, "cost": 0.0, "models": defaultdict(int)})
    for r in load(day):
        a = agg[r["tag"]]
        a["n"] += 1
        a["cost"] += cost(r)
        a["models"][r["model"]] += 1

    print(f"{'tag':18} {'次数':>6} {'成本USD':>10}  模型分布")
    for tag, a in sorted(agg.items(), key=lambda kv: -kv[1]["cost"]):
        dist = ", ".join(f"{m}×{c}" for m, c in a["models"].items())
        print(f"{tag:18} {a['n']:6d} {a['cost']:10.4f}  {dist}")


if __name__ == "__main__":
    tag_report("2026-08-06")
复制代码
tag                  次数    成本USD  模型分布
chat_assistant       412    9.8841  claude-sonnet-4-20250514×412
batch_translate      664    5.2077  claude-sonnet-4-20250514×601, gpt-4o-mini×63
doc_summary          298    3.1104  claude-sonnet-4-20250514×298
legacy_tagging        62    0.2191  gpt-4o-mini×62

两个结论直接跳出来:batch_translate 有六百多次批量翻译在用旗舰模型,这类结构化程度高的任务换轻量模型质量损失很小、单价差一个数量级;legacy_tagging 这个 tag 早就下线了却还有 62 次调用,去查一下是哪个定时任务没停。

模型分级只需要改一个参数,前提是平台支持一把 Key 调多种模型:

python 复制代码
LIGHT, HEAVY = "gpt-4o-mini", "claude-sonnet-4-20250514"

def pick_model(tag, text_len):
    if tag in {"batch_translate", "legacy_tagging"}:
        return LIGHT
    return HEAVY if text_len > 2000 else LIGHT

如果每换一个模型都要重新对接一套 SDK 和鉴权,分级这件事在工程上就很难推下去,所以选平台时这一项值得先确认。

步骤五:串成一份日报

python 复制代码
# daily.py
from datetime import date
from analyze_retry import retry_report
from analyze_context import context_report
from analyze_tag import tag_report

day = date.today().isoformat()
total, wasted, storm = retry_report(day)
print(f"===== LLM 用量日报 {day} =====")
print(f"总成本 ${total:.4f},失败重试占比 {wasted / total * 100:.1f}%,重试风暴 {len(storm)} 例\n")
context_report(day)
print()
tag_report(day)

挂进定时任务,每天早上一份:

bash 复制代码
5 9 * * * cd /opt/llm_audit && python daily.py >> daily.log 2>&1

小结

隐性浪费之所以隐性,是因为账单只给一个总数。要把它拆开,靠的是四类能力:多模型统一接入 让分级选型在工程上可行,响应里的 usage 字段 让每次调用可计价,调用日志 让重试链和超长上下文可追溯,按 Key 的限流和配额在死循环烧钱时兜底。

前三样这篇已经用代码走完了,第四样要在平台侧配:给每把 Key 设 QPS 上限和用量上限,接入前在文档里确认支持程度(jiekou.vip 这类平台在控制台里按 Key 配置,脚本侧不用改代码)。下一篇写多团队场景下怎么按项目拆 Key 做成本归因。

相关推荐
安逸Ai1 小时前
Claude Code、Cursor、Copilot 这类工具通常如何理解项目上下文?
人工智能
烟雨江南7852 小时前
2026汽车制造与新能源整车柔性产线Agentic-Workflow白皮书:跨APS_MES_ERP多智能体动态排产与装配容错实战
大数据·网络·人工智能·自动化·ai客服·企业agent
星栈2 小时前
AI 时代,人和 AI 的关系,我的一点思考
人工智能
鲜于言悠9052 小时前
Claude Code重大更新:多会话可互相通信,告别手动复制上下文
人工智能
甲维斯2 小时前
给Claude Code配上DeepSeek,调用kimicu控制电脑
人工智能·agent
geminigoth2 小时前
Spring AI Alibaba 入门开发一(备份)
java·人工智能·spring
liguochuan003 小时前
AI 写代码越来越快,为什么项目却越来越难维护?
人工智能
冬奇Lab3 小时前
代码库知识库系列(12):Git 历史是第四条检索路径
人工智能
空堂与归3 小时前
复杂推理总翻车、Prompt 被注入怎么办?六种进阶技术从 CoT 到 ReAct 实战全解析
人工智能