LLM批量调用成本骤降63%:错峰调度器与对账体系实战解析
2026/9/20 2:00:18 网站建设 项目流程

先问一句最直接的问题:你的 LLM 调用账单里,有多少钱是花在"其实可以等到半夜再跑"的任务上的?

我上个月接了一个批量模型评测项目,需要跑 800 万次 LLM 调用,按白天的按量单价粗算,第一批费用预览就奔着六位数去了。整批任务切到夜间低谷时段之后,账单直接缩水到原来的四成左右。这次收益让我彻底改变了对 LLM 成本控制的看法——峰谷定价不是省点零花钱的鸡肋操作,而是一个能把推理成本打下来一个量级的常规手段。

但事情远没有"定时把任务跑起来"这么简单。排队窗口怎么划、哪些任务能等哪些不能等、跑完之后怎么证明账真的对得上,每一项都有坑。我最终做了一个带完整对账能力的错峰调度器,从设计到落地踩了一圈路。这篇文章会把完整思路、关键代码和踩坑记录都拆开讲一遍,给正在被 LLM 账单折磨的同行一个可直接参考的模板。

1. 先把峰谷定价的账算明白:折扣哪来的、能省多少、坑在哪

1.1 目前国内主流的 LLM 峰谷计费模式

在动手写调度器之前,你得先搞清楚一个核心问题:你的模型供应商到底给不给夜间折扣?不是所有平台都有,而且就算有,规则差异也很大。

我实际关注过的几类模式大致如下:

  • 按量后付费 + 分时段折扣:这是最理想的模式。平台在特定时段(通常是 00:00-08:00 或 23:00-07:00)对推理单价直接打折,常见折扣在 5 折到 7.5 折之间。按量后付费的好处是灵活,不用预付款,但需要你主动把任务调到低价时段。
  • 预付费资源包 + 晚间加倍抵扣:部分云厂商提供资源包,白天 1 个抵扣单位换 1 个 token 额度,夜间同样数量的抵扣单位能换 1.5 到 2 个 token 额度。这种模式对稳定业务更友好,但资源包金额不小,适合已验证过量的团队。
  • 混合模式:有些平台同时存在两种模式,比如默认按量后付费,但同时可购买"夜间专用资源包"。这种最复杂,因为同时存在两套计价体系,对账时非常容易出偏差。
  • 无折扣:还有一类平台是全天候统一定价的,对于这类供应商,做错峰调度就没有意义,省下来的只有"避开高峰时段的限流"这一个间接收益。

所以在文章开头我先放一句忠告:先确认你的供应商支持峰谷计费,再往下设计调度器,否则整篇文章的方案对你来说都是空中楼阁。

1.2 峰谷差价的实际数学:同样的调用量差多少钱

拿一个典型场景算一笔账,你就明白这件事的收益量级了。

假设你的批量任务混合使用一个中等规模模型,白天按量价格是输入 0.8 元/M tokens、输出 3.2 元/M tokens。夜间价格打五折,即输入 0.4 元/M tokens、输出 1.6 元/M tokens。批量评测任务有大量的 prompt 输入和相对较短的输出,输入:输出 token 比例大约在 6:1 左右。也就是说,每 7 个 tokens 中约 6 个是输入、1 个是输出。

一次调用平均消耗 2000 个输入 tokens 和 350 个输出 tokens:

  • 白天单次成本 = 2000/1000000 × 0.8 + 350/1000000 × 3.2 = 0.0016 + 0.00112 = 0.00272 元
  • 夜间单次成本 = 2000/1000000 × 0.4 + 350/1000000 × 1.6 = 0.0008 + 0.00056 = 0.00136 元
  • 单次节省正好 0.00136 元,也就是一半

如果一个月跑 800 万次调用,白天成本大概 21760 元,夜间则只有 10880 元。省出来的 10880 元,已经足够覆盖一个初级工程师小半个月的工资了。如果模型单价更高、调用量更大,这个差值是成比例放大的。

