标题:“Grok 4.20”流式输出总在半截断掉怎么办?先别把 SSE 正常结束当成网络超时

正文:
解决“Grok 4.20”流式输出中断时,我一般先保存原始 SSE,确认有没有正常结束事件。接着检查客户端读取超时、代理缓冲和连接池复用,最后处理重连后的内容去重。

2026 年 11 月 4 日,我排查一个搜索 Agent 的故障,SDK 只抛出一句 Connection error。折腾半天才发现,连接根本没超时,而是客户端把正常 EOF 当成异常,又把请求重试了一遍。结果页面上直接重复了两段答案。

有个前提必须说清楚:截至 2026 年 11 月 7 日,现有一手资料无法证明“Grok 4.20”是 xAI 官方模型 ID。这个名称出现在 ContextX、clawdbot-skill-search-x 等第三方项目描述中,更像产品展示名称或路由别名。排障时别只截一张 UI 图,至少记下 base_url、请求中的实际 model、SDK 版本和服务商名称。

先判断是哪一层断了

现场现象优先检查判断依据
收到明确结束事件后连接关闭服务端结束语义通常属于正常完成,不要自动重试
HTTP 200,中途无结束事件直接 EOF代理或上游对比原始 SSE、响应头和网关日志
固定时长后中断客户端读取超时核对 read timeout,而不是 connect timeout
只有同一连接池中的第二次、第三次请求容易断连接池复用在同一进程中禁止连接复用做对照
重连成功但文字重复应用层去重重试请求生成了重叠内容
HTTP 401/403/429权限、配额或限流先处理状态码,别继续折腾 SSE

排查链路大概是这样:

flowchart LR
 A[客户端读取 SSE] --> B[反向代理]
 B --> C[API 网关]
 C --> D[上游路由]
 D --> E{收到结束事件?}
 E -->|是| F[正常完成]
 E -->|否| G[记录 EOF 与最后事件]
 G --> H[检查超时、缓冲和连接池]

最容易误判的是最后一个数据块之后的 EOF。TCP 连接关闭本身不代表网络故障。如果协议已经给出结束事件,客户端就应该正常保存结果,不能仅仅因为看到 socket close 就重放请求。

为什么会出现流式中断

SSE 中断通常出在服务端、传输链路或客户端。

服务端可能碰到限流、配额判断或上游推理超时。可供参考的社区案例有 Zed issue #63861:通过 OpenRouter 发起 BYOK 请求时,出现了下面的提示:

Free Usage Exceeded

这个案例只能说明某条 BYOK 路由曾出现配额相关问题,不能证明它和“Grok 4.20”直接相关。

传输层更麻烦。Nginx 响应缓冲、网关空闲超时、负载均衡器限制以及 HTTP/2 会话关闭,都可能导致服务端正常生成了数据,客户端却没有及时收到。实际排查时经常会出现这种情况:应用代码一行没改,绕过代理后问题就消失了。

客户端常见的问题也很具体:把整体超时和读取超时混为一谈、没有持续消费响应体,或者页面切换触发了 AbortController。下面是一些通用错误签名,并非已经确认的“Grok 4.20”官方返回:

curl: (18) transfer closed with outstanding read data remaining
requests.exceptions.ChunkedEncodingError: Response ended prematurely
openai.APIConnectionError: Connection error.

方案一:用 curl 保存响应头和原始 SSE

先绕过 SDK,别靠猜。

下面假设接口采用 Chat Completions 风格。Endpoint 和实际模型 ID 必须从服务商的模型列表或请求日志中确认,不能把页面上的“Grok 4.20”直接抄进请求。

以下命令需要在 Bash 中运行。先设置环境变量:

export BASE_URL='https://你的服务商地址/v1'
export API_KEY='你的 API Key'

: "${BASE_URL:?请先设置 BASE_URL}"
: "${API_KEY:?请先设置 API_KEY}"

URL="${BASE_URL%/}/chat/completions"
HEADERS=(
  -H "Authorization: Bearer $API_KEY"
  -H "Content-Type: application/json"
)

然后创建请求体。这里使用真值表中可确认的 grok-4.3 作为示例;如果服务商不提供该模型,必须替换成其模型列表返回的实际 ID:

cat > request.json <<'JSON'
{
  "model": "grok-4.3",
  "stream": true,
  "messages": [
    {
      "role": "user",
      "content": "输出一段较长文本"
    }
  ]
}
JSON

