AI 工具调用不是能调通就行:错误处理和可观测性才是落地关键

AI 应用一旦从问答走向执行,工具调用就绕不开。

查订单、搜文档、读数据库、发邮件、创建工单、调用内部系统,这些动作都需要模型通过工具和外部世界交互。Demo 阶段只要能调通一次,大家会觉得很兴奋:模型不只是会说,还能做事了。

但真实落地后你会发现,工具调用最难的不是“能不能调用”,而是调用失败时系统怎么处理。

参数错了怎么办?

接口超时怎么办?

查不到数据怎么办?

工具返回了半截结果怎么办?

模型误解工具输出怎么办?

这些问题如果没有统一设计,Agent 很快会从“自动化助手”变成“随机失败生成器”。

我自己第一个上线的内部 Agent,就栽在这上面。当时演示给老板看一切顺利,上线第二天客服群里就开始飘各种截图:用户问“我的订单到哪了”,AI 一本正经回复了一个根本不存在的物流单号;有人问退款,工具超时之后模型自己脑补了一句“已为您提交退款申请”——但实际上什么都没提交。那一周我加班补的全是失败路径的处理,没写一行新功能。从那以后我才真正理解,工具调用这件事,写“成功怎么走”只是开了个头,难的是把所有“走不通”的分支都收住。

工具调用的失败类型

我通常会把工具调用失败分成几类。

第一类是参数错误。模型给了不符合 schema 的参数,比如把字符串传成数组,把日期写成自然语言,或者漏掉必填字段。

第二类是业务错误。工具正常执行了,但业务上无法完成,比如订单不存在、用户无权限、库存不足、文档未找到。

第三类是系统错误。接口超时、服务不可用、网络失败、数据库连接异常,都属于这类。

第四类是语义错误。工具返回了结果,但模型理解错了,或者把多个候选结果混成一个结论。

这几类问题不能混在一起处理。参数错误应该让模型修正输入,业务错误应该给用户明确反馈,系统错误可能需要重试或降级,语义错误则要回到上下文和输出约束上排查。

这四类里,最容易被忽略的其实是第四类语义错误。前三类好歹会抛异常、会返回错误码,监控能抓到。语义错误是工具一切正常、HTTP 200、数据也对,但模型把结果读歪了。我遇到过最典型的一次:搜索工具返回了三条候选客户,模型把第一条的姓名和第三条的手机号拼在了一起回给用户,看起来非常自信,完全没法靠状态码发现。后来我处理这类问题的思路是——能在工具层就消灭歧义的,绝不留给模型判断。比如搜索类工具如果命中多条,我宁可显式返回一个 multiple_matches 的结构,让模型必须走澄清流程,也不让它在一堆候选里自由发挥。

所以我分类的目的不是为了好看,而是为了让每一类失败都有一个明确的“责任人”:参数错误归 schema 和校验,业务错误归产品话术,系统错误归重试和降级,语义错误归输出约束和结果裁剪。一旦混在一起,你就会写出那种“catch 了所有异常然后统一返回‘出错了请重试’”的代码,用户重试一百次也还是那个错。

工具参数必须校验

不要因为参数是模型生成的,就默认它可信。

工具调用入口应该像普通 API 一样做校验:

1type SearchOrderInput = {
2  orderId: string;
3};
4
5function validateSearchOrderInput(input: unknown): SearchOrderInput {
6  if (
7    typeof input !== "object" ||
8    input === null ||
9    typeof (input as SearchOrderInput).orderId !== "string"
10  ) {
11    throw new ToolInputError("INVALID_ORDER_ID", "订单号格式不正确");
12  }
13
14  return {
15    orderId: (input as SearchOrderInput).orderId.trim(),
16  };
17}

这段代码看起来基础,但非常重要。模型输出参数只是候选输入,真正进入业务系统前必须经过确定性代码检查。

如果你让模型生成的参数直接打到内部接口,相当于把最不稳定的一层放在了最靠近业务核心的位置。

后来我更倾向于把工具 schema 当成接口契约,而不是 Prompt 里的说明文字。

1{
2  "name": "searchOrder",
3  "description": "根据订单号查询订单配送状态",
4  "input_schema": {
5    "type": "object",
6    "required": ["orderId"],
7    "properties": {
8      "orderId": {
9        "type": "string",
10        "description": "订单号,只接受系统订单 ID"
11      }
12    },
13    "additionalProperties": false
14  }
15}

