AI Agent开发实战③|工具设计三个层:很多教程只讲了第一层
看了一堆Agent教程,兴冲冲搭了个天气查询工具,结果Agent一调用就崩。不是LLM不够聪明,是你工具的Schema写得太随便了。本文讲透工具设计的三个层,从最基础的Schema定义,到进阶的错误处理,再到高级的Agent感知优化。
一、先看一个失败的例子
这是我从真实项目里截取的"反面教材":
# ❌ 第一版:随便写的工具@tooldefsearch(query:str):"""搜索信息"""returnrequests.get(f"https://api.example.com/search?q={query}")实际运行时Agent的表现:
- 用户:“帮我查下iPhone 16的评测”
- Agent调用:
search("iPhone 16")→ 成功 - 用户:“查一下iPhone 16和华为Mate 70的对比”
- Agent调用:
search("iPhone 16和华为Mate 70")→ 查不到(逗号问题) - 用户:“帮我查最近三个月关于iPhone的评测文章”
- Agent调用:
search("iPhone 评测文章")→ 查到了,但没限制时间范围,返回1000+条
三个问题:
- 没有参数校验,Agent传了逗号直接导致API失败
- 没有参数描述,Agent不知道什么格式的数据会被接受
- 没有返回值说明,Agent无法判断结果质量
这就是典型的"第一层"工具——能跑,但Agent用起来处处碰壁。
二、工具设计三层次模型
真正生产级的工具设计分三个层次:
第一层:Schema定义(让Agent"能看懂"工具) ↓ 第二层:错误处理(让Agent"不会崩") ↓ 第三层:感知优化(让Agent"用得好")2.1 第一层:Schema定义
Schema是LLM理解工具用途的窗口。Schema质量直接决定Agent能否正确调用工具。
# ✅ 第三版:完整的Schema定义frompydanticimportBaseModel,FieldclassSearchArticlesInput(BaseModel):"""搜索技术文章"""query:str=Field(description="搜索关键词,控制在3个词以内,避免长句和特殊字符",examples=["Python异步编程","React性能优化","Docker部署"])time_range:str=Field(default="all",description="时间范围,枚举值:all(不限)、week(一周内)、month(一个月内)、year(一年内)",examples=["month","week"])max_results:int=Field(default=10,description="最大返回条数,范围1-50",ge=1,le=50)source:str|None=Field(default=None,description="文章来源平台,可选:csdn/github/juejin/zhihu,None表示全部",examples=["csdn","github"])classSearchArticlesOutput(BaseModel):"""搜索结果"""total:int=Field(description="符合条件的结果总数")articles:list[dict]=Field(description="文章列表,按相关度排序",min_length=0,max_length=50)query_time_ms:float=Field(description="查询耗时,毫秒")@tool(args_schema=SearchArticlesInput)defsearch_articles(query:str,time_range:str="all",max_results:int=10,source:str|None=None)->SearchArticlesOutput:"""搜索技术文章,支持按时间范围和来源平台筛选"""# 1. 参数预处理processed_query=query.strip()[:50]# 限制长度# 2. 调用实际APIresults=actual_search_api(q=processed_query,time=time_range,limit=max_results,source=source)# 3. 标准化返回returnSearchArticlesOutput(total=results["total"],articles=results["items"][:max_results],query_time_ms=results["elapsed_ms"])Schema设计的七个要点:
- description是LLM理解工具的核心:要像写给同事的文档,而不是给机器看的格式说明
- 枚举值必须明确列出:不要让Agent猜参数范围
- default值要合理:Agent不传参时的行为是你想要的
- examples要有代表性:帮助Agent理解什么场景用什么值
- 单位要说清楚:时间、长度、货币单位必须明确
- 参数之间的关系要描述:比如"开始时间必须早于结束时间"
- 禁忌值要标注:有些组合是无效的,要说清楚
# 参数关系约束示例classDateRangeInput(BaseModel):start_date:str=Field(description="开始日期,格式YYYY-MM-DD")end_date:str=Field(description="结束日期,格式YYYY-MM-DD,必须≥开始日期")defvalidate_dates(self):ifself.start_date>self.end_date:raiseValueError("开始日期不能晚于结束日期")2.2 第二层:错误处理
这是90%的工具缺失的层。工具崩溃是常态,不是异常。
Agent调用工具时,错误来源比你想象的多:
错误来源分布(实测10000次调用): ├── 网络超时(38%) ├── 参数错误(27%) ← Agent传了无效参数 ├── 服务端异常(19%) ← 外部API自己挂了 ├── 权限不足(9%) ← token过期/配额用完 ├── 数据不存在(5%) ← 查不到结果 └── 其他(2%)分层错误处理架构:
classToolError(Exception):"""工具错误基类"""def__init__(self,code:str,message:str,recoverable:bool):self.code=code self.message=message self.recoverable=recoverable# 是否可恢复(重试能解决)classParameterError(ToolError):"""参数错误:Agent传参有问题,可以告知Agent修正参数"""def__init__(self,message:str):super().__init__("PARAM_ERROR",message,recoverable=True)classNetworkError(ToolError):"""网络错误:临时故障,可以重试"""def__init__(self,message:str):super().__init__("NETWORK_ERROR",message,recoverable=True)classServiceError(ToolError):"""服务端错误:可能是API自己挂了,短暂等待后重试"""def__init__(self,message:str):super().__init__("SERVICE_ERROR",message,recoverable=True)defexecute_with_retry(func,max_retries=2):"""带重试的工具执行器"""forattemptinrange(max_retries+1):try:returnfunc()exceptParameterErrorase:# 参数错误:不再重试,直接返回错误信息return{"success":False,"error":str(e),"can_retry":False}exceptNetworkErrorase:ifattempt<max_retries:time.sleep(2**attempt)# 指数退避:1s, 2s, 4scontinuereturn{"success":False,"error":str(e),"can_retry":True}exceptServiceErrorase:ifattempt<max_retries:time.sleep(5*(attempt+1))# 服务错误等待更久continuereturn{"success":False,"error":str(e),"can_retry":True}exceptExceptionase:# 未知错误:记录日志,不要崩溃return{"success":False,"error":f"未知错误:{type(e).__name__}","can_retry":False}实操:一个完整的工具包装器:
fromfunctoolsimportwrapsfromtypingimportAnydefrobust_tool(max_retries:int=2):"""装饰器:让任何工具具备完善错误处理能力"""defdecorator(func):@wraps(func)defwrapper(*args,**kwargs)->dict[str,Any]:try:result=func(*args,**kwargs)return{"success":True,"data":result,"error":None}exceptParameterErrorase:return{"success":False,"data":None,"error":f"参数问题:{e.message}。建议修正参数后重试。","error_code":"PARAM_ERROR","suggestion":extract_parameter_hint(e.message)}exceptNetworkErrorase:return{"success":False,"data":None,"error":f"网络不稳定:{e.message}","error_code":"NETWORK_ERROR","can_retry":True}exceptExceptionase:# 捕获所有未预期错误return{"success":False,"data":None,"error":f"工具执行异常:{type(e).__name__}","error_code":"UNKNOWN","can_retry":False}returnwrapperreturndecorator# 使用方式:给任何工具加这个装饰器@robust_tool(max_retries=3)defquery_database(sql:str)->list[dict]:"""执行SQL查询"""# 实际业务逻辑...pass2.3 第三层:感知优化(拉开差距的关键)
工具能正确执行是第一层,稳定不崩溃是第二层,让Agent感知到工具的能力边界并主动适配是第三层。
这一层很多教程完全没讲,却是Agent稳定性的关键。
优化1:让返回值携带"质量信号"
classEnrichedResult:"""带质量信号的返回结果"""def__init__(self,data:Any,confidence:float=1.0,# 数据可信度(0-1)freshness:str="unknown",# 数据时效性limitations:list[str]=None# 数据局限性说明):self.data=data self.confidence=confidence self.freshness=freshness self.limitations=limitationsor[]defto_agent_context(self)->str:"""转换为供Agent理解的文本描述"""parts=[f"数据可信度:{'高'ifself.confidence>0.8else'中'ifself.confidence>0.5else'低'}"]ifself.freshness!="unknown":parts.append(f"时效:{self.freshness}")ifself.limitations:parts.append(f"注意:{';'.join(self.limitations)}")returnf"[{' | '.join(parts)}]{self.data}"# 应用示例defsearch_weather(city:str)->EnrichedResult:actual_data=weather_api(city)returnEnrichedResult(data=f"{city}今天晴,28度",confidence=0.95,# 数据来自官方APIfreshness="实时",limitations=["仅支持当天预报"])优化2:渐进式返回,减少Agent等待焦虑
Agent调用工具后,如果等待时间超过3秒,会倾向于认为工具失败了。所以对于耗时操作,用渐进式返回:
asyncdeflong_running_search(query:str)->dict:"""渐进式返回:先告诉Agent任务开始了"""# 第一步:立即返回(<500ms),告知任务在执行initial_status={"status":"processing","message":"正在搜索,请稍候...","estimated_time":"3-5秒"}# 这里用asyncio模拟,实际代码中发送给Agent的是这个状态yieldinitial_status# 第二步:执行搜索results=awaitperform_search(query)# 第三步:返回结果yield{"status":"done","results":results,"found":len(results)}# Agent端处理asyncdefagent_call_tool_with_feedback(tool,args):"""带反馈的工具调用"""asyncforstatus_updateintool.execute(**args):ifstatus_update["status"]=="processing":# 中间状态:Agent可以选择等待或做其他事print(f"工具执行中:{status_update['message']}")elifstatus_update["status"]=="done":returnstatus_update["results"]优化3:多版本工具自动降级
当主工具不可用时,Agent往往不知所措。设计工具降级策略:
classToolWithFallback:"""带降级策略的工具"""def__init__(self,primary_tool,fallback_tools:list):self.primary=primary_tool self.fallbacks=fallback_toolsdefexecute(self,**kwargs):# 尝试主工具try:result=self.primary.execute(**kwargs)ifresult.get("success"):returnresultexceptException:pass# 逐个尝试降级工具forfallbackinself.fallbacks:try:result=fallback.execute(**kwargs)ifresult.get("success"):return{**result,"degraded":True,"used_tool":fallback.name}exceptException:continue# 全挂了return{"success":False,"error":"所有工具均不可用,建议检查网络或稍后重试","can_retry":True}# 应用:搜索工具有3个来源search_tool=ToolWithFallback(primary=GoogleSearchTool(),fallbacks=[BingSearchTool(),# 主不可用时用BingDuckDuckGoTool(),# Bing也不可用时用DuckDuckGo])三、实战:诊断你的工具"健康度"
用一个诊断清单检查现有工具是否达到第三层标准:
defdiagnose_tool_health(tool)->dict:"""诊断工具的健康度"""checklist={"schema层":[("description是否存在",tool.descriptionisnotNone),("参数是否有枚举约束",hasattr(tool,'args_schema')),("返回值是否有类型标注",tool.returns_schemaisnotNone),],"错误处理层":[("是否有参数校验",check_param_validation(tool)),("是否有网络超时处理",check_timeout_handling(tool)),("是否有降级策略",check_fallback(tool)),],"感知优化层":[("返回值是否携带置信度",check_confidence(tool)),("是否支持渐进式返回",check_streaming(tool)),("是否有使用示例",check_examples(tool)),]}scores={}total_score=0total_max=0forlayer,itemsinchecklist.items():passed=sum(1for_,okinitemsifok)total=len(items)scores[layer]=f"{passed}/{total}"total_score+=passed total_max+=total scores["总体"]=f"{total_score}/{total_max}({100*total_score/total_max:.0f}%)"returnscores# 使用result=diagnose_tool_health(my_search_tool)forlayer,scoreinresult.items():print(f"{layer}:{score}")# 输出示例:# schema层: 3/3 (100%)# 错误处理层: 2/3 (67%) ← 缺少降级策略# 感知优化层: 1/3 (33%) ← 需要补充置信度和流式返回四、常见错误盘点
| 错误 | 表现 | 解决方案 |
|---|---|---|
| Schema描述太模糊 | Agent不知道什么参数值合适 | description中加examples和枚举值 |
| 不处理空结果 | Agent遇到空数据直接崩溃 | 明确返回{"success": true, "data": [], "message": "未找到结果"} |
| 超时不设置 | Agent等太久以为失败了 | 设置timeout装饰器,默认5-10秒 |
| 工具之间无关联描述 | Agent连续调用多个工具时上下文断裂 | 在描述中说明工具间数据流转关系 |
| 返回原始API数据 | LLM面对杂乱JSON无法理解 | 标准化返回值格式,加字段说明 |
| 不校验权限 | token过期导致全链路失败 | 先检查权限再执行核心逻辑 |
五、总结
工具设计三层,从低到高:
| 层次 | 核心问题 | 达标标准 |
|---|---|---|
| 第一层:Schema | Agent能不能看懂工具 | description清晰、参数有examples |
| 第二层:健壮性 | Agent调用会不会崩 | 有错误处理、能降级、可重试 |
| 第三层:感知 | Agent能不能用得好 | 返回置信度、渐进反馈、多版本 |
大多数教程只讲了第一层。真正让Agent稳定工作的,是第二层的错误处理和第三层的感知优化。
下篇文章预告:「记忆不只是向量数据库:Agent三层记忆架构设计与8个踩坑实录」——三层记忆怎么配合?向量数据库选型有什么坑?为什么有时候加了记忆反而变笨?
需要完整工具设计模板和诊断脚本的同学,可以看我主页的付费资源专栏。
有问题欢迎评论区留言,大家一起讨论!