引言

之前文章我们讲清了 Agent 的"大脑"(LLM)和"思路"(推理范式)。但光会想不会动,不是 Agent。这一篇进入第二部分,聊 Agent 与外部世界交互的那只"手"——工具调用(Tool Use)。你会看到:所谓"Agent 能调工具",底层到底发生了什么;工具的 Schema 该怎么设计;并行调用、结构化输出、错误重试这些实战里绕不开的坑该怎么处理。

一、先搞清楚:LLM 根本不会"执行"任何工具

这是初学者最大的误解。我们常说"大模型调用了天气 API",听起来像是模型自己发了个 HTTP 请求。它没有,也不能。

LLM 能做的唯一一件事,还是老本行——生成文本。所谓"工具调用",本质是这样一个约定:

你(开发者)告诉模型"有哪些工具、每个工具要什么参数";模型在需要时生成一段结构化文本,说"我想调用 get_weather,参数 city=北京";然后由你的代码去真正执行这个函数,再把结果当成文本喂回给模型。

模型负责"决定调什么、传什么参数",你的代码(工程侧)负责"真的去调"。这个分工一定要刻在脑子里,否则后面所有的错误处理都会想歪

       ┌─────────────────────────────────────────────┐
       │                  你的代码                     │
       │                                               │
  用户  │   ①带着工具清单问模型          ③真正执行函数  │
  ───▶ │   ─────────────────▶ [LLM]    ┌──────────┐   │
       │                        │       │ 天气 API  │   │
       │   ②模型返回"想调        │──────▶│ 数据库    │   │
       │     get_weather(北京)"  │       │ 计算器    │   │
       │                        │◀──────└──────────┘   │
       │   ④把结果文本喂回模型 ───┘        返回:晴 26℃    │
       │                                               │
       │   ⑤模型基于结果生成最终回答                     │
       └─────────────────────────────────────────────┘

二、演进:从"求模型输出 JSON"到原生 Function Calling

工具调用的能力不是一开始就有的,它经历了三个阶段,了解这段演进能帮你理解为什么现在的方式更可靠。

阶段一:纯 Prompt 硬解析(2022 及更早)。 那时模型没有工具概念,大家只能在提示里写:"如果需要查天气,请严格按 ACTION: get_weather(城市名) 的格式输出。"然后用正则去抠这段文本。这套 ReAct 的原始玩法能跑,但极其脆弱:模型可能多打个空格、把中文括号写成英文、或者干脆用自然语言"我需要查一下北京的天气"绕开你的格式。解析失败率高,是那个年代 Agent 不稳定的主因之一。

阶段二:原生 Function Calling(2023 中,OpenAI 首推)。 厂商把"工具"变成了 API 的一等公民:你在请求里用专门的 tools 字段传入工具的 JSON Schema,模型返回时也用专门的 tool_calls 字段给出结构化的调用意图——参数已经是解析好的 JSON,不用你再正则。模型还专门为此做了微调,格式遵循度大幅提升。这是 Agent 走向可靠的关键一步。

阶段三:并行调用 + 结构化输出 + 强约束(2024 至今)。 模型可以一次返回多个工具调用(并行);可以用 JSON Schema 强制约束输出结构(structured output),甚至保证 100% 合法 JSON;工具选择的准确率也越来越高。MCP(下一篇的主角)进一步把工具的接入方式也标准化了。

一句话总结演进方向:把"靠提示词祈祷模型格式正确",变成"靠协议和约束保证格式正确"。 今天除非你在用一个不支持 Function Calling 的老模型,否则永远优先用原生能力,不要自己回到正则解析的石器时代。

三、工具 Schema 设计:描述即文档,描述即产品

工具调用可靠不可靠,一大半取决于你怎么描述工具。因为模型选不选这个工具、传什么参数,唯一的依据就是你给的 Schema。它读不到你的函数实现,只能读描述。

一个典型的工具 Schema 长这样(JSON Schema 格式):

{
  "type": "function",
  "function": {
    "name": "search_flights",
    "description": "查询两地之间指定日期的航班。仅用于机票查询,不能查火车。",
    "parameters": {
      "type": "object",
      "properties": {
        "from_city": {"type": "string", "description": "出发城市中文名,如'北京'"},
        "to_city":   {"type": "string", "description": "到达城市中文名,如'上海'"},
        "date":      {"type": "string", "description": "出发日期,格式 YYYY-MM-DD"},
        "cabin":     {"type": "string", "enum": ["经济舱","商务舱"], "description": "舱位,默认经济舱"}
      },
      "required": ["from_city", "to_city", "date"]
    }
  }
}

