流式输出断在半句,最先要区分的不是模型本身好不好用,而是三种可能:服务端已经正常收尾但你少读了几片、服务端确实提前结束(长度上限或停止条件)、链路在中间被超时切断。建议把判断顺序固定下来:先逐片落盘拿到证据,再看结束原因字段,然后用同一请求走非流式做长度对照,最后才动读取循环和输出上限参数。没有分片日志,后面所有推断都只是在猜。
断在半句时,先看最后一片是否带结束原因:带了说明服务端主动收尾,优先查输出上限和停止条件;没带说明流没走完,优先查客户端读取循环和链路超时。再用同一提示词、同一参数走非流式对照输出长度:两者都短是生成端限制,只有流式短则是读取端或链路问题。这套判断只对可复现的请求成立,换模型版本或中间层需要重新取一次证据。
把流式分片原样落盘再判断
第一步不是修代码,而是让每一片都留下痕迹。下面这段只用于采集,不要直接放进线上读取路径,它的目的是回答一个问题:是服务端没发来,还是你这边没读到。字段名按你实际用的 SDK 替换,取不到就留空,不要为了凑字段去猜结构。
import json, time
def dump_stream(chunks, path='stream_dump.jsonl'):
t0 = time.monotonic()
prev = t0
with open(path, 'a', encoding='utf-8') as f:
for i, chunk in enumerate(chunks):
now = time.monotonic()
text = extract_text(chunk) # 按实际 SDK 取增量文本,取不到给空串
finish = extract_finish(chunk) # 按实际字段取结束原因,没有给 None
rec = {
'seq': i,
't_ms': round((now - t0) * 1000),
'gap_ms': round((now - prev) * 1000),
'text_len': len(text),
'text_tail': text[-40:],
'finish': finish,
}
print(json.dumps(rec, ensure_ascii=False), file=f, flush=True)
prev = now
拿到 stream_dump.jsonl 之后,按这几种情况判读:最后一条记录的 finish 为空、而 gap_ms 明显大于前面几片,更像链路停顿后被断开或触发了读取端超时;最后一条 finish 有值,说明服务端主动收尾,直接去核对第 2、4 节;中间出现 text_len 为 0 但带 finish 的片,是只带元数据的收尾片,不能当成“没内容”就忽略;记录条数明显少于肉眼看到的文本量,先怀疑读取循环提前退出。text_tail 建议保留,句尾是否真的断在半句,比总长度更直观。
检查结束原因字段说明了什么
与结束相关的字段位置,常见的有两种:一种挂在响应对象里,例如 choices[0].finish_reason 或 candidates[0].finishReason 这类路径;另一种在流式协议层,例如最后一行单独的结束标记,或者 done、is_finished 这类布尔字段。流式场景下这些字段通常只在最后一片有值,前面的分片为 null,这一点要在日志里体现出来,否则很容易把“前几片没有结束原因”误读成“没有结束原因”。
不确定字段名时,最省事的办法是先打原始报文,再对解析后的对象做一次递归搜索。下面两个写法可以二选一:
print(raw_line[:500]) # 原始 SSE 行,先看协议层长什么样
def find_finish(obj, path='', hits=None):
if hits is None:
hits = []
if isinstance(obj, dict):
for k, v in obj.items():
p = f'{path}.{k}' if path else k
key = k.lower()
if 'finish' in key or 'stop' in key or 'end_reason' in key or key == 'done':
hits.append((p, v))
find_finish(v, p, hits)
elif isinstance(obj, list):
for i, v in enumerate(obj):
find_finish(v, f'{path}[{i}]', hits)
return hits
print(find_finish(chunk_dict)) # chunk_dict 按 SDK 用 model_dump / dict(chunk) 取得
把搜到的值对回业务含义,通常落在四类:正常结束,表示服务端认为写完了,但你拿到的是半句,问题大概率在读取端,回到第 5 节;达到输出上限,说明被长度截断,去看第 4 节的参数;内容策略或其他拦截,需要改提示词或换输入,不是调参数能解决的;整个流里都搜不到结束原因,说明流没有正常走完,属于链路断开或客户端提前退出。这四类里,只有第一类和第三类能靠改客户端或改提示词处理,第二类和第四类的处理方向完全不同,不要混着改。
用同一请求走非流式做对照
对照实验的要求是除 stream 开关外,其他一切不变:同一个提示词、同一个输出上限、同一个温度、同一批工具或系统指令。否则长度差异说明不了任何问题。
params = build_params(
prompt=PROMPT,
temperature=0,
max_output_tokens=1024, # 与流式请求保持完全一致
)
full = client.generate(**params, stream=False) # stream 开关名按实际接口替换
plain = extract_text(full)
print(len(plain), extract_finish(full), repr(plain[-60:]))
记录时建议固定三个字段并排写:流式的字符数与结束原因、非流式的字符数与结束原因、两边结尾 60 字是否都是半句。判读方式是:两边都短且都断在半句,优先怀疑生成端的输出上限或停止条件;非流式完整而流式被截,问题落在流式链路或客户端读取上,跟模型能力关系不大;两边长度接近但结尾都不完整,那多半是提示词把模型引向了中途自然收尾,或者请求里带了让模型提前停的约束。每一组对照都记下请求时间和参数快照,否则过两天回看会分不清哪次改了参数。
核对最大输出长度与停止条件配置
输出上限一般放在请求体的生成配置里,名字随接口不同而变化,常见的是 maxOutputTokens、max_output_tokens 或 max_tokens 这类字段,位置通常在 generationConfig 一节或者请求体顶层。取值含义是“输出侧 token 上限”,不包含输入;需要注意的是有些模型把推理过程的 token 也计入这个上限,导致实际可见文本被压缩,看起来就像被截断。另外还要检查请求里有没有 stop 序列、停止词列表这类配置,它会让服务端在命中某个字符串时主动结束,结束原因往往表现为正常结束而不是长度超限。
改动实验建议一次只动一个变量:固定提示词和温度,把输出上限分别设为 256、1024 和 4096 各跑一次,每次记录参数值、返回字符数、结束原因、结尾 60 字。如果调大上限后结束原因从长度超限变成正常结束、句子也完整了,那之前就是上限压得太低;如果上限调到 4096 后仍然同样长度就结束且原因是正常结束,说明是模型自己决定停下,应该回头去看提示词和停止条件,而不是继续加大上限。这些结论都需要在你自己的环境和当前接口版本上取一次,不同接入层的默认值可能不一样。
修正客户端读取循环的退出条件
读取循环里最常见的三个反模式是:收到空文本就 break、累计长度达到某个阈值就 break、把第一段能解析的内容返回后就不读了。收尾片常常不带文本,空片判断会直接丢掉最后的元数据;阈值判断会把长回答固定截断在同一个位置,看起来比超时还像“被截断”。修正方向只有一条:只有结束标记出现或数据流自然关闭时才退出,其余情况都继续读,并且异常分支必须打日志。
def read_stream(resp, log):
parts = []
finished = False
try:
for chunk in resp:
text = extract_text(chunk)
if text:
parts.append(text)
if extract_finish(chunk):
finished = True
break
except TimeoutError as e:
log.warning('stream timeout, chars=%d, err=%s', sum(map(len, parts)), e)
except Exception:
log.exception('stream error, chars=%d', sum(map(len, parts)))
if not finished:
log.warning('stream closed without finish reason, chars=%d', sum(map(len, parts)))
return ''.join(parts), finished
返回的 finished 标志要向上传递,调用方据此决定是展示完整回答、提示“回答可能不完整”,还是触发一次非流式重试。异常分支里打印已经累积的字符数,是后续复盘时最有用的一条信息:字符数为 0 说明连接就没建立好,字符数中等说明读到一半断开,接近上限说明是长度而不是网络。超时时间本身也要和单次响应的正常耗时对齐,设得过短会把长回答系统性截在半句,这类现象往往表现为“短问题正常、长问题必断”,先怀疑超时值而不是模型。