阅读视图

发现新文章,点击刷新页面。

前端开发者做多步 Agent:别让 AI 边想边乱跑,用 Plan-Act-Observe 稳住 4 步任务

作者:前端转 AI 深度实践者

【省流助手/核心观点】:多步 Agent 最怕的不是不会调用工具,而是没有计划地乱调用工具。一个更可靠的 Agent 应该遵循 Plan-Act-Observe:先把任务拆成结构化步骤,再执行当前步骤,观察工具结果,把结果写回计划,并根据观察决定下一步。对前端开发者来说,这很像把复杂交互拆成流程节点:每一步有目标、有状态、有输入输出、有失败处理,而不是把所有逻辑塞进一个巨大的 handleUserInput


第 25 篇我们做了一个最小 Agent Loop。

它已经能完成这样的闭环:

用户输入
-> 模型判断是否调用工具
-> 程序执行工具
-> 工具结果回到上下文
-> 模型生成最终回答

这对简单问题已经够用。

比如:

帮我查一下订单 A1001 到哪了。

Agent 调一次 getOrderStatus,再组织答案,就能完成任务。

但真实用户不会总是问这么简单的问题。

他们更可能问:

帮我查一下订单 A1001 的物流,如果还没送达,再看一下售后政策,告诉我能不能申请延迟补偿。

这个问题突然变成了多步任务:

  1. 查订单状态。
  2. 判断是否送达。
  3. 如果没送达,查延迟补偿政策。
  4. 结合订单和政策给出建议。

这时候,如果 Agent 只是“边想边跑”,很容易跑偏。

1. 痛点:没有计划的 Agent,就像没看需求就开写代码

前端开发者应该很熟悉这种场景:

需求还没拆清楚,就开始写组件。

写着写着发现:

  • 状态放错地方了。
  • 接口顺序不对。
  • 错误态没处理。
  • 中间结果没有保存。
  • 最后发现第一步设计就错了。

多步 Agent 也是一样。

如果没有计划,它可能会:

  • 先查政策,再查订单,顺序反了。
  • 查完订单后忘记判断是否送达。
  • 明明订单已签收,还继续查延迟补偿。
  • 工具失败了还继续往下走。
  • 最终回答时说不清依据。

所以多步 Agent 的第一件事不是“多调几个工具”,而是先把任务拆清楚。

这就是 Plan。

2. 错误做法:让模型每一步临场发挥

一种常见写法是把所有控制权交给模型:

async function runFreeAgent(userInput: string) {
  let context = userInput;

  for (let i = 0; i < 5; i++) {
    const output = await llm.chat(`
你是一个自主 Agent,请根据当前上下文决定下一步。

上下文:
${context}
`);

    const toolResult = await runTool(output.toolCall);
    context += JSON.stringify(toolResult);
  }

  return context;
}

这段代码看起来很“自主”,但工程上很难维护:

  1. 不知道任务一开始被拆成了几步。
  2. 不知道当前执行到哪一步。
  3. 不知道某一步失败后该停还是继续。
  4. 不知道哪些步骤应该被跳过。
  5. 最终回答很难追溯依据。

多步 Agent 不是越自由越好。

真正可交付的系统,要让每一步都能被看见、被控制、被复盘。

3. 正确做法:先把任务变成结构化计划

Plan-Act-Observe 可以翻译成:

Plan:先拆解任务
Act:执行当前步骤
Observe:记录结果,并影响后续步骤

先定义一个计划步骤:

type StepStatus =
  | "pending"
  | "running"
  | "done"
  | "skipped"
  | "failed";

type PlanStep = {
  id: string;
  goal: string;
  toolName: string;
  args: Record<string, unknown>;
  status: StepStatus;
  observation?: unknown;
  error?: unknown;
  skipReason?: string;
};

对刚才那个用户问题,一个最小计划可以长这样:

const plan: PlanStep[] = [
  {
    id: "step_1",
    goal: "查询订单 A1001 的物流状态",
    toolName: "getOrderStatus",
    args: { orderId: "A1001" },
    status: "pending"
  },
  {
    id: "step_2",
    goal: "查询延迟送达补偿政策",
    toolName: "searchPolicy",
    args: { keyword: "延迟补偿" },
    status: "pending"
  }
];

这份计划有几个好处:

  • 每一步目标清楚。
  • 每一步要调用哪个工具清楚。
  • 每一步参数清楚。
  • 当前执行状态清楚。
  • 后面可以记录执行结果。

前端同学可以把它类比成多步骤表单:

Step 1:填写基础信息
Step 2:选择配送方式
Step 3:确认订单
Step 4:支付

每一步都有状态:未开始、进行中、完成、失败、跳过。

Agent 计划也是一样。

4. Act:一次只执行当前步骤

执行计划时,不要一次把所有步骤全部跑完。

更稳的方式是一次只拿一个 pending 步骤:

function getNextStep(plan: PlanStep[]) {
  return plan.find((step) => step.status === "pending") ?? null;
}

然后执行这个步骤:

type ToolResult =
  | {
      ok: true;
      data: unknown;
    }
  | {
      ok: false;
      errorType: string;
      message: string;
    };

async function act(step: PlanStep): Promise<ToolResult> {
  return runTool({
    toolName: step.toolName,
    args: step.args
  });
}

这件事看起来简单,但它让系统变得可控。

因为你随时知道:

  • 当前执行到哪一步。
  • 调用了哪个工具。
  • 用了什么参数。
  • 失败时应该标记哪一步。

多步 Agent 最怕“做了很多事,但没人知道它做到哪了”。

5. Observe:工具结果必须写回计划

执行工具之后,要把结果写回计划。

function observe(step: PlanStep, toolResult: ToolResult) {
  if (toolResult.ok) {
    step.status = "done";
    step.observation = toolResult.data;
    return;
  }

  step.status = "failed";
  step.error = {
    errorType: toolResult.errorType,
    message: toolResult.message
  };
}

Observe 不是“拿到结果就行”。

Observe 是把结果变成系统状态。只有状态被正确记录,后续步骤才能基于它做判断。

