Skip to content

让流式回答可重连、可重放、可恢复 ​

上一节解决的是“模型回答断了以后,怎样判断是重连还是续写”。这一篇再往前走一步:如果浏览器刷新了、后端进程重启了,或者同一个任务被两个 Worker 同时接管,系统还能不能把这次回答找回来?

很多最初的实现长这样:

text
浏览器请求
  -> 后端调用模型
  -> 后端收到一段就写回浏览器

这段代码在本地演示很顺,但它把三件不同的事绑在了一起:模型生成、任务状态保存、浏览器连接。浏览器一断,后端可能取消模型请求;后端重启,已经生成的内容可能只剩在内存里;用户再点一次“继续”,又可能启动第二个模型请求。

接下来先创建 Response Job,再让后台 Worker 生成并写事件,最后由 SSE 订阅接口按序号补发事件。示例使用通用的“模型流适配器”,不绑定某一家模型服务商;换成具体 SDK 时,只需要在适配器边界转换事件。

先画出四个角色 ​

先把职责写在纸上,再写代码:

text
浏览器
  |  创建任务 / 订阅事件 / 发送取消
  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 ​

这一小步要完成什么 ​

先不要接模型。用一个数据结构描述“这次回答现在进行到哪一步”。字段可以从下面这组最小集合开始:

python
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 表示客户端确认处理到哪里,它不等于服务端已保存的位置。

运行观察 ​

先在内存里创建任务:

python
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 的对象:

python
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
text_delta       新增文本片段
status           任务状态变化
tool_pending     等待工具或人工确认
error            可展示的错误码
completed        任务进入终态

一条文本事件可以这样表示:

python
event = ResponseEvent(
    response_id="resp-1001",
    seq=0,
    event_type="text_delta",
    data={"text": "VPN 的申请入口在内部门户。"},
    attempt=1,
)

seq 必须由服务端为同一个 response_id 单调递增。不要把模型返回的 token 下标直接当成 seq:模型流可能没有稳定下标,事件还可能被合并或重试写入。

运行观察 ​

用一个列表模拟可回放日志:

python
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、投递一个后台任务。伪代码可以先写成这样:

python
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,再逐个读取模型事件:

python
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 事件 ​

先实现“补历史,再等新事件” ​

浏览器重连时,需要带上自己已经处理到的序号:

text
GET /api/responses/resp-1001/events?after_seq=37

订阅服务的核心顺序是:

python
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 的格式可以很简单:

text
id: 38
event: text_delta
data: {"text":"请等待审批。"}

服务端也可以读取请求头 Last-Event-ID,但建议同时支持显式的 after_seq,因为移动端、非浏览器客户端和重放脚本不一定自动维护这个请求头。

运行观察:实验一,浏览器断开后重连 ​

按下面顺序手动做一次:

  1. 创建一个回答任务,记下 response_id。
  2. 订阅 SSE,等收到 seq=0 到 seq=3。
  3. 关闭浏览器标签页或主动结束 SSE 连接。
  4. 等待 Worker 再生成两三条事件。
  5. 用 after_seq=3 重新订阅。
  6. 检查客户端收到的是 seq=4 之后的事件,文本没有重复。

为了观察清楚,可以在客户端维护一个简单的去重集合:

python
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 不够描述生产状态。至少保留下面三个位置:

text
provider_offset   模型流适配器从上游读到哪里
persisted_seq     服务端事件日志保存到哪里
client_ack_seq    浏览器确认处理到哪里

它们可能暂时不同:

text
模型已产生第 50 段
  -> Worker 正在写入,persisted_seq 还是 49
  -> 服务端已写到 50,但浏览器只确认到 47

排查时可以这样读:

现象更可能卡在哪一层先查什么
provider_offset 前进,persisted_seq 不动Worker 写入或数据库变慢写入耗时、事务错误、连接池
persisted_seq 前进,client_ack_seq 不动SSE、代理或前端处理阻塞连接状态、代理缓冲、客户端日志
三个游标都不动,任务仍是 generatingWorker 卡住或租约过期心跳、租约、进程状态
client_ack_seq 大于 persisted_seq客户端确认协议有 bug拒绝非法 ack,重新从服务端位置补发