这里还有一点容易被忽略:很多供应商只对推理 token 打折,对上下文缓存命中部分(cache read tokens)可能是另一套价格,甚至不打折。你别看平台宣传页上大字写的"夜间 5 折"就以为全部费用都打折,一定要细读计费文档里有没有"适用范围"这类小字。

1.3 从账单结构看折扣的隐藏规则

我实际踩过的一个坑:同一笔任务,如果开始时间是晚上 23:58,结束时间是 00:02,平台按什么价格结算?

多数平台的计费口径是"按请求发起时间"定格,也有少数平台按"请求完成时间"才算。这两种口径在跨时段边界时会产生完全不同的结算结果。更麻烦的是,有些平台用的是 UTC 时间记账,你在本地看到的是北京时间,导出的账单明细却按 UTC 对齐。如果你的调度器不处理时区问题,凌晨刚进门的第一波任务,可能恰好被系统记成了白天的价格。

所以构建对账体系的第一步,不是写调度器,而是把你供应商的计费文档逐字读一遍,并明确三件事:

  1. 折扣时段的准确边界(几点到几点,按什么时区计算);
  2. 折扣适用哪些计费项(推理 tokens 是否包含缓存命中);
  3. 价格定格的时间口径(发起时间还是完成时间)。

只有这三件事都确认清楚了,你后续设计的调度逻辑才有依据,对账模型也才不会出现系统性偏差。

2. 调度器要解决的本质问题:把任务分三档,而不是一刀切延时

2.1 第一档:实时任务直接放行,别碰它

任何错峰调度器的第一原则都是:只调度可以等的任务,永远不要为了省钱去动在线推理。

实时任务包括用户聊天、客服机器人、API 网关背后的交互式请求、以及任何对首 token 延迟有硬性要求的场景。这类任务的响应时间通常要求在几百毫秒到 3 秒以内,一旦你把它排进夜间队列,整个业务就废了。

我在设计调研阶段也初步想过"要不要做一个快速切换开关,在夜间把实时流量也引到低价时段"——后来想明白了,实时任务的成本曲线和用户体验是绑定在一起的,为了省一半的钱去牺牲 P99 延迟,对在线业务来说是亏损的。

所以这部分任务在调度器里直接标记为priority: realtime,走一个最简单的直接调用通道,不进入任何队列和延迟逻辑。

2.2 第二档:准实时任务的灰度窗口

第二档是"能等,但不能等太久"的任务。典型场景包括定时报表、Webhook 回调、异步通知、批量发送邮件前的摘要生成等。

这类任务的宽容度通常在 5 分钟到 30 分钟之间。它们不一定非要卡在凌晨跑,但如果正好赶上低价时段(哪怕只是窗口边缘),能省一点是一点。

我的处理方式是给每个准实时任务加一个deadline字段,调度器在决策时先看当前时间距 deadline 还有多久:

  • 如果剩余时间 > 4 小时,任务可以先挂起,等待接近错峰窗口时再调度;
  • 如果剩余时间 < 30 分钟,说明已经无法再等了,立即派发,即使当前不是低价时段,也不能因为省钱而丢了 SLA。

这个灰度窗口的设计非常关键。它让系统有了一个可调节的成本与时效旋钮:你可以在错峰窗口前 30 分钟开始预投递一批准实时任务,把这个窗口的"便宜时段"利用率拉满,同时不会影响业务方的感知。

2.3 第三档:离线任务的批量队列设计

第三档才是错峰调度的主力军,也是所有省钱空间的大头。包括:

  • 批量模型评测和回归测试;
  • 语料清洗、数据增强、标签生成;
  • Embedding 批量生成;
  • RAG 知识库的索引刷新;
  • LLM 蒸馏流程中的大样本推理;
  • 任何以"天"或"小时"为单位的后台管道任务。

离线任务的特点是数量庞大、彼此独立、最终时效要求宽(一般数小时到次日早晨)。这类任务放到夜间批量跑,无论是成本还是资源利用率,都是最优解。