schema 的作用有两层。第一层是让模型更容易生成正确参数;第二层是让系统可以确定性拒绝不合法输入。真正上线时,第二层更重要。

即使模型支持结构化工具调用,我也不会跳过服务端校验。模型给出的结构化参数仍然是外部输入,不能因为它长得像 JSON 就默认可信。

后来我干脆不再手写 typeof 这种校验,太啰嗦也容易漏。我用 zod 把 schema 写一遍,既能在运行时校验,又能反过来生成给模型的 JSON Schema,一份定义两头用:

1import { z } from "zod";
2
3const SearchOrderSchema = z.object({
4  orderId: z
5    .string()
6    .trim()
7    .regex(/^[A-Z]\d{4,}$/, "订单号必须是字母开头加至少四位数字"),
8});
9
10function validateSearchOrderInput(input: unknown): SearchOrderInput {
11  const result = SearchOrderSchema.safeParse(input);
12  if (!result.success) {
13    throw new ToolInputError(
14      "INVALID_ORDER_ID",
15      result.error.issues.map((i) => i.message).join("; "),
16    );
17  }
18  return result.data;
19}

这里有个细节是我踩过坑才加上的:校验失败时,message 一定要写成模型能据此修正的人话,而不是 expected string, received array 这种 zod 默认报错。因为这个 message 会原样回到模型上下文里,模型靠它来决定下一步怎么重新组织参数。如果你给的是堆栈味十足的英文报错,模型大概率会重复犯同样的错,然后你就看到它在那儿原地打转、连调三四次都是同一个参数。把报错写成“订单号必须是字母开头加至少四位数字”,模型一次基本就能改对。

另一个容易忽略的点是“宽进严出”里的“宽”。模型经常会画蛇添足,比如你只要 orderId,它非要带一个 userId 进来。我一度对多余字段直接报错,结果反而触发了无谓的重试。后来想通了:多余字段如果无害,additionalProperties: false 在 schema 层声明清楚就行,运行时校验里用 .strip() 静默丢掉,没必要为它打断整个流程。该严的是必填和格式,该宽的是无害的冗余。

工具错误要统一结构

工具返回给模型的错误,最好不要是原始异常。

原始错误里可能有无关堆栈、内部路径、数据库信息,既污染上下文,也可能带来安全风险。更好的方式是统一包装:

1type ToolResult<T> =
2  | {
3      ok: true;
4      data: T;
5    }
6  | {
7      ok: false;
8      error: {
9        code: string;
10        message: string;
11        retryable: boolean;
12      };
13    };

工具执行时统一返回:

1async function searchOrder(input: unknown): Promise<ToolResult<Order>> {
2  try {
3    const params = validateSearchOrderInput(input);
4    const order = await orderService.findById(params.orderId);
5
6    if (!order) {
7      return {
8        ok: false,
9        error: {
10          code: "ORDER_NOT_FOUND",
11          message: "没有找到对应订单",
12          retryable: false,
13        },
14      };
15    }
16
17    return { ok: true, data: order };
18  } catch (error) {
19    return normalizeToolError(error);
20  }
21}

这样模型看到的是可理解、可行动的错误,而不是一段随机异常文本。

normalizeToolError 这个函数我建议每个项目都认真写一遍,它是错误结构能不能统一的关键。我现在的版本大致长这样:

1function normalizeToolError(error: unknown): ToolResult<never> {
2  if (error instanceof ToolInputError) {
3    return {
4      ok: false,
5      error: { code: error.code, message: error.message, retryable: false },
6    };
7  }
8  if (error instanceof TimeoutError || error instanceof FetchError) {
9    return {
10      ok: false,
11      error: {
12        code: "UPSTREAM_TIMEOUT",
13        message: "下游服务暂时不可用",
14        retryable: true,
15      },
16    };
17  }
18  // 兜底:内部错误一律不把原始信息透给模型
19  logger.error("unhandled tool error", { error });
20  return {
21    ok: false,
22    error: {
23      code: "INTERNAL_ERROR",
24      message: "内部错误,请稍后再试",
25      retryable: false,
26    },
27  };
28}