6. 观察结果应该能改变后续计划

计划不是死的。

我们的任务里有一句条件:

如果还没送达,再看一下售后政策。

如果第一步查到订单已签收,第二步其实应该跳过。

type OrderStatus = {
  status: "shipping" | "delivered" | "not_found";
  eta?: string;
};

function updatePlanAfterObservation(plan: PlanStep[]) {
  const orderStep = plan.find((step) => step.id === "step_1");
  if (!orderStep || orderStep.status !== "done") return;

  const order = orderStep.observation as OrderStatus;

  if (order.status === "delivered") {
    for (const step of plan.slice(1)) {
      if (step.status === "pending") {
        step.status = "skipped";
        step.skipReason = "订单已签收,不需要继续查询延迟补偿。";
      }
    }
  }
}

这才是 Observe 的价值。

它不是为了记日志而记日志,而是让工具结果影响下一步。

7. 一个最小 Plan Agent 长这样

下面是一版完整但仍然很小的执行器:

type PlanAgentResult = {
  ok: boolean;
  answer: string;
  plan: PlanStep[];
};

async function runPlanAgent(
  userInput: string,
  maxSteps = 4
): Promise<PlanAgentResult> {
  const plan = createPlan(userInput);
  let steps = 0;

  while (steps < maxSteps) {
    steps += 1;
    const step = getNextStep(plan);

    if (!step) {
      return generateFinalAnswer(plan);
    }

    step.status = "running";

    const toolResult = await act(step);
    observe(step, toolResult);

    if (step.status === "failed") {
      return generateFinalAnswer(plan);
    }

    updatePlanAfterObservation(plan);
  }

  return {
    ok: false,
    answer: "超过最大执行步数,Agent 已停止。",
    plan
  };
}

这段代码没有炫技,但结构非常清楚:

  • 先有计划。
  • 找到下一步。
  • 执行当前动作。
  • 观察结果。
  • 根据结果更新计划。
  • 没有下一步就回答。

如果以后接入真实模型,这个结构仍然成立。

只是 createPlan 可以由模型生成,generateFinalAnswer 也可以由模型根据计划结果生成。

8. 补上 createPlan 和 final answer 的最小实现

学习阶段不一定要一上来就让模型生成计划。

你可以先用规则把流程跑通。

function createPlan(userInput: string): PlanStep[] {
  if (!userInput.includes("A1001")) {
    return [
      {
        id: "step_1",
        goal: "告知用户当前示例只支持订单 A1001",
        toolName: "none",
        args: {},
        status: "skipped",
        skipReason: "当前示例只处理订单 A1001"
      }
    ];
  }

  return [
    {
      id: "step_1",
      goal: "查询订单 A1001 的物流状态",
      toolName: "getOrderStatus",
      args: { orderId: "A1001" },
      status: "pending"
    },
    {
      id: "step_2",
      goal: "查询延迟送达补偿政策",
      toolName: "searchPolicy",
      args: { keyword: "延迟补偿" },
      status: "pending"
    }
  ];
}

最终回答也可以先用规则生成:

function generateFinalAnswer(plan: PlanStep[]): PlanAgentResult {
  const failed = plan.find((step) => step.status === "failed");
  if (failed) {
    return {
      ok: false,
      answer: `任务在「${failed.goal}」失败:${JSON.stringify(
        failed.error
      )}`,
      plan
    };
  }

  const orderStep = plan.find((step) => step.id === "step_1");
  const policyStep = plan.find((step) => step.id === "step_2");
  const runnableSteps = plan.filter((step) => step.status !== "skipped");

  if (runnableSteps.length === 0) {
    return {
      ok: false,
      answer:
        plan
          .map((step) => step.skipReason)
          .filter(Boolean)
          .join("\n") || "当前任务没有可执行步骤。",
      plan
    };
  }

  const lines = [
    orderStep?.observation
      ? `订单查询结果:${JSON.stringify(orderStep.observation)}`
      : "没有订单查询结果。"
  ];

  if (policyStep?.status === "skipped") {
    lines.push(`政策查询已跳过:${policyStep.skipReason}`);
  } else if (policyStep?.observation) {
    lines.push(`政策查询结果:${JSON.stringify(policyStep.observation)}`);
  }

  return {
    ok: true,
    answer: lines.join("\n"),
    plan
  };
}

这不是最终产品文案,但它能帮你先验证流程。

等 Plan-Act-Observe 跑稳后,再让模型接管计划生成和最终表达,会更容易排查问题。

9. 前端页面怎么展示计划?

多步 Agent 如果只展示最终回答,用户不知道系统做了什么,开发者也很难排查。

可以把计划展示成步骤列表:

function AgentPlanView({ plan }: { plan: PlanStep[] }) {
  return (
    <ol>
      {plan.map((step) => (
        <li key={step.id}>
          <strong>{step.goal}</strong>
          <span>{step.status}</span>
          {step.skipReason && <p>{step.skipReason}</p>}
          {step.error && <pre>{JSON.stringify(step.error, null, 2)}</pre>}
        </li>
      ))}
    </ol>
  );
}

这类 UI 在开发环境、运营后台、企业内部工具里很有价值。

因为它能回答几个关键问题:

  • Agent 原计划做什么?
  • 当前执行到哪一步?
  • 哪一步失败了?
  • 哪一步被跳过了?
  • 最终答案基于哪些观察结果?

10. 生产环境避坑指南

1. 不要让计划无限长

初期计划控制在 2 到 4 步更稳。

计划越长,错误传播越严重,成本和延迟也越高。

2. 关键步骤失败后不要继续编

如果查订单失败,就不要继续基于空数据查补偿政策。

关键步骤失败时,应该停止并说明失败原因。

3. 跳过步骤要写明原因

不要只把状态改成 skipped

要写清 skipReason,否则排查时不知道是业务条件触发,还是系统漏执行。

4. 高风险步骤必须二次确认

如果计划里包含取消订单、发起退款、发送邮件、删除数据,一定要在执行前确认。

