← 返回文章选择

MediAC 流式回复:看见文字以后,还发生了什么

用 fetch 读 POST 的 SSE,要自己接手重连、解码和完成确认;最后一个字出来,done 还没到。

用户补了一句“疼了两天”,想要的是下一句追问。同步 /v1/dual 会连着报告一起等,于是这一阶段给 A 线单独做多轮与流式接口:聊天先输出文字,B 线调度在后面继续处理。

接口分别留了提交、恢复会话和读流的入口:

POST /v1/chat
GET /v1/sessions/{id}/messages
POST /v1/consult/stream

“疼了两天”要和历史放在一起读

这句话单独拿出来没有完整含义。后端按 session_id 找到会话,把历史与当前输入组织进追问 prompt;浏览器保存 id,刷新后再从服务端读消息。

id 是找到那次会话的键,消息本体存在服务端。从服务端读回的那份,和下一轮组织追问 prompt 用的是同一份记录。

服务端存历史、每轮重放,是这类接口的常规做法。Anthropic 的 Messages API 参考把多轮会话写成“无状态”的:接口自己不记得上一轮,要续上对话,就把先前的轮次放进 messages 参数。所以真正承担“会话”的,是服务端那份记录,它必须和组 prompt 时用的是同一份。

每轮重发历史也有成本。同一页文档提供了 cache_control 缓存断点,TTL 可选 5m 或 1h,默认 5 分钟;多轮时传上一轮的 previous_message_id,响应里会带 diagnostics.cache_miss_reason,说明这次为什么没复用缓存前缀。以后要压多轮成本,我会先从这两处下手。

POST 读 SSE,用不了原生 EventSource

SSE 适合这种服务端不断推送文本的过程,但这个接口用 POST,前端不能只 new 一个原生 EventSource。EventSource 没有设置 POST 请求体的选项,所以这里用 fetch 发请求,再读 response body。EventSource 构造参数

网络返回的一块字节也不一定是一条事件。前端要先累积文本,按空行分出完整的 SSE block,再解析 event 和 data;半条事件留在缓冲区等后续数据。SSE 格式说明值得对着读,不能假设一次 read 刚好对应一次 token 通知。

换成 fetch,等于接手三件内建行为

Azure 的 fetch-event-source 说明把原生 EventSource 的限制列得很全:不能带请求体,不能自定义请求头,只能发 GET,断线后的重试策略也由不得你。把参数全塞进 URL 走 GET 也不现实,多数浏览器的 URL 上限约 2000 字符,一段带历史的问诊很快就超了。

代价是,EventSource 原本替你做的事,现在都要自己做:

  1. 断线重连。按 HTML 规范,EventSource 断开后会自动重连,服务端可以用 retry 字段指定间隔;收到的 id 会在重连时放进 Last-Event-ID 请求头,服务端有机会凭它补发漏掉的事件。用 fetch 读流,这三样都没有了。
  2. 分块解码。字节块不光会切开事件,还会切开多字节字符。TextDecoder.decode() 文档写明按块处理时要传 stream: true,最后一块再用 false;不这样做,中文在块边界上就可能变成乱码。
  3. 半条事件的处理。同一份规范规定,流结束时还没处理完的数据必须丢弃,最后一个空行之前断掉的那条事件不会派发。缓冲区里等着的半条,在连接断开时就作废了。

还有一个部署层面的数字。MDN 的 SSE 页提醒,不用 HTTP/2 时,每个浏览器对同一域名只有 6 条连接,Chrome 和 Firefox 都标了不修;HTTP/2 下同时流数默认协商到 100。多开几个标签页各挂一条流,在 HTTP/1.1 上就会互相占满。

最后一个字出来,done 还没到

九月整理 backend/main.py,当前正常完成的顺序如下:

读取历史与 intake
→ 准备召回与 prompt
→ 逐段发送 token,后台同时累积完整文本
→ 提交用户消息与 assistant 消息到数据库
→ 更新 intake,检查是否调度报告
→ 发送 done,携带完整回答及报告任务状态

流式文字输出后,消息入库与调度检查完成才发送 done

最后一段文字已经显示,客户端也未必拿到了 done。数据库提交之后还要更新 intake、检查调度;这段失败的话,消息可能已经保存,客户端却收不到正常完成事件。连接断开本身也不能当成功确认。

对照一下 Anthropic 流式接口的事件形态:结束前先来一条或多条 message_delta,带最终的 stop_reason 和累计 usage,最后的 message_stop 很轻;流里还可能夹 ping,文档要求代码优雅地忽略不认识的事件类型。更值得借鉴的是错误:高峰期流里可能收到 event: error,内容是 overloaded_error,对应非流式场景的 HTTP 529。

这给了一个对照设计,不是现在已经在做的:调度检查失败时,往流里发一条 error 事件,客户端就不用靠“等没等到 done”去猜。另一条路是把完成状态放到服务端可查。OpenAI 的 background 模式就是这样,连接断了任务继续跑,客户端用 GET /v1/responses/{id} 轮询,状态离开 queued、in_progress 就是终态。MediAC 已经有读回消息的接口,我觉得缺的是一个能说明“这轮到底完没完”的状态。

中间层也可能让 done 看起来迟迟不到。sse-starlette 的 README 提到,Nginx 默认缓冲响应,SSE 事件要攒到约 16KB 才发出,Cloudflare 这类 CDN 约 100KB 才 flush;同一份 README 也把默认 15 秒一次的 ping 保活、循环里检查 request.is_disconnected() 列成常规配套。对应的开关在 nginx 文档里:关掉 proxy_buffering,或者让后端在流式响应上带 X-Accel-Buffering: no。本地逐字出、线上像一次性返回,多半是这里。

临时文本和正式消息是两份状态

前端收到 token 时先显示临时文本,正常完成后再放进正式消息列表。临时文本按 token 到达顺序逐段拼出来,只停留在页面上;数据库里那份才是消息本身。下一次问答读历史,靠的是已保存的消息,不是浏览器刚才画过哪个气泡。后面处理重复显示,也得从这两份状态的交接入手。

交接点放在哪,Vercel AI SDK 的消息持久化文档给了一个清楚的答案:落库挂在 toUIMessageStream 的 onEnd 回调上,此时拿得到含新回答的完整消息;服务端用 consumeStream() 主动把流消费完,这样客户端已经断开,结果也照样存下;消息 ID 要在保存前就稳定;生产上还应在消息里记一个“进行中 / 已完成”的状态,供断线刷新后对齐。不做这些,客户端一断,对话可能停在半截的状态。

旧同步接口仍保留,便于一次看完整 A/B。流式接口先解决 A 线什么时候开始出字;报告任务和发送按钮什么时候互不等待,还要接着查后面的调度与前端状态。

留下几句话 读者来信

读到这里,有什么想法?欢迎留下你的看法。