模型返回 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。上限建议同时设两道:

  1. maxContinues:例如 2~4 次。再多通常说明提示过长、字段过多,或模型在重复,继续烧 token 意义不大。
  2. 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 开大只能减少触发频率,挡不住半截数据入库。把「能否提交」从模型调用里拆出来,校验失败就拒绝,比事后修脏数据便宜得多。