← 返回资讯

流式输出中断怎么处理:SSE 断了怎么办的容错设计

2026-08-07

线上出过这么一次故障:客服助手的回答写到一半停住了,光标还在闪,用户等了四十秒发现不动了,刷新页面重问,又断在差不多的位置。后台日志里这些请求全是 200,监控面板上的「错误率」是干干净净的零。

这就是流式请求最讨厌的地方。非流式请求失败得很干脆——超时了、返回非 2xx 了、连接被重置了,你在一个 try 块里就能兜住,退避几百毫秒重试一次,用户什么都没察觉。整个请求要么全有要么全无,重试的代价就是多花一份钱、多等一会儿。

流式中断是”半个结果”。屏幕上已经有三百个字了,服务端也已经把这三百个字生成出来了(通常也就已经计入了用量),然后连接没了。这时候你面对的不是一个”要不要重试”的技术问题,而是三个搅在一起的问题:已经付出去的成本怎么算、屏幕上那半截内容怎么处理、用户要不要重新看一遍已经读过的东西。

流式本身的协议机制我在 流式输出 SSE:原理与各语言实现 里写过,这篇只谈它断掉之后的事。

流式的失败形态,比非流式多得多

非流式请求的失败基本可以归成”连不上”和”返回了错误”两类。流式请求因为一次调用横跨几秒到几十秒,中间任何一个时刻都可能出事,形态要细分得多。如果你的代码只有一个 except Exception,这五种情况会被塞进同一个错误分支,你就永远查不出到底是哪儿的问题。

形态现象怎么识别大概率原因
连接建立失败一个字都没出来,请求直接抛错还没进入迭代循环就抛异常,能拿到状态码鉴权、参数错误、限流、网络不通
首块迟迟不来连接建成,但久久没有第一帧连接成功到第一帧之间的计时超过阈值服务端排队、上下文过长、模型冷启动
传输中途断开已经吐了一部分,忽然停了迭代过程中抛连接类异常,或读到 EOF中间代理超时、网络抖动、服务端异常
缺正常结束标志内容看着挺完整,但流是”悄悄没的”循环正常退出,可结束标志位没被置上上游截断、代理提前关闭连接
末块数据不完整最后一帧解析失败缓冲区里剩了半行 JSON,解析抛错连接在一帧中间被切断

第四种最阴险,也是我开头那个故障的真身。流”看起来正常结束”和”流被掐断”在代码里长得一模一样——都是 for 循环跑完了。区别只在于你有没有真的看见那个结束信号。如果没有显式检查,被截断的回答会被当成正常回答存进数据库、发给用户、进入下一轮对话的上下文,然后污染后面所有的推理。

所以流式解析的骨架应该长这样,关键是那个 saw_end 标志:

saw_end = False
buf = []

try:
    for event in stream:                 # 具体的事件结构以所用服务的官方文档为准
        if is_terminal(event):           # 显式识别"这是结束信号"
            saw_end = True
            break
        buf.append(extract_text(event))
except (ConnectionError, TimeoutError, ChunkDecodeError) as e:
    # 走中断分支:buf 里是已经拿到的半截内容,别丢
    return Partial(text="".join(buf), reason=classify(e))

if not saw_end:
    # 循环正常退出但没见到结束标志 —— 这也是中断,不是成功
    return Partial(text="".join(buf), reason="missing_terminator")

return Complete(text="".join(buf))

代码只有十几行,但它把”成功”和”看起来像成功”分开了。具体的事件结构、结束标志长什么样,各家服务不一样,以你所用服务的官方文档为准——但”必须显式确认结束”这条工程要求是通用的。

超时要分成四层来设

只设一个总超时,是流式接入里最常见的偷懒做法,它同时会犯两个方向相反的错。

设短了,长回答被误杀。 一个要生成两千字的请求,本来就要跑三十秒,你设了二十秒总超时,它就永远在快写完的时候被砍掉——而且是每次都在快写完的时候,你已经付了绝大部分的生成成本。

