背景:答案半截通常不是模型”不会写”,而是输出治理缺位
大模型应用上线后,经常会遇到一种很尴尬的问题:接口返回 200,前端也展示了内容,但用户看到的是半截答案。

常见表现包括:报告写到第三节突然结束,代码块没有闭合,列表停在第 7 项,Markdown 表格少了最后几行,客服回复还没给结论就结束,Agent 计划只执行了一半却被当成完成。对用户来说,这不是”模型质量一般”,而是产品不可靠。
这类问题的根因通常不是单一的 max_tokens 太小。真正的问题是系统没有把输出长度当成生产资源来治理:
- 没有按任务类型设置预算
- 没有读取并解释
finish_reason / stop_reason / finishReason - 没有区分自然结束、长度截断、安全拦截、工具调用和异常结束
- 没有设计可控的续写策略
OpenAI 的文档说明,不同 API 使用 max_output_tokens 或 max_completion_tokens 控制输出上限,并建议通过明确长度指令、示例和 stop sequence 约束结果形态。Anthropic 文档则把 stop_reason 明确作为每次 Messages API 成功响应的一部分。Gemini API 的 FinishReason 也包含 STOP、MAX_TOKENS、SAFETY、MALFORMED_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 不一定等于业务完整
很多团队看到 stop、end_turn 或 STOP 就认为答案完成了。这个判断太粗。
模型自然停止只能说明它认为当前回复可以结束,或者遇到了配置的停止序列。它不能保证业务层的任务完整,例如:
- 用户要求”列出 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 这类统一接口会暴露 finishReason 和 rawFinishReason,这正好说明生产平台应该同时保存统一原因和原始原因。
推荐映射表如下:
| 供应商 | 原始枚举 | 归一化状态 |
|---|---|---|
| OpenAI | stop | completed |
length | max_output_reached | |
content_filter | safety_blocked | |
tool_calls | tool_call | |
| Anthropic | end_turn | completed |
max_tokens | max_output_reached | |
stop_sequence | completed | |
tool_use | tool_call | |
pause_turn | provider_error | |
refusal | safety_blocked | |
| Gemini | STOP | completed |
MAX_TOKENS | max_output_reached | |
SAFETY | safety_blocked | |
RECITATION | safety_blocked | |
MALFORMED_FUNCTION_CALL | malformed_output | |
UNEXPECTED_TOOL_CALL | malformed_output | |
TOO_MANY_TOOL_CALLS | malformed_output |
映射表应该版本化。供应商新增枚举时,默认不要当成成功,应先进入 unknown 或 provider_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,中间有账本,截断后有策略,展示前有完整性校验。
对用户来说,半截答案比慢一点更影响信任。对平台来说,输出上限又直接关联成本和延迟。只有把这两者统一起来,才能在”完整回答”和”成本可控”之间取得稳定平衡。
参考资料
- OpenAI Help Center, Controlling the length of OpenAI model responses: https://help.openai.com/en/articles/5072518-controlling-the-length-of-openai-model-responses
- Anthropic Claude Docs, Stop reasons and fallback: https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons
- Google Gemini API, GenerateContent / FinishReason: https://ai.google.dev/api/generate-content
- AI SDK Core, Generating Text / finishReason and rawFinishReason: https://ai-sdk.dev/docs/ai-sdk-core/generating-text