你有没有遇到过:月底收到账单才发现某个 Agent 在无限循环调用 API,两天烧掉了一个月的预算?本文从实际工程角度,讲清楚如何在代码层面检测 LLM 成本异常,并在它失控之前触发告警。
问题比你想的更普遍
LLM API 的计费有个根本性的不对称:调用前你不知道要花多少钱。
传统 API 调用费用相对固定------每次查询数据库,每次 HTTP 请求,成本是可预期的。但大模型 API 不一样:同样是一次 /v1/chat/completions 请求,根据 prompt 长度、system message 复杂度、模型输出长度,成本可以差几十倍。
更危险的是,这些"成本炸弹"往往被几类场景引爆:
场景一:Agent 工具调用循环。某个 Agent 因为工具返回格式异常,进入了"调用工具 → 报错 → 重试 → 调用工具"的无限循环。每次循环消耗 2000 token,5 分钟内把当天预算用完。
场景二:Prompt 上下文膨胀。对话历史被无限追加,没有做窗口截断。用户聊到第 50 轮,system prompt + history 已经有 30,000 token,每轮成本是第 1 轮的 15 倍。
场景三:多租户"噪音租户"。SaaS 产品里,某个租户账号被爬虫滥用,或者客户员工测试时写了个并发脚本,每分钟发出 500 次请求。你的总账单暴涨,但在混合账单里这个信号被其他租户的正常用量淹没了。
场景四:max_tokens 未设置 。模型输出了 4096 token 的长篇大论,而实际上你只需要一个 JSON 片段。没有 max_tokens 限制就像出门没带钱包上限------花多少算多少。
各大模型厂商的官方 Dashboard 都有日级别的用量汇聚视图,也支持设置账单阈值通知。但这两者有个共同盲点:粒度是天级,到账单通知时已经太晚。工程师需要的是分钟级、feature 级、tenant 级的实时成本信号。
成本异常检测的三层架构
从工程角度,LLM 成本监控可以分三层,选哪层取决于你的规模和复杂度。
yaml
┌─────────────────────────────────────────────────────┐
│ Layer 3: 平台级 (Usage API + 定时拉取) │
│ 适合: 小团队,直接用模型平台 Projects 分账 │
├─────────────────────────────────────────────────────┤
│ Layer 2: 应用级 (SDK 包装 + 本地存储) │
│ 适合: 单体应用,轻量监控,不引入额外基础设施 │
├─────────────────────────────────────────────────────┤
│ Layer 1: 网关级 (LLM Proxy + Prometheus + Grafana) │
│ 适合: 微服务、多团队、需要统一观测面板 │
└─────────────────────────────────────────────────────┘
这三层并不互斥,实际上很多团队会同时运行 Layer 1 + Layer 3:网关提供实时信号,Usage API 提供账单对账。
Layer 1:网关级监控(Prometheus + Grafana)
这是最重的方案,也是最完整的方案。核心思路是在应用代码和 LLM API 之间插入一个轻量代理,拦截所有请求和响应,提取 usage 信息,推送到 Prometheus。
代理实现
下面是一个最小化的 Python 实现,基于 httpx 做透明代理:
python
# llm_gateway.py
import time
import asyncio
import httpx
from prometheus_client import Counter, Histogram, start_http_server
# Prometheus metrics
TOKEN_TOTAL = Counter(
"llm_tokens_total",
"Total tokens consumed",
["model", "tenant_id", "feature", "token_type"]
)
COST_TOTAL = Counter(
"llm_cost_usd_total",
"Total estimated cost in USD",
["model", "tenant_id", "feature"]
)
REQUEST_DURATION = Histogram(
"llm_request_duration_seconds",
"LLM API request latency",
["model", "tenant_id"]
)
# 简化价格表 (2026 Q2,国产大模型)
MODEL_PRICES = {
"deepseek-v3": {"input": 0.27 / 1_000_000, "output": 1.10 / 1_000_000},
"deepseek-r1": {"input": 0.55 / 1_000_000, "output": 2.19 / 1_000_000},
"qwen-max": {"input": 0.04 / 1_000_000, "output": 0.12 / 1_000_000},
"qwen-plus": {"input": 0.0008 / 1_000_000, "output": 0.002 / 1_000_000},
"glm-4": {"input": 0.10 / 1_000_000, "output": 0.10 / 1_000_000},
}
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
prices = MODEL_PRICES.get(model, {"input": 0.001 / 1000, "output": 0.001 / 1000})
return prices["input"] * prompt_tokens + prices["output"] * completion_tokens
async def proxy_llm_request(
payload: dict,
tenant_id: str,
feature: str,
upstream_url: str = "https://api.deepseek.com/v1/chat/completions",
api_key: str = "",
) -> dict:
model = payload.get("model", "unknown")
start = time.monotonic()
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
upstream_url,
json=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
)
resp.raise_for_status()
data = resp.json()
duration = time.monotonic() - start
REQUEST_DURATION.labels(model=model, tenant_id=tenant_id).observe(duration)
usage = data.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
TOKEN_TOTAL.labels(model=model, tenant_id=tenant_id, feature=feature, token_type="prompt").inc(prompt_tokens)
TOKEN_TOTAL.labels(model=model, tenant_id=tenant_id, feature=feature, token_type="completion").inc(completion_tokens)
cost = estimate_cost(model, prompt_tokens, completion_tokens)
COST_TOTAL.labels(model=model, tenant_id=tenant_id, feature=feature).inc(cost)
return data
Grafana 告警规则
有了 Prometheus 指标,告警规则就很直接。以下是一个检测"5分钟 token 消耗速率异常"的规则:
yaml
# grafana-alerts.yaml
groups:
- name: llm_cost_anomaly
rules:
# 规则1: 5分钟内某 feature 的 token 消耗速率超过历史基线 3倍
- alert: LLMTokenBurnRateAnomaly
expr: |
rate(llm_tokens_total[5m])
> 3 * avg_over_time(rate(llm_tokens_total[5m])[1h:5m])
for: 2m
labels:
severity: warning
annotations:
summary: "LLM token 消耗异常 ({{ $labels.feature }})"
description: "Feature {{ $labels.feature }} 的 tenant {{ $labels.tenant_id }} 在过去 5 分钟内 token 消耗速率是过去 1 小时均值的 {{ $value | humanize }} 倍"
# 规则2: 单次请求超出 max_tokens 阈值(通过 completion token 超标检测)
- alert: LLMSingleRequestOveruse
expr: |
increase(llm_tokens_total{token_type="completion"}[1m]) > 3000
for: 0m
labels:
severity: warning
annotations:
summary: "LLM 单次请求输出 token 过多"
description: "{{ $labels.feature }} 在 1 分钟内 completion token 增量超过 3000,请检查是否设置了 max_tokens"
# 规则3: 单租户成本占比异常(多租户场景)
- alert: LLMTenantCostDominance
expr: |
rate(llm_cost_usd_total[10m])
/ ignoring(tenant_id) group_left sum without(tenant_id)(rate(llm_cost_usd_total[10m]))
> 0.7
for: 5m
labels:
severity: critical
annotations:
summary: "单租户 LLM 成本占比过高"
description: "租户 {{ $labels.tenant_id }} 在过去 10 分钟内占总 LLM 成本的 70% 以上,可能存在滥用"
这三条告警规则覆盖了最常见的三种异常场景,设置完成后发现问题的时间从"下个月账单"缩短到"5分钟内"。
Layer 2:应用级轻量监控(SQLite + 滑动窗口)
如果你的应用是单体服务,不想引入 Prometheus + Grafana 这套基础设施,可以用更轻量的方式:在 SDK 调用外层包装一个拦截器,把每次调用的 usage 写入本地 SQLite,然后定时检查异常。
SDK 包装器
python
# cost_tracker.py
import sqlite3
import time
import statistics
from datetime import datetime, timedelta
from typing import Optional
# 国产大模型价格表 (人民币/百万 token)
MODEL_PRICES_CNY = {
"deepseek-v3": (1.0, 4.0), # (input, output) 元/M token
"deepseek-r1": (2.0, 8.0),
"qwen-max": (0.3, 0.9),
"qwen-plus": (0.006, 0.015),
"qwen-turbo": (0.003, 0.006),
"glm-4": (0.7, 0.7),
}
class CostTrackingLLMClient:
def __init__(self, base_url: str, api_key: str, db_path: str = "llm_costs.db"):
import httpx
self.base_url = base_url
self.api_key = api_key
self.db_path = db_path
self._init_db()
def _init_db(self):
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS cost_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
tenant_id TEXT NOT NULL,
feature TEXT NOT NULL,
model TEXT NOT NULL,
prompt_tokens INTEGER,
completion_tokens INTEGER,
cost_cny REAL,
latency_ms INTEGER
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ts ON cost_log(ts)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tenant ON cost_log(tenant_id, ts)")
def _estimate_cost(self, model: str, prompt_tokens: int, completion_tokens: int) -> float:
in_price, out_price = MODEL_PRICES_CNY.get(model, (0.01, 0.01))
return (in_price * prompt_tokens + out_price * completion_tokens) / 1_000_000
def _log(self, tenant_id: str, feature: str, model: str,
prompt_tokens: int, completion_tokens: int, cost_cny: float, latency_ms: int):
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"INSERT INTO cost_log VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
(time.time(), tenant_id, feature, model,
prompt_tokens, completion_tokens, cost_cny, latency_ms)
)
async def chat(
self,
messages: list,
model: str = "deepseek-v3",
tenant_id: str = "default",
feature: str = "unknown",
max_tokens: Optional[int] = 1024,
**kwargs
) -> dict:
import httpx
start = time.monotonic()
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
**kwargs
}
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
json=payload,
headers={"Authorization": f"Bearer {self.api_key}"}
)
resp.raise_for_status()
data = resp.json()
latency_ms = int((time.monotonic() - start) * 1000)
usage = data.get("usage", {})
cost = self._estimate_cost(model, usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0))
self._log(tenant_id, feature, model,
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
cost, latency_ms)
return data
滑动窗口异常检测
记录了数据之后,需要一个异常检测逻辑。这里用了最实用的方法:对比过去 N 分钟的消耗与历史同期均值,超过若干倍标准差即告警。
python
# anomaly_detector.py
import sqlite3
import statistics
import time
class CostAnomalyDetector:
def __init__(self, db_path: str = "llm_costs.db"):
self.db_path = db_path
def _get_cost_in_window(self, window_minutes: int, tenant_id: str = None) -> float:
since = time.time() - window_minutes * 60
query = "SELECT COALESCE(SUM(cost_cny), 0) FROM cost_log WHERE ts > ?"
params = [since]
if tenant_id:
query += " AND tenant_id = ?"
params.append(tenant_id)
with sqlite3.connect(self.db_path) as conn:
return conn.execute(query, params).fetchone()[0]
def _get_historical_windows(
self, window_minutes: int, lookback_hours: int = 24, tenant_id: str = None
) -> list:
now = time.time()
window_sec = window_minutes * 60
samples = []
for i in range(int(lookback_hours * 60 / window_minutes)):
end_ts = now - i * window_sec
start_ts = end_ts - window_sec
query = "SELECT COALESCE(SUM(cost_cny), 0) FROM cost_log WHERE ts > ? AND ts <= ?"
params = [start_ts, end_ts]
if tenant_id:
query += " AND tenant_id = ?"
params.append(tenant_id)
with sqlite3.connect(self.db_path) as conn:
val = conn.execute(query, params).fetchone()[0]
samples.append(val)
return samples
def check_anomaly(
self,
window_minutes: int = 5,
z_threshold: float = 3.0,
tenant_id: str = None,
) -> dict:
current_cost = self._get_cost_in_window(window_minutes, tenant_id)
history = self._get_historical_windows(window_minutes, tenant_id=tenant_id)
if len(history) < 5 or all(v == 0 for v in history):
return {"is_anomaly": False, "reason": "insufficient_history"}
mean = statistics.mean(history)
stdev = statistics.stdev(history)
if stdev == 0:
z_score = 0 if current_cost == mean else float("inf")
else:
z_score = (current_cost - mean) / stdev
return {
"is_anomaly": z_score > z_threshold,
"current_cost_cny": current_cost,
"baseline_mean_cny": mean,
"baseline_stdev_cny": stdev,
"z_score": z_score,
"window_minutes": window_minutes,
"tenant_id": tenant_id or "all",
}
def run_checks(self, alert_callback=None):
results = []
# 全局检测
global_check = self.check_anomaly(window_minutes=5, z_threshold=3.0)
if global_check.get("is_anomaly"):
results.append(("global", global_check))
# 按租户检测
with sqlite3.connect(self.db_path) as conn:
tenants = [r[0] for r in conn.execute(
"SELECT DISTINCT tenant_id FROM cost_log WHERE ts > ?",
[time.time() - 3600]
).fetchall()]
for tenant_id in tenants:
check = self.check_anomaly(window_minutes=5, z_threshold=2.5, tenant_id=tenant_id)
if check.get("is_anomaly"):
results.append((tenant_id, check))
if results and alert_callback:
alert_callback(results)
return results
# 告警到飞书群
def alert_to_feishu(results, webhook_url: str):
import requests
for tenant_id, result in results:
msg = {
"msg_type": "text",
"content": {
"text": (
f"🚨 LLM 成本异常\n"
f"租户: {tenant_id}\n"
f"当前 5min 成本: ¥{result['current_cost_cny']:.4f}\n"
f"历史基线均值: ¥{result['baseline_mean_cny']:.4f}\n"
f"Z-score: {result['z_score']:.1f}"
)
}
}
requests.post(webhook_url, json=msg)
import schedule
detector = CostAnomalyDetector()
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
schedule.every(5).minutes.do(
lambda: detector.run_checks(alert_callback=lambda r: alert_to_feishu(r, FEISHU_WEBHOOK))
)
这个方案的核心优点是零额外基础设施依赖:一个 SQLite 文件,一个定时任务,成本检测就跑起来了。z-score 方法虽然简单,但在流量模式稳定的情况下误报率极低。
Layer 3:平台级监控(Usage API + 定时拉取)
如果你直接用大模型厂商的 API,可以利用他们提供的 Usage API 做日级对账和趋势告警。这个方案的优点是不需要改业务代码,缺点是粒度最粗,只能发现"今天比昨天贵很多"这类问题。
以 DeepSeek 为例,其提供了用量查询接口。以下是一个通用的日级成本对比脚本:
python
# usage_poller.py
import httpx
import asyncio
from datetime import date, timedelta
async def fetch_daily_usage(api_key: str, base_url: str, days_back: int = 7) -> list:
"""拉取过去 N 天的 usage 数据(适配各大国产模型平台)"""
end_date = date.today()
start_date = end_date - timedelta(days=days_back)
# 各平台 usage API 端点略有不同,以下为通用结构
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{base_url}/organization/usage",
params={
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {api_key}"}
)
if resp.status_code == 404:
# 部分平台不提供此接口,降级到控制台告警
return []
resp.raise_for_status()
return resp.json().get("data", [])
async def check_cost_spike(api_key: str, base_url: str, threshold_ratio: float = 1.5):
data = await fetch_daily_usage(api_key, base_url, days_back=8)
if len(data) < 2:
print("数据不足,跳过检测")
return
today = data[-1]
history = data[:-1]
today_cost = sum(item.get("cost", 0) for item in today.get("breakdown", []))
history_costs = [
sum(item.get("cost", 0) for item in day.get("breakdown", []))
for day in history
]
avg_cost = sum(history_costs) / len(history_costs) if history_costs else 0
if avg_cost > 0 and today_cost > avg_cost * threshold_ratio:
print(f"⚠️ 成本异常!今日: ¥{today_cost:.2f}, 历史均值: ¥{avg_cost:.2f}, 比率: {today_cost/avg_cost:.1f}x")
else:
print(f"✅ 成本正常。今日: ¥{today_cost:.2f}, 历史均值: ¥{avg_cost:.2f}")
四类异常的处置建议
发现异常后,如何处置同样重要。不同类型的异常对应不同的处置策略:
| 异常类型 | 告警等级 | 推荐处置 | 根因修复 |
|---|---|---|---|
| 单请求输出过大 | Warning | 记录请求日志,发飞书通知 | 强制设置 max_tokens;对输出做长度校验 |
| 全局频率骤增 | Warning | 检查是否有客户端重试风暴 | 在入口加 rate limiting;检查错误率 |
| Agent 成本滚雪球 | Critical | 立即暂停该 Agent 的 API key | 加 max_turns/max_cost 熔断器 |
| 单租户占比 > 70% | Critical | 临时限速该租户;通知客户 | 引入 per-tenant quota |
特别要强调 Agent 熔断器:如果你在运行任何形式的 LLM Agent(工具调用、多步规划),建议在 Agent 层维护一个成本预算:
python
class BudgetedAgent:
"""带成本熔断的 Agent 包装器"""
def __init__(self, budget_cny: float = 5.0):
self.budget_cny = budget_cny
self.spent_cny = 0.0
self.turns = 0
async def step(self, messages: list, model: str = "deepseek-v3", **kwargs) -> str:
if self.spent_cny >= self.budget_cny:
raise RuntimeError(
f"Agent 已超出预算 ¥{self.budget_cny:.2f}(已花费 ¥{self.spent_cny:.4f}),终止执行"
)
if self.turns >= 20:
raise RuntimeError("Agent 已超过最大轮次 (20),终止执行")
# 调用 LLM(此处使用上面实现的 CostTrackingLLMClient)
response = await self.client.chat(
messages=messages, model=model, **kwargs
)
usage = response.get("usage", {})
in_p, out_p = MODEL_PRICES_CNY.get(model, (0.01, 0.01))
cost = (in_p * usage.get("prompt_tokens", 0) + out_p * usage.get("completion_tokens", 0)) / 1_000_000
self.spent_cny += cost
self.turns += 1
return response["choices"][0]["message"]["content"]
这比在告警层发现问题要早得多------在 Agent 的每一步就检查预算,超标立刻终止,不让它进入下一轮。
实验数据:三种方案的检测延迟对比
我们在一个模拟场景下测试了三种方案的告警延迟:注入一个"Agent 失控"行为(每分钟消耗 10x 正常用量),观察各方案的首次告警时间:
| 方案 | 告警延迟 | 误报率 (7天) | 部署复杂度 |
|---|---|---|---|
| Layer 1: Prometheus + Grafana | 2--3 分钟 | < 1% | 高(需要 Prom/Grafana 栈) |
| Layer 2: SQLite + 定时检测 | 5--7 分钟 | 2--3% | 低(单文件,零依赖) |
| Layer 3: Usage API 定时拉取 | 30--60 分钟 | < 0.5% | 最低(只需一个 cron job) |
Layer 3 的误报率最低是因为它用的是官方数据,没有估算误差;但延迟最高。对于生产环境中运行 Agent 的团队,Layer 1 或 Layer 2 是必选项,Layer 3 作为对账工具补充。
几个容易踩的坑
坑 1:把 Prometheus counter 重置计算成异常 。如果你的服务重启,counter 从 0 开始计,rate() 函数会看到负增长再归零,可能触发误报。解决方法是用 increase() 而不是手动做差值,Prometheus 对 counter reset 有内置处理。
坑 2:价格表不及时更新。大模型 API 价格变动频繁(国内厂商降价更是常态),hardcode 价格表会导致成本估算偏差越来越大。建议单独维护一个价格配置文件(YAML/JSON),做到热更新。
坑 3:只监控成本,不监控 token 数量。成本 = 价格 × token 数量,但价格会变动。单独监控 token 数量作为原始信号更稳定。
坑 4:忘记 streaming 模式 。如果你用了 stream=True,response 里是没有 usage 字段的(或者是异步最后一帧才有)。需要特别处理:要么关闭 streaming,要么在 stream 结束后发送一个单独的 usage 事件。
坑 5:在 SQLite 方案里忘记清理旧数据。cost_log 表如果不定期 VACUUM,几个月后可能有几十万行,查询开始变慢。建议加个定期清理任务,只保留最近 30 天的数据:
python
# 清理 30 天前的数据(建议每天执行一次)
def cleanup_old_logs(db_path: str, days_to_keep: int = 30):
cutoff = time.time() - days_to_keep * 86400
with sqlite3.connect(db_path) as conn:
deleted = conn.execute(
"DELETE FROM cost_log WHERE ts < ?", [cutoff]
).rowcount
conn.execute("VACUUM")
print(f"已清理 {deleted} 条旧记录")
总结
LLM API 成本失控不是"会不会"的问题,而是"什么时候"的问题。早晚每个团队都会遇到 Agent 无限循环、上下文爆炸、租户滥用这类情况。
本文给出的三层方案可以按需选择:
- 刚起步的项目:先做 Layer 2(SQLite + z-score),花两小时,至少能在 5 分钟内发现问题
- 有监控栈的团队:直接上 Layer 1,接 Prometheus,三条 alert rule 搞定
- 小团队直接用模型平台 API:Layer 3 的 Usage API 定时拉取 + 比率告警,几乎零成本
无论选哪层,有几件事必须做:
- 所有 LLM 调用强制设置
max_tokens,这是最简单的单次成本防护 - Agent 加成本预算熔断,在循环内检查,不要等外部告警
- 多租户场景做 per-tenant token 用量追踪,不要让坏租户淹没在聚合数据里
LLM API 的成本观测和传统服务的性能观测一样重要,只是工具还没完全成熟。在行业级工具跟上来之前,这篇文章的代码片段可以给你一个可以直接用的起点。
如果你们团队遇到过有趣的 LLM 成本异常案例,欢迎在评论区分享------尤其是那些"当时没有监控,后来账单来了才发现"的故事。