
价格表变了,很多人的批处理任务还在按老姿势跑。
同一份代码、同一个模型,账单能差一倍,差别只在发请求的时刻。为什么错峰这件事一直没被认真做?因为它看起来太简单了,简单到没人愿意为它写调度器。
9 月 10 日 DeepSeek 发布 V4.1 Flash,除了 552B MoE 参数、CED 非对称结构这些模型侧的改动,还有一条容易被忽略的工程条款:峰谷定价被保留,闲时价格是高峰时段的一半。按官方公布的口径,闲时输入(缓存命中)低至 0.02 元/百万 token,输出 4 元;高峰时段输出 8 元(来源:DeepSeek 官方发布信息)。同时官方明确,9 月 14 日 12:00 起 V4 Pro 服务下线,请求路由到 V4.1 Flash。
对做批处理的人来说,这意味着同一份代码、同一个模型,账单能差一倍。差的不是模型选择,而是你发请求的时刻。
这篇文章不讲"要不要错峰",讲讲怎么把错峰做成一个能跑、能对账、能重试的调度器。完整代码在第四节,单文件、零第三方依赖,可以直接复制运行。
一、先把问题讲清楚

批量任务和交互式任务在经济特性上完全不同。
交互式请求的约束是延迟,用户等不了 8 小时,所以它必须付高峰价。而批量任务的约束通常只是"明天早上之前跑完"。摘要、分类、抽取、翻译、离线评测、数据集构造,这些任务的共同点是:可以在任意时刻执行,结果一样。
既然时刻可换,钱就可以省。真实场景里的账大概长这样:一万条文档摘要,平均每条 3000 输入 token、400 输出 token。
- 高峰跑:输出侧 4,000,000 token × 8 元/百万 = 32 元
- 闲时跑:输出侧 4,000,000 token × 4 元/百万 = 16 元
- 输入侧如果命中缓存,闲时 0.02 元/百万的价位,让输入成本几乎可以忽略
省下的这 16 元不多,但这是零改动、零风险的节省。当批量任务的规模到千万条量级,这就是一笔预算。
问题在于,把"错峰"写进 crontab 是不够的。你会遇到四个具体麻烦:
- 任务跑不完怎么办?跑到窗口结束,剩下的必须留到下一个窗口,不能顺手用高峰价发出去;
- 重复投递怎么办?脚本重跑一次,同一批任务发两遍;
- 部分失败怎么办?不能因为一条超时把整批重投;
- 到底省了多少?没有对账,错峰的收益就是自我安慰。
二、调度器要解决的四件事

我把它拆成四条约束,代码全部围绕它们写:
- 先落库,后调用。 任何一次请求发出前,任务必须在 SQLite 里存在。进程随时被 kill,重启后状态可恢复。
- 窗口判定显式带时区。 用
datetime而不是date比较,容器里跑 UTC 的时候才不会把"闲时"算成高峰。 - 幂等键。
job_id做主键,INSERT OR IGNORE,重复投递自动拦下。 - 按服务端 usage 对账。 不信自己的 token 估算,只信响应里的
usage字段。
展开讲一下第三、四条,这两条最容易被跳过,但恰恰是踩坑最多的地方。
幂等键不是"防重复提交"这么简单。 部分失败重放时,你要的是"失败的那几条重试",而不是"整批重投"。只要 job_id 稳定(比如用文档内容的 sha1),重放天然安全。反过来说,如果 job_id 每次都随机生成,重试就等于重复计费。
对账必须以服务端 usage 为准。 自己按字符数估 token,中文场景下偏差 10% 以上很正常,估出来的"省钱比例"就没有意义。代码里的 cost_of() 只做一件事:把响应里的 prompt_cache_hit_tokens / completion_tokens 按所属计价档乘上单价。
再补一句价格表的处理方式:生效价目里没有明确公布的字段,代码里留 None,对账时直接跳过并提示"未配置字段不计入"。宁可给一个下限估算,也不要算出一个看着完整、实际是编的数字。
三、关键实现

