
1. 工具调用到底解决了 Agent 的什么痛点很多人做 Agent 做到第七篇的时候手里已经有一个能对话、能记住上下文、能按角色设定回复的“聊天壳子”了。但你会发现它本质上还是个复读机——你问它今天天气它只能根据训练数据瞎编你让它帮你算个数它可能一本正经地给你一个错误答案。这不是模型不行而是它被关在了一个没有手脚的盒子里。工具调用Tool Calling / Function Calling就是给这个盒子开了一扇门。它让模型不再只输出自然语言而是能输出一段结构化的“我要调用某个工具参数是这些”的指令然后由我们的代码去真正执行这个工具再把结果喂回给模型让它基于真实结果继续推理。这个闭环一旦跑通Agent 就从“会说”变成了“会做”。我见过太多人卡在这一步模型明明返回了工具调用请求但代码里不知道怎么解析或者工具执行完了结果塞回去模型却不理睬再或者多个工具连续调用时上下文直接乱掉。这篇就围绕execute、AgentTool、ToolCall这几个核心概念把从零构建 Agent 第八步的完整链路拆开讲清楚。适合谁看如果你已经写过基础的 Agent 循环知道什么是 system prompt、什么是 message history但还没让 Agent 真正调用过外部工具那这篇就是为你准备的。如果你连 Agent 的基本对话循环都还没跑通建议先回去补一下前七篇的内容否则这里的很多设计决策你会看不懂为什么要这么做。提示工具调用不是“让模型直接执行代码”模型永远只负责生成调用意图真正的执行权必须牢牢握在你自己的代码手里。这个边界想不清楚后面一定会出安全问题。2. 拆解 ToolCall 的数据结构模型到底吐出了什么2.1 从一次真实的模型响应说起当你把工具的定义按照规范格式传给模型后模型在需要的时候不会返回普通的文本内容而是返回一个带有tool_calls字段的响应对象。这个对象的结构大致是这样的{ role: assistant, content: null, tool_calls: [ { id: call_abc123, type: function, function: { name: get_weather, arguments: {\city\: \杭州\, \unit\: \celsius\} } } ] }这里有几个关键点容易被忽略。第一content字段在有工具调用时通常是null或者空字符串不要指望它同时给你一段解释文字。第二arguments是一个JSON 字符串不是对象你需要自己json.loads解析。第三id是这次调用的唯一标识后面把工具执行结果塞回去的时候必须带上这个 id否则模型对不上号。我刚开始做的时候就在这里踩过坑直接把arguments当字典用结果报TypeError: string indices must be integers。后来才明白不同模型厂商对arguments的序列化方式不完全一致有的会给已经解析好的对象有的给字符串稳妥的做法是先判断类型再处理。2.2 为什么要有 id 这个字段很多人觉得 id 可有可无反正一次就调一个工具。但当你开启并行工具调用parallel tool calls时模型可能一次返回三个tool_calls比如同时查天气、查汇率、查航班。这时候你执行完三个工具要把三条结果都塞回消息历史模型靠什么区分哪条结果对应哪个调用就是靠 id。正确的做法是每执行完一个工具就构造一条role: tool的消息带上对应的tool_call_idtool_message { role: tool, tool_call_id: call[id], content: json.dumps(result, ensure_asciiFalse) }这个content也必须是字符串不能直接塞 Python 对象。我见过有人直接塞 dict结果序列化的时候报错排查半天才发现是这里的问题。2.3 arguments 解析的容错处理模型生成的 JSON 字符串不总是完美的。有时候会多一个逗号有时候引号转义有问题有时候干脆给你一段带 markdown 代码块的文本。所以解析的时候一定要做容错def parse_arguments(raw): if isinstance(raw, dict): return raw try: return json.loads(raw) except json.JSONDecodeError: # 尝试去掉可能的 markdown 包裹 cleaned raw.strip().strip().strip() if cleaned.startswith(json): cleaned cleaned[4:].strip() try: return json.loads(cleaned) except json.JSONDecodeError: return {}返回空字典是一种降级策略至少不会让整个流程崩掉。但更好的做法是把解析失败的信息也作为一种“工具执行结果”返回给模型让它知道自己参数给错了有机会重新生成。3. AgentTool 的设计把普通函数变成模型能看懂的工具3.1 工具描述的质量决定调用成功率模型怎么知道什么时候该调用哪个工具全靠你给的工具描述。这个描述包括工具名、功能说明、参数定义。我见过太多人随便写两句就完事结果模型要么不调用要么乱调用。一个好的工具描述应该包含三部分这个工具做什么、什么时候该用、每个参数的含义和格式。举个例子{ 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 } }, required: [from_city, to_city, date] } }注意description里我特意写了“当用户询问航班、机票、出行方式时使用此工具”这就是在给模型划触发边界。不写这句模型可能在你问“怎么去广州”的时候也去调航班工具但其实用户可能只是想问高铁。3.2 用类封装工具而不是散落的函数当工具数量超过三五个之后用裸函数管理会非常混乱。我推荐用一个AgentTool基类来统一封装class AgentTool: name description parameters {} def execute(self, **kwargs): raise NotImplementedError def to_schema(self): return { type: function, function: { name: self.name, description: self.description, parameters: self.parameters } }然后每个具体工具继承这个基类class WeatherTool(AgentTool): name get_weather description 查询指定城市的当前天气情况 parameters { type: object, properties: { city: {type: string, description: 城市名称} }, required: [city] } def execute(self, city): # 实际调用天气 API return {city: city, temp: 22, condition: 晴}这样做的好处是工具的定义、schema 生成、执行逻辑都在一起新增工具只需要加一个类注册到工具列表里就行。而且to_schema()方法可以批量生成模型需要的工具定义数组不用手写一堆重复的字典。3.3 工具注册表与查找Agent 在拿到模型的tool_calls之后需要根据function.name找到对应的工具实例来执行。所以需要一个注册表class ToolRegistry: def __init__(self): self.tools {} def register(self, tool: AgentTool): self.tools[tool.name] tool def get(self, name): return self.tools.get(name) def all_schemas(self): return [t.to_schema() for t in self.tools.values()]查找的时候一定要处理“工具不存在”的情况。模型有时候会幻觉出一个不存在的工具名这时候不能直接崩而要返回一条错误信息给模型让它知道这个工具不存在重新选择。注意工具名建议用下划线命名法全小写不要用驼峰或空格。有些模型对工具名的格式比较敏感驼峰命名可能导致调用失败。4. 完整的工具调用循环从模型请求到结果回填4.1 一次工具调用的完整生命周期把整个流程串起来一次工具调用经历这几个阶段用户发消息我们把消息历史 工具定义一起发给模型模型返回带tool_calls的响应我们解析每个tool_call找到对应工具执行execute把执行结果包装成role: tool的消息追加到历史再次调用模型这次带上工具结果模型基于结果生成最终回复这个循环可能重复多次。比如模型先查天气发现要下雨又决定查一下有没有室内活动推荐这就是两轮工具调用。所以代码里必须用一个while循环直到模型不再返回tool_calls为止。def run_agent(user_input, max_iterations10): messages.append({role: user, content: user_input}) for _ in range(max_iterations): response call_model(messages, toolsregistry.all_schemas()) msg response.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for call in msg.tool_calls: tool registry.get(call.function.name) if not tool: result {error: f工具 {call.function.name} 不存在} else: args parse_arguments(call.function.arguments) try: result tool.execute(**args) except Exception as e: result {error: str(e)} messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse) }) return 达到最大迭代次数任务未完成4.2 max_iterations 不是可有可无的保险上面代码里的max_iterations非常重要。我实测遇到过模型陷入死循环的情况它调用一个工具结果不理想又调用同一个工具参数几乎一样来回好几次。如果没有迭代上限这个循环会一直跑下去烧钱又烧时间。设多少合适一般任务 5 到 10 轮足够了。如果是复杂的多步推理任务可以放宽到 15。但超过 15 轮还没收敛大概率是工具描述有问题或者任务本身超出了模型能力继续跑也没意义。4.3 工具执行异常的处理策略工具执行失败是常态不是异常。网络超时、API 限流、参数格式错误都会导致execute抛异常。关键是怎么把异常信息有效地传递给模型。我的做法是把异常分成两类可恢复的和不可恢复的。可恢复的比如参数错误、临时网络问题把错误信息返回给模型让它调整参数重试。不可恢复的比如权限不足、资源不存在也返回给模型但同时在错误信息里明确说“此操作无法完成请告知用户”。try: result tool.execute(**args) except ValueError as e: result {error: f参数错误{e}请检查参数格式后重试} except TimeoutError: result {error: 工具执行超时请稍后重试} except Exception as e: result {error: f工具执行失败{e}此操作可能无法完成}这样模型拿到错误信息后能根据错误的性质决定是重试、换工具还是直接告诉用户失败。5. 多工具并行调用与结果顺序的坑5.1 并行调用时结果必须按 id 对应前面提过 id 的重要性这里展开说。当模型一次返回多个tool_calls时你可以选择串行执行也可以并行执行。串行简单但慢并行快但要注意结果顺序。不管你怎么执行往消息历史里追加tool消息时每条消息的tool_call_id必须和模型返回的tool_calls里的 id 一一对应。顺序其实不重要模型是靠 id 匹配的不是靠位置。但为了可读性我一般还是按原顺序追加。# 并行执行示例 from concurrent.futures import ThreadPoolExecutor def execute_tool_call(call): tool registry.get(call.function.name) args parse_arguments(call.function.arguments) try: result tool.execute(**args) except Exception as e: result {error: str(e)} return { role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse) } with ThreadPoolExecutor(max_workers4) as executor: tool_messages list(executor.map(execute_tool_call, msg.tool_calls)) messages.extend(tool_messages)5.2 并行调用的适用边界不是所有工具都适合并行。如果两个工具之间有依赖关系比如第二个工具的参数需要第一个工具的输出那必须串行。但模型在生成tool_calls的时候往往是基于当前上下文一次性生成的它默认这些调用是独立的。所以如果你发现模型生成的并行调用之间有隐含依赖说明你的工具描述没写清楚。应该在描述里明确说明“此工具需要在获取 XX 信息之后调用”引导模型分轮次调用。5.3 工具结果太长怎么办有些工具返回的数据非常大比如搜索工具返回几十条结果全部塞回消息历史会迅速撑爆上下文窗口。我的做法是在工具内部做截断和摘要def execute(self, query): results search_api(query) # 只取前 5 条每条只保留标题和摘要 trimmed [ {title: r[title], snippet: r[snippet][:200]} for r in results[:5] ] return {results: trimmed, total: len(results)}这样既保留了关键信息又控制了 token 消耗。如果模型需要更多细节它可以再调用一次工具带上更具体的查询条件。6. 实测中那些让人抓狂的失败场景6.1 模型不调用工具直接编答案这是最常见的问题。你明明定义了天气工具用户问天气模型却直接根据训练数据编了一个温度。原因通常是工具描述不够“强势”或者 system prompt 里没有强调“涉及实时信息必须调用工具”。解决办法有两个一是在 system prompt 里明确写“当用户询问实时信息天气、股价、新闻等时必须调用相应工具不得凭记忆回答”二是把工具描述写得更具体把触发场景列清楚。6.2 参数格式对不上模型给的参数类型和工具期望的不一致。比如工具期望date是YYYY-MM-DD格式模型给了2024年1月1日。这种问题靠 prompt 很难完全避免必须在工具执行层做兼容def normalize_date(date_str): # 尝试多种格式解析 for fmt in [%Y-%m-%d, %Y年%m月%d日, %Y/%m/%d]: try: return datetime.strptime(date_str, fmt).strftime(%Y-%m-%d) except ValueError: continue raise ValueError(f无法解析日期{date_str})6.3 工具执行成功但模型不采纳结果有时候工具明明返回了正确结果模型却在最终回复里忽略它继续用自己的知识回答。这通常是因为工具结果的消息格式不对或者模型没有意识到这条消息是“事实依据”。确保role: tool的消息格式正确tool_call_id匹配content是有效的 JSON 字符串。另外可以在 system prompt 里加一句“工具返回的结果是权威事实你的回答必须基于工具结果”。6.4 多轮调用后上下文混乱当工具调用超过三轮之后消息历史里会混杂 user、assistant、tool 三种角色的消息。如果中间有任何一条格式不对后续模型就可能报错或者行为异常。我的排查方法是把完整的消息历史打印出来逐条检查assistant 消息有没有tool_calls、tool 消息有没有tool_call_id、id 是否一一对应。十次里有八次问题都出在这里。失败现象最可能的原因排查动作模型不调用工具工具描述触发场景不明确检查 description 是否写了使用时机参数解析报错arguments 是字符串未解析加 isinstance 判断和 json.loads结果不回填tool_call_id 缺失或不匹配打印消息历史核对 id循环不终止模型反复调用同一工具加 max_iterations 并检查工具返回值上下文超限工具结果过长在工具内部做截断和摘要7. 让工具调用更稳的几个工程习惯第一个习惯是给每个工具写单元测试。不要依赖模型来测工具直接构造参数调用execute验证返回结构。工具本身稳定了模型调用失败才能定位到是模型的问题而不是工具的问题。第二个习惯是记录每次工具调用的日志。包括调用的工具名、参数、执行耗时、返回结果摘要。出问题的时候这些日志就是唯一的线索。我一般会把日志写到单独的文件按日期切分方便回溯。第三个习惯是给工具执行加超时。尤其是调用外部 API 的工具不加超时的话一个卡住的请求会把整个 Agent 循环拖死。Python 里可以用signal.alarm或者concurrent.futures的超时机制。from concurrent.futures import ThreadPoolExecutor, TimeoutError def execute_with_timeout(tool, args, timeout10): with ThreadPoolExecutor(max_workers1) as executor: future executor.submit(tool.execute, **args) try: return future.result(timeouttimeout) except TimeoutError: return {error: f工具执行超过 {timeout} 秒已中断}第四个习惯是工具返回值统一结构。成功时返回{data: ...}失败时返回{error: ...}。模型看到error字段就知道这次调用失败了看到data就知道成功了。统一的结构让模型更容易理解结果也让你自己的代码更好处理。这套东西跑通之后你的 Agent 才算真正有了“手脚”。后面无论是接数据库、接搜索引擎还是接内部业务系统都是在这个框架上加工具类而已。工具调用的稳定性直接决定了 Agent 能不能从 demo 走向可用。