文章

LLM 输出长度治理生产实战:用 Finish Reason、Token 预算与续写策略避免答案半截

本文从大模型答案半截、续写失控和成本抖动入手,讲解如何用 Finish Reason、Token 预算、长度分级、续写账本和上线门禁治理输出完整性,减少用户看到不完整答案的风险。

背景:答案半截通常不是模型”不会写”,而是输出治理缺位

大模型应用上线后,经常会遇到一种很尴尬的问题:接口返回 200,前端也展示了内容,但用户看到的是半截答案。

LLM 输出长度治理生产实战:用 Finish Reason、Token 预算与续写策略避免答案半截

常见表现包括:报告写到第三节突然结束,代码块没有闭合,列表停在第 7 项,Markdown 表格少了最后几行,客服回复还没给结论就结束,Agent 计划只执行了一半却被当成完成。对用户来说,这不是”模型质量一般”,而是产品不可靠

这类问题的根因通常不是单一的 max_tokens 太小。真正的问题是系统没有把输出长度当成生产资源来治理:

  • 没有按任务类型设置预算
  • 没有读取并解释 finish_reason / stop_reason / finishReason
  • 没有区分自然结束、长度截断、安全拦截、工具调用和异常结束
  • 没有设计可控的续写策略

OpenAI 的文档说明,不同 API 使用 max_output_tokensmax_completion_tokens 控制输出上限,并建议通过明确长度指令、示例和 stop sequence 约束结果形态。Anthropic 文档则把 stop_reason 明确作为每次 Messages API 成功响应的一部分。Gemini API 的 FinishReason 也包含 STOPMAX_TOKENSSAFETYMALFORMED_FUNCTION_CALL 等停止原因。

这些资料共同说明一个工程事实:模型停止生成并不等于业务已经完成。生产系统必须把”为什么停止”纳入请求账本和业务判断。

核心原理:Finish Reason 是输出状态机,不是调试字段

先把供应商停止原因归一化

不同供应商对停止原因的命名不同:OpenAI 常见是 finish_reason,Anthropic 是 stop_reason,Gemini 是 finishReason。字段名不同,但生产平台可以归一成内部状态:

type NormalizedFinishReason =
  | "completed"           // 自然结束或命中预期 stop sequence
  | "max_output_reached"  // 达到最大输出 token
  | "tool_call"           // 模型需要工具调用或正在发起工具调用
  | "safety_blocked"      // 安全、隐私、版权或策略原因停止
  | "malformed_output"    // 工具调用、结构化片段或多模态结果异常
  | "cancelled"           // 用户或上游取消
  | "provider_error"      // 供应商异常
  | "unknown";

归一化的价值在于,业务系统不需要记住每个供应商的枚举细节,但又不能丢失原始原因。建议同时保存:

type FinishReasonRecord = {
  normalized: NormalizedFinishReason;
  provider: "openai" | "anthropic" | "gemini" | "other";
  rawFinishReason?: string;
  rawStopReason?: string;
  finishMessage?: string;
  usage?: {
    inputTokens?: number;
    outputTokens?: number;
    reasoningTokens?: number;
    totalTokens?: number;
  };
};

这里的 rawFinishReason 很重要:统一枚举方便策略判断,原始值方便排障和供应商迁移。

stop 不一定等于业务完整

很多团队看到 stopend_turnSTOP 就认为答案完成了。这个判断太粗。

模型自然停止只能说明它认为当前回复可以结束,或者遇到了配置的停止序列。它不能保证业务层的任务完整,例如:

  • 用户要求”列出 20 条”,模型自然写了 12 条就结束
  • 用户要求输出完整方案,模型写了背景和原理,但没有写上线检查
  • 用户要求 Markdown 表格,模型自然停止时表格仍不完整
  • stop sequence 设置过宽,提前截断了本该保留的内容
  • 模型因为”看起来够了”而停止,但业务要求必须覆盖固定字段

因此,生产系统应将 finish reason 校验业务完整性校验分开。前者回答”模型为什么停”,后者回答”任务是否完成”。

max_tokens / MAX_TOKENS 应视为高风险结果

