LLM 推理成本与容量规划:Python 读日志算 P50/P95 token、月账单、并发

19 阅读3分钟

上线大模型应用后,“成本/延迟”几乎都会比你想象的复杂:
不是因为公式难,而是因为你没把数据打出来。

这篇给你一个工程可落地的最小方案:

  • 从请求日志(jsonl)统计 P50/P95 input/output tokens、P95 延迟
  • 按价格算 单次/日/月成本
  • 用 (Concurrency \approx QPS_{peak} \times Latency_{p95}) 估算 峰值并发
  • 输出一份能直接贴到评审会的数字

0)前提:你至少要记录这些字段

建议你在服务端(或网关)把下面字段落日志(脱敏后):

{
  "request_id": "xxx",
  "ok": true,
  "latency_ms": 1234,
  "input_tokens": 4321,
  "output_tokens": 567,
  "model": "gpt-4.1-mini",
  "prompt_version": "v12",
  "retrieval_onoff": true,
  "retry_count": 0
}

你不一定一开始就都有,但 latency_ms/input_tokens/output_tokens/ok 这四个建议先齐。


1)核心公式(够用)

1.1 单次成本

[ Cost_{call} \approx \frac{T_{in}}{1000}P_{in} + \frac{T_{out}}{1000}P_{out} ]

1.2 峰值并发(容量规划最关键)

[ Concurrency \approx QPS_{peak} \times Latency_{p95}(\text{seconds}) ]

经验:再乘 1.2~1.5 的安全系数覆盖抖动与重试。


2)最小脚手架:llm_budget.py

import json
from dataclasses import dataclass
from typing import Iterable, Optional


def percentile(sorted_vals: list[int], p: float) -> Optional[int]:
    if not sorted_vals:
        return None
    idx = int(p * (len(sorted_vals) - 1))
    return sorted_vals[idx]


@dataclass
class Pricing:
    # 单价:元/1k token
    input_per_1k: float
    output_per_1k: float


@dataclass
class BudgetInputs:
    daily_calls: int
    qps_peak: float
    safety_factor: float = 1.3


@dataclass
class Stats:
    p50_in: int
    p95_in: int
    p50_out: int
    p95_out: int
    p50_latency_ms: int
    p95_latency_ms: int


def load_jsonl(path: str) -> Iterable[dict]:
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            if line.strip():
                yield json.loads(line)


def compute_stats(rows: list[dict]) -> Stats:
    ins = sorted(int(r["input_tokens"]) for r in rows)
    outs = sorted(int(r["output_tokens"]) for r in rows)
    lats = sorted(int(r["latency_ms"]) for r in rows)
    return Stats(
        p50_in=percentile(ins, 0.50),
        p95_in=percentile(ins, 0.95),
        p50_out=percentile(outs, 0.50),
        p95_out=percentile(outs, 0.95),
        p50_latency_ms=percentile(lats, 0.50),
        p95_latency_ms=percentile(lats, 0.95),
    )


def estimate_cost_per_call(tin: int, tout: int, pricing: Pricing) -> float:
    return (tin / 1000.0) * pricing.input_per_1k + (tout / 1000.0) * pricing.output_per_1k


def main(
    path: str,
    pricing: Pricing,
    budget: BudgetInputs,
):
    ok_rows = [r for r in load_jsonl(path) if r.get("ok", True)]
    s = compute_stats(ok_rows)

    # 用 P95 估 SLA 预算(更接近线上上限)
    cost_per_call_p95 = estimate_cost_per_call(s.p95_in, s.p95_out, pricing)
    cost_day = cost_per_call_p95 * budget.daily_calls
    cost_month = cost_day * 30

    concurrency = budget.qps_peak * (s.p95_latency_ms / 1000.0) * budget.safety_factor

    print("=== stats ===")
    print(s)
    print("=== budget(P95) ===")
    print(
        {
            "cost_per_call_p95": round(cost_per_call_p95, 6),
            "cost_day": round(cost_day, 2),
            "cost_month": round(cost_month, 2),
            "concurrency_estimate": round(concurrency, 2),
        }
    )


if __name__ == "__main__":
    main(
        path="requests.jsonl",
        pricing=Pricing(input_per_1k=1.0, output_per_1k=3.0),  # 占位:按你的模型价格填
        budget=BudgetInputs(daily_calls=1000, qps_peak=10, safety_factor=1.3),
    )

3)怎么用(两步)

  1. 准备 requests.jsonl(每行一条请求记录)
  2. 填价格与业务量:input_per_1k/output_per_1k/daily_calls/qps_peak

脚本输出会给你:

  • token/延迟的 P50/P95
  • 按 P95 估算的单次/日/月预算
  • 峰值并发估算(含安全系数)

4)最常见坑(上线后超预算的原因)

  1. 上下文变长:history 越叠越长、TopK 越调越大、工具返回塞了全量日志
  2. 重试放大:排队变长→超时变多→重试变多→排队更长
  3. 只看平均值:平均延迟看着还行,P95 已经炸了

5)资源区:做多模型算账/对比时,先把接入层统一

你一定会对比不同模型/不同策略(比如不同上下文预算、RAG 开关)。
如果每次对比都要换一套 SDK/鉴权,成本会很高。

更省事的方式是统一成 OpenAI 兼容入口(多数时候只改 base_urlapi_key)。
举个例子:我会用 147ai 这类聚合入口做对比(具体模型与参数以其控制台/文档为准):

  • API Base URL:https://147ai.com
  • 端点:POST /v1/chat/completions
  • 鉴权:Authorization: Bearer <KEY>
  • 文档:https://147api.apifox.cn/