聊 Agent 开发绕不开工具调用。不管你是手写 ReAct Agent还是用现成框架搭业务智能体核心链路都绕不开同一件事模型提出了工具调用请求外部代码执行工具执行结果再喂回模型让它继续组织回答。这句话听起来简单实际动手很容易卡壳——工具调成功了结果也打出来了Agent 却不会接着往下说或者把一串 JSON 原封不动念给用户听。接下来我把“Agent 怎样调用工具并根据执行结果继续回答”这条链路完整拆开从原理讲到最小实现再列几个我踩过的坑。适合正在做 agent 开发、手写 agent 循环或者只想搞懂 function calling 机制的人。1. Agent 调用工具和普通函数调用根本是两回事很多刚开始接触 Agent 的人会下意识把“工具调用”理解成“模型在代码里帮我们执行了一个函数”。有这个理解偏差后面调试就会一直绕圈子。模型本质上是“预测下一个 token”的文本生成器它不可能真的去访问数据库、查天气、控制硬件或者操作第三方系统。它能做到的是在文本层面生成一段“调用工具的意图描述”真正的执行得靠我们自己的代码。1.1 模型只负责“说”不负责“做”你调用大模型接口传过去一段对话历史然后收到一个响应。如果模型判断当前需要外部信息它不会在响应里直接写“我已经查到北京天气 22 度”而是会给出一段结构化的调用意图类似“我建议你调用 get_weather参数是 city北京”。这个“建议”在 OpenAI 风格的接口里叫tool_calls在 Anthropic 风格的接口里叫tool_use不同框架叫法不同底层逻辑一样。模型把工具名、参数、调用标识都给你然后停下来等你。等你把工具执行完把结果以消息的形式放回对话历史再重新调用模型它才会基于结果继续生成文本。一个适合做类比的场景模型是项目经理它只负责在会议上说“小王你去查一下北京天气”它不会亲自去查。我们自己的代码是“小王”接到指令后跑去查天气再回来说“北京今天 22 度晴天”。项目经理听完才能继续安排剩下的工作。记住这个脑体分离的模型比记住任何 API 参数都重要。1.2 工具调用的四个必要组成部分一个完整的 Agent 工具调用循环至少包含四个部分模型、工具定义、执行器、上下文管理。缺了任何一个调用链都会断。组成角色常见实现模型根据对话历史判断要不要调用工具生成调用参数各类支持 function calling 的大模型 API工具定义用 JSON Schema 描述工具能干什么、参数是什么tools 参数里的 function 定义执行器解析模型生成的调用意图调用真实函数拿到结果本地 Python 函数、HTTP 请求等上下文管理把 assistant 的调用意图和工具的执行结果都放回消息列表messages 数组或更高层的 memory 机制很多人会忽略上下文管理觉得执行结果打印出来不就行了吗不行。模型是无状态的它只认你传进去的消息。如果执行完成后你不把结果写回上下文它下一轮根本不知道刚才发生了什么。而一旦你正确回填了模型就会像看聊天记录一样看到“我请求了天气”“工具返回了晴天 22 度”自然就能接着回答。后面讲到的所有实现都是围绕这四个部分做文章。2. 完整流程拆解从“想去查”到“拿到结果”把工具调用拆细通常有几个固定动作模型生成调用意图、执行器解析并执行、结果回填、再调用模型。这几个动作会在 Agent 内部循环出现直到模型认为不需要再调用任何工具。2.1 模型是如何告诉你“我要调用工具”的以 OpenAI 兼容接口为例模型返回的响应里会出现一个tool_calls字段。它的结构大致是这样的{ tool_calls: [ { id: call_abc123, type: function, function: { name: get_weather, arguments: {\city\:\北京\} } } ] }注意arguments不是 JSON 对象而是 JSON 字符串。这是因为模型输出本质上是 token 序列结构化的“对象”也需要先序列化成字符串才能稳定生成。执行器拿到之后需要先json.loads再传给具体函数。模型可以在一次响应中返回多个tool_calls表示“这几个工具可以并行调用”。比如用户问“北京和上海今天分别多少度”模型完全可以在一条回复里同时给出get_weather(city北京)和get_weather(city上海)。执行器拿到这两个调用后应该并行或顺序执行它们然后把两个结果都回填再重新调用模型。判断模型是否调用了工具代码层面就看这个字段if message.tool_calls: # 模型想要调用工具 else: # 模型准备直接输出最终回答2.2 执行器怎么把“调用意图”变成真实动作执行器本身不产生智能它只做翻译和搬运。模型输出的是结构化文本执行器把它解析成真实的函数调用。比较稳妥的写法是维护一个工具注册表把工具名映射到真实的 Python 函数TOOL_REGISTRY { get_weather: get_weather, calculator: calculator, } def execute_tool(name, arguments): if name not in TOOL_REGISTRY: return {error: f未知工具: {name}} try: args json.loads(arguments) if not isinstance(args, dict): return {error: 工具参数必须是JSON对象} return TOOL_REGISTRY[name](**args) except TypeError as e: return {error: f参数不匹配: {e}} except Exception as e: return {error: f工具执行失败: {e}}这里有一个我自己一直坚持的原则工具执行失败时尽量把错误信息作为“结果”返回而不是直接抛异常中断整个 Agent。因为模型看到返回的 error 信息之后有可能会自己换一条路比如重新传参数、调用另一个工具或者诚实告诉用户“服务暂时不可用”。一旦抛异常Agent 循环当场就断了。2.3 执行结果如何回填到对话上下文工具执行完要把结果变成一条消息放进messages。在 OpenAI 兼容接口里这条消息的role是tool并且必须带上对应的tool_call_id否则模型无法把结果和之前的调用意图对上。一个标准回填片段长这样messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse) })这里还有个容易漏掉的细节不仅要把 tool 结果回填还要把 assistant 那条带tool_calls的消息也追加到历史里。假设我不加 assistant 那条消息直接塞 tool 结果进去模型会看到一个孤零零的“北京晴天 22 度”但不知道这是谁调用的、对应哪个问题。加上 assistant 的 tool_calls 消息之后整个对话历史才形成一个完整闭环。回填完成后的上下文大致是这样[ {role: user, content: 北京今天多少度}, {role: assistant, content: null, tool_calls: [...]}, {role: tool, tool_call_id: call_abc123, content: {\city\:\北京\,\temperature\:22}} ]这段上下文再发给模型模型就能看懂逻辑链并给出基于真实数据的回答。3. 拿到执行结果之后Agent 是怎么继续回答的这是很多初学者最困惑的阶段“工具结果我都拿到了为什么 Agent 还不回答”因为你还少做一步把包含工具结果的消息列表重新发给模型让模型基于完整上下文继续生成。Agent 的“继续回答”不是我们自己拼字符串而是模型的第二次、第三次生成。3.1 核心循环再问一次模型而不是直接拼接答案整个 Agent 工具调用的运行逻辑其实是一个循环把当前消息列表发给模型。判断模型返回的 message 是否包含tool_calls。如果包含执行工具、回填结果、回到第 1 步。如果不包含message.content就是最终回答。可以理解为模型在没有工具结果的时候只能“猜”有了工具结果之后还要再给它一次“看答案说话”的机会。你必须在拿到结果后主动“再问一次模型”这个循环才会往前走。伪代码是这样for step in range(MAX_STEPS): response client.chat.completions.create( modelmodel_name, messagesmessages, toolsTOOLS, ) message response.choices[0].message if not message.tool_calls: return message.content messages.append(assistant_message_with_tool_calls) for tc in message.tool_calls: result execute_tool(tc.function.name, tc.function.arguments) messages.append(tool_result_message) # 超过最大步数必须强制停止循环的出口只有一个模型在某一轮生成的消息里不再包含tool_calls只有普通文本。3.2 继续回答的三种分支场景拿到工具结果之后模型继续回答的方式并不是只有一种。我在实际项目里遇到过三种常见分支。第一种工具结果已经足够模型直接总结成自然语言。比如用户问“北京今天多少度”工具返回“22 度晴”模型下一轮直接回答“北京今天 22 度晴体感舒适”。这是最简单、最理想的情况。第二种工具结果不够模型继续调用新工具。比如用户问“北京和上海今天分别多少度”第一轮模型可能只调用了北京的天气工具拿到北京结果后发现用户还问了上海于是第二轮继续生成get_weather(city上海)的调用。这个“不够”的判断完全是模型做出的它会在生成的文本里隐含表达“还缺上海的数据我需要再查一次”。第三种工具执行失败模型尝试纠错或向用户说明。比如工具返回{error: city not found}模型下一轮可能重新生成get_weather(city北京市)或者直接告诉用户“没有找到该城市的天气数据请确认城市名”。这三种分支可以被同一套循环逻辑覆盖不需要针对每种情况写不同的代码。你只需要保证两点工具结果足够结构化以及重新调用模型时有上下文。3.3 终止循环的条件与兜底策略无限循环是 Agent 开发里最恶心的问题之一。模型可能因为工具描述不清、参数传错、结果格式混乱反复调用同一个工具把 API 额度烧光。所以循环一定要有终止条件。除了“模型不再返回 tool_calls”之外至少要加两个兜底。第一个是最大步数。我自己一般设MAX_STEPS 5简单问答场景 2 到 3 轮就结束了如果业务复杂呈现某种链式调用可以放宽到 10但超过 10 步还停不下来多半是工具设计有问题。第二个是重复调用检测。如果模型对同一个工具、同一个参数连续调用多次而且工具结果一模一样基本可以判定它陷入了死循环。这时候应该强行跳出循环把最后一次的内容整理成回答或者直接告诉用户“操作步骤过多暂时无法完成”。seen set() for tc in message.tool_calls: key (tc.function.name, tc.function.arguments) if key in seen: raise StopAgentLoop(重复调用同一工具强制终止) seen.add(key)不要觉得这种兜底多余。模型不是万能的偶尔会犯傻没有兜底的 Agent 上生产环境就是定时炸弹。4. 手写一个最小可运行的 Agent 工具调用循环前面讲的都是原理这部分我给可以直接跑起来的最小实现。我自己现在是框架和手写混用但无论用不用框架都很推荐亲手写一遍这个循环写完你对“结果回填”的体感会完全不一样。4.1 准备环境与工具注册表首先需要一个支持 function calling 的大模型 API。只要能兼容 OpenAI 风格接口无论是云厂商模型、开源模型服务还是本地跑的推理服务都可以用下面这套代码。我这次准备两个演示工具一个是get_weather模拟查天气一个是calculator做简单四则运算。工具函数如下def get_weather(city: str) - dict: # 实际项目中这里可以接真实天气API return { city: city, temperature: 22, condition: 晴, humidity: 40 } def calculator(expression: str) - dict: # 演示用简化计算器只处理整数四则运算 # 生产环境不要直接 eval这里仅示意 try: result eval(expression) return {expression: expression, result: result} except Exception as e: return {error: str(e)}为了让代码示例简短我用eval演示。生产环境千万不要直接eval用户的表达式至少要做语法树白名单校验。工具调用的安全边界很重要Agent 能触达的系统必须是最小权限的。4.2 工具 schema 怎么写才能让模型不选错模型靠什么决定要不要调用某个工具很大程度上靠工具定义里的description。描述写得不清楚模型就会乱选工具、漏传参数甚至把工具调用当成任务目标。我给这两个工具写的 schema 是这样的TOOLS [ { type: function, function: { name: get_weather, description: 查询城市当天的天气情况。当用户询问某个城市的温度、晴雨、湿度时使用。city 是城市中文名例如北京。, parameters: { type: object, properties: { city: { type: string, description: 城市中文名如北京、上海、广州 } }, required: [city] } } }, { type: function, function: { name: calculator, description: 计算数学表达式支持加减乘除和括号。仅当用户需要精确计算结果时使用。, parameters: { type: object, properties: { expression: { type: string, description: 要计算的数学表达式例如 (123)*4 } }, required: [expression] } } } ]写 description 时的核心原则是写清楚“什么时候应该用”而不只是“这个工具能做什么”。比如天气工具的 description 里写“当用户询问天气时使用”比只写“获取天气信息”命中率高得多。还可以补充典型参数示例帮助模型正确填空。4.3 主循环代码核心只有二十多行下面是一段完整的、可直接套用的主循环。我尽量去掉了具体平台的 SDK 差异核心逻辑在绝大多数支持 function calling 的接口上都可以复用。import json from openai import OpenAI client OpenAI( api_keyyour-api-key, base_urlhttps://your-provider.example/v1 # 替换成你的服务地址 ) MAX_STEPS 5 messages [ {role: system, content: 你是AI助手如果要回答实时信息请使用工具获取数据后再作答。}, {role: user, content: 北京和上海今天分别多少度} ] for step in range(MAX_STEPS): response client.chat.completions.create( modelyour-model-name, messagesmessages, toolsTOOLS, tool_choiceauto, ) message response.choices[0].message if not message.tool_calls: print(最终回答:, message.content) break # 把模型的调用意图加入历史这一步不能少 messages.append({ role: assistant, content: message.content or , tool_calls: [ { id: tc.id, type: function, function: { name: tc.function.name, arguments: tc.function.arguments, }, } for tc in message.tool_calls ], }) # 逐个执行工具并把结果回填 for tc in message.tool_calls: result execute_tool(tc.function.name, tc.function.arguments) messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse), }) else: print(达到最大步数停止调用工具)这段代码的核心结构就是上一节讲到的循环判断tool_calls、回填 assistant 消息、执行工具、回填 tool 消息、再重新请求。跑一遍之后你就能看到 Agent 自己“思考”的全过程。4.4 演示两次工具调用之后生成最终回答假设模型在第一步里同时返回了两个tool_calls分别查北京和上海的天气。执行器会执行两次get_weather然后把两个 tool 结果都放入上下文。最终模型会基于两个结果生成一段综合回答。对话消息序列会是这样的user北京和上海今天分别多少度assistant包含两个tool_calls。tool{city:北京,temperature:22,...}tool{city:上海,temperature:25,...}assistant北京今天 22 度晴上海今天 25 度多云。两地昼夜温差较大记得增减衣物。从这个序列可以看到模型并不是自己“知道”天气而是通过工具拿到了结果再由语言能力把结果组织成自然回答。整个过程是模型、执行器、上下文三者协作的结果。5. 实践里最容易踩的坑和排查方法再往下就是经验部分了。代码能跑通只是一个开始真正让人头疼的往往是那些“看着都正常一跑就出问题”的场景。5.1 工具返回结果又大又乱模型直接“迷路”最常见的问题是工具返回了大量原始数据、日志或者庞大的 JSON把上下文塞得满满当当。模型注意力有限数据一多它反而抓不住关键信息可能生成一堆没用的废话甚至在回答里把整段 JSON 原样读出来。解决思路不是增加模型上下文长度而是控制工具返回的内容。工具返回给模型的内容应该是“给模型看的摘要”而不是“给人看的原始 dump”。比如查询订单接口返回了 100 个字段真正影响回答的也许只有订单状态、金额、商品名称。那就应该在工具内部做好裁剪只返回这几个字段。如果确实需要完整数据也要在回填前截断比如只保留前 2000 个字符并在末尾加上“内容过长已截断”提示。另外工具结果最好统一成简洁的 JSON 格式。不要有时返回纯文本、有时返回 XML、有时返回 Python dict 的字符串格式一乱模型误判的概率会直线上升。5.2 非法 JSON 和参数类型错误怎么救模型生成arguments时偶尔会产生非法 JSON比如引号用了中文全角、逗号漏了、或者多了一层花括号。这个问题在参数复杂、模型能力较弱时尤其明显。我的做法是写一个宽容的参数解析器。先尝试标准json.loads失败后再用正则提取{...}片段最后再尝试解析如果还是失败就把“参数解析失败”作为错误信息返回给模型让模型自己修正。import json import re def safe_parse_arguments(arguments): try: return json.loads(arguments) except json.JSONDecodeError: match re.search(r\{.*\}, arguments, re.S) if match: try: return json.loads(match.group()) except json.JSONDecodeError: return {error: 参数解析失败请检查JSON格式} return {error: 参数解析失败未找到有效JSON}模型看到错误信息后通常会在下一轮重新生成一个合法的参数。这比我们自己在执行器里抛异常要优雅很多。5.3 无限循环停不下来的 Agent 最可怕有些 Agent 框架会报错agent execution terminated due to error但更多时候模型不会主动报错而是一遍又一遍地生成tool_calls。我见过最离谱的一次模型在一个简单问答里连续调用了同一个搜索工具 11 次参数还都一样。后来排查发现是工具描述里写了“当用户提问时必须使用搜索工具”。模型把这句话理解成了强制条件哪怕它已经有足够信息回答了仍然不敢停。这个教训后来也变成我的一个默认检查项工具描述要写成“当需要实时信息时使用”而不是“必须调用”。兜底策略前面已经提过MAX_STEPS加上重复调用检测。这里再补一个原则到达MAX_STEPS后不要再给模型任何“继续尝试”的机会直接返回最后一次的文本如果最后一步还是tool_calls就用统一文案告诉用户“操作步骤过多请简化问题或稍后再试”。5.4 工具抛异常时别让 Agent 直接崩溃工具调用过程中网络超时、参数错误、业务异常非常常见。如果执行器直接抛异常Agent 整个循环就断了用户只会看到一条冰冷报错。更好的做法是“把异常当作数据回传”。工具内部捕获所有异常返回结构化的错误信息给模型def get_weather(city: str) - dict: try: resp requests.get(fhttps://api.example.com/weather?city{city}) resp.raise_for_status() return resp.json() except Exception as e: return {error: 天气服务暂时不可用, detail: str(e)}这样处理后模型下一轮就能看到错误信息它可能选择换一个工具、换一种参数或者直接对用户说“天气服务暂时不可用请稍后重试”。这样的产品体验比 Agent 当面崩溃好得多。6. 写在最后我的一点真实体会我自己的路径是先手写循环中途去试框架最后又回到手写。不是框架不好而是只有手写过之后才能真正理解框架里那些抽象概念在背后做了什么。现在我再去看 LangChain、LlamaIndex 或者各类 Agent 编排平台心里会非常清楚它们是在“替我管理哪一段逻辑”。如果非要总结几条经验我会说工具描述越具体模型越不会乱选工具返回越精简模型回答越稳定MAX_STEPS 和重复检测是每条 Agent 循环的底线千万别省。最后再分享一个小技巧当你发现 Agent 在结果已经足够回答问题时还在反复调工具先别急着怪模型。去检查一下工具描述里是不是写了“必须调用”“一定要调用”这类过于强硬的指令。把描述改成“可根据需要调用不需要时可忽略”循环率通常会明显下降。这个小问题曾经折磨了我整整一个下午。