最近在折腾一个内部 Agent 项目,说白了就是让大模型替人干活的小助手。项目做到一半,撞上了几乎所有 Agent 开发者都会遇到的墙:模型很聪明,但没有“手”。它能把你的需求理解得丝丝入扣,可一旦需要查实时数据、算一个精确认数字、或者去某个后端系统里拉订单详情,它就歇菜了——要么一本正经地瞎编,要么理直气壮告诉你“我现在还无法获取这些信息”。
这个问题的标准解法,就是给 Agent 添加工具调用能力(有些平台叫 Tool Calling,有些叫 Function Calling)。机制不复杂:把工具描述成结构化清单交给模型,模型在需要时自己决定调哪个、传什么参数,你的程序负责真实执行,把结果回传给模型继续思考。
这篇文章我围绕“搜索、计算、API”这三类最典型的工具,把从工具定义、协议交互到 Agent 主循环的完整实现讲一遍,包括实际踩过的坑和排查思路。适合正在做 Agent 应用、想跑通工具调用而不只是调用聊天接口的开发者,也适合对智能体感兴趣、想搞清楚“工具调用到底是个什么机制”的读者。
1. 为什么 Agent 必须长出手脚:工具调用的本质思路
1.1 光有对话能力的大模型还不够
很多人刚开始做 Agent 时会有一个错觉:模型都这么强了,直接问它不就行了?等你真丢几个需要外部信息或精确计算的问题进去,就会发现瓶颈非常明显。
首先是知识时效问题。任何模型的训练数据都有截止时间,你问它“今天的天气”“某只股票现在的价格”“某个接口当前的状态”,它根本没有信息来源,唯一合理的输出是坦白自己不知道。但很多模型在被追问时,会选择用训练数据里的模糊记忆拼凑一个看似合理的答案——这就是幻觉的源头。
其次是精确计算问题。语言模型本质上是在做下一个 token 的概率预测,不是真的在“算”。你让它估算“237.5 乘以 18% 再除以 3”这种带小数的问题,它可能凭训练经验给你一个接近但不精确的答案,在财务、统计这类要求精确的场景里完全不能忍。
最后是操作外部系统的问题。Agent 的价值在于“替人做事”,而做事就需要写数据库、调订单接口、发通知、改配置。这些动作模型一个都做不了,必须由真实代码去执行,执行完了再把结果带回来。
工具调用解决的就是这四类事:获取实时信息、执行精确计算、操作外部系统、顺带减少幻觉。
1.2 工具调用到底是怎么工作的
我用一句话描述整个机制:模型负责“决策”,你的程序负责“执行”,两边靠一套结构化的描述语言对接。
具体流程可以拆成五步:
- 你维护一份工具清单,每个工具包含名字、功能描述、参数定义,这部分通常用 JSON Schema 表达。
- 你把这清单随用户提问一起发给模型接口。模型看到的不只是你的问题,还有“当前环境里有哪些工具可用”。
- 模型内部推理后,如果认为某个工具能帮上忙,它不会真的调用,而是输出一个结构化的调用指令,包含工具名和参数 JSON。
- 你的程序解析这个指令,在自己这边执行对应的函数,拿到真实结果。
- 你把结果以一条特殊格式的消息回传给模型,模型结合结果组织最终回答。如果这次结果还不够,模型可以继续发起下一轮工具调用,形成一个循环。
我在项目里给同事打过个比方:模型像一个经验丰富的接线员,工具调用协议就是它手边那本“服务目录”。客户问什么,它翻目录决定转接给谁,真正干活的是被转接到的部门(你的代码)。
1.3 什么时候该用工具调用,什么时候用检索增强
很多刚接触的人会把工具调用和另一种常见方案——检索增强生成(RAG)搞混。我自己也曾经纠结过。简单区分:
- 如果你只是希望模型“读”一段本来不知道的文档,然后据此回答,RAG 更直接。你把相关文档片段塞进上下文,模型读完再回答。
- 如果你需要模型“做”一件事,或者获取一次性的实时状态,那必须用工具调用。典型的例子:查天气、查库存、创建工单、执行一段计算。
实际项目中两者经常搭配使用:RAG 提供静态知识底座,工具调用提供动态交互能力。比如一个客服 Agent,先用 RAG 找到产品手册里关于退换货的规则,再调用订单 API 查出用户这笔订单的真实状态,最后综合两者回答用户。
2. 工具注册与协议细节:先把基础打牢
2.1 工具的本质是一份 JSON 描述
工具调用之所以能跨模型、跨语言实现,关键就在于描述工具的这份 JSON 是纯文本、结构化的。我给你看一个搜索工具的描述,这是整个工具调用的地基:
{ "type": "function", "function": { "name": "search", "description": "当需要获取实时信息、查找最新资料或验证某个事实时,调用此工具。支持网页搜索。", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "搜索关键词,尽量具体,比如'2025年智能手表销量报告'" }, "limit": { "type": "integer", "description": "返回结果数量,默认5,最大10", "minimum": 1, "maximum": 10 } }, "required": ["query"] } } }这里有几个细节值得你琢磨:
- name 是模型调用的唯一标识,要见名知义,别用
func1这种。 - description 是给模型看的“使用说明”,决定它什么时候该调这个工具。
- parameters 里的每个字段也都有 description,模型根据这些说明来填参数。
- required 列表告诉模型哪些参数是必须的。
主流对话模型平台几乎都遵循类似的协议,差异主要在消息格式和返回字段的命名上,有的平台返回数组表示一次可以多个并行调用,有的平台分两步先选工具再生成参数。但底层的 JSON Schema 描述思想是完全一致的。
2.2 描述词写得好不好,直接决定模型调不调
这是我做工具调用以来最有体感的一条经验:工具能不能被正确调用,六七成靠 description 写得好不好,而不是你的代码逻辑。
模型不像人那样能“猜”你的意图。它面对一个工具时,基本完全依赖 description 来判断“这个工具什么时候用”“该填什么参数”。描述词写得太笼统,模型就会在不需要的时候乱调;写得太具体,又可能在需要变通时不敢调。
我总结的几条描述词经验:
- 开头第一句直接说明触发场景,例如“当用户询问实时天气时调用”。
- 明确边界,例如“仅用于查询,不用于修改数据”。
- 参数描述里给模型一些示例值,它填参数时会更准确。
- 如果多个工具容易混淆,在描述里互相排除。比如有
search和calculator两个工具,calculator的描述里可以写“仅用于数学计算,不要用于查找信息”。
有一次我遇到模型频繁把“查询订单余额”的工具和“查询交易记录”的工具搞混,调用了错误的接口。排查到最后,发现就是两个工具的 description 开头都写成了“查询用户信息”。改成一个写“查询用户当前可用余额”,另一个写“查询用户一段时间内的收入支出明细”之后,准确率马上上来了。
2.3 tool_choice 与控制策略
除了工具描述本身,模型的接口通常会提供一个控制参数,常见的有 auto、required、none 或者强制指定某个工具名。
- auto:模型自行判断要不要调工具。日常对话场景的默认选项。
- required:强制模型必须调用至少一个工具。适合你明确知道当前任务一定需要工具的流程。
- none:禁用工具。适合闲聊场景,防止模型胡调。
- 指定工具名:强制模型只能调某个特定工具,比如在某个多轮流程的固定步骤里。
我的习惯是:在通用对话入口用 auto,在专门的处理链路里用强制工具。比如一个“任务分解 Agent”,每次进来都必须先调用task_planner工具,那就把 tool_choice 固定成这个工具名。这样少了很多不确定性。
3. 三个核心工具的落地实现
3.1 搜索工具:让 Agent 拥有实时视野
搜索工具的价值是给 Agent 补上“时效”短板。实现上主要有两条路线:
路线一:接某个搜索服务商的结果 API。这是最省事的方案,返回结构化结果,代码简单,稳定可控。缺点是部分服务需要付费或有限流。
路线二:自己对目标站点做抓取再解析。适合内部系统、垂直站点。如果能不做就不做,网页结构变动频繁,维护成本非常高,而且还有合规和反爬问题。
我推荐先走路线一,把核心链路跑通,再考虑要不要做定制抓取。
下面是一个搜索工具的参考实现:
import requests SEARCH_API_KEY = "your_search_api_key" # 放到环境变量里,别写死在代码中 SEARCH_ENDPOINT = "https://api.search-example.cn/web_search" def search_tool(query: str, limit: int = 5) -> dict: """调用搜索服务 API,返回结构化搜索结果""" resp = requests.post( SEARCH_ENDPOINT, headers={"Authorization": f"Bearer {SEARCH_API_KEY}"}, json={"query": query, "num_results": limit}, timeout=10, ) resp.raise_for_status() data = resp.json() results = [] for item in data.get("items", [])[:limit]: results.append({ "title": item.get("title", "")[:50], "snippet": item.get("snippet", "")[:120], "url": item.get("url", ""), }) return {"status": "success", "results": results}注意几个我踩过的坑:
- 返回给模型的内容不要贪多。每个结果只保留标题、摘要、URL,控制在 100 字以内。模型上下文空间有限,塞一坨网页正文进去,既浪费 token,又稀释了关键信息。
- 搜索结果的顺序很重要。把评分最高的结果放前面,模型通常依赖前几个结果作答。
- 一定要做超时控制。搜索服务偶尔会变慢,超时后给模型返回一个“搜索超时”的错误信息,让它决定是重试还是换个方式回答。
实测下来,搜索工具返回 3 到 5 条结果最合适。太少了不够参考,太多了模型反而不知道该信哪条。
3.2 计算工具:让 Agent 算得准
我敢说,每个认真做过 Agent 的人都被模型的数学能力坑过。让它算简单的四则运算可能没问题,一旦涉及小数、百分比、多步运算,就开始飘。比如让它算“一件商品原价 299 元,打 8.5 折再用满 200 减 30 的券,最终多少钱”,它大概率会给出一个离谱的答案。
所以计算工具的核心原则是:数学运算交给计算器,模型只负责把用户的问题翻译成表达式。
计算工具最常见的实现是表达式求值。这里我必须提醒你一个安全问题:不要直接在代码里用 Python 原生的 eval 去执行模型生成的表达式。模型生成的内容不可信,eval 会执行任意代码,这是重大安全隐患。
我推荐用一个受限的表达式求值方案,本质上是把表达式解析成语法树,然后白名单校验节点类型,只放行算术相关的节点。
import ast import operator _ALLOWED_NODES = (ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant) _ALLOWED_OPERATORS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, ast.Mod: operator.mod, } def safe_eval(expression: str) -> float: tree = ast.parse(expression, mode="eval") for node in ast.walk(tree): # 不允许函数调用、属性访问、变量名等任何复杂语法 if not isinstance(node, _ALLOWED_NODES): raise ValueError(f"表达式包含不允许的语法: {type(node).__name__}") # 白名单执行:禁用所有内置函数和名字 return eval(compile(tree, "<string>", "eval"), {"__builtins__": {}}) def calculator_tool(expression: str) -> dict: try: value = safe_eval(expression) # 浮点数结果做一次规整,避免 0.30000000000000004 这种问题 value = round(value, 6) return {"status": "success", "result": value} except Exception as e: return {"status": "error", "error_message": f"表达式无法计算: {e}"}这里有个关键点我特别说明一下:为什么要限制 AST 节点类型?因为ast.Call(函数调用)、ast.Attribute(属性访问)、ast.Name(变量名)这些节点一旦放开,攻击者就能构造出读取文件、执行命令的表达式。只放行ast.Constant和算术节点,能执行的只有纯粹的数学运算。
实际用的时候,模型可能直接生成中文算式,比如“299乘以0.85再减去30”。这种情况我在工具里做了一步预处理:把中文数字词替换成符号,把“乘以”替换成*。这个预处理逻辑不复杂,但非常提升体验。
还有一点要提醒:这个 calculator 是“同步单表达式”模式,不支持多步临时变量。如果需要更复杂的能力,比如让模型写一小段 Python 代码并执行,那就需要上代码执行沙箱了。沙箱方案我后面在安全和扩展里会提到,但至少先跑通单表达式计算器,大多数场景已经够用。
3.3 API 工具:让 Agent 真正操作外部系统
前两个工具更像是“补充能力”,API 工具才是 Agent 从“聊天机器人”升级为“智能体”的关键。它的本质是:把你系统里已有的 REST API、内部服务、数据库查询封装成工具,让模型按需调用。
我拿一个最典型的“查天气”来演示。假设公司有一个天气服务接口,我们需要把它封装成一个工具。
import os import requests WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY") WEATHER_ENDPOINT = "https://api.weather-example.cn/v1/weather" def weather_api_tool(city: str, date: str = "today") -> dict: """查询指定城市某一天的天气情况""" params = {"city": city, "date": date} headers = {"X-API-Key": WEATHER_API_KEY} try: resp = requests.get(WEATHER_ENDPOINT, params=params, headers=headers, timeout=8) except requests.Timeout: return {"status": "error", "error_message": "天气服务查询超时,请稍后重试"} except requests.RequestException as e: return {"status": "error", "error_message": f"请求天气服务失败: {str(e)}"} if resp.status_code != 200: return { "status": "error", "error_message": f"天气服务返回错误状态码 {resp.status_code}: {resp.text[:100]}", } data = resp.json() return { "status": "success", "city": city, "date": date, "weather": data.get("weather"), "temperature": data.get("temperature"), "humidity": data.get("humidity"), "wind": data.get("wind"), }封装 API 工具时,我最看重三个工程规范:
第一个是统一的返回结构。不管成功失败,工具最终返回给模型的一定是{"status": "success"/"error", ...}这样的字典。这样 Agent 主循环不需要关心每个工具内部怎么处理错误,只要看 status 字段就知道结果是否可用。
第二个是错误信息的可读性。模型读不到原始堆栈,它只能读你回传的字符串。所以错误信息要写成“人话”,比如“城市参数不存在”而不是“KeyError: 'city'”。模型看到前者可以试着换一种方式问,看到后者只能懵。
第三个是鉴权信息的隔离。密钥通过环境变量传入,永远不要出现在工具描述或日志里。这一点我在后面安全环节还会重点讲。
4. Agent 主循环:把工具调用串起来跑
4.1 一次标准工具调用的完整生命周期
有了工具,还需要一个“调度中枢”把它们串起来。这个中枢就是 Agent 主循环。
我拿一个实际案例走一遍完整链路:
用户问:“帮我查一下今天北京的天气,顺便算一下如果气温 35 度、湿度 60%,体感温度大概是多少?”
第一轮调用模型,模型看到两个工具可用:weather_api_tool和calculator_tool。它判断需要先查天气,于是返回一个 tool_calls 指令,内容是调用weather_api_tool,参数是{"city": "北京", "date": "today"}。
你的程序收到这个指令,找到注册表里的weather_api_tool函数,实际执行,拿到天气数据(比如温度 35、湿度 60%)。然后你把结果以role=tool的消息回传,并再次调用模型。
模型看到天气结果后,意识到还需要计算体感温度,于是又返回一个 tool_calls,这次是调用calculator_tool,参数是某个公式表达式。你的程序再次执行,把计算结果回传。
最后,模型看到计算结果,组织最终回答:“今天北京气温 35 度,体感温度约为 39 度。”
注意看这个过程:模型发起了两轮工具调用,你的程序执行了两轮。关键就在于要把 assistant 那条带 tool_calls 的消息保留下来,因为模型平台需要用 tool_call_id 来对应工具结果和调用指令,丢失任何一环,上下文就接不上了。
4.2 一个可运行的 Agent 骨架代码
下面是一段精简但完整的 Agent 主循环,它把搜索、计算、API 三类工具统一管理起来:
import json TOOLS = { "search": {"function": search_tool, "schema": search_schema}, "calculator": {"function": calculator_tool, "schema": calculator_schema}, "weather": {"function": weather_api_tool, "schema": weather_schema}, } def call_model(messages, tools, tool_choice="auto"): """调用模型接口,简化版,实际请接入你的模型SDK""" # 按平台规范组请求体 # payload = {"model": "xxx", "messages": messages, "tools": tools, "tool_choice": tool_choice} # response = requests.post(...) # return response pass def run_agent(user_message: str, max_iterations: int = 5) -> str: messages = [{"role": "user", "content": user_message}] for step in range(max_iterations): response = call_model( messages=messages, tools=[t["schema"] for t in TOOLS.values()], tool_choice="auto", ) assistant_msg = response["message"] messages.append(assistant_msg) # 没有工具调用,说明模型准备直接回答了 if not assistant_msg.get("tool_calls"): return assistant_msg["content"] # 执行所有工具调用(模型可能一次返回多个并行调用) for tool_call in assistant_msg["tool_calls"]: tool_name = tool_call["function"]["name"] try: tool_args = json.loads(tool_call["function"]["arguments"]) except json.JSONDecodeError: tool_args = {} tool = TOOLS.get(tool_name) if tool is None: result = {"status": "error", "error_message": f"未知工具: {tool_name}"} else: try: result = tool["function"](**tool_args) except TypeError as e: result = {"status": "error", "error_message": f"工具参数错误: {str(e)}"} # 把工具执行结果回传给模型 messages.append({ "role": "tool", "tool_call_id": tool_call.get("id"), "content": json.dumps(result, ensure_ascii=False), }) return "已达到最大迭代次数,无法完成请求。请简化问题或稍后再试。"这段骨架里有几个值得展开的工程细节。
第一个是多工具并行调用。模型在一次响应里可能返回多个 tool_calls,比如既想查天气又想算表达式。这种情况下,代码里的 for 循环会逐个执行。为了提效,可以把这层 for 换成并发执行,比如用线程池把多个工具一次性跑完再统一回传。但要小心:如果多个工具之间有依赖关系,比如第二个工具要用第一个工具的结果,那就不能并行,必须顺序执行。
第二个是参数类型校验。工具函数接收的是 JSON 字符串解析后的字典,但模型生成的参数不保证类型完全正确。比如它可能把 limit 传成字符串"5"。我习惯在工具函数内部做一次显式类型转换和边界检查,如果转换失败就返回错误信息让模型重试。
第三个是最大迭代次数。这个参数是硬性保护。真实场景里模型可能因为上下文复杂、提示词引导不足而陷入反复调用工具的循环。迭代次数设太小,复杂任务完不成;设太大,浪费 token 和接口费用。我一般设 5 到 8 轮,如果超过还完不成,基本说明是工具设计或者提示词的问题。
4.3 迭代上限与并发执行的工程考量
关于迭代上限,我再多说一句。有次线上事故,某个 Agent 在“查订单”和“查用户”两个工具之间反复横跳了二十多轮,因为订单查询返回的状态字段不够清晰,模型总以为用户信息没查到,于是又去调用户查询工具。发现的时候已经烧了不少额度。
后来我做了一个改进:除了硬性的迭代上限,还记录每一轮工具调用的参数摘要。如果检测到某工具在连续三轮里收到近乎相同的参数,就直接打断循环,给模型回传一条“检测到重复调用,请基于已有信息直接回答”。这个机制帮我们堵住了不少死循环。
并发执行方面,我建议先用顺序执行把逻辑跑通,再考虑并行。工具调用依赖关系往往比想象中复杂,顺序执行虽然慢一点,但是排查问题容易。等工具数量多了、确实有性能瓶颈了,再按调用之间的依赖关系做拓扑分层,同层并行即可。
5. 常见问题与排查技巧实录
5.1 模型输出坏 JSON 怎么兜底
工具调用最常见的问题之一,是模型返回的 arguments 不是合法 JSON。有时候是多了一个尾逗号,有时候是参数名被换成了中文冒号,有时候干脆截断了一半。
我处理这个问题的思路分三层:
第一层是容错解析。先做一次严格json.loads,失败后再尝试一些轻量修复,比如去掉尾逗号、把单引号换成双引号、用正则提取出最外层的 JSON 对象片段。
第二层是把错误信息回传给模型。如果修复不了,就把原始字符串和解析错误一起封装成工具结果返回给它,让模型自己意识到格式有问题并重新生成。实测下来,大多数主流模型在被明确告知“你生成的参数不是合法 JSON,请看错误信息并重新调用”之后,都能自我修正。
第三层是防御性设计。在工具函数内部对参数做宽容处理,比如 limit 传成字符串就转成 int,query 缺省就填空字符串。总之不要让一个坏参数打挂整个 Agent。
5.2 死循环与重复调用怎么防
死循环是 Agent 工具调用里最让人头疼的问题。典型的症状是:模型不断调用工具,但每次调用的参数几乎一样,或者工具返回错误后它不换思路,而是一直重试。
防御手段我前面提过两个:迭代上限保护和重复参数检测。这里再补充一个排查技巧:你在开发阶段一定要打开工具调用日志,记录每一轮模型的 tool_calls 内容、工具执行耗时、返回结果摘要。死循环一旦出现,翻日志定位是最快的。
另外一个非常有效的预防手段是“思路引导”。在系统提示词里写上一句“如果某个工具反复返回相似错误,请停止调用该工具,换一个方式回答,或向用户说明当前无法完成该操作”。这句话对提升稳定性的帮助,有时候比任何代码层面的优化都明显。
5.3 工具返回内容太长怎么办
搜索工具返回了 20 条网页摘要,订单工具返回了 100 条明细,这些内容全部塞进上下文,很快就把模型的上下文窗口撑爆,既费钱又让模型抓不住重点。
我常用的剪裁策略有这几种:
- 数量上限:工具在汇总结果时就做截断,比如搜索结果最多返回 5 条。
- 字段精简:只保留对回答最关键的字段,把无关元数据扔掉。
- 摘要预压缩:长文本先让一个小模型或算法生成摘要,再回传给主模型。这个策略在处理日志、报表类数据时特别实用。
- 分块返回:如果用户确实需要完整明细,让模型先调用一个“查询总数”的工具,再按页查询。
这里有一个容易被忽略的点:工具返回的截断信息本身也应该被模型感知到。我习惯在结果里加一个字段,比如"truncated": true, "total_count": 200, "returned_count": 10,这样模型知道结果被截断了,回答时就不会说“一共就 10 条”,而是说“查询到 200 条记录,目前展示前 10 条”。
5.4 关于安全和鉴权的几条底线
Agent 的工具调用本质上是在给模型开放系统操作入口,安全这根弦必须从一开始就绷紧。我踩过不少坑,总结成几条硬性规则:
第一,最小权限原则。给 Agent 的工具权限一定要小于等于一个普通只读用户的权限。不要给它配一个能删库、能改配置、能操作生产的账号。宁可功能不够再单独加,也别一开始就给满。
第二,鉴权信息隔离。API Key、Token 一律从环境变量或密钥管理服务读取,不进代码仓库,不进日志。我见过直接把密钥写在工具描述里的案例,那等于把钥匙贴在大门上。
第三,执行沙箱。计算工具如果支持任意代码执行,一定要跑在隔离容器或受限沙箱里,限制网络、限制文件系统、限制资源占用。单表达式计算器则必须做 AST 白名单校验,这一点我在前面已经反复强调过。
第四,敏感操作二次确认。涉及修改、删除、订单、转账这类高影响操作的工具,我建议设计一个确认机制:工具本身不直接执行,而是先把操作内容和影响范围返回给用户,得到确认后再真正执行。Agent 可以先调用一个preview_operation工具展示将要执行的动作,等到用户确认后,再调用一个携带确认令牌的execute_operation工具。
还有一个经常被忽略的细节:工具日志。所有工具调用的入参和出参都要打日志,但日志要对敏感字段脱敏。手机号、身份证、地址这类信息在打印前做掩码处理,避免完整的个人数据进日志系统。
关于工具设计,最后再分享几条我自己的体会
工具调用这个能力,做出来容易,做好很难。我个人的体会是:工具数量不是越多越好。工具越多,模型的选择负担越重,选错工具的概率也越高。一个阶段的 Agent 最好只上它真正需要的几个工具,跑稳一个场景,再考虑增加新工具。
工具描述词值得你反复打磨。我的流程是:先按直觉写一版描述,然后用一批真实用户问题做测试,观察模型的调用准确率,再针对错误案例修改描述。改描述的比重往往超过改代码。
还有一个很实用的起步建议:如果你从来没做过工具调用,别一上来就接一堆复杂 API。用一个假数据工具,比如固定返回几条天气信息的fake_weather_tool,把 Agent 主循环、消息拼接、错误处理这一整套链路跑通,再换成真实工具。这套“先假后真”的思路能帮你少走很多弯路。等这一整套地基稳了,再去探索自动注册工具、工具间依赖编排、记忆机制这些进阶方向,就会从容很多。