当停止原因表示达到输出上限时,系统不应直接把结果标记为成功。它至少应该进入 truncated_candidate 状态。

原因很简单:模型不是在语义边界处主动结束,而是被硬限制打断。此时最后一段内容可能是半句话、半个代码块、半个 JSON 片段、半个工具参数,或者一个尚未完成的推理结论。

更稳妥的状态机如下:

model_response_received
  -> finish_reason_completed -> business_completeness_check -> complete | incomplete_needs_repair
  -> finish_reason_max_output_reached -> truncation_check -> auto_continue | ask_user | fail_with_partial | async_repair
  -> finish_reason_tool_call -> execute_tool_or_abort
  -> finish_reason_safety_blocked -> safety_policy_handling

不要把 max_tokens 当成普通成功,也不要无脑续写。它是一个需要策略判断的中间状态

工程落地:为每类任务建立输出预算

1. 按任务类型定义长度等级

不要让每个调用方随意传 max_output_tokens。平台应按任务类型定义默认预算、最大预算、是否允许续写、是否允许部分返回:

output_budget_profiles:
  short_answer:
    default_max_output_tokens: 300
    hard_max_output_tokens: 600
    allow_auto_continue: false
    partial_result_policy: show_with_warning
  long_report:
    default_max_output_tokens: 2500
    hard_max_output_tokens: 6000
    allow_auto_continue: true
    max_continuations: 2
    partial_result_policy: continue_before_show
  code_generation:
    default_max_output_tokens: 1800
    hard_max_output_tokens: 5000
    allow_auto_continue: true
    max_continuations: 1
    completeness_checks:
      - code_fence_closed
      - no_trailing_incomplete_function
  final_decision:
    default_max_output_tokens: 800
    hard_max_output_tokens: 1200
    allow_auto_continue: false
    partial_result_policy: fail_closed

这里的重点不是具体数值,而是把长度预算变成配置。客服短答、长报告、代码生成、合同审查、结构化抽取、Agent 总结,不应该共用同一套输出上限。

2. 请求前估算输出风险

调用模型前,系统已经知道任务类型、输入长度、用户期望、输出格式和模型上限。可以先做一次风险判断:

type OutputRisk = "low" | "medium" | "high";

function estimateOutputRisk(args: {
  taskType: string;
  requestedSections?: number;
  requestedItems?: number;
  inputTokens: number;
  maxOutputTokens: number;
  requiresCodeBlock?: boolean;
  requiresMarkdownTable?: boolean;
}): OutputRisk {
  let score = 0;
  if ((args.requestedSections ?? 0) >= 5) score += 2;
  if ((args.requestedItems ?? 0) >= 10) score += 2;
  if (args.requiresCodeBlock) score += 1;
  if (args.requiresMarkdownTable) score += 1;
  if (args.maxOutputTokens < 800 && args.taskType === "long_report") score += 3;
  if (args.inputTokens > 0.7 * 100000) score += 1;
  if (score >= 4) return "high";
  if (score >= 2) return "medium";
  return "low";
}

如果风险很高,系统可以提前做三件事:提高输出预算、要求模型先给目录再分段生成、或把任务改造成异步长任务。不要等答案半截后再补救

3. 响应后必须写入输出账本

每次模型调用都应记录输出账本,至少包括:

type OutputLedger = {
  requestId: string;
  tenantId: string;
  taskType: string;
  provider: string;
  model: string;
  promptVersion: string;
  requestedMaxOutputTokens: number;
  actualOutputTokens?: number;
  normalizedFinishReason: NormalizedFinishReason;
  rawFinishReason?: string;
  continuationOf?: string;
  continuationIndex: number;
  maxContinuations: number;
  completenessStatus: "complete" | "partial" | "unknown" | "failed";
  displayedToUser: boolean;
};

这张账本的价值很直接:你可以回答哪些任务最容易截断、哪个模型更常命中输出上限、哪些租户长期把预算打满、续写后成功率是多少,以及哪些前端页面正在展示不完整内容。

4. 完整性校验要按输出形态分层

不同输出形态有不同的完整性信号:

