Go 实战:用 Redis 实现 LLM 流式输出的断线续传

0 阅读1分钟

背景

最近在做一个 AI 对话服务,后端用 Go 写,LLM 的输出通过 SSE 推给前端。上线之后发现一个很头疼的问题:用户刷新一下页面,或者手机网络切一下,SSE 连接就断了。

后端不知道客户端走了,还在那跑着,token 照烧。用户那边啥也看不到,只能重新发一遍——又烧一次钱。

更坑的是我们做了水平扩展,LLM worker 和 HTTP handler 不在一个实例上。用户重连的时候,负载均衡可能把请求打到另一台机器,那台机器根本不知道之前有流在跑。

JS 生态有现成方案,Go 没有

查了一圈,发现 JS/TS 那边已经有不少方案了:

核心思路都一样:把 chunk 存到 Redis Streams 里,用 Pub/Sub 做信号通知,客户端重连时先 replay 历史再接实时数据。

但这些全是 TypeScript 的,而且和 Vercel AI SDK 深度绑定。Go 生态里找了半天,一个能用的都没有。

自己造了一个轮子

没办法,自己写了个库:streamhub

核心设计

两个 Redis 原语搞定:

  1. Redis StreamsXADD/XREAD)—— 存储每个 chunk,支持 replay
  2. 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 长连接,需要断线续传
  • 生产者和消费者不在同一进程/实例
  • 需要从其他服务远程取消正在运行的生成任务

和现有方案的对比

streamhubvercel/resumable-streamai-resumable-stream
语言GoTypeScriptTypeScript
断线 replay
跨实例 cancel
Generation fencing
单 producer 保证
框架依赖Vercel AI SDKVercel AI SDK

安装

go get github.com/gtoxlili/streamhub@v0.1.0

依赖很少,就一个 rueidis(Redis 客户端)。

总结

这个库目前还在早期阶段,API 可能会调整。如果你也在做 Go + LLM 流式输出的项目,遇到了类似的断线重连问题,可以试试看。

项目地址:github.com/gtoxlili/st…

有问题或者建议欢迎提 Issue。