前端转 AI 全栈:别只做聊天框,SSE 与审批流才是分水岭

OK,OK,大家好,欢迎大家来到大鹏 AI 教育,我是张大鹏。

最近不少前端同学都在研究 AI 应用开发。大家最先做出来的作品通常很相似:左边会话列表,中间聊天窗口,底部输入框,再接一个大模型接口。

这个 Demo 用一两天就能完成,但它距离真正可上线的 AI 产品还有一段很长的路。

我最近在检查一个 React 19 + TypeScript 的 AI 助手项目时,越来越确定一件事:

前端转 AI 全栈的分水岭,不是会不会画聊天框,而是能不能处理流式事件、断线恢复、工具审批和一致性。

普通 Web 应用通常由用户点击触发一个短请求,服务端返回结果,页面更新结束。Agent 应用却可能持续运行几十秒甚至几分钟,中间还会调用搜索、数据库或外部设备。

一次回答不再是一个字符串,而是一条不断变化、可能暂停、可能恢复的事件流。

这篇文章就从真实代码出发,拆解一个可上线的 AI 前端至少要补齐哪几层能力。

一、为什么"打字机效果"不等于流式架构

很多 AI 聊天 Demo 的流式效果只是把服务端返回的文本切成小块,逐字追加到页面:

ts 复制代码
const reader = response.body?.getReader();
const decoder = new TextDecoder('utf-8');

while (reader) {
  const { done, value } = await reader.read();
  if (done) break;
  setAnswer((text) => text + decoder.decode(value));
}

这段代码可以实现视觉上的"边生成边显示",但还无法支持真实 Agent。

因为真实流里不只有文本,还可能出现:

  • 💬 answer.delta:新增的一段回答。
  • 🔧 tool.call:模型准备调用工具。
  • tool.approval.required:重要操作等待用户审批。
  • tool.result:工具执行完成。
  • 📚 research.progress:研究任务的阶段进度。
  • 🛑 error:当前任务失败。
  • 🏁 end:整轮消息结束。

如果前端把所有数据都当成字符串,后续就只能继续写条件判断,最终很快变成一套难以维护的聊天框补丁。

正确的做法,是先把网络字节流转换成有类型的事件,再让不同状态模块消费事件。

二、第一层:用 fetch + ReadableStream 读取 SSE

项目没有直接使用最简单的 EventSource,而是使用 fetch 读取 SSE。

原因很现实:业务需要携带认证信息、主动取消请求,还要统一处理连接状态。

连接请求大致如下:

ts 复制代码
const response = await fetch(url, {
  headers: {
    Accept: 'text/event-stream',
    Authorization: `Bearer ${token}`,
  },
  signal: controller.signal,
});

if (!response.ok || !response.body) {
  throw new Error(`SSE connection failed: ${response.status}`);
}

随后使用 ReadableStream 和 UTF-8 解码器持续读取:

ts 复制代码
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';

while (!signal.aborted) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  // 按空行切分完整 SSE record,半条消息继续留在 buffer
}

这里有一个很容易被忽略的细节:一次 reader.read() 不保证正好得到一条完整事件。

网络层可能把一条事件拆成两段,也可能把多条事件合并在同一个数据块里。因此前端必须保留 buffer,只消费已经完整结束的记录。

SSE 记录不只有 data

一条可恢复的 SSE 通常包含:

text 复制代码
id: 42
event: tool.approval.required
data: {"conversation_id":"...","call_id":"call-1"}

前端解析后应得到一个统一对象:

ts 复制代码
type SSEEvent = {
  id?: string;
  type: string;
  data: unknown;
};

接下来再通过中央分发器交给 Redux 或其他状态容器:

ts 复制代码
switch (event.type) {
  case 'tool.approval.required':
    dispatch(addPendingApproval(event));
    break;
  case 'tool.approval.cleared':
    dispatch(resolveToolApproval(event));
    break;
  default:
    dispatch(sseEventReceived(event));
}

这样,聊天内容、审批通知、研究进度和系统状态就不必全部塞在同一个组件中。

三、第二层:断线以后从哪里继续

能够自动重连,还不等于能够恢复。

如果连接中途断开,前端重新请求同一个地址,却没有告诉服务端自己已经收到哪里,就可能发生两种情况:

  • 🔁 从头重放,页面出现重复内容和重复通知。
  • 🕳️ 只接收新内容,断线期间的事件永久丢失。

解决这个问题需要事件游标。

前端每收到一条带 id 的事件,就持久化最新游标:

ts 复制代码
onEvent(record);

