《互联网产品的 AI 架构·推理性能与成本工程》4.2 流式输出的提前终止

用户在第 1.2 秒切走私信页,模型也可能在答案已经足够确定时继续生成。本节区分用户取消、置信度停止与预算耗尽三种提前终止,给出期望成本、盈亏平衡置信度与首帧后不重试的决策代码,并用 node --test 与 200 条示例样本的真实运行结果说明省下的 token 与赔掉的重问成本。

用户在第 1.2 秒切走

「回声」私信智能回复流式输出时,用户平均在 1.2 秒内决定是否继续看。产品做了两件事:切走时发取消;当模型已经给出足够确定的答案时,界面允许点“就这样”。服务端把两种都叫 early stop,但成本含义不同:用户取消是“不要了”,置信度停止是“够了”。前者省下后续 token,后者还要保证停下来的答案足够好。

卷一的 5.2 讲了取消如何传播、半截响应如何收敛,5.3 讲了首帧后不重试的错误语义。本节不重复协议,只算三件事:什么时候允许停、停下来省多少、停错了赔多少。

文中数字除本机测试输出外均为案例假设值。

三种提前终止

终止方式触发停止条件是否计费能否重试
用户取消页面隐藏、点停止立即已生成 token否
置信度足够打分器过线minTokens 且 confidence 过阈值已生成 token首帧后否
预算耗尽deadline 或 maxTokens到达边界全部已生成视错误类型

用户取消没有讨价空间:信号一到就停,已经生成的 token 照常计费。置信度停止有讨价空间:停早了答案不完整,停晚了白花钱。预算耗尽不是主动优化,而是兜底。

提前停止的期望账

省下的成本等于“预期完整长度减已生成长度”乘以每 token 微元;错误停止的期望损失等于“1 减置信度”乘以重问一次的成本。净收益是两者之差,盈亏平衡置信度等于 1 减“省下成本除以重问成本”。

示例:已生成 70 token,预期完整 240 token,每 token 2 微元,重问一次 480 微元。省下 170 token 即 340 微元;置信度 0.86 时错误概率 0.14,期望损失 68 微元,净收益 272 微元;盈亏平衡置信度 0.291667。置信度 0.20 时期望损失 384 微元,净收益 -44 微元,不值得停。

这张账最重要的结论是:经济盈亏平衡点可能明显低于产品质量阈值。案例里 29% 的置信度就不亏钱,但产品要求 85% 才允许停。成本模型不能替代质量判断,它只能回答“这个质量阈值在经济上是否划得来”。

首帧之后失败不重试

用户已经看到开头,重试会让同一段话出现两遍。卷一的 5.3 把这条约束定在错误语义里;本节把它带进成本:首帧后的失败,已生成 token 必须计费,且不能用一个“重试成功”的请求把账抹平。

场景是否重试已生成 token
首帧前失败,错误可重试是0 或少量输入
首帧后失败否全部计费
用户取消否全部计费
置信度停止否(首帧已发出)全部计费

这就是提前停止的代价:一旦按置信度停了,后面即使发现答案不好,也不能在同一个请求里续写。决策必须在停的那一刻完成,而不是等完整结果出来再后悔。

实现

控制器只做决策,不关闭连接。它接收增量文本、置信度和已流逝时间,返回“继续或停止、原因、已欠 token”。token 由注入的计数器算,测试里可以精确到个位。

// file: src/perf/output-stream.ts
// tier: L1
// verify: node --test test/perf/output-stream.test.ts

import { AiError } from "../core/errors.ts";

export interface EarlyStopPolicy {
  /** 少于这么多输出 token 时,即使置信度很高也不停止。 */
  minTokens: number;
  /** 输出预算硬上限,由 4.1 的 planOutputBudget 给出。 */
  maxTokens: number;
  deadlineMs: number;
  confidenceThreshold: number;
  /** 首帧之后失败不重试:停止后的结果就是最终结果。 */
  noRetryAfterFirstFrame: boolean;
  /** 结构化输出必须等结构闭合后才允许按置信度停止。 */
  requireClosure?: boolean;
}

export interface EarlyStopState {
  tokens: number;
  confidence: number;
  firstFrameAtMs: number | null;
  elapsedMs: number;
  closed: boolean;
  cancelled: boolean;
  cancelReason: string | null;
  stopped: boolean;
}

export type EarlyStopReason = "continue" | "confidence" | "max-tokens" | "deadline" | "user-cancel";

export interface EarlyStopDecision {
  stop: boolean;
  reason: EarlyStopReason;
  /** 已经生成、必须计量的 token。 */
  committedTokens: number;
  /** 停止时最多还能省下的 token,等于预算上限减已生成。 */
  savedTokensUpperBound: number;
}

/** 与卷一取消路径一致:CJK 约 1 token,ASCII 约 4 字符 1 token。 */
export function approximateOutputTokens(text: string): number {
  let tokens = 0;
  for (const char of text) tokens += (char.codePointAt(0) ?? 0) > 0x7f ? 1 : 0.25;
  return Math.ceil(tokens);
}

