用户在第 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 | 置信度 | 决策 |
|---|---|---|---|
| 1 | 10 | 0.30 | 继续 |
| 5 | 50 | 0.77 | 继续 |
| 6 | 60 | 0.83 | 继续(未过阈值) |
| 7 | 70 | 0.86 | 停止:confidence |
停止时已生成 70 token,距上限最多还能省 230 token。按上面的经济参数,净收益 272 微元。
把这套策略按能力套到 200 条长度样本上,并假设各能力在最小长度后的置信度已经足够(这是质量评测要验证的前提):
| 能力 | 请求数 | 停止长度 | 省下 token | 省下微元 | 占该能力 token |
|---|---|---|---|---|---|
| chat.reply | 140 | 60 | 12,658 | 25,316 | 60.11% |
| ticket.suggest | 40 | 120 | 8,487 | 16,974 | 63.87% |
| summary.long | 20 | 220 | 12,843 | 25,686 | 74.48% |
| 合计 | 200 | — | 33,988 | 67,976 | 65.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 不会因为用户切走而退回。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。