if (record.id) {
  dispatch(sseLastEventIdAdvanced(record.id));
}

下一次连接时,把游标传给服务端:

ts 复制代码
const params = new URLSearchParams();

if (lastEventId) {
  params.set('last_event_id', lastEventId);
}

fetch(`/api/messages/${messageId}/events?${params}`);

项目把游标放在查询参数中,而不是自定义请求头里,是为了让跨域 GET 保持简单请求,避免额外的预检问题。

服务端同时兼容标准 Last-Event-ID 请求头和查询参数,然后从事件日志中读取游标之后的数据。

这时,SSE 才从"打字机动画"升级为真正的可恢复传输层。

事件已经被清理怎么办

事件日志不可能无限保存。如果客户端离线太久,它保存的游标可能已经落在保留窗口之外。

服务端会返回类似 backlog.truncated 的事件。前端收到后不能继续相信旧游标,而应该:

  • 🧹 清除本地 Last-Event-ID
  • 🔄 重新获取当前完整状态。
  • 🚫 避免继续从一个已经不存在的位置重连。

这类"游标失效"分支,往往比正常连接更能体现一个 AI 产品是否真正考虑过生产环境。

四、第三层:工具调用不能默认全部执行

Agent 与普通聊天机器人的最大差别,是它不只生成文字,还可能执行动作。

查询天气和读取公开文档风险较低,但修改数据库、发送消息、删除文件或者操作外部设备,不能因为模型生成了参数就立即执行。

人在环审批的基本流程是:

text 复制代码
模型请求工具
  ↓
后端判断该动作需要审批
  ↓
保存待审批状态
  ↓
发送 tool.approval.required
  ↓
前端展示参数和风险
  ↓
用户批准或拒绝
  ↓
沿原会话恢复 Agent

前端提交的不是一句自然语言"我同意",而是结构化决定:

ts 复制代码
type ToolAction = {
  call_id: string;
  decision: 'approved' | 'denied';
  comment?: string;
};

批准和拒绝必须绑定具体 call_id,否则当一轮对话同时出现多个工具时,系统无法判断用户到底批准了哪个动作。

五、第四层:暂停状态必须写入数据库

只在内存中保存待审批工具,是一个很危险的实现。

用户可能几分钟后才决定,期间服务可能重启、扩容或把下一次请求路由到另一台实例。如果审批状态只存在某个 Python 对象中,用户点击"批准"时,原任务已经找不到了。

项目使用 pending_tool_state 保存:

  • 🆔 会话 ID 和用户 ID。
  • 🧠 继续推理所需的消息上下文。
  • 🔧 待处理工具调用及其参数。
  • 🧰 当前可用工具和 Schema。
  • 🤖 Agent 配置。
  • ⏰ 创建时间、过期时间和恢复状态。

保存时以"会话 + 用户"为唯一边界:

sql 复制代码
INSERT INTO pending_tool_state (...)
VALUES (...)
ON CONFLICT (conversation_id, user_id)
DO UPDATE SET
  pending_tool_calls = EXCLUDED.pending_tool_calls,
  status = 'pending';

用户提交决定后,系统先把状态从 pending 原子地改成 resuming

sql 复制代码
UPDATE pending_tool_state
SET status = 'resuming',
    resumed_at = NOW()
WHERE conversation_id = :conversation_id
  AND user_id = :user_id
  AND status = 'pending';

只有一个请求能够成功领取这次恢复权,从而避免用户连点两次按钮或网络重试导致工具重复执行。

任务成功结束后再删除待审批记录;如果恢复过程中进程异常,后台清理任务可以根据时间把卡住的状态重新整理。

六、审批 UI 不是两个按钮那么简单

一个可信的审批卡片,至少应该让用户看到:

  • 🏷️ 工具名称和动作类型。
  • 📦 即将提交的关键参数。
  • ⚠️ 是否会写数据、调用外部系统或产生费用。
  • 📝 可选的批准/拒绝说明。
  • ⏳ 当前状态:等待、恢复中、已批准、已拒绝或已失效。

"批准"按钮点击后也不能立即从页面消失。

更合理的状态变化是:

text 复制代码
awaiting_approval
  ↓
submitting_decision
  ↓
resuming
  ↓
completed / failed

如果服务端后来发送 tool.approval.cleared,前端还要清理旧通知,避免用户从历史通知中再次操作一个已经失效的审批。

这也是为什么审批状态应该进入全局事件系统,而不是只保存在当前聊天气泡的局部 useState 中。