不要把 provider_offset 当成“模型 token 位置”写死。某些上游只给事件 ID,某些只给 request ID,某些完全不支持恢复;你的字段可以保存适配器能提供的游标,不能凭空制造供应商能力。

第 6 步:用 attempt、lease 和 fencing token 防止旧 Worker 回写 ​

先复现一个很隐蔽的并发问题 ​

想象这个时间线:

text
旧 Worker A 卡在网络调用中
用户点击“继续”,系统启动 Worker B
Worker B 将 current_attempt 改成 2
Worker A 过了一会儿恢复,继续写入旧内容

如果只检查 response_id,A 和 B 都会写同一个任务。结果可能是文本交错、状态倒退,甚至把 B 已经完成的结果覆盖掉。

给每次接管分配 attempt ​

领取任务时,使用共享存储的条件更新:

python
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:

python
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 应该定期续租:

python
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 秒后大家自觉退出”。

运行观察:实验二,模型流中断后续写 ​

按以下步骤模拟:

  1. Worker A 生成到 seq=5 时主动抛出连接异常。
  2. 把任务状态改为 interrupted,保留已写入事件。
  3. 启动 Worker B,把 current_attempt 改为 2,并从保存的事实和草稿构造续写请求。
  4. 让 Worker A 延迟一秒后尝试写入一条旧事件。
  5. 验证 A 的写入被 attempt 检查拒绝,B 的事件序号继续向前。

可以先用这段伪测试表达期望:

python
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 到底要写什么 ​

收到文本增量时,通常需要同时更新:

text
1. 事件日志:response_id + seq + text_delta
2. 任务摘要:assembled_text、last_persisted_seq、updated_at
3. Worker 游标:provider_offset

如果只更新其中一部分,重启后会出现“事件日志有内容但任务摘要没有”或“摘要说到 seq=50,日志其实只有 49”的不一致。

理想情况下,把事件追加和任务摘要更新放在同一个数据库事务里,并用 (response_id, seq) 唯一约束:

sql
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 连接长时间没有文本时,某些代理会把它当成空闲连接。服务端可以发送注释行作为心跳:

text
: heartbeat 2026-09-11T12:00:00Z

客户端不需要展示这条注释,但代理看到字节流后会认为连接仍然活跃。心跳间隔要小于代理的空闲超时,并且模型请求超时、任务总超时和 SSE 连接超时要分别配置。

代理缓冲会让“流式”变成批量返回 ​

如果 Nginx 或其他网关在缓冲响应,后端虽然每 100 毫秒写一段,浏览器可能几秒后才一起收到。以 Nginx 为例,SSE 响应通常需要关注 X-Accel-Buffering: no 和 proxy_read_timeout;具体配置应以你使用的 Nginx 版本和部署方式为准,可参考 Nginx proxy 模块文档。

先在测试环境用 curl -N 观察事件是否按间隔到达:

bash
curl -N "http://localhost:8000/api/responses/resp-1001/events?after_seq=3"

如果服务端日志每秒都有事件,curl 却只在最后一次性打印,先查代理缓冲和响应头,不要急着改模型参数。

慢客户端不能拖住 Worker ​

某个手机网络很慢时,不能让 Worker 停下来等待它消费每一条事件。事件先写入可回放日志,SSE 订阅者按自己的速度读取;超过连接缓冲上限时可以断开慢客户端,让它使用最后一个 seq 重连。生成任务的吞吐不应由最慢的浏览器决定。

第 9 步:把结束原因和状态机写清楚 ​

建议把“运行中”和“为什么结束”分开设计。最小状态机可以画成:

text
queued -> generating -> completed
                  \-> interrupted -> recovering -> generating
                  \-> truncated
                  \-> cancelled
                  \-> failed

终态事件的 data 中再保存归一化后的结束原因:

text
completed             正常收到结束信号,结构化结果也完整
truncated_by_limit    达到输出长度限制
content_blocked       上游或业务安全策略阻断
tool_pending           等待工具或人工确认,尚未是最终回答
user_cancelled        用户主动停止,不应自动续写
transport_interrupted 连接或 Worker 中断,可能进入恢复
upstream_failed       上游模型或网络错误

不同模型服务商可能使用 stop、length、content_filter 等不同字段。请在模型适配器中归一化,再让业务状态机使用自己的名称。收到 stop 也不代表一定能结束:还要检查 JSON 是否完整、待执行工具是否为空、人工确认是否已经处理。

动手改一改 ​

给状态机增加 awaiting_approval,然后画出“创建工单”的路径:

text
generating -> awaiting_approval -> generating -> completed
                         \-> cancelled

想一想:审批等待期间应该让 Worker 一直占着 lease 吗?通常不应该。把待审批动作持久化后释放 Worker,批准时再以新的 attempt 接管,资源和恢复边界会更清楚。

第 10 步:做两组生产故障演练 ​

不要只测“网络正常能输出”。至少手动注入以下故障,并记录 response_id、attempt、断点、事件序号和最终状态。

演练一:浏览器断开,任务继续 ​

  1. 创建任务后立刻订阅 SSE。
  2. 在收到一半文本时关闭连接。
  3. 确认 Worker 仍在写事件,任务没有被误标记为 cancelled。
  4. 过几秒用 after_seq 重连。
  5. 检查历史事件只补发一次,并最终收到 completed。

如果第 3 步任务停了,说明 API 请求和 Worker 仍然耦合;如果第 5 步一直等不到终态,说明终态没有写入事件日志或订阅循环没有检查它。

演练二:后端重启和双 Worker ​

  1. 让 Worker A 生成到中间位置后停止进程。
  2. 任务租约过期,启动 Worker B。
  3. B 使用新 attempt 继续生成。
  4. 让 A 的旧进程模拟延迟写入。
  5. 确认旧写入被 fencing 条件拒绝,事件序号没有倒退。

再增加一个测试:用户连续点击两次“继续”。正确结果应该是只有一个新 attempt 获得 lease,另一个请求返回“已有恢复任务在运行”,而不是创建两次模型请求。

常见误区 ​

  • 把 SSE 当成生成任务本身:SSE 只是订阅通道,后台任务必须有独立生命周期。
  • 只用 Pub/Sub 不保存事件:实时通知丢失后没有补发依据。
  • 所有游标都叫 seq:模型读取、服务端持久化和客户端确认是三个位置,混用后很难定位故障。
  • 只靠 lease,不做 fencing:租约过期的旧 Worker 仍可能迟到写入。
  • 把浏览器断开等同于用户取消:连接断开可能只是网络问题,取消必须是用户明确意图。
  • 收到 finish_reason 就标记成功:还要检查工具、JSON、审批和业务终态。
  • 把完整正文每个 token 写进数据库:写放大严重,应合并小 delta,同时保留可接受的恢复窗口。
  • 把内部日志原样发给浏览器:事件中应使用脱敏后的错误码和状态,不泄露提示词、密钥或其他租户数据。

你可以继续做的练习 ​

  1. 把内存列表换成数据库事件表,为 (response_id, seq) 增加唯一约束,并模拟重复插入。
  2. 为 client_ack_seq 增加接口:客户端只能确认不大于 last_persisted_seq 的事件,越界时返回错误。
  3. 把文本事件和工具事件混在同一条日志中,验证重放时不会把半截工具参数当作文本展示。
  4. 给 Worker 增加总耗时、最大恢复次数和最大事件数限制,分别模拟超限后的终态。
  5. 用 curl -N 对比直连后端和经过 Nginx 的响应,确认心跳、事件 ID 和缓冲配置的差异。

完成这些练习后,你应该能解释一件事:浏览器是否在线,只决定它能不能实时看到事件;一次回答是否能恢复,取决于任务、事件日志、并发接管和权限边界是否被持久化。

持续学习,持续实践。