2023 年我做一个 AI 内容生成服务,用户给一个主题,系统调大模型生成一段结构化的结果——一段 JSON,里面有标题、摘要、正文。调用大模型时max_tokens 这个参数,我压根没多想。第一版我做得很省事:设个 max_tokens,不就是给模型的输出定个长度上限、免得它太啰嗦太费钱?我随手填了个 max_tokens=500,调用,拿到 content,json.loads() 解析出来,就完事了。本地开发时——真不错:我测几个短主题,模型生成的 JSON又完整又规整,稳稳地解析成功,几行代码搞定。我心里很踏实:"max_tokens 嘛,不就是设个上限防止太长?"可等这个服务真正上线、面对成千上万个真实主题,一串问题冒了出来。第一种最先把我打懵:模型返回的 JSON莫名其妙少了半截——最后一个字段写到一半就没了,json.loads() 直接抛异常,整个请求 500。第二种最难缠:有的请求里,一篇正文生成到一半被硬生生切断,用户拿到的是一段话说到一半的残稿。第三种最头疼:我以为把 max_tokens 调大就好了,调到 4000,结果账单暴涨,而且偶尔还是会被截断。第四种最莫名其妙:上了流式输出后,前端一直转圈不结束——因为我没去判断这个流到底是正常说完了、还是被截断结束了。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"max_tokens 就是给输出定个温柔的长度上限"。这句话把 max_tokens 想象成了一个"提示"——像在跟模型商量"你尽量别超过这个长度"。可它不是。我脑子里,max_tokens 是一句温和的建议,模型会"看着办"——快到上限了,它会自己找个合适的地方,把话收个尾。可 max_tokens 根本不是这种东西。它是一把"到点即斩"的铡刀:模型一个 token 一个 token 地往外生成,生成的数量一旦撞到 max_tokens,引擎会立刻、无条件地停止——不管那个位置是一句话的中间、一个单词的中间,还是一个 JSON 刚写了一半。它绝不会"看着办",绝不会替你把话收尾。更要命的是,模型每次回答都会附带一个叫 finish_reason 的字段,明明白白告诉你它这次是"为什么停下来的":是 stop——它自己把话说完了、自然收尾;还是 length——它的话还没说完,是被 max_tokens 这把铡刀硬砍断的。这个字段一直都在,可我第一版的代码,从来没有去读它。我默认了"拿到 content 就等于拿到一份完整的回答",可一个 finish_reason 是 length 的回答,是一个被拦腰斩断的半成品——它的 JSON 缺胳膊少腿,它的正文话说到一半。我第一版所有的麻烦,根上都是同一件事:我把一个可能被铡刀斩断的半成品,当成了一份保证完整的成品,而那个本该警示我"这是半成品"的 finish_reason 字段,我从头到尾视而不见。真正用对大模型的输出,核心不是"设个 max_tokens、拿到 content 就用",而是把每一次模型回答都当作"可能被 max_tokens 斩断的半成品"来对待:先读 finish_reason 判断它到底完没完整,被截断了就续写补全或明确报错,而 max_tokens 本身要从上下文窗口里算出来、给输出留足预算。这篇文章就把大模型的输出截断梳理一遍:为什么"设个上限就行"是错的、怎么读 finish_reason、被截断了怎么续写补全、max_tokens 到底该怎么算、流式输出下怎么判断截断,以及结构化输出、截断监控这些把输出处理做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一套"设个 max_tokens、拿到 content 就解析"的 AI 生成服务,在真正面对海量真实主题后冒出一串问题:模型返回的 JSON少了半截,json.loads() 直接抛异常;一篇正文生成到一半被硬切断,用户拿到残稿;把 max_tokens 调大后账单暴涨,却偶尔还是被截断;上了流式输出,前端一直转圈,因为没判断流是正常结束还是被截断结束。
我当时的错误认知:"max_tokens 就是给模型的输出定个长度上限,免得它太啰嗦、太费钱。"
真相:这个认知错在它把 max_tokens 想象成了一句"温和的建议"。在我脑子里,模型快到这个上限时,会像一个懂分寸的人那样,自己找个地方把话收个尾。可 max_tokens 完全不是这种东西。它是一把"到点即斩"的铡刀:模型逐 token 生成,数量一旦撞到 max_tokens,引擎立刻、无条件地停止——哪怕那里是一句话的中间、一个 JSON 刚写了一半。它绝不会替你收尾。而模型每次回答都附带一个 finish_reason 字段,告诉你它为什么停下来:stop 是自己说完了,length 是被铡刀砍断了。开头那四个问题,根上全是"没读 finish_reason,把半成品当成了成品":JSON 少半截,是因为我没判断 finish_reason,把一个被砍断的 length 回答直接拿去解析;正文是残稿,是因为我把一个截断的回答原样交给了用户;调大仍被截断、账单暴涨,是因为我用"拍脑袋调大"代替了"按预算计算";流式转圈不结束,是因为我没去读流末尾那个 finish_reason。问题的根子清楚了:这不是"模型话太多"的小毛病,而是要换一个根本的认知——每一次模型回答都可能被 max_tokens 斩断,用对它,就是要先读 finish_reason 确认它完不完整,再决定补全、报错还是直接用。
要把大模型的输出截断处理对,需要几块认知:
- 为什么"设个上限就行"是错的——
max_tokens是铡刀,不是温柔的上限; - 读
finish_reason——模型这次回答到底是说完了,还是被砍断了; - 被截断了怎么办——把半截内容拼回去,让模型续写补全;
- 给输出留足预算——
max_tokens要从上下文窗口里算出来; - 流式输出下的截断判断——
finish_reason藏在最后一个 chunk 里; - 结构化输出截断、截断率监控这些工程坑怎么处理。
一、为什么"设个 max_tokens 就行"是错的
先把这件最根本的事钉死:"设个 max_tokens 就行"错在它脑子里有一幅错误的图景——它把 max_tokens,想象成一句对模型说的"温和的建议":你尽量把回答控制在这个长度以内,快到了你就自己收个尾。这幅图景里,模型是一个善解人意、懂得分寸的对话者,它会照顾你的意图,在合适的地方优雅地停笔。这幅图景之所以危险,是因为它把"模型的生成过程"和"模型对停止时机的控制权"这两件事彻底搞混了。要看清这一点,得想清楚模型是怎么生成回答的:它不是先想好一整段话再吐出来,而是一个 token 一个 token 地往外蹦——每生成一个 token,就把它接到已有内容的后面,再基于此预测下一个。在这个过程里,有两种完全不同的力量可以让它停下来。第一种,是模型自己:它生成着生成着,觉得这段话的意思已经表达完整了,于是生成一个特殊的"结束 token",主动收尾——这是它自己做的决定,对应的 finish_reason 是 stop。第二种,是 max_tokens:它是一个由外部、由调用方设定的硬性计数上限,和模型"想不想说完"毫无关系——引擎在每生成一个 token 后都会数一下,数到 max_tokens,就立刻掐断生成,这一刀切在哪里,纯粹取决于那个数字,而不取决于语义,对应的 finish_reason 是 length。关键就在这里:这两种停止,产出的是性质完全不同的东西。stop 停下的回答,是一个语义完整的成品。length 停下的回答,是一个被拦腰斩断的半成品——它可能恰好停在一个词的中间、一个句子的中间、一个还没闭合的 JSON 括号前面。把它们俩当成同一种东西、不加区分地拿来就用,就是第一版所有事故的总根源。所以正确的图景是:max_tokens 不是建议,是一把无视语义、到点即斩的铡刀;模型对"停在哪里"并没有你想象的控制权;而你必须靠 finish_reason 这个字段,去分辨手里这份回答,到底是模型说完的成品,还是被铡刀斩断的半成品。把"我设了个上限、模型会照办"换成"我架了把铡刀、我必须事后检查它斩没斩到内容",你才算站到了正确处理输出的起点上。
下面这段代码,就是我那个"本地测短主题没事、上线就解析失败"的第一版:
# 反面教材:把 max_tokens 当成"温柔的上限",拿到 content 就当完整结果用
import json
def get_user_profile(prompt):
resp = call_llm(prompt, max_tokens=500) # 以为这只是"别太啰嗦"的上限
text = resp.choices[0].message.content
return json.loads(text) # 破绽:被截断的 JSON 在这里直接抛异常
这段代码在本地开发时表现不错,因为本地我测的主题,其实是"短小而温和"的——是我自己随手想的几个简单主题,模型生成的 JSON普遍很短,远远到不了 500 token 这把铡刀的位置。我亲手扮演了一个温和的用户,我给的主题让模型每次都在远早于上限处自然说完,于是每一次 finish_reason 都恰好是 stop。代码恰好一路平安,你看不出任何破绽。它的问题不在某一行语法上——call_llm()、取 content、json.loads(),语法都对——而在它对"拿到的这份回答完不完整",做了一个想当然的假设:它假设拿到的 content 永远是完整的。它从头到尾没有去碰 finish_reason 这个字段,也就从没想过模型这次可能是被 max_tokens 砍断的。本地我测的主题都短,这个假设恰好每次都成立;一上线、来了个内容丰富的主题,模型生成的 JSON一长、撞上 500 这把铡刀,假设就被击穿,json.loads() 拿到半截 JSON,当场报错。问题的根子清楚了:正确处理输出,第一步不是把 max_tokens 调大,而是承认"每一次回答都可能被铡刀斩断",然后老老实实去读 finish_reason,先判断它完不完整。下面五节,就是这件事怎么落地。
二、读 finish_reason:模型这次为什么停下来
纠正误判的第一步,是把那个被忽略的字段捡起来。每一次模型回答里,都有一个 finish_reason,它是判断输出完不完整的唯一可靠依据:
def get_finish_reason(resp):
"""读出模型为什么停下来 —— 这是判断输出完不完整的唯一依据。"""
return resp.choices[0].finish_reason # "stop"=自然说完 / "length"=被 max_tokens 砍断
def is_truncated(resp):
"""这次回答是不是被截断的半成品。"""
return get_finish_reason(resp) == "length"
但只判断"截没截断"还不够——finish_reason 有好几种取值,每一种都对应着完全不同的处理,要把它们分门别类:
def classify_response(resp):
"""按 finish_reason 分类:不同的停止原因,要走完全不同的处理。"""
reason = get_finish_reason(resp)
if reason == "stop":
return "ok" # 模型自己说完了,内容完整,可直接用
if reason == "length":
return "truncated" # 撞上 max_tokens,内容是半截的,要补全
if reason == "content_filter":
return "filtered" # 被安全过滤拦了,要走兜底
return "unknown" # 其他/未知,保守处理
这里的认知要点是:这一节要建立的观念是——一次模型调用的返回值,从来不只是那段文本内容,它还包含一份"这次生成是怎么结束的"的元信息,而 finish_reason 就是这份元信息里最关键的一项。第一版的代码,眼里只有 content,把 finish_reason 当成不存在,这就好比你收到一个快递,只看里面的东西,从不看箱子上那张"是否完好、有无破损"的标签。finish_reason 就是这张标签。它为什么必须读?因为正如上一节所说,content 这段文本本身,是无法自证完整的——一段在句子中间戛然而止的文本,和一段正常结束的文本,单看文本你分不出来,尤其当内容本来就比较随意时。唯一能告诉你"这段文本是完整的还是被斩断的"的,就是 finish_reason。再说为什么要"分类"而不只是"判断真假"。很多人会把它简化成一个布尔判断:是不是 length。但 finish_reason 的取值不止 stop 和 length 两种,还有 content_filter(内容被安全系统拦截)等等,不同的厂商和接口可能还有别的取值。这些不同的结束原因,意味着你手里的内容处于完全不同的状态,因此需要完全不同的下一步:stop 意味着内容完整,可以直接用;length 意味着内容是半截的,要去补全;content_filter 意味着内容是被拦截的,要走兜底话术。如果你只做"是不是 length"的二元判断,就会把 content_filter 这种情况错误地归到"不是 length,所以没问题"的那一类里,把一个被拦截的、不完整的回答当成正常结果用掉。所以正确的做法是 classify_response 这样:把 finish_reason 的每一种取值都显式地列出来、对应到一种明确的处理类别,不认识的取值也要有一个保守的兜底分类。这背后是一个通用的工程观念:对一个有多种取值的状态字段,要穷举它的每一种取值并分别处理,而不是只挑你关心的那一种、把其余的混为一谈。一句话:每次调用的返回值里都有 finish_reason 这份元信息,它是判断输出状态的唯一依据,而且要按它的每一种取值分类处理。知道了怎么判断截断,下一步就是——真的截断了,该怎么补救。
三、被截断了怎么办:把半截内容拼回去续写
判断出 finish_reason 是 length,真正要做的是补全它。最直接的办法是续写:把已经生成的那半截内容,拼回对话历史里,让模型接着往下写:
def continue_generation(prompt, partial, max_rounds=3):
"""被截断就续写:把已生成的半截内容拼回去,让模型接着往下写。"""
full = partial
for _ in range(max_rounds):
messages = [
{"role": "user", "content": prompt},
{"role": "assistant", "content": full}, # 把已经写出来的半截塞回去
{"role": "user", "content": "接着上面继续写,不要重复已经写过的内容"},
]
resp = call_llm_messages(messages, max_tokens=500)
full += resp.choices[0].message.content
if get_finish_reason(resp) == "stop": # 这一轮终于自然说完了
return full
return full # 续写到轮数上限仍未完,返回已有的
下面这张图,把拿到一次回复后,该按 finish_reason 怎么走画出来:
这里的认知要点是:这一节要想清楚的是——发现截断之后,你有几条路可走,以及"续写"这条路是怎么成立的。面对一个 length 的回答,最容易想到的两条路是:一是直接报错,告诉用户"生成失败,请重试";二是整个重新生成一遍。这两条路在某些场景下是对的,但都有明显的代价:报错把问题甩给了用户,重新生成则把前面那半截已经付费、也大概率没问题的内容全部扔掉、从头再来,既慢又贵。于是有了第三条路——续写。续写之所以可行,根子在大模型对话接口的一个基本特性:模型本身是无状态的,它每一次回答,都完全基于你这一次传给它的那串 messages。这意味着,你可以"伪造"一段对话历史:你把原始的 prompt 作为用户消息,把模型已经生成的那半截内容,作为一条 assistant 消息接在后面,再补一句用户消息说"接着往下写"。模型收到这串 messages,会理解成"我之前已经说了这些,现在要继续",于是它就从那个断点接着生成。这就是 continue_generation 在做的事。这里有两个细节值得留意。第一,续写本身也可能再次被 max_tokens 截断——一次很长的输出,可能要续写好几轮才写完。所以续写必须放在一个循环里,每一轮都重新检查 finish_reason,直到某一轮终于拿到 stop。第二,这个循环必须有一个轮数上限 max_rounds。为什么?因为万一模型陷入某种停不下来的状态,没有上限的续写就会变成一个无限烧钱、无限耗时的循环——这和一个没有出口的重试是一样危险的。给它一个明确的轮数上限,到顶了就停、返回已有的内容,这是任何"反复调用直到满足条件"的逻辑都必须配备的安全阀。一句话:发现截断后,续写是比报错和重生成更经济的补救——把半截内容当作 assistant 消息拼回去让模型接着写,但续写必须在带轮数上限的循环里进行。会补全了,可还有个更上游的问题——max_tokens 这个数,本就不该拍脑袋填。
四、给输出留足预算:max_tokens 该怎么算
续写是事后补救,而更应该做的是事前别让它轻易截断。关键在于:max_tokens 不该拍脑袋填一个数,而要从上下文窗口里算出来。模型的上下文窗口是输入和输出共用的——提示词占掉一部分,剩下的才是能留给输出的预算:
def calc_max_tokens(context_window, prompt_tokens, margin=256):
"""给输出留足预算:max_tokens 要从上下文窗口里算出来,不是拍脑袋。"""
budget = context_window - prompt_tokens - margin # 窗口减去提示词,再留一点安全余量
if budget <= 0:
raise ValueError("提示词本身已塞满上下文窗口,没有空间留给输出")
return budget
这里的认知要点是:这一节要扭过来的观念是——max_tokens 不是一个孤立的、可以随手填的数字,它处在一组互相牵制的约束里,必须算着定。要理解这一点,得先认清一个事实:模型的上下文窗口,是输入和输出共享的同一块空间。一个号称 8K 上下文的模型,意思是"输入的 token 数 + 输出的 token 数"这个总和不能超过 8192,而不是输入 8K、输出再 8K。这个共享关系,直接决定了 max_tokens 的取值不是自由的。先看第一版的错法:随手填 max_tokens=500。这个 500 和实际的提示词长度毫无关系——如果提示词很长,500 的输出空间可能不够,内容频繁被截断;如果提示词很短,500 又可能白白浪费了窗口里大量本可用于输出的空间。再看第三种问题里"把 max_tokens 调大到 4000"的错法:这同样是拍脑袋,它带来两个新麻烦。一是费用——很多接口里,max_tokens 会影响计费或资源预留,一个不必要的大数字是在浪费钱;二是它依然可能截断——如果某次提示词本身就很长,窗口里根本剩不下 4000 的空间,你填 4000 也是空头支票,实际可用的远没那么多。正确的做法是 calc_max_tokens 这样:先数清楚这次提示词占了多少 token,用上下文窗口的总大小减去它,再扣掉一个安全余量(token 计数有时会有些许误差,留点缓冲),剩下的才是这一次真正能给输出的预算。这个算法还顺带帮你抓到一个严重错误:如果算出来的预算是零甚至负数,说明你的提示词本身已经把窗口塞满了——这是个必须当场抛出来的问题,而不该等到调用时才发现。这背后的通用观念是:凡是处在共享资源约束下的参数,都不能独立地拍脑袋设定,必须根据"总量"和"已被占用的量"动态计算出"还剩多少可用"。一句话:max_tokens 要用'上下文窗口减去提示词再留余量'算出来,因为输入和输出共享同一个窗口。事前预算和事后补全都有了,可还有一个场景的截断判断格外特殊——流式输出。
五、流式输出下的截断判断
现在的 AI 应用大多是流式输出:模型的字一块一块地推过来。流式下,finish_reason 不在某个固定的地方,而是藏在最后一个 chunk 里——你必须一直读到流的末尾,才能知道这次生成是不是被截断了:
def stream_and_detect(prompt):
"""流式输出下:finish_reason 在最后一个 chunk 里,必须读到它才知道结尾是否被截断。"""
text = ""
finish_reason = None
for chunk in call_llm_stream(prompt, max_tokens=500):
delta = chunk.choices[0].delta.content or ""
text += delta
if chunk.choices[0].finish_reason: # 只有最后一个 chunk 才带 finish_reason
finish_reason = chunk.choices[0].finish_reason
return text, finish_reason == "length" # 第二个返回值:这次是不是被截断了
把预算计算、调用、截断判断、续写补全合成一个安全的调用入口:
def safe_complete(prompt, context_window=8192):
"""安全调用:算预算 -> 调模型 -> 查 finish_reason -> 截断就续写补全。"""
prompt_tokens = count_tokens(prompt)
max_tokens = calc_max_tokens(context_window, prompt_tokens)
resp = call_llm(prompt, max_tokens=max_tokens)
text = resp.choices[0].message.content
if get_finish_reason(resp) == "length": # 撞了铡刀
text = continue_generation(prompt, text) # 续写补全
return text
这里的认知要点是:流式输出下的截断判断,难点不在于判断本身,而在于流式这个形态,把"何时才能做这个判断"的时机推到了最后,你必须为此调整代码的结构。先想清楚流式和非流式的区别。非流式调用,你一次性拿到一个完整的响应对象,里面 content 和 finish_reason 一应俱全,你随时可以读 finish_reason。流式调用不是这样:服务端把回答切成一连串很小的 chunk,一块一块地推给你,每个 chunk 通常只携带这一小段新增的文本。关键在于,finish_reason 这个"这次生成是怎么结束的"的信息,只有在生成真正结束时才能确定,所以它只会出现在最后一个 chunk 里——前面所有的 chunk,它们的 finish_reason 都是空的。这就带来一个直接的后果:在流式输出的过程中,你是没有办法提前知道这次会不会被截断的,你必须老老实实地把整个流消费到底、拿到那最后一个携带 finish_reason 的 chunk,才能下结论。第四个问题里"前端一直转圈"就是栽在这:代码消费完了文本,却没有去捕获最后那个 chunk 里的 finish_reason,于是它永远不知道这个流到底是正常结束的还是被截断结束的,也就给不出一个明确的"结束"信号。stream_and_detect 的写法就是针对这一点:它在循环里,一边累加文本,一边时刻检查当前 chunk 的 finish_reason 是否非空,一旦非空就记下来;循环结束后,它把累加的文本和"是否被截断"这个判断一起返回。注意它的返回值是一个二元组——文本,加上一个明确的截断标志位。这是流式接口设计的一个要点:不要只返回内容,要把"这次结束的状态"作为一个同等重要的返回值一并交出去,让调用方能据此决定是直接用、还是去续写。最后,safe_complete 把前面四节串成了一条完整的链路:先用 calc_max_tokens 算出这次该给多少输出预算,再调用,再读 finish_reason,是 length 就续写补全。一句话:流式下 finish_reason 只在最后一个 chunk 里,必须消费完整个流才能判断截断,并把截断状态作为一个明确的返回值交给调用方。主干都齐了,最后是几个把输出截断处理真正用到生产里才会撞见的工程坑。
六、工程坑:结构化输出、续写衔接、监控与重试
主干之外,还有几个工程坑,不处理就会让你的输出处理在边角上出问题。坑 1:结构化输出的截断尤其危险,补全后必须再做字段校验。纯文本截断,顶多是"少一段";可 JSON 截断,是"整个无法解析"——半截 JSON 不是"短一点的 JSON",而是语法上彻底坏掉的字符串。而且就算续写后能解析了,也不等于内容完整,还要用 schema 校验必需字段在不在:
import json
def get_validated(prompt, required_fields):
"""结构化输出截断尤其隐蔽:能解析 不等于 完整,补全后还要校验必需字段。"""
text = safe_complete(prompt)
try:
data = json.loads(text)
except json.JSONDecodeError:
raise ValueError("JSON 输出被截断,且续写补全后仍无法解析")
missing = [k for k in required_fields if k not in data]
if missing:
raise ValueError(f"输出虽能解析,但缺少必需字段: {missing}")
return data
坑 2:续写的"衔接处"容易出问题。把半截内容拼回去让模型接着写,模型可能重复一遍最后那句、也可能漏掉中间的衔接。续写的提示词要明确叮嘱"不要重复已写内容、从断点无缝接续";高风险场景下,衔接处要做去重检查。坑 3:截断率要监控,它是 max_tokens 设置的体检指标。把每次的 finish_reason 统计起来,length 的占比就是截断率。它突然升高,就是 max_tokens 设小了、或提示词变长了的信号:
import collections
_finish_stats = collections.Counter()
def record_finish(resp):
"""监控 finish_reason 分布:length 占比升高,就是 max_tokens 偏小的预警。"""
_finish_stats[get_finish_reason(resp)] += 1
total = sum(_finish_stats.values())
if total >= 100:
ratio = _finish_stats["length"] / total
if ratio > 0.05:
print(f"警告:截断率 {ratio:.1%},max_tokens 可能设得过小")
坑 4:不是所有截断都该续写,有时该重试或报错。续写适合"内容本身没问题、只是没写完";但如果截断反复发生,或者业务上要求一次成型(比如一段必须原子生成的代码),那就该重试或直接报错,而不是无限续写。坑 5:max_tokens 也要防"设得过大撑爆窗口"。第四节算预算时,如果提示词动态变长(比如带了很长的 RAG 上下文),算出来的预算会变小,要确保调用前用的是实时算出的值,而不是一个写死的常量。坑 6:不同模型、不同接口的 finish_reason 取值不统一。有的叫 length,有的叫 max_tokens;有的还有 tool_calls 等取值。封装一层,把各家的取值归一化成你自己的几个标准类别,别让接口差异散落到业务代码里。坑 7:截断会让"按 token 计费"的成本失真。一次截断 + 三轮续写,总 token 消耗远高于一次成型——因为每轮续写都要把前面的内容当输入重新发一遍。监控成本时,要把续写的额外开销算进去。坑 8:用上"提前停止"的其他手段。除了 max_tokens,还可以用 stop 序列让模型在特定标记处主动停止——这是"语义性的停止",和 max_tokens 的"机械性截断"不同,用好它能减少不必要的截断。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| max_tokens 是铡刀 | 到点即斩、无视语义的硬上限,不是模型会照办的温柔建议 |
| 设个上限就行的错 | 不读 finish_reason,把可能被斩断的半成品当成完整成品用 |
| finish_reason | 每次回答附带的元信息,是判断输出完不完整的唯一依据 |
| stop 与 length | stop 是模型自然说完,length 是被 max_tokens 砍断的半成品 |
| 按取值分类处理 | finish_reason 每种取值穷举对应一种处理,别只做二元判断 |
| 续写补全 | 把半截内容当 assistant 消息拼回去,让模型从断点接着写 |
| 续写要带轮数上限 | 续写本身也可能被截断,循环必须有上限,防无限烧钱 |
| max_tokens 算出来 | 上下文窗口减提示词再留余量,输入输出共享同一个窗口 |
| 流式 finish_reason | 只在最后一个 chunk 里,必须消费完整个流才能判断截断 |
| 结构化输出截断 | 半截 JSON 彻底无法解析,补全后还要校验必需字段 |
避坑清单
- 把 max_tokens 当作到点即斩的铡刀,不是模型会照办的温柔上限。
- 每次回答先读 finish_reason,它是判断输出完不完整的唯一依据。
- finish_reason 的每种取值都穷举处理,别只做"是不是 length"的二元判断。
- finish_reason 是 length,就当半成品对待:续写补全或明确报错。
- 续写把半截内容当 assistant 消息拼回去,放在带轮数上限的循环里。
- max_tokens 从上下文窗口减提示词算出来,别拍脑袋填一个数。
- 提示词本身塞满窗口、输出预算为零时,要当场抛错而不是硬调。
- 流式下读最后一个 chunk 的 finish_reason,把截断状态作为返回值交出去。
- 结构化输出截断后,能解析不等于完整,还要校验必需字段。
- 监控 length 的占比即截断率,占比升高就是 max_tokens 偏小的信号。
总结
回头看那串"JSON 少了半截解析失败、正文是残稿、调大仍截断且账单暴涨、流式转圈不结束"的问题,以及我后来在输出截断上接连踩的坑,最该记住的不是某一个续写函数的写法,而是我动手前那个想当然的判断——"max_tokens 就是给输出定个长度上限,免得它太啰嗦太费钱"。这句话错在它把 max_tokens 想象成了一句温和的建议。我以为模型快到上限时会自己找个地方把话收个尾,我拿到的 content 永远是完整的。可我忽略了一件最要紧的事:max_tokens 根本不是建议,而是一把"到点即斩"的铡刀——模型逐 token 生成,数量一旦撞到这个值,引擎立刻、无条件地停止,哪怕那里是一句话的中间、一个 JSON 刚写了一半,它绝不会替你收尾。而模型每次回答都附带一个 finish_reason 字段,明明白白告诉我这次是 stop(自己说完了)还是 length(被铡刀砍断了)——这个字段一直都在,我的第一版却从来没有读过它。我默认了"拿到 content 就是完整的回答",可一个 finish_reason 是 length 的回答,是一个被拦腰斩断的半成品。我第一版的错,就是把这样一个可能被斩断的半成品,当成了一份保证完整的成品,而那个本该警示我的 finish_reason,我从头到尾视而不见。这个错配,本地开发时根本看不出来——因为本地测试的"主题"是我自己想的,都短小温和,模型每次都在远早于上限处自然说完,finish_reason 恰好次次是 stop,代码恰好一路平安;它只会在真正上线、面对海量真实主题里那些内容丰富、足以让输出撑到铡刀位置的请求时,以一次解析失败、一篇残稿的方式爆出来。
所以做对大模型的输出处理,真正的功夫不在"调用模型、拿到 content"那几行上。调模型本身不难。真正的功夫,在于你要从一开始就承认"每一次回答都可能被 max_tokens 这把铡刀斩断",然后为"识别它、补全它"做足设计:你不能默认拿到的 content 就是完整的,就每次都去读 finish_reason、按它的每种取值分类处理;你不能把一个被截断的半成品直接用掉,就把半截内容拼回去、在带轮数上限的循环里续写补全;你不能拍脑袋填一个 max_tokens,就从上下文窗口里减去提示词、算出真正该给输出的预算;你不能让流式输出不明不白地结束,就读到最后一个 chunk 的 finish_reason、把截断状态明确交出去;而到了结构化输出截断、续写衔接、截断率监控这些边角上,你还要处处守住,别让一个半成品又从某个角落溜过去。这篇文章的几节,其实就是顺着这套规矩展开的:先想清楚"设个上限就行"为什么错,再讲读 finish_reason、续写补全、算 max_tokens 预算、流式下的截断判断,最后是结构化输出、监控这几个把输出处理守扎实的工程细节。
你会发现,大模型的输出截断这件事,和现实里"用有限的信纸写一封长信"完全相通。一个粗心的寄信人会怎么做?他只拿了一张信纸就开始奋笔疾书,写着写着,写到信纸的最后一行,纸就没了——而他要说的话还没说完,最后一句话就停在了半空中。可他看都不看,把这张话说到一半的信纸,直接塞进信封寄了出去。收信的人拆开一看,满头雾水:这封信怎么没头没尾、戛然而止?而一个细心的寄信人怎么做?他写完一张纸,一定会先停下来看一眼:这封信,是话说完了、自然收的尾,还是纸写完了、被逼着断的?(这就是读 finish_reason);如果是话没说完、纸不够了,他绝不会把半截信寄出去——他会再抽一张新纸,接着上一张的断点往下写(这就是续写补全);而且他下一次写信前,会先估摸一下这封信大概多长、一开始就备足够的纸(这就是按预算算 max_tokens);要是写的是一份有固定格式的表格,他还会在补全后再核对一遍每一栏都填齐了没有(这就是结构化输出的字段校验)。同样是寄一封信,粗心的人脑子里只有"我把字写出来就行",细心的人脑子里始终装着"信纸是有限的、我得检查这封信到底写完没写完"——差别不在"把信寄出去这个动作本身难不难",只在寄信的人心里有没有"信纸会用完、寄出去之前必须确认信写完整了"这根弦。
最后想说,大模型的输出截断处理做没做对,差距永远不会在"本地开发、自己测几个主题"时暴露——本地那几个测试的主题就是你自己想的,短小、温和,模型生成的内容远远到不了 max_tokens 那把铡刀的位置,每一次 finish_reason 都恰好是 stop,你那段"调用、拿 content、解析"的代码恰好每一次都拿到了完整的回答,JSON 规规整整地解析成功,你自然觉得"max_tokens 嘛,设个上限"一点问题都没有。它只在真实的、面对海量主题、其中总有内容丰富到足以撑爆输出预算的环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为一个被截断的 JSON,让整个请求 500,会因为一篇生成到一半的残稿,让用户拿到没头没尾的内容,会因为没判断流的结束状态,让前端永远转着圈;而做对了,你的每一次回答都先过了 finish_reason 的检查,被截断的就续写补全、补全后还校验了字段,max_tokens 是按上下文窗口的预算算出来的,流式输出的结束状态被明明白白地交了出去,无论主题多长、内容多丰富,每一份到达用户手里的输出都是完整的、规整的、有头有尾的。所以别等"一个截断的 JSON 把请求打成 500"那一刻找上门,在你写下调用大模型的第一行代码时就该想清楚:这次回答我读 finish_reason 了吗、是 length 我续写了吗、max_tokens 我算过预算了吗、流式的结束状态我判断了吗、结构化输出我校验字段了吗,这一道道关口,我是不是都替这份可能被斩断的半成品守住了?这些问题有了答案,你交付的才不只是一套"本地测几个主题看着对"的代码,而是一个无论主题多长、内容多丰富,每一份输出都完整可用、有头有尾的、让人放心的系统。
—— 别看了 · 2026