确认文件创建成功后,再执行 curl:

set -o pipefail

curl -sS -N --http1.1 \
  -D headers.txt \
  "${HEADERS[@]}" \
  --data-binary @request.json \
  "$URL" |
tee stream.log

-N 会关闭 curl 自身的输出缓冲,-D headers.txt 会单独保存响应头。stream.log 保存的是 curl 解码传输编码后的响应体,适合检查 SSE 事件边界,但不等同于网络抓包中的原始 TCP 字节。

需要给每行加时间戳时,可以这样采集:

set -o pipefail

curl -sS -N --http1.1 \
  "${HEADERS[@]}" \
  --data-binary @request.json \
  "$URL" |
awk '{
  print strftime("%Y-%m-%dT%H:%M:%S"), $0;
  fflush();
}' |
tee sse-timed.log

这里的秒级时间只用于定位长时间空档,不代表精确的 token 延迟。部分系统自带的 awk 不支持 strftime();遇到这种情况可改用 GNU awk,也就是 gawk。

主要检查这些信息:

  1. HTTP 状态码和 Content-Type;
  2. 最后一个完整 SSE 事件;
  3. 是否出现服务商定义的结束标记;
  4. EOF 前多久没有收到任何字节;
  5. 响应头中是否有服务商请求 ID、网关 ID 或错误信息。

目前没有可靠的“Grok 4.20”官方协议资料,因此不要把结束条件硬编码成只有 [DONE]。有些接口会在 JSON 中返回 finish_reason,有些会发送独立结束事件,还有些会使用服务商自定义字段。具体解析方式必须以实际服务商协议为准。

方案二:拆开客户端超时,并真正消费流

连接超时和读取超时是两回事。连接几秒内建立成功,不代表一段长回复也必须在几秒内结束。

以 HTTPX 为例,排障时可以暂时拆开四类超时:

import httpx

url = "https://你的服务商地址/v1/chat/completions"
headers = {
    "Authorization": "Bearer 你的 API Key",
    "Content-Type": "application/json",
}
body = {
    "model": "grok-4.3",
    "stream": True,
    "messages": [
        {"role": "user", "content": "输出一段较长文本"}
    ],
}

timeout = httpx.Timeout(
    connect=5.0,
    read=300.0,
    write=30.0,
    pool=30.0,
)

with httpx.Client(timeout=timeout) as client:
    with client.stream(
        "POST",
        url,
        headers=headers,
        json=body,
    ) as response:
        response.raise_for_status()

        for line in response.iter_lines():
            print(line, flush=True)

client.stream() 返回的是上下文管理器,必须进入:

with client.stream(...) as response: pass

随后还必须迭代 response.iter_lines()、response.iter_bytes() 或以其他方式持续读取响应体。只创建 stream 对象而不进入上下文、也不消费响应体,并不能完成一次流式请求。

如果目的是保存尽可能接近客户端实际接收内容的响应体,可以按字节读取:

import httpx

timeout = httpx.Timeout(
    connect=5.0,
    read=300.0,
    write=30.0,
    pool=30.0,
)

with httpx.Client(timeout=timeout) as client:
    with client.stream(
        "POST",
        url,
        headers=headers,
        json=body,
    ) as response:
        response.raise_for_status()

        with open("httpx-stream.log", "wb") as output:
            for chunk in response.iter_bytes():
                output.write(chunk)
                output.flush()

这里的 read=300 秒只是诊断配置,不是官方限制,也未必适合生产环境。HTTPX 的读取超时主要约束等待下一批响应数据的时间,不等同于整次请求最多只能运行 300 秒。

如果从 30 秒改到 300 秒后故障消失,只能说明原来的 read timeout 可能偏短。接下来仍要查清楚为什么链路会连续 30 秒没有数据。

浏览器端还要检查 AbortController。页面卸载、路由切换、组件销毁或前端超时器,都可能主动执行 abort()。碰到这种情况,上游服务端日志可能完全正常。

方案三:关闭 Nginx 响应缓冲做对照

Nginx 代理 SSE 时,应把配置放进实际匹配该接口的 location 中,而不是只零散添加几条指令。例如:

location /v1/ {
    proxy_pass https://api_upstream;

    proxy_http_version 1.1;
    proxy_set_header Host api_upstream;
    proxy_set_header Connection "";

    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 300s;

    gzip off;

    add_header Cache-Control "no-cache" always;
}