调度器为离线任务维护一个 Redis 延迟队列(以分数形式存储期望执行的时间戳),夜间窗口开启时批量 Pop 并分发给 Worker。这里的核心架构是"延迟队列 + 批量派发",而不是简单的 cron 定时任务。原因也很直接:夜间窗口的开启和关闭是由计费时段决定的,而这个窗口可能在几个月后因为供应商调价而改变。你是愿意改一行 cron 表达式,还是改一个可配置的调度时间源?显然是后者。

3. 对账模块的设计思路:怎么证明"真的省了钱"

3.1 为什么要本地自建一套计价模型,而不只看平台账单

很多人会问:平台账单都出好了,为什么不直接看账单?原因在于,平台账单回答不了你的业务问题。

  • 平台账单只告诉你花了多少钱,不告诉你这些钱花在哪个任务类型、哪个批次、哪次实验上;
  • 平台账单只按自然时间聚合,不告诉你"白天运行的实时任务花了多少、夜间批量任务花了多少";
  • 平台账单位于你无法完全控制的地方,存在时区差异、折扣规则版本差异、缓存命中计费差异等问题,如果不做本地交叉验证,你根本无法发现平台是否多扣了。

所以对账模块的第一层设计是一个本地计价模型:调度器每发出一个请求,本地立刻根据请求的时刻、模型、tokens 数量,按当时的费率快照计算出一个"预期成本",存进数据库。这条记录就是未来对账的基准。

3.2 本地计价的关键:费率快照而不是实时查价

计费价格是会变的。供应商可能在某个月初调整以厘为单位的单价,也可能临时调整夜间折扣比例。如果你的本地计价模型是写死的价格表,一旦供应商调价,你的预期成本和实际账单之间就会产生系统性偏差。

解法是引入费率快照表。每次拉取供应商价格表后,把完整的费率版本存进一张表,并记录生效时间范围。调度器在计算任务成本时,读取的是"这个请求发起时刻所对应的费率版本",而不是当前最新版本。

我用的表结构大致是这样的:

CREATE TABLE rate_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, model_name VARCHAR(64) NOT NULL, price_type VARCHAR(32) NOT NULL, -- input / output / cache_read unit_price DECIMAL(12, 8) NOT NULL, -- 元/千tokens,便于精确计算 discount_rate DECIMAL(4, 2) NOT NULL DEFAULT 1.00, effective_start TIMESTAMP NOT NULL, effective_end TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

有了这张表,对账就变成了一个简单的乘法加聚合问题。更棒的是,任何价格调整都会留下历史痕迹,当平台账单和本地模型出现系统性偏差时,你可以直接回溯到某个费率版本的生效时间,快速判断问题出在哪。

3.3 调用明细埋点到账单比对的闭环

对账的第二步是把每一次调用的明细存下来。调度器在发起请求时生成一个request_id(通常取 UUID),在请求返回后解析响应里的usage字段,把输入 tokens、输出 tokens、缓存命中 tokens 以及本次费率快照版本、计算出的成本、实际折扣金额一并写入调用明细表。

这个明细表的结构建议是:

CREATE TABLE llm_call_log ( request_id VARCHAR(64) PRIMARY KEY, task_type VARCHAR(64) NOT NULL, model_name VARCHAR(64) NOT NULL, start_time TIMESTAMP NOT NULL, end_time TIMESTAMP NOT NULL, input_tokens INT NOT NULL, output_tokens INT NOT NULL, cache_read_tokens INT DEFAULT 0, rate_snapshot_id BIGINT NOT NULL, expected_cost DECIMAL(12, 6) NOT NULL, actual_cost DECIMAL(12, 6) NULL, status VARCHAR(16) NOT NULL );

每天凌晨对账跑一遍:把本地llm_call_log按天聚合的expected_cost和从平台导出的账单明细(通常以 CSV 形式通过邮件或 API 获取)做一次差集比对。我定了一个容忍阈值:单日偏差在 ±2% 以内视为正常,超出就触发告警,进入人工排查流程。为什么是 2%?因为平台计费口径中总有一些无法完全对齐的细节(比如平台对不足 1 token 的四舍五入、批量计费请求的重试策略等),追求 0 误差的成本远高于收益,2% 是一个现实且安全的边界。

