让流式回答可重连、可重放、可恢复
上一节解决的是“模型回答断了以后,怎样判断是重连还是续写”。这一篇再往前走一步:如果浏览器刷新了、后端进程重启了,或者同一个任务被两个 Worker 同时接管,系统还能不能把这次回答找回来?
很多最初的实现长这样:
浏览器请求
-> 后端调用模型
-> 后端收到一段就写回浏览器这段代码在本地演示很顺,但它把三件不同的事绑在了一起:模型生成、任务状态保存、浏览器连接。浏览器一断,后端可能取消模型请求;后端重启,已经生成的内容可能只剩在内存里;用户再点一次“继续”,又可能启动第二个模型请求。
接下来先创建 Response Job,再让后台 Worker 生成并写事件,最后由 SSE 订阅接口按序号补发事件。示例使用通用的“模型流适配器”,不绑定某一家模型服务商;换成具体 SDK 时,只需要在适配器边界转换事件。
先画出四个角色
先把职责写在纸上,再写代码:
浏览器
| 创建任务 / 订阅事件 / 发送取消
v
API 服务 ---------> Response Job 存储
|
v
后台 Worker
|
v
模型流适配器这里有四个边界:
- API 服务只负责鉴权、创建任务和提供事件订阅,不负责把整段模型生成绑在 HTTP 请求上。
- Response Job是一次回答的业务记录,有稳定的
response_id,不等同于某条浏览器连接。 - Worker负责从模型接收事件、保存事件日志和推进任务状态。
- SSE 订阅只是读取事件日志并推送给浏览器。浏览器断开,订阅结束,但任务可以继续运行。
SSE 是浏览器和你自己的服务之间的传输方式。可以先阅读 MDN 的 Server-sent events 说明 了解 id、event 和 data 的格式。这里的 Last-Event-ID 只属于你的事件流,不代表上游模型支持从某个 token 继续生成。
第 1 步:先把一次回答建模成 Response Job
这一小步要完成什么
先不要接模型。用一个数据结构描述“这次回答现在进行到哪一步”。字段可以从下面这组最小集合开始:
from dataclasses import dataclass
from typing import Literal
JobStatus = Literal[
"queued",
"generating",
"recovering",
"interrupted",
"completed",
"truncated",
"cancelled",
"failed",
]
@dataclass
class ResponseJob:
response_id: str
owner_id: str
tenant_id: str
status: JobStatus = "queued"
current_attempt: int = 0
generation_active: bool = False
last_persisted_seq: int = -1
assembled_text: str = ""
provider_offset: str | None = None
client_ack_seq: int = -1各字段的作用如下:
response_id定位一次回答,不能用浏览器连接 ID 代替。owner_id和tenant_id用于鉴权和租户隔离,恢复接口不能只凭一个可猜的 ID。current_attempt区分第一次生成和后续恢复轮次。generation_active表示是否已经有 Worker 在生成,不能只靠进程内布尔变量。last_persisted_seq表示事件日志已经保存到哪里。provider_offset记录模型适配器读到的上游位置;有些供应商没有可恢复的 offset,可以保持为空。client_ack_seq表示客户端确认处理到哪里,它不等于服务端已保存的位置。
运行观察
先在内存里创建任务:
job = ResponseJob(
response_id="resp-1001",
owner_id="user-42",
tenant_id="acme",
)
print(job.status, job.last_persisted_seq, job.client_ack_seq)你应该看到任务还没有开始生成,两个游标都是 -1。这一步的重点不是把字段写全,而是禁止用一个 is_done 字段包办所有状态。
常见错误
- 把
response_id设计成连续整数,并且不检查用户归属,容易被猜 ID 越权读取。 - 只保存
assembled_text,丢掉事件序号后无法判断重放事件是否重复。 - 把
generation_active放在 Python 全局变量中。多进程或多实例部署时,每个进程看到的值都可能不同。
动手改一改
给任务增加 cancel_requested_at 和 expires_at。想一想:用户点击停止时,为什么先写“请求取消”,而不是立即删除任务?后台 Worker 需要读到这个意图,才能在下一次事件边界安全退出。
第 2 步:为事件日志设计一个最小协议
这一小步要完成什么
Worker 收到模型的一小段输出后,不能只修改 assembled_text。还要写一条有序事件,方便断线后的订阅者从中间补发。
先定义内部事件,不直接暴露某个供应商 SDK 的对象:
from dataclasses import dataclass
from typing import Any
@dataclass
class ResponseEvent:
response_id: str
seq: int
event_type: str
data: dict[str, Any]
attempt: int建议先支持这些 event_type:
text_delta 新增文本片段
status 任务状态变化
tool_pending 等待工具或人工确认
error 可展示的错误码
completed 任务进入终态一条文本事件可以这样表示:
event = ResponseEvent(
response_id="resp-1001",
seq=0,
event_type="text_delta",
data={"text": "VPN 的申请入口在内部门户。"},
attempt=1,
)seq 必须由服务端为同一个 response_id 单调递增。不要把模型返回的 token 下标直接当成 seq:模型流可能没有稳定下标,事件还可能被合并或重试写入。
运行观察
用一个列表模拟可回放日志:
event_log: list[ResponseEvent] = []
def append_event(event: ResponseEvent) -> None:
expected = len(event_log)
if event.seq != expected:
raise ValueError(f"seq 不连续:期望 {expected},收到 {event.seq}")
event_log.append(event)
append_event(event)
print(event_log[0].event_type, event_log[0].data)然后把同一条事件再追加一次。程序应该拒绝它,而不是把文本重复写入。真实数据库中不能依赖 len(event_log),需要使用唯一约束,例如 (response_id, seq),并在冲突时读取已有事件确认内容是否一致。
事件数据不要塞什么
事件日志里只放恢复和展示所需的数据:文本增量、状态、错误码、工具调用 ID 等。不要把 API Key、完整 system prompt、未经脱敏的用户隐私和上游原始响应全部写进去。事件会被重放、复制和长期保留,日志权限必须按租户隔离。
第 3 步:把后台 Worker 和浏览器连接拆开
先实现创建任务
创建接口只做三件事:鉴权、写入 ResponseJob、投递一个后台任务。伪代码可以先写成这样:
def create_response(user_id: str, tenant_id: str, prompt: str) -> ResponseJob:
job = ResponseJob(
response_id=new_id(),
owner_id=user_id,
tenant_id=tenant_id,
status="queued",
)
job_store.insert(job, prompt=prompt)
task_queue.enqueue("generate_response", response_id=job.response_id)
return job这里的 task_queue 可以先用本地线程或一个待处理列表模拟。重点是:创建 HTTP 请求返回后,生成任务仍有自己的生命周期。
再实现 Worker 的第一版
Worker 取到任务后,先把状态从 queued 改成 generating,再逐个读取模型事件:
def generate_response(response_id: str) -> None:
job = job_store.get(response_id)
attempt = job.current_attempt + 1
if not job_store.claim(job, attempt=attempt):
return # 已有别的 Worker 接管,当前 Worker 不执行
for provider_event in model_stream(job_store.get_prompt(response_id)):
event = normalize_provider_event(provider_event, response_id, attempt)
job_store.append_and_apply(event)normalize_provider_event 把供应商事件转换成你的 ResponseEvent。append_and_apply 要同时完成“写事件”和“推进任务摘要”这两件事,后面会讨论事务边界。先不要在这个函数里直接向浏览器写数据。
运行观察
让模拟模型每隔一秒返回一个 text_delta,然后关闭浏览器订阅。Worker 的日志仍应继续增长。再次查询任务时,status 和 last_persisted_seq 应该还在推进。
如果关闭浏览器就导致 Worker 停止,不要先改前端。先检查模型调用是不是写在了 SSE 请求处理函数里;它应该移动到任务队列或独立 Worker 中。
第 4 步:用 after_seq 重放 SSE 事件
先实现“补历史,再等新事件”
浏览器重连时,需要带上自己已经处理到的序号:
GET /api/responses/resp-1001/events?after_seq=37订阅服务的核心顺序是:
def subscribe(response_id: str, after_seq: int):
next_seq = after_seq + 1
while True:
events = event_store.list_after(response_id, next_seq)
for event in events:
yield to_sse(event)
next_seq = event.seq + 1
if job_store.is_terminal(response_id, next_seq - 1):
break
yield ": heartbeat\n\n"
wait_for_new_event(response_id, next_seq)event_store 和 wait_for_new_event 可以用数据库查询加通知机制实现。订阅时先补发历史,再等待新事件;终态也必须作为事件写入日志,不能只存在 ResponseJob.status 里。否则刚好在最后一个文本片段之后断线的客户端,不知道任务已经完成。
SSE 的格式可以很简单:
id: 38
event: text_delta
data: {"text":"请等待审批。"}服务端也可以读取请求头 Last-Event-ID,但建议同时支持显式的 after_seq,因为移动端、非浏览器客户端和重放脚本不一定自动维护这个请求头。
运行观察:实验一,浏览器断开后重连
按下面顺序手动做一次:
- 创建一个回答任务,记下
response_id。 - 订阅 SSE,等收到
seq=0到seq=3。 - 关闭浏览器标签页或主动结束 SSE 连接。
- 等待 Worker 再生成两三条事件。
- 用
after_seq=3重新订阅。 - 检查客户端收到的是
seq=4之后的事件,文本没有重复。
为了观察清楚,可以在客户端维护一个简单的去重集合:
seen: set[tuple[str, int]] = set()
def handle_event(event: ResponseEvent) -> None:
key = (event.response_id, event.seq)
if key in seen:
return
seen.add(key)
print(event.event_type, event.seq, event.data)客户端去重是最后一道保险,不能替代服务端的唯一约束和正确游标。事件重复到达时,服务端和客户端都应该保持幂等。
常见错误
- 重连时从
seq=0开始,然后把整段正文再次追加到页面。 - 只把实时事件通过 Redis Pub/Sub 发给当前连接,错过的事件无法补回。
- 只发送文本事件,不发送
completed或failed终态,客户端会一直等待。 after_seq没有校验任务归属,用户可以读取其他租户的事件。
第 5 步:分清三个游标
一个 seq 不够描述生产状态。至少保留下面三个位置:
provider_offset 模型流适配器从上游读到哪里
persisted_seq 服务端事件日志保存到哪里
client_ack_seq 浏览器确认处理到哪里它们可能暂时不同:
模型已产生第 50 段
-> Worker 正在写入,persisted_seq 还是 49
-> 服务端已写到 50,但浏览器只确认到 47排查时可以这样读:
| 现象 | 更可能卡在哪一层 | 先查什么 |
|---|---|---|
provider_offset 前进,persisted_seq 不动 | Worker 写入或数据库变慢 | 写入耗时、事务错误、连接池 |
persisted_seq 前进,client_ack_seq 不动 | SSE、代理或前端处理阻塞 | 连接状态、代理缓冲、客户端日志 |
三个游标都不动,任务仍是 generating | Worker 卡住或租约过期 | 心跳、租约、进程状态 |
client_ack_seq 大于 persisted_seq | 客户端确认协议有 bug | 拒绝非法 ack,重新从服务端位置补发 |
不要把 provider_offset 当成“模型 token 位置”写死。某些上游只给事件 ID,某些只给 request ID,某些完全不支持恢复;你的字段可以保存适配器能提供的游标,不能凭空制造供应商能力。
第 6 步:用 attempt、lease 和 fencing token 防止旧 Worker 回写
先复现一个很隐蔽的并发问题
想象这个时间线:
旧 Worker A 卡在网络调用中
用户点击“继续”,系统启动 Worker B
Worker B 将 current_attempt 改成 2
Worker A 过了一会儿恢复,继续写入旧内容如果只检查 response_id,A 和 B 都会写同一个任务。结果可能是文本交错、状态倒退,甚至把 B 已经完成的结果覆盖掉。
给每次接管分配 attempt
领取任务时,使用共享存储的条件更新:
def claim(job: ResponseJob, attempt: int, worker_id: str) -> bool:
return job_store.compare_and_set(
response_id=job.response_id,
expected_status={"queued", "recovering", "interrupted"},
updates={
"status": "generating",
"current_attempt": attempt,
"lease_owner": worker_id,
"lease_expires_at": now() + 30,
},
)Worker 每次写事件前都检查自己拿到的 attempt:
def can_write(response_id: str, worker_attempt: int) -> bool:
current = job_store.get_current_attempt(response_id)
return current == worker_attempt这就是最小版 fencing 思路:旧 Worker 即使从网络调用中恢复,也没有资格写入新 attempt 的任务。生产代码中最好把这个检查做成数据库条件写入的一部分,而不是先 get 再单独 update,否则两个操作之间仍可能被其他 Worker 插入。
lease 解决什么问题
attempt 防止旧轮次写入,lease 用来判断当前 Worker 是否还活着。Worker 应该定期续租:
def renew_lease(response_id: str, attempt: int, worker_id: str) -> bool:
return job_store.renew_if_owner(
response_id=response_id,
attempt=attempt,
lease_owner=worker_id,
lease_expires_at=now() + 30,
)租约过期不等于旧 Worker 一定停止。它可能只是网络卡住,所以仍然需要 fencing 条件写入,不能只靠“等 30 秒后大家自觉退出”。
运行观察:实验二,模型流中断后续写
按以下步骤模拟:
- Worker A 生成到
seq=5时主动抛出连接异常。 - 把任务状态改为
interrupted,保留已写入事件。 - 启动 Worker B,把
current_attempt改为 2,并从保存的事实和草稿构造续写请求。 - 让 Worker A 延迟一秒后尝试写入一条旧事件。
- 验证 A 的写入被 attempt 检查拒绝,B 的事件序号继续向前。
可以先用这段伪测试表达期望:
assert job_store.claim(job, attempt=2, worker_id="worker-b")
assert not job_store.append_from_worker(
response_id=job.response_id,
attempt=1,
event=old_event,
)这里的“续写”是新的模型请求,不是假设模型能从 token 位置恢复。续写时要读取原始用户请求、已经确认的工具结果、当前草稿和任务状态;不要把旧 Worker 的半截调用直接当成已完成事实。
第 7 步:明确持久化和事务边界
一次 delta 到底要写什么
收到文本增量时,通常需要同时更新:
1. 事件日志:response_id + seq + text_delta
2. 任务摘要:assembled_text、last_persisted_seq、updated_at
3. Worker 游标:provider_offset如果只更新其中一部分,重启后会出现“事件日志有内容但任务摘要没有”或“摘要说到 seq=50,日志其实只有 49”的不一致。
理想情况下,把事件追加和任务摘要更新放在同一个数据库事务里,并用 (response_id, seq) 唯一约束:
BEGIN;
INSERT INTO response_events(response_id, seq, event_type, payload, attempt)
VALUES (:response_id, :seq, 'text_delta', :payload, :attempt);
UPDATE response_jobs
SET assembled_text = assembled_text || :text,
last_persisted_seq = :seq,
updated_at = CURRENT_TIMESTAMP
WHERE response_id = :response_id
AND current_attempt = :attempt;
COMMIT;字符串追加、JSON 字段和锁策略需要按数据库类型调整。UPDATE 也要检查 current_attempt,让旧 Worker 的事务无法推进新轮次。
Redis Pub/Sub 为什么不够
Redis Pub/Sub 适合把“有新事件”通知给当前在线的订阅者,但消息发出后不会为断线客户端保留可查询的历史。浏览器恰好断开时,Pub/Sub 的那几条消息就没有了。
需要补发时,应使用带历史的存储,例如:
- 数据库事件表:适合按
response_id + seq查询和审计; - Redis Streams:适合按消息 ID 追加、读取和消费组处理;
- Kafka 等日志系统:适合更大规模的事件保留和多消费者场景。
也可以用 Pub/Sub 做低延迟通知,再让订阅服务回数据库或 Stream 读取真实事件。不要把“通知到了”当成“事件已经可回放”。
事件太碎怎么办
每个 token 写一条数据库记录会让写放大、索引和重放成本都上升。可以在 Worker 内把很小的 delta 合并成几十到几百毫秒一条事件,但要保留以下边界:
- 合并不能跨越工具调用、错误或终态;
- 每个事件仍有明确的
seq; - 任务设置事件数量和正文大小上限;
- 进程崩溃前尚未提交的缓冲会丢失,恢复时要从最后一个已提交事件继续。
如果事件摘要和业务表分属不同服务,考虑 transactional outbox:业务事务先写 outbox,再由投递器发布通知。不要在数据库提交前就向浏览器发送“已完成”。
第 8 步:处理 SSE 心跳、代理缓冲和慢客户端
心跳不是为了让模型继续生成
SSE 连接长时间没有文本时,某些代理会把它当成空闲连接。服务端可以发送注释行作为心跳:
: heartbeat 2026-09-11T12:00:00Z客户端不需要展示这条注释,但代理看到字节流后会认为连接仍然活跃。心跳间隔要小于代理的空闲超时,并且模型请求超时、任务总超时和 SSE 连接超时要分别配置。
代理缓冲会让“流式”变成批量返回
如果 Nginx 或其他网关在缓冲响应,后端虽然每 100 毫秒写一段,浏览器可能几秒后才一起收到。以 Nginx 为例,SSE 响应通常需要关注 X-Accel-Buffering: no 和 proxy_read_timeout;具体配置应以你使用的 Nginx 版本和部署方式为准,可参考 Nginx proxy 模块文档。
先在测试环境用 curl -N 观察事件是否按间隔到达:
curl -N "http://localhost:8000/api/responses/resp-1001/events?after_seq=3"如果服务端日志每秒都有事件,curl 却只在最后一次性打印,先查代理缓冲和响应头,不要急着改模型参数。
慢客户端不能拖住 Worker
某个手机网络很慢时,不能让 Worker 停下来等待它消费每一条事件。事件先写入可回放日志,SSE 订阅者按自己的速度读取;超过连接缓冲上限时可以断开慢客户端,让它使用最后一个 seq 重连。生成任务的吞吐不应由最慢的浏览器决定。
第 9 步:把结束原因和状态机写清楚
建议把“运行中”和“为什么结束”分开设计。最小状态机可以画成:
queued -> generating -> completed
\-> interrupted -> recovering -> generating
\-> truncated
\-> cancelled
\-> failed终态事件的 data 中再保存归一化后的结束原因:
completed 正常收到结束信号,结构化结果也完整
truncated_by_limit 达到输出长度限制
content_blocked 上游或业务安全策略阻断
tool_pending 等待工具或人工确认,尚未是最终回答
user_cancelled 用户主动停止,不应自动续写
transport_interrupted 连接或 Worker 中断,可能进入恢复
upstream_failed 上游模型或网络错误不同模型服务商可能使用 stop、length、content_filter 等不同字段。请在模型适配器中归一化,再让业务状态机使用自己的名称。收到 stop 也不代表一定能结束:还要检查 JSON 是否完整、待执行工具是否为空、人工确认是否已经处理。
动手改一改
给状态机增加 awaiting_approval,然后画出“创建工单”的路径:
generating -> awaiting_approval -> generating -> completed
\-> cancelled想一想:审批等待期间应该让 Worker 一直占着 lease 吗?通常不应该。把待审批动作持久化后释放 Worker,批准时再以新的 attempt 接管,资源和恢复边界会更清楚。
第 10 步:做两组生产故障演练
不要只测“网络正常能输出”。至少手动注入以下故障,并记录 response_id、attempt、断点、事件序号和最终状态。
演练一:浏览器断开,任务继续
- 创建任务后立刻订阅 SSE。
- 在收到一半文本时关闭连接。
- 确认 Worker 仍在写事件,任务没有被误标记为
cancelled。 - 过几秒用
after_seq重连。 - 检查历史事件只补发一次,并最终收到
completed。
如果第 3 步任务停了,说明 API 请求和 Worker 仍然耦合;如果第 5 步一直等不到终态,说明终态没有写入事件日志或订阅循环没有检查它。
演练二:后端重启和双 Worker
- 让 Worker A 生成到中间位置后停止进程。
- 任务租约过期,启动 Worker B。
- B 使用新 attempt 继续生成。
- 让 A 的旧进程模拟延迟写入。
- 确认旧写入被 fencing 条件拒绝,事件序号没有倒退。
再增加一个测试:用户连续点击两次“继续”。正确结果应该是只有一个新 attempt 获得 lease,另一个请求返回“已有恢复任务在运行”,而不是创建两次模型请求。
常见误区
- 把 SSE 当成生成任务本身:SSE 只是订阅通道,后台任务必须有独立生命周期。
- 只用 Pub/Sub 不保存事件:实时通知丢失后没有补发依据。
- 所有游标都叫
seq:模型读取、服务端持久化和客户端确认是三个位置,混用后很难定位故障。 - 只靠 lease,不做 fencing:租约过期的旧 Worker 仍可能迟到写入。
- 把浏览器断开等同于用户取消:连接断开可能只是网络问题,取消必须是用户明确意图。
- 收到
finish_reason就标记成功:还要检查工具、JSON、审批和业务终态。 - 把完整正文每个 token 写进数据库:写放大严重,应合并小 delta,同时保留可接受的恢复窗口。
- 把内部日志原样发给浏览器:事件中应使用脱敏后的错误码和状态,不泄露提示词、密钥或其他租户数据。
你可以继续做的练习
- 把内存列表换成数据库事件表,为
(response_id, seq)增加唯一约束,并模拟重复插入。 - 为
client_ack_seq增加接口:客户端只能确认不大于last_persisted_seq的事件,越界时返回错误。 - 把文本事件和工具事件混在同一条日志中,验证重放时不会把半截工具参数当作文本展示。
- 给 Worker 增加总耗时、最大恢复次数和最大事件数限制,分别模拟超限后的终态。
- 用
curl -N对比直连后端和经过 Nginx 的响应,确认心跳、事件 ID 和缓冲配置的差异。
完成这些练习后,你应该能解释一件事:浏览器是否在线,只决定它能不能实时看到事件;一次回答是否能恢复,取决于任务、事件日志、并发接管和权限边界是否被持久化。