Plan 可以建议高风险步骤,但不能自动越过权限和确认。

5. 每一步都要可回放

记录每一步的 goaltoolNameargsstatusobservationerror

否则多步 Agent 一旦出错,就会变成“它好像自己做了很多事,但没人知道具体发生了什么”。

11. 常见误区

误区 1:计划越详细越好

不一定。初期计划 2 到 4 步最好。太长的计划会增加错误传播和维护成本。

误区 2:生成计划后就不能改

计划应该能根据观察结果调整。否则 Observe 就只是记录日志,没有真正参与决策。

误区 3:工具失败后继续执行后续步骤

如果关键步骤失败,应该停下来说明失败原因,而不是继续编一个完整答案。

误区 4:所有计划都必须由模型生成

不需要。学习阶段可以先用规则生成计划。真实项目里,也可以把固定业务流程写死,只让模型处理自然语言理解和答案表达。

12. 给前端开发者的落地清单

如果你要在团队里做多步 Agent,可以从这份清单开始:

  1. 定义任务类型。
  2. 为每种任务设计最短计划。
  3. 每一步都要有 idgoalstatus
  4. 每一步只调用一个清晰工具。
  5. 工具结果写入 observation
  6. 失败写入 error
  7. 可跳过步骤写入 skippedskipReason
  8. 最终答案必须基于 plan 里的观察结果。
  9. 记录完整执行日志。
  10. 用测试用例覆盖完成、跳过、失败三种路径。

这份清单看起来像工程流程,而不是 AI 魔法。

这正是重点。

Agent 工程越往后走,越不是让模型随便发挥,而是给模型一个清晰、可观察、可回放的工作台。

结语

多步 Agent 不能边想边乱跑。

它需要先计划,再行动,再观察。

Plan 让任务有结构。
Act 让系统真正执行。
Observe 让结果回到状态,并影响下一步。

这就是 Agent 从“能调工具”走向“能完成任务”的关键一步。

对前端开发者来说,这不是陌生领域。你早就熟悉流程、状态、副作用和错误处理。现在只是把这些工程能力,用在 AI Agent 上。

会写 Prompt 只是开始。

会设计可控的多步执行流程,才是 AI 工程真正的成长信号。

前端开发者做 Agent:别写成一次请求,用 5 步受控循环防止 AI 乱跑

作者:前端转 AI 深度实践者

【省流助手/核心观点】:Agent 不是“调用一次大模型”就结束,也不是让模型无限自由发挥。一个最小 Agent Loop 至少要经历 5 步:接收用户输入、模型决定是否调用工具、程序执行工具、工具结果写回上下文、模型生成最终回答。真正可靠的 Agent Loop 必须受控:有 maxSteps、有工具边界、有结构化输出、有错误处理、有 trace 记录。对前端开发者来说,它很像一个带状态、带副作用、带退出条件的事件循环。


很多人第一次做 Agent,会把它想得过于神秘。

仿佛只要 Prompt 写成:

你是一个自主智能体,请一步一步完成用户任务。

模型就会自己查资料、自己调接口、自己整理结论、自己处理异常。

这个想象很美,但工程系统不能靠想象跑。

真正的 Agent,不是让模型无限自由发挥。
真正的 Agent,是让模型和工具在程序控制下协作。

这一层协作,叫 Agent Loop

1. 痛点:一次调用只能聊天,不能稳定办事

前两篇我们讲了 Tool Calling 和工具 Schema。

模型不应该编真实世界的信息,而应该提出工具调用意图。

比如用户问:

帮我查一下订单 A1001 到哪了,并告诉我什么时候能收到。

模型可以输出:

{
  "type": "tool_call",
  "toolName": "getOrderStatus",
  "args": {
    "orderId": "A1001"
  }
}

程序执行工具,得到:

{
  "status": "shipping",
  "eta": "2026-05-03"
}

问题来了:这就结束了吗?

对程序来说,工具结果已经拿到了。
但对用户来说,他想看的不是 JSON,而是一句能读懂的话:

订单 A1001 当前运输中,预计 2026-05-03 送达。

所以完整流程应该是:

用户提问
-> 模型提出工具调用
-> 程序执行工具
-> 工具结果写回上下文
-> 模型组织最终回答

这就是最小 Agent Loop。

2. 错误做法:把 Agent 写成一次模型请求

很多早期实现会这样写:

async function askAgent(question: string) {
  const prompt = `
你是一个智能助手,请完成用户任务。

用户问题:${question}
`;

  return llm.chat(prompt);
}

这段代码的问题是:它没有工具执行,也没有状态回写。

模型只能生成一段回答。
如果答案涉及订单、库存、权限、合同、退款金额,它很可能是在“猜”。

另一种常见错误,是只执行一次工具,然后直接把工具 JSON 返回给用户:

async function askOrderOnce(orderId: string) {
  const result = await getOrderStatus({ orderId });
  return JSON.stringify(result);
}

这看起来接了工具,但还不是 Agent Loop。

因为它缺了最后一步:让模型基于工具结果组织面向用户的回答。
也缺了更重要的一点:当模型还需要第二个工具时,系统没有继续循环的能力。

3. 正确做法:把模型输出设计成两种类型

在普通聊天里,模型输出通常就是一段文本。

但在 Agent 里,模型输出至少有两种可能:

type ModelOutput =
  | {
      type: "tool_call";
      toolName: string;
      args: Record<string, unknown>;
    }
  | {
      type: "final_answer";
      content: string;
    };

第一种是工具调用:

{
  "type": "tool_call",
  "toolName": "getOrderStatus",
  "args": {
    "orderId": "A1001"
  }
}

意思是:现在信息不够,需要程序帮我调用工具。

第二种是最终回答:

{
  "type": "final_answer",
  "content": "订单 A1001 当前运输中,预计 2026-05-03 送达。"
}

意思是:我已经可以回答用户了。

Agent Loop 的核心判断就一句话:

如果是 tool_call,就执行工具,然后继续。
如果是 final_answer,就返回答案,然后结束。

