《互联网产品的 AI 架构·推理性能与成本工程》4.1 输出上限与停止条件

「回声」客服的工单建议在 max_tokens 边界被截成半截 JSON,另一个能力又因上限过大把长回复的成本推高。本节把输出预算拆成目标、余量与闭包三段,给出 max_tokens 计算、跨增量块的停止序列扫描与截断分类的 TypeScript 实现,并用 node --test 的真实输出验证每一种截断的可用性与重试判断。

半截 JSON 与一张超长的账单

「回声」客服工作台的工单建议返回结构化 JSON。上线第二周,坐席反馈“订单建议”偶尔只显示一半:模型在 recommendations 数组中间停下,finish_reason 是 length。同一周,另一个“会话摘要”能力把 max_tokens 从 300 提到 1024,理由是“怕截断”,结果 P99 输出从 780 token 涨到 1100 token,成本上升 41%。

两个问题看起来相反,根因相同:团队把 max_tokens 当成一个凭感觉填的数字。它其实由三个部分组成:业务希望的长度、措辞抖动的余量、结构化输出收尾所需的闭包空间。少了任何一段,都会以截断或浪费的形式付账。

文中数字除标注为本机测试输出外,都是案例假设值。

上限由三段组成

目标长度不是模型上限

目标长度来自产品:智能回复希望 P90 在 300 token 内,工单建议希望 P90 在 220 token 内,摘要希望 P90 在 700 token 内。这些数字应该从长度分布里取,而不是抄模型的最大上下文。模型上限是硬边界,目标长度是产品承诺,两者混用会让“能生成多长”变成“应该生成多长”。

余量按比例,不按心情

模型不会每次都恰好写到目标长度。留 10% 的余量,是为了让“多解释一句”不至于触发截断;留 50% 则是把预算当保险,最后都变成账单。余量必须是计划的一部分,不能等到截断率升高再临时调大。

结构化输出要预留闭包

JSON 的最后几个字符没有内容价值,却是解析成功的前提。一个三层对象至少需要 }、}、] 三个闭符号,字符串还可能停在一半,需要闭引号。max_tokens 如果只按正文长度给,模型会在数组中间耗尽预算,返回一段永远无法解析的文本。

闭包预留按“一个符号一个 token”保守计算。真实分词器可能把 }] 合成一个 token,按更保守的口径算会多留一两个 token,但不会生成半截结构。

示例样本上的预算计划

在本机跑同一组参数,得到:

能力目标余量闭包maxTokens被硬上限截住
chat.reply300300330否
ticket.suggest220224246否
summary.long700700770否
ticket.suggest(硬上限 200)220224200是,缺口 46

最后一行是重点:硬上限低于“目标 + 余量 + 闭包”时,计划不隐藏缺口,而是把 shortfallTokens 如实报出来。团队要决定是换模型、降目标,还是接受更高截断率。

停止序列要在块边界上扫描

停止序列看起来只是 indexOf,真正难的是流式:用户设置的停止词可能被切成两块,第一块末尾是 \n,第二块开头才是另一个 \n。如果每块单独判断,停止词永远命中不了;如果等到整段结束再判断,停止就失去了“提前”的意义。

扫描器要维护一个很短的尾巴:末尾可能是某个停止序列前缀的部分先压住,等下一块到了再决定。压住的长度最多是“最长停止词减一”,所以内存不随输出增长。命中后,序列本身不进入结果,只保留它之前的文本。

同一位置命中多个序列时选最长的一个。比如 END 与 END! 同时命中,选 END!,避免把本应保留的 ! 当成停止后的残留。

截断要分类,不能只看结束原因

finish_reason 只回答“为什么停”,不回答“结果能不能用”。同样一个 length,普通文本是“读得懂但少一截”,结构化 JSON 是“完全无法解析”;同样一个 stop,可能是正常结束,也可能是模型在 JSON 中间提前收尾。

类型触发条件可用自动重试
complete正常结束,结构合法是否
stop-sequence命中停止词序列前有内容即可用否
max-tokens-valid命中长度上限,JSON 仍合法是否
max-tokens命中长度上限,结构未闭合否是
truncated-structure正常结束,结构未闭合否是
content-filter提供方拦截否否
empty没有任何内容否是

分类的价值在于把“截断率”拆成可行动的子项。max-tokens 升高,说明预算给少了或模型变啰嗦;truncated-structure 升高,说明提示没有要求模型收尾;stop-sequence 升高,可能是停止词误配,模型在正文里自然写出了这个词。

实现

代码分成三块:预算计划、停止序列扫描、截断分类。它不调用模型,也不发请求,输入输出都是普通对象,方便在网关、BFF 或测试里复用。

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

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

/** 结构化输出只需要知道“最深要闭合几层”,不需要完整 Schema。 */
export interface StructuredShape {
  maxDepth: number;
  openStringLikely?: boolean;
}

export interface OutputBudgetInput {
  capability: string;
  targetTokens: number;
  hardCapTokens: number;
  structured?: StructuredShape;
  marginRatio?: number;
}

