将模型生成直接绑定 HTTP 断连取消,会让短暂网络故障终止已付费的生成,并在重连时重新发起请求。文章建议拆分生成启动与 SSE 订阅,通过流 ID 管理任务,并由清理机制处理长期无人订阅的生成。
你的聊天 UI 在办公室 Wi-Fi 下看起来一切正常。可一旦有人在火车上使用,SSE 连接在一句话说到一半时断开,回答就可能从头开始,或者模型继续消耗 token,却已经没人接收。同一个功能,换个网络,表现就完全不同。
我花了一段时间,把一个“演示时能用”的流式功能改造成能够应对断线重连的实现。完整的可运行项目放在 Tech Skill Builder,包含 ASP.NET Core 10、TypedResults.ServerSentEvents 和测试。这篇文章是一份简短的检查清单:我反复看到的五个错误,以及对应的解决方案。
花五分钟搭出来的版本,通常会把 GetStreamingResponseAsync 绑定到 HttpContext.RequestAborted。关闭标签页,模型调用就停止了。这看起来挺省钱,直到你发现不稳定的代理也会触发同样的行为:每一次短暂断线,都会取消你已经付费执行的工作,而重连又会发起一个新的 prompt。
解决办法:把启动生成和订阅拆开。
POST /api/chat/streams 在 registry 中启动生成,返回 202,以及 streamId、eventsUrl、cancelUrl。
GET /api/chat/streams/{id}/events 用于订阅 SSE。
即使订阅者断开连接,生成仍会继续运行,直到 sweeper 判定已经没人关心这个回答。
// POST /api/chat/streams
var stream = registry.TryStart(messages, owner);
return TypedResults.Accepted(
$"/api/chat/streams/{stream.Id}/events",
new StreamStarted(stream.Id, eventsUrl, cancelUrl));
// GET /api/chat/streams/{id}/events
// Last-Event-ID header (or ?lastEventId=) → replay missed events
return TypedResults.ServerSentEvents(
StreamSubscriber.ReadAsync(stream, after, options, time, http.RequestAborted));
// full implementation in the complete project
EventSource 只支持 GET。仅这一点,就足以推动你采用这种拆分方式:重连时,你无法通过 POST 发送请求体。
如果每个 SSE 事件都没有 id:,浏览器就没有可放进 Last-Event-ID 的值。你的客户端要么重复显示 token,要么要求服务端重新生成。
解决办法:为每个回答维护一个只追加的 StreamLog。每个事件都分配一个序列号。订阅时,解析 Last-Event-ID,然后只重放错过的事件。缓冲区要有容量上限;如果客户端回来得太晚,就发送一个包含当前全部文本的快照,而不是逐条发送那些已经被清除的增量事件。
public long Append(ChatStreamEvent e)
{
// assign next sequence, store event, wake waiters
// if over capacity → trim oldest; keep aggregated text for snapshots
// full implementation in the complete project
}
如果流已经结束,客户端也已经接收完全部事件呢?返回 204 No Content。这是 SSE 规范规定的做法,可以阻止 EventSource 无休止地重连。
遇到连接问题时,浏览器会触发 EventSource 自身的 error。如果你的消息也使用 event: error,这两种错误就会被混为一谈,而且往往还会进入同一个处理函数。你会花上比预期更多的时间排查:“到底是模型出问题了,还是 Wi-Fi 出问题了?”
解决办法:使用一组精简、朴素的事件名称。
把失败事件命名为 failed,而不是 error。在消息中放入一条适合展示给用户的提示,以及一个 errorId。已经生成的部分文本继续保留在屏幕上。
代理和负载均衡器很容易处理掉空闲连接。当模型在思考,或者工具正在执行时,你的 SSE 可能长时间没有消息,最终被断开。另一方面,用户点击“停止”,也会期待计费随之停止。如果取消操作只是关闭浏览器这一侧的连接,服务端可能还在继续生成。
工作进行期间,定时发送 heartbeat,例如每 15 秒一次。
提供 POST /api/chat/streams/{id}/cancel,取消模型调用,并以 done / finishReason: cancelled 结束。
发送 X-Accel-Buffering: no,避免 nginx 把整个流缓冲成一大块数据后才发出去。
// Cancel endpoint shape
if (registry.Find(id) is not { } stream || stream.Owner != owner)
return TypedResults.NotFound();
return stream.RequestCancel()
? TypedResults.Accepted((string?)null)
: TypedResults.Conflict(); // already finished
// full implementation in the complete project
用 TypedResults.ServerSentEvents 包装一个原始的 token 循环,很适合做可行性验证。但它还算不上产品级 API。你没有断点续传,没有归属校验,没有对活跃流数量的限流,也没有支持多实例部署的方案。
在宣布完成之前,检查以下事项:
完整项目包含 coalescer,用于批量合并细小的 token 片段;还包含 sweeper 的时间配置、无需密钥即可用于演示的离线流式模型,以及完整的测试套件。这篇文章提供的是地图,完整实现还需要你亲自走一遍。
如果你想获取可运行的源码和更深入的配套文章,可以到 Tech Skill Builder 获取。会员可获得完整的、支持断点续传的流式解决方案,直接通过 dotnet test 和 dotnet run 就能测试和运行。产品页面上提供限时会员价格;如果你正在开发任何向浏览器流式发送 token 的功能,这部分就是你应该在下一次网络不稳定的演示之前完成的工作。
网络不稳定并不是罕见情况。把断点续传当作功能的一部分,而不是收到第一张支持工单后才补上的修复。
如果还需要采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。