设长了,卡死的连接迟迟不释放。 你把总超时放到一百八十秒,那么一个在第二秒就实际卡死、再也不会吐字的连接,会在你的连接池里、在用户的屏幕上白白挂满三分钟。用户三十秒前就走了,你的并发额度还被它占着。

问题的根源在于:总时长这一个数字,同时承担了”判断是否卡死”和”允许长回答跑完”两个互相矛盾的职责。拆开就行了。

层级管什么设置思路
连接超时TCP 握手 + TLS + 请求发出最短的一档。这一步慢基本就是网络或线路有问题,早失败早重试
首块超时请求发出到收到第一帧反映服务端排队和预填充。长上下文请求要相应放宽
块间空闲超时相邻两帧之间的最大间隔核心的一层。只要还在稳定吐字就不打断,一旦停止吐字就快速判死
总时长上限兜底闸门最宽松的一档,防止无限长的回答拖垮资源,正常请求不应触碰它

真正干活的是块间空闲超时。它的语义正好是你想表达的那句话:「只要模型还在往外吐东西,我就继续等;一旦它连续这么久一个字都不吐,我就认为它死了。」这个判断和回答总长度完全解耦,所以长回答不会被误杀,卡死的连接也能在几秒内被识别出来。

实现上就是每收到一帧就重置一次计时器:

import time

IDLE_LIMIT = ...   # 块间空闲上限,按你自己观测到的分布定
HARD_LIMIT = ...   # 总时长兜底

start = last_chunk_at = time.monotonic()

for event in stream:
    now = time.monotonic()
    if now - last_chunk_at > IDLE_LIMIT:
        raise StreamStalled("块间空闲超限")
    if now - start > HARD_LIMIT:
        raise StreamStalled("总时长超限")
    last_chunk_at = now          # 收到一帧就续命
    handle(event)

具体数值我不给。你的上游线路、上下文长度、模型、并发情况都和我的不一样,抄来的数字没有意义。 稳妥的起法是:先把这四个值设得比你的直觉宽松一倍,把每一层实际发生的耗时打成指标跑一两周,看清楚分布之后再往回收。收的时候盯住”被超时砍掉的请求里有多少其实是正常的长回答”这个比例,这比拍脑袋定数字靠谱得多。超时参数本身的取法在 API 请求超时怎么设置 里还有更细的展开。

还有一个容易漏的坑:你和模型服务之间往往不止一层。网关、反向代理、云厂商的负载均衡、公司出口的防火墙,每一层都可能有自己的空闲连接超时,而它们默认值经常比你的业务超时短。表现就是流稳定地在某个整数秒附近断掉——比如总在六十秒左右——那多半不是模型的问题,是中间某一跳在按它自己的规矩掐你。查的时候先看断点时间的分布:如果断点集中在一个整数附近,几乎一定是配置项,不是随机故障。

断了之后:三种策略,三种代价

拿到半截内容之后,有三条路可走。没有哪条是普遍最优的,它们的代价落在不同的地方。

策略一:整体重来

丢掉已收到的部分,清空界面,重新发一次完整请求。

好处是实现最简单,结果一定是自洽完整的一段。代价也直白:这次调用的成本翻倍(前半截的生成通常已经产生用量),而且用户要眼睁睁看着答案从头再写一遍——如果第二次又断在类似位置,体验会急剧变差。

适合短回答、结构化输出、以及那种”半截等于没有”的场景。比如让模型返回一段 JSON,半截 JSON 解析都过不了,留着毫无意义,重来是唯一选择。重试的退避节奏参见 重试与指数退避怎么做;如果这次调用会触发扣费、下单、写库这类副作用,重试前务必把幂等键的事情想清楚。

策略二:保留已收到的部分,明确告诉用户

界面上保留那三百个字,在末尾加一个明确的状态提示——「回答未完整,连接中断」,配一个「继续」或「重新生成」的按钮,把选择权交回用户。