function fail(message: string): never {
  throw new AiError("invalid-request", `output-stream: ${message}`);
}

function requireInteger(value: number, field: string, min: number): number {
  if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
  return value;
}

function requireConfidence(value: number, field: string): number {
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
    fail(`${field} must be a finite number in [0, 1]`);
  }
  return value;
}

export interface EarlyStopOptions {
  policy: EarlyStopPolicy;
  /** 注入计数器,测试里可以直接按 token 计数。 */
  countTokens?: (text: string) => number;
}

/** 只负责“是否停、为什么停、欠了多少 token”,关闭连接交给卷一的取消信号。 */
export class EarlyStopController {
  readonly #policy: EarlyStopPolicy;
  readonly #count: (text: string) => number;
  #tokens = 0;
  #confidence = 0;
  #firstFrameAtMs: number | null = null;
  #elapsedMs = 0;
  #closed = false;
  #cancelled = false;
  #cancelReason: string | null = null;
  #decision: EarlyStopDecision | null = null;

  constructor(options: EarlyStopOptions) {
    const policy = options.policy;
    requireInteger(policy.minTokens, "minTokens", 0);
    requireInteger(policy.maxTokens, "maxTokens", 1);
    requireInteger(policy.deadlineMs, "deadlineMs", 1);
    requireConfidence(policy.confidenceThreshold, "confidenceThreshold");
    if (policy.minTokens > policy.maxTokens) fail("minTokens must not exceed maxTokens");
    this.#policy = { ...policy };
    this.#count = options.countTokens ?? approximateOutputTokens;
  }

  get state(): EarlyStopState {
    return {
      tokens: this.#tokens, confidence: this.#confidence,
      firstFrameAtMs: this.#firstFrameAtMs, elapsedMs: this.#elapsedMs,
      closed: this.#closed, cancelled: this.#cancelled,
      cancelReason: this.#cancelReason, stopped: this.#decision?.stop === true,
    };
  }

  markClosed(): void {
    this.#closed = true;
  }

  cancel(reason = "user"): EarlyStopDecision {
    this.#cancelled = true;
    this.#cancelReason = reason;
    return this.#finalize("user-cancel");
  }

