DeepSeek-V4.1-Flash-1017 输出被截断、是长度上限设小了还是流没收完?

文章导读
输出停在半句话处,先不要急着改提示词。三种来源留下的痕迹不一样:长度上限截断时,返回体里通常带着明确的结束原因,输出 token 数贴着上限;流式读取循环提前退出时,连接看起来正常,但你手里的文本就是缺了尾巴,也拿不到结束原因;请求超时或被中断时,往往伴随异常、连接重置或读取超时日志。建议把排查顺序固定下来:先看结束原因字段,再做长度对照实验,然后检查流式收尾,最后用非流式请求对照。
📋 目录
  1. 壹 先看返回体里的结束原因字段
  2. 贰 把长度上限调到明显大于预期再试一次
  3. 叁 检查流式读取循环是否提前退出
  4. 肆 用同一提示词做一次非流式对照
  5. 伍 记录每次请求的输入输出长度
A A

输出停在半句话处,先不要急着改提示词。三种来源留下的痕迹不一样:长度上限截断时,返回体里通常带着明确的结束原因,输出 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,就先给到几千),用同一提示词跑一次;再换回你认为“设小了”的原值跑一次。对比方式是看三项而不是看字数直观感受:

  1. 返回体里的输出 token 数(usage 中的输出计数);
  2. 输出文本的字符数;
  3. 结束原因字段取值。

如果原值下输出 token 数几乎正好等于上限、且结束原因是上限标记,调大后文本自然变长,那截断来自参数。如果调大上限后输出长度基本没变、结束原因还是原样,参数就不是主因,回到结束原因和读取环节上看。注意:上限设得过大在长输出场景会拉长响应时间,验证完按实际需要回调,不要长期挂着。

检查流式读取循环是否提前退出

流式截断最常见的一种情况是:客户端在结束标记到达之前就 break 了,或者异常被 except 吞掉后直接返回已经拼好的半截文本。检查点是三处——循环退出条件、结束标记的判定写法、异常分支是否留痕。

DeepSeek-V4.1-Flash-1017 输出被截断、是长度上限设小了还是流没收完?
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 非空的计数在短时间内集中出现,通常是超时、连接中断或客户端版本问题。

这三类条件分别对应参数、输出规模和读取收尾,日志字段齐了,下次再遇到半截输出,就能直接定位到是调大上限、改读取循环,还是处理超时与重试。