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

相关推荐
硅谷秋水1 小时前
PhyGround:生成式世界模型中的物理推理基准测试
人工智能·深度学习·机器学习·计算机视觉·语言模型
深海鱼肝油ya1 小时前
基于FastAPI的AI智能体Web系统构建(二)
人工智能·fastapi·python开发·异步框架·agent开发
TheBestRucy1 小时前
RAG知识库问答系统落地:从向量检索到上下文增强的全链路实践
人工智能·python·langchain·aigc·交互
TechEdu2026062 小时前
[人工智能]生成式AI开源生态:库、工具与工作流
人工智能·ai
Hui Baby2 小时前
大模型微调完整分类
人工智能·机器学习
吐了啊取名字太难2 小时前
美颜系统AI修图本地跑并支持Mac、win、安卓、iOS不卡顿
android·人工智能·windows·数码相机·mac·ai编程
coder_zrx2 小时前
大语言模型训练范式:从 GPT 到 Llama 的 RLHF 演进
人工智能·深度学习