  onDelta(text: string, confidence: number, elapsedMs: number): EarlyStopDecision {
    if (this.#decision !== null && this.#decision.stop) return this.#decision;
    requireConfidence(confidence, "confidence");
    this.#elapsedMs = requireInteger(elapsedMs, "elapsedMs", 0);
    if (text !== "") {
      this.#tokens += this.#count(text);
      this.#confidence = confidence;
      this.#firstFrameAtMs ??= elapsedMs;
    }
    if (this.#cancelled) return this.#finalize("user-cancel");
    if (this.#elapsedMs >= this.#policy.deadlineMs) return this.#finalize("deadline");
    if (this.#tokens >= this.#policy.maxTokens) return this.#finalize("max-tokens");

    const confidenceReached = this.#tokens >= this.#policy.minTokens
      && this.#confidence >= this.#policy.confidenceThreshold;
    const closureReady = this.#policy.requireClosure !== true || this.#closed;
    if (confidenceReached && closureReady) return this.#finalize("confidence");

    return {
      stop: false, reason: "continue", committedTokens: this.#tokens,
      savedTokensUpperBound: this.#policy.maxTokens - this.#tokens,
    };
  }

  #finalize(reason: Exclude<EarlyStopReason, "continue">): EarlyStopDecision {
    if (this.#decision !== null && this.#decision.stop) return this.#decision;
    this.#decision = {
      stop: true, reason, committedTokens: this.#tokens,
      savedTokensUpperBound: Math.max(0, this.#policy.maxTokens - this.#tokens),
    };
    return this.#decision;
  }
}

export interface StopEconomicsInput {
  emittedTokens: number;
  expectedFullTokens: number;
  confidence: number;
  microsPerToken: number;
  /** 错误停止后重新问一次的成本,包含输入与重建输出。 */
  reaskCostMicros: number;
}

export interface StopEconomics {
  savedTokens: number;
  savedMicros: number;
  wrongProbability: number;
  expectedLossMicros: number;
  netMicros: number;
  breakEvenConfidence: number;
  worthStopping: boolean;
}

/** 省下的是“本来还会生成的 token”,赔的是“停早了要重问”。 */
export function evaluateStopEconomics(input: StopEconomicsInput): StopEconomics {
  const emitted = requireInteger(input.emittedTokens, "emittedTokens", 0);
  const full = requireInteger(input.expectedFullTokens, "expectedFullTokens", 0);
  const micros = requireInteger(input.microsPerToken, "microsPerToken", 0);
  const reask = requireInteger(input.reaskCostMicros, "reaskCostMicros", 0);
  const confidence = requireConfidence(input.confidence, "confidence");
  const savedTokens = Math.max(0, full - emitted);
  const savedMicros = savedTokens * micros;
  const wrongProbability = 1 - confidence;
  const expectedLossMicros = Math.ceil(wrongProbability * reask - 1e-9);
  const rawBreakEven = reask === 0 ? 0 : Math.min(1, Math.max(0, 1 - savedMicros / reask));

  return {
    savedTokens, savedMicros, wrongProbability, expectedLossMicros,
    netMicros: savedMicros - expectedLossMicros,
    breakEvenConfidence: Math.round(rawBreakEven * 1_000_000) / 1_000_000,
    worthStopping: savedMicros - expectedLossMicros > 0,
  };
}

export interface StreamFailureInput {
  firstFrameSent: boolean;
  errorRetryable: boolean;
  noRetryAfterFirstFrame: boolean;
  committedTokens: number;
}

export interface StreamFailureDecision {
  retryable: boolean;
  committedTokens: number;
  reason: "retry-allowed" | "no-retry-after-first-frame" | "error-not-retryable";
}

/** 首帧之后失败不重试;已生成的 token 仍然计费。 */
export function decideStreamFailure(input: StreamFailureInput): StreamFailureDecision {
  const committedTokens = requireInteger(input.committedTokens, "committedTokens", 0);
  if (input.firstFrameSent && input.noRetryAfterFirstFrame) {
    return { retryable: false, committedTokens, reason: "no-retry-after-first-frame" };
  }
  if (!input.errorRetryable) {
    return { retryable: false, committedTokens, reason: "error-not-retryable" };
  }
  return { retryable: true, committedTokens, reason: "retry-allowed" };
}

两个刻意设计。第一,停止顺序是取消、截止、上限、置信度;取消和硬边界永远优先,置信度只在还有预算时才有意义。第二,第一次停止后决策被冻结,后续增量不能把流重新打开,这对应“首帧后不重试”的语义。

evaluateStopEconomics 是纯函数,可以在离线样本上先算一遍,再决定阈值。decideStreamFailure 把“能不能重试”和“已经花了多少”放在同一个返回值里,避免调用方只拿到一个布尔值就忘了计量。

示例样本上的运行结果

一次模拟流:每块 10 个 token,间隔 90ms,置信度依次为 0.30、0.52、0.63、0.71、0.77、0.83、0.86。策略是最少 60 token、阈值 0.85、上限 300。

块累计 token置信度决策
1100.30继续
5500.77继续
6600.83继续(未过阈值)
7700.86停止:confidence

停止时已生成 70 token,距上限最多还能省 230 token。按上面的经济参数,净收益 272 微元。

把这套策略按能力套到 200 条长度样本上,并假设各能力在最小长度后的置信度已经足够(这是质量评测要验证的前提):

能力请求数停止长度省下 token省下微元占该能力 token
chat.reply1406012,65825,31660.11%
ticket.suggest401208,48716,97463.87%
summary.long2022012,84325,68674.48%
合计200—33,98867,97665.88%

这些是示例样本上的运行结果,不是线上节省。真实收益取决于置信度打分器的准确率;如果打分器在长答案上过早给高分,省下的 token 会以返工和投诉的形式还回去。

测试与运行结果

测试覆盖最小长度、置信度停止、结构闭合门槛、硬上限与截止优先、取消、幂等、经济盈亏平衡、首帧失败与 token 估算。执行:

node --test test/perf/output-stream.test.ts

本机一次运行的真实输出摘要:

ℹ tests 12
ℹ pass 12
ℹ fail 0
ℹ duration_ms 158.712417

和卷一 5.2、5.3 的分工

5.2 的控制器回答“怎么停下来、半截结果怎么收敛”,本节回答“值不值得停、停了欠多少”。5.3 回答“哪些错误可以重试”,本节回答“首帧后不重试时,成本怎么记”。三节合起来才是完整的提前终止:协议负责传输,经济负责决策,计量负责结账。

小结

提前终止有三种:用户取消、置信度足够、预算耗尽。用户取消和硬边界没有讨价空间,置信度停止需要用期望账判断:省下的 token 减去错误停止的期望损失。经济盈亏平衡点通常低于质量阈值,所以成本模型不能替代质量门禁。首帧之后失败不重试,已生成 token 必须计费;停止决策一旦做出就冻结。

下一节把视线从“停得早不早”移到“写得多长”:输出长度如何影响延迟与成本,长尾为什么值得单独治理。

阅读导航:上一节:4.1 输出上限与停止条件 · 下一节:4.3 生成长度的非线性成本 。

常见错误与边界

  • 只设 maxTokens 兜底,不设置信度停止。模型会一直写到上限,省不下任何 token。
  • 在 minTokens 之前按置信度停止。开头几个 token 的置信度没有统计意义。
  • 把经济盈亏平衡点当成产品阈值。29% 不亏钱,不代表 29% 的答案可以交付。
  • 首帧后仍自动重试。用户会看到两份开头,且两次生成都要计费。
  • 停止后允许继续追加。决策被打开,客户端状态机无法收敛。
  • 用实际完整长度算节省。决策时不知道完整长度,只能用预期值,并明确这是估计。
  • 把示例样本的节省写成线上收益。置信度打分器的准确率才是决定因素。
  • 取消路径不计费。已生成的 token 不会因为用户切走而退回。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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