需要注意:

  • 真正关闭 Nginx 代理响应缓冲的是 proxy_buffering off;。
  • add_header X-Accel-Buffering no; 只是尝试向下游添加响应头,不能代替 proxy_buffering off;。
  • 如果上游应用直接返回 X-Accel-Buffering: no,Nginx 通常可以用它控制当前响应的缓冲行为;但是否生效还要检查有没有配置 proxy_ignore_headers X-Accel-Buffering。
  • proxy_read_timeout 300s 是相邻两次读取之间允许等待的时间,不是整个响应的总时长。
  • proxy_request_buffering off; 控制的是请求体缓冲,通常不是解决 SSE 响应卡顿的关键。

300s 只是排障起点。最大连接时间还可能受负载均衡器、CDN、Serverless 平台或上游网关限制,不能假设改完 Nginx 就解决了整条链路的问题。

如果服务端可控,可以定期发送 SSE 注释心跳:

: heartbeat

SSE 注释以冒号开头,并以空行结束。它不会产生业务内容,但可以让部分代理持续观察到连接上有数据传输。

心跳间隔应小于链路中最短的空闲超时,并留出安全余量。15 秒或 30 秒都只能作为测试起点,不存在脱离部署环境仍适用于所有链路的统一最佳值。

聚合 API 也可以作为对照路径,例如并行检查 OpenRouter、ofox.io 与当前直连结果,但必须尽量保持以下条件一致:

  • 请求体;
  • 实际模型 ID;
  • HTTP 版本;
  • 客户端超时;
  • 是否使用代理;
  • 是否复用连接。

这里的目的不是“换个平台后能用就算修好了”,而是缩小故障发生在哪一跳。

方案四:在同一进程中排除连接池复用问题

有些问题只会在复用旧连接时出现:第一次正常,第二次半截断掉,第三次又恢复。这时需要在同一个客户端进程、同一个连接池中做对照。

单独启动一次 curl 时,并不存在可供它复用的“上一次 curl 进程中的连接”。下面这条命令:

curl -H "Connection: close" ...

主要表达的是本次 HTTP/1.1 传输完成后关闭连接,不能可靠证明当前请求没有复用同一 curl 进程里已经存在的连接。HTTP/2 也不应依赖 Connection: close 这个逐跳请求头来控制会话复用。

curl 中更合适的参数是:

  • --fresh-connect:本次传输不复用连接缓存中的现有连接;
  • --no-reuse:本次传输完成后关闭连接,不留给后续传输复用。

先在同一个 curl 进程中执行两次传输,观察默认复用行为:

curl -v -sS -N --http1.1 \
  -D reuse-1.headers \
  -o reuse-1.sse \
  "${HEADERS[@]}" \
  --data-binary @request.json \
  "$URL" \
  --next \
  -v -sS -N --http1.1 \
  -D reuse-2.headers \
  -o reuse-2.sse \
  "${HEADERS[@]}" \
  --data-binary @request.json \
  "$URL"

--next 让两个传输发生在同一个 curl 进程中。连接缓存仍属于同一进程,因此第二次传输才有机会复用第一次的连接。可以从 -v 输出中检查 curl 是否明确报告复用了现有连接。

然后运行禁止复用的对照组:

curl -v -sS -N --http1.1 \
  --fresh-connect \
  --no-reuse \
  -D fresh-1.headers \
  -o fresh-1.sse \
  "${HEADERS[@]}" \
  --data-binary @request.json \
  "$URL" \
  --next \
  -v -sS -N --http1.1 \
  --fresh-connect \
  --no-reuse \
  -D fresh-2.headers \
  -o fresh-2.sse \
  "${HEADERS[@]}" \
  --data-binary @request.json \
  "$URL"

如果 curl 支持 HTTP/2,还可以把 --http1.1 换成 --http2,重复同样的测试。

建议设置以下四组对照:

对照组HTTP 版本连接复用用途
AHTTP/1.1禁止最朴素基线
BHTTP/1.1允许定位 keep-alive 问题
CHTTP/2允许定位多路复用或会话问题
DHTTP/2使用 --fresh-connect --no-reuse排除旧 HTTP/2 会话状态

对照测试时一次只改一个变量,而且要核实连接是否真的复用。服务端可能主动关闭第一次连接,此时即使客户端允许复用,第二次请求也只能新建连接。

