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

42 阅读8分钟

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