复杂 Agent 会加入规划、记忆、多工具、多轮任务,但地基仍然是这个判断。

4. 先用 mockModel 练骨架,别一上来接真实 API

很多人一上来就接真实模型,结果调试时一团乱:

  • 是 Prompt 不清楚?
  • 是模型没有按格式返回?
  • 是工具调度器有 bug?
  • 是参数校验拦错了?
  • 是 API 调用失败?
  • 是网络或环境变量问题?

更稳的做法是:先用 mockModel

就像前端开发时,后端接口没好,你会先 mock 数据,把页面状态和交互跑通。

Agent 也一样。

type Message =
  | {
      role: "user";
      content: string;
    }
  | {
      role: "tool";
      toolName: string;
      content: unknown;
    };

function mockModel(messages: Message[]): ModelOutput {
  const lastMessage = messages[messages.length - 1];

  if (lastMessage.role === "user") {
    if (lastMessage.content.includes("A1001")) {
      return {
        type: "tool_call",
        toolName: "getOrderStatus",
        args: { orderId: "A1001" }
      };
    }

    return {
      type: "final_answer",
      content: "我暂时只能处理订单 A1001 的查询。"
    };
  }

  if (lastMessage.role === "tool") {
    const result = lastMessage.content as {
      status?: string;
      eta?: string;
    };

    return {
      type: "final_answer",
      content: `订单当前状态为 ${result.status},预计 ${result.eta} 送达。`
    };
  }

  return {
    type: "final_answer",
    content: "无法处理当前请求。"
  };
}

它不聪明,但它稳定。

稳定的好处是,你可以先验证 Agent Loop 的工程结构:

  • messages 是否维护正确。
  • 工具调用是否被执行。
  • 工具结果是否写回上下文。
  • 最终回答是否能结束循环。
  • 错误路径是否会返回。

等骨架跑稳了,再接真实模型,问题会清楚很多。

5. messages 是 Agent 的短期记忆

Agent Loop 里最重要的数据结构之一是 messages

它记录了整个运行过程:

const messages: Message[] = [
  {
    role: "user",
    content: "帮我查订单 A1001"
  },
  {
    role: "tool",
    toolName: "getOrderStatus",
    content: {
      status: "shipping",
      eta: "2026-05-03"
    }
  }
];

为什么工具结果要写回 messages

因为模型下一轮需要看到它。

如果工具执行完了,但你没有把结果放回上下文,模型就像刚查完资料却失忆:

工具查到了,但模型不知道查到了什么。

这类 bug 在 Agent 开发里非常常见。

记住一个规则:

工具执行不是终点,工具结果必须回到上下文。

6. 一个最小 Agent Loop 长这样

先准备一个工具和调度器:

type ToolResult =
  | {
      ok: true;
      toolName: string;
      data: unknown;
    }
  | {
      ok: false;
      toolName?: string;
      error: string;
    };

async function getOrderStatus(args: Record<string, unknown>) {
  if (args.orderId !== "A1001") {
    return { status: "not_found" };
  }

  return {
    status: "shipping",
    eta: "2026-05-03"
  };
}

async function runTool(output: Extract<ModelOutput, { type: "tool_call" }>) {
  if (output.toolName !== "getOrderStatus") {
    return {
      ok: false,
      toolName: output.toolName,
      error: `未知工具:${output.toolName}`
    } satisfies ToolResult;
  }

  const data = await getOrderStatus(output.args);

  return {
    ok: true,
    toolName: output.toolName,
    data
  } satisfies ToolResult;
}

然后写 Agent Loop:

type AgentRunResult =
  | {
      ok: true;
      answer: string;
      steps: number;
      messages: Message[];
    }
  | {
      ok: false;
      error: string;
      steps: number;
      messages: Message[];
    };

async function runAgentLoop(
  userInput: string,
  maxSteps = 3
): Promise<AgentRunResult> {
  const messages: Message[] = [
    {
      role: "user",
      content: userInput
    }
  ];

  for (let step = 1; step <= maxSteps; step++) {
    const output = mockModel(messages);

    if (output.type === "final_answer") {
      return {
        ok: true,
        answer: output.content,
        steps: step,
        messages
      };
    }

    if (output.type === "tool_call") {
      const toolResult = await runTool(output);

      messages.push({
        role: "tool",
        toolName: output.toolName,
        content: toolResult.ok ? toolResult.data : toolResult
      });

      continue;
    }

    return {
      ok: false,
      error: "模型输出了未知类型",
      steps: step,
      messages
    };
  }

  return {
    ok: false,
    error: "超过最大步骤数,Agent 已停止",
    steps: maxSteps,
    messages
  };
}

这段代码已经包含 Agent 的核心骨架:

  • 有用户输入。
  • 有模型决策。
  • 有工具执行。
  • 有上下文更新。
  • 有最终回答。
  • 有最大步数。
  • 有异常退出。

复杂 Agent 往后扩展,基本都是在这个骨架上继续加能力。

7. maxSteps 是 Agent 的安全绳

为什么一定要有 maxSteps

因为模型可能会反复调用工具。

比如一个异常模型每次都返回:

{
  "type": "tool_call",
  "toolName": "searchPolicy",
  "args": {
    "keyword": "报销"
  }
}

如果你没有最大步数,Agent 就会一直查,一直查,一直查。

这不是智能,这是迷路。

maxSteps 就像安全绳:

最多跑 3 步,跑不完就停下来,把错误返回。

玩具 demo 假设模型总是乖。

工程系统默认任何环节都可能出错。

8. 前端页面怎么展示 Agent Loop?

如果你只展示最终回答,排查 Agent 问题会很痛苦。

建议至少在开发环境或内部后台展示步骤 trace。

type AgentTraceStep = {
  step: number;
  modelOutput: ModelOutput;
  toolResult?: ToolResult;
};

Agent Loop 运行时记录 trace:

