把多步串起来:Agent 前端编排的状态机与进度可视化

把多步串起来: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 的上下文必须持久化,支持刷新后恢复。最终在执行可控性、用户可见性与副作用安全之间取得平衡。这条路在十步以内的业务编排下能跑通,回报是值得的。

相关推荐
小弥儿2 分钟前
Firecrawl:把整个网页变成 AI 可查询的数据库
数据库·人工智能·学习
探物 AI3 分钟前
yolo检测中的激活函数19:ReLU激活函数 (Rectified Linear Unit)
网络·人工智能·深度学习·yolo
晓天衡宇•评测社区4 分钟前
大语言模型 8 月榜单更新:Claude Opus 5 登顶,Gemini 3.6-Flash 与 DeepSeek-V4 Flash 展现差异化优势
大数据·人工智能
dunge20268 分钟前
2026 ChatGPT Plus / Pro + Codex 实战:从 0 搭一套 AI 编程项目模板,AGENTS.md + Git + 测试一次配好
人工智能·git·chatgpt
不爱土豆唯爱马铃薯8 分钟前
有些创意,差一个声音才完整
人工智能
小淮AI9 分钟前
论文AI生成痕迹检测工具概况与选型参考
人工智能
CCYe、10 分钟前
Agent如何接入API?(包含work Buddy、Trae、Claude等)
人工智能
为美好的生活献上中指11 分钟前
Spring AI Advisor 深度实战:构建严谨的 AI Agent 拦截链
java·人工智能·spring·advisor·aiagent
aiqianzhan11 分钟前
采购数字化选型:智慧采购平台五维对照与常见路线
大数据·人工智能
云空12 分钟前
《软件专业完整学习路线图(本科4年+自学通用版,2026就业向)》
人工智能·科技·学习·计算机·编程·软件