AI SSE Client 和普通 SSE Client 有什么不同:一次生成任务背后的坑与设计边界

AI SSE Client 和普通 SSE Client 有什么不同:一次生成任务背后的坑与设计边界

SSE 的底层 API 很简单:new EventSource(url)

但放到 AI 应用里,尤其是 AI Chat、AI Agent、AI Coding 这类场景,SSE Client 面对的就不再是普通事件推送,而是一条完整的生成任务生命周期。

这篇文章想聊清楚:AI SSE Client 和普通 SSE Client 到底有什么区别,以及为什么 AI 场景里的 SSE 更容易踩坑。

一、最大的区别:普通 SSE 是事件流,AI SSE 是生成过程流

普通 SSE 更像是在订阅一个事件频道:

txt 复制代码
你有一条新通知
订单状态更新了
股票价格变化了
构建日志多了一行

这些事件通常相对独立。前端收到一条,处理一条。

AI SSE 不一样。

一次 AI 请求可能会持续推送:

txt 复制代码
answer      AI 正文 token
reasoning   显式推理摘要
thought     工具调用状态
status      后端生命周期状态
artifact    生成产物
done        本次生成完成
error       本次生成失败

这些事件共同描述的是一次生成任务

所以普通 SSE 解决的是:

txt 复制代码
我怎么稳定接收服务端事件?

AI SSE 解决的是:

txt 复制代码
我怎么稳定承载一次 AI 生成任务的完整生命周期?

这两件事看起来都叫 SSE,但工程复杂度完全不一样。

二、普通 SSE Client 和 AI SSE Client 的对比

先放一张整体对比表:

维度 普通 SSE Client AI SSE Client
数据语义 通知、日志、状态更新 一次 AI run 的生成过程
数据频率 通常较低或中等 token 级高频
消息关系 多数事件相对独立 强顺序、强上下文
结束语义 可能是无限流 多数是一次任务,需要明确 done
重连风险 通常可以重连 可能导致模型和工具重复执行
错误处理 连接错误为主 模型错误、工具错误、业务错误、连接错误都要区分
UI 消费 单一区域展示较多 正文、推理摘要、工具状态、产物多路分发
输入复杂度 GET 订阅常见 prompt、文件、上下文可能很长
幂等要求 相对较低 很高,尤其涉及工具调用和代码生成
取消语义 close 连接通常够用 还需要取消后端 run
安全风险 一般业务数据 prompt、代码、工具参数、推理内容更敏感
状态机 connected / disconnected idle / creating / streaming / tool_running / saving / building / done / error

如果你只拿普通 SSE 的写法去做 AI 流,很容易在真实场景里翻车。

三、坑 1:不能看到 data 就拼到正文

普通 SSE 里,后端可能只推一种数据:

json 复制代码
{"message":"你有一条新通知"}

前端收到后展示即可。

但 AI SSE 里,后端可能连续推这些:

json 复制代码
{"type":"answer","d":"你好"}
json 复制代码
{"type":"reasoning","d":"我先分析一下需求"}
json 复制代码
{"type":"thought","d":{"id":"call_1","type":"tool_call","status":"pending"}}
json 复制代码
{"type":"status","d":"正在调用 AI 生成服务"}
json 复制代码
{"type":"artifact","d":{"appId":123}}

如果你写成:

ts 复制代码
eventSource.onmessage = (event) => {
  const payload = JSON.parse(event.data);
  assistantMessage.content += payload.d;
};

那最终用户可能看到:

txt 复制代码
正在调用 AI 生成服务
我先分析一下需求
你好
[object Object]

这就是 AI SSE 最常见的坑:把所有事件都当成正文。

AI SSE 必须先分流:

txt 复制代码
answer     -> AI 正文
reasoning  -> 推理摘要
thought    -> 工具调用状态
status     -> 生命周期状态,不进正文
artifact   -> 产物区,不进正文
done       -> 结束事件
error      -> 错误事件

更合理的 parser 输出应该是结构化 chunk:

ts 复制代码
export interface AiStreamChunk {
  content?: string;
  reasoningContent?: string;
  thought?: AgentThought;
  artifact?: ArtifactEvent;
}

