AI 流式输出不是逐字显示:一套可恢复、可追踪、可验证的任务协议
AI 流式输出不是把回答切成小段逐字显示,而是让一次生成在任务状态、业务事件、数据库约束、恢复游标和可信上下文的共同约束下,能够被持续交付、重新连接和确定性验证。
起点:一条已经删除的同步 JSON 链路
改造前,my-world 的 AI 助手使用 POST /api/assistant/chat:后端完成校验、上下文构建和 Provider 调用,等待模型完整返回后再输出 JSON;前端执行 response.json(),拿到完整回答后一次性追加助手消息。这条旧接口及其对应 UI 现在都已经删除。
这条历史链路可以完成问答,但它没有生成任务、事件日志和服务端消息历史。页面刷新后,React 内存里的对话会消失;网络断开后,前端没有任务 ID 和恢复游标,无法知道后端是否继续生成;用户停止等待,也不等于服务端和 Provider 一定停止计算。
如果只想制造“逐字出现”的效果,可以在前端把完整回答切成小段并定时显示。但这种做法没有回答更重要的问题:
- 当前生成处于
queued、running、finished、failed还是cancelled? - 前端漏掉一段内容后,怎样发现事件缺口?
- 刷新或断网后,怎样恢复原任务而不是重复调用模型?
- 取消和正常完成几乎同时发生时,哪个终态有效?
- 进程重启后,旧 Provider 流无法续接,数据库里的
running应该怎样处理? - 新问题进入模型前,历史消息由谁提供,又按什么预算裁剪?
最终实现没有保留同步 JSON 回退或双版本开关。前台 ChatWidget 只挂载持久化流式组件,服务端只保留 /api/assistant/v2/* 任务、快照、事件、取消和重试接口。这里的 /v2/ 是已经落地的稳定协议路径命名,不代表运行时还存在第二套聊天链路。API 与 Worker 统一受 ASSISTANT_ENABLED 控制;功能关闭时不会创建会话或任务。
这次收敛发生在隔离契约、数据库约束、浏览器行为和回归门禁全部建立之后。它不是用新界面覆盖旧界面,而是删除了已经失去恢复能力的旧路径,只留下一个可追踪的执行模型。
第一步不是改页面,而是验证协议
正式实现前,我先在 src/lib/assistant/streaming-demo/ 和 scripts/streaming-context-demo.mjs 中做了隔离 fixture Demo。它不读取真实 API key,不请求外部模型,也不改线上接口,只验证状态机和事件协议能否执行。
Demo 定义了六类业务事件:
事件 | 作用 |
|---|---|
message_started | 确认任务进入 running |
content_delta | 携带一次正文增量 |
message_finished | 携带最终内容、usage 和 finishReason |
message_failed | 保留部分内容并返回安全错误 |
message_cancelled | 保留取消前内容和取消原因 |
message_reset | 重试时切换到新任务并重置原助手消息 |
每个事件都有 type、taskId、messageId、seq、emittedAt 和与类型对应的 data。任务内 seq 从 1 单调递增;客户端忽略 seq <= lastAppliedSeq 的重复事件,只追加 seq === lastAppliedSeq + 1 的连续事件,发现更大的跳号就停止盲目拼接并重新获取快照。
隔离 Demo 最终 8/8 通过,覆盖正常完成、首个 delta 延迟、断线恢复、取消、Provider 中途失败、新任务重试、重试耗尽、上下文预算和 20 次并发幂等。它证明了设计契约可以执行,却仍然没有真实 HTTP SSE、数据库、Worker、浏览器网络和模型 Provider。
这个顺序让我避免了一个常见误判:先看到文字动起来,再补状态和恢复。实际更可靠的顺序是先确定事件和终态,再让页面成为协议的消费者。
四组持久化数据,把内容和执行过程分开
当前唯一流式链路在 Payload/Postgres 中使用四组数据:
数据 | 负责什么 | 关键约束 |
|---|---|---|
assistant-conversations | 会话归属、状态、消息序号和匿名过期时间 | 按 ownerType + ownerKey 查询 |
assistant-messages | 用户与助手内容、稳定顺序、当前生效任务 | (conversationId, messageSeq) 唯一 |
assistant-generation-tasks | 幂等、状态、租约、检查点、用量、错误和重试关系 | (conversationId, idempotencyKey) 唯一 |
assistant-task-events | 可以补发的业务事件日志 | (taskId, seq) 唯一 |
一次发送会在事务中创建用户消息、助手占位消息和 queued 任务。会话内的 messageSeq 由数据库连续分配,不依赖客户端时间排序。相同 conversationId + idempotencyKey 的重复请求复用原任务,因此双击和网络重试不会重复创建消息、任务和 Provider 调用。
消息与任务必须分开,是因为一条助手消息可能经历多次生成。旧任务中途失败后,数据库继续保留它的部分内容、错误和 failed 证据;显式重试创建带 retryOfTaskId 的新任务,复用原助手消息位置,并以独立 seq 从 1 开始。前端收到 message_reset 后才清空旧展示、切换任务和重置游标。旧任务不会被改回 running。
迁移还增加了数据库终态 trigger:finished、failed、cancelled 一旦落库,后续更新不能把任务切到另一个状态。应用层使用条件更新,数据库再提供最后一道保护。这样,即使取消、完成和晚到 delta 并发发生,也只会保留一个终态。
所有权不是在 URL 上带一个 ID
持久化以后,conversationId 和 taskId 都成为可查询资源。只校验“这个 ID 存在”会造成跨用户读取,因此每个会话、任务、事件、取消和重试接口都会重新解析所有者。
Payload 登录用户使用服务端验证后的 user ID。匿名访客由服务端生成高熵随机值,签名后写入 HttpOnly、SameSite=Lax Cookie;数据库只保存该随机值的哈希,不保存可以直接复用的明文凭证。生产部署必须提供至少 32 字节随机值的 ASSISTANT_ANON_SIGNING_SECRET,缺失安全签名密钥时拒绝创建匿名会话,不回退到可预测默认值。跨登录用户或跨匿名凭证访问时统一返回 404,避免通过响应差异枚举资源。
这也解释了为什么页面刷新时不能只凭 localStorage 中的 conversationId 恢复。这个 ID 只负责定位候选会话,真正授权仍来自服务端验证的登录态或匿名 Cookie。
自动化验证覆盖了跨用户和跨匿名凭证访问,确认裸 ID 不能绕过归属边界。
创建请求、SSE 连接和 Provider 调用彼此解耦
当前创建接口只保存 queued 任务并唤醒 Worker,不在一次 HTTP 请求里悬挂一个无法追踪的后台 Promise。Next 长驻 Node 进程启动时通过 instrumentation 挂载进程内调度器,Worker 使用 Postgres 数据库租约领取任务。
领取逻辑只选择 queued,并使用 FOR UPDATE SKIP LOCKED 与条件更新写入 leaseOwner、leaseExpiresAt 和 running 状态。这使创建接口、下游 SSE 和上游 Provider 拥有独立生命周期:浏览器关闭面板或断开 SSE,只结束当前订阅,不取消后台任务;只要 Worker 仍然持有有效租约,生成就可以继续。
普通 Chat Completions 流无法在进程重启后续接。Worker 重启时不会重新领取旧 running,而是先查找租约过期任务,把最后检查点和部分内容保留下来,再以安全、可重试的 TASK_INTERRUPTED 进入 failed。用户之后可以创建新任务重试,但系统不能沿原 seq 假装恢复了已经丢失的 Provider 流。
当前实现面向低流量、单实例部署。数据库领取和租约已经为多实例保留边界,但还不能据此宣称多实例 Worker 已经完成生产验证。
匿名会话已经具备保留期清理:Worker 定期删除过期匿名会话,数据库外键级联清理对应消息、任务和事件。上线后仍需持续观察长期调度,并为登录用户历史定义独立保留策略。
SSE 发送的是业务事件,不是任意文本块
事件接口是 GET /api/assistant/v2/tasks/:taskId/events?after=:lastAppliedSeq。它从数据库查询 seq 大于游标的持久化事件,先补发断线期间缺失的数据,再继续轮询实时事件。路径中的 /v2/ 是稳定协议名称,不对应另一套旧聊天实现。
业务帧使用标准 SSE 结构:
event: content_delta
id: taskId:seq
data: {...}
心跳使用 : ping <iso-time> 注释帧,不写入业务事件表,也不分配 seq。它只解决连接长时间没有字节的问题,不能证明 Provider 一定健康,更不能重置首个 delta 或流空闲超时。
客户端维护 lastAppliedSeq:
seq <= lastAppliedSeq 重复,忽略
seq == lastAppliedSeq + 1 连续,应用并推进游标
seq > lastAppliedSeq + 1 缺口,重新查询快照快照返回 content、status 和 snapshotSeq。客户端应用快照时使用服务端内容覆盖本地消息,并设置 lastAppliedSeq = snapshotSeq。如果状态仍是 queued 或 running,继续连接事件流;如果已经 finished、failed 或 cancelled,直接展示终态,不依赖浏览器刚好收到最后一个 SSE 事件。
这种设计同时支持两种恢复:SSE 短暂断线后按 taskId 和游标重连;页面刷新后先读取服务端会话与活跃任务快照,再恢复原任务。关闭聊天面板只改变界面展开状态,不触发取消。
Provider 必须先解析完整上游事件
真实网络 chunk 没有业务边界。一个上游 SSE frame 可能跨两个 chunk,一个 chunk 也可能包含多个 frame。当前流式 Provider 因此使用增量 parser 缓冲文本,按空行切分完整 SSE frame,兼容 LF、CRLF 和多行 data:,再解析 JSON、[DONE]、正文 delta、usage、finishReason 和 request ID。
请求会显式发送 stream: true 和 Accept: text/event-stream。Provider 只向 Worker 暴露归一化后的 delta 与 metadata,不把上游原始 JSON 和敏感错误直接交给前端。
唯一流式链路不依赖一个笼统定时器,而是区分三类 Provider 超时,并单独维护任务租约:
- 首个 delta 超时 45 秒:连接建立后迟迟没有正文。
- 流空闲超时 30 秒:已经产生正文,但后续长时间没有新 delta。
- 总执行超时 180 秒:无论中间是否有数据,整个调用不能无限延长。
- Worker 租约 45 秒:约束哪个实例当前拥有任务执行权。
首次正文前遇到可重试临时错误,可以在同一任务的次数预算内重试;已经输出正文后失败,则保留部分内容并让旧任务进入 failed。再次生成必须创建新任务,不能把重新生成伪装成模型断点续写。
当前 Worker 实现的是 sawDelta 之前按最大尝试次数进行有限重试,还没有读取 429 Retry-After,也没有指数退避和随机抖动。因此它已经验证了“重试次数有上限”,但还不能视为完整的生产重试调度。
这里有一条必须保留的证据演进:第一次真实 Provider 冒烟把 maxTokens 严格限制为 8,但原模型因免费额度耗尽返回 HTTP 403。这个结果只证明了失败链路和安全收口,没有证明真实模型能够成功产生 delta。随后更换为有可用额度的模型,用户实机发送一条消息,回答成功完成,前端也观察到了流式输出。上线前进一步对这条真实任务做了脱敏核验:任务为 finished,共有 44 个连续业务事件,message_finished 只有一次,任务、消息与增量拼接后的正文哈希一致,三处最终序号均为 44,usage、finishReason、Provider request ID 和完成时间都已保存。功能部署到线上后,我再次发送消息,页面也确实持续收到并展示了增量内容。基础真实 Provider、数据库终态和线上流式展示因此都有了对应证据;长期进程稳定性、告警和更长时间的代理连接仍需要持续观察。
在删除旧同步接口前,这次真实失败还暴露过共享 Provider 的安全缺口:非 2xx 响应可能把上游英文额度错误带到前端。修复后,服务端只记录 HTTP status 与 Provider request ID,浏览器固定收到 MODEL_PROVIDER_ERROR 和“AI 模型服务暂时不可用。”。回归测试模拟带内部细节的 403,明确断言原始额度、鉴权和诊断正文不会出现在前端异常中。
上下文不再由浏览器提供真相
改造前的同步接口会接收浏览器回传的最近历史。当前创建任务时忽略前端提交的完整 history,Worker 根据 conversationId 和 inputMessageId 从 Postgres 读取当前问题以及它之前的可信消息。
历史选择有一条明确规则:用户消息可以进入候选,助手消息只有状态为 finished 时才作为普通历史。failed 或 cancelled 的部分回答不会悄悄污染下一次上下文。
模型输入由服务端的 System、站点公开资料、可信历史和当前问题组成。预算算法先为输出与估算误差保留空间,再使用保守的确定性 token 估算;历史按最近优先选择,规范化后相同的消息会去重。System 和当前问题这些固定输入本身超出预算时,任务直接返回 CONTEXT_BUDGET_EXCEEDED,不调用 Provider。
这是一套可信、可测试的 MVP 裁剪策略,但还不是完整的长期记忆系统。结构化摘要、摘要事实核对和 summaryThroughMessageId 持久化明确延期,当前超预算时只做确定性去重和裁剪。
取消、失败和重试必须保留证据
用户点击取消后,queued 任务可以直接进入 cancelled;running 任务先在数据库记录取消请求,再由当前实例通过 taskId 找到对应的 AbortController,中止 Provider 读取。任务进入终态前会强制保存部分内容和最终序号。
如果取消与完成接近同时发生,应用层条件更新和数据库终态 trigger 共同决定先提交者获胜。之后到达的 delta、finished 或再次取消都不能覆盖终态。
失败任务保留 partial content 和经过业务层收口的安全错误。只有错误允许重试时,前端才展示重新生成入口;重试创建独立任务并关联旧任务。原始 Provider 错误、密钥和内部 Prompt 不进入公开响应。
这些规则也落实到了唯一流式界面:页面展示 queued、running、finished、failed 和 cancelled,生成中可以取消,失败或取消后可以显式重新生成。发送过程还有客户端提交锁和服务端幂等约束,双击不会创建两次逻辑请求。
验证不能只看一条成功回答
V1.2.1 的验收分成五层。
第一层是隔离契约:
npm run demo:streaming-context:8/8 通过。npm run demo:structured-output:原有结构化输出回归 28/28 通过。
第二层是本地生产链路自动化:
npm run test:assistant-streaming:33/33 通过。- 覆盖正常完成、首 delta、空闲和总超时、断线与刷新恢复、queued/running 取消、取消完成竞争、中途失败、新任务重试、重试耗尽、可信上下文预算、20 次并发幂等、消息排序、终态 trigger、跨用户与跨匿名凭证隔离、stale running 失败化、Provider chunk 与 SSE frame 拼接、共享 Provider 原始错误正文不泄漏,以及 user 后追加可信 system 上下文时仍能定位最后一条 user。
- 新增门禁还覆盖首 delta 等待期间独立续租、production fixture 硬拒绝、fixture 标签不进入真实 Provider、无尾随空行的最终 SSE 帧、可信 owner 限流、生产匿名签名密钥至少 32 字节和 Worker 默认单并发。
第三层是数据库和工程门禁:
npm exec tsc -- --noEmit通过。npm run db:migrate通过,迁移表记录 batch 1/2,无异常批次。npm run build通过。git diff --check通过。
第四层是唯一流式入口的本地浏览器验收。在桌面 1280x720 和移动 390x844 两个视口验证了:
- 回答内容可见增量追加。
- 用户消息先出现,生成状态位于对应助手消息气泡内,不会抢在用户消息前面。
- 距底部 80px 内保持自动跟随;用户主动上滚后,新 delta 不会强制拉回底部。
- queued/running 取消后不再追加晚到内容。
- 中途失败保留部分内容,显式重试切换到新任务。
- 页面刷新和下游 SSE 断连后恢复同一任务。
- 双击发送不会重复创建消息。
- 关闭面板不会取消后台任务。
浏览器验收期间没有应用 error/warn,也没有出现框架错误层。
最后一层是真实 Provider 和上线验证。第一次 maxTokens=8 请求因免费额度耗尽返回 HTTP 403,验证了安全降级与原始错误收口;更换为有可用额度的模型后,一条真实消息成功完成并在前端流式展示。上线前对该任务的 44 个连续事件、最终内容、序号、usage 和终态进行了脱敏核验;功能部署后又在真实线上页面确认了增量展示。
因此,当前准确结论是:V1.2.1 已经收敛为唯一持久化流式聊天,数据库、Worker、SSE、Provider parser、上下文与前端恢复形成了可运行闭环;TypeScript、33/33 流式测试、8/8 隔离 Demo、28/28 结构化输出回归、迁移、构建、diff check、双视口浏览器门禁、真实任务数据库终态和线上流式展示均已通过。功能已经上线,但这仍不等于多实例、长期告警、极端连接容量和完整重试调度已经完成。
从逐字显示走向可交付边界
这次实践把我对“流式输出完成”的判断从界面效果推进到了工程契约。
现在,一次生成可以回答这些问题:谁拥有这段会话;哪个逻辑请求创建了任务;Worker 是否持有租约;客户端已经应用到哪个 seq;数据库正文保存到哪个 snapshotSeq;断线后从哪里补发;取消、完成和失败如何竞争;重试为什么必须是新任务;哪些可信历史进入模型;真实 Provider 是否真的通过。
唯一流式链路已经通过 33/33 自动化、两个浏览器视口、完整构建、真实 Provider 数据核验和线上流式展示。此前的 403 没有被删除,而是成为失败安全收口的证据;新的成功消息则证明真实流可以工作。工程验证不是用后一次成功覆盖前一次失败,而是判断每条证据究竟支持哪个结论。
页面逐字显示很容易被看见。能够恢复、追踪、隔离、约束和诚实说明未验证边界,才是流式 AI 功能接近真实交付的标志。
有想法或建议? 联系我