本文基于
AI Mind项目的真实实现整理。GitHub:github.com/HWYD/ai-min...
对应代码版本:
v0.4.10线上体验:ai.hwyblog.cloud/instant-min...AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。
如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。
流式 AI 应用里,网络断开看起来像一个前端问题:读流失败,再连一次就好。
但一旦后端已经启动模型、执行 Tool(模型触发的外部工具调用),甚至进入长时间运行的 Agent 任务流程,重新发一次请求就不再安全。客户端不知道服务端有没有收到第一次 POST,也不知道执行是否仍在继续;如果盲目重试,很可能得到两次模型调用、两次 Agent 任务流程,或者一段无法判断是否完整的回答。
v0.4.10 处理的不是"给聊天加一个重连按钮",而是把一次用户请求变成一个可识别、可观察、可恢复的执行单元。
这一版保留现有的 fetch POST + NDJSON 入口(服务端持续按行返回 JSON 事件),但把输出升级为带稳定身份和顺序的事件流:请求使用幂等键避免重复开工,运行过程写入可重放的 public event(可安全交付给用户的事件);客户端只在事件真正显示到 UI 后推进 cursor(记录"已经确认显示到第几条事件"的位置)。断线时,它会补齐缺失事件,再回到同一个 run(同一次执行)的实时输出。
本文的核心判断是:可恢复流的关键不是自动重连,而是先让一次执行拥有稳定身份、事件日志和终态语义。

