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

0 阅读13分钟

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 更像是在订阅一个事件频道:

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

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

AI SSE 不一样。

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

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

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

所以普通 SSE 解决的是:

我怎么稳定接收服务端事件?

AI SSE 解决的是:

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

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

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

先放一张整体对比表:

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

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

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

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

{"message":"你有一条新通知"}

前端收到后展示即可。

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

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

如果你写成:

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

那最终用户可能看到:

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

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

AI SSE 必须先分流:

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

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

export interface AiStreamChunk {
  content?: string;
  reasoningContent?: string;
  thought?: AgentThought;
  artifact?: ArtifactEvent;
}

或者更严格一点:

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 可能几秒推一条:

订单状态更新
通知来了
日志新增一行

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

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

function onChunk(chunk: AiStreamChunk) {
  message.content += chunk.content ?? '';
}

在 Vue 或 React 中可能导致:

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

更好的做法是做 token buffer。

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

let textBuffer = '';
let scheduled = false;

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

  if (scheduled) {
    return;
  }

  scheduled = true;

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

也可以用时间窗口:

const flushInterval = 50;

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

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

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

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

比如通知流断了:

重连后继续接通知

构建日志断了:

重连后继续看日志

但 AI SSE 不一定。

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

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

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

GET /ai/chat?message=帮我生成页面

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

结果是:

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

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

我一般会这样分:

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

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

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

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

重新订阅同一个 run 的事件流

例如:

POST /ai/runs
  -> { runId }

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

断线后重连:

GET /ai/runs/{runId}/events?lastEventId=1001

而不是重新创建一个 run。

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

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

eventSource.close();

通常就够了。

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

前端不再接收消息

不一定代表:

后端停止生成

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

1. 前端关闭 SSE 连接
2. 后端取消当前 run / task / model call

更成熟的接口应该有:

POST /ai/runs/{runId}/cancel

否则会出现这种情况:

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

所以 AI SSE Client 里要区分:

manual close
done close
business-error close
network-error close
timeout close

不要把手动取消显示成:

生成失败

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

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

很多人会以为:

eventSource.onerror = (event) => {
  console.log(event.data);
};

可以拿到后端错误。

通常不行。

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

HTTP status
response body
后端错误 JSON

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

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

这些错误不能只提示:

SSE 连接中断

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

event: business-error
data: {"code":"MODEL_TIMEOUT","message":"模型响应超时"}

前端监听:

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

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

也就是说:

business-error 处理业务失败
onerror 处理连接失败

这两条线一定要分开。

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

普通 SSE 可能是无限流:

通知中心
股票价格
在线状态

这种流不一定有 done。

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

开始生成
持续输出
保存结果
生成完成

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

event: done
data: {"type":"done","d":""}

前端收到后:

eventSource.addEventListener('done', () => {
  eventSource.close();
  onDone();
});

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

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

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

open -> streaming -> done -> closed

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

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

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

tool_call pending
tool_call success

如果后端这样发:

{
  "type": "thought",
  "d": {
    "status": "pending",
    "title": "调用工具:writeFile"
  }
}

完成时又这样发:

{
  "type": "thought",
  "d": {
    "status": "success",
    "title": "工具完成:writeFile"
  }
}

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

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

调用工具:writeFile
工具完成:writeFile

成熟协议应该这样:

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

前端根据 id 合并:

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 更重视:

runId
messageId
toolCallId
artifactId
eventId

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

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

AI 正文常常是 Markdown。

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

例如当前只收到:

```ts
const a =

下一秒才收到:

 1;
```

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

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

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

正在增长的不完整文档

常见策略:

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

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

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

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

answer
reasoning
thinking
thought
chain-of-thought
tool trace

工程上一定要分清:

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

我更推荐把 Thought 理解成:

Agent 工具调用可视化

而不是:

模型完整思维链

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

调用了哪个工具
参数是什么
是否成功
输出是什么

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

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

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

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

只能 GET
不能自定义 request body
不能自由设置 Authorization header

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

new EventSource('/api/notifications')

但 AI 请求经常包含:

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

如果全部放在 query:

/api/chat/stream?message=很长很长的用户输入...

会有这些风险:

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

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

POST /ai/runs
body: { message, files, options }
  -> { runId }

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

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

fetch + ReadableStream

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

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

AI 任务是有后端状态的。

前端 SSE 断了,只能说明:

前端收不到事件了

不代表:

后端任务结束了

可能发生这种情况:

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

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

还需要:

run status 查询
messages 查询
artifact 查询
build status 查询

例如:

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 可能只有:

connected
disconnected

AI SSE 至少会出现:

idle
creating_run
connecting
streaming
tool_running
saving
building
done
error
cancelled

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

输入框
  能不能发送
  能不能取消

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

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

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

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

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

不要只有一个:

onMessage

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

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

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

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 分流
敏感字段过滤

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

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

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

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

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

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

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

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

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

前端链路可以是:

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

这样比:

GET /chat/stream?message=...

成熟很多。

它解决了:

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

十七、小结

普通 SSE Client 关注的是:

连接一个服务端事件流,并持续接收消息。

AI SSE Client 关注的是:

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

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

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

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

普通 SSE 是事件订阅,AI SSE 是任务编排。

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