把多步串起来:Agent 前端编排的状态机与进度可视化
一、Agent 多步执行:从一次性回答到工具链编排
大模型能力升级后,业务对它的期待早已不是"问答"。某内部办公系统接入 Agent 后,产品希望用户一句话完成"查我下周的出差、对比三家航司、下单最便宜的、同步给我领导"。这条指令背后是四步工具调用:日历查询、航司搜索、订单创建、消息推送。
第一期上线时,前端只渲染了一个 loading 圆圈。结果用户在第二步等了 8 秒后失去耐心直接刷新。而后端的下单请求还在跑,库存被锁定却无人感知。运营排查了一下午才把脏订单清掉。
这事我见过太多团队栽进去------把 Agent 当成一次 fetch 调用。多步工具编排有它的工程复杂性:每步都可能失败、用户可能中途取消、中间产物需要可视化、某些步骤还要人工介入。前端不能用一个 isLoading 走天下,必须用状态机把每一步的边界与转移条件写死。
更隐蔽的是进度可见性。Agent 执行十秒以上时,用户的耐心会在第三秒开始消耗。若界面只显示"思考中",用户会误判为卡死。必须把"正在查日历"、"正在比价"这类步骤名实时展示出来,给用户可预期的等待感。
二、状态机驱动:从工具调用到 UI 进度的语义映射
Agent 多步任务的本质是状态机。每一步有 idle、running、success、failed、cancelled、awaiting-human 六种状态。状态之间的转移必须明确:running 可被用户中断转为 cancelled,failed 可被用户重试转回 running,awaiting-human 在用户确认后转为 running。
模型侧的工具调用是异步的,调用结果通过回包返回。前端要做的是把"模型决策"与"工具执行"两件事解耦。模型决定下一步调什么工具,前端把工具调用编排进状态机,工具执行完再把结果回灌给模型。这样模型只关心决策,前端只关心执行与展示。
进度可视化的关键是中间产物展示。每步 success 时把"查到的航班列表"、"比价结果"等数据快照到步骤对象里。UI 不必等全部完成,就能渲染已完成的步骤产出,用户能逐步看到 Agent 在做什么。
失败重试不能粗暴重来。整任务重跑会重复执行已成功步骤,造成副作用(如重复下单)。必须以 step 为粒度重试,仅重跑失败的那一步。这要求每个步骤是幂等的,或者带有去重 token。
人工介入口子要预留。某些步骤(如下单、转账)的副作用不可逆,必须在状态机里插入 awaiting-human,让用户确认后再继续。这一步是合规与风控的硬性要求,省不得。
综上,Agent 多步编排以进度可视化、step 粒度重试与 awaiting-human 人工介入三点为核心,共同保证任务的可观测与可恢复。
三、生产级代码:Agent 步骤编排器
下面给出一个可复用的编排器。它把每步建模为状态机节点,支持 AbortSignal 中断、step 粒度重试、awaiting-human 暂停。
typescript
// 步骤状态机的类型定义
export type StepStatus =
| 'idle' | 'running' | 'success'
| 'failed' | 'cancelled' | 'awaiting-human';
export interface Step<TInput = unknown, TOutput = unknown> {
id: string;
name: string; // UI 展示的步骤中文名
status: StepStatus;
input?: TInput;
output?: TOutput; // 中间产物,供 UI 渐进渲染
error?: string;
retries: number;
maxRetries: number;
// 真正的工具执行器:必须返回 Promise,支持外部中断
run: (input: TInput, signal: AbortSignal) => Promise<TOutput>;
// 是否需要人工确认(如下单、转账)
requireHumanConfirm?: boolean;
}
export class AgentOrchestrator {
private steps: Step[] = [];
private abortCtrl = new AbortController();
private listeners = new Set<(s: Step[]) => void>();
// 步骤 id → confirm resolver,用于挂起 awaiting-human 等待用户确认
private confirmResolvers = new Map<string, (v: boolean) => void>();
// 注册步骤序列:顺序固定,但每步可独立重试
use(steps: Step[]) {
this.steps = steps;
return this;
}
subscribe(fn: (s: Step[]) => void) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private emit() {
// 深拷贝快照,避免外部误改内部状态
const snap = JSON.parse(JSON.stringify(this.steps));
this.listeners.forEach((fn) => fn(snap));
}
// 执行整个任务:串行推进,遇 awaiting-human 暂停等待
async run(): Promise<{ ok: boolean; failedAt?: string }> {
for (const step of this.steps) {
if (this.abortCtrl.signal.aborted) {
step.status = 'cancelled';
this.emit();
return { ok: false, failedAt: step.id };
}
const result = await this.runStep(step);
if (!result.ok) return { ok: false, failedAt: step.id };
}
return { ok: true };
}
private async runStep(step: Step): Promise<{ ok: boolean }> {
step.status = 'running';
this.emit();
// 需要人工确认时,暂停并等待外部 resume 调用
if (step.requireHumanConfirm) {
step.status = 'awaiting-human';
this.emit();
const confirmed = await this.awaitConfirm(step);
if (!confirmed) {
step.status = 'cancelled';
this.emit();
return { ok: false };
}
step.status = 'running';
this.emit();
}
// 重试循环:受 maxRetries 限制,避免无限重试放大副作用
while (step.retries <= step.maxRetries) {
try {
const out = await step.run(step.input!, this.abortCtrl.signal);
step.output = out;
step.status = 'success';
this.emit();
return { ok: true };
} catch (err) {
if (this.abortCtrl.signal.aborted) {
step.status = 'cancelled';
this.emit();
return { ok: false };
}
step.retries++;
step.error = (err as Error).message;
// 指数退避,避免高频重试压垮下游工具
await this.backoff(step.retries);
}
}
step.status = 'failed';
this.emit();
return { ok: false };
}
private awaitConfirm(step: Step): Promise<boolean> {
// 暴露 resolve 给 UI 层,用户点击确认/拒绝时通过 resume 触发
return new Promise((resolve) => {
this.confirmResolvers.set(step.id, resolve);
});
}
// 外部调用:用户在 UI 上点击"确认继续"或"取消"
resume(stepId: string, confirmed: boolean) {
const r = this.confirmResolvers.get(stepId);
if (r) {
r(confirmed);
this.confirmResolvers.delete(stepId);
}
}
private backoff(retries: number) {
const delay = Math.min(1000 * 2 ** retries, 8000);
return new Promise((r) => setTimeout(r, delay));
}
// 中断整个任务:已 running 的步骤通过 AbortSignal 通知
cancel() {
this.abortCtrl.abort();
// 把所有挂起的人工确认一并拒绝,避免 Promise 永久悬挂
this.confirmResolvers.forEach((r) => r(false));
this.confirmResolvers.clear();
}
}
业务接线示例:
typescript
// 业务接线:把"查库存→下单→通知"三步注册进编排器
const agent = new AgentOrchestrator().use([
{
id: 'check-stock',
name: '查询库存',
status: 'idle', retries: 0, maxRetries: 2,
run: async (sku, signal) => {
// 超时与中断由 fetch 内置 signal 兜底
const res = await fetch(`/api/stock?sku=${sku}`, { signal });
if (!res.ok) throw new Error(`库存查询失败: ${res.status}`);
return res.json();
},
},
{
id: 'place-order',
name: '提交订单',
status: 'idle', retries: 0, maxRetries: 0, // 副作用不可逆,禁止自动重试
requireHumanConfirm: true,
run: async (input, signal) => {
const res = await fetch('/api/order', {
method: 'POST',
body: JSON.stringify(input),
signal,
});
if (!res.ok) throw new Error(`下单失败: ${res.status}`);
return res.json();
},
},
]);
const unsub = agent.subscribe((steps) => renderTimeline(steps));
const result = await agent.run();
unsub();
关键点三处。其一,状态以快照方式外发,UI 拿到的永远是不可变副本。其二,重试以 step 为粒度,副作用步骤必须 requireHumanConfirm,且 maxRetries 设为 0。其三,中断通过 AbortSignal 贯穿到工具层,并把挂起的 confirm 一并拒绝。
四、边界分析:状态机复杂度与人工介入的成本
状态机的最大代价是状态空间膨胀。每多一种状态,转移矩阵就指数级膨胀。六状态乘以 N 步骤,组合路径很快超过人力可覆盖的测试边界。必须配状态转移表做穷尽单测,否则上线后偶发分支难复现。
人工介入并非免费。awaiting-human 把同步任务拖成异步任务,前端要把整个执行上下文持久化到内存或 IndexedDB。用户切走再回来时,必须能恢复到中断点。某订单流程曾因未持久化上下文,用户刷新页面后订单卡在"待确认"再也无法推进,运营手工兜底了一周才把流程补全。
重试机制要警惕副作用放大。下单、转账这类不可逆工具,绝不能自动重试。maxRetries 必须设为 0,唯一兜底是人工介入。只有查询类工具才允许自动重试,且要配指数退避避免压垮下游。
进度可视化要克制信息量。把每步的中间产物全部展示,会让界面信息过载。应该只展示用户关心的字段(如"查到 3 条航班"),原始结构化数据折叠到详情抽屉里。否则用户在密集步骤流里会迷失重点。
适用边界:3 步以上、有副作用、需要人工确认的复杂任务收益最高。单步工具调用、纯查询类任务无需状态机,直接 await 即可。
五、总结
Agent 前端编排的核心是把每一步工具调用建模为状态机节点,用快照与中断信号贯穿 UI 与执行层。落地建议:第一,每步明确六状态枚举,禁止用单一 loading 走天下。第二,副作用步骤必须 requireHumanConfirm,maxRetries 设为 0。禁止自动重试不可逆操作。第三,重试以 step 为粒度,配指数退避避免压垮下游。第四,中间产物按用户视角裁剪展示,原始数据折叠到详情层。第五,awaiting-human 的上下文必须持久化,支持刷新后恢复。最终在执行可控性、用户可见性与副作用安全之间取得平衡。这条路在十步以内的业务编排下能跑通,回报是值得的。