3.4 对不齐时的排查路径

对账系统的价值只有在对不齐的时候才真正体现。我遇到过的偏差原因大致分四类,按出现频率排序:

  • Token 计数口径不一致:本地用 tiktoken 或模型自带的 tokenizer 预计算,平台计费用的是服务端完整 tokenizer。对于某些语种和特殊符号,两边可能差出 5%-10%。这个只能靠经验对齐,或者直接使用 API 返回的usage作为唯一口径。
  • 重试请求被计数:你发出的请求第一次超时、第二次成功,本地只记录了一次,但平台上可能记了两次(或者平台对超时请求不计费,视供应商而定)。这种问题需要靠request_id幂等机制来隔离。
  • 时区边界错位:凌晨 00:10 的任务,本地按北京时间记成当天,平台按 UTC 记到前一天,聚合日差了。这种问题在对比时一定要固定时区口径。
  • 缓存命中计费规则变动:平台在某个版本之前对 cache read 部分按输入价的 10% 计费,之后可能调整。如果费率快照同步不及时,偏差就会突然出现。

我特别想强调:对账模块的输出不能只是一个"对上了/对不上"的布尔值,还要能输出差异明细。哪怕只是一份差异行数最多的 50 条记录,都能让你的排查效率翻倍。

4. 落到代码:调度队列、幂等投递和限流的完整实现

4.1 任务模型与队列存储选型

我最终采用的方案是 PostgreSQL 存任务元数据 + Redis 做延迟队列,Worker 用 Python 的asyncio+httpx并发执行。选型原因很简单:

  • Redis 的 ZSET 天然支持延迟队列。用执行时间戳作为 score,ZRANGEBYSCORE取当前到期任务,复杂度低,不引入额外组件;
  • PostgreSQL 提供事务性,保证任务状态与调用明细的一致性;
  • Pythonasyncio能在单机内撑起足够高的并发,800 万次调用不需要引入庞大的分布式框架。

任务模型大致是这样的:

from enum import Enum from datetime import datetime from pydantic import BaseModel class TaskPriority(str, Enum): REALTIME = "realtime" NEAR_REALTIME = "near_realtime" OFFLINE = "offline" class LLMTask(BaseModel): task_id: str priority: TaskPriority model: str prompt: str max_tokens: int = 512 deadline: datetime | None = None # 准实时任务必填 payload: dict = {}

离线任务的deadline设为次日业务开始前 1 小时,比如次日 09:00 前必须跑完。调度器每天早上从库里拉离线任务时,把deadline减去当前时间得到最大可等待窗口,如果窗口覆盖夜间低价段则直接入延迟队列。

4.2 调度主循环:从 Redis ZSET 批量弹出到 Worker

调度主循环的代码非常短,但里面有两个细节值得注意:一个是批量弹出时的原子性,另一个是任务派发后必须确认 Worker 已经接收,否则可能出现"调度器以为发出去了但 Worker 崩了没收到"的情况。

import asyncio import json import redis.asyncio as aioredis from datetime import datetime, timezone DELAY_QUEUE_KEY = "llm_scheduler:delay_queue" DISPATCH_CHANNEL = "llm_scheduler:dispatch" async def scheduler_loop(redis: aioredis.Redis, batch_size: int = 64): while True: now = datetime.now(timezone.utc).timestamp() # 原子地从延迟队列中取出所有到期任务 tasks = await redis.zrangebyscore( DELAY_QUEUE_KEY, 0, now, start=0, num=batch_size, withscores=True ) if not tasks: await asyncio.sleep(1) continue pipeline = redis.pipeline() for member, _ in tasks: pipeline.zrem(DELAY_QUEUE_KEY, member) await pipeline.execute() for member, _ in tasks: await redis.publish(DISPATCH_CHANNEL, member)

这里有个非常值得注意的细节:ZREMpublish。如果反过来,当 Worker 消费时看到任务已经投递,但失败重试的机制还没建立,就会丢任务。ZREM成功保证了任务的所有权已经从队列转移到了本次派发,随后 publish 即使失败,调度器也能根据任务状态表做补偿。