async function runAgentLoopWithTrace(userInput: string, maxSteps = 3) {
  const messages: Message[] = [{ role: "user", content: userInput }];
  const trace: AgentTraceStep[] = [];

  for (let step = 1; step <= maxSteps; step++) {
    const output = mockModel(messages);
    const traceStep: AgentTraceStep = {
      step,
      modelOutput: output
    };

    if (output.type === "final_answer") {
      trace.push(traceStep);
      return {
        ok: true,
        answer: output.content,
        messages,
        trace
      };
    }

    const toolResult = await runTool(output);
    traceStep.toolResult = toolResult;
    trace.push(traceStep);

    messages.push({
      role: "tool",
      toolName: output.toolName,
      content: toolResult.ok ? toolResult.data : toolResult
    });
  }

  return {
    ok: false,
    error: "超过最大步骤数,Agent 已停止",
    messages,
    trace
  };
}

前端可以把 trace 做成折叠面板:

function AgentTracePanel({ trace }: { trace: AgentTraceStep[] }) {
  return (
    <details>
      <summary>Agent 执行过程</summary>
      {trace.map((item) => (
        <section key={item.step}>
          <h4>Step {item.step}</h4>
          <pre>{JSON.stringify(item.modelOutput, null, 2)}</pre>
          {item.toolResult && (
            <pre>{JSON.stringify(item.toolResult, null, 2)}</pre>
          )}
        </section>
      ))}
    </details>
  );
}

这不是炫技,而是为了让 Agent 出问题时能定位:

  • 模型哪一步选错了工具?
  • 参数是哪一步变错的?
  • 工具有没有执行成功?
  • 工具结果有没有回到上下文?
  • 为什么没有生成最终回答?

9. Agent Loop 不是无限自治,而是受控协作

很多人喜欢把 Agent 描述成“自主智能体”。

这个词没问题,但容易让初学者误会:好像越自主越高级。

工程里不是这样。

真正可靠的 Agent,不是无限自治,而是受控协作。

它应该有:

  • 明确的工具范围。
  • 明确的参数 Schema。
  • 明确的最大步数。
  • 明确的错误处理。
  • 明确的风险拦截。
  • 明确的日志和 traceId。

模型可以决定下一步,但只能在程序允许的范围内决定。

对前端开发者来说,可以这样类比:

用户输入 = 用户事件
messages = 状态容器
modelOutput = 状态决策
tool_call = effect 描述
runTool = effect 执行器
final_answer = 渲染结果
maxSteps = 防止无限循环的保护

Agent 不是凭空多出来的外星架构。

它只是把语言模型放进了你熟悉的状态与副作用系统里。

10. 生产环境避坑指南

1. 不要让 Agent 无限循环

必须设置 maxSteps
超过步数就停止,并返回结构化错误。

2. 不要把 tool error 当正常工具结果

工具失败时,要让模型知道这是失败,而不是把错误文本当成正常数据继续编。

建议在 tool message 里保留 okerrorTypemessage 等字段。

3. 高风险工具不要自动执行

取消订单、发送邮件、修改权限、扣款、删除数据,都不应该在 Agent Loop 中无确认执行。

这类工具应该返回 confirmation_required,交给用户或业务规则确认。

4. 每一步都要记录 traceId

一次 Agent 请求可能包含多次模型调用和多次工具调用。

没有统一 traceId,排查时很难把它们串起来。

5. 步数越多,不代表能力越强

步数越多,成本、延迟和错误概率都会上升。

大多数业务型 Agent,先把 2 到 4 步跑稳定,比追求长链路自主规划更重要。

11. 常见误区

误区 1:一次大模型调用就是 Agent

不是。一次模型调用只是聊天或生成。Agent 至少要有决策、工具、状态和循环。

误区 2:工具执行完就结束

不一定。工具结果通常还要回到模型,让模型生成用户能读懂的最终回答。

误区 3:Agent 应该尽可能多跑几步

不是。步数越多,成本、延迟和错误概率都会上升。够用就好。

误区 4:先接真实模型才能学 Agent

不需要。先用 mock 模型跑通控制流,反而更适合学习和调试。

12. 给前端开发者的落地清单

如果你要在团队里实现一个最小 Agent,可以从这份清单开始:

  1. 明确 Agent 支持哪些任务。
  2. 定义可用工具和工具 Schema。
  3. 实现工具调度器。
  4. 设计模型输出类型:tool_callfinal_answer
  5. 维护 messages
  6. 工具结果写回上下文。
  7. 设置 maxSteps
  8. 记录每一步 trace。
  9. 处理未知工具、参数错误和工具失败。
  10. 高风险工具必须走确认。

这份清单能帮你避免把 Agent 做成一个“看起来会自己想办法,实际上出了事没人知道在哪里”的黑盒。

结语

Agent 不是一次调用。

Agent 是一轮受控循环。

模型负责判断下一步,工具负责获取真实信息或执行动作,程序负责维护状态、控制边界、处理错误和决定什么时候停止。

对前端开发者来说,这件事并不陌生。你早就熟悉事件、状态、副作用和渲染。现在只是多了一个语言模型,帮助系统理解自然语言里的意图。

当你能写出一个最小 Agent Loop,能看懂每一步发生了什么,能让它在错误时停下来而不是乱跑,你就已经跨过了 Agent 工程最重要的一道门槛。

前端开发者做 Agent:Tool Calling 别只写函数名,用 Schema 少踩 5 个坑

作者:前端转 AI 深度实践者

【省流助手/核心观点】:Tool Calling 不是把几个函数名丢给模型就完事了。函数名只能告诉模型“有什么工具”,但不能说明“什么时候用、怎么传参、哪些字段必填、风险有多高、能不能自动执行”。真正可维护的 Agent 工程,需要给每个工具写一份 Schema:描述用途、约束参数、标记风险、控制确认流程。对前端开发者来说,这就像给 API 写 TypeScript 类型、接口文档、表单校验和权限边界。


第 23 篇我们讲了 Agent 的第一块积木:Tool Calling。

简单说,就是让模型不再硬编答案,而是学会提出工具调用意图。

用户问:

帮我查一下订单 A1001 到哪了。

模型不应该直接编:

订单正在配送中。

而应该输出:

{
  "toolName": "getOrderStatus",
  "args": {
    "orderId": "A1001"
  }
}

然后程序真正去查订单系统。

这一步很关键,它把 AI 应用从“会聊天”推进到“能办事”。

但只做到这里还不够。

因为模型可能会:

  • 选错工具。
  • 少传参数。
  • 把数字传成字符串。
  • 把枚举值写错。
  • 调用根本不存在的工具。
  • 请求执行一个高风险操作。

所以这篇文章继续往下走一步:

Tool Calling 不能只靠函数名,还要给工具写 Schema。

这个 Schema,就是工具说明书。

1. 痛点:只注册函数,对程序够用,对模型不够用

先看一个很常见的工具注册表:

const tools = {
  getOrderStatus,
  calculateRefund,
  searchPolicy
};

这对程序来说确实能跑。

程序看到 getOrderStatus,就能找到对应函数执行。

但对模型来说,它只看到几个名字。
这些名字没有完整说明:

  • getOrderStatus 是查物流,还是查订单详情?
  • calculateRefund 是预估退款,还是直接发起退款?
  • searchPolicy 是查公司制度,还是查售后政策?
  • 每个工具需要哪些参数?
  • 参数类型是什么?
  • 哪些参数允许哪些枚举值?
  • 哪些工具只是查询,哪些工具会改变系统状态?

这就像你给新同事一个接口列表:

/order
/refund
/policy

然后让他自己猜每个接口怎么用。

他可能猜对,也很容易猜错。

Agent 系统一样。工具名不是工具契约,工具名只是入口。

2. 错误做法:模型输出什么,程序就执行什么

很多人第一次写 Tool Calling,会不自觉地相信模型输出:

async function unsafeRunTool(toolCall: {
  toolName: string;
  args: Record<string, unknown>;
}) {
  const tool = tools[toolCall.toolName as keyof typeof tools];
  return tool(toolCall.args);
}

这段代码最大的问题是:没有任何边界。

模型可能传错类型:

{
  "toolName": "getOrderStatus",
  "args": {
    "orderId": 1001
  }
}

但你的工具需要的是字符串:

async function getOrderStatus(args: { orderId: string }) {
  // ...
}

模型也可能少传参数:

{
  "toolName": "calculateRefund",
  "args": {
    "reason": "damaged"
  }
}

但退款计算至少需要 orderId

它还可能传错枚举值:

{
  "toolName": "calculateRefund",
  "args": {
    "orderId": "A1001",
    "reason": "随便退一下"
  }
}

如果你直接执行,错误会在很深的业务代码里爆炸。更糟的是,高风险工具可能被误触发。

所以你需要工具 Schema。

3. 正确做法:Schema = 接口文档 + 类型定义 + 安全边界

一个更可靠的工具定义,应该同时包含 handler 和 schema。

type ToolRisk = "low" | "medium" | "high";

type ToolSchema = {
  name: string;
  description: string;
  risk: ToolRisk;
  requiresConfirmation: boolean;
  parameters: {
    type: "object";
    required: string[];
    properties: Record<
      string,
      {
        type: "string" | "number" | "boolean";
        description: string;
        enum?: string[];
        pattern?: string;
      }
    >;
  };
};

type ToolDefinition = {
  schema: ToolSchema;
  handler: (args: Record<string, unknown>) => Promise<unknown>;
};

比如 getOrderStatus 可以这样写:

const getOrderStatusTool: ToolDefinition = {
  schema: {
    name: "getOrderStatus",
    description:
      "查询订单物流状态。适用于用户询问订单是否发货、是否签收、预计何时送达。不用于退款、取消订单或修改地址。",
    risk: "low",
    requiresConfirmation: false,
    parameters: {
      type: "object",
      required: ["orderId"],
      properties: {
        orderId: {
          type: "string",
          description: "订单编号,例如 A1001",
          pattern: "^A\d{4}$"
        }
      }
    }
  },
  handler: async (args) => {
    const orderId = args.orderId;
    if (typeof orderId !== "string") {
      throw new Error("orderId 必须是字符串");
    }

    return {
      orderId,
      status: "shipping",
      eta: "2026-04-28"
    };
  }
};

这份 Schema 至少告诉系统 5 件事:

  1. 工具是做什么的。
  2. 什么时候该用这个工具。
  3. 参数有哪些,哪些必填。
  4. 参数类型和格式是什么。
  5. 工具有没有风险,能不能直接执行。

如果你是前端开发者,可以把它理解成:

Tool Schema = API 文档 + TypeScript 类型 + 表单校验规则 + 权限提示

没有 Schema 的 Tool Calling,就像没有类型定义的接口联调。能跑,但迟早会在边界条件上摔跤。

4. 好的 description,会显著减少选错工具

很多人写工具描述,会写得很短:

查询订单。

这当然比没有好,但还不够。

更好的描述要包含适用场景和不适用场景:

查询订单物流状态。适用于用户询问订单是否发货、是否签收、预计何时送达。不用于退款、取消订单或修改地址。

这个描述告诉模型:

  • 用户问物流,选它。
  • 用户问退款,不要选它。
  • 用户问取消订单,也不要选它。

工具描述不是写给人看的注释而已。

它会影响模型的选择质量。你可以把工具描述理解成一种挂在工具上的 Prompt。

5. 参数校验:模型输出的 args 本质上是不可信输入

args 是模型生成的,所以它应该被当成用户输入处理。

一个最小参数校验器可以这样写:

function validateArgs(schema: ToolSchema, args: Record<string, unknown>) {
  const errors: string[] = [];
  const { required, properties } = schema.parameters;

  for (const key of required) {
    if (!(key in args)) {
      errors.push(`缺少必填参数:${key}`);
    }
  }

  for (const [key, rules] of Object.entries(properties)) {
    const value = args[key];
    if (value === undefined) continue;

    if (rules.type === "string" && typeof value !== "string") {
      errors.push(`参数 ${key} 应该是 string`);
      continue;
    }

    if (rules.type === "number" && typeof value !== "number") {
      errors.push(`参数 ${key} 应该是 number`);
      continue;
    }

    if (rules.type === "boolean" && typeof value !== "boolean") {
      errors.push(`参数 ${key} 应该是 boolean`);
      continue;
    }

    if (rules.enum && !rules.enum.includes(String(value))) {
      errors.push(`参数 ${key} 必须是 ${rules.enum.join(", ")} 之一`);
    }

    if (
      rules.pattern &&
      typeof value === "string" &&
      !new RegExp(rules.pattern).test(value)
    ) {
      errors.push(`参数 ${key} 格式不合法`);
    }
  }

  return errors;
}

这段代码看起来不酷,但它是 Agent 稳定性的地基。

AI 工程里很多真正有价值的代码,都不是“让模型更聪明”,而是让系统在模型不稳定时也不会乱跑。

6. 调度器要像网关,而不是传声筒

没有 Schema 校验时,调度器像一个传声筒:

模型说调用什么,我就调用什么。

更好的调度器应该像网关:

模型提出调用意图
-> 检查工具是否存在
-> 检查参数是否合法
-> 检查风险等级
-> 检查是否需要确认
-> 决定是否执行

一个升级版 runTool 可以这样写:

type ToolCall = {
  toolName: string;
  args: Record<string, unknown>;
};

type ToolRunResult =
  | {
      ok: true;
      toolName: string;
      data: unknown;
    }
  | {
      ok: false;
      toolName?: string;
      errorType:
        | "unknown_tool"
        | "invalid_arguments"
        | "confirmation_required"
        | "tool_error";
      message: string;
      errors?: string[];
    };

const toolRegistry: Record<string, ToolDefinition> = {
  getOrderStatus: getOrderStatusTool
};

async function runTool(toolCall: ToolCall): Promise<ToolRunResult> {
  const tool = toolRegistry[toolCall.toolName];

  if (!tool) {
    return {
      ok: false,
      toolName: toolCall.toolName,
      errorType: "unknown_tool",
      message: `未知工具:${toolCall.toolName}`
    };
  }

  const errors = validateArgs(tool.schema, toolCall.args ?? {});
  if (errors.length > 0) {
    return {
      ok: false,
      toolName: tool.schema.name,
      errorType: "invalid_arguments",
      message: "工具参数不合法",
      errors
    };
  }

  if (tool.schema.requiresConfirmation) {
    return {
      ok: false,
      toolName: tool.schema.name,
      errorType: "confirmation_required",
      message: "该工具属于高风险操作,需要用户确认后才能执行"
    };
  }

  try {
    const data = await tool.handler(toolCall.args);
    return {
      ok: true,
      toolName: tool.schema.name,
      data
    };
  } catch (error) {
    return {
      ok: false,
      toolName: tool.schema.name,
      errorType: "tool_error",
      message: error instanceof Error ? error.message : "工具执行失败"
    };
  }
}

这段逻辑让系统多了几道闸门。

这些闸门不是为了为难模型,而是为了保护用户、业务和团队。

7. 查询类工具和写入类工具必须分开看

Tool Calling 里最需要警惕的是副作用。

查询类工具通常风险较低:

  • 查订单状态。
  • 查制度文档。
  • 查天气。
  • 查库存。

它们只是读取信息,不改变系统状态。

写入类工具就不一样了:

  • 取消订单。
  • 发起退款。
  • 修改地址。
  • 发送邮件。
  • 删除数据。
  • 修改用户权限。

这些操作会改变真实业务状态。

如果模型误触发,后果可能很麻烦。

所以你应该给工具标记风险:

const cancelOrderTool: ToolDefinition = {
  schema: {
    name: "cancelOrder",
    description:
      "取消指定订单。仅当用户明确要求取消订单时使用。不用于查询订单状态或咨询退款政策。",
    risk: "high",
    requiresConfirmation: true,
    parameters: {
      type: "object",
      required: ["orderId", "reason"],
      properties: {
        orderId: {
          type: "string",
          description: "订单编号,例如 A1001",
          pattern: "^A\d{4}$"
        },
        reason: {
          type: "string",
          description: "取消原因",
          enum: ["user_request", "wrong_address", "duplicate_order"]
        }
      }
    }
  },
  handler: async (args) => {
    return {
      cancelled: true,
      orderId: args.orderId
    };
  }
};

当模型试图调用高风险工具时,系统不应该立刻执行,而应该返回确认态。

这不是“不智能”,这是负责任。

AI 工程里有一个很实用的原则:

能查的,可以宽一点;能改的,必须严一点。

8. 生产环境避坑指南

1. Schema 要和真实 handler 同步维护

最危险的情况是:Schema 说需要 orderId,handler 实际读的是 id

这类错不会总是立刻暴露,但会让模型、前端和后端一起困惑。

建议把 Schema 和 handler 放在同一个文件或同一个模块里维护,不要散落在不同仓库里。

2. 不要用模糊工具名

坏名字:

doUserThing
handleOrder
processData

好名字:

getOrderStatus
calculateRefundEstimate
searchPolicyDocs
createSupportTicketDraft

名字越清楚,模型越容易选对,团队也越容易维护。

3. description 要写“不适用场景”

很多工具选错,不是因为模型不知道它能做什么,而是不知道它不能做什么。

描述里最好写清楚:

  • 适用于什么问题。
  • 不适用于什么问题。
  • 和相似工具的区别是什么。

4. 高风险工具必须程序层拦截

不要只在 Prompt 里写“谨慎使用”。

Prompt 是软约束。权限校验、二次确认、审计日志才是硬约束。

涉及删除、支付、发送、修改权限、批量操作的工具,都应该默认需要确认。

5. 结构化错误要返回给模型和日志

不要只返回:

调用失败。

更好的错误是:

{
  "ok": false,
  "errorType": "invalid_arguments",
  "message": "工具参数不合法",
  "errors": ["缺少必填参数:orderId"]
}

这样模型可以尝试修正参数,开发者也能快速定位问题。

9. 常见误区