或者更严格一点:

ts 复制代码
type AiStreamEvent =
  | { type: 'answer'; content: string }
  | { type: 'reasoning'; content: string }
  | { type: 'tool_call'; toolCall: ToolCallEvent }
  | { type: 'artifact'; artifact: ArtifactEvent }
  | { type: 'status'; status: StatusEvent };

重点不是类型怎么命名,而是不要让 UI 层直接面对后端原始 SSE。

四、坑 2:AI 流是高频 token 流,直接 setState 会卡

普通 SSE 可能几秒推一条:

txt 复制代码
订单状态更新
通知来了
日志新增一行

AI SSE 可能几十毫秒推一个 token。

如果每个 token 都直接触发响应式更新:

ts 复制代码
function onChunk(chunk: AiStreamChunk) {
  message.content += chunk.content ?? '';
}

在 Vue 或 React 中可能导致:

txt 复制代码
频繁 render
滚动抖动
Markdown 反复解析
代码高亮重复计算
页面输入卡顿

更好的做法是做 token buffer。

例如用 requestAnimationFrame 合并一帧内的 token:

ts 复制代码
let textBuffer = '';
let scheduled = false;

function appendToken(token: string) {
  textBuffer += token;

  if (scheduled) {
    return;
  }

  scheduled = true;

  requestAnimationFrame(() => {
    assistantMessage.content += textBuffer;
    textBuffer = '';
    scheduled = false;
  });
}

也可以用时间窗口:

ts 复制代码
const flushInterval = 50;

每 30ms 到 50ms 批量更新一次 UI。

这对 AI 流非常重要,因为 AI 正文不是普通事件,它是高频 token 流。

五、坑 3:自动重连可能导致任务重复执行

普通 SSE 的自动重连通常是好事。

比如通知流断了:

txt 复制代码
重连后继续接通知

构建日志断了:

txt 复制代码
重连后继续看日志

但 AI SSE 不一定。

一次 AI 请求可能会触发很多副作用:

txt 复制代码
调用模型
调用工具
写文件
删除文件
安装依赖
构建项目
部署应用
保存会话
扣费

如果网络抖动后,前端简单地重新请求:

txt 复制代码
GET /ai/chat?message=帮我生成页面

后端可能会重新执行一遍任务。

结果是:

txt 复制代码
同一条会话保存两次
同一个文件写两次
工具调用重复执行
构建任务重复
token 成本增加
AI 输出重复

所以 AI SSE 的自动重连要非常谨慎。

我一般会这样分:

场景 是否建议自动重连
通知流 可以
构建日志流 可以
AI 纯文本生成流 可以有限重连,但需要后端支持恢复
AI Agent 工具执行流 默认不建议
AI Coding 写文件/构建流 默认不建议

成熟的 AI SSE 重连需要后端配合:

txt 复制代码
runId
requestId
messageId
eventId
Last-Event-ID
工具调用幂等
任务状态查询
断线恢复

更理想的不是"重新请求生成接口",而是:

txt 复制代码
重新订阅同一个 run 的事件流

例如:

txt 复制代码
POST /ai/runs
  -> { runId }

GET /ai/runs/{runId}/events
  -> SSE

断线后重连:

txt 复制代码
GET /ai/runs/{runId}/events?lastEventId=1001

而不是重新创建一个 run。

六、坑 4:取消不是简单 close EventSource

普通 SSE 里,用户离开页面时:

ts 复制代码
eventSource.close();

通常就够了。

但 AI 任务里,close() 只代表:

txt 复制代码
前端不再接收消息

不一定代表:

txt 复制代码
后端停止生成

用户点击"停止生成"时,真正应该做两件事:

txt 复制代码
1. 前端关闭 SSE 连接
2. 后端取消当前 run / task / model call

更成熟的接口应该有:

http 复制代码
POST /ai/runs/{runId}/cancel

否则会出现这种情况:

txt 复制代码
用户前端点了停止
页面不再流式输出
但后端模型还在跑
工具还在执行
文件还在写
token 还在消耗

所以 AI SSE Client 里要区分:

txt 复制代码
manual close
done close
business-error close
network-error close
timeout close

