REACT · Vol.VII · LESSON 48 · AI 时代的前端进阶
Agent 前端:工具调用与状态机
Chat 与 Agent:一次 RPC 与一条工作流
第 46 篇我们拆完的 Chat 前端有个隐含假设:一次提问,一次回答——中间只有一条流式消息在动。Agent 打破了这个假设:你问一句,它自己干十步。先把两者摆在一张表里看清楚:
| 维度 | Chat(一问一答) | Agent(多步自主调用) |
|---|---|---|
| 交互轮次 | 一次请求,一次响应 | 一次目标,模型自己拆成多步执行 |
| 单次时长 | 秒级 | 十几秒到几分钟 |
| 前端要展示的 | 一条流式消息(第 45 篇) | 过程(思考 / 工具 / 结果)+ 最终结论 |
| 失败形态 | 请求失败,整条重来 | 中途某步失败,要能定位、能单独重试 |
| 风险 | 内容不当 | 真实世界副作用:下单、退款、发消息 |
后端视角一看就懂:Chat 是一次 RPC,Agent 是一条工作流。LLM 在「思考」和「调工具」之间反复横跳,期间要查订单库、调内部 API、比对结果,最后才给你答案。前端的工作随之从「渲染响应」升级为「渲染工作流」——这可比多画几个转圈动画深刻得多。
tool-call 回放:把黑盒过程搬上台面
先看 Agent 在后端是怎么跑的:用户提问 → 后端把对话历史和工具清单发给 LLM → LLM 不直接回答,返回 tool_calls(「我要查订单」)→ 后端执行真工具(查订单库、调内部 API)→ 结果回填进对话序列 → 再问 LLM → 它可能再调下一个工具……直到它认为可以回答了,才开始输出 delta。这条内部过程,用户原本完全看不见。
Agent 体验的核心,就是把这个黑盒过程「回放」给用户看:思考中 → 正在查询订单(勾)→ 正在比对库存(勾)→ 生成中 → 答案逐字出现。前端看到的是一条 SSE 事件流,照例先建模——可辨识联合,第 28 篇和第 45 篇的老朋友:
type AgentEvent =
| { type: "delta"; text: string } // 最终回答的流式片段(第 45 篇)
| { type: "tool_call"; id: string; name: string; args: unknown } // 模型要调工具了
| { type: "tool_result"; id: string; ok: boolean; summary: string } // 后端执行完毕
| { type: "done"; totalTokens: number }
| { type: "error"; message: string };
五种事件,对应五处 UI:delta → 正文逐字追加;tool_call → 时间线里新增一张「正在执行」的卡片;tool_result → 对应卡片打勾或变红;done → 收官;error → 错误横幅。事件流是输入,界面是输出——又回到第 1 篇那句话 UI = f(state),只是这次 state 要靠事件流喂出来。
你在后端早就见过这套架构:流程引擎每走一步发一条事件,监控大屏按事件更新节点状态,事后还能靠事件序列回放整个执行过程。前端这里做的事一模一样——给 Agent 装一块「监控大屏」,SSE 事件流就是那条 MQ。区别只在于:这块大屏的渲染介质是 React 组件树。
useReducer:给 Agent 装一台状态机
为什么不用 useState?因为 Agent 的状态是一伙的:阶段(phase)、工具步骤(steps)、正在生成的回答(answer),一个事件常常要同时改好几处。拆成多个 useState,就会出现「delta 都到了、phase 还停在 tool_running」这种互相踩脚的中间态。useReducer 把「事件 → 状态迁移」集中成一张表——这正是状态机的教科书写法。先定义状态与动作:
interface ToolStep {
id: string;
name: string; // 工具名,如 queryOrder
status: "running" | "ok" | "fail";
summary?: string; // 结果一句话摘要,折叠时展示
}
interface AgentState {
phase: "idle" | "thinking" | "tool_running" | "streaming" | "done" | "error";
steps: ToolStep[]; // 时间线卡片的数据源
answer: string; // 最终回答,流式追加
}
type AgentAction =
| { type: "SUBMIT" }
| { type: "TOOL_CALL"; id: string; name: string }
| { type: "TOOL_RESULT"; id: string; ok: boolean; summary: string }
| { type: "DELTA"; text: string }
| { type: "DONE" }
| { type: "ERROR" };
然后是迁移表本体——整个 Agent 界面的「宪法」:
function reducer(state: AgentState, action: AgentAction): AgentState {
switch (action.type) {
case "SUBMIT":
return { phase: "thinking", steps: [], answer: "" }; // 全量重置,进入思考
case "TOOL_CALL":
return {
...state,
phase: "tool_running",
steps: [...state.steps, { id: action.id, name: action.name, status: "running" }],
};
case "TOOL_RESULT": {
const status: ToolStep["status"] = action.ok ? "ok" : "fail";
const steps = state.steps.map((s) =>
s.id === action.id ? { ...s, status, summary: action.summary } : s
);
return { ...state, phase: "thinking", steps }; // 一步结束,回 thinking 等模型下一步
}
case "DELTA":
return { ...state, phase: "streaming", answer: state.answer + action.text };
case "DONE":
return { ...state, phase: "done" };
case "ERROR":
return { ...state, phase: "error" };
}
}
注意 switch 故意不写 default:六个动作全覆盖,将来少写一个分支,TS 会盯着返回类型报错——可辨识联合的福利。SSE 事件到达后,一个薄薄的适配层把 AgentEvent 翻译成 action 再 dispatch,单向流转:
eventSource.onmessage = (e) => {
const event: AgentEvent = JSON.parse(e.data);
switch (event.type) {
case "delta": return dispatch({ type: "DELTA", text: event.text });
case "tool_call": return dispatch({ type: "TOOL_CALL", id: event.id, name: event.name });
case "tool_result": return dispatch({ type: "TOOL_RESULT", id: event.id, ok: event.ok, summary: event.summary });
case "done": return dispatch({ type: "DONE" });
case "error": return dispatch({ type: "ERROR" });
}
};
这张图你在后端画过无数次:Spring StateMachine、审批流、订单状态机。有限状态、事件驱动迁移、非法迁移直接不发生。区别只是:后端状态机管业务流程,前端状态机管「界面上该显示什么」。有了它,复杂度从「散落各处的布尔标志」降维成「查一张迁移表」——转圈动画该出现在哪、什么时候停,全都由 phase 一个字段说了算。
时间线卡片与「人在回路」:把过程摊开给用户看
状态机有了,渲染层只剩两件事:把 steps 画成时间线,把危险步骤交还给人。每个工具步骤渲染成一张默认折叠的卡片——running 转圈、ok 打勾、fail 给重试按钮:
const STEP_LABEL: Record<string, string> = {
queryOrder: "正在查询订单",
checkStock: "正在比对库存",
createRefund: "正在创建退款单",
};
function StepCard({ step, onRetry }: { step: ToolStep; onRetry: (id: string) => void }) {
const [open, setOpen] = useState(false); // 默认折叠,只留一行摘要
return (
<div className="step-card" data-status={step.status}>
<button onClick={() => setOpen(!open)}>
<StatusIcon status={step.status} />
{/* running 转圈 / ok 打勾 / fail 叉号 */}
<span>{STEP_LABEL[step.name] ?? step.name}</span>
{step.status === "fail" && (
<span onClick={(e) => { e.stopPropagation(); onRetry(step.id); }}>重试</span>
)}
</button>
{open && <pre className="step-detail">{step.summary}</pre>}
</div>
);
}
时间线是「过程可视化」,还差半块拼图:危险工具不能由模型一句话就执行。退款、删除、对外发消息,必须用户亲自拍板——「人在回路」(human-in-the-loop)。正确的建模方式:它是状态机里的一等状态,不是 UI 上一个弹窗。事件协议给 tool_call 加 needsConfirm 标记,reducer 收到后分流:
// AgentAction 的 TOOL_CALL 扩展:{ type: "TOOL_CALL"; id; name; needsConfirm: boolean }
// AgentState 的 phase 扩展:"awaiting_confirmation"
// ToolStep 的 status 扩展:"waiting_confirm"
case "TOOL_CALL": {
const step: ToolStep = {
id: action.id,
name: action.name,
status: action.needsConfirm ? "waiting_confirm" : "running",
};
return {
...state,
phase: action.needsConfirm ? "awaiting_confirmation" : "tool_running",
steps: [...state.steps, step],
};
}
phase === "awaiting_confirmation" 时,那张卡片渲染出「确认执行 / 取消」按钮;用户点击后前端调确认接口,后端才真正执行工具,tool_result 事件继续推进状态机。为什么必须建状态而不是弹窗?弹窗是视图层的临时物:刷新页面它就没了,两个标签页各弹各的,超时没人管。状态是可恢复、可同步、可测试的:刷新后从持久化恢复出 awaiting_confirmation(第 22 篇的功课),超时由后端兜底——到点回一条 ok: false 的 tool_result,状态机自然流转到失败。
流程实例停在 WAITING_APPROVAL,审批动作触发迁移,超时未审自动驳回——没人把「审批」做成一个前端弹窗就完事,因为重启、超时、并发审批都得靠状态兜底。Agent 的确认节点就是审批节点,用户就是那个审批人。你后端攒下的工作流经验,在这里原封不动地复用。
收官:Agent 前端三件套
把事件、动作、状态、界面四层的对应关系收进一张速查表,对着它就能写出完整的 Agent 界面:
| SSE 事件 | dispatch 动作 | 状态迁移 | 界面表现 |
|---|---|---|---|
tool_call | TOOL_CALL | thinking → tool_running / awaiting_confirmation | 时间线新卡片转圈,或显示「等待确认」 |
tool_result | TOOL_RESULT | steps 更新,回 thinking | 卡片打勾或变红,失败给重试 |
delta | DELTA | → streaming,answer 追加 | 结论逐字出现(第 45 篇) |
done | DONE | → done | 定格答案,展示 token 用量 |
error | ERROR | → error | 错误横幅 + 整体重试入口 |
一句话收束本篇,也是卷七前半程的方法论总纲:Agent 前端 = 状态机 + 事件流 UI + 人在回路。状态机保证界面永远处于合法状态,事件流保证后端每一步都被看见,人在回路保证危险的副作用有人签字。三者齐了,多步自主执行才敢放到用户面前。
- Agent ≠ Chat:多步、长耗时、有真实副作用,前端从「渲染响应」升级为「渲染工作流」。
- SSE 事件建模成可辨识联合(delta / tool_call / tool_result / done / error),事件流是输入,状态是输出。
- useReducer 集中管理「事件 → 迁移」,拒绝散落的 setState 互相踩脚;switch 不写 default,让 TS 盯着穷举。
- 工具步骤渲染成可折叠时间线卡片:running 转圈、ok 打勾、fail 重试,黑盒过程全程可见。
- 危险操作建模成 awaiting_confirmation 一等状态而非弹窗——「人在回路」要可恢复、可超时、可测试。
本章回顾
这一篇把第 45、46 篇的「单条消息流式」升级成了「多步执行的编排」:Agent 的内部过程被 SSE 事件流暴露出来,事件建模成可辨识联合,useReducer 驱动一台 idle → thinking → tool_running → streaming → done 的状态机,工具步骤摊开成时间线卡片,危险动作交还给人拍板。第 43 到 48 篇,AI 时代前端工程师的武器库到此配齐:转型路线、服务端组件、流式 UI、集成实践、结对提效、Agent 界面。技术主线只剩最后一站——把 React、后端、AI 全部串进一个真实项目。下一篇第 49 篇:从 React 到全栈·项目打通,全书压轴,正式开始。
Comments · 评论