输出停在半句话处,先不要急着改提示词。三种来源留下的痕迹不一样:长度上限截断时,返回体里通常带着明确的结束原因,输出 token 数贴着上限;流式读取循环提前退出时,连接看起来正常,但你手里的文本就是缺了尾巴,也拿不到结束原因;请求超时或被中断时,往往伴随异常、连接重置或读取超时日志。建议把排查顺序固定下来:先看结束原因字段,再做长度对照实验,然后检查流式收尾,最后用非流式请求对照。
先在原始返回体里找结束原因字段。值是 length、max_tokens 这类上限标记,说明是长度参数截断;值是正常结束标记却仍缺内容,就要看流式循环是否在结束标记前退出。字段缺失或为 null 时,优先怀疑客户端没读完流、读取超时或连接中断。再用“调大上限”的对照实验和同一提示词的非流式请求分离原因。所有判断都落到响应字段、日志和读取代码上,不靠观感。
先看返回体里的结束原因字段
不同接入方式里这个字段名不统一,常见的有 finish_reason、stop_reason、finishReason,下文统一用 <FINISH_FIELD> 占位,实际字段名以你接入的接口文档为准。判断原则只有一条:结束原因是“达到上限”还是“自然结束”。
length、max_tokens、max_output_tokens一类取值:输出是被长度上限截断的,文本停在半句符合预期,先去看参数。stop、end_turn、stop_sequence一类取值:模型认为已经说完,但仍缺内容,问题更可能出在流式读取或文本拼接环节。content_filter一类取值:输出被策略拦截,不一定是长度问题,需要单独看待。- 字段缺失、为
null或整段响应解析失败:先不要归因于模型,多半是没收到完整的结束块。
字段缺失时的回退判断:流式场景看有没有收到结束标记(如 [DONE]、message_stop 事件,或结束原因为非空的最后一个事件);非流式场景看响应是否正常闭合、usage 里的输出 token 数是否可读。两样都没有,基本可以判定这次请求没有正常收尾,而不是模型只写了半句。
# 非流式:先把原始返回打出来,不要只看拼好的文本
resp = call_model(prompt, stream=False)
data = resp.json()
print("finish:", data.get("<FINISH_FIELD>"))
print("usage_out:", (data.get("usage") or {}).get("<OUTPUT_TOKENS_FIELD>"))
print("chars:", len(data.get("<OUTPUT_TEXT_FIELD>") or ""))
把长度上限调到明显大于预期再试一次
长度上限参数一般在请求体顶层,可能叫 max_tokens、max_output_tokens、max_completion_tokens,也有的接入把它放在生成参数对象里。位置以你实际使用的接口为准,但思路一致:做一次只改上限、其余全不动的对照实验。
{
"model": "<MODEL_ID>",
"messages": [{"role": "user", "content": "<同一段提示词>"}],
"max_tokens": 4096,
"stream": false
}
操作步骤:先把上限设成明显大于你预期输出长度的值(比如预期几百 token,就先给到几千),用同一提示词跑一次;再换回你认为“设小了”的原值跑一次。对比方式是看三项而不是看字数直观感受:
- 返回体里的输出 token 数(
usage中的输出计数); - 输出文本的字符数;
- 结束原因字段取值。
如果原值下输出 token 数几乎正好等于上限、且结束原因是上限标记,调大后文本自然变长,那截断来自参数。如果调大上限后输出长度基本没变、结束原因还是原样,参数就不是主因,回到结束原因和读取环节上看。注意:上限设得过大在长输出场景会拉长响应时间,验证完按实际需要回调,不要长期挂着。
检查流式读取循环是否提前退出
流式截断最常见的一种情况是:客户端在结束标记到达之前就 break 了,或者异常被 except 吞掉后直接返回已经拼好的半截文本。检查点是三处——循环退出条件、结束标记的判定写法、异常分支是否留痕。
buf = []
got_end = False
try:
for event in stream:
if is_text_delta(event):
buf.append(text_of(event))
# 只认真正的结束标记,不要因为空 delta 或心跳就退出
if is_end_marker(event) or is_error_event(event):
got_end = is_error_event(event) is False
break
except (ReadTimeout, ConnectionError) as e:
# 异常分支必须记录,不要静默 return 半截文本
log.warning("stream interrupted: %r", e)
finally:
log.info("stream_end=%s chars=%d", got_end, sum(len(x) for x in buf))
return "".join(buf)
结束标记的判定写法要具体:服务端发来的 [DONE] 行、message_stop 类事件,或某个事件里结束原因字段非空,这些才算结束。不要把“本次事件没有文本增量”当成结束,也不要在 while True 里对 chunk 为空的判断直接 break。异常分支的处理原则是:记录下来并让调用方知道这次没收完,可以在返回结构里加一个 truncated=true 标记,而不是把半截文本冒充完整结果。
用同一提示词做一次非流式对照
流式和非流式用同一提示词、同一上限各跑一次,能把“流式解析问题”和“模型输出问题”分开。模型只在非流式下也停在同一位置,说明是长度或内容层面的问题;只有流式缺尾巴,问题落在读取与拼接。
| 对照维度 | 流式 | 非流式 | 差异说明什么 |
|---|---|---|---|
| 输出 token 数 | 记录 | 记录 | 两者差距大,先查流式是否丢事件 |
| 输出字符数 | 记录 | 记录 | 流式明显偏短,重点查读取循环 |
| 结束原因字段 | 记录 | 记录 | 流式为 null 而非流式正常,多为收尾问题 |
| 是否收到结束标记 | 记录 | 不适用 | 没收到即客户端提前退出或超时 |
| 耗时与异常 | 记录 | 记录 | 耗时接近超时阈值时优先怀疑超时 |
差异点记录时建议带上请求 id、提示词摘要、上限值、读取方式,便于多次复现后横向比对。只跑一次往往看不出规律,偶发截断要攒几次样本再下判断。
记录每次请求的输入输出长度
把每次调用都落一条结构化日志,偶发截断才会从“感觉有时候会断”变成可统计的现象。
{
"req_id": "<REQUEST_ID>",
"stream": true,
"max_tokens": 4096,
"prompt_tokens": 812,
"output_tokens": 4094,
"output_chars": 6321,
"finish": "<FINISH_FIELD> 的取值",
"stream_end": true,
"elapsed_ms": 18400,
"error": null
}
告警的触发条件可以按三类设,阈值结合业务容忍度定,不要照搬别人给的数字:
- 结束原因等于上限标记的请求占比连续升高,说明上限或提示词把输出顶到了天花板;
- 输出 token 数达到所设上限的九成以上,可作为观察线,先观察再决定是否告警;
stream_end=false或error非空的计数在短时间内集中出现,通常是超时、连接中断或客户端版本问题。
这三类条件分别对应参数、输出规模和读取收尾,日志字段齐了,下次再遇到半截输出,就能直接定位到是调大上限、改读取循环,还是处理超时与重试。