智能体开发自定义工具实操:从Function Calling到多工具调试
2026/9/7 2:35:54 网站建设 项目流程

自定义工具在智能体开发里是一个绕不开的实操环节。很多新手学 AI 编程和智能体开发,一开始觉得把提示词写好就够了,等到真要做一个能查数据、能计算、能调接口的 agent 时,才发现模型只负责“想”,真正干活的还得靠外部函数。这些可以注册给模型调用的函数,就是自定义工具。这篇文章就围绕“自定义工具实操”这个主题,把整条链路拆开讲:先理解模型和工具怎么配合,再动手写一个工具,然后处理参数、返回、多工具协作和调试。适合正在学智能体开发、或者已经会调模型接口但想把功能做实的开发者阅读。

先给个结论:自定义工具本身不复杂,真正容易踩坑的是四个地方——工具描述写得太模糊、参数类型和说明不清晰、错误返回没有给模型可理解的提示、多个工具同时挂载后命名混乱。下面按实操顺序逐个拆开,给出一套可以直接照着做的思路。

1. 自定义工具在智能体里到底扮演什么角色

1.1 模型不是万能的,工具就是模型的“手”

大语言模型本质上是一个文本生成模型。它能理解你的问题,能生成看起来很合理的回复,但它没有实时获取外部数据的能力,也没有直接操作业务系统的能力。

举一个最常见的例子。你问“北京现在天气怎么样”,模型如果只靠训练数据,它没有办法告诉你真实天气。你问“帮我算一下这个季度销售总额”,如果数字比较复杂,模型直接算很容易出错。这时候就需要给模型配工具。

工具的本质是一段普通代码,可以是一个函数、一个类方法、一个 HTTP 接口封装,甚至是一条命令行脚本。关键在于它能不能在合适的时机被模型调用,并把执行结果正确返回给模型。

1.2 完整的工具调用链路

这里先把概念说清楚。很多人第一次接触 Function Calling 时,容易误以为模型会直接执行代码。实际不是这样。

整个链路是:

  1. 用户输入问题。
  2. 应用把用户输入、系统提示词、可用工具列表一起发给模型。
  3. 模型不直接执行工具,而是返回一个“调用意图”,告诉应用它想调用哪个工具、参数是什么。
  4. 应用在本地执行对应的函数。
  5. 函数执行结果作为一条 tool 消息发回给模型。
  6. 模型根据工具返回的结果,生成最终回复。

“模型只决定调用,代码由应用执行”这个设计很关键。它把大模型的语义理解能力和普通代码的确定性执行分离开来了。模型只要学会“什么时候用哪个工具”,剩下的计算、查询、文件操作等动作,全部由稳定可靠的代码完成。

1.3 自定义工具和内置工具的区别

很多智能体框架会自带一些内置工具,比如网页搜索、计算器、代码执行器等。内置工具用起来方便,但实际项目里总有业务特有的操作是内置工具覆盖不了的。

自定义工具就是在自己项目里定义的函数。常见的例子:

  • 查询公司内部业务数据库
  • 调用某个内部 API,并把返回数据整理成指定格式
  • 执行一段本地 Python 脚本,比如批量重命名文件
  • 解析用户上传的 Excel 或 CSV 文件
  • 生成一张图表并保存到指定目录

这些业务代码原本就在系统里。自定义工具要做的,就是把这些函数暴露给模型,让模型能通过自然语言触发它们。

理解了“把一个普通函数注册成模型能理解和调用的工具”这件事,后面不管换什么框架、什么模型,都只是包装形式不同,核心原理是一样的。

2. 动手前,先把环境和核心概念准备好

2.1 需要一个支持工具调用的模型接口

工具调用在接口层的名字一般叫 Function Calling。现在主流模型接口基本都支持,不同服务商的接入方式略有差异,但核心概念一致:请求体里带上一个 tools 参数,声明有哪些函数可用。

第一次实践时,建议先使用一个支持工具调用的在线模型接口做最小验证。跑通链路后,再考虑开源模型或本地部署。原因是工具调用对模型的指令跟随能力有要求,部分开源小模型的工具调用成功率波动较大。如果你用一个小模型测试,发现模型总是不调工具,先不要急着怀疑代码,很可能是模型本身能力不够。

建议第一次实践时把模型 temperature 调到 0 或 0.1,减少生成随机性,方便排查问题。

2.2 本地开发环境建议

我建议按下面这个组合准备,通用性比较强:

  • Python 3.10 或以上
  • 一个支持工具调用的模型接口 SDK,或者直接使用对应模型服务商的 SDK
  • requests 库,后续调用外部 HTTP 接口会用到
  • 一个清晰的项目目录:tools/ 放工具函数,config/ 放配置,main.py 做调用入口