七、幂等键是最后一道保险

即使数据库能够原子领取待审批状态,前端提交写操作时仍然应该携带幂等键:

ts 复制代码
await fetch('/stream', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': requestId,
  },
  body: JSON.stringify({
    conversation_id: conversationId,
    tool_actions: actions,
  }),
});

它主要解决的是"不知道上一次请求是否已经到达服务端"的灰色状态。

比如用户点击批准后网络立即断开。前端没有收到响应,但服务端可能已经开始执行。如果前端重新生成一个请求 ID 再提交,就可能造成重复操作。

正确做法是:同一个逻辑动作重试时复用同一个幂等键。

八、测试应该验证什么

这个项目已经把流式 UX 和审批恢复整理成独立测试边界,重点不是"页面有没有显示几个字",而是下面这些契约:

  • 🧪 SSE 每个非空行只能是 data:id:event:
  • 🏁 流最终能够收到明确的 end 事件。
  • 💾 会话数据真实写入数据库。
  • ✋ 待审批记录可以从 pending 进入 resuming
  • 🧹恢复成功后,pending_tool_state 被清理。
  • 📴 用户中途取消流,不留下孤儿审批状态。
  • 🔄 同一会话随后仍能继续发起请求。

对应的验证入口包括:

powershell 复制代码
# 后端审批、事件重放与会话恢复
python -m pytest -q `
  tests/test_tool_approval.py `
  tests/test_event_replay.py `
  tests/test_continuation.py

# 前端事件流和事件分发
cd frontend
npm test -- `
  src/events/eventStreamClient.test.ts `
  src/events/dispatchEvent.test.ts

需要诚实说明的是:在我当前检查代码的环境中,该项目的 pytestvitest 依赖没有安装,因此我只完成了源码与测试契约核对,没有把这两条命令包装成"本机已通过"。

这本身也是工程实践的一部分:没有执行成功的验证,就不能写成绿灯。

九、前端转 AI 全栈,真正要补的能力

从这个项目回看,前端开发者并不需要先把所有后端框架学一遍。更有效的路线是围绕一条完整用户链路补能力:

  • 🌊 能解析和管理结构化 SSE,而不只是显示流式文字。
  • 🧭 能用事件游标完成断线续传和状态重建。
  • 🧱 能把 Agent 状态映射为清晰的前端状态机。
  • ✋ 能为重要工具设计人在环审批。
  • 💾 理解为什么暂停状态必须持久化。
  • 🔐 理解幂等、授权和重复执行风险。
  • ✅ 能用端到端证据证明任务真的完成。

当你能独立完成这些能力时,你做的就不再是一个"套模型接口的聊天页面",而是一个真正具备运行边界的 AI 产品。

写在最后

AI 时代并没有让前端变得不重要,恰恰相反,模型能力越强,前端越需要把不可预测的执行过程变成用户能够理解、控制和恢复的产品体验。

聊天框只是入口。

事件流决定体验是否连续,审批流决定系统是否可信,持久化和幂等决定它能不能安全地跑到生产环境。

下一步,我准备继续把这条链路向"成本可见、失败可解释、结果可验收"扩展。对于想转 AI 应用工程的前端同学,这些能力会比再背一套组件 API 更接近真正的岗位需求。

相关推荐
机器人落地派1 小时前
机器人项目变更闭环检查表:别只写“已经改了”
人工智能·机器人·人形机器人·机器人落地·评审
小园子的小菜1 小时前
从Prompt到Loop:AI工程化四层范式完全指南(Prompt/Context/Harness/Loop)
人工智能·prompt
canonical-entropy1 小时前
Mission Driver:Loop Engineering 的一种通用参考实现
大数据·人工智能·ai-agent·可逆计算·nop平台·harness
cn分享汇1 小时前
启锐H20照片打印机,随时实地轻松打印
人工智能
Python私教1 小时前
AI Agent 为什么总在最后一步失败?从启动异常到可恢复工作流
人工智能
武子康1 小时前
生产环境的模型路由不是一次难度分类:从硬约束可行域到状态检查点升级
人工智能·llm·agent
m沐沐1 小时前
【计算机视觉】OpenCV 物体跟踪——原理、算法与CSRT跟踪器实战
人工智能·python·深度学习·opencv·算法·计算机视觉·人脸识别
rain_sxr1 小时前
一个模型打天下:多模型路由前端的策略层与降级兜底
人工智能
墨舟的AI笔记1 小时前
前端 Prompt 工程化:模板版本管理与 A/B 评测的工程闭环
人工智能