一次「夜里跑一下」的账单
「回声」的内容团队要每天检查 12 万条会话,找出辱骂、泄密和答非所问。第一版实现很直接:凌晨一点,一个脚本分页读会话,逐条调用在线网关的摘要能力,超时设 2 秒,失败重试三次。白天看没问题,月底账单却多出三成,而且在线高峰偶尔出现「转圈圈」。
排查后发现三件事同时发生。脚本和用户请求走同一个网关、同一份额度,凌晨的批量流量把当天剩余配额吃掉;每条请求都按在线语义重试,模型已经生成的部分被丢弃后重新计费;有一条会话因为内容被拦截,重试三次仍然失败,脚本把整个分页作业标记为失败,第二天从头再跑,又付了一遍钱。
这不是「脚本写得不好」,而是把离线作业当成了低优先级在线请求。两者的截止对象、失败单位和成本结构都不同。本节先把边界划清,再用一组可复算的数字说明边界落在哪里。
文中成本与耗时,除标注为本机测试输出外,都是示例样本上的运行结果,不对应任何真实厂商价格。
离线不是低优先级在线
最常见的误解是:离线就是优先级更低的在线,把 priority 调成 batch 就行。这个做法会在三个地方失效。
截止对象不同
在线请求的截止时间是「这一次调用必须在多少毫秒内返回」。离线作业的截止时间是「这个窗口结束前,这批条目要产出多少」。前者约束单次调用,后者约束整批进度。
把 6 小时写进每条请求的 deadlineMs 没有意义:网关的准入会按当前队列深度估算完成时间,批量流量一多就整批被拒;即使不拒,单条请求失败也无法表达「这条可以明天再算」。离线需要的是窗口预算与断点,不是一条很长的超时。
失败单位不同
在线失败的单位是「这次请求」,重试语义是整次重放。离线失败的单位是「这条条目」:一条坏数据不应该让 12 万条一起失败。离线作业需要逐条结果、可隔离的毒条目、可续跑的完成位点。
这条差异直接决定成本。在线重试会把已经消耗的输入 token 和固定开销再付一次;离线按条目重试,已完成的条目不重算。示例成本结构里,在线重试率 6%,离线重试率 2%,差距就来自这里。
成本结构不同
在线按请求付固定开销:鉴权、路由、连接、日志、一次请求的调度。离线把这些开销摊到一个作业上,但多了一次作业级的固定开销:清单生成、分片调度、结果落盘、监控与告警。
所以离线不是无条件更便宜。条数太少时,作业固定开销摊不薄,离线反而更贵。示例结构下,在线每条 636 微元,离线每条 429 微元,作业固定开销 50,000 微元,盈亏平衡点是 242 条:
ceil(50,000 / (636 - 429)) = 242
240 条的评测作业留在在线,不是因为它「不够批」,而是因为离线会多花 320 微元。这个结论必须由成本结构算出来,不能凭感觉。
预算归属不同
离线可以调度优先级低,但不能偷偷花在线的钱。它应该有独立的窗口预算与配额,失败与重试记在自己的账上。否则一次补跑就能把当天的在线余量吃光,而看板上只看到「在线错误率正常」。
卷一 8.x 讲过「先预留、再结算」;离线把这套口径用在窗口上:作业启动时按条数预留,结束时按实际用量结算,未完成部分释放或顺延。配额是准入,账单是事实,两者不能混。
四个判据
判定一个任务该不该移出在线路径,依次问四个问题:
- 结果是否进入用户等待路径?是,留在在线。
- 单条结果能等多久,窗口是否超过这个上限?超过,留在在线。
- 失败能否按条目隔离,是否允许部分成功?不能,需要为窗口预留重跑容量。
- 窗口容量是否够?不够,切出紧急子集或溢出部分回在线。
把这四条写成判定器,输入是作业画像,输出是在线、离线或混合,以及拆分条数。示例样本上的六个作业:
| 作业 | 条数 | 消费方式 | 窗口 | 判定 | 主要原因 |
|---|---|---|---|---|---|
| realtime.reply | 1 | 阻塞 | 0 | 在线 | 结果进入用户等待 |
| ad-hoc.summary | 40 | 延后 | 8h | 在线 | 低于 242 条 |
| nightly.eval | 240 | 延后 | 8h | 在线 | 低于 242 条 |
| invoice.extract | 800 | 延后 | 4h | 离线 | 容量足够,预留重跑 |
| campaign.titles | 5,000 | 延后 | 6h | 混合 | 200 条当天投放 |
| conversation.review | 120,000 | 延后 | 6h | 离线 | 窗口利用率 0.8 |
nightly.eval 是最容易被误判的一个:它有窗口、可延后、允许部分成功,看起来就该离线,但 240 条低于盈亏平衡点。把它留在在线不是保守,而是省钱。
invoice.extract 不允许部分成功,所以窗口容量要扣掉一次完整重跑。示例里容量 150,000、预留比例 0.2,有效容量 120,000;800 条只用了 0.67%,仍然安全。如果作业是 130,000 条,有效容量不够,判定器会把 10,000 条切回在线,而不是赌一次不会失败。
成本对比
同一组作业,与「全部走在线」比较:
| 作业 | 全在线(微元) | 计划(微元) | 节省(微元) | 节省率 |
|---|---|---|---|---|
| conversation.review | 76,320,000 | 51,530,000 | 24,790,000 | 32.48% |
| campaign.titles | 3,180,000 | 2,236,400 | 943,600 | 29.67% |
| invoice.extract | 508,800 | 393,200 | 115,600 | 22.72% |
| nightly.eval | 152,640 | 152,640 | 0 | 0 |
节省率随条数变化,不会固定在三成。条数越接近盈亏平衡点,节省越薄;低于平衡点,计划成本会反超。把这张表和判定器一起发布,业务方才能自己回答「我这个作业值不值得离线」。
混合方案只对真正离线的部分计作业固定开销。campaign.titles 的 200 条紧急条目走在线,4,800 条进入窗口,固定开销只付一次,所以仍有三成节省。如果为了省事把整批都塞进窗口,当天投放就会迟到;如果因为 200 条紧急就把 5,000 条全放在线,又白付了固定开销。
实现
下面的判定器不依赖调度框架,也不访问网络。它只做两件事:按四个判据给出放置方案,按成本结构算出与全在线的差额。
// file: src/perf/batch.ts
// tier: L1
// verify: node --test test/perf/batch.test.ts
import { AiError } from "../core/errors.ts";
export type Placement = "online" | "offline" | "hybrid";
export type Consumption = "blocking" | "deferred";
/** 作业画像:描述的是“一批条目”,不是“一次用户请求”。 */
export interface TaskProfile {
id: string;
items: number;
/** 单条结果最多能等多久;不是整个作业的截止时间。 */
itemToleranceMs: number;
/** 结果的新鲜度上限,超过就必须重算。 */
freshnessMaxAgeMs: number;
/** 本次作业允许占用的窗口长度。 */
windowMs: number;
partialSuccessAllowed: boolean;
consumption: Consumption;
/** 必须赶在窗口前产出的紧急条数,例如当天投放的商品。 */
urgentItems: number;
}
export interface PlacementPolicy {
/** 成本盈亏平衡条数,由 breakEvenItems 在给定成本结构下算出。 */
minItems: number;
/** 当前窗口内可处理的条数。 */
capacityPerWindow: number;
/** 全有或全无时为重跑预留的容量比例。 */
rerunReserveRatio: number;
}
export interface PlacementDecision {
taskId: string;
placement: Placement;
reasons: string[];
onlineItems: number;
offlineItems: number;
/** 作业条数 / 有效窗口容量;大于 1 表示一个窗口做不完。 */
windowUtilization: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `batch: ${message}`);
}
function requireCount(value: number, field: string, min = 0): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
/** 离线不是“低优先级在线”:判定必须同时看消费方式、时效、容量与失败单位。 */
export function classifyTask(task: TaskProfile, policy: PlacementPolicy): PlacementDecision {
requireCount(task.items, "items", 1);
requireCount(task.urgentItems, "urgentItems", 0);
requireCount(task.itemToleranceMs, "itemToleranceMs", 0);
requireCount(task.freshnessMaxAgeMs, "freshnessMaxAgeMs", 0);
requireCount(task.windowMs, "windowMs", 0);
requireCount(policy.minItems, "minItems", 0);
requireCount(policy.capacityPerWindow, "capacityPerWindow", 0);
if (!Number.isFinite(policy.rerunReserveRatio) || policy.rerunReserveRatio < 0 || policy.rerunReserveRatio >= 1) {
fail("rerunReserveRatio must be in [0, 1)");
}
if (task.urgentItems > task.items) fail("urgentItems cannot exceed items");
// 全有或全无时,窗口容量必须留出一次完整重跑,否则失败就必然错过窗口。
const capacity = task.partialSuccessAllowed
? policy.capacityPerWindow
: Math.floor(policy.capacityPerWindow * (1 - policy.rerunReserveRatio));
const utilization = capacity === 0 ? Number.POSITIVE_INFINITY : task.items / capacity;
const base = { taskId: task.id, onlineItems: 0, offlineItems: 0, windowUtilization: utilization };
if (task.consumption === "blocking") {
return { ...base, placement: "online", onlineItems: task.items, reasons: ["结果进入用户等待路径"] };
}
if (task.items < policy.minItems) {
return {
...base,
placement: "online",
onlineItems: task.items,
reasons: [`低于成本盈亏平衡点 ${policy.minItems} 条`],
};
}
if (task.urgentItems === task.items) {
return {
...base,
placement: "online",
onlineItems: task.items,
reasons: ["全部条目都要在窗口前产出"],
};
}
if (task.windowMs > task.itemToleranceMs || task.windowMs > task.freshnessMaxAgeMs) {
return {
...base,
placement: "online",
onlineItems: task.items,
reasons: ["窗口晚于结果可接受的新鲜度"],
};
}
const deferred = task.items - task.urgentItems;
if (deferred <= capacity) {
if (task.urgentItems === 0) {
return {
...base,
placement: "offline",
offlineItems: deferred,
reasons: ["允许延后消费,窗口容量足够"],
};
}
return {
...base,
placement: "hybrid",
onlineItems: task.urgentItems,
offlineItems: deferred,
reasons: ["紧急子集走在线,其余进入窗口"],
};
}
const online = task.items - capacity;
return {
...base,
placement: "hybrid",
onlineItems: online,
offlineItems: capacity,
reasons: [`窗口容量不足,${online} 条回落在在线路径`],
};
}
/** 成本结构:在线按请求付固定开销,离线按作业付一次固定开销。 */
export interface CostShape {
tokenMicrosPerItem: number;
onlineCallFixedMicros: number;
offlineJobFixedMicros: number;
/** 在线按整次请求重试,失败会重复输入与固定开销。 */
onlineRetryRate: number;
/** 离线按条目重试,已完成的条目不会重算。 */
offlineRetryRate: number;
}
export interface CostComparison {
onlineMicros: number;
plannedMicros: number;
savingMicros: number;
savingShare: number;
}
function requireRate(value: number, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
fail(`${field} must be in [0, 1]`);
}
return value;
}
export function onlineItemMicros(shape: CostShape): number {
const base = requireCount(shape.tokenMicrosPerItem, "tokenMicrosPerItem") +
requireCount(shape.onlineCallFixedMicros, "onlineCallFixedMicros");
return Math.ceil(base * (1 + requireRate(shape.onlineRetryRate, "onlineRetryRate")));
}
export function offlineItemMicros(shape: CostShape): number {
const base = requireCount(shape.tokenMicrosPerItem, "tokenMicrosPerItem");
return Math.ceil(base * (1 + requireRate(shape.offlineRetryRate, "offlineRetryRate")));
}
/** 离线固定开销 / 每条节省额;低于这个条数,离线一定更贵。 */
export function breakEvenItems(shape: CostShape): number {
const delta = onlineItemMicros(shape) - offlineItemMicros(shape);
if (delta <= 0) return Number.POSITIVE_INFINITY;
return Math.ceil(requireCount(shape.offlineJobFixedMicros, "offlineJobFixedMicros") / delta);
}
/** 与“全部走在线”比较;混合方案只对实际离线的那部分计入作业固定开销。 */
export function compareCost(decision: PlacementDecision, shape: CostShape): CostComparison {
const onlineCount = decision.onlineItems;
const offlineCount = decision.offlineItems;
const onlineAll = (onlineCount + offlineCount) * onlineItemMicros(shape);
const planned = onlineCount * onlineItemMicros(shape) +
(offlineCount > 0 ? requireCount(shape.offlineJobFixedMicros, "offlineJobFixedMicros") +
offlineCount * offlineItemMicros(shape) : 0);
return {
onlineMicros: onlineAll,
plannedMicros: planned,
savingMicros: onlineAll - planned,
savingShare: onlineAll === 0 ? 0 : (onlineAll - planned) / onlineAll,
};
}
判定顺序是有意安排的。先看消费方式,再看条数,再看时效,最后看容量。顺序换掉会得到不同结论:如果先看容量,一个 300 条的作业会被判成离线,但它其实还没过盈亏平衡点。
compareCost 对混合方案只计一次作业固定开销,因为在线子集和离线子集属于同一次作业发布。这个口径要和财务对齐:如果离线作业的固定开销实际按分片重复发生,offlineJobFixedMicros 要改成按分片计,盈亏平衡点也会随之上移。
测试与运行结果
本章测试文件覆盖 7.1 到 7.3 的三个模块。执行:
node --test test/perf/batch.test.ts
本机一次运行的真实输出摘要:
ℹ tests 32
ℹ suites 0
ℹ pass 32
ℹ fail 0
ℹ duration_ms 621.1925
边界类测试不只断言「大作业离线」,还断言了 240 条必须留在在线、130,000 条在全有或全无时要切出 10,000 条、窗口晚于新鲜度时必须回到在线。这些反例比正例更能防止判定器退化成「条数大于阈值就离线」。
与卷一 3.x、8.x 的衔接
卷一 3.x 把网关的职责收敛成契约、路由、流式、错误归一与计量,并明确了不放进网关的四件事。离线作业仍然使用同一套能力契约与模型档案,但作业调度、断点、窗口预算不属于网关。把批处理调度器塞进网关,会让网关依赖业务清单和作业状态,边界立刻消失。
卷一 8.x 的预算口径在离线场景要换单位:在线按请求预留,离线按窗口与条数预留;在线按请求结算,离线按条目结算并允许跨窗口顺延。相同的是「失败也要计量」:已经调用过的条目,即使最终被隔离,成本仍然真实发生。
离线可以排在在线之后调度,但它的截止、失败与成本语义是独立的。把它当成「低优先级在线」,三个语义都会丢。
小结
在线与离线的边界由四件事决定:结果是否有人等、窗口是否超过新鲜度、失败能否按条目隔离、窗口容量是否足够。离线不是低优先级在线:截止对象是窗口而不是单次调用,失败单位是条目而不是请求,成本结构里有一次作业固定开销,因此存在盈亏平衡点。
示例结构下这个点是 242 条;低于它的作业留在在线更便宜,高于它才值得进窗口。混合方案把紧急子集留在在线,其余进入窗口,但只为离线部分付一次作业固定开销。
下一节处理离线作业内部的三件事:怎样分片、重试预算放在哪里、幂等键如何防止重跑重复计费。
阅读导航:上一节:6.3 模型级联与成本收益 · 下一节:7.2 分片、重试与幂等 。
常见错误与边界
- 把离线当成低优先级在线。只改优先级,截止、失败与成本语义仍然是错的。
- 给每条离线请求设一个很长的超时。单条超时不能表达整批进度,也无法断点续跑。
- 用条数阈值拍脑袋。盈亏平衡点由固定开销与每条节省额算出来,换模型就变。
- 忽略全有或全无的重跑容量。不预留,一次失败就必然错过窗口。
- 把离线流量混进在线配额。补跑会吃光当天余量,看板还显示正常。
- 只比较单价,不比较作业固定开销。小作业离线一定更贵。
- 把紧急子集和普通条目一起塞进窗口。当天要用的结果会迟到。
- 让离线作业复用在线网关的准入与重试。整批被拒或整批重放,都是放大故障。
- 认为离线一定更便宜。条数、重试率、固定开销任一变化,结论都可能反转。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。