1. 从一张说不清的账单说起
上个月有个做 Agent 产品的朋友找我,说他们团队这个月的模型账单比上个月多了将近一倍,但谁也说不清钱花在哪了。产品说是研发在疯狂调试,研发说是线上流量涨了,运维说调用量曲线看着挺平稳。三方各执一词,最后只能拍脑袋决定"下个月省着点用"。
这个场景我太熟了。只要你的系统里跑着 Agent,只要它背后挂着大模型 API,账单失控几乎是迟早的事。因为 Agent 和传统的接口调用完全不是一个物种——一次用户请求可能触发十几轮模型调用,每一轮带着不同的上下文长度,中间还夹着工具调用、重试、反思循环。你看到的"一次对话",在账单上可能是几十次 Token 消耗。
所以问题从来不是"账单为什么涨",而是"账单涨在了哪一类活上"。这两者的区别,就像你只知道这个月伙食费超支了,和你知道超支是因为天天点外卖还是因为买了太多零食——后者才能指导你行动。
这篇东西就是围绕这个痛点展开的。我会讲清楚怎么用一套叫洞察台(内部代号 Caravel)的可观测方案,把 Agent 的模型消耗拆解到"哪类任务最费 Token"这个粒度。适合正在做 Agent 开发、被账单困扰、又不想盲目砍功能的同学。全文基于我自己的实操经验,涉及参数和配置的地方我会把计算过程写清楚,你可以直接抄。
2. 为什么 Agent 的账单天生就难算
2.1 Agent 和普通调用的本质差异
普通的大模型调用很好算账:一次请求,输入多少 Token,输出多少 Token,乘上单价就完事。你甚至能在网关层做个简单的计数就搞定。
Agent 不行。一个典型的 Agent 执行链路是这样的:用户提问 → 规划(一次模型调用)→ 决定调用工具 → 工具返回结果 → 把结果塞回上下文再问模型 → 模型可能觉得信息不够再调一次工具 → 最后生成回答。这中间每一次"问模型"都是一次独立的计费事件,而且每次的上下文都在膨胀,因为历史消息、工具返回、中间推理都要带上。
我实测过一个简单的"查天气并推荐穿搭"的 Agent,用户就问了一句话,背后跑了 7 次模型调用,Token 消耗是单次问答的 11 倍。如果这个 Agent 还带反思机制(让模型检查自己的答案),轻松翻到 20 倍。
2.2 账单失真的三个典型来源
第一个来源是上下文重复计费。Agent 每一轮都要把完整历史重新发一遍,这意味着同一段对话内容会被反复计费。一个 10 轮的 Agent 任务,第一轮的内容可能被计费了 10 次。很多人第一次看到这个真相都会愣一下。
第二个来源是隐式重试。模型超时、返回格式不对、工具调用失败,框架层往往会自动重试。这些重试在业务日志里可能只记了一条"成功",但账单上每一次都算钱。
第三个来源是任务类型混杂。你的系统里可能同时跑着意图识别、内容生成、代码补全、数据抽取好几类活,它们对 Token 的消耗模式完全不同。如果只统计总量,你永远不知道是哪一类在吃钱。
提示:如果你现在的监控只能看到"总 Token 数"和"总调用次数",那你其实处于"账单盲区"。这两个指标对优化几乎没有指导意义。
2.3 洞察台要解决的核心问题
洞察台(Caravel)的设计目标很明确:把每一次模型调用都打上足够的标签,让你能按任务类型、Agent 链路、工具调用、用户会话等多个维度去聚合消耗。Caravel 这个词本身是"轻快帆船"的意思,取的就是"在数据的海洋里灵活穿行"的意象。
它要回答的问题就三个:哪类活最费?为什么费?能不能省?下面我按搭建顺序一步步讲。
3. 洞察台的整体设计思路
3.1 核心思路:调用即事件
整个方案的地基是一个很朴素的想法——把每一次模型调用当成一个事件来记录。这个事件里要带上足够多的上下文标签,让后续分析能切片。
具体来说,每次调用发生时,我们记录这些字段:
| 字段名 | 含义 | 示例 |
|---|---|---|
| trace_id | 一次用户请求的全局链路 ID | tr_8f3a2b |
| span_id | 链路内单次调用的 ID | sp_001 |
| task_type | 任务类型标签 | intent_recognize |
| agent_name | 哪个 Agent 发起的 | shopping_assistant |
| model | 模型名 | gpt-4o |
| prompt_tokens | 输入 Token | 3421 |
| completion_tokens | 输出 Token | 512 |
| tool_calls | 本轮触发的工具数 | 2 |
| retry_count | 重试次数 | 0 |
| latency_ms | 耗时 | 2340 |
| timestamp | 时间戳 | 1717000000 |
这张表是整个洞察台的核心。你会发现它比普通的调用日志多了task_type、agent_name、tool_calls、retry_count这几个关键标签——正是这几个字段让"哪类活最费"这个问题变得可回答。
3.2 为什么选事件流而不是直接查数据库
有人会问,为什么不直接在业务代码里写个计数器,按任务类型累加不就行了?
我试过,不行。原因有三个。第一,累加会丢失明细,你没法回溯"某个具体会话为什么这么贵"。第二,Agent 的调用是异步并发的,累加器会有竞态问题。第三,你迟早会想按新的维度切分,而累加器一旦写死维度就改不动了。
事件流的思路是:先无脑记录明细,分析的时候再聚合。存储成本确实高一些,但换来的是分析自由度。Caravel 用的是"明细落盘 + 定时聚合"的两层结构,明细保留 30 天,聚合结果长期保留。
3.3 标签体系怎么设计才不返工
标签体系是这套方案里最容易返工的地方,我踩过坑,这里重点说。
task_type这个标签一定要在业务语义层定义,而不是在技术层。什么意思?不要用"chat_completion"这种技术动作当标签,要用"意图识别""商品推荐""售后问答"这种业务动作。因为老板问的是"哪类活费钱",他关心的是业务,不是技术。
我的做法是维护一张任务类型字典表,每个 Agent 在发起调用时显式传入任务类型。如果传了未知类型,就落到unknown桶里,然后定期 review 这个桶,把新出现的类型补进字典。
# 任务类型字典示例 TASK_TYPES = { "intent_recognize": "意图识别", "product_recommend": "商品推荐", "after_sale_qa": "售后问答", "content_generate": "内容生成", "code_complete": "代码补全", "data_extract": "数据抽取", "reflection": "反思校验", }注意:
reflection(反思校验)这类"内部动作"一定要单独打标。很多团队发现账单异常,最后查出来是反思循环失控——模型反复自我检查,一轮任务跑了二十几次反思调用。
4. 核心细节解析与实操要点
4.1 埋点位置的选择:网关层还是 SDK 层
埋点位置决定了你能拿到多少信息。两个选择:网关层和 SDK 层。
网关层埋点的好处是统一、无侵入,所有调用都经过它。坏处是它拿不到业务语义——网关不知道这次调用是"意图识别"还是"商品推荐",它只看到一次 HTTP 请求。
SDK 层埋点的好处是能拿到完整的业务上下文,任务类型、Agent 名称、工具调用都能带上。坏处是需要改代码,而且如果团队用了多种语言,SDK 要维护多份。
我的建议是两层都埋,网关层兜底,SDK 层补充。网关层保证不漏记(哪怕 SDK 忘了传标签,至少 Token 数是对的),SDK 层负责打业务标签。两边用同一个trace_id关联,分析时以 SDK 层为准,网关层做校验。
4.2 Token 计数的准确性陷阱
Token 计数看着简单,其实坑不少。
第一个坑是不同模型的 Tokenizer 不一样。你不能用 GPT 的 tokenizer 去估算 Claude 的 Token 数,误差能到 20% 以上。正确做法是优先用 API 返回的usage字段,那是官方计费口径,最准。
第二个坑是流式响应的 usage 可能拿不到。有些 API 在流式模式下不返回 usage,或者只在最后一个 chunk 返回。这时候你得自己用对应模型的 tokenizer 估算,并且明确标注这是"估算值"。
第三个坑是缓存命中的 Token 计费不同。现在很多模型对缓存命中的输入 Token 有折扣,如果你不区分"缓存命中"和"未命中",算出来的成本会偏高。Caravel 里我专门加了cached_tokens字段来区分。
# 成本计算示例,注意缓存折扣 def calc_cost(usage, model_pricing): normal_input = usage["prompt_tokens"] - usage.get("cached_tokens", 0) cached_input = usage.get("cached_tokens", 0) cost = ( normal_input * model_pricing["input"] + cached_input * model_pricing["input_cached"] + usage["completion_tokens"] * model_pricing["output"] ) return cost4.3 链路追踪的 ID 传递
Agent 的调用链路可能跨多个服务、多个进程,trace_id必须能一路传下去。我用的是标准的 W3C Trace Context 格式,在 HTTP header 里带traceparent。
难点在于工具调用。当 Agent 调用一个外部工具,工具内部又调了模型,这个trace_id要能续上。我的做法是在工具调用的入参里显式塞一个_trace_context字段,工具实现方负责把它透传给下游。
如果工具是第三方不可控的,那就只能断链,这时候用parent_span_id记录"我调用了这个工具",工具内部的调用单独成链,分析时通过时间窗口做近似关联。
4.4 采样策略:全量还是抽样
全量记录明细,存储压力确实大。一个中等规模的 Agent 系统,一天几十万次调用很正常,每次调用一条明细,一个月就是千万级。
我的策略是分层采样:错误调用、高消耗调用(超过阈值)、新任务类型,这三类全量记录;普通调用按 10% 采样。这样既保证了异常可追溯,又控制了存储成本。
阈值怎么定?我按 P95 消耗来定。先跑一周全量,统计出各类任务的 P95 Token 消耗,然后把这个值作为"高消耗"的阈值。低于阈值的采样,高于阈值的全记。
5. 实操过程与核心环节实现
5.1 环境准备与依赖
Caravel 我用的是一套轻量组合:采集端用 Python SDK,存储用 ClickHouse(列式存储,聚合查询快),可视化用 Grafana。整套下来单机就能跑,不需要复杂的集群。
# 依赖清单 pip install clickhouse-driver pip install tiktoken pip install opentelemetry-api pip install opentelemetry-sdkClickHouse 建表语句,重点是标签字段都建成低基数的 String,方便做 group by:
CREATE TABLE model_calls ( trace_id String, span_id String, task_type LowCardinality(String), agent_name LowCardinality(String), model LowCardinality(String), prompt_tokens UInt32, completion_tokens UInt32, cached_tokens UInt32, tool_calls UInt8, retry_count UInt8, latency_ms UInt32, cost Decimal(10, 6), ts DateTime ) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(ts) ORDER BY (task_type, ts);LowCardinality这个类型很关键,它把重复的字符串做了字典编码,存储能省一大半,查询还更快。ORDER BY (task_type, ts)是为了让按任务类型聚合的查询走索引。
5.2 采集 SDK 的核心实现
SDK 的核心是一个装饰器,包住模型调用函数,自动记录前后状态。
import time import uuid from functools import wraps def track_model_call(task_type, agent_name): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): span_id = f"sp_{uuid.uuid4().hex[:8]}" start = time.time() retry = 0 while True: try: response = func(*args, **kwargs) break except Exception as e: retry += 1 if retry > 3: raise latency = int((time.time() - start) * 1000) usage = response.get("usage", {}) record = { "span_id": span_id, "task_type": task_type, "agent_name": agent_name, "model": response.get("model"), "prompt_tokens": usage.get("prompt_tokens", 0), "completion_tokens": usage.get("completion_tokens", 0), "cached_tokens": usage.get("cached_tokens", 0), "retry_count": retry, "latency_ms": latency, "ts": int(time.time()), } enqueue(record) # 异步写入队列 return response return wrapper return decorator这里有个细节:enqueue是异步的,不能阻塞主流程。我用的是一个内存队列加后台线程批量写入,每 100 条或每 2 秒刷一次盘。这样即使 ClickHouse 短暂不可用,也不会影响业务。
5.3 成本计算的参数选择
成本计算要维护一张模型价格表。这里我建议把价格表做成配置,不要硬编码,因为模型价格变动很频繁。
MODEL_PRICING = { "gpt-4o": {"input": 2.5e-6, "input_cached": 1.25e-6, "output": 1e-5}, "gpt-4o-mini": {"input": 1.5e-7, "input_cached": 7.5e-8, "output": 6e-7}, "claude-3-5-sonnet": {"input": 3e-6, "input_cached": 3e-7, "output": 1.5e-5}, }单位是"每 Token 美元"。注意input_cached通常比input便宜一半甚至更多,这个折扣一定要算进去,否则你会高估成本,做出错误的优化决策。
我实测过一个案例:某团队以为自己的缓存没生效,因为成本一直很高。后来把cached_tokens单独统计出来,发现缓存命中率其实有 60%,只是他们之前没区分,把缓存命中的 Token 也按原价算了。
5.4 聚合查询:回答"哪类活最费"
数据落盘后,核心查询就一条 SQL:
SELECT task_type, count() AS call_count, sum(prompt_tokens + completion_tokens) AS total_tokens, sum(cost) AS total_cost, avg(cost) AS avg_cost_per_call, sum(retry_count) AS total_retries FROM model_calls WHERE ts >= now() - INTERVAL 7 DAY GROUP BY task_type ORDER BY total_cost DESC;这条查询直接告诉你:过去 7 天,哪类任务总成本最高,平均每次调用多少钱,重试了多少次。
但光看总成本还不够,因为高总成本可能只是因为调用量大,不代表"费"。我还会算一个单位成本指标——每完成一个业务动作的成本。比如"每处理一个售后问题花多少钱",这个指标才能反映效率。
-- 单位业务成本 SELECT task_type, sum(cost) / count(DISTINCT trace_id) AS cost_per_task FROM model_calls WHERE ts >= now() - INTERVAL 7 DAY GROUP BY task_type ORDER BY cost_per_task DESC;5.5 可视化看板的搭建
Grafana 里我搭了四块面板,从粗到细层层下钻。
第一块是总览:总成本、总 Token、总调用数、平均延迟,四个大数字,一眼看全局。
第二块是任务类型排行:横向柱状图,按总成本排序,一眼看出哪类活最费。
第三块是成本趋势:折线图,按天展示各类任务的成本变化,用来发现异常增长。
第四块是下钻明细:点某个任务类型,展开看它的调用链路,找出最贵的那些具体会话。
提示:第三块趋势图是发现问题的关键。账单突然涨,一定是某条曲线突然翘头。我遇到过一次,是"反思校验"这条线三天内涨了 5 倍,查下去发现是新上线的 Agent 反思逻辑写了个死循环。
6. 常见问题与排查技巧实录
6.1 账单涨了但调用量没涨,怎么查
这是最典型的情况。调用量平稳但成本上升,通常有三个原因。
第一是上下文变长。可能是某个功能上线后,往 prompt 里塞了更多历史消息或知识库内容。查法:对比prompt_tokens的日均值,看是不是涨了。
第二是模型被换了。可能某次发布把便宜的模型换成了贵的。查法:按model字段分组看成本占比变化。
第三是缓存失效。缓存命中率下降,同样的调用变贵了。查法:算cached_tokens / prompt_tokens的比值趋势。
我整理了一张速查表:
| 现象 | 可能原因 | 排查字段 |
|---|---|---|
| 调用量平、成本涨 | 上下文变长 | prompt_tokens 均值 |
| 调用量平、成本涨 | 模型被换 | model 分组占比 |
| 调用量平、成本涨 | 缓存失效 | cached_tokens 比值 |
| 调用量涨、成本涨更多 | 重试增多 | retry_count 总和 |
| 某类任务成本突增 | 逻辑死循环 | 单 trace 的 span 数 |
6.2 单次会话成本异常高怎么定位
有时候总量正常,但个别会话贵得离谱。这时候要按trace_id聚合,找出 span 数最多的那些会话。
SELECT trace_id, count() AS span_count, sum(cost) AS session_cost FROM model_calls WHERE ts >= now() - INTERVAL 1 DAY GROUP BY trace_id ORDER BY session_cost DESC LIMIT 20;拿到最贵的 trace_id 后,把它的所有 span 按时间排序拉出来,就能看到完整的执行链路。我一般会重点看两个地方:有没有同一个工具被反复调用,有没有反思循环停不下来。
6.3 埋点数据对不上账怎么办
偶尔会遇到自己统计的成本和官方账单对不上。差异通常在 5% 以内是正常的(时区、汇率、计费口径差异),超过 10% 就要查。
常见原因:一是漏记了某些调用路径(比如某个服务没接 SDK),二是 Token 估算不准(流式响应没拿到 usage),三是价格表过期了。
我的做法是每周做一次对账,把官方账单和 Caravel 统计拉出来对比,差异超过阈值就告警。这个习惯帮我抓到过好几次漏埋点的问题。
6.4 几个我踩过的坑
第一个坑:忘了给异步调用打标。Agent 里有些调用是在后台线程发起的,如果 SDK 的上下文传递没做好,这些调用会落到unknown桶里。我后来强制要求所有调用必须显式传task_type,不传就抛异常。
第二个坑:重试计数算重了。一开始我在装饰器里用局部变量记重试,结果嵌套调用时计数串了。后来改成用 contextvar 传递,才准确。
第三个坑:时区没统一。ClickHouse 存的是 UTC,Grafana 显示的是本地时间,对账时差了几个小时,白白排查了半天。现在所有时间戳统一存 UTC,展示层再转换。
7. 优化动作:从洞察到省钱
7.1 按任务类型做差异化策略
有了洞察数据,优化就有了靶子。我的经验是,不同任务类型要用不同的省钱策略。
对于高频低价值的任务(比如意图识别),果断换小模型。这类任务通常输出很短,小模型完全够用,成本能降 90% 以上。
对于低频高价值的任务(比如复杂推理),保留大模型,但优化 prompt,减少不必要的上下文。
对于反思校验这类内部动作,设置硬性上限。比如最多反思 2 轮,超过就强制结束。这一条帮我省过一大笔钱。
7.2 上下文压缩的实操
上下文压缩是省钱的大头。我的做法是分层:最近 3 轮对话保留原文,更早的对话做摘要,工具返回结果只保留关键字段。
摘要本身也要花 Token,所以要算账。如果一段历史原文是 2000 Token,摘要成 200 Token,但摘要调用本身花了 300 Token,那只有在这段历史会被引用 2 次以上时才划算。这个账要算清楚,别为了省而省。
7.3 缓存策略的调整
缓存命中能省一半以上的输入成本,值得投入。关键是设计好缓存 key。我的做法是把 system prompt 和固定的 few-shot 示例放在最前面,这部分内容稳定,容易命中缓存。变化的部分放后面。
实测下来,把稳定内容前置后,缓存命中率从 20% 提到了 65%,输入成本直接砍半。
8. 我个人的一点体会
这套洞察台搭下来,最大的价值不是省了多少钱,而是让团队对"钱花在哪"有了共识。以前开会讨论成本,大家各说各的;现在打开看板,哪类活费钱一目了然,讨论直接进入"怎么优化"的环节。
如果你现在还在用"总 Token 数"这种粗粒度指标管成本,我建议先花两天把埋点做起来。不用一步到位,先把task_type和trace_id这两个标签打上,你就能回答 80% 的问题了。剩下的维度可以慢慢加。
最后分享一个小技巧:给成本设个日环比告警,涨幅超过 30% 就通知。这个简单的告警帮我提前发现过好几次问题,比月底看账单再补救强太多。