如果你用的是 LangChain、LangGraph 等框架,也可以直接安装对应依赖。但我不建议第一遍就一头扎进框架的 Agent 封装。先把原生接口的工具调用格式完整写一遍,你对原理的理解会扎实很多。

2.3 工具定义的四个核心字段

在原生接口里,一个工具的定义通常长这样:

{ "type": "function", "function": { "name": "get_weather", "description": "根据城市名称查询当前天气", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京、上海" } }, "required": ["city"] } } }

字段不多,但每个都很关键:

  • name:工具唯一标识,模型会在返回结果里引用这个名字。
  • description:模型的“使用说明书”,决定模型在什么场景下选择这个工具。
  • parameters:用 JSON Schema 描述参数结构,模型的调用参数会按这个结构生成。
  • required:标记哪些参数必须传。

对应真正执行的函数可以非常简单:

def get_weather(city: str): data = {"北京": "晴,25°C", "上海": "多云,28°C"} return data.get(city, f"暂无{city}的天气数据")

函数和 JSON 元数据放在一起看,就能理解自定义工具的全貌:一个是模型看到的行为描述,一个是应用真正执行的逻辑。

3. 写第一个自定义工具:从定义到调用闭环

3.1 最小可运行的完整示例

这里写一个不依赖复杂框架的最小示例,方便你先复现,再扩展。

import json from openai import OpenAI client = OpenAI() def get_weather(city: str) -> str: """模拟天气查询函数。""" data = {"北京": "晴,25°C", "上海": "多云,28°C"} return data.get(city, f"暂无{city}的天气数据") tools = [ { "type": "function", "function": { "name": "get_weather", "description": "根据城市名称查询当前天气,城市名称使用中文。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京、上海" } }, "required": ["city"] } } } ] messages = [ {"role": "system", "content": "你是天气助手。工具查询不到时要如实说不知道,不要编造。"}, {"role": "user", "content": "北京今天天气怎么样?"} ] response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools ) message = response.choices[0].message print("模型返回内容:", message)

运行后,你会看到模型返回的 message 里带有一个 tool_calls 字段。它只是表达了“调用意图”,还没有真正执行函数。接下来需要在应用层手动执行,并把结果回传给模型。

3.2 执行工具并回传结果

if message.tool_calls: tool_call = message.tool_calls[0] function_name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) print(f"模型选择调用:{function_name}") print(f"参数为:{arguments}") function_result = get_weather(**arguments) messages.append(message) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": function_result }) final_response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools ) print("最终回答:", final_response.choices[0].message.content)

这个闭环跑通后,你就掌握了自定义工具的核心机制。后面不管用 LangChain 的 @tool 装饰器,还是其他框架的工具注册方式,原理都是同一个:模型发出调用申请,应用执行函数,再把结果回传。

3.3 第一次实操最容易遇到的三个问题

第一个,模型完全不返回 tool_calls。大概率是描述或系统提示词里没有给出足够的调用理由。检查 description 是否明确写了工具的适用场景,系统提示词里是否允许模型自主调用。

第二个,模型返回的参数和你函数定义对不上。要么是 required 字段没写清楚,要么是参数的 description 不够具体。试着把参数说明写成“城市名称,中文全称,例如:北京、上海”,通常会有明显改善。

第三个,回传 tool 结果时漏掉 tool_call_id。新版本 SDK 一般会严格要求每条工具结果对应一个 tool_call_id,漏掉会直接报错。

4. 参数设计和返回结果设计:决定工具好不好用

4.1 参数设计要“少而明确”

设计自定义工具的参数时,最常见的错误是贪多。一个函数动辄七八个参数,模型要把每个参数都猜对,难度相当高。

更推荐的做法是:一个工具只做一件足够明确的事,参数控制在两到三个以内。如果业务逻辑确实复杂,可以考虑把多个参数打包成一个 JSON 字符串参数,或者把任务拆成多个粒度更小的工具。

举个例子:

# 不推荐:参数过多,模型容易传错 def query_sales(region: str, product_type: str, start_date: str, end_date: str, channel: str, group_by: str): pass # 推荐:参数少,范围明确 def query_sales_by_region(region: str, date: str): pass

参数少,模型判断负担就小,工具被正确调用的概率自然高。

4.2 返回结果要方便模型“再阅读”

工具执行完后,结果会以文本形式返回给模型。所以返回字符串的质量,直接影响最终回复的质量。

