前阵子我搭了一个客服问答Agent,上线第二天就翻车了。用户问“帮我查一下编号A2093的订单啥时候发货”,Agent一本正经地回答“您的订单预计在3个工作日内发出,请您耐心等待”。实际上它根本看不到任何订单数据,那只是大模型典型的“礼貌性幻觉”。
这件事让我彻底想明白一个道理:在LLM应用里,光有一个聪明的“大脑”远远不够,Agent要真正替人干活,必须长出“手”。所谓工具调用能力,就是给模型配备一组可执行的函数——搜索、计算、第三方API等——模型负责决策“什么时候调用哪个工具”,程序负责真正执行,再把结果喂回给模型做下一步推理。这篇内容就是把搜索、计算、API这三类最常用的工具能力逐一拆开,讲清楚原理、给出可直接复制改写的代码、再补上我在实际项目中踩过的坑。适合正在做LLM应用开发、想给聊天机器人或自动化Agent升级能力的开发者。
1. 给Agent“装上手”之前:先想清楚哪类能力必须靠工具
动手写代码之前,建议先做一次需求判断。工具调用不是越多越好,而是要把模型“天生做不好的事”交给外部程序,把模型“擅长的事”留给推理。
1.1 模型单纯靠“死记硬背”搞不定的三类任务
大语言模型本质是一个文本续写器。它学到的知识是在训练阶段固化下来的,推理也建立在概率预测之上。这就决定了下面三类任务单靠模型硬答,一定会出问题:
- 时效性信息:训练数据有截止时间。你问“某城市今天天气怎么样”“目前某股票价格是多少”“某产品最新版本有哪些功能”,模型要么编一个,要么只会说“我不支持实时信息”。
- 精确计算:多位乘除、多步推导、进制转换、单位换算。模型输出是token概率,它不是计算器,数字越大、步骤越多,出错率越高。
- 外部系统交互:模型根本不知道你的订单库、用户库、支付系统里有什么数据,更没有权限去改任何状态。它只能“说”,不能“查”和“改”。
用一张表可以看得很直观:
| 任务类型 | 模型硬答的表现 | 加入工具后的表现 |
|---|---|---|
| 查询实时天气 | 编造一个看起来合理的天气描述 | 搜索工具返回真实预报,准确到温度区间 |
| 计算4567×289 | 大概率给出接近但错误的数字 | 计算工具返回1323863,分毫不差 |
| 查订单A2093状态 | 用通用话术安抚用户,实际是幻觉 | 业务API返回真实物流节点,附更新时间 |
这个判断逻辑可以复用:凡是“结果需要外部事实来校验”的,就应该接工具;凡是“结果能从模型已有知识推理出来”的,比如写作、改写、总结、给建议,反而没必要动用工具串,否则只会增加调用延迟和成本。
1.2 工具调用常见的三种落地形态
同样是“给模型加工具”,业界有几种不同做法,区别在于模型怎么表达“我要调用工具”,以及程序怎么解析这个意图。
第一种是原生Function Calling协议。部分主流大模型服务商在接口里提供了tools参数,调用时声明好工具清单,模型在回复时除了普通文本,还会多返回一个结构化字段,包含要调用的函数名和参数。这是目前我用下来最稳定、最省事的方案,参数按JSON Schema传入,解析成本很低。
第二种是ReAct风格的提示词约定。不依赖平台原生协议,而是把工具的使用说明写进system prompt,要求模型在需要工具时输出一段约定格式的JSON,例如{"action": "web_search", "action_input": "某城市今天天气"}。程序自己写解析器和执行器。好处是换模型厂商不用改代码,坏处是模型偶尔会格式漂移,需要写一套纠错逻辑。
第三种是框架封装。像LangChain这类编排框架把上述机制再包了一层,内置了搜索、计算等常用工具的Tool类,用起来快,但中间多了一层抽象,出现问题时要排查框架源码,学习成本反而更高。
| 形态 | 稳定性 | 可控性 | 上手速度 | 适合场景 |
|---|---|---|---|---|
| 原生Function Calling | 高 | 中 | 快 | 应用只绑定单一模型服务商 |
| ReAct式JSON约定 | 中 | 高 | 中 | 需要跨服务商切换 |
| 框架Tool封装 | 中高 | 低 | 最快 | 快速验证原型 |
我自己的偏好是:正式项目优先用原生Function Calling,因为它让“模型决策”和“程序执行”的边界最清晰;如果哪天需要切换底层模型服务商,再退到ReAct式约定也不迟。
2. 核心机制拆解:模型是决策者,不是执行者
很多人第一次接触工具调用时会有一个误解,以为“让Agent用工具”意味着模型内部会运行代码。实际上模型只是输出了一段结构化的“调用意愿”,真正执行的是你的代码。理清这个边界,后面所有问题都好办。
2.1 一次完整调用的五个步骤
拿一个最简单的场景举例:用户问“某城市今天适合跑步吗”。一次带工具调用的完整流程是这样的:
- 程序把用户问题发给模型,同时附带tools参数,里面声明了web_search和calc两个工具的Schema。
- 模型判断“适合跑步吗”需要天气数据,于是返回一个tool_calls结构,内容是:调用web_search,参数为query=“某城市今天天气”。
- 程序收到这个结构后,不把文本直接返给用户,而是自己去执行search函数,拿到真实的天气JSON。
- 程序把搜索结果以“工具消息”的身份追加进对话上下文,再发给模型。
- 模型看到工具结果后,结合天气信息继续推理,生成最终答复“上午有小雨,建议改到室内跑步”。
代码层面,用伪代码描述就是:
# 第一轮:带工具声明的对话 resp = client.chat( model=MODEL_NAME, messages=[ {"role": "user", "content": "某城市今天适合跑步吗?"} ], tools=TOOLS_SCHEMA, ) # 检查模型是否要求调用工具 if resp.tool_calls: call = resp.tool_calls[0] result = dispatch_tool(call) # 这里执行你的真实函数 messages.append({"role": "tool", "tool_call_id": call.id, "content": result}) # 第二轮:把工具结果还给模型,让它生成最终答案 final_resp = client.chat(model=MODEL_NAME, messages=messages, tools=TOOLS_SCHEMA) print(final_resp.content)其中dispatch_tool是个简单的分发函数,本质就是一个字典查找:
def dispatch_tool(call): tool_map = { "web_search": web_search, "safe_calc": safe_calc, "query_order_status": query_order_status, } func = tool_map[call.function.name] return json.dumps(func(**json.loads(call.function.arguments)), ensure_ascii=False)这个闭环里最容易出错的一环,不是工具执行本身,而是“参数传递”。模型返回的arguments是JSON字符串,需要反序列化后再传参;如果模型生成的参数类型不对,比如把数字字段传成了字符串,执行器要有校验逻辑,不能直接崩掉。
2.2 工具Schema怎么写,直接决定模型选不选得对
同一个功能,Schema写得含糊和写得具体,模型的选择命中率差距很大。工具描述对模型来说,就像产品说明书对用户一样——它只能根据你写的说明来决定“什么时候用这个工具”。
一个标准的搜索工具Schema长这样:
{ "name": "web_search", "description": "搜索互联网获取实时信息。当用户询问新闻、天气、实体动态、行情、物流跟踪、人物近况等训练数据可能覆盖不到的内容时使用。", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "精简后的搜索关键词,去掉语气词和冗余信息,保留核心实体和限定词" } }, "required": ["query"] } }写Schema有三条经验,是常规文档里找不到的:
第一,description里要写“使用场景”,而不是只写“功能”。光写“搜索网页”模型经常不知道该何时触发;写成“当用户询问实时信息时使用”,模型就容易识别。第二,参数description要写清楚“输入什么形式的内容”。比如search工具的query,建议让模型做关键词精简,而不是把整段用户问题原封不动传进来,不然搜索接口的召回质量会差一大截。第三,一次暴露给模型的工具不要超过8个。工具太多,模型像在逛一个商品过多的超市,容易拿错东西。我一般控制在5个左右,确定必须要用的再挂上去。
3. 接入搜索工具:让Agent拥有“实时信息源”
聊完机制,进入实战。搜索工具是Agent工具链里最常用的一环,它解决的是“模型知识截止时间”和“长尾冷门信息覆盖率不够”这两大短板。
3.1 搜索API的选型思路
做Agent的搜索能力,有几种选型路径:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 商用搜索开放API | 返回结构化结果,稳定,有配额管理 | 需要付费,有调用速率限制 | 中大型Agent产品 |
| 新闻/专项数据API | 垂直领域精度高,噪音少 | 覆盖范围窄 | 只做新闻、舆情、金融等场景 |
| 自建爬虫加索引 | 可控数据源,无费用增长问题 | 反爬规则复杂,合规和稳定性风险高 | 内部知识库闭环系统 |
如果是自己做项目起步,不要一上来就自建爬虫,先用商用搜索API把流程跑通。等到搜索量大、成本敏感了,再考虑混合方案。
这里提一个容易忽略的点:搜索API返回的结果里,标题、链接、摘要这三项是最常用的,但很多接口还会返回图片、站点分类、发布时间等扩展字段。保存时可以全量存,但喂给模型前一定要裁剪。
3.2 一个最小可用的搜索函数实现
我写过一个最小实现,核心就是把HTTP请求包装成一个工具函数:
import requests def web_search(query: str, max_results: int = 5) -> dict: url = "https://api.example.com/v1/search" params = { "q": query, "count": max_results, "api_key": SEARCH_API_KEY, } try: resp = requests.get(url, params=params, timeout=10) resp.raise_for_status() data = resp.json() except requests.exceptions.Timeout: return {"error": "搜索服务超时,请稍后重试"} except requests.exceptions.HTTPError: return {"error": f"搜索服务返回错误状态码 {resp.status_code}"} results = [] for item in data.get("results", []): results.append({ "title": item.get("title", ""), "url": item.get("url", ""), "snippet": item.get("snippet", "")[:160], }) return {"results": results}注意两点:一是timeout参数一定不能省,搜索接口是外部依赖,慢后端不能拖死整个Agent会话;二是把异常信息做成可读的error字段返回,这样模型在下一轮还能基于错误做决策,比如换个query重试,而不是直接对用户说“我调接口失败了”。
3.3 结果瘦身:别把原始返回一股脑丢进上下文
搜索接口返回的原始JSON很容易突破几千字符。如果直接把整个response传给模型,会出现三个问题:一是挤占上下文窗口,留下给推理的空间变小;二是噪音信息干扰模型判断,让它分不清哪条才是关键结果;三是token消耗成倍上涨,成本随之翻倍。
我建议在把搜索结果接入对话之前做一个三层瘦身:
第一层,字段裁剪。去掉图片参数、站点统计、竞价信息等扩展字段,只留title、url、snippet。第二层,摘要截断。snippet超过160个字符就截断,截断点尽量放在句号或逗号后面。第三层,去重过滤。同一域名下的相似标题只保留一条,能显著减少冗余。
这一步做完,一次搜索返回的文本量能压缩到原来的三分之一左右,而关键信息几乎无损。
4. 接入计算工具:给Agent装一颗“精确的数学脑”
搜索解决信息问题,计算解决精确性问题。聊到数学能力,LLM真的很脆弱,这不是参数规模的问题,而是概率生成模式决定了它天生不适合做精确运算。
4.1 为什么模型算不对数,而你却需要一个专门的计算器
让模型算4567乘289,它可能会给出接近但错误的数字。原因在于,模型学到的是“乘法过程的模式”,而不是真正执行乘法。它的输出是“最可能的下一个token”,一旦数字变大、进位变多、步骤变长,某一个token偏离,后面全盘皆错。
但Agent应用里又经常遇到数学需求:优惠计算、拼单分摊、日期差、单位换算、库存核算等等。这时候最好的方案就是给Agent挂一个safe_calc工具,让模型把用户的话翻译成数学表达式,然后交给Python精确执行。
4.2 别直接用eval,用AST白名单才能既精确又安全
有人图省事直接写eval(expression),这在Agent场景里非常危险。用户和模型生成的表达式都可能包含恶意代码,比如__import__('os').system('rm -rf ...'),eval会直接执行。正确的做法是做一个基于AST(抽象语法树)的白名单解析器,只允许四则运算和幂运算这些安全节点。
import ast import operator ALLOWED_NODES = ( ast.Expression, ast.Constant, ast.BinOp, ast.UnaryOp, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.USub, ast.Load, ) OPERATORS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, } def safe_eval(node): if isinstance(node, ast.Constant): return node.value if isinstance(node, ast.BinOp): left = safe_eval(node.left) right = safe_eval(node.right) return OPERATORS[type(node.op)](left, right) if isinstance(node, ast.UnaryOp): operand = safe_eval(node.operand) return -operand if isinstance(node.op, ast.USub) else operand raise ValueError("不支持的表达式") def safe_calc(expression: str): tree = ast.parse(expression, mode="eval") for node in ast.walk(tree): if not isinstance(node, ALLOWED_NODES): return {"error": "表达式包含不允许的操作"} try: result = safe_eval(tree.body) return {"result": result} except Exception: return {"error": "表达式计算失败,请检查括号和运算符"}这段代码能覆盖加减乘除、括号、负数、幂运算,并且从根本上杜绝了代码注入。如果你预算用现成库,也可以用SymPy做符号计算,它能顺手处理根号、积分等更复杂的数学表达式,适合场景更重的Agent。
4.3 把计算工具的“边界”设计清楚,模型才不会滥用
计算工具的一大坑是模型把不该算的东西也丢进来算。比如用户问“某城市今天气温会不会超过30度”,模型有可能把这个拆成表达式30 > 28,这其实是可以的;但如果模型把“搜索天气”和“温度比较”两件事混在一个工具里,就会出现决策混乱。
我在实际项目里的做法是:给safe_calc工具的description加一句话“仅用于精确数学运算,如四则运算、幂运算、比较运算,不用于信息查询、文本处理、搜索”。这看起来是小事,但能明显降低模型把这个工具当成万金油去用的情况。另外,计算前建议让模型先“规范化”表达式,把中文数字、百分比、单位转为数学表达,这一步可以在提示词里加一条小约束,也可以在调用计算工具之前加一个preprocess函数。
5. 接入业务API:让Agent真正触达你的系统数据
搜索和计算属于通用工具,第三方API属于定制工具。业务API接入是Agent从“玩具”走向“生产力”的关键一步,也是工程上最需要抠细节的地方。
5.1 一个实战样例:把“查询订单状态”封装成工具
假设你的Agent需要回答用户关于订单状态的问题。后端已经有一个HTTP接口:GET /api/v1/orders/{order_id}/status,返回订单状态、物流节点、更新时间等信息。现在要把它变成一个Agent可调用的工具,分三步。
第一步,定义函数。第二步,写好函数和Schema。第三步,把Schema挂到工具列表里。函数实现大致是这样:
import requests def query_order_status(order_id: str) -> dict: url = f"https://internal.example.com/api/v1/orders/{order_id}/status" headers = {"Authorization": f"Bearer {API_TOKEN}"} try: resp = requests.get(url, headers=headers, timeout=5) resp.raise_for_status() data = resp.json() return { "order_id": data.get("order_id"), "status": data.get("status"), "logistics": data.get("logistics", []), "updated_at": data.get("updated_at"), } except requests.exceptions.HTTPError as exc: code = exc.response.status_code if code == 404: return {"error": f"订单 {order_id} 不存在,请确认编号是否正确"} return {"error": f"订单服务暂时不可用,状态码 {code}"} except requests.exceptions.Timeout: return {"error": "订单查询超时,请稍后再试"}对应Schema里,description可以这样写:“根据订单ID查询订单状态和物流信息。当用户询问订单编号、物流进度、发货时间时使用。订单ID一般是以A或B开头的字符串编号。”
5.2 鉴权、超时和错误回传:三个必须做对的细节
业务API和公开搜索API不一样,它涉及鉴权、租户隔离、内部网络,踩坑点更多。
鉴权方面,不要把完整的Token暴露给模型,也不要在工具函数里硬编码密钥。更好的是在Agent服务启动时从环境变量或密钥管理服务读取密钥,只把它用于HTTP请求头。如果系统支持临时票据,优先用短期票据代替长期Token。
超时方面,业务接口经常比搜索接口还慢。我习惯把下游API超时控制在5秒内,如果超过5秒返回error字段,宁可不回答也不能挂起会话。因为生产环境里,一次对话可能串联多次工具调用,每次多等3秒,用户体感就会很差。
错误回传方面,一定要返回“对模型友好”的错误信息。模型不是人,你给它返回一长串堆栈,它只能干瞪眼;你告诉它“订单A2093不存在”,它就能继续判断是让用户检查编号还是转人工。这个小差别,直接决定了Agent的容错能力。
5.3 多租户场景下的参数隔离思路
如果你的Agent服务给多个客户共同使用,业务API工具要格外注意用户隔离。不能用户A查订单,结果函数内部用了用户B的Token和权限。做法是:在工具分发层额外注入一个context对象,把当前用户的身份ID、会话ID放进去,工具函数内部根据这个context来计算请求参数,而不是让模型自由传入用户的身份标识。模型只负责传业务参数,比如订单号、关键词,身份信息由运行环境自动追加。
这一步做不好,Agent很容易变成越权工具,合规风险很大。
6. 工具调用循环的控制与四个典型坑
把搜索、计算、API这些单个工具都装好之后,还有一个全局性的问题需要处理:Agent可能会在一次回答里连续调用多个工具,也可能调错、调超时、甚至陷入死循环。这部分是上线前必须做好的。
6.1 一段可复用的工具调用循环骨架
原生的Function Calling接口允许模型在一次响应里返回多个tool_calls,也允许连续多轮调用。所以程序端要写一个循环,而不是每轮都手动处理。一个骨架结构如下:
MAX_ITERATIONS = 5 def run_agent(user_message: str): messages = [{"role": "user", "content": user_message}] for _ in range(MAX_ITERATIONS): resp = client.chat(model=MODEL_NAME, messages=messages, tools=TOOLS_SCHEMA) if not resp.tool_calls: return resp.content for call in resp.tool_calls: result = dispatch_tool(call) messages.append({ "role": "tool", "tool_call_id": call.id, "content": result, }) return "处理流程过长,请简化问题或稍后再试"MAX_ITERATIONS建议设在3到5之间。搜索、计算、API三轮基本能覆盖绝大部分需求,超过这个次数大概率是模型在循环里打转。
6.2 坑一:工具返回了模型读不懂的数据
第一次实战时,我遇到过工具返回的JSON里包含特殊符号、换行符和超长文本,模型在下一轮解析时直接报格式错误。解决方法是:在返回给模型之前,对content做一次JSON序列化,保证所有非ASCII字符被转义,同时截断超长字段。
return json.dumps(result, ensure_ascii=True, default=str)[:2000]ensure_ascii=True虽然会让内容变得不太好看,但能规避掉很多编码解析问题。
6.3 坑二:工具描述不清导致模型选错工具
有一次我把搜索工具的description写得过于大而全,结果模型连“计算两笔订单的合计费用”这种纯本地运算也调用了搜索工具,搜索接口白白跑一趟,返回一堆无关网页。这类问题只能从Schema侧解决:每个工具的边界要写成“什么情况用、什么情况不用”,并且通过测试集反复调描述文本。改一句话、命中率从50%提到90%,这种事真的会发生。
6.4 坑三:下游API太慢,把整个会话拖死
搜索接口、业务API都可能慢到几十秒。如果Agent是直连前端,用户早就走了。我的方案是给每次外部HTTP调用加timeout,并且在工具函数内部做统一降级:比如搜索接口超时了,就返回一条固定错误信息,让模型直接告诉用户“实时搜索暂不可用”,而不是卡住不回复。高可用优先于高可用性,这句在Agent工具链里是真的。
6.5 坑四:Agent进入工具调用死循环
最棘手的是循环调用。我见过一次事故:用户问“某产品最新款”,搜索结果里包含了一条“该产品评测”的链接,Agent觉得信息不够,又调搜索,搜索返回还是不够详细,它又继续调……直到最大轮次才停下来。用户没有得到任何答复。
对付循环有两个手段:一个是前面说的MAX_ITERATIONS兜底;另一个是在循环里加一个“重复意图检测”,如果模型连续两次调用了同一个工具且参数完全相同,就强制中断,并把结果返给用户或转接人工。
6.6 上线前建议:从三个工具起步
最后给一个很实际的建议:不管你的Agent最终要接多少个服务,第一版先只挂3个工具——一个搜索、一个计算、一个最核心的业务API。工具数量少,模型的选择准确性高,调试范围小,日志也能看得明白。等这三个跑稳了,再逐步扩充,每次加一个都要回到测试集上重新跑一遍旧用例,防止新增工具挤占旧工具的触发空间。
我个人的体会是,工具调用能力的工程难度不在于“让模型返回tool_calls”,而在于“让模型每一次决策都可解释、可兜底、可观测”。日志里记录下每一次工具选择、参数、耗时、返回结果大小,排查问题时会轻松非常多。Agent的“手”装上之后,真正考验人的是给它划定清晰的能力边界,以及为每一次意外留好后路。