Beam 的回答中途停住,通常来自两个彼此独立的原因:输出撞上了最大生成长度上限,或者历史对话太长、把上下文窗口占满,导致前面的内容被挤掉、输入侧被截断。两者也可能同时出现,但排查时要分开做。建议的判断顺序是:先读返回结果里的结束原因字段,再记录本次请求的输入和输出 token 长度,然后每次只改一个变量复测——先把最大生成长度调大一档,再把历史清空成单轮请求。
输出中途断掉不一定就是长度不够。先看结束原因:若被标记为达到长度上限,优先调大最大生成长度;若结束原因正常但内容接不上前文,更像是历史把上下文窗口占满。判断时记录输入输出长度做对比,并分别用“调大长度”和“清空历史”两次单变量复测确认,不要同时改多个参数。上下文窗口是输入与输出共享的,调大输出上限也有可能让输入侧超限。
在返回结果里找到结束原因字段
先分清正常收尾和被截断。不同接入方式的字段名不一样,常见叫 finish_reason、stop_reason、finishReason、stopReason,位置可能在整个响应的顶层,也可能在候选结果数组里。取值含义通常是这几类:正常收尾(stop、end_turn 之类)、达到最大生成长度(length、max_tokens 之类)、内容安全拦截(content_filter 之类)、需要调用工具(tool_calls、function_call 之类)。如果输入本身超过窗口上限,有的接入会直接抛错,而不是返回一个结束原因,这种情况要单独看。
打印方式上,建议把原始响应整体落到日志里,而不是只截取文本字段,否则结束原因和用量信息会一起丢掉。
import json, logging
resp = client.chat(...) # 换成你自己的接入方式,resp 可能是 dict 也可能是对象
logging.info('raw_response=%s', json.dumps(resp, ensure_ascii=False, default=str))
# 字段位置以实际接入为准,这里给出常见的取值路径
reason = (
resp.get('finish_reason')
or resp.get('stop_reason')
or (resp.get('choices') or [{}])[0].get('finish_reason')
)
print('finish_reason =', reason)
判断办法很直接:结束原因是 length 这类取值,说明输出被最大生成长度砍断;结束原因是 stop 这类正常收尾,但内容读起来接不上前文、像从中间开始,要先怀疑历史内容把上下文窗口占满,而不是输出长度不够;如果请求直接返回输入超长错误,说明窗口已经被塞满,得先清历史或压缩输入。
记录本次请求的输入长度与输出长度
拿到长度基线,后面两次复测才有对比依据。优先用返回里的 usage 数据,常见字段是 prompt_tokens、completion_tokens、total_tokens,实际字段名随接入方式变化。没有 usage 时,用与目标模型匹配的分词器在本地按同一口径统计,不要用字符数替代 token 数。统计输入时要按真正发出去的整段内容算,包括 system 说明、历史轮次和当前问题,而不是只算当前这一句。
usage = resp.get('usage') or {}
print('prompt_tokens =', usage.get('prompt_tokens'))
print('completion_tokens =', usage.get('completion_tokens'))
print('total_tokens =', usage.get('total_tokens'))
# 没有 usage 时,用与模型匹配的分词器按实际口径统计
ids = tokenizer.encode(prompt_text)
print('prompt_tokens_local =', len(ids))
建议把每次请求记成一行:请求标识、输入长度、输出长度、结束原因、最大生成长度取值。这样调参前后是哪一项变了、结果差在哪,一眼能看出来。具体 token 口径以你实际使用的分词器为准,不要照搬别人的数值。
把最大生成长度调大一档后复测同一提示词
这一步用来排除输出长度上限。该参数常见位置包括请求体顶层的 max_tokens、max_output_tokens、maxTokens,或生成配置里的 max_new_tokens、n_predict 这类字段,名称以你实际的请求参数为准。调整方式是往上加一档,或按接口允许的范围取一个更大值,不要一次设成极端值,否则不好判断是哪一档起的作用。
params = {
'model': '...',
'max_tokens': 2048, # 示例占位,按接口允许范围从原值调大一档
'temperature': 0.2, # 保持与原请求一致
}
# 两次请求保持同一提示词、同一历史、同一温度
# 记录对照:max_tokens / finish_reason / prompt_tokens / completion_tokens / 结尾是否完整
复测要求同一提示词、同一历史、同一温度,只改最大生成长度这一项。如果第一次结束原因是 length、第二次变成正常收尾且结尾补全了输出,基本可以确认是最大生成长度造成的截断。需要注意上下文窗口是输入加输出共享的:把输出上限调大后,可能出现输入侧超限报错,这属于窗口总预算不够,不是输出上限的问题,放到最后一节按顺序处理。
清空历史只留单轮请求再复测
这一步用来排除历史内容把上下文窗口塞满。具体做法是新建会话,或直接清空消息列表,只保留必要的 system 说明和当前这一条用户问题,之前的往返内容一律不带。提示词和参数跟上一节保持一致,重发一次。
messages = [
{'role': 'system', 'content': '...'},
{'role': 'user', 'content': '原来会断掉的那条问题'},
]
# 不要携带之前的 assistant / user 往返内容
预期观察点有三个:一是输入长度是否比多轮时明显下降,用上一节的统计口径对比;二是结束原因是否变成正常收尾;三是输出是否恢复完整。如果多轮会断、单轮完整,问题主要出在历史占满了窗口;如果单轮仍然被标记为达到长度上限,那还是最大生成长度的问题;如果单轮也接不上前文,要继续检查单轮输入本身是否已经很长,或接入侧是否存在输入截断策略。
两种限制同时存在时的处理顺序
如果结束原因是 length,同时输入长度又已经接近窗口上限,说明两种限制叠在一起。处理顺序建议是先砍历史,再调长度。历史可以在不改变任务目标的前提下压缩,长度上限则受模型和接口本身的约束,先动能动的变量。
历史压缩的常见做法是:只保留最近若干轮对话,更早的内容做摘要后放进 system 或首条消息,长文档改成按需检索的片段而不是整篇塞入。每压缩一次,用同一提示词复测,并记录三项:输入长度、结束原因、输出是否完整。确认输入长度降下来、也不再出现内容接不上前文之后,再决定是否需要把最大生成长度调大一档;如果调大后出现输入超长报错,把输入超长报错作为窗口总预算仍不够的信号,继续压缩历史或缩短单次输入。
边界也要留意:摘要会丢细节,涉及数字、编号、硬性约束的内容建议保留原文片段;调大最大生成长度会增加等待时间和调用开销,也不会突破模型窗口上限。所有判断都以你实际返回的结束原因和 token 统计为准,需要结合自己的接入环境确认。