好的返回结果通常满足三点:

  • 简洁,但包含回答用户问题所需的核心信息
  • 不要把内部异常堆栈直接返回给模型
  • 失败时返回的提示语,要能帮助模型决定是向用户解释,还是尝试其他工具

一个规范示例:

def query_stock_price(code: str) -> str: if not code or len(code) != 6: return "错误:股票代码必须是6位数字,例如 600519" # 实际查询逻辑 return "股票代码 600519,收盘价 1715.00 元,涨跌幅 +0.82%"

模型收到这个结果后,能直接提取“600519”“1715.00”“0.82%”这些信息组织成最终回答,不需要再猜测。

4.3 错误处理不能漏

工具执行中一定会出现异常,常见的有参数不合法、接口超时、数据不存在、依赖服务报错。这些异常如果不处理,直接抛出到上层,整个 agent 程序可能会崩掉。

更合理的做法是在工具内部捕获异常,并把可读的错误信息返回给模型:

def call_remote_api(params: dict) -> str: try: resp = requests.get("https://example.com/api", params=params, timeout=5) if resp.status_code == 200: return json.dumps(resp.json(), ensure_ascii=False) return f"请求失败,HTTP状态码:{resp.status_code}" except requests.Timeout: return "错误:外部接口请求超时" except Exception as exc: return f"错误:接口调用失败,详情:{exc}"

这样即使出错,链路还是完整的。模型会收到一条错误说明,可以继续引导用户或换一种处理方式。

4.4 返回用纯文本还是 JSON

数据量小、结构简单时,用纯文本没有问题。数据字段多、结构复杂时,建议返回 JSON 字符串,并在函数说明里提前告知模型返回结构。

return json.dumps({ "code": "600519", "price": 1715.0, "change_percent": 0.82, "updated_at": "2025-01-15 15:00:00" }, ensure_ascii=False)

需要注意,不要在返回结果里混入大量日志或调试信息。模型会把整段文本当作回答素材,无关内容越多,最终回答被带偏的概率越大。

5. 多工具场景:注册、命名、任务拆分

5.1 多个工具注册时的命名规范

一个智能体通常不止一个工具。比如查询类工具可能同时有 get_weather、query_stock_price、get_news 三个。工具一多,命名就开始影响调用准确性。

命名建议采用“动词 + 对象”的结构。动词说明动作,对象说明作用域。例如:

  • get_weather
  • query_stock_price
  • send_email
  • list_recent_files

尽量避免 create、handle、do 这种过于泛化的动词。模型看到 do_something,很难判断应该什么时候调用。

5.2 描述信息不要互相覆盖

工具描述是模型做选择的主要依据。如果两个工具的 description 都写成“查询信息”,模型很容易选错。

好的描述至少包含三个信息:

  • 这个工具是什么
  • 什么场景下使用
  • 什么情况下不要使用
{ "name": "get_weather", "description": "查询指定城市的当前天气。当用户询问天气、气温、降水时使用。如果是历史天气,不要使用本工具。" }

5.3 工具数量不是越多越好

很多框架支持给不同的智能体配置不同的工具集。比如前台客服智能体只挂工单查询工具,后台数据分析智能体才挂数据库查询工具。

这样做的好处非常明显:工具越少,模型判断越准。工具列表过长时,模型不仅决策变慢,还容易出现相互干扰。

我在一次测试里挂载了 12 个工具,模型频繁选错。后来把工具按场景拆分成两组,每组只放 5 到 6 个,调用准确率立刻上来了。如果你发现工具总选错,优先考虑减工具,而不是改描述。

5.4 工具之间互相调用怎么处理

有些场景下工具 A 需要工具 B 的结果。一种做法是在工具 A 内部直接调用工具 B 的函数,另一种是让模型先调 B 再调 A。

我更推荐第一种,在代码层面直接复用函数。原因是模型的多步调用不可控,一旦中间某一步出错,后续流程全部乱掉。代码层面直接嵌套,稳定得多。

def query_stock_and_news(code: str) -> str: price_result = query_stock_price(code) news_result = get_stock_news(code) return f"{price_result}\n{news_result}"

这种复合工具适合当做一个新工具注册给模型,而不是依赖模型在对话里自动串联两个工具。

6. 测试和排查:把智能体当普通程序来调试

6.1 先测函数,再测模型层

很多人在模型调用环节反复报错,最后发现是工具函数本身就有 bug。建议把测试顺序固定成三层。

第一层,直接调用函数,分别传入正常参数和异常参数,看返回结果是否符合预期。

python -c "from tools.weather import get_weather; print(get_weather('北京'))"

第二层,单独检查工具元数据,确认 name、description、parameters 格式正确。可以写一个小脚本校验 JSON Schema 是否合法。