export interface OutputBudgetPlan {
  capability: string;
  targetTokens: number;
  marginTokens: number;
  closureTokens: number;
  maxTokens: number;
  cappedByModel: boolean;
  closureGuaranteed: boolean;
  shortfallTokens: number;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `output: ${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;
}

/** 每个未闭合容器一个闭符号,字符串再预留一个闭引号;按保守口径算 token。 */
export function closureReserveTokens(shape: StructuredShape): number {
  requireInteger(shape.maxDepth, "structured.maxDepth", 1);
  return shape.maxDepth + (shape.openStringLikely === false ? 0 : 1);
}

/** 把产品目标、抖动余量与闭包空间合成 maxTokens;闭包装不下就拒绝计划。 */
export function planOutputBudget(input: OutputBudgetInput): OutputBudgetPlan {
  const target = requireInteger(input.targetTokens, "targetTokens", 1);
  const hardCap = requireInteger(input.hardCapTokens, "hardCapTokens", 1);
  const ratio = input.marginRatio ?? 0.1;
  if (!(ratio >= 0 && ratio < 1)) fail("marginRatio must be in [0, 1)");

  const margin = Math.ceil(target * ratio);
  const closure = input.structured ? closureReserveTokens(input.structured) : 0;
  const wanted = target + margin + closure;
  const maxTokens = Math.min(wanted, hardCap);
  const closureGuaranteed = maxTokens >= closure + 1;
  if (input.structured && !closureGuaranteed) {
    fail(`hardCapTokens ${hardCap} cannot fit closure ${closure} plus content`);
  }

  return {
    capability: input.capability,
    targetTokens: target,
    marginTokens: margin,
    closureTokens: closure,
    maxTokens,
    cappedByModel: wanted > hardCap,
    closureGuaranteed,
    shortfallTokens: Math.max(0, wanted - maxTokens),
  };
}

export interface StopHit {
  /** 停止序列第一个字符在输出中的码点下标。 */
  index: number;
  sequence: string;
  /** 停止序列之前的文本,序列本身不进入结果。 */
  keptText: string;
}

function indexOfSequence(haystack: readonly string[], needle: readonly string[]): number {
  if (needle.length === 0 || needle.length > haystack.length) return -1;
  outer: for (let start = 0; start <= haystack.length - needle.length; start += 1) {
    for (let offset = 0; offset < needle.length; offset += 1) {
      if (haystack[start + offset] !== needle[offset]) continue outer;
    }
    return start;
  }
  return -1;
}

/** 同一位置命中多个序列时选最长的一个,让“更具体”的停止词优先。 */
export function findStopSequence(text: string, sequences: readonly string[]): StopHit | null {
  const chars = Array.from(text);
  let best: StopHit | null = null;
  for (const sequence of sequences) {
    if (sequence === "") continue;
    const needle = Array.from(sequence);
    const index = indexOfSequence(chars, needle);
    if (index < 0) continue;
    const better = best === null || index < best.index
      || (index === best.index && needle.length > Array.from(best.sequence).length);
    if (better) best = { index, sequence, keptText: chars.slice(0, index).join("") };
  }
  return best;
}

export interface ScanResult {
  text: string;
  hit: StopHit | null;
}

/** 跨增量块扫描停止序列;末尾可能是序列前缀的部分必须压住,等下一块再判断。 */
export class StopSequenceScanner {
  readonly #sequences: string[][];
  #buffer: string[] = [];
  #emitted: string[] = [];
  #stopped = false;

  constructor(sequences: readonly string[]) {
    this.#sequences = sequences.filter((item) => item !== "").map((item) => Array.from(item));
  }

  get stopped(): boolean {
    return this.#stopped;
  }

  feed(chunk: string): ScanResult {
    if (this.#stopped) return { text: "", hit: null };
    this.#buffer.push(...Array.from(chunk));
    const hit = findStopSequence(
      this.#buffer.join(""),
      this.#sequences.map((sequence) => sequence.join("")),
    );
    if (hit !== null) {
      this.#stopped = true;
      const keptText = this.#emitted.join("") + hit.keptText;
      this.#buffer = [];
      return { text: hit.keptText, hit: { ...hit, keptText } };
    }

    const holdback = this.#holdback();
    const emitCount = Math.max(0, this.#buffer.length - holdback);
    const text = this.#buffer.slice(0, emitCount).join("");
    this.#emitted.push(...this.#buffer.slice(0, emitCount));
    this.#buffer = this.#buffer.slice(emitCount);
    return { text, hit: null };
  }

  #holdback(): number {
    const longest = Math.max(0, ...this.#sequences.map((sequence) => sequence.length - 1));
    const limit = Math.min(this.#buffer.length, longest);
    for (let length = limit; length > 0; length -= 1) {
      const suffix = this.#buffer.slice(this.#buffer.length - length);
      for (const sequence of this.#sequences) {
        if (length >= sequence.length) continue;
        if (sequence.slice(0, length).every((char, index) => char === suffix[index])) {
          return length;
        }
      }
    }
    return 0;
  }
}

export type OutputStopKind =
  | "complete" | "stop-sequence" | "max-tokens" | "max-tokens-valid"
  | "truncated-structure" | "content-filter" | "tool-call" | "empty";

export interface OutputClassificationInput {
  finishReason: "stop" | "length" | "tool_call" | "content_filter" | null;
  text: string;
  /** 结构化输出传解析结果;普通文本保持 undefined。 */
  structuredValid?: boolean;
  stopHit?: StopHit | null;
}

export interface OutputClassification {
  kind: OutputStopKind;
  usable: boolean;
  retryable: boolean;
  keptText: string;
  detail: string;
}

/** 截断不是布尔值:同时看结束原因、结构是否闭合、是否命中停止词。 */
export function classifyOutput(input: OutputClassificationInput): OutputClassification {
  const text = input.text;
  if (input.stopHit) {
    const kept = input.stopHit.keptText;
    return {
      kind: "stop-sequence", usable: kept.trim() !== "", retryable: false, keptText: kept,
      detail: `hit stop sequence ${JSON.stringify(input.stopHit.sequence)} at char ${input.stopHit.index}`,
    };
  }
  if (input.finishReason === "content_filter") {
    return { kind: "content-filter", usable: false, retryable: false, keptText: "", detail: "blocked by provider content filter" };
  }
  if (input.finishReason === "tool_call") {
    return { kind: "tool-call", usable: text !== "", retryable: false, keptText: text, detail: "tool call payload" };
  }
  if (text === "") {
    return { kind: "empty", usable: false, retryable: true, keptText: "", detail: "provider returned no content" };
  }
  if (input.finishReason === "length") {
    if (input.structuredValid === true) {
      return { kind: "max-tokens-valid", usable: true, retryable: false, keptText: text, detail: "hit token cap but structure is still valid" };
    }
    if (input.structuredValid === false) {
      return { kind: "max-tokens", usable: false, retryable: true, keptText: text, detail: "hit token cap before structure closed" };
    }
    return { kind: "max-tokens", usable: true, retryable: false, keptText: text, detail: "plain text is partial but readable" };
  }
  if (input.structuredValid === false) {
    return { kind: "truncated-structure", usable: false, retryable: true, keptText: text, detail: "model stopped before structure closed" };
  }
  return { kind: "complete", usable: true, retryable: false, keptText: text, detail: "completed with a valid ending" };
}

三个设计取舍。第一,闭包装不下时直接抛 invalid-request,而不是把 maxTokens 调到闭包以下生成一段注定失败的内容。第二,扫描器只压住“可能是前缀”的尾巴,不缓存整段输出,内存上限由停止词长度决定。第三,分类返回对象而不是抛异常,因为截断是预期事件,应该被计数、分流、降级,而不是打成 500。

测试与运行结果

测试覆盖预算拆分、闭包预留、停止序列命中与跨块扫描、各类截断分类、空输出与非法输入。执行:

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

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 13
ℹ pass 13
ℹ fail 0
ℹ duration_ms 133.424375

跨块扫描的测试值得单独看:第一块只喂到 \n,扫描器只放出前面的中文,把 \n 压住;第二块补上另一个 \n,命中发生在第一块与第二块的边界上,keptText 仍然是完整的前缀文本。这个用例如果写成“每块单独判断”,会稳定漏判。

和卷一的分工

卷一的 5.1 定了 StreamEvent 与 finishReason,8.2 把 maxOutputTokens 用作预留上界。本节不重复这两件事,只补上它们之间的空白:maxOutputTokens 应该填多少,以及拿到 finishReason 之后如何判断结果能不能用。

停止序列扫描也不替代卷一的取消信号。取消是用户或生命周期发起的“不要了”,停止词是内容层面的“到这里就够了”;前者关闭连接,后者只影响保留多少文本。

小结

输出上限由目标长度、抖动余量与闭包空间三段组成,max_tokens 是这三段的合成,不是模型能力的复制。结构化输出必须预留收尾空间,装不下时要报缺口而不是静默截断。停止序列要在增量块边界上扫描,命中后只保留序列之前的文本。截断要按结束原因、结构是否闭合、是否命中停止词分类,才能区分“够用”“残缺可重试”与“重试无意义”。

下一节处理另一种停止:用户不想等了,或者模型已经足够确定。提前终止省下的是真实 token,代价是首帧之后不能反悔。

阅读导航:上一节:3.3 上下文压缩与检索裁剪 · 下一节:4.2 流式输出的提前终止 。

常见错误与边界

  • 把 max_tokens 设成模型上限。上限越大,模型越可能写长,成本随之上浮。
  • 只按正文长度给预算,不预留 JSON 闭包。解析失败时还以为是模型不稳定。
  • 闭包装不下时静默截断。调用方拿到一定残缺的结果,重试也无法变好。
  • 停止序列逐块 indexOf。跨块命中的停止词永远扫不到。
  • 命中停止词后把序列本身留在结果里。下游会看到多余的 END 或换行。
  • 只看 finish_reason 判断截断。stop 也可能是结构没闭合的提前收尾。
  • 对 max-tokens 的普通文本无条件重试。整段重来会翻倍成本,截断点还会漂移。
  • 把停止词当成分隔符。停止词是“不要后面的内容”,不是“把内容切开”。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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