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 回调。