前四章我们把 ADK 的基本功练完了:Agent 的定义、模型参数调整、会话与状态管理、多轮记忆处理。但说实话,不管给内部同学做分享还是自己动手跑 demo,大家最兴奋的永远是同一个环节——工具集成。一个只会聊天的 Agent 就像一个只有嘴没有手的实习生,能说不能干;一旦接上工具,它才能真正去查订单、算价格、拉日报、改配置。本文是《Google Agent Development Kit (ADK) 指南》的第五章,主题是工具集成与自定义。我会把在实际项目里接工具踩过的坑、总结出来的套路,按一条完整链路讲透,适合已经能写出基础 Agent、想让 Agent 真正接入业务系统的朋友。
1. 先搞清楚一条调用链路:工具不是“插件挂载”,是模型的一种选择
很多初学者第一次接工具时,脑子里想的是“我教模型怎么用这个函数”。这个理解方向有问题。实际上整个过程不是模型学会使用函数,而是模型在每一步推理时决定“我要不要去请求外部工具来获取信息”,然后由你的程序去执行这个工具、把结果返回给模型,模型再继续推理。理解不了这一层,后面所有调试都会变难。
1.1 Function Calling 的两段式原理
如果你用过 Gemini、GPT 这些模型的 Function Calling 接口,应该对这套机制不陌生。比如现在有一个函数get_weather(city),当用户说“北京明天穿什么”时,模型并不会直接输出天气预报,而是先输出一个类似“我要调用 get_weather,参数是 {city: '北京'}”的结构。你的代码收到这个结构,去请求天气服务,再把结果作为用户侧消息注入下一次模型请求。模型看到真实天气数据后,才开始组织自然语言回答。
这个过程就是 agent 循环的核心:推理、决定调用工具、执行、把结果喂回去、再推理。ADK 做的事情,就是把这个循环的脚手架搭好,让你不用手写一堆请求模型、解析 tool_call、拼接消息的胶水代码。
你可以把这个机制想象成一位老板(模型)和一位下属(你的代码)。老板手头有一张清单,上面写满了可用的电话分机(工具列表)。遇到问题时,老板先看清单,决定“这问题需要找财务部确认”,然后拨分机、按下免提。下属接通电话、回答问题后,老板基于听到的数字继续做决策。整个过程中,老板不需要知道财务部内部怎么记账,下属也不需要替老板做判断。
1.2 在 ADK 中工具处于什么位置
ADK 里定义一个带工具的 Agent 非常直接,核心是把工具列表塞给Agent的tools参数。注册完成后,框架每次自动执行上面的“模型决定调用 -> 框架调工具 -> 结果回填”循环。这个循环什么时候停呢?通常是模型判断不再需要调用工具、直接输出最终回复的时候,或者达到你设置的最大轮数上限。
我在项目里给新人讲这部分时,最喜欢用的一个例子是“现在几点”。如果模型没有任何工具,你问它“帮我查一下现在北京时间几点”,它只能凭训练数据猜一个,或者诚实说不知道。但如果你注册了一个get_current_time()工具,模型会在这个环节选择调用它。这说明一个关键点:工具本质上扩展了模型的信息边界,让 Agent 从“只根据训练数据推断”变成“可以查询实时状态”。
所以当你说“我的 Agent 表现不够好”时,先别急着换大模型或者堆更复杂的 prompt,先检查它手上有没有解决这类问题所必需的工具。没有工具,再强的模型也只能当一个“什么都懂但什么都做不了”的顾问。
2. 内置工具和官方工具生态:先跑通整条链路再动手自定义
很多人的误区是一上来就写自定义工具,连框架自带的工具长什么样都没见过。我建议你先用官方提供的工具跑一遍,把“模型选择工具 -> 代码执行 -> 结果回填”这个链路从纸上落到实战里。因为自定义工具本质上也是往这个同样的注册机制里塞函数,你只有先理解官方工具如何工作,才知道自己写的东西模块边界该画在哪里。
2.1 把代码执行能力交给 Agent
官方工具生态里最常见的无疑是代码执行工具。想象一个场景:用户问“我有一笔 2000 元的订单,但实际只到账了 1536.4 元,请帮我算一下到账比例是多少”。如果让模型硬算,它虽然能做,但遇到稍微复杂的利率折算、税费计算时,内心其实是在赌。如果 Agent 带有代码执行工具,框架会把这段计算任务交给运行环境去执行,得到精确结果后再让模型组织回答。优势是显而易见的:精确性、可审计、不容易在大数字运算上翻车。
除了代码执行,常见的官方扩展还覆盖网页搜索、内容抓取这类信息获取场景。具体每个扩展的导入路径和参数格式,会随版本变化,建议直接打开官方扩展目录照着示例抄。本文不写死某个导入语句,是为了避免你把一个旧版本的路径死记硬背下来,结果升级后抓瞎。重点是理解“官方工具也是工具”,它们的注册方式和你自己写的函数完全一致。
2.2 注册工具的入口长什么样
基于 ADK 的常见用法,一个带工具的 Agent 大概是这个样子:
from google.adk.agents import Agent # 这里以官方扩展提供的代码执行工具为例 # 实际导入路径请以你安装版本的官方文档为准 code_exec_tool = load_official_tool("code_execution") support_agent = Agent( model="gemini-2.0-flash", instruction="你是一名技术支持助手,需要计算问题时使用代码执行工具。", tools=[code_exec_tool], )然后通过 Runner 跑起来。当用户问到一个需要精确计算的数学类问题时,你在日志里就会看到类似“工具调用 -> 执行结果返回”的记录,最终回复的语气明显更笃定,因为它是真算出来的,不是猜出来的。
我当时第一次跑通时,最大的感触是:工具调用的过程是完全动态的。同一个问题,模型可能这次选择调用工具、下次直接凭常识回答,这取决于它对问题难度、工具描述相关性的判断。所以工具描述写得好不好,直接决定模型是否会勤快地使用它,这一点后文专门展开。
2.3 为什么建议先借内置工具找手感
手痒想写自定义工具很正常,但我还是建议留在看完后面两章再动手。原因有三个。第一,内置工具的调用过程是被广泛验证过的,你不会怀疑“是不是我工具定义方式不对”或“框架 bug”,排查范围小。第二,通过日志观察内置工具从被选中到执行完成的全过程,你能建立对 tool call 时机的直觉:模型是在第几轮开始调、携带什么参数、结果如何回填。第三,官方工具的描述和参数 schema 往往是经过打磨的,值得作为模板来参考,你可以照着那个粒度去写自己的函数描述。
3. 自定义工具的正确姿势:三个层级由浅入深
终于到本文的重头戏。ADK 里自定义工具并不神秘,本质上就是把一个普通 Python 函数暴露给模型。但这里依然有三个层级,从“能用”到“好用”再到“能在生产环境跑”,区别很值得聊。
3.1 基础版:用装饰器把一个业务函数变成工具
最简单的自定义方式是用@tool装饰器包装一个普通函数。函数名、参数、docstring 会被自动提取成模型可读的声明。一个非常典型的例子是退款金额计算:
from google.adk.tools import tool @tool def calc_refund_amount(order_amount: float, refund_ratio: float) -> dict: """计算退款金额。 Args: order_amount: 订单实付金额,单位元。 refund_ratio: 退款比例,0到1之间的小数。 Returns: 一个包含 refund_amount 和 currency 字段的字典。 """ amount = round(order_amount * refund_ratio, 2) return {"refund_amount": amount, "currency": "CNY"}这段代码里最重要的一行不是round(...),而是那个 docstring。模型看不到你的函数体,它只靠函数名、参数名、docstring 来判断“这个工具是干什么的、什么时候该用它、参数怎么传”。所以 docstring 里必须写清楚参数的单位、范围、语义,返回什么样的结构,以及哪些场景适合调用。你写的不是给人看的注释,而是给模型看的说明书。
调用约定上我建议:所有工具统一返回结构化的字典或对象,不要返回“计算成功:xxx元”这种非结构化字符串。原因是模型在后续推理时需要拼接这些信息,结构化返回更容易被理解,也不容易在生成参数时产生歧义。比如返回{"refund_amount": 120.00, "currency": "CNY"}比返回 “退款金额是120块” 要稳得多。
3.2 进阶版:类工具拿到完整上下文,而不是只处理参数
很多工具表面上只需要一个参数,实际业务里却隐式依赖当前会话的很多信息,比如当前用户是谁、什么语言、有没有权限。这时候光靠参数传是不够的,因为你不可能让模型替你把每个隐藏变量都推理出来。正确的做法是让工具在执行时主动从会话上下文里读取这些信息。
ADK 这类框架通常会提供一个上下文对象,在工具执行时注入。不同的版本 API 形态略有差异,但思路一致:工具可以拿到当前 session 的 id、用户 id、状态数据等。我见过一个客服场景的工单创建工具,表面上函数签名只有issue_description,但实际它从上下文里读到了用户 ID,从而给工单自动带上用户维度和渠道来源。
这样设计的好处是防止模型“自由发挥”参数。如果让模型通过参数传用户 ID,它可能从早期的对话里猜一个,猜错就让整个工具调用失真。上下文注入的核心原则是:凡是系统侧能自动获取的信息,都不要让模型通过参数猜。
3.3 实战版:封装第三方 SDK 和异步耗时接口
生产环境里最常干的一件事,是把公司内部订单、库存、CRM 系统的 SDK 暴露成 Agent 工具。SDK 本身可能是同步的,也可能要调远程 HTTP 接口,不能直接阻塞 agent 循环。所以做工具封装时有两件事必须处理:第一,能异步就异步,如果你的 SDK 尚未提供异步方法,建议自己丢进线程池执行,别让同步阻塞卡住整个 agent 响应;第二,必须给所有远程调用加超时与重试,否则下游抖动一次,你的 Agent 就卡住几十秒,用户早跑了。
我自己常用的一种模式,是在@tool函数内部做异常捕获与超时控制。以查询订单接口为例:
import asyncio from google.adk.tools import tool @tool async def get_order_status(order_id: str) -> dict: """查询订单当前状态。 Args: order_id: 订单号,例如 ORD20250101。 Returns: 包含 order_id、status、updated_at 的字典。 """ try: result = await asyncio.wait_for( call_internal_order_api(order_id), timeout=10, ) return {"order_id": order_id, "status": result.status, "updated_at": result.updated_at} except asyncio.TimeoutError: return {"order_id": order_id, "status": "unknown", "error": "query_timeout"} except Exception as exc: return {"order_id": order_id, "status": "unknown", "error": str(exc)}注意这里的处理方式:我没有让异常直接抛到 agent 循环里,而是把错误情况结构化成{"error": "..."}返回。原因后面马上讲,这一步是做对工具容错的关键。
4. 工具契约:模型的“说明书”决定它会不会用对
工具定义里包含的信息,在 agent 运行时会以函数声明(Function Declaration)的形式交给模型。很多项目方在这里翻车:工具确实写了、注册了,但模型就是不用它、或者用的时候老传错参数。问题通常不是模型笨,而是你的“说明书”写得不够明确。
4.1 三条关键信息:名称、描述、参数
一个声明被模型读取时,最重要的三样东西:工具名、描述、参数 schema。工具名最好直接说明业务动作,别用get_info这种抽象名字,换成get_order_status_by_order_id这种见名知义的,模型在判断“该不该用”时会更果断。描述要覆盖完整的功能边界,包括适用场景、不适用场景、行为约束。
参数 schema 的重要性常常被低估。你定义ratio: float时,模型只知道这是个浮点数,但它不知道范围是 0 到 1。如果你加上了校验说明或允许枚举值,模型传错参数的概率会大幅下降。这里的原则是:不要把参数定义得比实际需求更宽泛,格式能精确就精确到格式。
4.2 垃圾说明与合格说明的对比
下面这张表是我在做工具评审时常用的对比标准,分享出来供参考:
| 对比项 | 不合格示例 | 合格示例 |
|---|---|---|
| 工具名 | get_info | get_order_status_by_order_id |
| 描述 | 获取订单信息 | 根据订单号查询订单最新状态,包括待支付、已发货、已签收;仅支持近一年的订单 |
| 参数 | order_id: str | order_id: str = Field(description="订单号,以 ORD 开头,例如 ORD20250101") |
| 返回说明 | 返回订单状态 | 返回一个字典,包含 order_id、status、updated_at 三个字段,status 取值为 pending/shipped/delivered |
| 错误处理 | 可能抛出异常 | 查询失败时返回 status=unknown 和 error 字段 |
看到差别了吗?合格的描述明确告诉模型“什么时候可以用”、“参数格式长什么样”、“返回数据长什么样”,甚至隐含了“什么情况下不能用”。模型拿到这样的说明书,选择准确率会显著提升。我见过一个客服 Agent,优化完工具描述后,工具调用准确率从六成出头提升到九成以上,注意这里没有改任何模型参数,纯粹是说明书变清晰了。
4.3 参数 schema 与工具数量的微妙平衡
还有一个问题是工具太多怎么办。当你把十几个工具一股脑塞给模型时,它可能出现“选择困难”,或者被一些名字相似的干扰。我的建议是:在注册之前先问一句,这个工具是否真的需要模型决定调用?如果某个操作是无条件伴随的,比如用户必填手机号之后必须校验,那就应该写成代码里的编排逻辑,而不是让模型自由选择。少即是多,模型面前每多一个工具,就多一份选错的风险。
参数 schema 层面,我强烈推荐给关键字段加上格式约束。比如金额字段是分还是元,必须在描述里写清楚;日期字段给 ISO 格式;状态字段明确枚举值。模型不像人那样能通过常识猜出字段含义,它完全依赖你给出的声明。你给的约束越细,它跑偏的可能性越小。
5. 工具报错与部分失败:设计一个不会死循环的容错体系
工具一旦接入真实服务,错误是常态而不是例外。下游接口超时、数据格式变更、权限失效,这些都会发生。关键不在于你写了多少防御代码,而在于你在工具出错时让模型看到了什么。
5.1 第一道防线:异常捕获后结构化返回错误
前面在实战示例里我已经示范过,工具内部捕获所有异常,并返回一个包含error字段的结构化结果。为什么要这么做?因为模型看到error字段后,会自动进入“补救模式”:它可能向用户解释“系统暂时查不到,请稍后再试”,或者主动建议换个方式查询。而如果你让异常裸奔到 agent 循环,轻则整个对话中断,重则触发无意义的错误重复执行,体验极差。
错误信息本身也要写给模型看,而不是写给人看。返回{"error": "order not found"}比返回内部堆栈好得多。模型不需要知道你调用了什么 SDK、网络异常是哪一层,它只需要知道发生了什么业务语义层面的事,然后决定下一步。
原则:工具返回给人的,是一个容易理解的业务结果;工具返回给模型的,是一个包含状态的数据包。前者友好,后者结构化,二者都需要。
5.2 第二道防线:超时与重试
网络调用的三种典型问题,超时、瞬时抖动、永久性失败,在工具层要区分对待。超时用asyncio.wait_for控制;瞬时抖动可以加一到两次短重试,比如 300ms、500ms,别把自己写成重试风暴;永久性失败则快速返回错误,不要反复烧钱。重试不要超过两次,否则用户感受到的延迟会非常离谱。
还有一个很隐蔽的坑:工具里的重试必须放在异常捕获之内,而且每次重试之间要有间隔。有些人用asyncio.sleep(0)代替间隔,看起来是异步了,实际上和死循环里的忙等待一样。正确的做法是设置合理的退避间隔,比如await asyncio.sleep(0.2)。如果你的项目允许引入重试库,直接用现成的 tenacity 之类,别自己手搓。
5.3 第三道防线:控制 agent 循环的最大轮数
即使容错做得好,仍然可能出现模型反复调用同一工具的情形。比如用户反复追问“你再查一下”,模型就固执着不停呼叫订单接口。这类情况必须在外层设置硬性轮数上限。ADK 的 Runner 层面通常提供类似max_steps、max_turns的参数,具体名称看版本,最早在生产环境使用时我就调低了它。
我习惯设置一个总轮数上限,比如 10 轮以内。超过之后框架会强制让模型生成最终回复,不再允许继续调用工具。这个兜底不是用来解决正常业务的,而是防止那种“模型陷入调用-失败-再调用”的泥潭。别看这是个简单参数,它在实战中救过我太多次。
5.4 工具报错信息的排查技巧
当你觉得某个工具老是出错时,先别盯着代码猜,把 agent 日志里的工具调用轨迹打开。你要看三样东西:模型选择了哪个工具、传了哪些参数、工具返回了什么。三者串起来,你基本能判断问题出在模型侧还是出在你封装的 SDK 侧。如果是参数传错,往往说明说明书不够清晰;如果是真实业务异常,那才是代码逻辑问题。这个区分看起来简单,但在项目里能省掉大量无效排查时间。
6. 多工具协作与上下文传递:把工具从“点”连成“网”
单工具能解决单点问题,但现实中的 agent 往往要同时处理多个动作。比如用户问“我这个订单能退款吗?能退多少?完成后退款什么时候到账?”这里面至少有三个动作:查订单状态、计算可退金额、查询退款规则。你当然可以写一个大而全的工具,把三步全做掉,但这会让工具变得臃肿且不可复用。更好的做法是拆成多个语义单一的工具,让模型决定按什么顺序调用。
6.1 一个业务场景拆多个工具
我比较推荐按业务对象和动作来拆分工具,而不是按“用户问题”拆。比如订单相关的全量场景,不要只做一个order_service工具,而是拆成get_order_status、calc_refund_amount、get_refund_policy。每个工具负责一个最小可独立判断的动作。模型可以自由组合它们,比如先查状态,确认可以退款后,再计算金额,最后发起工单。
这样拆的好处是每个工具的说明可以写得非常聚焦,模型理解难度低,选择准确率高。代价是工具数量上升,但只要你保持每个工具命名清晰、语义独立,其实比三五个大而全的工具更不容易出错。我见过有人把十几个动作塞进一个工具,结果是模型内部开始胡猜参数,调试难度陡然上升。
6.2 工具内部如何读取会话上下文
多工具协作场景里,很多公共信息不该由每个工具各自请求一遍。比如客服 agent 已经知道当前用户 ID,每个工具做权限校验时不应该再问一次。更合理的方案是在工具执行时注入当前会话上下文。ADK 的工具执行上下文里,通常能拿到 session 状态、用户 ID、历史消息片段等。
我通常在自定义工具里显式声明需要读取什么上下文,比如在函数内部通过上下文参数获取user_id。有一些框架允许把上下文对象注入到工具函数的参数里,你只要在签名里声明一个特殊类型的参数,框架会在运行时填充。这个机制的好处是工具不需要通过模型猜测用户 ID,系统侧自动注入。
使用上下文传递时要小心一点:不要在工具里返回敏感信息,比如内部账号密码、完整的用户隐私字段。模型的输出最终是要显示给终端用户的,工具是数据入口,也是信息泄露的出口,边界要看好,不该出现的信息别让工具带出来。
6.3 模型自由编排 vs 代码硬编排
最后聊一个编排思路问题。当工具数量多且时序固定时,有两种流派:让模型自由决定调用顺序;或者由你的代码硬性规定先调 A 再调 B,再交给模型总结。
自由编排更灵活,适合探索性和生成式任务,比如写完代码自动运行测试,测试失败再让模型修复,循环往复。固定编排更稳,适合业务规则明确的流程,比如登录校验、风控查询、下单操作,这些步骤不该让模型自由发挥。
ADK 两种都支持:一个工具内部可以再调另一个工具,这属于代码编排;你也可以在 instruction 里约束模型“必须先查订单状态再计算退款”,这属于软约束。我的建议是:涉及真实交易的流程用硬编排,涉及信息查询与解释的可放给模型。把该交给模型的决定权交给它,把不该交给它的全握在代码手里。
7. 调试工具的一个实用工作流:先空手跑、再加工具、最后看日志
很多朋友遇到工具不触发就焦虑,直接开始改 prompt。我总结了一个每次新接一批工具时都会用的调试工作流,效率很高。
第一步,先让 Agent 不带任何工具跑一遍同样的问题集。这一步的目的是记录它在没有工具时的回答质量、错误率。有些问题它其实凭常识就能答对,那你就要思考这个工具是否必要;有些问题它完全答不出,那这个工具就是刚需。
第二步,逐批加入工具,而不是一次性全来。每次加一个工具,跑几个典型用例,观察模型是否在预期场景调用了它。如果没调用,先检查描述是否清晰、工具名是否有歧义;别急着继续加下一个,否则很难定位是哪个写得不好。
第三步,把 agent 日志拉出来看调用轨迹。关注时间戳:什么时候模型决定调用工具、什么时候工具返回、结果有没有被引用到最终回复中。如果工具被正确调用、结果也正确,只是最终回复质量不佳,问题往往在结果回填后的提示组织上,可以在 instruction 里加一句“如果工具查询失败,请明确告诉用户”。这个工作流我已经复制给好几个团队,基本能覆盖九成以上的工具接入问题。
最后再分享一个小技巧:给工具命名时建议加一个业务前缀,比如订单类工具统一用order_开头,工单类统一用ticket_开头。这不仅仅是规范,更是在帮模型建立一个“工具地图”,让它面对问题时能更快地匹配到正确的分机。工具集成这件事,说复杂也复杂,说简单也简单,大多数问题不是出在代码能力上,而是出在你有没有把工具当作一个有性格、有边界、有说明书的“服务方”来对待。先把说明书写好,模型自然手到擒来。