这里面每个字段都在替你"教"模型,几条实战原则:

描述要写给模型看,像写给一个没有上下文的新同事。 description 不是注释,它是模型的决策依据。"查询航班"不如"查询两地之间指定日期的航班";更要紧的是写清边界——"仅用于机票查询,不能查火车",这一句能挡掉大量误用。之前文章讲的"工具描述要像招聘 JD 一样精确"是同一件事。

参数用 enum、format、required 尽量收紧。 能枚举就别让模型自由发挥。cabin 给了 enum,模型就不会传"头等舱豪华座"这种你后端不认识的值。日期用描述 + 示例锁死格式,能省掉大量清洗。required 明确哪些必填,避免模型漏传。

参数数量要克制,宁可拆成多个工具。 一个工具塞十几个参数,模型很容易传错或漏传。如果一个工具能干好几件事(用一个 action 参数区分),不如拆成几个职责单一的小工具——模型选工具比填复杂参数更擅长。

工具总数也要克制。 给模型挂五十个工具,它的选择准确率会明显下降,而且每个 Schema 都占 token(成本 + 挤占上下文)。工具多的时候,考虑按场景分组、动态只挂载当前相关的工具,或加一层路由

记住一个心法:你不是在写函数签名,你是在写一份让模型能读懂的产品说明书。 花在打磨 description 上的时间,回报率极高。

四、并行工具调用:效率红利与它带来的坑

现代模型支持在一轮里返回多个工具调用。比如用户问"北京和上海今天各自天气怎么样",模型可以一次性返回两个 get_weather 调用,你并发执行,延迟几乎减半。

在本系列第一篇的最小 Agent 里,我们已经见过这个结构——reply.toolCalls 是一个列表,循环执行。真正做并行,只是把这个循环从串行改成并发:

// reply.toolCalls 可能包含多个调用,并发执行以降低延迟
List<CompletableFuture<Map<String,Object>>> futures = reply.toolCalls.stream()
    .map(tc -> CompletableFuture.supplyAsync(() -> {
        String result = callTool(tc.name, tc.args);   // 真正执行
        return Map.<String,Object>of(
            "role", "tool", "tool_call_id", tc.id, "content", result);
    }, executor))
    .toList();

// 等全部完成后,按顺序把结果加回上下文
futures.stream().map(CompletableFuture::join).forEach(messages::add);

看起来很美,但并行带来两个必须警惕的坑:

坑一:只有"相互独立"的调用才能并行。 查北京天气和查上海天气互不依赖,可以并行。但如果第二步依赖第一步的结果(先查用户 ID,再用 ID 查订单),模型本应分两轮串行来做。模型偶尔会误判,把有依赖的调用并行发出来,导致第二个调用参数缺失。你的工具实现要能容忍这种情况(返回明确的错误,让模型下一轮纠正),而不是崩溃。

坑二:并行写操作的安全性。 并行读通常没问题,但如果多个并行调用都在(改库存、发消息、转账),要格外小心竞态和重复执行。涉及写操作时,宁可牺牲一点延迟改成串行,或在工具内部做好并发控制。这直接引出下一节的幂等设计。

实战建议: 并行是锦上添花的性能优化,不是必需品。先把串行跑对,再在确认独立、且延迟真的是瓶颈时,针对只读工具开并行。别一上来就为了炫技把所有调用并发。

五、结构化输出:工具调用的"孪生兄弟"

很多人分不清 Function Calling 和 Structured Output(结构化输出),其实它们底层是同一套机制的两种用途。

  • Function Calling:让模型输出"我要调用哪个函数、参数是什么"——目的是触发一个动作
  • Structured Output:让模型输出"严格符合某个 JSON Schema 的数据"——目的是拿到规整的数据,不一定要执行什么。

举个例子,你想从一段简历文本里抽取"姓名、年限、技能列表",不需要调任何工具,只需要模型吐出规整 JSON:

{"name": "张三", "years": 5, "skills": ["Java", "Kubernetes"]}