第三层,再走完整的模型调用链路,用几种不同说法的问题测试模型是否能选对工具。

6.2 日志要记录四个关键信息

排查智能体问题时,最麻烦的情况是:用户说“它答错了”,但你看不到模型当时选了哪个工具、传了什么参数、工具返回了什么。所以日志里至少要记录四个字段:

  • 用户原始输入
  • 模型返回的工具名称和参数
  • 工具执行结果
  • 最终回答内容

有了这些记录,大部分问题都能快速定位到是哪一层出错。

6.3 常见问题排查表

现象优先排查方向常见原因
模型完全不调用工具工具描述、系统提示词描述里没有触发场景,或提示词限制调用
调用了但参数错误参数 description、required参数说明模糊,模型猜测了错误格式
工具执行报错函数输入验证没有兼容 None、空字符串、非预期格式
返回内容模型没用上返回结果格式返回夹杂日志,结构不清晰
多个工具选错工具描述边界描述重叠,模型无法区分
回传 tool 结果报错tool_call_id 是否匹配漏传或错传 tool_call_id

6.4 从“对话式排查”变成“数据式排查”

不要只看最终回答来判断智能体是否正确。你应该先看模型到底调用了哪个工具。没有调用,问题在模型选择层;调用了但结果不对,问题在工具函数或参数层。这样逐层拆解,比反复修改提示词效率高得多。

遇到卡住或者返回异常时,按下面顺序检查:

  1. 看日志里模型调用了哪个工具。
  2. 看传入参数是否合法。
  3. 手动执行这个函数,确认函数本身没有 bug。
  4. 检查返回结果是不是模型容易理解的形式。
  5. 最后再考虑调描述、调提示词。

6.5 一个真实的排查复盘

我在一次测试中遇到过这种情况:用户问“查一下上个月的销售数据”,智能体返回了一段“接口报错”。日志显示模型选择了 query_sales_data,参数是 {"month": "上个月"}。显然,模型没有把“上个月”换算成具体日期。

问题不在函数,而是参数描述不够严格。我把参数描述改成“月份格式为 YYYY-MM,例如:2025-01。如果用户说'上个月',你需要先计算上个月的日期,再作为参数传入”,并在系统提示词里补了一句“涉及日期计算时必须先计算出具体日期”。之后再测试,模型就能正确传入 2025-01 这类值了。

这种问题很常见。它说明的不是模型不行,而是你的工具描述没有覆盖到模型会遇到的真实情况。

7. 从课程实操到真实项目,还需要关注什么

7.1 工具粒度不是越细越好

颗粒度太粗的工具,比如一个“处理所有数据”的函数,内部逻辑会非常复杂,模型也难以判断调用时机。颗粒度太细的工具,比如只做字符串转小写,又会让工具列表变得很长,模型选择负担加重。

比较合适的粒度是:一个工具能独立完成一次业务动作,输入输出都有清晰边界。课程练习里的天气预报工具、股票查询工具,本质上都是这个思路。

7.2 工具执行时间和并发要考虑

自定义工具在真实项目里还要考虑执行时间。有些工具是快速查询,几十毫秒返回;有些工具要几秒,比如调外部接口或处理大文件。如果模型要等多个工具串行返回,用户等待体感会很差。

处理方法有两个方向。一是控制工具复杂度,尽量让每个工具在几秒内完成。二是增加超时控制,避免工具永久卡住。课程示例可以只关注功能是否跑通,但进入生产环境,超时、重试、失败隔离都是必须考虑的。

7.3 从 Demo 到生产的最小路径

如果已经跑通了示例代码,想继续往生产方向走,我建议按这个顺序补充:

  1. 给所有工具加上超时和错误返回。
  2. 增加日志记录,至少能回答“模型选了什么工具、传了什么参数”。
  3. 给工具函数写单元测试。
  4. 控制工具列表长度,按场景分组挂载。
  5. 上线前用一批真实输入做回归,统计工具调用成功率。

7.4 框架和原生接口怎么选

如果你只是学习或者做原型,原生接口完全够用,而且能帮你把原理搞清楚。如果要做复杂的智能体编排、多步规划、状态持久化,框架会省很多事。

但我的建议是,无论用哪个框架,都要保留对工具元数据的控制权。工具的描述、参数、返回结果,才是智能体质量的真正决定因素。框架只是把调用循环封装好了,真正会不会被正确调用,还是取决于你写工具时是否足够细致。

自定义工具实操,练到最后就是练两件事:一是让模型知道“你有哪些能力”,二是让代码知道“模型要你做什么”。把这两件事做得清晰、可测、可控,就是一个靠谱的智能体。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询