深入解析「APP 退出后重新进入,输出还能自动继续」背后的技术原理 —— 以豆包为例
你一定是经历过这个场景才开始好奇的:
这看起来像魔法,实际上是精心设计的「服务端缓冲 + 客户端续传」机制。
要理解断线续传,必须先理解正常流式是怎么跑的。
SSE 是 HTTP 长连接协议:客户端发一个 GET/POST 请求,服务端持续推送 text/event-stream 格式的数据,直到结束或断开。
// 客户端发起 POST 请求
POST /v1/chat/completions
Content-Type: application/json
Accept: text/event-stream
{ "stream": true, "messages": [...] }
// 服务端持续推送 SSE 事件(逐 token)
data: {"id":"req_abc", "delta":{"content":"春"}}
data: {"id":"req_abc", "delta":{"content":"眠"}}
data: {"id":"req_abc", "delta":{"content":"不"}}
data: {"id":"req_abc", "delta":{"content":"觉"}}
data: {"id":"req_abc", "delta":{"content":"晓"}}
data: [DONE]
一次 SSE 连接对应 一个 LLM 推理会话。服务端在内存中保存了:
这个会话状态只在连接存活期间有效。连接一断,服务端通常会立即清理。
把「退出再进继续输出」拆成三个子问题:
APP 退出时 TCP 连接断开,但服务端的 LLM 推理进程必须继续跑,不能因为客户端走了就停。
APP 退出前已经收到并显示了部分内容(比如 "春眠不觉"),重新打开时必须把这些内容原样恢复。
APP 重新打开后,发起一个新请求,服务端识别出「这是之前那个会话的续传」,把断开期间生成的新内容一次性补上,然后继续流式推送。
这是业界最主流的实现路径,豆包、ChatGPT、Kimi 本质上都是这个方案。
LLM 推理引擎和消息推送解耦 —— 推理不断,消息堆积在 Redis,客户端随时来取
下面按时间线完整走一遍这个流程:
用户发起提问时,服务端不仅要启动 LLM 推理,还要创建一个持久化会话:
// 服务端处理用户提问
func HandleChat(userId, conversationId, messages) {
// 1. 生成唯一的会话 ID
sessionId := UUID()
// 2. 在 Redis 中创建会话状态
redis.HSet("session:"+sessionId, {
"status": "generating", // 状态:生成中
"conversation_id": conversationId, // 关联的对话 ID
"user_id": userId, // 用户 ID
"messages": json(messages), // 原始请求 messages
"created_at": now(),
"total_tokens": 0,
"last_seq": 0, // 最后一条消息序号
})
// 3. 创建一个 Redis List 用于堆积生成的 token
// key: session:{id}:tokens
// 4. 异步启动 LLM 推理(不阻塞 HTTP 响应)
go startLLMInference(sessionId, messages)
// 5. 立即返回 sessionId 给客户端
return { "session_id": sessionId }
}
LLM 推理在独立 goroutine / 进程中进行,每生成一个 token 就写入 Redis:
func startLLMInference(sessionId, messages) {
stream := llm.ChatCompletionStream(messages)
seq := 0
for chunk := range stream {
seq++
// 每个 token 封装成带序号的消息
tokenMsg := json({
"seq": seq, // 消息序号,用于断点续传定位
"content": chunk.Delta.Content, // token 文本内容
"ts": now(), // 时间戳
})
// 写入 Redis List — 尾部追加
redis.RPush("session:"+sessionId+":tokens", tokenMsg)
// 更新最后序号
redis.HSet("session:"+sessionId, "last_seq", seq)
// 发布到 Redis Pub/Sub,通知推送层有新 token
redis.Publish("channel:session:"+sessionId, tokenMsg)
}
// 推理完成,标记会话状态
redis.HSet("session:"+sessionId, "status", "completed")
redis.Publish("channel:session:"+sessionId, "__DONE__")
}
客户端正常在线时,通过 SSE / WebSocket 订阅 Redis Pub/Sub,实时收到每个 token:
// 推送层 —— 订阅 Redis 频道,转发给客户端
func streamTokens(sessionId) {
// 建立 SSE 连接,设置 Content-Type: text/event-stream
pubsub := redis.Subscribe("channel:session:"+sessionId)
for msg := range pubsub.Channel() {
if msg.Payload == "__DONE__" {
writeSSE("data: [DONE]\n\n")
break
}
writeSSE("data: " + msg.Payload + "\n\n")
flush()
}
}
这是回答你问题的核心部分。当 APP 退出再进来,发生了两件事:
客户端断开 SSE 连接 → 推送层感知到连接断开 → 但不做任何事。
因为 LLM 推理进程 完全独立,它不知道也不需要知道客户端是否在线。它只是继续往 Redis 队列里写 token。
这是整个设计最巧妙的地方:生产者和消费者完全解耦。
APP 重新打开后,发现自己所在的对话中有一个 session_id 且状态为 "generating"(说明服务端还在推理),于是发起续传:
// 客户端重新进入对话页面
// 1. 先查对话列表,发现当前对话有未完成的会话
// 会话状态: "generating",已生成到 seq=42
// 2. 客户端本地已显示了 seq=0~15 的内容(退出前收到的)
// 从 UI 中取出 lastDisplayedSeq = 15
// 3. 发起续传请求
POST /v1/chat/resume
{
"session_id": "abc-123",
"last_seq": 15 // 告诉服务端:我收到了前 15 条
}
// 服务端处理续传请求
func ResumeSession(sessionId, lastSeq) {
// 1. 查 Redis 确认会话存在且状态为 "generating"
session := redis.HGetAll("session:" + sessionId)
if session.Status == "" {
return 404 "会话不存在或已过期"
}
// 2. 补发 lastSeq 之后的所有历史 token(一次性)
history := redis.LRange(
"session:"+sessionId+":tokens",
lastSeq, // 起始位置 = 客户端最后收到的序号
-1, // 到末尾
)
// 3. 先把缺少的历史内容一次性发给客户端
for _, tokenJson := range history {
writeSSE("data: " + tokenJson + "\n\n")
}
// 4. 然后继续实时订阅,推送后续新 token
pubsub := redis.Subscribe("channel:session:" + sessionId)
for msg := range pubsub.Channel() {
if msg.Payload == "__DONE__" { break }
writeSSE("data: " + msg.Payload + "\n\n")
}
}
断线重连后,不只是拿到 token 就行 —— 客户端还要把 UI 恢复成「正在输出中」的状态。
GET /conversations/:id/messages 返回所有消息(包括不完整的 AI 回复)。status: "generating" 标记,客户端看到这个就知道「这条还没输出完」。session_id 还在、状态为 generating,自动调 resume 接口,开始补推。// 客户端 APP 重新进入对话的伪代码
async function enterConversation(conversationId) {
// 1. 拉取消息列表(从服务端/本地缓存)
const messages = await api.getMessages(conversationId)
// 2. 渲染 UI
renderMessages(messages)
// 3. 找到最后一条未完成的 AI 回复
const lastMsg = messages[messages.length - 1]
if (lastMsg.role === 'assistant' && lastMsg.status === 'generating') {
// 4. 显示「正在生成」的光标动画
showTypingIndicator()
// 5. 发起续传(last_seq 是这条消息已收到的 token 数)
const stream = await api.resumeStream(lastMsg.session_id, lastMsg.last_seq)
// 6. 逐 token 追加到 UI
for await (const chunk of stream) {
lastMsg.content += chunk.content
lastMsg.last_seq = chunk.seq
updateUI() // 增量渲染,打字机效果
}
// 7. 完成后去掉光标动画
hideTypingIndicator()
}
}
这个方案不是免费的,有一些必须要做的取舍:
| 决策点 | Option A | Option B | 推荐 |
|---|---|---|---|
| 消息存储 | Redis 纯内存,性能好但有内存上限 | 消息队列 (Kafka/Pulsar),支持持久化和回溯 | 小规模用 Redis,大规模用 Kafka |
| 会话过期 | 推理完成后立即删除,节省资源 | 保留 N 分钟(如 30min),给续传窗口 | 保留 30 分钟,过期后提示「已过期」 |
| 推送方式 | WebSocket 全双工,实时性好 | HTTP 长轮询,兼容性好,防火墙友好 | 移动端优先长轮询,桌面端用 WS |
| 推理取消 | 客户端断开就取消推理,省 GPU | 客户端断开推理继续,可续传 | 要看产品定位:豆包选 B,ChatGPT 大部分场景选 A |
豆包的「退出再进继续输出」并不是什么魔法,而是把
「SSE 长连接」替换成了「Redis 消息队列 + 序号偏移续传」
让 LLM 推理进程和客户端连接完全解耦。
推理不管你在线不在线,只管往 Redis 里写 token。
客户端上线后拿着上次的序号来取,从断点继续。
这就是生产者-消费者模型在大模型流式输出场景下的经典应用。