有个 AI 功能,用大模型根据用户的数据生成一份分析报告——通常是几百上千字的长文本。开发时我拿短一点的例子测,生成得有头有尾、完美收官。可上线后,用户开始投诉:生成的报告经常莫名其妙地"戛然而止"——写到一半,一句话说到中间,就突然没了,像被人硬生生掐断了一样;有时生成的是 JSON,结果是一段残缺的、括号都没闭合的、无法解析的半截 JSON。诡异的是,内容本身质量不错,就是"不完整",而且断在哪儿似乎没什么规律。
我盯着那些被"掐断"的输出查了好一阵,真相浮出水面,而且简单得让我有点哭笑不得:是我给大模型设的 max_tokens(最大输出 token 数)太小了,模型还没把话说完,就撞到了这个输出长度的上限,被强行截断了。更关键的是,我的代码完全没有去检查模型返回里的 finish_reason 字段——这个字段会明明白白地告诉你"模型这次是为什么停下来的":是 stop(它自然地说完了),还是 length(它是因为撞到了 max_tokens 上限、被截断了)。我那些"残缺的报告",finish_reason 全是 length,模型其实在用这个字段喊"我还没说完、是被你掐断的!",可我的代码对这个信号视而不见,把半截的输出当成完整的结果返回给了用户。
这就是 LLM 应用里一个朴素、却极其常见、又极易被忽视的坑:输出被 max_tokens 截断,而代码没有检查 finish_reason、把残缺的结果当完整的用了。它在短输出时毫无征兆(因为没触及上限),却会在长输出场景下频繁地、无规律地"掐断"模型的回答。这篇文章,就从这次"AI 报告总是说一半"的事故出发,把大模型输出长度、token 限制、流式输出这些工程细节,一次讲透。
先摆几个关于 LLM 输出的想当然
动手复盘前,先把我自己曾经深信、后来被这次截断教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "模型会把话说完整, 不用管长度" | 它受 max_tokens 限制, 超了就被硬截断 |
| "返回的内容就是完整的结果" | 可能是被截断的半截, 要看 finish_reason 才知道 |
| "max_tokens 设多大无所谓" | 设小了截断, 设大了费钱、还可能超模型上限 |
| "短输出测试没问题, 长的也没问题" | 短的没触及上限, 长的才会暴露截断 |
| "生成长文就等着一次性返回就行" | 长生成耗时久, 不用流式输出体验极差、易超时 |
这些念头的共同病根,是把大模型的输出,想当然地当成了一个"总会完整、想多长就多长"的东西,却忽略了一个硬约束:模型的单次输出,是有 token 数量上限的(由 max_tokens 参数和模型自身的上下文窗口共同限定);而当它撞到这个上限时,会被毫不留情地"截断",哪怕话只说了一半。要看清这次事故,得先理解这个输出长度的限制。
第一件事:输出有长度上限,撞上就被硬截断
理解这个坑的关键,是认清大模型输出的两个长度约束。第一个是你在调用时设的 max_tokens 参数——它明确告诉模型"这次最多生成这么多个 token";第二个是模型本身的上下文窗口上限(输入 + 输出的 token 总和不能超过它)。当模型生成的内容,撞到这两个限制中先到的那一个时,它就会立刻停止生成、把已经生成的部分返回——不管这时候话有没有说完、句子是否完整、JSON 是否闭合。它是"写到第 N 个 token,时间到、强制交卷",而不是"写完一个完整的回答再交卷"。
而模型在返回结果时,会用一个 finish_reason(完成原因)字段,诚实地告诉你它"为什么停下来":stop 表示"它自然地、完整地说完了";length 表示"它是因为撞到了长度上限、被强制截断的"。这是判断"输出到底完不完整"的唯一可靠依据。我那次的全部问题,就在于:输出被截断了(finish_reason 是 length),而我的代码根本没看这个字段,把半截输出当完整的用了。下面这张图,把这个机制画出来:
看懂这张图,事故的根就清楚了:输出被截断这件事,模型其实已经通过 finish_reason=length 明明白白地告诉你了——是我的代码没去"听"这个信号,才把残缺当完整。这也揭示了一个关键认知:消费大模型的输出时,不能只取 content 就完事,必须同时检查 finish_reason,确认这次输出到底是"说完了"还是"被掐断了"。接下来,我们就看怎么正确地处理它。
第二件事:必须检查 finish_reason + 合理设 max_tokens
根治的第一步,是每次消费大模型输出时,都检查 finish_reason,确认输出是完整的(stop)还是被截断的(length);并把 max_tokens 设到一个能容纳你预期输出的合理值。
# 反例:只取 content, 不看 finish_reason, 把半截当完整用
resp = client.chat.completions.create(model="...", messages=msgs, max_tokens=500)
report = resp.choices[0].message.content # 可能是被截断的半截!
# 正解:检查 finish_reason, 并设合理的 max_tokens
resp = client.chat.completions.create(model="...", messages=msgs, max_tokens=4000)
choice = resp.choices[0]
content = choice.message.content
if choice.finish_reason == "length":
log.warning("LLM 输出被 max_tokens 截断, 内容不完整")
content = handle_truncated(content, msgs) # 要应对, 见下节
# finish_reason == "stop" 才是真正完整的输出
return content
关于 max_tokens 怎么设,有个权衡:设小了长输出会被截断;设大了要注意它和输入加起来不能超过模型上下文窗口总上限,且更大输出潜力意味着更高成本和延迟。合理做法是根据这个功能"预期的最长输出"来设并留余量;同时在提示词里引导模型"控制在多少字以内"。核心:max_tokens 要和业务预期匹配,既别小到截断正常输出,也别大到浪费和超限。
第三件事:输出真的很长怎么办?——续写或分段生成
有时输出确实长到超过单次 max_tokens(甚至模型上下文窗口)的极限,这种情况光调大解决不了,得用"分段生成"或"续写":把一个超长生成任务拆成多次调用,每次生成一部分再拼接。
# 超长输出: 检测到被截断(length), 就让模型"接着上次继续写"
def generate_long(msgs, max_rounds=5):
full = ""
for _ in range(max_rounds):
resp = client.chat.completions.create(model="...", messages=msgs, max_tokens=4000)
choice = resp.choices[0]
full += choice.message.content
if choice.finish_reason == "stop":
break # 模型说完了, 结束
if choice.finish_reason == "length":
# 被截断了, 把已生成的内容加进对话, 让它接着写
msgs.append({"role": "assistant", "content": choice.message.content})
msgs.append({"role": "user", "content": "请接着上面的内容继续写完。"})
else:
break
return full
# 更好的思路: 结构化长内容(如报告)拆成"先生成大纲, 再逐段生成"
# 让每次的输出都稳稳落在 max_tokens 之内, 结构也更可控
两种应对超长输出的思路:一种是"续写"——检测到截断后把已生成内容塞回对话、让模型接着写,循环到 finish_reason=stop;另一种、往往更好的是"分而治之"——在设计上就把大任务拆成多个小任务(先大纲、再逐段),让每次调用的输出都落在 max_tokens 之内。后者不仅避免截断,长文结构也更清晰可控、质量更高。核心:不要让模型"一口气吐出一篇巨著",而要把大任务拆成它能从容完成的小块——这既绕开长度限制,也符合"让模型做规模适中的事"的工程原则。
第四件事:长生成必须用流式输出(streaming)
生成长文本还有一个体验和稳定性的大问题:耗时。大模型是一个 token 一个 token 往外蹦的,生成几千字可能要十几秒甚至几十秒。如果用"非流式"方式调用——傻等模型把整篇生成完再一次性返回——用户会对着转圈圈干等十几秒,体验极差;而且这么长的单次请求极容易撞上各种超时,导致请求直接失败。正确做法是用流式输出(streaming):模型生成一点就实时推给前端,让文字像打字一样一个个蹦出来。
# 流式输出:模型生成一点就推一点, 而不是等全部生成完再返回
stream = client.chat.completions.create(
model="...", messages=msgs, max_tokens=4000, stream=True) # 开启流式
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
send_to_frontend(delta) # 实时推给前端, 像打字机一样显示
fr = chunk.choices[0].finish_reason
if fr == "length":
log.warning("流式输出也被截断了") # 流式同样要检查截断!
# 体验: 用户立刻看到文字往外冒, 而不是干等十几秒看转圈
流式输出带来两大好处:其一,体验极大提升——用户几乎立刻看到内容开始往外冒(首字延迟很低),而非对着空白干等;这也是所有 AI 对话产品都"打字机式"蹦字的原因。其二,规避超时——连接持续不断有数据流过来,不会因"长时间无响应"被网关/客户端判定超时。对任何"生成较长内容"的 AI 功能,流式输出几乎是必选项——既是体验刚需,也是稳定性保障。(流式模式下,检查 finish_reason 依然要做,它在最后一个数据块里。)
第五件事:别忘了"输入"也会撑爆上下文窗口
前面聊"输出"被截断,但还有一个对称的坑在"输入"端:模型的上下文窗口,是"输入 token + 输出 token"的总和上限。如果输入太长(塞了超长文档、或一大堆对话历史),它可能本身就快把窗口占满,没给输出留多少空间——后果是:要么因"输入+max_tokens 超过窗口上限"直接报错,要么输出空间被挤压、稍长就被截断。
# 输入太长, 挤占输出空间, 甚至超过上下文窗口总上限
# 上下文窗口 = 输入 tokens + 输出 tokens, 是一个总预算!
input_tokens = count_tokens(prompt) # 估算输入占多少 token
context_limit = 128000 # 模型的上下文窗口上限
available_for_output = context_limit - input_tokens # 留给输出的空间
if available_for_output < expected_output_tokens:
prompt = compress_input(prompt) # 输出空间不够: 摘要/裁剪输入
# 用 tiktoken 等库可精确估算文本 token 数
# 常见"输入膨胀"来源:
# - 塞进去的长文档/检索结果没裁剪(呼应 RAG: 结果要截断)
# - 多轮对话历史越积越长(呼应 Agent 上下文: 要摘要压缩)
这个坑提醒我们:要有"token 预算"的意识——把上下文窗口看成一个"输入和输出共享的、固定大小的总预算",输入吃得越多,留给输出的就越少。所以处理长输入时(尤其 RAG 检索结果、长对话历史),要主动裁剪、摘要、压缩,给输出留足空间。这和之前聊 RAG、聊 Agent 上下文管理一脉相承——它们本质上都是在管理"上下文窗口"这个有限的总预算。把输入、输出都纳入这个预算通盘考虑,才能既不报错、也不被截断。
第六件事:截断之后怎么优雅应对
最后,即便做了上面这些,万一还是检测到输出被截断(finish_reason=length),也要有优雅的应对,而非把残缺品直接抛给用户。
# 检测到截断后的几种应对:
def handle_truncated(content, msgs):
# 方式1:续写——让模型接着写完(见第三节), 适合长文生成
# 方式2:结构化输出(JSON)截断 = 失败, 直接重试或报错, 别用半截
# 方式3:关键场景给用户明确提示, 而非残缺内容:
return content + "\n\n[内容较长, 已截断, 请点击继续生成]"
# 方式4:记录监控——统计"截断率", 偏高说明 max_tokens 普遍设小了
# 核心: 截断是"已知异常", 要显式处理, 绝不能把半截当完整悄悄发出去
应对截断的核心原则:截断是一种"已被 finish_reason 明确告知的异常状态",必须显式地、有意识地处理它,绝不能像我那次一样视而不见、把半截内容当完整结果悄悄交给用户。具体怎么处理——续写、重试、还是提示用户——取决于业务;但"必须处理、不能忽略"是不变的。同时把"截断率"纳入监控,能帮你发现"是不是 max_tokens 普遍设小了"这种系统性问题。到这儿,大模型输出长度相关的方方面面就齐了。我把它收成一张决策图:
把这套理解建立起来,"AI 输出说一半"这类问题就能被预防和处理。最后,拧成几条可直接照做的铁律:
- 消费输出必查 finish_reason,length 表示被截断, 绝不能把半截当完整用。
- max_tokens 按预期输出设足,既别小到截断, 也别大到超窗口/浪费。
- 超长内容用续写或分段生成,拆成大纲+逐段, 让每次输出落在上限内。
- 长生成必须用流式输出,提升体验、规避超时(流式下也要查 finish_reason)。
- 建立 token 预算意识,输入+输出共享上下文窗口, 长输入要裁剪压缩。
- 截断要优雅应对,续写/重试/提示用户, 别把残缺品悄悄发出去。
- 监控截断率,偏高说明 max_tokens 系统性设小了, 该调整。
把六件事拧成一张参数对照表
说了这么多,落到实操上,最容易出问题的其实是几个关键参数和判断点到底怎么取值。我把它们汇成一张表,设计任何"调大模型生成内容"的功能时,对照着逐项确认一遍,基本就能绕开"输出说一半"这一整类坑。
| 关注点 | 错误做法(我踩过的) | 正确做法 |
|---|---|---|
| max_tokens | 用默认值或拍脑袋设个小数(如 256),撞上就截断 | 按预期输出长度设足,长报告类给到几千,但别超窗口 |
| finish_reason | 根本没读,默认输出都是完整的 | 每次都读,等于 length 即截断,显式分支处理 |
| 超长内容 | 指望一次调用生成几万字,必然被截 | 先生成大纲,再逐段(逐章)生成,每段都落在上限内 |
| 响应方式 | 非流式,等全部生成完一次性返回,用户干等、易超时 | 流式 streaming,实时蹦字,体验好且不超时 |
| 输入长度 | 只管塞,不管输入吃掉多少窗口 | 有 token 预算意识,长输入先裁剪/摘要,给输出留空间 |
| 截断后处理 | 把半截内容当完整结果悄悄发出去 | 续写/重试/提示用户,绝不静默,并监控截断率 |
这张表里每一行,都对应着我那次事故里"本可以避免"的一个环节。回头看,那个"AI 报告总是说一半"的问题之所以让我排查了很久,正是因为它把好几个坑叠在了一起:max_tokens 设小了、又没读 finish_reason、生成的内容本身偏长、还用的非流式。任何一个环节做对,问题都不至于那么隐蔽。所以这张表的价值,不只是"逐项规避",更是提醒我:大模型应用里,很多看似诡异的问题,根因往往不是模型本身"不行",而是我们对它的"工作边界"——尤其是 token 这个核心约束——理解不够、敬畏不足。
写在最后:把大模型当成"有边界"的伙伴
这次"AI 报告总是说一半"的排查,给我最深的改变,是它让我彻底放下了对大模型的一种"幻想"。在踩这个坑之前,我下意识里把大模型当成一个"无所不能、有求必应"的黑盒——我提需求,它给答案,中间发生了什么、有什么限制,我懒得深究。可正是这种"不深究",让我在 max_tokens 和 finish_reason 这种最基础的约束上栽了跟头,还排查了大半天。这件事让我明白:要把大模型用好、用稳,恰恰要放下"它无所不能"的幻想,转而清醒地认识它的"边界"——它有上下文窗口的上限,它的输出有长度的约束,它会因为这些约束而"戛然而止"。把它当成一个能力很强、但有明确边界的伙伴去协作,远比把它当成无边界的魔法,要靠谱得多。
更进一步,我意识到"工程化"这三个字,对大模型应用而言,分量格外重。模型本身的能力固然重要,但真正决定一个 AI 功能"好不好用、稳不稳定"的,往往是模型之外那一圈工程:参数怎么配、返回怎么校验、长内容怎么拆、异常怎么兜底、体验怎么优化。我那次的问题,模型一点没错——它老老实实地在 max_tokens 处停了下来,还诚实地用 finish_reason=length 告诉了我"我是被截断的"——错的全是我这圈工程没做好:没设够、没去读、没处理。所以,与其抱怨"模型怎么又说一半",不如反躬自省:我有没有把它的输出当成"需要被认真校验和处理的、有约束的数据",而不是"理所当然就完整正确的结果"?
这,其实又回到了贯穿这一系列复盘的那个朴素内核:对你所依赖的每一个组件——无论是数据库、缓存、第三方接口,还是如今的大模型——都不要想当然地"假设它会按我以为的方式完美工作",而要去理解它真实的行为和边界,并为它"不按预期工作"的那一刻,预先做好准备。大模型是个新事物,但用好它的底层心法,和用好任何一项技术,并无二致:少一点想当然的乐观,多一份对边界的敬畏和对异常的周全——你的 AI 功能,自然就从"总是说一半"的玩具,长成了真正可靠的生产力。愿这次"说一半"的小事故,也能帮你把面前这位强大的伙伴,用得更稳、更远。
一个容易被忽略的延伸:不只是"文本"会截断
最后补一个我后来才意识到的延伸点。上面聊的大多以"生成一段长文本"为例,但"输出被 max_tokens 截断"这件事,影响的远不止纯文本。当你让大模型输出结构化的内容——比如一段 JSON、一个代码片段、一份表格数据——一旦中途被截断,后果往往比文本被截更严重:文本截断了,顶多是话说一半还能读;可一个 JSON 写到一半被截,它就是一个语法都不合法、根本无法解析的残缺串,你的程序拿到它直接抛异常崩溃,这正是我最初那个 bug 最致命的表现形式。
所以,凡是依赖大模型输出"结构化数据"的场景,对截断要格外警惕、零容忍:一旦 finish_reason 是 length,就应当把这次输出直接判定为"失败",触发重试或报错,绝不能尝试去解析那半截残缺的结构。这和处理纯文本时"还能展示、能续写"的宽容态度,截然不同。结构化输出讲究的是"要么完整合法,要么就是失败",没有"残缺但能凑合用"的中间地带。理解了这一层,你在做"让 AI 返回 JSON 给程序用"这类功能时,就会自然而然地把 finish_reason 校验放在解析之前,从根上堵住"半截 JSON 撑爆解析器"这个最常见的坑——而这,恰恰就是我当初那场排查的起点。把这一类细节都照顾到,大模型才能真正成为你应用里那个稳定、可信赖的一环。
—— 别看了 · 2026