误区 1:函数名写清楚就够了

不够。函数名只能表达一小部分语义,不能替代参数规则、风险等级和适用边界。

误区 2:模型很聪明,参数错了它会自己修

有时会,但不能依赖。工程系统要把错误显式返回,而不是期待模型每次都猜对。

误区 3:高风险工具只要 Prompt 写“谨慎使用”就行

不行。Prompt 是软约束,权限和确认是硬约束。涉及副作用的工具必须在程序层把关。

误区 4:Schema 越复杂越好

也不是。初期 Schema 要清晰、够用、容易维护。复杂度应该来自真实问题,而不是一开始就堆满规则。

10. 给前端开发者的落地清单

如果你正在设计 Agent 工具,可以从这份清单开始:

  1. 每个工具必须有清晰名字。
  2. 每个工具必须有 description,包含适用和不适用场景。
  3. 每个工具必须声明必填参数。
  4. 每个参数必须有类型、描述和必要的格式约束。
  5. 枚举参数必须列出允许值。
  6. 工具必须标记风险等级。
  7. 高风险工具默认需要确认。
  8. 调度器必须返回结构化错误。
  9. 工具调用必须记录 traceId。
  10. 工具 Schema 要和代码一起维护。

这份清单不花哨,但很实用。它会让你的 Agent 不只是演示时聪明,而是在真实用户、真实业务、真实错误里也能站得住。

结语

Tool Calling 的第一步,是让模型知道有哪些工具。

Tool Calling 的第二步,是让系统知道这些工具该怎么被安全、稳定、可维护地使用。

这就是 Schema 的价值。

它像一份工具说明书,也像一份协作契约:告诉模型什么时候该用,告诉程序怎么校验,告诉团队风险在哪里。

如果说第 23 篇让我们把 Agent 的“手”接上去,那么第 24 篇就是给这只手标上边界、权限和刹车。

真正可靠的 Agent,不是模型想做什么就做什么。

真正可靠的 Agent,是模型提出建议,程序负责把关,工具在清晰边界内执行。

RAG 落地 3 个月,我才发现排序(Rerank)比检索更重要

作者:前端转 AI 深度实践者

【省流助手/核心观点】:RAG 系统的精度瓶颈往往不在 Embedding 检索,而在排序。语义检索(一阶段)只能保证“相关”,但不能保证“最优”。引入 Rerank(二阶段重排序),将最精准的资料排在最前面,能显著提升模型回复的贴题度,解决 AI “答非所问”的顽疾。


1. 痛点:为什么你的 AI 总是“差一点”?

作为前端开发者,我们习惯了 Array.sort()。但在 AI 知识库场景中,排序的失效会导致灾难。

你是否遇到过这种情况:

  • AI 回答没报错,但重点全偏了
  • 引用了资料,但引用的是过时的、次要的段落。
  • 感觉模型“理解力不行”,其实是它看到的上下文(Context)不对

真相是:模型也有“注意力局限”。 如果你把最重要的答案排在 Top 5 的最后一名,受限于上下文窗口和位置偏差(Lost in the Middle),模型极大概率会忽略它。


2. 代码实战:一阶段检索 vs 二阶段检索(Rerank)

我们可以把 Rerank 类比为前端面试的“初筛”与“技术终面”。

❌ 错误做法:直接拿 Embedding 结果喂给模型

只靠向量相似度,容易被“关键词重合”但业务无关的片段干扰。

# 伪代码:一阶段检索直接收工
raw_results = vector_db.search(query_vector, limit=5)
# 风险:Top 1 可能是个无关的 FAQ,真正的 API 文档排在 Top 5,模型漏看了

✅ 正确做法:检索(Top 20) + Rerank(Top 5)

先“广撒网”,再用专业的重排序模型进行“精挑选”。

# 1. 第一阶段:快速召回(向量检索)
initial_results = vector_db.search(query_vector, limit=20)

# 2. 第二阶段:Rerank 精排
# 使用类似 BGE-Reranker 的模型对 query 和 doc 进行交叉评分
reranked_results = reranker_model.predict(
    query=user_query,
    documents=[res.text for res in initial_results]
)

# 3. 截取最高分的 Top 5 喂给大模型
final_context = reranked_results[:5]
# 收益:最核心、最贴题的资料现在稳稳地坐在 Top 1 的位置

3. 生产环境避坑指南

在真实业务中落地 Rerank,请务必关注这 3 点:

  1. 延迟与精度的权衡:Rerank 是交叉编码器(Cross-Encoder),计算量比向量检索大得多。建议:召回阶段取 20-30 个片段即可,不要全量 Rerank,否则接口响应会从 200ms 飙升到 2s。
  2. 模型选型建议:不要自己训练,优先使用开源方案。国内推荐 BGE-Reranker,海外推荐 Cohere Rerank。对于中文业务,BGE 的表现非常惊艳。
  3. 注意上下文“噪音”:Rerank 的分值通常是 0-1 的概率值。如果最高分也低于 0.3,说明知识库里可能真的没有答案,此时应直接触发“不知道”逻辑,而不是强行让 AI 瞎猜。

4. 逻辑校正:排序不是装饰,是决策依据

很多团队容易陷入“改 Prompt”的死循环。

对读者的建议:当你觉得 AI 回答不准时,第一步不是改 Prompt,而是打印出检索回来的 Top 3 资料

  • 如果前三名里没有正确答案 -> 去优化 Embedding切块逻辑
  • 如果正确答案在第四、五名 -> 赶紧加上 Rerank

排序的本质是减少模型面对的熵(混乱度)。 给模型看最干净、最直接的证据,它才能给出最专业的回答。


结语

RAG 不只是找资料,还要把最关键的资料“递”到模型嘴边。

从“有没有”走向“准不准”,是每一个 AI 工程化团队的必经之路。 如果你的系统还在“差一点”的泥潭里挣扎,不妨试试 Rerank,这可能是你性价比最高的一次优化。


点赞 + 收藏不迷路,带你持续解锁前端转型 AI 的工程干货!

❌