这是最诚实的一种,也是我最推荐的默认策略。它的好处经常被低估:用户已经读过的那部分内容没有被浪费,很多时候半截回答已经解决了他的问题,他根本不需要后半段。而且它把不确定性明说了出来,比悄悄补个句号假装完整要好得多。

代价是产品上要接受”界面里会出现不完整的东西”,需要设计一个不显得像 bug 的错误态。适合长文生成、内容创作、代码解释这类增量本身就有价值的场景。

策略三:续写

把已经生成的部分作为上文塞回去,让模型接着往下写,前端把新内容拼在旧内容后面。

看起来最优雅,实际上是三种里最难做好的,有三个绕不开的问题:

风格可能不连贯。 第二次调用是一次全新的生成,模型看到的是”一段已有文本”而不是”我自己刚才的思路”,接上去的部分在语气、详略、列表编号上很容易和前半截对不齐。

可能重复或跳跃。 断点常常落在一个词、一句话的中间,模型会倾向于重新起一句完整的话,于是接缝处出现半句重复;反过来也可能直接跳过断掉的那句往下写。做续写一定要在拼接处做去重处理,比较前后两段的重叠部分,别原样直接拼。

要多付一份输入成本。 已经生成的那几百上千 token 现在变成了新请求的输入,续写次数越多,重复塞进去的上文越长。长文档场景下续写三四次,输入侧的累计消耗可能相当可观。

所以续写只在一种情况下划算:回答很长、重来的生成成本远高于把上文重塞一遍的输入成本、并且业务能容忍接缝处的瑕疵。 短回答用续写是净亏——省下的生成量还不够付重塞上文的输入。

选择的判据可以简化成一张表:

场景建议策略理由
结构化 / JSON 输出整体重来半截结构不可用,保留没有意义
短对话回复整体重来重来的成本低,体验损失小
长文、报告、代码生成保留 + 提示,或续写增量本身有价值,重来浪费大
已产生副作用的调用保留 + 人工确认自动重来可能造成重复执行
断点在极靠前的位置整体重来已生成部分太少,不值得补救

成本视角:流式重试比非流式贵,退避要更保守

这是最容易被忽略的一条。非流式请求失败时,你往往还有”这次不算钱”的可能;流式请求断在中途时,前面那半截通常已经实实在在地生成出来了。

我要在这里划一条明确的线:中断请求的计费口径各家不同,也可能随时调整,我不给任何断言,你必须自己去所用服务的官方文档和账单明细里确认。 确认的办法很简单也很可靠——做一次可控的实验:发一个会生成长回答的请求,在收到明显一部分内容之后主动断开连接,隔一段时间去账单或用量明细里看这次调用被记了多少。用你自己的账单说话,别用任何人的转述,包括我的。

但不管口径如何,有一个工程结论是稳的:流式请求的重试,期望成本高于非流式。同样的重试次数上限,流式那边”每次失败都可能带走一部分实际成本”的概率更高。所以退避策略上要比非流式更保守:

  • 重试次数上限调低。 非流式敢试三次的地方,流式先按更少的次数走,尤其是长回答场景。
  • 按断点位置区分对待。 断在第一帧之前的,基本没产生生成成本,可以正常重试;断在已经吐了一大段之后的,重试前先掂量一下——这种情况往往更适合走”保留 + 提示”。
  • 区分该重试和不该重试的错误。 鉴权失败、参数非法、内容被拦截,这些重试一百次也是同样结果,只是在烧钱。只对连接类、超时类、明确可重试的服务端错误做退避重试。
  • 给中断重试单独设预算护栏。 如果上游抖动,无脑重试会在几分钟内造出一个成本尖峰。在网关层加一个”每分钟中断重试次数”的闸门,比事后看账单发现问题强得多。

前端要做的三件事

后端把中断处理干净了,界面上还是可能一团糟。增量渲染的整体做法在 流式打字机效果怎么做 里有,这里只补容错相关的三条。

第一,渲染层必须能接受”提前结束”。 很多打字机实现假设内容一定会写完,比如按固定节奏从缓冲区里取字、缓冲区空了就等下一批。流断了之后这个循环没人叫停,界面就永远停在最后一个字上、光标一直闪。要有一条从网络层直达渲染层的”到此为止”信号,让渲染追平剩余缓冲后立刻停下,并把状态切成终态。

