模型返回 HTTP 200 且
choices非空,并不等于一次完整生成。finish_reason为length时,输出停在max_tokens边界上,半截 JSON 和半截 tool call 参数会直接打进校验层和业务库。本文用 Node.js 演示如何识别截断、按重叠窗口续写,并在 schema 通过前拒绝提交,同时给续写次数与 token 用量加上限。
生产里真正踩过的坑
线上最常见的误判是:请求没抛错,就把 message.content 当作成品。Chat Completions 的 HTTP 层只保证「这次调用完成了」,不保证「模型把话说完了」。配额用尽时接口仍然返回 200,choices[0].message.content 是一段看起来合法的字符串,只是停在句子、括号,或 tool arguments 的中间。
后续链路如果按成功处理,事故会分成几类。半截 JSON 让 JSON.parse 抛错,被笼统记成「模型胡言」;有的服务用正则抠字段,抠到残缺值后写入订单或工单表。更麻烦的是 tool call:function.arguments 本身是字符串,截断后不一定会在网关层失败,缺引号、缺字段的参数仍可能被业务代码往下传,工具带着错误参数去打支付、改库存。这类问题往往在校验层或对账时才暴露,而不是在模型调用当时。
把 max_tokens 调到很大,只能降低触发频率,不能当正确性保证。长列表、嵌套对象、多条 steps 仍然会顶到上限;费用和延迟先涨上去,截断还会在高峰或超长输入时出现。生产需要的是显式检测、有上限的续写,以及校验通过前的提交门禁,而不是靠把上限开满来赌完整输出。
检测 finish_reason,而不是看 HTTP 状态码
OpenAI Chat Completions 里,是否说完看的是 choices[0].finish_reason。需要当成一等公民的常见值包括:
stop:遇到自然停止或 stop sequence,通常表示这段生成结束。length:触达max_tokens或上下文上限,输出被截断。tool_calls:模型停下是为了调用工具;这不等于参数完整,仍应对arguments做 JSON 解析。content_filter:内容策略中断,应失败而不是续写。
不要把「有 content」当成「完整」。length 时 content 几乎总是非空的。也不要只在解析失败后再倒推截断:有的半截文本碰巧能 parse 成更短的对象,缺的是后面的字段,要靠 schema 才能拦住。流式场景里,finish_reason 出现在最后一个 chunk。应先累积 delta,结束后再判定;不要在流中途入库或触发工具。
下面这段检测可以单独运行,不依赖网络。它覆盖内容截断,以及 tool_calls 参数无法解析的情况。
export function isTruncatedChoice(choice) {
if (!choice) return true;
if (choice.finish_reason === "length") return true;
if (choice.finish_reason === "content_filter") return true;
if (choice.finish_reason === "tool_calls") {
const calls = choice.message?.tool_calls ?? [];
if (calls.length === 0) return true;
for (const call of calls) {
const raw = call.function?.arguments ?? "";
try {
JSON.parse(raw);
} catch {
return true;
}
}
}
return false;
}
const sample = {
finish_reason: "length",
message: {
role: "assistant",
content: '{"title":"支付 504","severity":"high"',
},
};
console.log(isTruncatedChoice(sample)); // true
如果同时对接其他提供方,建议在适配层把类似 Anthropic 的 stop_reason === "max_tokens" 归一成同一个 truncated 标记,后面的续写和门禁就不用写两套。截断之后不要执行 tool,不要把半截 JSON 写入主库,也不要向用户展示成已生成完毕。日志里应留下 finish_reason、已累计 token、续写次数,方便和用量对上。
重叠窗口续写,而不是整段重跑
识别到 length 之后,有两种常用策略。一是丢弃本次输出、加大 max_tokens 整段重试;二是把已生成前缀留给模型,让它从断开处接着写。前者实现简单,但前缀 token 会再付一次。后者能复用已生成内容,但必须处理重复输出。
不要假设模型会从最后一个字符精确接着写。常见情况是重复最后半句,或在 json_object 模式下重新开一个 {。合并时用重叠窗口:在旧文本后缀和新文本前缀里找一段相同子串,只拼接未重复的部分。找不到足够重叠就直接拼接,让后面的 schema 门禁否决,而不是在合并阶段「猜」正确位置。
response_format: { type: "json_object" } 能提高首包是 JSON 对象的概率,但不能保证续写轮次仍从断点继续。若模型每轮都重新输出完整对象,拼接结果会变成两个对象连在一起,JSON.parse 失败——这应视为失败,而不是再做一次字符串修补。严格的 Structured Outputs(json_schema)对历史消息更敏感,出问题时应停止缝合,改为整段重试并提高上限。
export function mergeByOverlap(previous, incoming, minOverlap = 24) {
if (!previous) return incoming;
const maxCheck = Math.min(previous.length, incoming.length, 200);
for (let n = maxCheck; n >= minOverlap; n -= 1) {
const suffix = previous.slice(-n);
if (incoming.startsWith(suffix)) {
return previous + incoming.slice(n);
}
}
return previous + incoming;
}
const previous = '{"title":"支付 504","severity":"high","ste';
const incoming = 'severity":"high","steps":["核对网关日志"],"owner":"oncall"}';
console.log(mergeByOverlap(previous, incoming));
续写提示应明确要求「从断开处继续、不要重复、不要解释、不要代码围栏」。不要写成「请输出完整 JSON」,那会诱导模型重开一个对象,重叠窗口就失效了。
Schema 校验通过前拒绝提交
续写循环停下来,只表示最近一次 finish_reason 不是 length,不表示对象能入库。应对拼接后的完整字符串做两件事:先 JSON.parse,再用 schema 校验。任一失败都视为本次任务失败,写下原因,不要部分提交。
下面把重叠合并、续写循环和门禁收成一份可运行脚本。依赖官方 openai SDK 与 zod,环境变量需要 OPENAI_API_KEY。Node.js 18+ 可直接把文件存为 continue-until-valid.mjs。为了本地看到截断,max_tokens 故意设得很小;正式环境按字段规模提高上限,但门禁不要拿掉。部分新模型用 max_completion_tokens 而不是 max_tokens,以该模型文档为准,不要两个一起传。
import OpenAI from "openai";
import { z } from "zod";
const client = new OpenAI();
const MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini";
const TicketSchema = z.object({
title: z.string().min(1),
severity: z.enum(["low", "medium", "high", "critical"]),
steps: z.array(z.string()).min(1),
owner: z.string().min(1),
});
function mergeByOverlap(previous, incoming, minOverlap = 24) {
if (!previous) return incoming;
const maxCheck = Math.min(previous.length, incoming.length, 200);
for (let n = maxCheck; n >= minOverlap; n -= 1) {
const suffix = previous.slice(-n);
if (incoming.startsWith(suffix)) {
return previous + incoming.slice(n);
}
}
return previous + incoming;
}
async function continueOnLength({
messages,
maxTokens,
maxContinues,
maxTotalTokens,
}) {
let assembled = "";
let continues = 0;
let tokensUsed = 0;
let finishReason = null;
while (continues <= maxContinues) {
const iterMessages = assembled
? [
...messages,
{ role: "assistant", content: assembled },
{
role: "user",
content:
"上次输出因长度限制被截断。请紧接着未完成处继续,不要重复已有内容,不要解释,不要使用 Markdown 代码围栏。",
},
]
: messages;
const res = await client.chat.completions.create({
model: MODEL,
messages: iterMessages,
max_tokens: maxTokens,
temperature: 0,
response_format: { type: "json_object" },
});
const choice = res.choices[0];
const chunk = choice?.message?.content ?? "";
finishReason = choice?.finish_reason ?? null;
const used = res.usage?.total_tokens ?? 0;
if (tokensUsed + used > maxTotalTokens) {
return {
ok: false,
reason: "token_budget_exceeded",
text: assembled,
finishReason,
tokensUsed: tokensUsed + used,
continues,
};
}
tokensUsed += used;
assembled = mergeByOverlap(assembled, chunk);
if (finishReason === "length") {
continues += 1;
continue;
}
if (finishReason === "content_filter") {
return {
ok: false,
reason: "content_filter",
text: assembled,
finishReason,
tokensUsed,
continues,
};
}
return {
ok: true,
text: assembled,
finishReason,
tokensUsed,
continues,
};
}
return {
ok: false,
reason: "max_continues_exceeded",
text: assembled,
finishReason,
tokensUsed,
continues,
};
}
export async function generateTicketOrRefuse(userPrompt) {
const messages = [
{
role: "system",
content:
"只输出一个 JSON 对象,不要 Markdown,不要解释。字段为 title、severity、steps、owner。severity 只能是 low、medium、high、critical。",
},
{ role: "user", content: userPrompt },
];
const gen = await continueOnLength({
messages,
maxTokens: 64,
maxContinues: 3,
maxTotalTokens: 4000,
});
if (!gen.ok) return gen;
let parsed;
try {
parsed = JSON.parse(gen.text);
} catch {
return { ok: false, reason: "invalid_json", ...gen };
}
const gated = TicketSchema.safeParse(parsed);
if (!gated.success) {
return {
ok: false,
reason: "schema_failed",
issues: gated.error.issues,
...gen,
};
}
return {
ok: true,
data: gated.data,
finishReason: gen.finishReason,
tokensUsed: gen.tokensUsed,
continues: gen.continues,
};
}
const result = await generateTicketOrRefuse(
"根据故障写工单:支付回调偶发 504,高峰期重试后仍有少量掉单,需要值班确认对账。",
);
if (!result.ok) {
console.error("拒绝提交", result.reason);
process.exitCode = 1;
} else {
console.log("通过门禁,可以入库");
console.log(JSON.stringify(result.data, null, 2));
}
console.log(result);
ok: false 时调用方只应记日志或进入人工队列,不要把 text 写入业务表。schema_failed 若要再试,应开一次新的生成(整段重试),而不是在已经失败的拼接结果上继续缝,并且必须占用同一套次数与 token 预算。
给续写次数和费用加上限
没有上限的续写,等于把一次 max_tokens 拆成多次之后的无限循环。每一轮都会再付 prompt(含系统提示和已生成前缀)和 completion。上限建议同时设两道:
maxContinues:例如 2~4 次。再多通常说明提示过长、字段过多,或模型在重复,继续烧 token 意义不大。maxTotalTokens:用接口返回的usage.total_tokens累加,超过即停。不要在业务代码里写死单价;单价会变,对账以官方用量字段为准。
失败原因要可区分:max_continues_exceeded、token_budget_exceeded、invalid_json、schema_failed、content_filter。运维可以分别告警。content_filter 不要续写。工具调用一旦判定截断,正确动作是不执行、不补一次「接着写 arguments」,而是失败或整段重试——补写工具参数比补写普通 JSON 更容易静默跑偏。
本地验证时可以把 max_tokens 降到 32 或 64,观察是否出现 length、续写后 schema 是否通过。验证完再改回业务上限。不要用「把上限开到模型允许的最大值」代替这套门禁。
总结
finish_reason=length 是截断信号,不是成功。生产路径应先识别截断(含不完整的 tool arguments),再用重叠窗口做有限续写,最后用 JSON.parse 加 schema 做提交门禁;次数和累计 token 必须封顶。把 max_tokens 开大只能减少触发频率,挡不住半截数据入库。把「能否提交」从模型调用里拆出来,校验失败就拒绝,比事后修脏数据便宜得多。
评论