Worker 侧的逻辑更简单,从 Redis 订阅频道接任务,并发执行,执行完后写明细表。我用Semaphore控制同时存活的请求数量,而不是简单限制 QPS,原因下面马上讲。

4.3 幂等投递:如何防止重启导致重复调用

错峰调度器最怕的事,不是没跑到,而是重复跑。因为重复跑不仅浪费钱,还可能污染下游数据。尤其在夜间窗口里,如果调度器因为 20 分钟的长任务阻塞而重启,Redis 里的任务会被再次取出投递,同一个task_id就会被执行两遍。

我在llm_call_log表里对request_id建了唯一索引,同时要求 Worker 在执行前先检查任务状态表里该task_id是否已经是runningcompleted。只有pending状态才能执行。处理流程是:

async def acquire_task(task_id: str, worker_id: str) -> bool: # SQL: UPDATE llm_tasks SET status='running', worker_id=%s # WHERE task_id=%s AND status='pending' ...

这个UPDATE ... WHERE status='pending'原子占坑的方式,是防止重复执行的最优雅手段。只要数据库事务性没问题,多 Worker 并发时也只有一个能抢到任务。真正的调用在抢坑成功后才发起。

4.4 桶限流而不是简单 QPS 限制

夜间窗口启动时,堆积了一整天的离线任务会在头 30 分钟内大量到期,瞬间打满 Worker 并发。如果不对并发做限制,供应商的 API 网关立刻返回 429,你的重试逻辑会继续打过去,结果就是高峰打爆、间歇性漂流。

我用的是令牌桶限流,但桶的容量不是按每秒请求数,而是按"同时存活请求数"来设计的。原因很简单:LLM 推理是慢请求,单个请求可能占用 5-20 秒,如果按 QPS 限制,一旦请求变慢,实际并发数会无限增长,仍然可能打满平台配额。

from asyncio import Semaphore class RateLimiter: def __init__(self, max_concurrent: int, max_rate: int): self.semaphore = Semaphore(max_concurrent) self.rate_limit = max_rate # 每秒请求上限 self._tokens = max_rate self._last_refill = time.time() async def acquire(self): await self.semaphore.acquire() # 补充令牌并等待可用的速率令牌 ... def release(self): self.semaphore.release()

这里多了一个限速计算:每个请求在启动前会检查"距上次请求是否已过1 / rate_limit秒",不够就 sleep。这个策略使得夜间窗口以匀速消耗 API 配额,而不是打一发歇半天。实测结果表明,配合重试退避,429 出现率降到了几乎为零。

4.5 费率表同步与折扣窗口配置

rate_snapshot数据怎么维护?我的方案是写一个定时任务,每天凌晨拉取供应商价格文档或费用中心 API,对比effective_starteffective_end是否有变化,有变化就插入新版本。同时,窗口的开关时间也做成了配置:

class DiscountWindow(BaseModel): provider: str model: str timezone: str = "Asia/Shanghai" window_start: str = "00:00" # 折扣窗口开始 window_end: str = "08:00" # 折扣窗口结束 discount_rate: float = 0.5

在博文里给一个建议:不要把窗口写死在代码里。供应商调折扣时段是常有的事,比如从 00:00-08:00 改成 22:00-06:00。这个字段放到配置中心或数据库里,改起来是分钟级的事,而写死在代码里,八成会因为没人记得改而永远留在旧版本上。

5. 实际跑了一个月的经验:节省比例、踩坑清单、适用边界

5.1 真实数据:一个月 800 万次调用,账怎么平的

这个月我跑了三个批次,用同一个错峰调度器,结果差异比较明显:

  • 第一批次是模型评测,约 500 万次调用,全部离线,夜间完成。本地计价模型统计的总费用约 1.2 万元,平台账单 1.21 万元,偏差 0.8%,在 2% 阈值内。
  • 第二批次是 Embedding 生成,约 250 万次,量较大但单次极便宜。本地区总费用约 800 元,平台账单 780 元,偏差略超 2.5%。排查后发现是本地把失败重试请求也计入了输入 token 预估,修正后对齐。
  • 第三批次是实时对话透传,约 50 万次,全部走白天正常通道,没有错峰,也没有省钱空间,但这部分验证了系统不会误伤实时任务。