如果禁止复用后连续请求都正常,再检查:

  • 连接池中的空闲连接存活时间;
  • Nginx 或网关 keep-alive 配置;
  • 客户端是否会复用已被服务端关闭的连接;
  • DNS 或负载均衡后端是否发生切换;
  • HTTP/2 GOAWAY、RST_STREAM 等协议事件。

不要同时更换 SDK、代理和模型路由,否则日志再多也难以归因。

方案五:重连后给内容去重

首个 token 出现前失败,可以做有限次数的指数退避重试。已经输出一半后再把原请求完整重放,就不安全了。新回答可能重复、改写,也可能直接走向另一个结论。

至少保存下面这些信息:

  • 业务请求 ID;
  • 服务商请求 ID;
  • 已确认输出文本;
  • 最后一个完整事件序号或事件 ID;
  • 是否收到结束事件;
  • 重试次数和重试原因。

简单文本可以计算“旧文本后缀”和“新文本前缀”的最长重叠:

def merge(old: str, new: str) -> str:
    n = min(len(old), len(new))

    for i in range(n, 0, -1):
        if old.endswith(new[:i]):
            return old + new[i:]

    return old + new

这只是保底方案。代码块、Markdown 表格、增量 JSON 和工具调用不能只按字符拼接,最好根据事件 ID、消息 ID或结构化片段去重。字符级算法也无法识别“语义相同但措辞不同”的重复内容。

为了交叉验证路由,可以通过 OpenRouter 与 ofox.io 分别采集同一请求的 SSE,但不要拿第三方展示名称充当实际模型 ID。

按 2026 年 11 月 7 日随文提供的模型目录核对,可确认的模型 ID 包括 grok-4.3;该目录没有 grok-4.20。因此,“Grok 4.20”的官方身份、价格、上下文长度和最大流式时长都应标记为待验证。

常见问题 FAQ

Q:收到 EOF,就是网络断了吗?

不一定。先看最后一个完整事件是否表示正常结束。只有在没有收到结束事件、响应体不完整或出现明确传输错误时,才应归类为异常 EOF。

同时也要注意:服务商协议未必使用 [DONE],必须根据实际接口定义判断结束条件。

Q:curl 正常,但 Python SDK 总是中断怎么办?

先检查以下项目:

  1. SDK 的 read timeout;
  2. 是否真正进入了流式响应上下文;
  3. 是否完整迭代了响应流;
  4. 异常处理是否吞掉了最后一个事件;
  5. 底层 HTTP 客户端是否复用了失效连接;
  6. 页面或调用方是否主动取消了请求。

然后使用相同请求体、相同模型 ID 和相同 HTTP 版本,对比 SDK 与 curl -N 保存的日志。

Q:HTTP 200 后半截断掉,应该重试吗?

首个 token 返回前可以有限重试。已经收到部分文本后,不要盲目重放。

应先保存已接收内容、请求 ID 和最后一个完整事件。确认业务允许后,再做续写、重放或降级为非流式请求,并对重复内容进行处理。

Q:Nginx 配了 proxy_buffering off 还是中断怎么办?

继续检查:

  • 负载均衡器和 CDN 的空闲超时;
  • 上游 API 网关;
  • HTTP/2 会话状态;
  • 客户端读取超时;
  • 服务端是否发送心跳;
  • 应用是否主动取消请求;
  • 上游是否真的持续生成数据。

Nginx 只是链路中的一跳,关闭它的缓冲不代表其他层没有超时或缓冲。

Q:怎么确认“Grok 4.20”的真实模型 ID?

调用实际 API 提供商的模型列表接口,或者查看请求日志中最终路由的 model 字段。如果服务商还会进行二次路由,应同时保存其响应中的模型、提供商和请求 ID。

GitHub 项目名称、产品界面和营销文案只能证明第三方使用过这个标签,不能代替官方 API 文档。

我的最终选择

我现在的处理方式很朴素:以保存下来的 SSE 日志作为基线,协议结束事件优先于 socket 状态。要不要重试,必须先知道已经输出了什么,以及服务商是否明确表示请求已经完成。

遇到半截中断时,我会先使用 HTTP/1.1,在同一个客户端进程中通过 --fresh-connect 和 --no-reuse 禁止连接复用,并尽可能绕过反向代理。基础链路确认正常后,再逐层恢复连接池、HTTP/2 和代理。

这样排查可能慢一点,但不会把正常结束误报成超时,也不会因为一次无脑重试,让用户看到两份黏在一起的答案。

Logo

更多推荐