
从零构建 Agent 系列我已经写到了第八篇前面几篇把 Agent 的基础框架、记忆机制和规划模块都讲得差不多了。今天这篇要聊的是所有 Agent 从“聊天玩具”变成“生产力工具”的关键转折点——让 Agent 调用工具。换句话说就是在模型只会输出文字的基础上给它接上真正能执行动作的手和脚。这篇内容我尽量按我第一次真正把 Function Calling 跑通时的思路去写。你不需要有特别深的背景只要写过几段 Python、调过 LLM API就能跟着把“工具调用”这条链路完整搭起来。我会重点讲工具定义、请求解析、执行回传、循环控制以及我在实际项目里踩过的坑。1. 工具调用到底解决了什么问题——先看清这条分界线1.1 只会聊天和真正干活的差距纯文本模型在没有工具的情况下最大的尴尬是你问它“帮我查一下现在东京几度”它会一本正经地告诉你“我无法实时获取天气数据建议你打开天气应用”。这不是模型笨而是它的训练数据里根本没有实时信息模型只能做两件事回忆训练时见过的知识以及基于概率生成看起来合理的文字。所以它没有能力“查询”任何外部系统。我之前见过不少刚接触 Agent 的朋友第一反应是用 Prompt 硬堆比如在系统提示词里写“你是助手当用户需要天气时请搜索API并返回结果。”结果模型每次都上演一场精彩的幻觉表演编造一个看起来合理的 JSON告诉你“已调用天气接口温度25度”。因为它根本没有任何机制去真正发起一次网络请求。工具调用Tool Calling / Function Calling解决的就是这个问题模型不再自己去“假装执行”而是把自己的决策结果以结构化形式表达出来例如“我想调用 get_weather参数是 Tokyo”。然后由我们这边真正的代码去执行这个函数再把结果送回给模型让模型基于真实结果继续回答。这句话是整个 Agent 体系的基石务必刻在脑子里模型负责决策代码负责执行工具调用是二者之间的协议。1.2 Agent调用工具的本质模型决策代码执行很多文章会把工具调用描述得很玄好像 Agent 突然获得了某种“行动能力”。在我看来它本质上是一种非常朴素的交互协议普通对话是“用户说一句模型回一句”工具调用则变成了“模型说一句然后附加一个触发指令请执行这个函数的这些参数”。这个“触发指令”就是 OpenAI 兼容 API 里的tool_calls字段。模型返回的是一条消息消息里除了正常的文本内容还会带一个数组数组里每一项包含function.name和function.arguments。name是模型想调用哪个工具arguments是模型根据你提供的 JSON Schema 自动生成的一组参数。需要注意的关键点是模型没有真正执行工具它只是“请求”执行。真正执行动作的是你的应用层代码。所以工具调用本质上是一种分工协议模型的知识和逻辑推理能力负责判断“该做什么”你的代码负责把“做什么”变成“真实结果”。理解了这一层后面遇到各种坑就不会慌。1.3 完整流程先在你脑中跑一遍我建议所有初学者在动手写代码之前先把这个流程在纸上画一遍。因为我最初写的时候总是搞混“哪一步该我处理哪一步该模型处理”。一个最简的完整流程如下发送给模型的消息里除了普通用户消息还带上tools参数。这个参数描述了你有哪些工具、工具接收什么参数。模型收到用户问题后判断是否需要调用工具。如果不调用直接正常回复。如果调用模型返回tool_calls里面包含工具名和参数 JSON 字符串。你的代码解析出工具名和参数执行对应的函数得到结果字符串。把结果以role: tool的消息追加到会话里并带上tool_call_id把这一轮对话再次发给模型。模型看到工具结果后生成最终的自然语言回复如果它觉得还需要更多工具会再次返回tool_calls此时重复第 3 步直到模型不再请求调用工具或达到最大轮数。我把这个流程叫做“工具调用循环”。你不需要一开始就设计花哨的编排引擎先把这个最基本的循环跑通后面所有高级功能都是在这个循环上生长的。2. 把Agent的技能登记造册工具定义与注册机制2.1 JSON Schema不是摆设模型靠它判断何时调你既然模型只能通过tools参数来“认识”你的工具那么工具描述的质量就直接决定了 Agent 的智商。在使用 OpenAI 兼容 API 时每个工具都要提供一个 JSON Schema 形式的结构包含name、description、parameters。{ type: function, function: { name: get_weather, description: 获取指定城市的当前天气温度与天气状况, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海、东京 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位默认摄氏度 } }, required: [city] } } }这个结构不是随便写写的。模型在推理时会阅读每个工具的description来比较用户的意图如果意图匹配就会提取用户问题里的实体填充到parameters里。所以description写得好不好直接影响模型能不能正确决定“该不该调用”以及“该传什么参数”。实际经验中description要偏向“这个工具在什么场景下使用”而不是复述函数名。比如get_weather的描述写“获取指定城市当前天气”比写“这是一个获取天气的函数”要好得多。更进一步的写法是在描述里给出典型场景“当用户问到某地天气、温度、体感或是否需要带伞时使用”。模型对场景描述更敏感。2.2 一个装饰器搞定工具注册在实际项目里我不会手工管理一大串 JSON Schema 列表因为很容易在工具函数改了签名之后忘记更新 Schema。比较好的做法是用装饰器把函数定义、参数说明和业务逻辑绑定在一起自动生成 tools 列表。我用 Python 写过一个非常简单的注册器核心逻辑如下tool_registry {} def tool(nameNone, description, param_schemaNone): def decorator(func): nonlocal description if not description: description (func.__doc__ or ).strip() schema param_schema or auto_gen_schema(func) entry { type: function, function: { name: name or func.__name__, description: description, parameters: schema, } } tool_registry[entry[function][name]] func setattr(func, tool_schema, entry) return func return decorator这里我偷懒用了auto_gen_schema实际项目中你可以让parameters由你手动写也可以利用类型注解和 docstring 自动推断。对小项目来说手动写 Schema 完全够用但要注意的是函数参数如果改名或增删必须同步更新 Schema。这个同步成本就是很多人后来重构工具注册器的原因。装饰器的另一个好处是工具的真实执行函数和 Schema 永远放在一起维护。你可以随时通过tool_registry拿到所有工具列表也可以单独取出某个工具的函数来跑单测。2.3 工具命名与描述的“话术”影响调用成功率这一点我特别想强调因为我亲眼见过同一个函数改名之后调用成功率从80%掉到50%的情况。模型对name和description的敏感程度远超很多人的直觉。命名方面优先用动词短语比如send_email、create_ticket、query_database不要用email这种名词也不要带汉字。description里一定要包含工具用于什么场景、参数每个字段的含义、以及可能的取值。尤其是有枚举值时一定要在描述里写清楚否则模型可能传入一个你完全没料到的字符串。举个反例我一开始给一个保存用户笔记的工具写的描述是“保存笔记”。结果模型经常在用户说“帮我记一下明天开会”时不调用它而是生成一段文本“我帮你记在心里了”。后来我把描述改成“将用户提供的任何需要日后回顾的信息持久化保存。当用户说记住、保存、提醒我、写下来等时调用”调用率一下子就上来了。所以你在搭工具注册机制时一定要把描述当作产品文案来写不是随便一句话。这属于成本最低、收益最高的优化手段。3. 接通LLM APITool Calling的请求构造与返回解析3.1 发送tools参数后模型究竟返回了什么基础循环跑通之前你需要先写一个可以和 LLM API 对话的函数。这里我说的是 OpenAI 兼容的 Chat Completions 接口因为目前绝大多数开源和商业模型都支持这套协议。假设你已经有了一个chat(messages, tools)函数核心代码长这样import json from openai import OpenAI client OpenAI(api_keyyour-key, base_urlyour-endpoint) def chat_completion(messages, toolsNone, modelgpt-4o-mini): params { model: model, messages: messages, } if tools: params[tools] tools params[tool_choice] auto try: resp client.chat.completions.create(**params) except Exception as e: raise RuntimeError(fLLM API 调用失败: {e}) return resp.choices[0].messagetool_choice设成auto会让模型自己决定是否调用工具。如果你期望某次请求必须调用某个工具也可以强制指定但我一般只在测试时这么干线上都会留给模型判断。重点来了当模型决定调用工具时我们拿到的message对象会有一个tool_calls属性它不是一个字典列表而是一个对象列表。每个对象有id、function.name、function.arguments。其中arguments是一个字符串里面是模型生成的 JSON比如{city: 东京, unit: celsius}。这里最容易被坑的就是很多人以为可以直接取到字典结果得到一个字符串然后拿eval一执行代码崩了。3.2 解析tool_calls不要拿eval直接执行解析function.arguments唯一安全的方式是先用json.loads解析字符串再做参数校验然后把参数以关键字参数的方式传给函数。def execute_tool_call(tool_call): func_name tool_call.function.name args_str tool_call.function.arguments if func_name not in tool_registry: return { success: False, error: f工具 {func_name} 不存在 } try: args json.loads(args_str) if args_str else {} except json.JSONDecodeError as e: return { success: False, error: f参数解析失败: {e}原始参数: {args_str} } func tool_registry[func_name] try: result func(**args) except TypeError as e: return { success: False, error: f参数不匹配请检查参数: {e}已解析参数: {args} } return {success: True, result: result}这里我用了对象但实际跑代码时你可能拿到的是 SimpleNamespace 类似结构。为了让文章中的代码更通用我这里统一描述为“从 message 中提取”。我建议永远不要用eval去执行一个 LLM 返回的字符串即使你调的是自己定义的工具。因为你不知道模型会编排出什么函数名也不知道参数里会不会出现奇怪的对象。轻则程序崩溃重则直接被攻击者利用提示注入做掉。用json.loads字典映射是最稳的做法。3.3 参数校验模型也会“脑补”函数签名你以为模型生成的参数一定符合你给的 JSON Schema我一开始也这么以为结果被现实狠狠教育。模型时不时会漏掉必填参数或把时间格式填成2024年7月18日你的函数如果用datetime.strptime解析就直接抛异常。所以参数校验不能省。最简单的方法是在执行函数前用jsonschema库校验解析后的args是否符合工具的schema[function][parameters]。from jsonschema import validate, ValidationError schema tool_registry[func_name].tool_schema[function][parameters] if schema: try: validate(instanceargs, schemaschema) except ValidationError as e: return {success: False, error: f参数校验失败: {e.message}}如果项目比较小也可以只做必填字段和类型的检查不必引入全套 JSON Schema。但我的经验是只要你后面工具数量超过五个jsonschema 就能帮你拦截掉大量低级错误。校验失败时把错误信息返回给模型模型会尝试修正参数重新调用这是 Agent 自我纠错的一种典型方式。4. 真正让Agent做事执行、回传和循环4.1 执行一次还是执行多轮循环退出条件把工具调用的“一次往返”跑通之后你必须考虑一个问题Agent 可能一次需要调用多个工具或者一个工具的结果需要作为另一个工具的参数。这两种情况都要求循环机制。最简单的循环用while True实现最多跑max_turns轮def run_agent_with_tools(user_input, max_turns5): messages [{role: user, content: user_input}] tool_schemas [tool_registry[name].tool_schema for name in tool_registry] for turn in range(max_turns): message chat_completion(messages, toolstool_schemas) messages.append(message) if not message.tool_calls: return message.content for tool_call in message.tool_calls: exec_result execute_tool_call(tool_call) messages.append({ role: tool, tool_call_id: tool_call.id, content: format_tool_result(exec_result), }) raise RuntimeError(f达到最大调用轮次 {max_turns}仍未得到最终结果)核心退出条件就是message.tool_calls为空。一旦模型返回了纯文本意味着它已经完成推理可以结束循环。max_turns是必须加的因为我见过模型在工具结果不理想时反复调用同一个工具好像进入死循环不加上限你的 API 账单会非常感人。4.2 工具结果怎么回传roletool的消息格式回传工具结果时必须遵守两个关键点一是role必须是tool二是tool_call_id必须对上模型返回的那个工具调用 ID。如果你只传roleuser给模型很多模型会分不清这是工具结果并可能将工具输出当成新的用户指令。content字段建议传字符串。有些实习生同学直接把整个 Python 对象塞进去结果序列化报错或者模型看到{success: True}这种字典结构后理解困难。所以我在format_tool_result里做了统一处理如果是普通字符串就原样返回如果是字典或列表用json.dumps(ensure_asciiFalse)转成紧凑 JSON如果是异常返回带 error 标记的字符串。def format_tool_result(exec_result): if not exec_result[success]: return f工具执行失败: {exec_result[error]} result exec_result[result] if isinstance(result, str): return result return json.dumps(result, ensure_asciiFalse, defaultstr)这里还有个细节不要给tool消息里塞太多无结构的东西比如巨大的日志或二进制数据。模型上下文有限信息过载反而会干扰后续判断。我会在工具函数层就做一次“结果精简”只把需要给模型看到的关键字段传回。4.3 跑一个真实案例查天气、算温差、给穿衣建议纸上谈兵没意思我直接给你一个真实案例。假设我们有两个工具get_weather(city)和calculate(a, b, op)。用户输入是“北京今天多少度顺便算一下最低温和最高温的温差”。第一轮消息发给模型后模型会返回两个tool_calls一个调用get_weather参数是{city: 北京}另一个调用calculate参数是{a: 28, b: 17, op: subtract}。这里模型会推测最低温和最高温数值因为它的常识里北京这个季节差不多是这个范围但它不会真的拿到数据。所以后面的计算其实基于猜测值这没问题只要最终回答里让用户清楚这些数据是猜测的。我们把两个工具的执行结果分别回传后代码再次调用模型。模型发现天气数据是真的回来了于是修正自己的措辞告诉你“北京今天最高温 28 度最低温 17 度温差 11 度早晚注意加外套”。这个案例虽然简单但它已经包含了“多工具并行调用”“结果回传”“最终生成”整个流程。我建议你把这套流程封装成run_agent_with_tools然后从最简单的两个函数开始测试再逐步扩展成真正的业务工具。千万一上来就接几十个工具不然出问题都不知道是哪一环。5. 线上必须处理的三类边界超时、异常与安全5.1 工具执行卡住怎么办给执行器包一层超时我第一次把 Agent 接上真实数据库查询时工具函数直接卡了 30 秒原因是数据库连接池耗尽而模型还在乖乖等结果。如果不对工具执行过程设置超时你的整个 Agent 循环会被一个慢工具拖死表现为用户那边“转圈转半天”。最实用的解法是用concurrent.futures.ThreadPoolExecutor包一层超时。from concurrent.futures import ThreadPoolExecutor, TimeoutError def run_with_timeout(func, args, timeout_seconds10): with ThreadPoolExecutor(max_workers1) as executor: future executor.submit(func, **args) try: return future.result(timeouttimeout_seconds) except TimeoutError: future.cancel() return None, 工具执行超时超时后端到端处理将“工具执行超时”作为tool回传给模型模型可能会换一种说法或者告知用户稍后再试。这种方案比直接抛异常终止整个 Agent 要体面得多。当然ThreadPoolExecutor 包单函数只是最轻量的做法。生产项目里我一般改用 asyncio 或进程池避免阻塞 Agent 主线程。但核心思路是一样的工具是外部依赖必须设置执行边界。5.2 异常不能中断会话把错误返回给模型继续修正工具函数跑挂了不能让整个 Agent 崩溃。因为模型完全有能力从错误信息中学习并纠正自己。比如一个工具要求date参数格式是YYYY-MM-DD模型第一次传了7月18日你的解析函数抛异常后如果把错误原样返回模型它大概率会修正成2024-07-18再调用一次。所以我在execute_tool_call里面已经把TypeError、校验错误、业务异常都接住了并统一返回{success: False, error: 具体错误}。然后这个错误信息通过format_tool_result变成工具执行失败: 日期格式应为YYYY-MM-DD...回传给模型。这里有一个安全考虑错误信息里可能会包含文件路径、SQL 语句、堆栈等敏感内容。我不建议把完整 traceback 直接回传模型尤其是要传给第三方模型时。更稳妥的做法是返回“面向业务的可读错误”具体堆栈留给本地日志。5.3 敏感操作与权限控制Agent不能什么都替用户做当你的工具列表里出现“发送邮件”“删除文件”“转账”这种功能时你就必须考虑安全边界了。很多 Agent 安全事件都是出在工具调用上模型被提示注入诱导调用了危险工具。我建议在工具分类上做一个简单的三档控制只读工具查天气、查数据库、搜索可以直接执行。普通写工具保存笔记、创建日程可以执行但需要记录审计日志。敏感工具删除、发送、支付必须经过人工确认。人工确认的实现有很多种。最简单的是在execute_tool_call里遇到敏感工具时不是真正执行而是生成一个“待确认任务”返回给前端用户点击确认后再用同样的参数执行一次真实功能。如果你的 Agent 跑在命令行里至少也要在终端输出“即将执行以下操作输入 y 确认”。我在一个内部工具项目里就吃过亏当时为了让 Agent 能自动清理临时文件我把delete_file直接暴露给了模型结果一次测试中模型根据一段被污染的上下文真的删错了文件。从那以后所有危险操作都强制走人工确认。记住一句话模型的判断能力再强也不该拥有不受监督的破坏权限。6. 进阶设计多工具并行、工具检索与可观测性以及我踩过的坑6.1 一次请求调用多个工具并行执行与结果绑定前面提到模型可能在一次回复里返回多个tool_calls。对这种情况我们要并行执行而不是挨个执行因为工具之间往往没有依赖串行会白白拖慢响应时间。import concurrent.futures def execute_tool_calls_batch(tool_calls): results {} with concurrent.futures.ThreadPoolExecutor() as executor: future_to_id { executor.submit(execute_tool_call, tc): tc.id for tc in tool_calls } for future in concurrent.futures.as_completed(future_to_id): tool_call_id future_to_id[future] results[tool_call_id] future.result() return results然后按tool_call_id把结果回传给模型。要注意的是如果某个工具失败了不要影响其它工具的返回这样才能让模型综合所有信息决策。另外如果工具之间有依赖比如第二个工具要用第一个工具的结果那就不能在模型回合里“假并行”而是要让模型在下一轮看到第一个结果后再调用第二个工具。这属于规划能力的范畴以后我会专门讲。6.2 工具数量多了从全量传JSON到动态检索当工具只有五个的时候把所有 Schema 塞进tools参数没有任何问题。但工具一旦超过二十个甚至上百个你会发现两个问题一是上下文被 Schema 占掉太多二是模型在大量工具里“挑花眼”调用准确率下降。这时候不能无脑全量传了。我目前采用的方法是给每个工具注册时打上标签或功能关键词然后在每次请求前做一个简单的工具检索用用户的输入去匹配最相关的十几个工具。匹配可以用 embedding 向量库也可以用规则关键词。对中小项目来说关键词 人工映射已经足够对复杂项目我会把每个工具的 description 和其他信息 embedding 起来用户输入进来后先做相似度检索只把 TopK 工具的 Schema 发给模型。这种“动态工具选择”的思路等价于给 Agent 配了一个秘书先帮它筛掉明显不该看的工具。否则你传一百个工具模型的注意力一分散工具调用的可靠性会明显下降。6.3 可观测性为什么我一度觉得“Agent乱调用工具”有一段时间我发现 Agent 总是不按预期调用工具有时候明明该搜索它却去调了计算器。排查时特别费劲因为中间隔了模型、代码、工具三层光靠打印一条tool_call日志根本看不出问题。后来我在 Agent 循环的每一层都加了结构化日志{ event: tool_call_request, turn: 1, tool_name: get_weather, arguments: {city: 北京}, model: gpt-4o-mini, timestamp: ... }以及工具执行结果日志{ event: tool_exec_result, tool_call_id: ..., success: true, time_ms: 230, result_preview: 北京 28度晴 }有了这些日志我才能把“模型为什么这样决策”和“工具执行是否成功”关联起来。之前那种只有“调用了工具然后结果错误”的模糊信息根本没法定位问题。我建议你从一开始就在 Agent 框架里埋好可观测性不要等出了问题再补。哪怕是先用 Python 的logger输出带event字段的 JSON 日志也比裸 print 强得多。生产环境可以再接 LangSmith、Langfuse 之类的追踪平台但前提是你自己的日志结构是清晰的。6.4 对工具调用做评估没有评估改一个描述都胆战心惊最后说一个我经常会跟团队强调的点Agent 的工具调用能力一定要有评估集和回归测试。因为工具调用的行为不是确定性的模型换了版本、你改了某句描述、加了某个新工具都可能影响原有场景的调用成功率。我维护了一个很小的评估集类似这样测试输入期望行为期望工具期望关键参数北京今天冷吗调用 get_weatherget_weathercity北京帮我记住明天十点开会调用 save_notesave_notecontent包含会议这篇论文讲了什么不调用工具无-每次修改工具或调整提示词我都会跑一遍这个评估集。如果调用成功率降了说明改动有副作用需要回退。这比肉眼观察几次对话结果可靠得多。你在对 Agent 评测时一定要检查“参数”是否对而不仅仅是“工具名”是否对。因为模型经常调用对了工具却传错了参数比如把城市名写错这种错误必须靠参数断言才能拦住。工具调用做好的 Agent有时候会给你一种“它真的懂我”的错觉。但拆开看不过是一个被正确描述的函数的集合再加上一个稳定的循环调度器。然而正是这个调度器让模型从文本生成的牢笼里走了出来。后续要在这个基础上加更复杂的规划、反思、多智能体协作都是顺手的事。希望这一篇能帮你的 Agent 真正长出“手和脚”。