综合算下来,如果把所有任务都放白天按量跑,总费用粗估约 4 万元;切换到错峰调度后,整体费用约 1.5 万元(实时任务白天部分 + 离线任务夜间折扣部分),整体节省比例接近 63%。如果只看纯离线批量任务,节省比例接近 50%,正好与夜间五折折扣一致。

从对账结果来看,本地计价模型和平台账单的差值始终稳定在 2% 以内,说明这套对账闭环是有效的,不是自我安慰。

5.2 踩坑记录整理

  • 缓存命中费用导致对不上账:有段时间夜间任务的缓存命中率特别高,本地计价模型按输入价格打了折,平台却按缓存命中价(比输入价低但不打折)计费。两边算法口径不一致,账就对不齐。后来在本地计价模型里显式区分了 cache_read 的费率,问题解决。
  • 时区必须统一:调度器最开始用本机时间生成执行时间戳,存储到 Redis 延迟队列时按本地时间算的,但平台账单按 UTC。导致每天凌晨 00:00-01:00 之间的任务,本地记到当天,平台记到前一天,日账单差异在月初对账时炸了一遍。修正策略是:调度器内部全部使用 UTC 时间戳,只在展示层转成北京时间。
  • 跨折扣边界任务的价格口径:有些任务在 23:55 开始、00:05 结束,平台按发起时间计算全程折扣,本地模型却按结束时间计算。遇到这种边缘任务,我最终统一采用平台的"发起时间定格法",本地模型也跟着改为按start_time的费率快照计算。虽然跨边界任务占比不到 1%,但如果不处理,每一次都是对账告警的触发器。
  • 夜间批量消耗平台并发配额:夜间任务全部挤在 00:00-01:00 启动,前 15 分钟打出的 QPS 峰值比白天高 3 倍。平台虽然没限制总调用量,但返回了较多 429。增加速率限制器后,把每一批任务的启动时间做随机 jitter,晚上整体更平稳。
  • 供应商的折扣不是永久有效的:我遇到过供应商月初突然调整折扣时段,从 00:00-08:00 改成 23:00-07:00。因为窗口配置化,我只改了一条数据库记录就完成了调整。如果你把窗口写死在代码里,这种调整就要重新发版,麻烦得多。

5.3 什么任务别做错峰

最后必须坦白讲一下错峰调度的适用边界,不是什么任务都值得排进夜间。否则系统白白引入复杂度,收益反而为负。

  • 在线实时推理碰都不要碰,降本和用户体验永远是两难,不需要为了省一半的钱牺牲 p95 延迟;
  • 低于 100 万次/月的调用量不太值得开发调度器。按五折折扣算,100 万次调用也就省几百到几千元,而开发、调试、对账的时间成本远高于这个数。如果你只有这个量级,最简单的做法是写个 cron 在夜间跑,不做完整调度系统;
  • 任务 SLA 小于 4 小时的不要排夜间,因为 4 小时以内的容忍度不足以覆盖夜间窗口,强行延迟反而有超时风险;
  • 依赖外部数据同步的任务,比如每天凌晨某个第三方接口才推送数据,你的任务如果排到 00:00 反而比白天更晚完成,收益不大。这类任务要考虑错峰窗口与外部数据就绪时间的交集。

我最后想说的是:错峰调度器的核心价值不在代码本身,而在你把它当作成本工程的一部分来系统性思考。调度队列、费率快照、对账日志,这套东西搭建起来后,你接下来做的任何批量 LLM 任务都能自动享受到低价窗口的红利,而且每省一分钱都有账可查。如果你也在跑大批量离线推理任务,建议先从最简单的分档 + 延迟队列入手,把对账日志从一开始就埋好。等账单数字对上的那一刻,你会觉得之前踩的坑都值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询