Claude Sonnet 5.5 回复总被截断 / 是输出上限设小了还是提示给太长?

文章导读
同一个提示,短问能答完,长问只回半句,判断方向通常是:先看返回体里的停止原因字段,再看本次输出用量和设定输出上限的差距,最后才去怀疑提示太长被上游截掉。停止原因如果是 max_tokens 一类取值,基本可以按输出上限设小了处理;如果停止原因是正常结束、回答却明显断在半句,那么上限不是主因,要往提示过长或流式收流不完整的方向查。这五步的顺序不要颠倒,先换模型往往会把真正的原因盖住。
📋 目录
  1. 一 先在返回体里读出停止原因字段
  2. 二 把本次输出的用量数字和设定上限放在一起比
  3. 三 把提示按段落分段计数,找出被截掉的位置
  4. 四 用同一提示做长短两组对照
  5. 五 调大输出上限后重跑一次
A A

同一个提示,短问能答完,长问只回半句,判断方向通常是:先看返回体里的停止原因字段,再看本次输出用量和设定输出上限的差距,最后才去怀疑提示太长被上游截掉。停止原因如果是 max_tokens 一类取值,基本可以按输出上限设小了处理;如果停止原因是正常结束、回答却明显断在半句,那么上限不是主因,要往提示过长或流式收流不完整的方向查。这五步的顺序不要颠倒,先换模型往往会把真正的原因盖住。

先读停止原因,再比用量与上限,之后才动提示。停止原因是 max_tokens 或等价取值时,按输出上限不足处理;是正常结束但回答语义中断,则优先查提示是否被上游截断、流式是否丢终止事件。适用场景:单次调用或能完整收流的流式调用。验证方式:同一提示改一处参数复跑并记录字段。边界:不同 SDK 字段名和取值语义可能不同,需以你当前接入环境实际返回体为准,调大上限也会增加费用与等待时间。

先在返回体里读出停止原因字段

这一步的目的是分清这次返回是「正常收尾」还是「被上限或其它条件提前终止」。字段位置一般在一次性响应的 JSON 顶层;流式响应通常在最后一个事件的 message 对象里,而不是每个增量块上。下面的骨架是通用写法,字段名按你实际 SDK 的返回结构调整。

# 通用解析骨架,替换 call_model 和字段名
resp = call_model(prompt)
stop = resp.get('stop_reason') or resp.get('finish_reason')
if stop is None:
    stop = last_event.get('delta', {}).get('stop_reason')
print('stop_reason =', stop)

常见取值和对应含义:

  • end_turn / stop:模型自然结束,回答长度由内容决定,不是被输出上限截停。此时回答仍像半句,问题多半在提示或收流环节。
  • max_tokens / length:触到本次请求设定的输出上限,断开位置通常在 token 边界,句子或代码块会断在半途。
  • stop_sequence:命中你配置的自定义停止串,属于预期行为,不是故障。
  • tool_use:模型在请求调用工具,这一轮还没有最终自然语言回答。
  • 空值或 None:流式没收到终止事件、连接被提前关闭、客户端超时提前返回,需要回头看传输层日志。

读不到字段时,先确认打印的是最终响应对象而不是某个中间增量块,这一点比换模型更值得先做。

把本次输出的用量数字和设定上限放在一起比

确认是否正好卡在设定上限上。用量字段通常在返回体的 usage 对象里,输出上限是你自己在请求里传的参数,两者要同时打进一行日志,才能横向比较。

Claude Sonnet 5.5 回复总被截断 / 是输出上限设小了还是提示给太长?
usage = resp.get('usage', {})
print('in=', usage.get('input_tokens'),
      'out=', usage.get('output_tokens'),
      'cap=', request_max_output_tokens)

建议按行追加到日志文件,一行对照记录长这样,字段名按你的封装替换:

ts=... prompt_chars=... prompt_tokens=... output_tokens=... max_output_tokens=... stop_reason=...
观察到的情况倾向判断下一步
output_tokens 贴着 max_output_tokens,停止原因是上限类取值输出上限设小了进入第 5 步,调大上限复跑
output_tokens 明显低于上限,停止原因是正常结束不是上限问题进入第 3、4 步查提示与传输
用量字段缺失日志或封装层没透传先补全日志再判断

把提示按段落分段计数,找出被截掉的位置

这一步用来判断是不是提示太长,导致前段内容根本没送进去。上游如果对输入做了截断,模型看到的提示和你拼出来的提示就不一致,外在表现常常是回答只覆盖了提示后半部分,或者完全忽略前面的约束。按段落统计比看总长度更有用,因为你能定位到哪一段之后开始丢。

import re, pathlib
text = pathlib.Path('prompt.txt').read_text(encoding='utf-8')
paras = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()]
total = 0
for i, p in enumerate(paras, 1):
    total += len(p)
    print(f'#{i} chars={len(p)} cum={total} head={p[:40]!r}')

注意 len 只是字符数,中英混排、代码片段的 token 换算比例不同,这里只用来找分界点,不能当成精确的 token 计数。截断点前后做内容比对的方法:把提示里的每一条硬约束和关键段落关键词列出来,逐条核对模型回答里有没有体现。前段约束全部缺失、后段却答得很细,优先怀疑输入侧被截;只有尾部内容缺一截、前面都正常,更可能是输出上限。

用同一提示做长短两组对照

固定变量才能确认截断和内容长短的对应关系。模型、温度、系统提示、输出上限都保持不变,只改输入长度,参数写进一个字典复用,避免两次请求之间参数漂移。

Claude Sonnet 5.5 回复总被截断 / 是输出上限设小了还是提示给太长?
base = {'model': MODEL, 'max_output_tokens': CAP, 'temperature': TEMP, 'system': SYS}
run('short', {**base, 'prompt': SHORT})
run('long',  {**base, 'prompt': LONG})

两组请求的日志并排展示,其余字段应完全一致,只有提示相关字段不同:

[short] prompt_chars=... prompt_tokens=... output_tokens=... max_output_tokens=... stop_reason=...
[long ] prompt_chars=... prompt_tokens=... output_tokens=... max_output_tokens=... stop_reason=...

判断方式:短提示正常结束、长提示在同样参数下被上限截停,说明上限数值本身没变小,而是长输入挤占了可用输出空间(输入输出共用同一个上下文窗口时会发生),或者长提示触发了更长的回答。两组都被上限截停,回到第 5 步调上限。两组都正常结束而长提示答案不完整,重点回到第 3 步和收流解析。

调大输出上限后重跑一次

走到这里是为了确认前面看到的是配置问题而不是模型行为。同一个提示,只改输出上限,记录返回值长度和停止原因的变化。

for cap in (CAP_SMALL, CAP_LARGE):
    r = call_model(prompt, max_output_tokens=cap)
    print(cap, r['stop_reason'], len(r['text']), r['usage']['output_tokens'])
对比项改动前改动后
max_output_tokens原值调大后的值
stop_reason上限类取值期望变为正常结束
返回文本长度断在半句内容补全则为配置问题

如果调大后停止原因变成正常结束、内容也补全了,可以按输出上限配置问题收尾,并把上限值和对应场景写进配置注释。如果仍然在相近长度处停住、或语义依旧中断,说明瓶颈不在上限,回到提示截断、传输层收流和客户端解析这三处继续查。调大上限通常会同时抬高费用和等待时间,建议先在测试请求或低流量路径上验证,不要直接全量替换线上参数。