把多步串起来:Agent 前端编排的状态机与进度可视化
把多步串起来Agent 前端编排的状态机与进度可视化一、Agent 多步执行从一次性回答到工具链编排大模型能力升级后业务对它的期待早已不是问答。某内部办公系统接入 Agent 后产品希望用户一句话完成查我下周的出差、对比三家航司、下单最便宜的、同步给我领导。这条指令背后是四步工具调用日历查询、航司搜索、订单创建、消息推送。第一期上线时前端只渲染了一个 loading 圆圈。结果用户在第二步等了 8 秒后失去耐心直接刷新。而后端的下单请求还在跑库存被锁定却无人感知。运营排查了一下午才把脏订单清掉。这事我见过太多团队栽进去——把 Agent 当成一次 fetch 调用。多步工具编排有它的工程复杂性每步都可能失败、用户可能中途取消、中间产物需要可视化、某些步骤还要人工介入。前端不能用一个isLoading走天下必须用状态机把每一步的边界与转移条件写死。更隐蔽的是进度可见性。Agent 执行十秒以上时用户的耐心会在第三秒开始消耗。若界面只显示思考中用户会误判为卡死。必须把正在查日历、正在比价这类步骤名实时展示出来给用户可预期的等待感。二、状态机驱动从工具调用到 UI 进度的语义映射Agent 多步任务的本质是状态机。每一步有 idle、running、success、failed、cancelled、awaiting-human 六种状态。状态之间的转移必须明确running 可被用户中断转为 cancelledfailed 可被用户重试转回 runningawaiting-human 在用户确认后转为 running。模型侧的工具调用是异步的调用结果通过回包返回。前端要做的是把模型决策与工具执行两件事解耦。模型决定下一步调什么工具前端把工具调用编排进状态机工具执行完再把结果回灌给模型。这样模型只关心决策前端只关心执行与展示。进度可视化的关键是中间产物展示。每步 success 时把查到的航班列表、比价结果等数据快照到步骤对象里。UI 不必等全部完成就能渲染已完成的步骤产出用户能逐步看到 Agent 在做什么。失败重试不能粗暴重来。整任务重跑会重复执行已成功步骤造成副作用如重复下单。必须以 step 为粒度重试仅重跑失败的那一步。这要求每个步骤是幂等的或者带有去重 token。人工介入口子要预留。某些步骤如下单、转账的副作用不可逆必须在状态机里插入 awaiting-human让用户确认后再继续。这一步是合规与风控的硬性要求省不得。综上Agent 多步编排以进度可视化、step 粒度重试与 awaiting-human 人工介入三点为核心共同保证任务的可观测与可恢复。三、生产级代码Agent 步骤编排器下面给出一个可复用的编排器。它把每步建模为状态机节点支持 AbortSignal 中断、step 粒度重试、awaiting-human 暂停。// 步骤状态机的类型定义 export type StepStatus | idle | running | success | failed | cancelled | awaiting-human; export interface StepTInput 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) PromiseTOutput; // 是否需要人工确认如下单、转账 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 Mapstring, (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): Promiseboolean { // 暴露 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(); } }业务接线示例// 业务接线把查库存→下单→通知三步注册进编排器 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 走天下。第二副作用步骤必须 requireHumanConfirmmaxRetries 设为 0。禁止自动重试不可逆操作。第三重试以 step 为粒度配指数退避避免压垮下游。第四中间产物按用户视角裁剪展示原始数据折叠到详情层。第五awaiting-human 的上下文必须持久化支持刷新后恢复。最终在执行可控性、用户可见性与副作用安全之间取得平衡。这条路在十步以内的业务编排下能跑通回报是值得的。