背景
最近在做一个 AI 对话服务,后端用 Go 写,LLM 的输出通过 SSE 推给前端。上线之后发现一个很头疼的问题:用户刷新一下页面,或者手机网络切一下,SSE 连接就断了。
后端不知道客户端走了,还在那跑着,token 照烧。用户那边啥也看不到,只能重新发一遍——又烧一次钱。
更坑的是我们做了水平扩展,LLM worker 和 HTTP handler 不在一个实例上。用户重连的时候,负载均衡可能把请求打到另一台机器,那台机器根本不知道之前有流在跑。
JS 生态有现成方案,Go 没有
查了一圈,发现 JS/TS 那边已经有不少方案了:
- Vercel 出了 resumable-stream
- 社区有 ai-resumable-stream
- Ably 甚至做成了商业产品
核心思路都一样:把 chunk 存到 Redis Streams 里,用 Pub/Sub 做信号通知,客户端重连时先 replay 历史再接实时数据。
但这些全是 TypeScript 的,而且和 Vercel AI SDK 深度绑定。Go 生态里找了半天,一个能用的都没有。
自己造了一个轮子
没办法,自己写了个库:streamhub
核心设计
两个 Redis 原语搞定:
- Redis Streams(
XADD/XREAD)—— 存储每个 chunk,支持 replay - Redis Pub/Sub —— 传递 cancel 信号,跨实例通知
另外加了两个机制:
- Generation ID:类似 fencing token,防止旧的 producer 往新的流里写脏数据
- 单 Producer 注册:同一个 session 只允许一个 producer,避免重复调 LLM
代码长什么样
生产端:
stream, created, err := hub.Register("chat:123", func() {
// 收到 cancel 信号时的回调
llmCancel()
})
if !created {
return // 别的实例已经在跑了,不要重复生产
}
defer stream.Close()
for token := range llmOutput {
stream.Publish(token)
}
消费端(可以在任意实例上):
stream := hub.Get("chat:123")
chunks, unsub := stream.Subscribe(128)
defer unsub()
for chunk := range chunks {
// 自动先 replay 历史,再转 live
fmt.Fprintf(w, "data: %s\n\n", chunk)
w.(http.Flusher).Flush()
}
取消(从任意实例):
hub.Get("chat:123").Cancel()
Cancel 通过 Redis Pub/Sub 广播,producer 所在实例会收到回调。
适用场景
- LLM/AI Agent 的流式输出
- SSE/WebSocket 长连接,需要断线续传
- 生产者和消费者不在同一进程/实例
- 需要从其他服务远程取消正在运行的生成任务
和现有方案的对比
| streamhub | vercel/resumable-stream | ai-resumable-stream | |
|---|---|---|---|
| 语言 | Go | TypeScript | TypeScript |
| 断线 replay | ✅ | ✅ | ✅ |
| 跨实例 cancel | ✅ | ❌ | ✅ |
| Generation fencing | ✅ | ✅ | ❌ |
| 单 producer 保证 | ✅ | ❌ | ❌ |
| 框架依赖 | 无 | Vercel AI SDK | Vercel AI SDK |
安装
go get github.com/gtoxlili/streamhub@v0.1.0
依赖很少,就一个 rueidis(Redis 客户端)。
总结
这个库目前还在早期阶段,API 可能会调整。如果你也在做 Go + LLM 流式输出的项目,遇到了类似的断线重连问题,可以试试看。
有问题或者建议欢迎提 Issue。