半截 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.reply | 300 | 30 | 0 | 330 | 否 |
| ticket.suggest | 220 | 22 | 4 | 246 | 否 |
| summary.long | 700 | 70 | 0 | 770 | 否 |
| ticket.suggest(硬上限 200) | 220 | 22 | 4 | 200 | 是,缺口 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的普通文本无条件重试。整段重来会翻倍成本,截断点还会漂移。 - 把停止词当成分隔符。停止词是“不要后面的内容”,不是“把内容切开”。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。