《互联网产品的 AI 架构·推理性能与成本工程》7.1 在线与离线的边界

「回声」把 12 万条会话质检塞进在线网关,结果抢走配额、重复计费、单条超时。本节用消费方式、时效、失败单位与容量四个判据划出在线与离线的边界,给出 242 条的盈亏平衡点、混合切分方法与可运行的 TypeScript 判定器,并说明离线为何不是低优先级在线。

一次「夜里跑一下」的账单

「回声」的内容团队要每天检查 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 讲过「先预留、再结算」;离线把这套口径用在窗口上:作业启动时按条数预留,结束时按实际用量结算,未完成部分释放或顺延。配额是准入,账单是事实,两者不能混。

四个判据

判定一个任务该不该移出在线路径,依次问四个问题:

  1. 结果是否进入用户等待路径?是,留在在线。
  2. 单条结果能等多久,窗口是否超过这个上限?超过,留在在线。
  3. 失败能否按条目隔离,是否允许部分成功?不能,需要为窗口预留重跑容量。
  4. 窗口容量是否够?不够,切出紧急子集或溢出部分回在线。

把这四条写成判定器,输入是作业画像,输出是在线、离线或混合,以及拆分条数。示例样本上的六个作业:

作业条数消费方式窗口判定主要原因
realtime.reply1阻塞0在线结果进入用户等待
ad-hoc.summary40延后8h在线低于 242 条
nightly.eval240延后8h在线低于 242 条
invoice.extract800延后4h离线容量足够,预留重跑
campaign.titles5,000延后6h混合200 条当天投放
conversation.review120,000延后6h离线窗口利用率 0.8

nightly.eval 是最容易被误判的一个:它有窗口、可延后、允许部分成功,看起来就该离线,但 240 条低于盈亏平衡点。把它留在在线不是保守,而是省钱。

invoice.extract 不允许部分成功,所以窗口容量要扣掉一次完整重跑。示例里容量 150,000、预留比例 0.2,有效容量 120,000;800 条只用了 0.67%,仍然安全。如果作业是 130,000 条,有效容量不够,判定器会把 10,000 条切回在线,而不是赌一次不会失败。

成本对比

同一组作业,与「全部走在线」比较:

作业全在线(微元)计划(微元)节省(微元)节省率
conversation.review76,320,00051,530,00024,790,00032.48%
campaign.titles3,180,0002,236,400943,60029.67%
invoice.extract508,800393,200115,60022.72%
nightly.eval152,640152,64000

节省率随条数变化,不会固定在三成。条数越接近盈亏平衡点,节省越薄;低于平衡点,计划成本会反超。把这张表和判定器一起发布,业务方才能自己回答「我这个作业值不值得离线」。

混合方案只对真正离线的部分计作业固定开销。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 分片、重试与幂等 。

常见错误与边界

  • 把离线当成低优先级在线。只改优先级,截止、失败与成本语义仍然是错的。
  • 给每条离线请求设一个很长的超时。单条超时不能表达整批进度,也无法断点续跑。
  • 用条数阈值拍脑袋。盈亏平衡点由固定开销与每条节省额算出来,换模型就变。
  • 忽略全有或全无的重跑容量。不预留,一次失败就必然错过窗口。
  • 把离线流量混进在线配额。补跑会吃光当天余量,看板还显示正常。
  • 只比较单价,不比较作业固定开销。小作业离线一定更贵。
  • 把紧急子集和普通条目一起塞进窗口。当天要用的结果会迟到。
  • 让离线作业复用在线网关的准入与重试。整批被拒或整批重放,都是放大故障。
  • 认为离线一定更便宜。条数、重试率、固定开销任一变化,结论都可能反转。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查