兜底分支里我特意做了两件事:一是把真实异常打到日志(给人看),二是只回一句无信息量的“内部错误”给模型(给模型看)。这两个受众的需求是相反的——人需要堆栈和上下文去定位,模型需要的是干净、可行动、不含敏感信息的反馈。一开始我图省事直接 error.message 透传,结果有一次把数据库连接串里的内网地址带进了模型上下文,虽然没酿成事故,但那一下让我决定彻底把“给人的错误”和“给模型的错误”这两条路分开。

不是所有失败都应该重试

重试是工具调用里很容易被滥用的策略。

接口超时可以重试,临时网络错误可以重试,但参数错误、权限错误、数据不存在,重试多少次都没意义。盲目重试只会增加延迟、放大成本,还可能对下游系统造成压力。

我会给每个错误明确 retryable

1{
2  "ok": false,
3  "error": {
4    "code": "UPSTREAM_TIMEOUT",
5    "message": "订单服务响应超时",
6    "retryable": true
7  }
8}

然后重试策略由确定性代码控制,而不是让模型自己决定要不要再试。

1async function runWithRetry(fn, maxRetries = 2) {
2  let lastResult;
3
4  for (let attempt = 0; attempt <= maxRetries; attempt++) {
5    lastResult = await fn();
6
7    if (lastResult.ok || !lastResult.error.retryable) {
8      return lastResult;
9    }
10  }
11
12  return lastResult;
13}

模型可以参与判断下一步怎么和用户沟通,但不要把基础稳定性完全交给模型自由发挥。

上面这个 runWithRetry 是最简版本,真正上线我还会补两样东西:退避和超时。连续打同一个超时的下游,不退避只会雪上加霜,所以重试之间要加指数退避加抖动;另外整个调用链得有个总预算,不能因为重试把一次用户提问拖到十几秒。

1async function runWithRetry(fn, { maxRetries = 2, budgetMs = 8000 } = {}) {
2  const deadline = Date.now() + budgetMs;
3  let lastResult;
4  for (let attempt = 0; attempt <= maxRetries; attempt++) {
5    lastResult = await fn();
6    if (lastResult.ok || !lastResult.error.retryable) return lastResult;
7    if (Date.now() >= deadline) break;
8    const backoff = Math.min(1000 * 2 ** attempt, 3000) + Math.random() * 200;
9    await new Promise((r) => setTimeout(r, backoff));
10  }
11  return lastResult;
12}

还有个反直觉的经验:重试这件事最好对模型透明。也就是说,确定性代码重试了三次最终还是失败,回给模型的应该是一次干净的失败结果,而不是把三次失败的过程都塞进上下文。我早期把每次重试都作为一条 tool message 喂回去,结果模型看到“失败、失败、失败”,自己也跟着慌,开始向用户道歉式地反复确认。重试是基础设施层的事,模型只需要知道最终结论。

有副作用的工具要做幂等和确认

读工具和写工具要分开看。

查询订单、搜索文档、读取配置,失败了大多可以重试。但创建工单、发送邮件、修改配置、提交审批,这些工具一旦执行就会改变外部系统。它们不能只靠“模型觉得应该执行”。

我现在会给有副作用的工具加三层约束:

  • 先 dry run,返回将要执行的动作。
  • 关键动作需要用户确认或规则确认。
  • 真正执行时带幂等键,避免重复提交。
1async function createTicket(input, context) {
2  const params = validateCreateTicketInput(input);
3
4  if (!context.confirmed) {
5    return {
6      ok: false,
7      error: {
8        code: "CONFIRMATION_REQUIRED",
9        message: `将为 ${params.system} 创建故障工单,请确认后继续`,
10        retryable: false,
11      },
12    };
13  }
14
15  return ticketService.create({
16    ...params,
17    idempotencyKey: context.traceId,
18  });
19}

这类设计看起来不像“AI 能力”,但它决定了系统敢不敢接近真实业务。只读工具可以相对开放,写工具必须收紧。

幂等键怎么选是个有讲究的地方。我一开始随手用 traceId 当幂等键,看着没问题,直到出现一个场景:用户在同一轮对话里说“帮我建两个一模一样的工单”,结果第二个被幂等掉了,模型还以为成功了。问题就出在 traceId 粒度太粗——它标识的是“这一次请求”,而不是“这一个动作”。后来我改成基于动作语义来生成键,比如把 system + 标题 + 用户意图哈希 组合起来,真正想表达“同一个动作不要重复执行”时才会撞键。幂等键要回答的是“什么情况下算重复”,这个定义得贴着业务来,不能图省事拿个现成的 ID 塞进去。

