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
需要诚实说明的是:在我当前检查代码的环境中,该项目的 pytest 和 vitest 依赖没有安装,因此我只完成了源码与测试契约核对,没有把这两条命令包装成"本机已通过"。
这本身也是工程实践的一部分:没有执行成功的验证,就不能写成绿灯。
九、前端转 AI 全栈,真正要补的能力
从这个项目回看,前端开发者并不需要先把所有后端框架学一遍。更有效的路线是围绕一条完整用户链路补能力:
- 🌊 能解析和管理结构化 SSE,而不只是显示流式文字。
- 🧭 能用事件游标完成断线续传和状态重建。
- 🧱 能把 Agent 状态映射为清晰的前端状态机。
- ✋ 能为重要工具设计人在环审批。
- 💾 理解为什么暂停状态必须持久化。
- 🔐 理解幂等、授权和重复执行风险。
- ✅ 能用端到端证据证明任务真的完成。
当你能独立完成这些能力时,你做的就不再是一个"套模型接口的聊天页面",而是一个真正具备运行边界的 AI 产品。
写在最后
AI 时代并没有让前端变得不重要,恰恰相反,模型能力越强,前端越需要把不可预测的执行过程变成用户能够理解、控制和恢复的产品体验。
聊天框只是入口。
事件流决定体验是否连续,审批流决定系统是否可信,持久化和幂等决定它能不能安全地跑到生产环境。
下一步,我准备继续把这条链路向"成本可见、失败可解释、结果可验收"扩展。对于想转 AI 应用工程的前端同学,这些能力会比再背一套组件 API 更接近真正的岗位需求。