阅读视图

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

前端开发者做 Agent:别只会执行,用 4 类失败策略让 AI 知道怎么停

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

【省流助手/核心观点】:Agent 做 demo 时,最显眼的是“它会调用工具”。但进入真实工程后,更关键的是“工具失败时它怎么办”。可靠的 Agent 必须区分错误类型:超时可以重试,空结果可以降级,权限不足要停止,高风险操作要等待确认。Agent 的成熟度,不取决于它能跑多远,而取决于它在出错时有没有刹车、有没有解释、有没有边界。


前面几篇,我们已经把 Agent 的核心骨架搭起来了。

  • 第 23 篇:Tool Calling,让模型学会调用工具。
  • 第 24 篇:工具 Schema,让工具有参数规则和风险边界。
  • 第 25 篇:Agent Loop,让模型和工具形成闭环。
  • 第 26 篇:Plan-Act-Observe,让 Agent 能处理多步任务。

这一路下来,Agent 看起来越来越像一个能做事的系统。

但还有一个问题必须正面面对:

如果某一步失败了,Agent 该怎么办?

这个问题比“怎么调用工具”更接近真实工程。

因为 demo 里的工具通常都成功。
真实世界里的工具不一定。

1. 痛点:会执行不难,失败后不乱跑才难

想象一个任务:

帮我查一下订单 A1001 的物流,如果还没送达,再查延迟补偿政策。

一个理想流程是:

查订单
-> 判断是否送达
-> 查政策
-> 给出建议

但真实执行时可能发生很多事:

  • 订单接口超时。
  • 订单不存在。
  • 用户没有权限查这个订单。
  • 政策搜索没有结果。
  • 工具参数缺失。
  • 高风险工具需要用户确认。

如果 Agent 没有失败策略,它可能会做出很糟糕的行为:

  • 接口超时后直接放弃。
  • 订单不存在还继续查政策。
  • 权限不足却假装查到了。
  • 政策为空却编一个政策。
  • 连续重试同一步,卡到超时。

所以 Agent 真正难的不是“能不能执行”,而是失败时知道该怎么办。

2. 错误做法:把所有失败都当成普通异常

很多初版系统喜欢这样返回:

type BadToolResult = {
  ok: false;
  error: string;
};

比如:

{
  "ok": false,
  "error": "执行失败"
}

这当然比程序直接崩掉好,但对 Agent 没什么帮助。

因为它不知道下一步应该做什么。

更危险的是,很多系统会写出这种“无脑继续”的逻辑:

async function unsafeContinue(plan: PlanStep[]) {
  for (const step of plan) {
    const result = await act(step);
    step.observation = result;
  }

  return generateFinalAnswer(plan);
}

这段代码的问题是:不管工具成功还是失败,后续步骤都继续执行。

如果查订单失败了,还继续查补偿政策;如果权限不足,还继续生成完整答案。用户看到的结果很顺,但可信度已经坏了。

3. 正确做法:给失败分类,再映射处理策略

先把错误类型结构化。

type ToolErrorType =
  | "invalid_arguments"
  | "unknown_tool"
  | "not_found"
  | "timeout"
  | "permission_denied"
  | "confirmation_required"
  | "empty_result";

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

每一种错误的含义都不同:

  • timeout:可能只是网络抖动。
  • invalid_arguments:模型或程序传参错了。
  • not_found:目标资源不存在。
  • permission_denied:用户没有权限。
  • confirmation_required:动作有风险,需要用户确认。
  • empty_result:查询成功了,但没有找到内容。

错误类型越清楚,Agent 越能做正确决策。

接着设计处理策略:

type FailureDecision =
  | "retry"
  | "fallback"
  | "pause"
  | "stop";

const failureStrategies: Record<ToolErrorType, FailureDecision> = {
  timeout: "retry",
  empty_result: "fallback",
  not_found: "stop",
  invalid_arguments: "stop",
  unknown_tool: "stop",
  permission_denied: "stop",
  confirmation_required: "pause"
};

这张表非常朴素,但非常有用。

它把“失败了怎么办”从一句模糊的话,变成了明确工程策略。

4. 重试要克制,不要把 retry 当万能药

看到失败,很多人的第一反应是:

那就重试。

重试确实有用,但不能滥用。