1. 真正的问题不是断线,而是重复执行
在一次性的 POST + NDJSON 模式里,启动执行和交付输出共用同一条 HTTP 连接:前端发起 POST,后端开始运行,同时不断把 chunk(增量输出)写入响应。
这在网络正常时很直接,但连接中断后会产生一个无法从客户端单独回答的问题:这次执行到底有没有开始?
如果服务端尚未收到请求,重发 POST 是合理的;如果服务端已经开始模型调用或 Agent workflow,重发 POST 就会制造第二次执行。更麻烦的是,客户端即使知道执行仍在继续,也没有一个稳定的位置去补齐断线期间漏掉的输出。
所以我没有把"重试"直接绑在原始 POST 上,而是先拆开两件事:
- HTTP 连接是否还活着;
- 这一次用户发起的聊天或 Agent 任务是否仍在执行。
前者属于传输问题,后者属于运行时问题。只有两者分开,断线后才可能恢复同一次执行,而不是重新开始一次看似相同的工作。
2. 不迁移到 EventSource,也能借用 SSE 的恢复语义
SSE(服务器向浏览器持续推送事件的协议)已经提供了 Last-Event-ID、自动重连和事件重放的思路,但浏览器原生 EventSource 只有 GET 入口,不适合当前 AI Mind 需要提交复杂 POST body(请求内容)的聊天、模型选择和 Agent 请求。
因此 v0.4.10 没有把现有协议整体替换成 SSE,而是保留两段式链路:
text
POST /api/chat
-> 创建或复用一次 StreamRun(一次可恢复的流式执行),并返回初始 NDJSON 流
GET /api/chat/runs/{runId}/stream
-> 按 cursor 重放缺失事件,并继续订阅新事件
这不是在 HTTP 层"模拟 SSE",而是借用其中最有价值的语义:事件要有编号,客户端要能声明自己已消费到哪里,服务端要能从这个位置继续交付。
为了让普通聊天和不同类型的 Agent 任务走向同一个恢复边界,@ai-mind/stream-core 新增了 StreamEventEnvelope(流事件信封)。业务 chunk(一次增量输出)仍然保留原有含义,恢复所需的信息被固定放在 envelope(给每条输出加上的统一外层信息)中。下面是协议的关键字段;已经结束的事件会额外带上最终状态,未结束的事件则带当前运行状态:
ts
type StreamEventEnvelope = {
eventId: string
eventKind: 'chunk' | 'lifecycle' | 'terminal'
payload: ChatStreamChunk | StreamLifecyclePayload
protocolVersion: 1
runId: string
sequence: number
} & (
| { terminal: true; terminalState: StreamTerminalState }
| { terminal?: false; runStatus?: StreamRunStatus }
)
runId 表示一次完整执行,sequence 表示该 run 内的事件顺序,eventId 标识某一条具体事件。终态和生命周期信息也随 envelope 交付,因此前端不再只能依靠"流关闭了"来猜测执行是否已经结束。protocolVersion 则让未来的协议演进可以被明确拒绝,而不是把不同格式的数据悄悄混在一起消费。
3. 先定义同一次执行:幂等请求与 StreamRun
可恢复流的第一层基础不是事件日志,而是请求幂等。
AI Mind 为初始 POST /api/chat 要求 Idempotency-Key(客户端为一次提交生成、重试时始终复用的固定编号)。服务端把它和浏览器 session 派生的 owner、由规范化请求计算出的 request fingerprint(请求内容摘要:用于判断两次请求是否真的是同一份内容)绑定到 StreamRequest(请求幂等记录);真正的执行实体则是 StreamRun(一次流式执行)。
text
owner session(浏览器会话归属) + Idempotency-Key + request fingerprint
-> StreamRequest
-> StreamRun
这里的重点不是"相同 key 就直接忽略",而是要区分两种情况:
- key 和请求 fingerprint 都一致:这是同一次请求的重试,返回既有 run 的 replay descriptor(告诉客户端"已有执行应从哪里继续"的 JSON 说明);
- key 一致、请求内容不同:这是错误复用,明确返回
IDEMPOTENCY_CONFLICT。
replay descriptor 会携带既有 runId、运行状态、最后 sequence 和恢复地址。客户端收到它后立即切换到 recovery GET(恢复用的 GET 请求),而不是重新启动模型、Tool 或 Agent 任务。
并发也在这个边界内处理。即使两个相同请求同时通过了前置查询,数据库在 (ownerSessionHash, idempotencyKey) 上的唯一约束仍会阻止创建两条 StreamRequest;遇到唯一键冲突后,服务端会回读已有记录并返回 replay descriptor,而不是把并发竞争暴露成偶发 500。把 owner 放进唯一维度也意味着:同一幂等键不会跨浏览器 session 串用。
这保证的是请求创建和 run 启动层面的幂等。对于外部 Tool 或 MCP 的副作用,这一版没有宣称 exactly-once(外部动作只发生一次);那需要稳定的调用身份和持久化副作用账本,不能被一个 HTTP 幂等键替代。
4. 事件先落库,再写入当前连接
只有请求不重复还不够。断线之后想补齐输出,服务端还必须知道自己已经交付过哪些事件。
为此,v0.4.10 引入了三类持久化记录:
StreamRequest:保存幂等请求和 fingerprint;StreamRun:保存执行类型、归属、状态、最后 sequence、终态和保留边界;StreamEvent:保存可公开重放的事件信封。
StreamEventStore(流事件存储)追加事件时,会先锁定当前 run(当前这一次执行),再从 lastSequence + 1 生成下一个序号。下面是省略 payload 大小、归属和错误分支后的真实主干:
ts
await lockStreamRunForAppend(transaction, input.runId)
const run = await this.getOwnedRun(transaction, input.runId, input.ownerSessionHash)
const sequence = run.lastSequence + 1
const terminal = Boolean(input.terminalState)
const envelope: StreamEventEnvelopeDto = {
eventId: this.createEventId(),
eventKind: terminal ? 'terminal' : input.eventKind,
payload: input.payload,
protocolVersion: streamProtocolVersion,
runId: run.id,
sequence,
...(terminal ? { terminal: true, terminalState: input.terminalState } : {}),
}
const parsedEnvelope = streamEventEnvelopeSchema.safeParse(envelope)
if (!parsedEnvelope.success) throw new StreamEventStoreError('STREAM_EVENT_INVALID', '...')
const persistedEvent = await transaction.streamEvent.create({
data: {
id: parsedEnvelope.data.eventId,
runId: run.id,
sequence,
payload: parsedEnvelope.data.payload,
},
})
await transaction.streamRun.update({
data: { lastSequence: sequence },
where: { id: run.id },
})
return toEnvelope(persistedEvent)
完整实现还会在同一事务中推进运行状态、终态序号和滚动保留边界;数据库的 (runId, sequence) 唯一约束也保证同一个 run 内不会有重复序号。
更重要的是,写入顺序是:先完成 public event(对外事件)的投影和持久化,再写给当前 NDJSON 连接。
当前连接可以断,但已经投影成功的事件仍在事件日志里。恢复时,服务端不需要重新推理,也不需要重复运行 Tool,只需要从正确的 sequence 继续读取。
StreamEventProjector(公共事件投影器)还负责把业务 chunk 限制在严格 public DTO(只允许对外传输的字段集合)内。raw GraphState(Agent 的内部完整状态)、checkpoint(可用于续跑的状态快照)、内部 prompt、provider 原始异常、密钥和 cookie 都不会进入可恢复事件。这让恢复机制只保存交付给用户的事实,而不把运行时内部状态扩散到协议边界。
5. cursor 恢复的目标是补齐,不是从头重播
客户端每成功把一个 envelope(带统一外层信息的事件)应用到 UI,就保存一个 cursor(已确认显示进度)。它包含当前 runId、最后确认的 sequence、对应 eventId 和协议版本。
恢复请求通过 Last-Event-ID 或 after 传递最后确认的 sequence。这里沿用了 SSE 的 header 名,但值是数值序列号,并不是 envelope 的 UUID eventId。服务端只重放 sequence > after 的事件;补齐后,如果 run 仍未结束,就继续轮询新事件并发送 heartbeat(告诉客户端连接仍有效的心跳),直到拿到 terminal event(最终事件)。
客户端同样需要防御错误重放。它不会仅凭"收到了一条 JSON"就更新 UI,而是校验 run、协议版本、顺序和游标处的事件身份:
ts
if (expectedRunId && envelope.runId !== expectedRunId) {
throw new Error('恢复流事件属于其他 run')
}
if (envelope.protocolVersion !== expectedProtocolVersion) {
throw new Error('恢复流协议版本不匹配')
}
if (envelope.sequence <= lastSequence) {
if (envelope.sequence === lastSequence && cursor?.eventId && envelope.eventId !== cursor.eventId) {
throw new Error('恢复流事件标识与已确认游标不一致')
}
return false
}
if (envelope.sequence > lastSequence + 1) {
throw new Error('恢复流事件出现缺口')
}
这让三种标识各自承担明确职责:
runId保证事件属于当前执行;sequence保证事件按序补齐、重复不重复应用;eventId用于确认同一 sequence 位置的事件身份没有发生漂移。
cursor 不是一张谁拿到都能使用的门票。恢复 GET、取消和终态查询都会校验 owner session(浏览器会话归属);跨 session 请求不会得到事件内容。
如果 cursor 已经过期,或者客户端给出的 sequence 领先于服务端进度,服务端不会静默跳过事件继续播放,而是返回 CURSOR_EXPIRED 或 CURSOR_AHEAD。客户端据此进入"无法完整恢复"的明确状态,并得到终态查询或重新发起请求的指引。

