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 一拉,整条执行链就出来了。
光有单条日志还不够,真正能驱动改进的是聚合。我会定期按 errorCode 和 tool 维度做汇总,看失败到底集中在哪。有一次跑出来 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 应用承担了真实系统的复杂度。越接近业务核心,越要让这些失败分支交给确定性代码处理,而不是指望模型每次都判断对。