回答到一半停住,最常见的误判是先去把超时调大。MiniMax M3.1 Flash 这类流式接口,中断至少有三个来源:请求里没真正开启流式、客户端读流的超时先到了、中间层把长连接断掉。三者要改的地方完全不同,所以先花几分钟做一次分类,比直接改参数省时间。建议的判断顺序是:先看这次响应头的内容类型,确认是不是流式;再看数据块的到达间隔,看是均匀走到某处停住,还是总在某个固定时长附近断;最后才怀疑网关或负载均衡。
判断路径:先确认返回头是 text/event-stream 而不是 application/json,排除“没开流式被误判成中断”;再用带时间戳的读取循环看块间隔,若间隔总在某个固定数值附近突然中断,多半是客户端或中间层的读超时;若服务端已经下发结束标记,则要往请求内容、长度或内容策略上找。三种情况改的参数不同,只调大超时往往治不好。
先确认这次请求到底有没有开启流式
不少“断在一半”的现场,其实是请求根本没走流式:服务端一次性把完整回答算完再返回,客户端在等待期间没有任何数据可读,等到读超时触发了,看起来就像回答说到一半被切断。所以第一步是把请求体翻出来看一遍。
流式开关通常是一个布尔参数,放在请求体顶层,不在 messages 里。下面是一个可替换的通用骨架,模型名、地址和鉴权头按你控制台里的实际值填:
POST /v1/chat/completions
Content-Type: application/json
Authorization: Bearer $API_KEY
{
"model": "MiniMax-M3.1-Flash",
"messages": [{"role": "user", "content": "写一段 300 字的产品说明"}],
"stream": true
}发出去之后先不看内容,看响应头里的 Content-Type。开了流式,通常是 text/event-stream,正文以 data: {...} 形式逐行到达,末尾会有 data: [DONE] 或一个非空的结束原因字段;没开流式,通常是 application/json,整段响应体一次性返回,里面已经带好完整的 choices 和用量统计。
- 开了流式:日志里应该出现多行
data:片段,每行只含一小段增量文本。 - 没开流式:日志里只有一条完整的 JSON,中间没有任何中间态。
- 判断落点:如果日志里只有一条完整 JSON,而客户端却报超时或截断,问题在客户端等待策略,不在模型侧生成。
还有一种容易混的情况:客户端库或框架把 stream 参数吞掉了,或者默认值不是 true。可以直接用 curl 发一次同样的请求体做对照,把返回头打印出来,curl -i -N 里的 -N 用来关闭 curl 自己的缓冲,避免看不到中间块。
给每个数据块打时间戳再读
确认是流式之后,第二步看“怎么停的”。关键不是看总耗时,而是看每一块之间的间隔分布:均匀到达说明链路正常;间隔逐步变大然后归零,通常是超时或链路空闲被回收;间隔一直很小但内容突然变短,更像服务端主动结束。
下面这段读取循环只做一件事:把每个数据块的到达时刻和与上一块的间隔打印出来,顺便按行落盘,方便中断后回看。替换项是 URL、鉴权头和模型名。
import json, time, httpx
t0 = time.monotonic()
last = None
with httpx.stream("POST", URL, headers=HEADERS, json=payload,
timeout=httpx.Timeout(connect=10, read=60, write=10, pool=10)) as r:
print("HTTP", r.status_code, "CT:", r.headers.get("content-type"))
with open("stream.log", "w", encoding="utf-8") as f:
for line in r.iter_lines():
now = time.monotonic()
gap = 0.0 if last is None else now - last
last = now
stamp = f"{now - t0:8.3f}s gap={gap:6.3f}s"
f.write(f"{stamp} {line}\n")
f.flush()
if line:
print(stamp, line[:100])时间戳要打在两处:进入循环之前记一次起点,每读到一行再记一次。这样能直接看出“首块延迟”和“块间间隔”是两回事——首块延迟大会让人误以为模型很慢,块间间隔突然拉大才是中断前兆。
判断方法很朴素:把 gap 列从日志里拉出来看。若 gap 一直在几十毫秒到几百毫秒之间波动,最后一行后面就没了,说明是连接被对端或中间层关掉;若 gap 逐步爬升,最后停在一个接近你某个配置值的数上(比如 30 秒、60 秒),基本可以锁定那一层超时。注意不同环境这个数值会不一样,需要结合你自己的配置确认。
区分客户端超时与服务端提前结束
这一步要回答的是:该改客户端参数,还是该改请求内容。
客户端超时通常分三个维度,配错维度的现象很像但修法不同:连接超时(连不上,请求都发不出去)、读超时(两块数据之间允许等待的最长时间)、总超时(整个请求的墙钟上限)。流式长回答最容易被总超时误杀——回答本身没断,是客户端自己掐了。以常见的库为例:httpx.Timeout(connect=10, read=60, write=10, pool=10) 里 read 是块间隔上限,不设总时长;requests 的 timeout 元组是 (连接, 读);curl 侧 `--max-time` 是总时长,`--connect-timeout` 才是连接阶段,长时间流式建议不要用 `--max-time` 卡死。
服务端结束则要看结束标记怎么判断。稳妥的写法是同时认两种信号:SSE 的 [DONE] 行,以及最后一个数据块里 finish_reason 之类字段非空。不要只靠“读到空行”判断结束,很多实现会在块之间插空行。
if line.startswith("data:"):
body = line[5:].strip()
if body == "[DONE]":
print("server done")
break
chunk = json.loads(body)
choice = (chunk.get("choices") or [{}])[0]
if choice.get("finish_reason"):
print("finish_reason:", choice["finish_reason"])
# 常见取值如 stop / length / content_filter,含义不同,按实际情况处理两种情况的日志长相差别很明显,可以用作对照。客户端超时一般是这样的:正常若干块之后,间隔一路涨到某个固定值,然后抛出一个读超时或连接异常,日志里没有结束标记。
[ 0.412s] gap= 0.000s data: {"choices":[{"delta":{"content":"这"}}]}
[ 0.463s] gap= 0.051s data: {"choices":[{"delta":{"content":"款"}}]}
[ 60.470s] gap=60.007s ReadTimeout: timed out服务端提前结束则是另一种:块还在持续到达,间隔正常,然后出现结束原因或结束标记,连接是干净关闭的。
[ 0.463s] gap= 0.051s data: {"choices":[{"delta":{"content":"款"}}]}
[ 1.204s] gap= 0.038s data: {"choices":[{"delta":{},"finish_reason":"length"}]}
[ 1.205s] gap= 0.001s data: [DONE]如果结束原因是 length 一类,说明是被输出长度上限截断的,这时把超时调大没有任何作用,应该去调请求里的最大输出限制或把任务拆小。
查一遍中间层的连接保持设置
客户端和服务端都正常,中断还是会发生在中间。流式响应是一条长时间空闲又突然有数据的长连接,链路上任何一层的空闲回收策略都可能先动手,而且关连接往往不给你明确错误。
先看请求头。除了正常的鉴权头,流式场景一般要带上 Accept: text/event-stream,有些链路需要 Connection: keep-alive;如果前面是 nginx,还常需要一个关闭缓冲的响应头(例如在后端返回里加 X-Accel-Buffering: no),否则数据被攒着不发,客户端看起来就是“一直不来数据,然后超时”。
再去找超时配置的位置。通用做法是按链路从内向外找:反向代理层看 proxy 相关的读超时和缓冲开关(nginx 里是 proxy_read_timeout、proxy_buffering,写在 http、server 或 location 段都可能生效,就近的配置优先);负载均衡层看监听器或后端服务器组里的空闲超时、连接保持时长;容器或 API 网关层看路由级超时。这些默认值在不同产品和版本里不一致,必须自己去配置项里确认,不能照抄。
location /v1/ {
proxy_pass http://upstream;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 300s;
}最省事的一步是对照实验:同一个入口,发一个非流式的短请求,再发一个流式请求,观察两者是否都在同一个时间点附近失败。短请求正常、长流式固定时长后断,指向中间层空闲超时;两者都失败,问题更靠前,先去查网络和鉴权。做对照时把两边的响应头和耗时都记下来,才方便比对。
把断点位置记录下来做一次复现
偶发中断最难的地方是没法验证修没修好。做法是把每次请求的关键现场记下来,让中断从“偶发”变成“可重复出现的条件”。
建议至少记录这些字段:请求时间、请求 id 或响应头里的追踪 id、模型名与关键参数(是否流式、最大输出限制、温度等不必须,但流式开关必须有)、客户端库名称与版本、首块到达耗时、最大块间隔、最后一块的内容与结束标记、客户端抛出的异常类型、请求走了哪一层入口(直连还是经过代理)。这些字段落到一行日志里就够了,不需要复杂结构。
复现步骤可以固定成三步:第一步,用同样的请求体连续发若干次,只改一个变量(比如把最大输出限制调大或把读超时调长),看中断位置是否随之移动;第二步,换一条链路做同样请求,比如绕过中间层直连,对比是否还断;第三步,把带时间戳的 stream.log 和这次请求 id 对应起来存档。中断位置跟着某个配置值移动,基本就锁定了那一层。
结论写进哪里很关键:如果是超时参数问题,写进接入说明或配置清单里,注明哪个参数控制块间隔、建议值和适用场景;如果是中间层限制,写进部署或网关配置文档,附上改动前后的对照记录;如果是请求内容被截断,写进调用方的问题记录表,标明触发条件和改法。不要只留在聊天记录里,否则下次同样现象还要从头排查一遍。