6. 最危险的窗口,是还没拿到 runId 的那一次 POST
已经获得 runId 后,恢复路径非常清楚:直接按 cursor 走恢复用的 GET。
真正棘手的是初始 POST 的响应丢失。服务端可能已经创建了 StreamRun 并启动执行,但客户端因为网络异常、空响应 body 或网关超时,尚未拿到 X-Run-Id。此时客户端无法直接请求 recovery GET,也不能武断地认定"这次提交失败了"。
这一版在同一页面生命周期内保留原始 payload(请求内容)和 Idempotency-Key,只对传输失败、缺少响应 body(响应体),以及 408、502、503、504 做有限的初始 POST 重试。
重试采用指数退避加随机抖动:从 500ms 起步,按 2 倍增长,基础延迟先封顶 8 秒,再在每次等待上加入上下 20% 的随机抖动。先封顶、后抖动意味着它不会机械地卡在同一个时刻重试,也不会无限拉长等待。初始 POST 最多重试 3 次,总预算不超过 20 秒。
最关键的仍然是复用同一个幂等键。服务端若已经创建 run,重复 POST 会返回 replay descriptor;客户端随后转入 GET recovery,而不是再次执行模型或 Agent 任务流程。
如果这段预算耗尽,界面会表达"初始提交状态未确认",而不是告诉用户请求一定没有被服务端接受。这比一个看似干脆的失败提示更诚实,也更符合分布式请求的真实状态。
7. 断线不等于取消,取消也不等于已经终止
可恢复流还有一条容易被忽略的边界:浏览器连接断开不应自动取消后台执行。
StreamExecutionCoordinator(执行协调器)为每个 run 管理独立的执行信号,并通过 execution owner claim 避免同一个 run 被并发启动。HTTP request 的 signal 只代表当前传输连接,而真正的模型调用或 Agent 任务执行由 run-scoped controller 驱动。这样,客户端临时失去连接后,服务端仍可继续完成该 run 并持续写入事件日志。
用户点击停止则是另一条路径。cancel route 先写入 cancelRequestedAt,把"用户希望取消"持久化为 durable intent(即使连接中断,服务端也能读到的取消意图):
ts
const run = await this.repository.markCancelRequested(input)
this.activeExecutions.get(input.runId)?.controller.abort()
return run
正在运行的 executor(实际执行任务的一方)观察到取消意图并真正停止之后,才由执行链投影唯一的 cancelled terminal event。
这里刻意没有让 cancel route 直接伪造终态。否则客户端可能已经看到"已取消",而后台仍在写事件,最终会出现两个互相矛盾的事实。
前端保持 optimistic stop(用户点击后立即停止本地展示)的体验:用户点击停止后,立即中止本地 reader 和 retry;服务端则在执行真正收口后,用 terminal event 给出最终事实。取消优先于下一次自动重连,但不抢占执行器对终态的写入权。
8. Agent 的业务状态和流交付状态要保持分层
以 Tasklist Agent(一类可暂停、可等待人工决策的任务执行 Agent)为例,它已经拥有 AgentRun、AgentInterrupt 和 LangGraph checkpoint(状态快照)等业务运行时状态。做 stream recovery 时,最容易犯的错误是把所有状态都塞进同一张表或同一条流。
AI Mind 没有这样做。
AgentRun、AgentInterrupt 和 checkpoint 仍然负责 Agent 的业务执行、暂停、人工决策和恢复;StreamRun、StreamEvent 则只负责公共事件的顺序、交付、重放和连接恢复。
这类 Agent 会把自己的 agentRunId 显式关联到对应的 StreamRun。普通聊天和其他 Agent 执行场景不需要这层关联,仍然可以共享同一套可恢复流能力。
这层拆分让交付协议不需要理解 raw GraphState,也让 Agent runtime 不必感知每条浏览器连接的生命周期。后续无论继续增强 Agent checkpoint,还是扩大 stream recovery 的范围,两个边界都不会互相拖拽。
9. 这类恢复能力,应该怎样验证
可恢复流不能只靠"手动断一次网,看到页面还能输出"来验收。它本质上是一份跨客户端、传输、数据库和执行器的协议,需要分层证明。
- 协议层:envelope schema、终态字段和协议版本必须能被稳定解析;
- 存储层:同一 run 的 sequence 连续且唯一,事件保留窗口之外应明确报错;
- 请求层:相同 key 与相同 fingerprint 复用 run,key 冲突则拒绝,而不是悄悄执行;
- 客户端层:重复事件不重复渲染、序列缺口停止应用、游标处 eventId 不一致立即失败;
- 链路层:普通聊天及两类 Agent 任务(Tasklist Agent 与 Delivery Chain)都要覆盖"已知 run 的 GET 恢复"和"初始 POST 响应丢失"的分支。
尤其要把 cancel 放进恢复测试:用户停止后,reader 和 retry 应立即收口,但服务端的最终 cancelled 事件只能由真正停止的执行器写出。这样才能避免把"前端已经不读了"误判为"后台已经终止"。
10. 可恢复不等于无限容灾
v0.4.10 的目标是解决同一页面生命周期内的 client disconnect recovery,而不是一次性建设通用分布式执行平台。
这一版明确保留了几个边界:
- 不支持 Node.js process crash 后接管内存中的 active executor;
- 不引入 worker queue、跨实例 execution lease 或通用双向通道;
- 不承诺外部 Tool / MCP 副作用 exactly-once;
- 不提供无限历史事件或任意时间点 replay;
- 页面刷新或关闭后,不重新订阅仍在执行的 active run。
事件采用滚动保留策略:每个新事件默认保留 10 分钟,活跃 run 会随着新事件推进保留边界;默认每个 run 最多保留 20,000 条事件、每条 public payload 最大 256 KiB。超过窗口后,系统宁可明确告诉客户端无法完整恢复,也不悄悄忽略一段历史。
如果未来要支持进程崩溃后的执行接管,仍需要 durable executor lease、queue / worker,以及与 checkpoint 协同的 resume 设计。那是下一层运行时能力,而不是把当前恢复逻辑继续堆复杂就能自然得到的结果。
总结
回头看,v0.4.10 最重要的不是新增了 recovery GET 或 cancel endpoint,而是给流式执行补上了一套能够解释状态的模型。
一次请求先通过幂等键找到唯一 run;一次 run 的输出被投影成有序 public event;断线后的客户端用 cursor 回到正确位置;用户取消先记录意图,再由执行器写出唯一终态;Agent 的业务状态和浏览器交付状态仍然各自独立。
这些选择让 POST + NDJSON 不再只是一条"连上就能看到、断了就只能重来"的响应流,而开始具备长时间 AI 执行需要的恢复、收口和演进空间。
项目地址
👉 GitHub:github.com/HWYD/ai-min...
👉 线上体验:ai.hwyblog.cloud/instant-min...
如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎顺手帮项目点个 Star⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。