过去大家靠在提示里写"请只返回 JSON,不要任何多余文字"来实现,但模型总爱加一句"好的,这是您要的结果:",或者用 ```json 包起来,解析照样出错。现在主流模型支持 response_format 指定 JSON Schema,能从解码层面保证输出必然是合法且符合 Schema 的 JSON——这是质的区别,不再是"祈祷"。

什么时候用哪个? 一个实用的判断:如果模型输出后你的代码要拿去执行副作用(调 API、写库),用 Function Calling;如果只是要把非结构化信息变成结构化数据留着自己用,用 Structured Output。Agent 的工具调用循环里,两者常常混用:比如先用工具查到原始数据,最后一步用 structured output 把答案整理成前端要的格式。

六、错误处理、重试与幂等:决定 Agent 能不能上生产

Demo 里的工具永远成功,生产里的工具随时会挂:网络超时、下游 500、参数非法、限流。Agent 能不能真正落地,一大半看错误处理做得好不好。 这里有一个和传统软件很不一样的核心原则:

工具出错时,不要直接抛异常中断循环,而要把错误"如实"变成文本喂回给模型,让它自己决定怎么办。

 背后的深意——错误也是一种"观察"。模型收到"城市名无效"后,可能会重新问用户、或换个参数重试。这是 Agent 相比传统程序独有的"自愈"能力,前提是你得把错误信息给它。

但"喂回给模型"不是万能药,要分清两类错误,分层处理:

该由代码兜底的错误(不打扰模型): 网络抖动、下游临时 5xx、限流——这些是瞬时故障,正确做法是在工具内部做自动重试(指数退避),重试几次仍失败再上报。别让模型去处理"网络抽风"这种它根本无能为力的事,那只会白白烧 token。

该交给模型的错误(喂回上下文): 参数不合法、查无结果、业务规则拒绝("该航班已售罄")——这些是语义层面的问题,只有模型(或用户)能决策下一步,必须如实反馈。反馈时要给出可操作的信息:不是干巴巴一句"错误",而是"日期格式应为 YYYY-MM-DD,你传的是'明天'",模型才知道怎么改。

              工具执行出错
                   │
        ┌──────────┴───────────┐
        ▼                      ▼
   瞬时/技术故障            语义/业务错误
   (超时、5xx、限流)       (参数错、无结果、被拒)
        │                      │
   代码内自动重试          如实写成文本
   (指数退避 N 次)         喂回给模型
        │                      │
   仍失败才上报            模型据此纠正
                          或询问用户

最后是幂等,尤其针对写操作。 Agent 循环里模型可能重复调用同一个工具(它"忘了"上一轮调过,或重试逻辑触发)。如果这是个"扣款""下单""发消息"的工具,重复执行就是事故。解决办法和分布式系统一样:给写操作加幂等键(idempotency key)——同一个业务动作用同一个 key,工具内部检测到 key 重复就直接返回上次结果,不再真正执行。涉及资金、库存、通知的工具,幂等不是可选项,是必选项。

七、踩坑记录:工具调用最容易栽的几个坑

这些都是真实会遇到的,提前知道能少走弯路:

坑一:工具描述用了模型看不懂的黑话/缩写。 你写 description: "查询 SKU 的 ATP",模型未必知道 ATP 是可用库存。描述里少用内部缩写,把话说全。

坑二:把错误吞掉、只返回空。 工具失败时 return "" 或 return null,模型拿到空结果会以为"查到了但没数据",然后一本正经地编。永远返回明确的错误文本,别让模型猜。

坑三:参数校验完全甩给模型。 别指望 Schema 的 required、enum 能 100% 兜住,模型偶尔还是会传出格的值。工具入口一定要自己再校验一遍,这是安全底线——尤其是会拼进 SQL、命令、文件路径的参数

坑四:工具返回的内容过长,撑爆上下文。 一个工具返回几万字的原始 JSON,几轮下来上下文就爆了,成本飙升还可能触发"上下文腐化"。工具的返回值也要"面向模型精简"——只回模型下一步真正需要的字段,别把整个数据库记录甩回去

坑五:死循环。 模型可能反复调用同一个工具却始终不满意,循环停不下来。务必设 maxSteps 上限。并考虑检测"连续 N 次调用相同工具且参数相同"就强制中断。

八、小结

这一篇我们钻进了 Agent 的"手":工具调用的本质是模型生成结构化的调用意图、由你的代码执行、再把结果喂回;它从脆弱的正则解析,演进到了可靠的原生 Function Calling;工具的 Schema 描述就是产品说明书,值得反复打磨;并行调用是性能红利但要防依赖和竞态;结构化输出是它的孪生兄弟,专管"拿规整数据";而错误分层处理 + 幂等设计,是 Agent 从 Demo 走向生产的分水岭。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