比如接口超时,可以重试。
但下面这些错误,重试通常没有意义:

  • 工具不存在。
  • 参数缺失。
  • 用户没权限。
  • 订单不存在。

所以每个步骤都应该有重试计数:

type PlanStep = {
  id: string;
  goal: string;
  toolName: string;
  args: Record<string, unknown>;
  status:
    | "pending"
    | "running"
    | "done"
    | "failed"
    | "paused"
    | "fallback";
  retryCount: number;
  maxRetries: number;
  observation?: unknown;
  error?: ToolResult;
  fallbackReason?: string;
};

重试判断可以这样写:

function canRetry(step: PlanStep, result: ToolResult) {
  return (
    !result.ok &&
    result.errorType === "timeout" &&
    step.retryCount < step.maxRetries
  );
}

没有上限的重试,不叫韧性,叫迷路。

5. 降级不是糊弄用户,而是诚实表达边界

还有一种失败很常见:空结果。

比如政策搜索工具返回:

{
  "ok": false,
  "errorType": "empty_result",
  "message": "没有找到相关政策"
}

这时候不一定要让整个任务失败。

Agent 可以降级回答:

我查到了订单 A1001 当前仍在运输中,但没有找到明确的延迟补偿政策。建议你联系人工客服确认是否可申请补偿。

这就是降级。

降级不是假装成功。

降级是:

  • 告诉用户哪些信息查到了。
  • 告诉用户哪些信息没查到。
  • 不编造不存在的依据。
  • 给出下一步建议。

这比“为了完整而胡编”可靠得多。

6. 暂停也是一种能力

有些操作不能失败后直接结束,也不能自动继续。

例如:

帮我直接取消这个订单。

取消订单是有副作用的高风险操作。

即使模型判断要调用 cancelOrder,程序也应该返回:

{
  "ok": false,
  "errorType": "confirmation_required",
  "message": "取消订单需要用户确认。"
}

这时候 Agent 的正确行为不是继续执行,而是暂停:

这个操作会取消订单 A1001。请确认是否继续。

暂停不是不智能。

暂停是安全边界的一部分。

一个系统如果不知道什么时候停下来问用户,就不适合处理真实业务。

7. 把失败处理接进 Plan-Act-Observe

第 26 篇我们有:

Plan -> Act -> Observe

现在加上失败策略:

Plan
-> Act
-> 如果成功:Observe Success
-> 如果失败:Handle Failure
-> Retry / Fallback / Pause / Stop

核心代码可以这样写:

function handleStepFailure(step: PlanStep, result: ToolResult) {
  if (result.ok) return "none";

  const decision = failureStrategies[result.errorType];

  if (decision === "retry" && canRetry(step, result)) {
    step.retryCount += 1;
    step.status = "pending";
    step.error = result;
    return "retry";
  }

  if (decision === "fallback") {
    step.status = "fallback";
    step.fallbackReason = result.message;
    step.error = result;
    return "fallback";
  }

  if (decision === "pause") {
    step.status = "paused";
    step.error = result;
    return "pause";
  }

  step.status = "failed";
  step.error = result;
  return "stop";
}

再把它接进执行循环:

async function runStepWithFailureControl(step: PlanStep) {
  step.status = "running";

  const result = await act(step);

  if (result.ok) {
    step.status = "done";
    step.observation = result.data;
    return "continue";
  }

  const decision = handleStepFailure(step, result);

  if (decision === "retry") {
    return "retry";
  }

  if (decision === "fallback") {
    return "continue";
  }

  if (decision === "pause") {
    return "pause";
  }

  return "stop";
}

这让 Agent 多了一套刹车系统。

它不再是“计划里有几步就硬跑几步”,而是会根据错误类型做不同处理。

8. 最终答案必须解释失败

失败处理还有一个关键点:最终答案不能只说“失败了”。

它应该说明:

  • 哪些步骤成功了。
  • 哪些步骤失败了。
  • 失败原因是什么。
  • 是否重试过。
  • 是否降级了。
  • 用户下一步可以做什么。

可以从 plan 里生成一个更清楚的回答:

function summarizePlan(plan: PlanStep[]) {
  const done = plan.filter((step) => step.status === "done");
  const failed = plan.filter((step) => step.status === "failed");
  const fallback = plan.filter((step) => step.status === "fallback");
  const paused = plan.filter((step) => step.status === "paused");

  return {
    done: done.map((step) => step.goal),
    failed: failed.map((step) => ({
      goal: step.goal,
      reason: step.error && !step.error.ok ? step.error.message : "未知错误"
    })),
    fallback: fallback.map((step) => ({
      goal: step.goal,
      reason: step.fallbackReason
    })),
    paused: paused.map((step) => ({
      goal: step.goal,
      reason: step.error && !step.error.ok ? step.error.message : "等待确认"
    }))
  };
}

用户侧表达可以是:

我查到了订单 A1001 当前仍在运输中。
但在查询延迟补偿政策时没有找到明确结果,因此不能确认是否可自动申请补偿。
建议你联系人工客服,并提供订单号 A1001 进一步确认。

这类回答虽然不“全能”,但可信。

AI 产品最怕的不是说“我不知道”。
最怕的是不知道还装知道。

9. 前端开发者怎么理解失败处理

前端其实非常懂失败处理。

你写页面时不会只写成功态。

你还会考虑:

  • loading。
  • empty。
  • error。
  • disabled。
  • retry。
  • permission denied。
  • confirm modal。

Agent 也是一样。

工具调用的失败态,就是 AI 系统里的 error state。

高风险确认,就是 AI 系统里的 confirm modal。

空结果降级,就是 AI 系统里的 empty state。

重试上限,就是 AI 系统里的防抖和保护阈值。

所以前端经验在这里非常有价值。

你不是从零开始学 Agent。

你是在把已有的工程直觉迁移到 AI 系统里。

10. 生产环境避坑指南

1. 只重试临时性错误

适合重试的通常是 timeout、临时网络错误、上游服务短暂不可用。

不适合重试的是 invalid_argumentspermission_deniednot_foundunknown_tool

2. 重试必须有上限和间隔

每一步都要有 retryCountmaxRetries

更进一步,可以加指数退避,避免把上游服务打爆。

3. fallback 不能伪装成成功

降级回答必须告诉用户哪些信息拿到了,哪些信息没拿到。

不要把空结果包装成确定结论。

4. pause 必须能恢复

如果高风险操作进入 paused,前端要能保存当前 plan,并在用户确认后从暂停步骤继续。

不要让用户确认之后系统重新从第一步跑一遍。

5. 失败要进日志和评测集

每次失败都应该记录:

  • traceId
  • step id
  • tool name
  • errorType
  • retryCount
  • final decision

高频失败样本应该回流到测试用例或 Agent 评测集。

11. 常见误区

误区 1:失败了就让模型再试一次

不对。模型重试不是万能药。只有临时性错误才适合重试。

误区 2:降级就是糊弄用户

不是。好的降级是透明说明边界,不编造结果,并给出下一步建议。

误区 3:工具失败了也让 Agent 继续跑完整计划

危险。关键步骤失败时应该停止或暂停,而不是继续生成看似完整的答案。

误区 4:错误信息写给开发者看就行

不够。内部错误要结构化,用户侧表达要清楚、克制、可行动。

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

如果你在团队里做 Agent 失败处理,可以从这份清单开始:

  1. 所有工具失败都必须返回 errorType
  2. 错误类型要能映射到处理策略。
  3. 只有临时性错误才允许重试。
  4. 每一步都要有 retryCountmaxRetries
  5. 高风险工具必须支持暂停确认。
  6. 空结果可以降级,但不能假装成功。
  7. 关键步骤失败后不要继续硬跑。
  8. 最终答案要说明成功、失败、跳过和下一步建议。
  9. 执行日志要记录每次重试。
  10. 测试用例必须覆盖成功、重试、降级、暂停、停止。

这份清单不华丽,但很保命。

Agent 越像能办事的系统,越要认真处理失败。

结语

Agent 真正难的,不是会执行。

真正难的是失败后不乱执行。

它要知道什么时候重试,什么时候降级,什么时候暂停,什么时候停止。

这听起来没有“自主智能体”那么炫,但它决定了系统能不能进入真实业务。

一个可靠的 Agent,不是永远顺利地跑到终点。

一个可靠的 Agent,是遇到坑时不会假装没看见,而是踩刹车、留痕迹、说清楚,然后给用户一个可信的下一步。

前端开发者做多步 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,是模型提出建议,程序负责把关,工具在清晰边界内执行。

❌