成本仪表板常犯两个极端:总成本过高无法行动,或四十个面板无人阅读。推荐五张图覆盖财务、工程、产品三方关切,并以日均趋势为首选视图。
成本仪表盘通常在两个方向上失效:要么只有一个无人能采取行动的总数,要么有四十个无人会看的面板。五个图表,每个回答一个大家真的会在口头上提出的问题,这个数量恰到好处——而且每个图表都是你今天就能执行的查询。
三类受众对同一份数据提出截然不同的问题,如果仪表盘忽略了这种分化,最终哪个都服务不好。财务问的是本月会花多少、为什么与上月不同。工程问的是某个具体改动带来了什么。产品问的是某项功能在当前用户数十倍的情况下是否承担得起。下面这五个图表覆盖了全部三类,大致按这个顺序排列——这也是为什么仪表盘顶部是一条趋势线而不是明细拆分:任何人问的第一个问题都是数字在往哪个方向走,然后才是哪部分在动。
所有查询都针对日志页面的 llm_request 表和按客户追踪的每日汇总。有条通用规则:environment = 'prod' 永远要带上,因为预发和测试环境的花销会污染它触及的每一条趋势。
每日支出,附带当月截至目前的总数和到月底的直线预测。预测值是财务看的面板;每日序列才是让阶梯式变化变得明显的关键。
with daily as (
select started_at::date as day, sum(cost_usd) as spend
from llm_request
where environment = 'prod'
and started_at >= date_trunc('month', now()) - interval '2 months'
group by 1
),
mtd as (
select sum(spend) as spend_mtd,
count(*) as days_elapsed
from daily where day >= date_trunc('month', now())::date
)
select d.day,
d.spend,
avg(d.spend) over (order by d.day rows between 6 preceding and current row)
as spend_7d_avg,
(select round(spend_mtd, 2) from mtd) as mtd,
(select round(spend_mtd / nullif(days_elapsed, 0)
* extract(day from date_trunc('month', now())
+ interval '1 month - 1 day'), 2) from mtd) as projected_month
from daily d
order by d.day;
七日均值的存在是因为大多数 B2B 产品的模型支出有很强的工作日形态,原始每日线会让每个周一看起来像一次故障。把两个序列都留在图表上;它们之间的差距本身就蕴含信息。
按你正在调查的维度堆叠支出——功能、模型或租户。重要的细节是显式的 top-N 加一个 other 桶,因为有六十个序列的堆叠图不是图表而是色盲测试,而且一个在增长的 other 桶本身就是一项发现。
with ranked as (
select feature, sum(cost_usd) as spend,
row_number() over (order by sum(cost_usd) desc) as rn
from llm_request
where environment = 'prod' and started_at >= now() - interval '30 days'
group by 1
)
select started_at::date as day,
coalesce(r.feature, 'other') as bucket,
round(sum(l.cost_usd), 2) as spend
from llm_request l
left join ranked r on r.feature = l.feature and r.rn <= 8
where l.environment = 'prod' and l.started_at >= now() - interval '30 days'
group by 1, 2
order by 1, 3 desc;
把它做成维度作为参数而不是三个独立面板。同一个查询把 served_model 换成 feature 就能回答不同的问题——迁移是否真的转移了流量——换成 tenant_id 就能回答是否某一个客户就等于整个故事。一个查询的三个视图比三组会各自漂移的查询更好。
关于堆叠顺序的一个提示:按当前支出降序排列而不是字母序,这样重要的那条带就在堆叠底部,能清晰读出高度变化。在堆叠图中,只有最底部的序列能准确读取;它上方的所有内容都继承了下层一切的摆动。
这是列表中唯一一个在支出上升时反而会下降的图表,而这恰恰是你最想能够展示的情况。
-- 'outcome' 是你自己的表:每个已完成任务、已解决工单、
-- 已生成文档、已接受建议一行。如果没有,建一个比这页任何
-- 图表都更有价值。
select date_trunc('week', o.completed_at) as wk,
o.outcome_type,
count(*) as outcomes,
round(sum(l.cost_usd), 2) as spend,
round(sum(l.cost_usd) / nullif(count(*), 0), 4) as cost_per_outcome,
round(avg(l.calls_per_outcome), 2) as avg_calls
from outcome o
join lateral (
select sum(cost_usd) as cost_usd, count(*) as calls_per_outcome
from llm_request
where trace_id = o.trace_id and environment = 'prod'
) l on true
where o.completed_at >= now() - interval '12 weeks'
group by 1, 2
order by 1, 2;
avg_calls 列才是解释变化的关键。每成果成本上升有两个原因——模型变贵了,或者你的 agent 开始用更多的轮次来完成——而只有第二个是你能控制的。
如果你没有成果表,这张图就是建一个的理由。它不需要多复杂:每个已完成任务一行,带有类型、时间戳和产生它的 trace id。trace id 是承重列,因为它让你能把多步骤流程中的每个模型调用归因到它所对应的那个成果——这是按请求 join 做不到的,因为一个成果可能涉及一次检索调用、四次 agent 轮次和一次总结。
产生了用户未收到内容的支出。四个类别,而且几乎每个产品的每类都比预期要多。
select date_trunc('week', started_at) as wk,
round(sum(cost_usd) filter (where error_type is not null), 2) as failed,
round(sum(cost_usd) filter (where attempt > 1), 2) as retried,
round(sum(cost_usd) filter (where finish_reason = 'length'), 2) as truncated,
round(sum(cost_usd) filter (where cancelled), 2) as abandoned,
round(sum(cost_usd), 2) as total,
round(100 * sum(cost_usd) filter (
where error_type is not null or attempt > 1
or finish_reason = 'length' or cancelled
) / nullif(sum(cost_usd), 0), 1) as waste_pct
from llm_request
where environment = 'prod' and started_at >= now() - interval '12 weeks'
group by 1 order by 1;
abandoned——用户在长答案还在流式输出时关闭了标签页——需要在请求行上写入取消标志,是最常被完全遗漏的类别。对于任何流式输出长内容的特性,它可能占支出不小的一部分,而且与另外三个不同,它是通过传播取消来修复的,而不是通过改变模型的任何东西。
每请求成本的分布,而不是它的均值。支出通常集中在一小部分非常大的请求的尾部,而平均请求成本完全掩盖了这一点。
with r as (
select cost_usd,
ntile(100) over (order by cost_usd) as pct
from llm_request
where environment = 'prod' and started_at >= now() - interval '7 days'
)
select round(avg(cost_usd) filter (where pct <= 50), 6) as p50_cost,
round(avg(cost_usd) filter (where pct = 95), 6) as p95_cost,
round(avg(cost_usd) filter (where pct = 99), 6) as p99_cost,
round(100 * sum(cost_usd) filter (where pct = 100)
/ nullif(sum(cost_usd), 0), 1) as top1pct_share,
round(100 * sum(cost_usd) filter (where pct > 90)
/ nullif(sum(cost_usd), 0), 1) as top10pct_share
from r;
如果前 1% 的请求占据了大量的支出,你的优化目标就是少数代码路径中的上下文长度或扇出问题,而不是模型选择。如果支出分布均匀,那就是模型价格或用量的问题。两者指向完全不同的工作,而这正是告诉你现在身处哪条路上的图表。
支出是否值得。没有成本图表包含价值。图表 3 最接近,但前提是你的成果表对什么算作成果是诚实的。
你的数字是否正确。这里的每一个数字都依赖于你的定价表。把月度总数与提供商发票核对,并把这种对比也放到仪表盘上——持续的差距意味着费率过时了、某个代码路径遗漏了,或者有密钥在你的客户端之外被使用。
变化来自哪里,仅凭自己。图表 1 和 2 显示有东西变了;但通过版本与成本归因的拆分才能给它命名。
任何你未捕获的维度相关的事。这才是真正要在归因页面而非仪表盘页面上花时间的原因。
上述反复出现的弱点是定价表:每个模型每个提供商的单价,包括缓存输入和推理 token 费率,需要保持最新。Multigrid 在每次请求运行时发布这些费率和价格,这给了核对检查第二个独立数字,而不是用自己的计算结果与自己做对比。