多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

LLM批量调用成本骤降63%:错峰调度器与对账体系实战解析

LLM批量调用成本骤降63%:错峰调度器与对账体系实战解析 先问一句最直接的问题你的 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 对齐。如果你的调度器不处理时区问题凌晨刚进门的第一波任务可能恰好被系统记成了白天的价格。所以构建对账体系的第一步不是写调度器而是把你供应商的计费文档逐字读一遍并明确三件事折扣时段的准确边界几点到几点按什么时区计算折扣适用哪些计费项推理 tokens 是否包含缓存命中价格定格的时间口径发起时间还是完成时间。只有这三件事都确认清楚了你后续设计的调度逻辑才有依据对账模型也才不会出现系统性偏差。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 的asynciohttpx并发执行。选型原因很简单Redis 的 ZSET 天然支持延迟队列。用执行时间戳作为 scoreZRANGEBYSCORE取当前到期任务复杂度低不引入额外组件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, start0, numbatch_size, withscoresTrue ) 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)这里有个非常值得注意的细节先ZREM再publish。如果反过来当 Worker 消费时看到任务已经投递但失败重试的机制还没建立就会丢任务。ZREM成功保证了任务的所有权已经从队列转移到了本次派发随后 publish 即使失败调度器也能根据任务状态表做补偿。Worker 侧的逻辑更简单从 Redis 订阅频道接任务并发执行执行完后写明细表。我用Semaphore控制同时存活的请求数量而不是简单限制 QPS原因下面马上讲。4.3 幂等投递如何防止重启导致重复调用错峰调度器最怕的事不是没跑到而是重复跑。因为重复跑不仅浪费钱还可能污染下游数据。尤其在夜间窗口里如果调度器因为 20 分钟的长任务阻塞而重启Redis 里的任务会被再次取出投递同一个task_id就会被执行两遍。我在llm_call_log表里对request_id建了唯一索引同时要求 Worker 在执行前先检查任务状态表里该task_id是否已经是running或completed。只有pending状态才能执行。处理流程是async def acquire_task(task_id: str, worker_id: str) - bool: # SQL: UPDATE llm_tasks SET statusrunning, worker_id%s # WHERE task_id%s AND statuspending ...这个UPDATE ... WHERE statuspending原子占坑的方式是防止重复执行的最优雅手段。只要数据库事务性没问题多 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_start和effective_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 任务都能自动享受到低价窗口的红利而且每省一分钱都有账可查。如果你也在跑大批量离线推理任务建议先从最简单的分档 延迟队列入手把对账日志从一开始就埋好。等账单数字对上的那一刻你会觉得之前踩的坑都值了。
返回列表