第二,必须有明确的错误态,而不是永远转圈。 转圈是”还在进行”的意思,用中断状态显示转圈就是在骗用户。中断之后要立刻切成一个可识别的终态:一行说明加一个动作按钮。永远转圈的界面比直接报错更糟,因为它剥夺了用户做决定的机会——他不知道该等还是该重来。

第三,界面要能区分”写完了”和”断了”。 这两种状态在视觉上必须不同。写完了就是安静的终态;断了要有明确的视觉提示。用户凭界面就能判断这段内容是否完整,这直接影响他要不要相信这段内容、要不要复制走用。

还有一个细节:Markdown 增量渲染要能容忍不闭合的结构。截断很容易发生在代码块中间、表格中间、加粗标记中间,解析器碰到不闭合的语法可能吐出一堆错乱的排版。渲染前对未闭合的围栏做一次补全再送进解析器,是很便宜的一层保护。

可观测性:中断率是投诉的先行指标

流式调用不能只记”成功/失败”和总耗时。至少要有这几个指标:

  • 中断率:中断请求数占流式请求总数的比例,按模型、按上游、按业务场景分别打点。
  • 中断类型分布:前面表格里那五种形态各自的占比。这个分布本身就是诊断——首块超时涨说明上游在排队,块间空闲超时涨说明连接层不稳,缺结束标志涨大概率是某个中间代理在掐。
  • 首块延迟:连接成功到第一帧的耗时分布。这是用户主观感受最直接的指标,也是最早出现劣化信号的地方。
  • 平均完成率:中断时已收到内容量占该场景典型完整回答长度的比例。这个数决定了你的补救策略该往哪边偏——完成率高说明多数中断发生在尾部,“保留 + 提示”的收益大;完成率低说明断得早,重来更合适。
  • 中断后的处理去向:重来了多少、保留了多少、续写了多少、用户主动放弃了多少。没有这个数,你没法评估自己的策略选对了没有。

中断率上涨几乎总是先于用户投诉。 原因很实在:单次中断用户往往会自己重试一遍,不会专门来找你;等到他忍不住来投诉,通常已经连着遇到好几次了,那时中断率早就翻了倍。把中断率做成有告警阈值的曲线,你能提前几个小时甚至几天发现上游劣化。

打点还有一个容易漏的地方:中断的请求要和成功的请求打进同一套指标里,用状态字段区分,别只在异常日志里记一笔。 只记日志的后果就是开头那个故障——面板上错误率是零,因为那些请求 HTTP 状态码确实是 200,没人把它们算进失败。

自查清单

  1. 流式解析里有没有一个显式的”看见结束标志”的布尔量?循环正常退出但没看见结束标志时,是不是走了中断分支而不是成功分支?
  2. 连接超时、首块超时、块间空闲超时、总时长上限,这四个值是不是分开配置的?如果只有一个总超时,先把块间空闲超时加上。
  3. 中间链路(网关、反向代理、负载均衡、防火墙)的空闲超时是不是都比业务超时长?断点时间是否集中在某个整数附近?
  4. 中断时已收到的内容有没有被保留下来交给上层?还是在异常里被整个丢掉了?
  5. 三种补救策略是按场景选的,还是全站一刀切?结构化输出和长文生成用的是同一套逻辑吗?
  6. 中断请求的实际计费口径,你是自己在账单里核实过的,还是听来的?做一次主动断开的验证实验。
  7. 流式重试的次数上限,是不是比非流式更低?有没有区分”断在首块前”和”断在生成中途”?有没有单独的重试预算护栏?
  8. 界面在中断时是明确的错误态还是永远转圈?用户能不能一眼分清”写完了”和”断了”?
  9. 中断率、中断类型分布、首块延迟、平均完成率,这四个指标现在打出来了吗?中断率有告警阈值吗?

相关阅读