输出形态完整性检查重点
普通自然语言末尾是否以半句话、冒号、未完成列表项、未完成小节标题结束
Markdown代码块、表格、列表、引用块是否闭合
代码括号、函数、类、代码块 fences 是否明显不完整(轻量检查,非编译器)
结构化输出JSON/XML/YAML 是否可解析、必填字段是否齐全

一个简单的 Markdown 检查可以这样做:

function checkMarkdownCompleteness(text: string): string[] {
  const issues: string[] = [];
  const fenceCount = (text.match(/```/g) ?? []).length;
  if (fenceCount % 2 !== 0) {
    issues.push("unclosed_code_fence");
  }
  const trimmed = text.trim();
  if (/[::,,、]$/.test(trimmed)) {
    issues.push("ends_with_open_punctuation");
  }
  if (/^[-*]\s+$/m.test(trimmed)) {
    issues.push("empty_list_item");
  }
  return issues;
}

轻量检查不能证明答案完全正确,但能捕捉大量”明显半截”的情况。

续写策略:可以续,但必须有边界

续写请求要带上上下文和目标

当输出达到上限时,最简单的做法是再问一句”继续”。这在个人使用中可行,但生产系统不能这么粗糙。

更稳妥的续写请求应包含:

  • 上一次响应的摘要或最后稳定边界
  • 已完成章节或字段
  • 禁止重复已输出内容
  • 本次只补齐未完成部分
  • 续写次数上限
  • 新的输出预算

示例指令:

Continue the previous answer from the last complete section.
Do not repeat content already provided.
Complete only the unfinished parts.
If the previous answer ended in the middle of a list, continue the list numbering.
If you cannot safely continue, say: CANNOT_CONTINUE.

对于中文业务,也可以把这段指令内置在系统层,不必暴露给用户。

不要拼接两个语义不一致的答案

续写最大的风险是模型重新组织思路,导致前后不一致。例如:

  • 第一段说”推荐方案 A”,续写后又补出”因此选择方案 B”
  • 前文列了 1-5,后文又从 1 开始
  • 代码前半段用了某个变量名,后半段改了接口

因此续写后要做最小一致性校验

  • 是否重复大量前文
  • 是否从正确编号继续
  • 是否保持同一标题结构
  • 是否出现明显相反结论
  • 是否闭合代码块或表格
  • 是否超过最大续写次数

如果校验失败,不要把拼接结果直接展示给用户。可以改为提示”内容较长,已生成部分结果,可点击继续生成剩余部分”,或者转为异步任务。

高风险任务不应自动续写

以下任务不建议自动续写:

  • 会触发工具调用或外部副作用的 Agent 任务
  • 涉及支付、下单、审批、发邮件、改权限的任务
  • 医疗、金融、法律、保险等需要完整审查的结论
  • 安全策略、内容审核、拒答边界相关输出
  • 必须一次性签名、归档、审计的文档

这些场景下,达到输出上限应该进入人工确认、重新规划或异步生成,而不是静默续写。

多供应商适配:保留原始原因,统一业务动作

OpenAI、Anthropic、Gemini 和前端 AI SDK 都提供了停止原因相关字段,但语义和枚举不完全一致。Vercel AI SDK 这类统一接口会暴露 finishReasonrawFinishReason,这正好说明生产平台应该同时保存统一原因和原始原因。

推荐映射表如下:

供应商原始枚举归一化状态
OpenAIstopcompleted
lengthmax_output_reached
content_filtersafety_blocked
tool_callstool_call
Anthropicend_turncompleted
max_tokensmax_output_reached
stop_sequencecompleted
tool_usetool_call
pause_turnprovider_error
refusalsafety_blocked
GeminiSTOPcompleted
MAX_TOKENSmax_output_reached
SAFETYsafety_blocked
RECITATIONsafety_blocked
MALFORMED_FUNCTION_CALLmalformed_output
UNEXPECTED_TOOL_CALLmalformed_output
TOO_MANY_TOOL_CALLSmalformed_output

映射表应该版本化。供应商新增枚举时,默认不要当成成功,应先进入 unknownprovider_error,并告警给平台团队。

适用场景

这套输出长度治理适合以下场景:

  • 长报告、技术方案、论文摘要、SEO 文章生成
  • 代码生成、配置生成、脚本生成
  • 客服长答、知识库答复、合同条款解释
  • Agent 执行后的最终总结
  • 多供应商 LLM Gateway,需要统一 finish reason
  • 需要按租户控制 Token 成本和输出长度的 SaaS 产品

如果只是短问答或个人助手,可以先做最简单的检查:读取 finish reason,遇到 max token 时提示用户继续。但只要面向生产用户,就建议至少建立输出预算、账本和完整性校验。

常见误区

误区一:把 max output tokens 设得越大越好

过大的输出上限会增加成本、延迟和不可控内容长度,也可能让用户等待过久。正确做法是按任务类型设置预算,并对长任务使用分段生成或异步任务。

误区二:只靠提示词要求”回答完整”

提示词有帮助,但不是硬约束。模型仍可能达到输出上限、遇到安全拦截、工具调用中断或 stop sequence。生产系统必须读取响应元数据。

误区三:遇到截断就自动继续

自动续写适合低风险长文本,不适合所有业务。续写前要判断任务是否可拼接、是否有副作用、是否允许部分结果,以及是否已经达到续写上限。

误区四:只记录最终文本,不记录停止原因

没有 finish reason 和 usage,后续无法判断答案半截是预算太小、模型自然停、stop sequence 配错、安全策略触发,还是供应商异常。

误区五:把前端”继续生成”当成唯一方案

前端按钮只是交互入口。真正的续写应该由后端根据请求账本、任务类型、上一次输出边界和最大续写次数控制。

上线检查清单

输出预算

  • 是否按 task_type 定义默认输出上限和硬上限
  • 是否区分短答、长报告、代码、结构化结果、最终结论
  • 是否限制租户级和用户级的长输出请求
  • 是否记录 requested max tokens 和 actual output tokens

Finish Reason

  • 是否读取供应商原始 finish reason / stop reason
  • 是否归一化为内部枚举
  • 是否保留 raw reason
  • 新增未知枚举是否会告警

截断检测

  • max output reached 是否进入 partial 状态
  • Markdown、代码、表格、列表是否有轻量完整性检查
  • completed 状态是否仍会经过业务完整性校验
  • 是否能区分自然结束、长度截断、安全拦截和工具调用

续写策略

  • 是否配置 allow_auto_continue
  • 是否限制 max_continuations
  • 续写是否避免重复前文
  • 是否对高风险任务禁用自动续写
  • 拼接结果是否再次校验完整性

可观测性

  • 是否有 finish_reason 分布看板
  • 是否能按模型、供应商、任务类型、租户查看 max token 命中率
  • 是否监控续写成功率、续写失败率、用户手动继续率
  • 是否能回放一次半截答案的完整请求链路

结论

LLM 输出长度治理的关键,不是简单把 max_output_tokens 调大,而是把输出过程纳入生产控制面:请求前有预算,响应后看 finish reason,中间有账本,截断后有策略,展示前有完整性校验。

对用户来说,半截答案比慢一点更影响信任。对平台来说,输出上限又直接关联成本和延迟。只有把这两者统一起来,才能在”完整回答”和”成本可控”之间取得稳定平衡。

参考资料

常见问题

finish reason 是 stop 就一定代表答案完整吗?
不一定。stop 通常表示模型自然停止或遇到停止序列,但业务仍应结合章节完整性、输出格式、末尾语义和任务类型做完整性校验。
遇到 max_tokens 或 length 是否应该自动续写?
只适合无副作用、可拼接、语义连续的任务。涉及工具调用、审批、支付、合规结论或结构化结果时,应先进入校验或人工确认。
Token 预算应该由前端还是后端控制?
前端可以提供期望长度,但生产预算应由后端按任务类型、模型、租户、成本和输出契约统一裁决,并记录实际 finish reason 与 usage。
如何从根本上减少答案半截?
先按任务类型设置合理 Token 预算,再要求模型输出可分段结构;对长任务采用"先目录后分段"的生成模式;响应后读取 finish reason,并对 max output reached 做续写或降级处理。