从一次凌晨三点的事故说起
凌晨三点,线上告警:Agent 连续三次调用天气 API 返回了“晴”,但用户反馈窗外正在下暴雨。我盯着日志看了十分钟,发现 Agent 调用的参数里 latitude=39.9042, longitude=116.4074——这是北京天安门的坐标。用户明明在深圳。
问题出在哪?Agent 把用户说的“深圳”解析成了“北京”的经纬度。更致命的是,我写的工具函数没有校验参数范围,直接把这个坐标塞给了气象局 API。那天晚上我学到一件事:工具调用不是“把参数传过去就行”,而是 Agent 与外部世界之间的防火墙。
工具调用的本质:给 Agent 装一双手
很多人把 Function Calling 理解成“让大模型调用函数”,这个说法没错,但太浅了。真正的工程视角是:工具调用是 Agent 的“执行层”,负责把自然语言意图翻译成结构化的系统指令。
大模型本身不执行任何操作,它只生成 JSON。比如用户说“帮我查一下北京明天的天气”,模型输出的是:
{"function":"get_weather","parameters":{"city":"北京","date":"2026-01-15"}}然后你的代码拿到这个 JSON,去调用真实的 API。这个“拿到 JSON → 校验 → 执行 → 返回结果”的过程,就是执行层的全部工作。
工具定义:别让模型猜你的参数
写工具定义(Tool Definition)是最容易踩坑的地方。我见过最离谱的写法是:
# 别这样写!参数描述太模糊tools=[{"type":"function","function":{"name":"send_email","description":"发送邮件","parameters":{"type":"object","properties":{"to":{"type":"string"},"subject":{"type":"string"},"body":{"type":"string"}}}}}]模型看到这个定义,会把“发送邮件”理解成任何形式的邮件发送。用户说“给张三发个问候邮件”,模型可能把“张三”填进to字段,但系统里根本没有叫“张三”的用户。
正确的做法是把参数约束写进描述里:
# 这里踩过坑:参数描述要包含业务规则tools=[{"type":"function","function":{"name":"send_email","description":"发送内部邮件,收件人必须是公司邮箱后缀","parameters":{"type":"object","properties":{"to":{"type":"string","description":"收件人邮箱,必须是 @company.com 结尾"},"subject":{"type":"string","description":"邮件主题,不超过100字符"},"body":{"type":"string","description":"邮件正文,支持Markdown格式"}},"required":["to","subject","body"]}}}]经验:描述越具体,模型越不容易犯错。把业务规则、边界条件、格式要求全部写进去。别指望模型“理解”你的业务,它只理解你写出来的文字。
参数校验:执行层的最后一道防线
模型生成的参数不一定合法。我见过模型把temperature传成"high"(字符串),把user_id传成负数。所以执行层必须做两件事:
- 类型校验:确保参数类型正确
- 业务校验:确保参数值在合理范围内
defvalidate_weather_params(params):# 这里踩过坑:模型可能传非数字的经纬度try:lat=float(params.get("latitude",0))lon=float(params.get("longitude",0))except(TypeError,ValueError):returnFalse,"经纬度必须是数字"# 别这样写:直接信任模型传的值ifnot(-90<=lat<=90):returnFalse,f"纬度{lat}超出范围 [-90, 90]"ifnot(-180<=lon<=180):returnFalse,f"经度{lon}超出范围 [-180, 180]"returnTrue,{"latitude":lat,"longitude":lon}经验:永远不要信任模型输出的参数。把它当成用户输入来校验。我见过一个生产事故,模型把amount传成了-100,导致财务系统扣了负数的钱——相当于给用户充值了。
错误处理:别让 Agent 卡死
工具调用一定会出错。API 超时、参数非法、权限不足……这些错误怎么处理,决定了 Agent 的鲁棒性。
我见过两种极端:
- 直接抛异常:Agent 对话中断,用户看到一堆 traceback
- 吞掉错误:Agent 说“操作成功”,但实际没执行
正确的做法是把错误信息结构化地返回给模型,让模型决定下一步:
defcall_api_with_retry(func,params,max_retries=2):forattemptinrange(max_retries):try:result=func(**params)return{"status":"success","data":result}exceptTimeoutError:ifattempt==max_retries-1:return{"status":"error","error_type":"timeout","message":"API 请求超时,请稍后重试"}continueexceptPermissionErrorase:return{"status":"error","error_type":"permission","message":f"权限不足:{str(e)}"}exceptExceptionase:return{"status":"error","error_type":"unknown","message":f"未知错误:{str(e)}"}经验:错误信息要包含“错误类型”和“可读描述”。模型看到error_type: timeout会知道“哦,需要重试”;看到error_type: permission会知道“需要换一个方式”。
上下文管理:工具调用的“记忆”
Agent 调用工具后,结果需要放回对话上下文。但这里有个坑:工具返回的数据可能很大。
比如调用“查询用户订单”API,返回了 1000 条订单记录。如果你把这些数据全部塞回上下文,token 会爆炸,模型也会迷失在数据里。
我的做法是对工具返回结果做摘要:
defsummarize_tool_result(result,max_length=500):# 这里踩过坑:直接返回完整数据导致 token 超限ifisinstance(result,list)andlen(result)>10:summary=result[:5]# 只保留前5条summary.append(f"... 还有{len(result)-5}条记录未显示")returnsummaryifisinstance(result,str)andlen(result)>max_length:returnresult[:max_length]+"..."returnresult经验:工具返回给模型的数据,应该是“模型需要知道的信息”,而不是“API 返回的全部信息”。模型不需要看 1000 条订单,它只需要知道“用户有 1000 条订单,最近一条是昨天”。
并发与限流:别把下游打崩
Agent 调用工具时,可能同时触发多个 API 调用。比如用户说“查一下北京、上海、深圳的天气”,模型可能一次性生成三个工具调用请求。
如果你的代码是串行执行的,用户要等 3 秒(每个 API 1 秒)。但如果并发执行,1 秒就能返回。但并发有风险:下游 API 可能有频率限制。
importasynciofromaiolimiterimportAsyncLimiter# 这里踩过坑:并发太高被 API 封 IPlimiter=AsyncLimiter(max_rate=10,time_period=1)# 每秒最多10次asyncdefcall_with_rate_limit(func,params):asyncwithlimiter:returnawaitfunc(**params)asyncdefbatch_call_tools(tool_calls):tasks=[]forcallintool_calls:task=call_with_rate_limit(call.func,call.params)tasks.append(task)results=awaitasyncio.gather(*tasks,return_exceptions=True)returnresults经验:给每个外部 API 单独配置限流器。不同 API 的限流策略不同,别用一个全局限流器。我见过一个案例:调用天气 API 和调用数据库 API 共用一个限流器,导致数据库查询被天气 API 拖慢。
安全:工具调用的“门禁”
工具调用是 Agent 与外部系统交互的通道,也是最容易被攻击的地方。我见过最严重的安全事故是:Agent 调用了delete_user工具,参数是user_id=1,结果删除了管理员账号。
安全措施至少包括:
- 参数白名单:只允许模型使用指定的参数值
- 操作权限:区分“只读”和“读写”工具
- 人工确认:高危操作需要用户二次确认
# 别这样写:直接执行模型传过来的 SQLdefexecute_sql(query):# 这里踩过坑:模型可能传 DROP TABLEallowed_operations=["SELECT","INSERT","UPDATE"]operation=query.split()[0].upper()ifoperationnotinallowed_operations:return{"status":"error","message":f"不允许执行{operation}操作"}# 执行查询...经验:把工具分为“安全工具”和“危险工具”。安全工具(如查询天气)可以直接执行;危险工具(如删除数据)需要用户确认。这个分类写在工具定义里,让模型知道哪些工具需要“先问用户”。
个人经验:工具调用的三个原则
写了两年 Agent 工具调用,踩了无数坑,总结三个原则:
原则一:工具是 Agent 的“手”,不是“大脑”
工具只负责执行,不负责决策。决策是模型的事。所以工具函数要简单、确定、可预测。不要在工具函数里写复杂的业务逻辑,那应该放在模型推理阶段。
原则二:每个工具都要有“失败预案”
工具调用一定会失败。网络超时、参数错误、权限不足……每个失败场景都要有对应的处理逻辑。我见过最差的代码是try: ... except: pass,这会让 Agent 在错误状态下继续运行,产生更严重的后果。
原则三:工具定义是“合同”,不是“说明书”
模型会严格按照工具定义来生成参数。所以工具定义要像法律合同一样严谨:参数类型、取值范围、业务规则、错误码……全部写清楚。模糊的描述会导致模型产生幻觉。
最后说一句:工具调用的调试是最痛苦的。因为错误可能来自模型(参数生成错误)、来自网络(API 超时)、来自业务(参数不合法)。建议在开发阶段,给每个工具调用加上详细的日志,记录“模型生成的参数 → 校验结果 → API 返回结果 → 返回给模型的结果”。这样出问题时,你能快速定位是哪个环节出了问题。
别问我为什么知道——凌晨三点的教训,一次就够了。