窗口判定。这是全篇最短也最容易写错的函数:
python
TZ_CN = timezone(timedelta(hours=8)) # 显式时区,绝不依赖系统本地时区
def in_offpeak(now: datetime) -> bool:
minutes = now.hour * 60 + now.minute
for start, end in OFFPEAK:
a, b = _hm(start), _hm(end)
if a <= b:
if a <= minutes < b:
return True
else: # 跨天窗口,例如 22:00-02:00
if minutes >= a or minutes < b:
return True
return False
注意 a <= b 的分支。优惠窗口经常是跨天的(凌晨开始、早上结束),用 date 比较或者假设 start < end 都会在跨天时失败。
未到窗口时不要 sleep 一分钟轮询,直接算出下一个窗口开启时刻,带 0~90 秒抖动后一次性睡过去。抖动是必要的:整点齐发会让同一批人在同一秒撞上速率限制。
python
def next_window_start(now: datetime) -> datetime:
today = now.replace(second=0, microsecond=0)
candidates = []
for start, _ in OFFPEAK:
a = _hm(start)
cand = today.replace(hour=a // 60, minute=a % 60)
if cand <= now:
cand += timedelta(days=1)
candidates.append(cand)
return min(candidates) + timedelta(seconds=random.randint(0, 90))
状态流转。看到 running 与 failed 两种状态应该能猜到设计意图:崩溃时可能留下 running 的僵尸任务,恢复时按 attempts 决定重试还是放弃。
python
def mark(con, job_id: str, **kw) -> None:
kw["updated"] = datetime.now(TZ_CN).isoformat(timespec="seconds")
sets = ", ".join(f"{k}=?" for k in kw)
con.execute(f"UPDATE jobs SET {sets} WHERE id=?", (*kw.values(), job_id))
con.commit()
限流退避。429 是错峰调度里最常见的错误,说明你和别人想的是同一件事。别用固定间隔硬刷:
python
if e.code == 429:
back = min(2 ** (attempts + 1) * 2, 120)
print(f" 429 限流,退避 {back}s")
time.sleep(back)
四、完整代码

保存为 offpeak_scheduler.py,Python 3.8+,只用标准库。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
offpeak_scheduler.py 按峰谷定价窗口调度的 LLM 批量任务队列(单文件,零第三方依赖)
设计目标
1. 任务只落库、不丢:先写 SQLite,再发请求;
2. 不在优惠时段就等待,而不是硬刷(避免高峰价 + 触发限流);
3. 幂等:同一 job_id 重复投递不会重复计费;
4. 可对账:按服务端返回的 usage 累加,输出「实付 / 高峰价 / 省下多少」。
用法
python offpeak_scheduler.py init --db jobs.db --demo
python offpeak_scheduler.py run --db jobs.db --dry-run --at "2026-09-12T07:00:00+08:00"
python offpeak_scheduler.py run --db jobs.db
python offpeak_scheduler.py report --db jobs.db
环境变量:LLM_API_KEY,LLM_BASE_URL
免责:价格表与优惠时段均为配置项,默认值取自 2026-09-10 官方公布口径,
未公布的字段留 None(不计入对账)。上线前请以官方文档核对 PRICES / OFFPEAK。
"""
import argparse
import json
import os
import random
import sqlite3
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
TZ_CN = timezone(timedelta(hours=8)) # 显式时区,绝不依赖系统本地时区
# 优惠时段(北京时间)。跨天窗口,判定必须用 datetime 而不是 date。
OFFPEAK = [("00:30", "08:30")]
# 价格表:元 / 百万 token。None = 未获取到官方口径,不计入对账。
PRICES = {
"peak": {"in_cache": None, "in_miss": None, "out": 8.00},
"offpeak": {"in_cache": 0.02, "in_miss": None, "out": 4.00},
}
# ---------- 时间窗口 ----------
def _hm(s: str) -> int:
h, m = s.split(":")
return int(h) * 60 + int(m)
def in_offpeak(now: datetime) -> bool:
"""now 必须带时区;返回是否落在任一优惠窗口内。"""
minutes = now.hour * 60 + now.minute
for start, end in OFFPEAK:
a, b = _hm(start), _hm(end)
if a <= b:
if a <= minutes < b:
return True
else: # 跨天窗口,例如 22:00-02:00
if minutes >= a or minutes < b:
return True
return False
def next_window_start(now: datetime) -> datetime:
"""返回下一个优惠窗口的开始时刻(带抖动,避免整点齐发)。"""
today = now.replace(second=0, microsecond=0)
candidates = []
for start, _ in OFFPEAK:
a = _hm(start)
cand = today.replace(hour=a // 60, minute=a % 60)
if cand <= now:
cand += timedelta(days=1)
candidates.append(cand)
return min(candidates) + timedelta(seconds=random.randint(0, 90))
# ---------- 存储 ----------
DDL = """
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
prompt TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
in_cache INTEGER NOT NULL DEFAULT 0,
in_miss INTEGER NOT NULL DEFAULT 0,
out INTEGER NOT NULL DEFAULT 0,
billed TEXT,
result TEXT,
err TEXT,
created TEXT NOT NULL,
updated TEXT
);
"""
def connect(path: str) -> sqlite3.Connection:
con = sqlite3.connect(path, timeout=30)
con.execute("PRAGMA journal_mode=WAL")
con.executescript(DDL)
return con
def enqueue(con, job_id: str, prompt: str) -> bool:
"""先落库,后调用。已存在则跳过(幂等)。"""
cur = con.execute(
"INSERT OR IGNORE INTO jobs(id, prompt, created) VALUES (?,?,?)",
(job_id, prompt, datetime.now(TZ_CN).isoformat(timespec="seconds")),
)
con.commit()
return cur.rowcount == 1
def take_pending(con, limit: int) -> list:
return con.execute(
"SELECT id, prompt, attempts FROM jobs WHERE state IN ('pending','failed') "
"ORDER BY created LIMIT ?", (limit,)
).fetchall()
def mark(con, job_id: str, **kw) -> None:
kw["updated"] = datetime.now(TZ_CN).isoformat(timespec="seconds")
sets = ", ".join(f"{k}=?" for k in kw)
con.execute(f"UPDATE jobs SET {sets} WHERE id=?", (*kw.values(), job_id))
con.commit()
# ---------- 调用 ----------
@dataclass
class Usage:
in_cache: int
in_miss: int
out: int
def _post(base_url: str, api_key: str, model: str, prompt: str, timeout: int = 120) -> dict:
body = json.dumps({
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
}).encode("utf-8")
req = urllib.request.Request(
base_url.rstrip("/") + "/chat/completions",
data=body,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def call_model(prompt: str, cfg, dry: bool):
"""返回 (文本, Usage)。dry=True 时走本地假实现,便于离线联调。"""
if dry:
time.sleep(0.01)
n_in = 120 + len(prompt) // 3
return ("[dry-run] " + prompt[:24], Usage(n_in // 2, n_in // 2, 60 + len(prompt) // 4))
data = _post(cfg.base_url, cfg.api_key, cfg.model, prompt)
u = data.get("usage", {}) or {}
cache_hit = int(u.get("prompt_cache_hit_tokens", 0) or 0)
in_all = int(u.get("prompt_tokens", 0) or 0)
text = data["choices"][0]["message"]["content"]
return text, Usage(cache_hit, max(in_all - cache_hit, 0), int(u.get("completion_tokens", 0) or 0))
def cost_of(billed: str, u: Usage) -> float:
p = PRICES.get(billed, {})
per_m = 1_000_000
total = 0.0
for key, tokens in (("in_cache", u.in_cache), ("in_miss", u.in_miss), ("out", u.out)):
price = p.get(key)
if price is None: # 未配置的价格不计入,避免算出假数字
continue
total += price * tokens / per_m
return total
# ---------- 主循环 ----------
def run(args) -> int:
cfg = argparse.Namespace(
base_url=args.base_url or os.environ.get("LLM_BASE_URL", "https://api.deepseek.com/v1"),
api_key=os.environ.get("LLM_API_KEY", ""),
model=args.model,
)
if not cfg.api_key and not args.dry_run:
print("[FATAL] 缺少 LLM_API_KEY;如需离线联调用 --dry-run", file=sys.stderr)
return 2
con = connect(args.db)
now = datetime.fromisoformat(args.at).astimezone(TZ_CN) if args.at else datetime.now(TZ_CN)
pending = take_pending(con, args.limit)
if not pending:
print(f"[{now:%Y-%m-%d %H:%M}] 无待处理任务")
return 0
print(f"[{now:%Y-%m-%d %H:%M} 北京] 待处理 {len(pending)} 条,"
f"当前处于{'优惠' if in_offpeak(now) else '高峰'}时段")
if not in_offpeak(now):
wake = next_window_start(now)
wait = (wake - now).total_seconds()
print(f" 非优惠时段,等待至 {wake:%Y-%m-%d %H:%M:%S}({wait/60:.1f} 分钟后),本轮不发请求")
if not args.force:
return 0
print(" [--force] 忽略窗口,按高峰价立即执行")
billed = "offpeak" if in_offpeak(now) and not args.force else "peak"
ok = fail = 0
paid = 0.0
for job_id, prompt, attempts in pending:
if not in_offpeak(datetime.now(TZ_CN)) and not args.force:
print(" 窗口结束,停止本轮")
break
mark(con, job_id, state="running", attempts=attempts + 1)
try:
text, u = call_model(prompt, cfg, args.dry_run)
except urllib.error.HTTPError as e:
body = e.read()[:200].decode("utf-8", "ignore")
mark(con, job_id, state="failed", err=f"HTTP {e.code}: {body}")
fail += 1
if e.code == 429: # 限流:指数退避,别用固定间隔硬刷
back = min(2 ** (attempts + 1) * 2, 120)
print(f" 429 限流,退避 {back}s")
time.sleep(back)
continue
except Exception as e: # 超时/网络抖动同样只重试单条
mark(con, job_id, state="failed", err=f"{type(e).__name__}: {e}")
fail += 1
continue
c = cost_of(billed, u)
paid += c
mark(con, job_id, state="done", result=text[:2000], err=None,
in_cache=u.in_cache, in_miss=u.in_miss, out=u.out, billed=billed)
ok += 1
print(f" 完成 {ok} 条 / 失败 {fail} 条 · 本轮实付约 ¥{paid:.4f}(按 {billed} 价)")
return 0
def report(args) -> int:
con = connect(args.db)
rows = con.execute(
"SELECT billed, COUNT(*), SUM(in_cache), SUM(in_miss), SUM(out) FROM jobs "
"WHERE state='done' GROUP BY billed"
).fetchall()
if not rows:
print("暂无已完成任务")
return 0
print(f"{'计价档':<10}{'条数':>6}{'缓存命中入':>12}{'未命中入':>12}{'出':>12}{'实付¥':>10}")
total_paid = total_peak = 0.0
for billed, n, ic, im, out in rows:
u = Usage(ic or 0, im or 0, out or 0)
paid = cost_of(billed, u)
peak = cost_of("peak", u)
total_paid += paid
total_peak += peak
print(f"{billed:<10}{n:>6}{u.in_cache:>12}{u.in_miss:>12}{u.out:>12}{paid:>10.4f}")
saved = total_peak - total_paid
pct = (saved / total_peak * 100) if total_peak else 0.0
print(f"\n按高峰价应付 ¥{total_peak:.4f} → 实付 ¥{total_paid:.4f},"
f"省下 ¥{saved:.4f}({pct:.1f}%)")
print("注:未配置价格的字段不计入,故上表为下限估算。")
return 0
def demo_init(args) -> int:
con = connect(args.db)
n = 0
for i in range(20):
if enqueue(con, f"job-{i:03d}", f"把下面这段技术文档总结成 3 条要点:doc-{i}"):
n += 1
print(f"已入队 {n} 条演示任务(其余为重复投递,被幂等键拦下)")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="峰谷定价下的 LLM 批量任务调度器")
sub = ap.add_subparsers(dest="cmd", required=True)
for name, fn in (("init", demo_init), ("run", run), ("report", report)):
p = sub.add_parser(name)
p.add_argument("--db", default="jobs.db")
p.add_argument("--limit", type=int, default=200)
p.add_argument("--model", default="deepseek-v4.1-flash")
p.add_argument("--base-url", default=None)
p.add_argument("--dry-run", action="store_true")
p.add_argument("--at", default=None, help="模拟当前时间,ISO8601 带时区")
p.add_argument("--force", action="store_true", help="忽略窗口,按高峰价立即执行")
p.add_argument("--demo", action="store_true", help="init 子命令:造演示任务")
p.set_defaults(func=fn)
args = ap.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
五、跑一遍看看

先造 20 条演示任务,然后用 --at 把"当前时间"分别设成高峰和闲时,验证窗口判定。--at 这个参数值得专门加:时间相关的逻辑不注入时钟就没法自动化验证,你不可能真的等到凌晨三点。
bash
python offpeak_scheduler.py init --db jobs_demo.db --demo
已入队 20 条演示任务(其余为重复投递,被幂等键拦下)
下午三点跑一次,它应该拒绝工作,并告诉你下一个窗口在哪:
bash
python offpeak_scheduler.py run --db jobs_demo.db --dry-run --at "2026-09-12T15:00:00+08:00"
[2026-09-12 15:00 北京] 待处理 20 条,当前处于高峰时段
非优惠时段,等待至 2026-09-13 00:30:34(570.6 分钟后),本轮不发请求
凌晨三点再跑,正常执行:
bash
python offpeak_scheduler.py run --db jobs_demo.db --dry-run --at "2026-09-12T03:00:00+08:00"
[2026-09-12 03:00 北京] 待处理 20 条,当前处于优惠时段
完成 20 条 / 失败 0 条 · 本轮实付约 ¥0.0053(按 offpeak 价)
最后对账:
bash
python offpeak_scheduler.py report --db jobs_demo.db
计价档 条数 缓存命中入 未命中入 出 实付¥
offpeak 20 1280 1280 1320 0.0053
按高峰价应付 ¥0.0106 → 实付 ¥0.0053,省下 ¥0.0053(49.8%)
注:未配置价格的字段不计入,故上表为下限估算。
省下 49.8%,接近理论上的"闲时五折"。差额来自缓存命中输入这一项:闲时 0.02 元/百万的价位在高峰档没有公布值,代码按未配置处理,所以 Peak 侧少算了一部分,实际节省只会略高于这个数。这就是"留 None 而不是猜一个数"的价值:结果偏保守,但方向不会骗人。
要挂到生产上,cron 只需要一条,让它每 30 分钟醒一次,非窗口期会立刻自己判断并退出:
bash
*/30 * * * * cd /opt/batch && LLM_API_KEY=xxx /usr/bin/python3 offpeak_scheduler.py run --db jobs.db >> run.log 2>&1
六、几个工程取舍
不过,有几个取舍值得单独说清楚,因为它们决定了这套东西能不能长期跑下去。
为什么用 SQLite,不用 Redis。 这个场景的瓶颈是"等待",不是"吞吐"。单机批处理用 SQLite 有 WAL 模式,写入足够、天然持久化、零运维;换成 Redis 你要额外担心持久化配置和内存上限。任务并发到需要几十个 worker 抢同一个队列时,再考虑 Redis 或 MQ 不迟。
为什么不用 Celery。 Celery 解决的是"把任务分发到多台机器",你要解决的是"把任务推迟到某个时间点"。为一个时间判断引入 broker + result backend,是负债而不是能力。
为什么不用 APScheduler 之类的时间库。 窗口判定就是两次整数比较,二十行代码。引入依赖的收益是负的。
--force 为什么要留。 线上总会有"这张报表客户现在就要"的场景。留一个显式的强制通道,比让同事偷偷改系统时间好。代价是它会按高峰价计费,所以代码里把它写进 billed 字段,账单上看得见。
幂等键怎么选。 演示里用的是顺序 id,生产环境建议用「文档内容 sha1 + 提示词模板版本号」。模板改了就应该重跑,内容没变就不该重复计费,这两件事都由键的设计决定。
七、踩坑清单
- 时区。 容器里默认 UTC,北京时间 03:00 在容器里是 19:00,正好落在高峰。代码里所有
now都显式声明时区,in_offpeak()拿到不带的时区对象时应该直接抛错而不是静默按本地时区算。 - 跨天窗口。
00:30到08:30看着不跨天,但窗口的概念是每天循环的;一旦你把窗口配成22:00-06:00,只用start < end判断的实现立刻失效。 - 整点齐发。 别在窗口开启的整秒启动全部任务,加随机抖动。这是错峰方案里最容易制造限流的地方,因为你想省钱的时候,别人也在想。
- 在窗口末尾开始的长任务。 循环里每次迭代都重新判断
in_offpeak(),窗口结束就停在安全点,不要在窗口最后一分钟起一个跑十分钟的请求。 - 对账窗口与实际计费不一致。 计费以服务端时间为准,客户端时钟有漂移。窗口边界前后 1 分钟内的请求,建议在对账时单独标出来核对,别默认自己一定在闲时。
- 把"缓存命中价"和"KV 压缩"混为一谈。 这是两件事:缓存命中省的是 prefill 的输入计算,KV Cache 压缩省的是显存占用。前者影响账单,后者影响你能开多长的上下文。
running状态的僵尸任务。 进程被 SIGKILL 时会留下running。恢复逻辑里要显式处理:超过 N 分钟仍为running的任务,按attempts决定重试或标记失败。- 别把延迟敏感任务塞进来。 这个调度器的语义是"可以等"。实时客服、在线搜索补全这类任务放进来,省下的钱不够赔体验。
八、留两个问题给你
- 你的批量任务里,有多少比例是真的可以等到凌晨执行?如果这个比例很高,卡点通常在哪一步,是调度、是上游数据就绪时间,还是业务方不接受延迟?
- 如果厂商把"闲时折扣"从五折改成七折甚至取消,你的成本模型还能站得住吗?换模型、换架构、还是压缩输入长度,你会先动哪一个?
- 你现在的批处理是怎么触发的,定时任务、消息队列,还是人工点一下?有没有遇到过跑到一半窗口结束、剩下任务按高峰价发出去的情况?
上面三个问题里如果有一个是你踩过的坑,欢迎在评论区留言,我会把典型做法整理成下一篇。
数据与事件来源
- DeepSeek V4.1 Flash 发布信息与定价(2026-09-10):模型规模 552B MoE、CED 非对称结构、输入激活 8B / 输出激活 16B、MIT 许可、9 月 14 日 12:00 起 V4 Pro 下线并路由至 V4.1 Flash,闲时输入(缓存命中)0.02 元/百万 token、输出 4 元、高峰输出 8 元。来源:DeepSeek 官方发布信息;转述见腾讯研究院 AI 速递(20260911)。上述价格口径已与官方发布信息及第三方转述交叉核对。
- 优惠时段的具体起止以 DeepSeek 开放平台定价文档为准;本文代码将窗口与价格全部做成配置项(
OFFPEAK/PRICES),未在公开口径中明确的价格字段留None且不计入对账,不做估算填充。 - 文中的命令输出为本机实际执行
offpeak_scheduler.py(--dry-run模式)所得,非示意性粘贴;文中所有金额均可用随文脚本复算核实。