不要把手动取消显示成:

txt 复制代码
生成失败

取消是用户行为,不是错误。

七、坑 5:业务错误不要依赖 onerror

很多人会以为:

ts 复制代码
eventSource.onerror = (event) => {
  console.log(event.data);
};

可以拿到后端错误。

通常不行。

EventSource.onerror 是连接层错误,它不会稳定暴露:

txt 复制代码
HTTP status
response body
后端错误 JSON

AI 场景里,业务错误又非常多:

txt 复制代码
未登录
无权限
余额不足
模型超时
工具执行失败
文件写入失败
上下文过长
安全策略拦截

这些错误不能只提示:

txt 复制代码
SSE 连接中断

更推荐后端主动推自定义事件:

txt 复制代码
event: business-error
data: {"code":"MODEL_TIMEOUT","message":"模型响应超时"}

前端监听:

ts 复制代码
eventSource.addEventListener('business-error', (event) => {
  const payload = JSON.parse((event as MessageEvent<string>).data);

  onError({
    type: 'business',
    code: payload.code,
    message: payload.message,
  });
});

也就是说:

txt 复制代码
business-error 处理业务失败
onerror 处理连接失败

这两条线一定要分开。

八、坑 6:AI 生成必须有 done 事件

普通 SSE 可能是无限流:

txt 复制代码
通知中心
股票价格
在线状态

这种流不一定有 done。

但 AI 生成多数是一次性任务:

txt 复制代码
开始生成
持续输出
保存结果
生成完成

所以必须有明确的结束事件:

txt 复制代码
event: done
data: {"type":"done","d":""}

前端收到后:

ts 复制代码
eventSource.addEventListener('done', () => {
  eventSource.close();
  onDone();
});

如果没有 done,会导致很多状态无法结束:

txt 复制代码
typing 动画不知道何时停
输入框不知道何时恢复
右侧预览不知道何时刷新
当前 run 不知道何时释放
连接引用不知道何时清理

AI SSE 一定要把生命周期闭合。

txt 复制代码
open -> streaming -> done -> closed

九、坑 7:工具调用必须有稳定 id

AI Agent 和 AI Coding 经常会有工具调用。

一次工具调用通常有两个阶段:

txt 复制代码
tool_call pending
tool_call success

如果后端这样发:

json 复制代码
{
  "type": "thought",
  "d": {
    "status": "pending",
    "title": "调用工具:writeFile"
  }
}

完成时又这样发:

json 复制代码
{
  "type": "thought",
  "d": {
    "status": "success",
    "title": "工具完成:writeFile"
  }
}

但是没有同一个 id,前端不知道它们是不是同一次调用。

最终只能展示成两条割裂记录:

txt 复制代码
调用工具:writeFile
工具完成:writeFile

成熟协议应该这样:

json 复制代码
{
  "type": "thought",
  "d": {
    "id": "call_1",
    "type": "tool_call",
    "status": "pending",
    "title": "调用工具:writeFile"
  }
}
json 复制代码
{
  "type": "thought",
  "d": {
    "id": "call_1",
    "type": "tool_call",
    "status": "success",
    "title": "工具完成:writeFile",
    "output": "文件写入成功"
  }
}

前端根据 id 合并:

ts 复制代码
function mergeToolCall(events: ToolCallEvent[], next: ToolCallEvent) {
  const index = events.findIndex(event => event.id === next.id);

  if (index < 0) {
    return [...events, next];
  }

  return events.map((event, currentIndex) => {
    return currentIndex === index ? { ...event, ...next } : event;
  });
}

这就是为什么 AI SSE 需要比普通 SSE 更重视:

txt 复制代码
runId
messageId
toolCallId
artifactId
eventId

没有稳定 id,前端很难做合并、去重和恢复。

十、坑 8:流式 Markdown 天然是不完整的

AI 正文常常是 Markdown。

但流式输出意味着任意时刻 Markdown 都可能是不完整的。

例如当前只收到:

md 复制代码
```ts
const a =

下一秒才收到:

md 复制代码
 1;
```

如果 Markdown 渲染器或者代码高亮器处理不好,会出现:

txt 复制代码
代码块闪烁
高亮报错
HTML 结构不稳定
滚动位置抖动
页面频繁重排

AI SSE 的 UI 消费层要把正文当成:

txt 复制代码
正在增长的不完整文档

常见策略:

  • Markdown 渲染器必须容忍不完整输入;
  • 代码高亮失败时 fallback 成纯文本;
  • token 更新要节流;
  • done 后再做一次完整渲染;
  • 用户手动上滑后暂停自动滚动。

普通 SSE 通知消息一般不会遇到这个问题。

十一、坑 9:不要把私有 CoT 当 Thought 展示

AI 应用里经常出现几个容易混淆的词:

txt 复制代码
answer
reasoning
thinking
thought
chain-of-thought
tool trace

工程上一定要分清:

类型 是否建议展示
answer 展示,最终正文
reasoning summary 可以展示,前提是后端明确给的是摘要
thought / tool trace 可以展示,展示工具过程
private chain-of-thought 不展示

我更推荐把 Thought 理解成:

txt 复制代码
Agent 工具调用可视化

而不是:

txt 复制代码
模型完整思维链

也就是说,ThoughtChain 更适合展示:

txt 复制代码
调用了哪个工具
参数是什么
是否成功
输出是什么

不适合展示模型完整私有推理过程。

这是 AI 产品里非常重要的安全和产品边界。

十二、坑 10:不要把长 prompt 放进 URL query

浏览器原生 EventSource 有几个限制:

txt 复制代码
只能 GET
不能自定义 request body
不能自由设置 Authorization header

普通 SSE 订阅通知流没问题:

ts 复制代码
new EventSource('/api/notifications')

但 AI 请求经常包含:

txt 复制代码
长 prompt
文件内容
上下文
选中的代码
工具配置
模型参数

如果全部放在 query:

txt 复制代码
/api/chat/stream?message=很长很长的用户输入...

会有这些风险:

txt 复制代码
URL 长度限制
特殊字符编码问题
敏感 prompt 进入浏览器历史
敏感 prompt 进入网关日志
服务端 access log 暴露用户内容
文件内容无法承载

更成熟的做法是先创建 run:

txt 复制代码
POST /ai/runs
body: { message, files, options }
  -> { runId }

GET /ai/runs/{runId}/events
  -> SSE

如果必须用 POST 流式,也可以考虑 fetch-based SSE:

txt 复制代码
fetch + ReadableStream

但这样就不是原生 EventSource 了,需要自己处理 SSE 格式解析或使用成熟库。

十三、坑 11:前端断线不代表后端任务停止

AI 任务是有后端状态的。

前端 SSE 断了,只能说明:

txt 复制代码
前端收不到事件了

不代表:

txt 复制代码
后端任务结束了

可能发生这种情况:

txt 复制代码
前端网络断了
EventSource 触发 onerror
页面显示连接失败
但后端模型还在生成
几秒后文件已经写完
构建也跑完了

所以生产 AI 系统不能只靠 SSE 连接作为事实来源。

还需要:

txt 复制代码
run status 查询
messages 查询
artifact 查询
build status 查询

例如:

http 复制代码
GET /ai/runs/{runId}
GET /ai/runs/{runId}/messages
GET /ai/runs/{runId}/artifacts
GET /apps/{appId}/build/status

SSE 是实时通道,不是最终事实来源。

最终事实来源应该是后端任务状态和数据库。

十四、坑 12:AI SSE 的状态机比普通 SSE 更复杂

普通 SSE UI 可能只有:

txt 复制代码
connected
disconnected

AI SSE 至少会出现:

txt 复制代码
idle
creating_run
connecting
streaming
tool_running
saving
building
done
error
cancelled

并且不同 UI 区域关心不同状态:

txt 复制代码
输入框
  能不能发送
  能不能取消

消息气泡
  是否 typing
  是否展示错误

工具链
  哪个工具 pending
  哪个工具 success
  哪个工具 error

右侧产物区
  是否生成中
  是否构建中
  是否预览就绪

所以 AI SSE Client 最好暴露完整生命周期:

