LLM API 成本失控怎么办?工程师的实时异常检测指南

你有没有遇到过:月底收到账单才发现某个 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 定时拉取 + 比率告警,几乎零成本

无论选哪层,有几件事必须做:

  1. 所有 LLM 调用强制设置 max_tokens,这是最简单的单次成本防护
  2. Agent 加成本预算熔断,在循环内检查,不要等外部告警
  3. 多租户场景做 per-tenant token 用量追踪,不要让坏租户淹没在聚合数据里

LLM API 的成本观测和传统服务的性能观测一样重要,只是工具还没完全成熟。在行业级工具跟上来之前,这篇文章的代码片段可以给你一个可以直接用的起点。


如果你们团队遇到过有趣的 LLM 成本异常案例,欢迎在评论区分享------尤其是那些"当时没有监控,后来账单来了才发现"的故事。

相关推荐
练习两年半的攻城狮1 小时前
LlamaIndex ResponseMode 深度解析
python·llamaindex
阿pin1 小时前
Java随笔-红黑树
java·python·算法·红黑树
2603_965148112 小时前
eBay商品数据API:寻找海外仓与价格洼地
大数据·人工智能·windows·python·microsoft
2601_956319882 小时前
TqSdk Tick 数据适合做什么?获取方法与使用边界
人工智能·python
苏灿烤鱼2 小时前
把 Agent 做成一家公司,真比通用提示词好用吗?
python·agent·shell
北斗落凡尘12 小时前
LangGraph 入门实战(2)
python·langchain
ttod_qzstudio12 小时前
Java 常用语法极简通关(五):类与对象——字段、方法、构造器、this 与 static
java·开发语言·python
jufeng130714 小时前
【系列:手搓自主 AI Agent:Hermes 架构原理剖析 · 第 1 篇】
人工智能·python·架构·agent