另一个坑是确认流程的状态。context.confirmed 这个标志位是从哪来的,得想清楚。如果它来自前端按钮,那相对可靠;但如果是模型在多轮里自己判断“用户应该是确认了”,那就危险了——我见过模型把用户的“嗯”理解成对一个高风险操作的确认。所以高风险动作的确认,我坚持要走带显式语义的通道,比如前端弹一个确认卡片、用户点了之后才把 confirmed: true 带进 context,而不是让模型从自然语言里去猜“用户到底同意了没”。

工具结果要给模型“够用的信息”

工具返回太少,模型会猜。

工具返回太多,模型会乱。

所以工具结果最好经过裁剪,只返回完成当前任务需要的字段。

比如查订单时,内部系统可能有几十个字段,但用户只问“我的快递到哪了”,模型不需要看到支付流水、优惠券、内部备注。

1{
2  "orderId": "A1024",
3  "status": "shipped",
4  "delivery": {
5    "company": "顺丰",
6    "trackingNo": "SF123456",
7    "latestEvent": "已到达上海分拨中心",
8    "updatedAt": "2025-10-14T09:30:00+08:00"
9  }
10}

这类结果足够回答问题,也不暴露多余信息。工具输出不是数据库 dump,而是模型执行任务的上下文材料。

裁剪除了安全和清晰,还有一个很现实的理由:token 和成本。我们有个工具早期直接把内部接口的响应整个透传,单条订单 JSON 三千多 token,一轮多查几次,上下文一下就被撑爆,既慢又贵,还把真正重要的字段埋在一堆噪声里,模型反而更容易抓错重点。把返回裁到二十几个 token 之后,不光省钱,准确率肉眼可见地上来了。这件事让我意识到,工具输出的“信息密度”几乎和参数校验一样重要——给模型喂的每一个字段,它都会当成需要理解的信号,无关字段不是中性的,是干扰。

我现在的习惯是给每个工具单独写一个 toModelView 函数,专门负责把内部数据结构映射成给模型看的精简视图。内部 DTO 怎么改是内部的事,给模型的契约由这个函数稳定守住,两边解耦。这样下游接口加字段、改结构,也不会莫名其妙影响到模型的行为。

可观测性非常关键

Agent 出问题时,如果没有日志,排查会很痛苦。

你需要知道:

  • 模型为什么选择这个工具
  • 传了什么参数
  • 工具返回了什么
  • 是否发生重试
  • 最终回答引用了哪些结果
  • 失败集中在哪个工具或错误码

我会给每次工具调用记录一个结构化日志:

1{
2  "traceId": "trace-20251014-001",
3  "tool": "searchOrder",
4  "input": {
5    "orderId": "A1024"
6  },
7  "result": "success",
8  "latencyMs": 238,
9  "model": "agent-model",
10  "createdAt": "2025-10-14T10:12:00+08:00"
11}

如果失败:

1{
2  "traceId": "trace-20251014-002",
3  "tool": "searchOrder",
4  "result": "error",
5  "errorCode": "ORDER_NOT_FOUND",
6  "retryable": false,
7  "latencyMs": 91
8}

有了这些日志,你才能做失败分析。否则每次用户说“AI 又答错了”,团队只能看最终回答猜原因。

这里我踩过一个真实的坑:日志里千万别把工具的原始参数和返回无脑全存。input 里很可能有手机号、地址、订单号这些 PII,直接落盘到日志系统,合规那关就过不去。我现在的做法是在记日志前过一层脱敏,敏感字段只留掩码或哈希,既能用于排查“同一个用户是不是反复失败”,又不会把明文存进可被广泛检索的日志里。

traceId 要贯穿一整轮对话,这点我强调过头都不为过。一次用户提问可能触发模型连续调用三四个工具,如果每个工具各记各的、没有串起来的 ID,事后你根本拼不出“它当时到底是怎么一步步走到这个错误答案的”。我会在对话入口生成一个 traceId,往下透传到每一次工具调用,再配一个 step 序号。排查时按 traceId 一拉,整条执行链就出来了。