ts 复制代码
interface AiSseHandlers<TChunk> {
  onOpen?: () => void;
  onChunk?: (chunk: TChunk) => void;
  onDone?: () => void;
  onError?: (error: Error) => void;
  onClose?: (reason: CloseReason) => void;
  onRetry?: (meta: RetryMeta) => void;
}

不要只有一个:

ts 复制代码
onMessage

否则状态层会很难准确表达 UI。

十五、AI SSE Client 应该重点增强哪些能力

如果从普通 SSE Client 升级到 AI SSE Client,我会重点加这些能力:

txt 复制代码
runId / requestId
messageId / eventId
business-error 自定义事件
done 自定义事件
heartbeat
connect timeout
idle timeout
parser 注入
chunk buffer
手动 cancel
后端 run cancel
谨慎自动重连
Last-Event-ID / 断线恢复
消息去重
工具调用 id 合并
artifact 分流
reasoning 和 thought 分流
敏感字段过滤

其中我认为最重要的前五个是:

txt 复制代码
1. 事件分流
2. 错误分层
3. done 生命周期
4. 取消语义
5. 幂等和重连策略

这五个不清楚,AI SSE 迟早会出问题。

十六、一个推荐的 AI SSE 调用模型

我更推荐任务驱动的模型:

txt 复制代码
POST /ai/runs
  创建一次 AI run,提交 prompt、上下文、文件等复杂输入

GET /ai/runs/{runId}/events
  订阅这个 run 的 SSE 事件

POST /ai/runs/{runId}/cancel
  取消后端任务

GET /ai/runs/{runId}
  查询任务状态

前端链路可以是:

txt 复制代码
submit user input
  -> create run
  -> insert user message
  -> insert assistant placeholder
  -> subscribe run events
  -> parse event
  -> merge chunk into message
  -> done
  -> refresh artifact/build status

这样比:

txt 复制代码
GET /chat/stream?message=...

成熟很多。

它解决了:

txt 复制代码
长 prompt 不适合 URL
取消需要 runId
重连需要 runId
恢复需要 runId
状态查询需要 runId
工具调用归属需要 runId
产物归属需要 runId

十七、小结

普通 SSE Client 关注的是:

txt 复制代码
连接一个服务端事件流,并持续接收消息。

AI SSE Client 关注的是:

txt 复制代码
承载一次 AI 生成任务从开始、流式输出、工具调用、产物生成、错误处理到最终结束的完整生命周期。

所以 AI SSE 的关键不只是 EventSource,而是:

txt 复制代码
事件分流
高频渲染
错误分层
done 生命周期
取消语义
幂等重连
工具调用合并
产物分发
敏感数据处理
任务状态恢复

我会把这件事总结成一句话:

txt 复制代码
普通 SSE 是事件订阅,AI SSE 是任务编排。

只有按任务生命周期去设计,AI SSE Client 才不会在真实业务里变成一个到处漏水的 onmessage 回调。

相关推荐
KoPa13 分钟前
HeySmart:事件总线——异步解耦的艺术
前端·后端
金花顺14 分钟前
ndroid 音频系统:AudioTrack 源码深度解析(从 Java 构造到 Native 启动)
前端·架构
PedroQue9922 分钟前
@meng-xi/create-uni-app v1.0.0 正式发布
前端·uni-app
younuo365530 分钟前
广州网站搭建费用明细:域名、服务器与开发成本全解析
服务器·前端·github
恋猫de小郭38 分钟前
Flutter A2UI 深度解析,它是怎么提供动态生产力的,然后为什么 A2UI 不只是 Flutter
android·前端·flutter
snow@li41 分钟前
服务器运维:Alibaba Cloud Linux 4 LTS・Vue前端 Java 后端 K3S 部署 CICD 目录规范清单
linux·前端·vue.js
百变梦仔1 小时前
Codex 启动回复合格后,我会用三类证据验收前端改动
前端·github
the局外人1 小时前
别让 Codex 一口气写完整个前端:5 组 Skills,把页面、逻辑、测试和构建拆清楚
前端·人工智能·agent
honkun61 小时前
vue 表格组件 vxe-table 实实现专业记账凭证编辑表格与自动汇总
前端·javascript·vue.js