光有单条日志还不够,真正能驱动改进的是聚合。我会定期按 errorCodetool 维度做汇总,看失败到底集中在哪。有一次跑出来 INVALID_DATE 占了某个工具失败的七成,顺藤摸瓜发现是 schema 的 description 里日期格式写得含糊,模型老把“下周一”这种相对时间直接塞进去。我没改一行代码,只是把 description 改成“必须是 YYYY-MM-DD 的绝对日期,相对时间请先自行换算”,那个错误码第二天就基本消失了。这种改进,没有聚合数据是发现不了的——你光看单条日志,只会觉得“偶尔有个日期错”,永远意识不到它其实是个系统性问题。

工具权限不能省

工具调用还有一个风险:模型可能选择了正确工具,但当前用户没有权限执行。

权限判断必须在工具层做,不能只靠 Prompt 写“不要访问无权限数据”。

1async function getCustomerProfile(input, context) {
2  if (!context.permissions.includes("customer:read")) {
3    return {
4      ok: false,
5      error: {
6        code: "PERMISSION_DENIED",
7        message: "当前用户没有查看客户资料的权限",
8        retryable: false,
9      },
10    };
11  }
12
13  return customerService.findProfile(input.customerId);
14}

Prompt 是约束,权限是系统边界。两者不能互相替代。

工具还要按风险分级。比如:

1L1 只读公开信息:搜索文档、查帮助中心
2L2 只读业务信息:查订单、查客户资料
3L3 可写低风险动作:创建草稿、生成待确认工单
4L4 可写高风险动作:发邮件、改配置、提交审批

不同等级对应不同权限、日志和确认策略。不要把所有工具都放在一个“可调用工具列表”里让模型自由挑。工具越靠近业务核心,越需要确定性系统把边界守住。

还有个跟权限相关、但经常被分开看待的问题:工具暴露面。很多人喜欢把所有工具一股脑都注册给模型,觉得能力越多越好。但工具列表越长,模型选错工具的概率越高,prompt 也越长越贵。我后来按场景和用户角色动态裁剪可见工具集——客服坐席的会话里就不该出现 L4 的改配置工具,哪怕权限层会拦住,根本不让模型看见它,比让它选了再被拒要干净得多。少给模型几把它本来就不该碰的“枪”,比事后一个个去拦要省心。

权限校验我也建议尽量收敛到一处,而不是每个工具的开头都手抄一遍 if (!context.permissions.includes(...))。我的做法是在工具注册时声明它需要的权限,由统一的执行器在调用前集中检查:

1const tools = {
2  getCustomerProfile: {
3    requiredPermission: "customer:read",
4    riskLevel: "L2",
5    handler: getCustomerProfileHandler,
6  },
7};
8
9async function invokeTool(name, input, context) {
10  const tool = tools[name];
11  if (!context.permissions.includes(tool.requiredPermission)) {
12    return permissionDenied(tool.requiredPermission);
13  }
14  return tool.handler(input, context);
15}

这样权限是声明式的,新加工具时漏写校验的概率小很多,审计起来也是一张表能看完,不用去翻每个 handler 的实现。

我自己的经验

工具调用最容易在 Demo 阶段被高估。

因为 Demo 输入干净、路径固定、工具正常、网络稳定,模型只要按预期调用一次就很惊艳。但真实业务里,失败路径比成功路径多得多。能不能落地,看的是系统在参数不完整、工具超时、查不到数据、权限不足时,还能不能给出可解释、可恢复的反馈。

我现在看 Agent 应用,会先看工具层设计,而不是先看模型多聪明。工具 schema 是否清晰,错误是否统一,日志是否完整,权限是否可靠,这些东西决定了系统上线后能不能维护。

延伸阅读

推荐看 OpenAI Cookbook 关于 tool calling 和 structured outputs 的文章,也可以看 LangGraph、LlamaIndex、Semantic Kernel 里关于工具、状态和观测的设计。不要只看它们怎么调模型,更要看它们如何管理失败、状态和执行路径。

AI 工具调用不是“模型会调用接口”就结束了。

真正的工程问题在失败路径:参数错了怎么修正,工具挂了怎么降级,权限不足怎么拒绝,结果不完整怎么追问,线上出错怎么追踪。工具调用让 AI 能做事,也让 AI 应用承担了真实系统的复杂度。越接近业务核心,越要让这些失败分支交给确定性代码处理,而不是指望模型每次都判断对。