第二章:工具系统 - 给Agent装上手脚

本章你将学到

  • 理解为什么Agent需要工具,以及工具在Agent架构中的角色
  • 使用 functiontool 把普通的Go函数变成Agent可以调用的工具
  • 理解ADK如何从Go结构体自动生成JSON Schema
  • 组合多个工具给Agent使用
  • 使用Gemini内置工具(如Google搜索)
  • agenttool 把一个Agent包装成工具给另一个Agent用
  • 深入了解 tool.Context 的各种能力
  • 理解工具调用在Agent循环中的完整生命周期

为什么Agent需要工具

先来想一个问题:大语言模型(LLM)厉害在哪里?它能理解自然语言、做推理、写代码、 总结文章......但它有一个致命的弱点 -- 它只能"想",不能"做"

打个比方,LLM就像一个超级聪明的大脑,但是被装在一个罐子里。它能帮你想出一套 完美的旅行计划,但它没办法帮你订机票;它能告诉你今天应该查一下天气,但它自己 查不了天气。

工具(Tool)就是给这个大脑装上手脚。

有了工具,Agent就变成了一个完整的"人":

  • 大脑(LLM)负责理解用户意图、做出决策
  • 手脚(工具)负责执行具体操作

一个典型的Agent工作流程是这样的:

用户:"帮我查一下北京今天的天气"

Agent的大脑(LLM)想:
  "用户想知道北京的天气,我应该用搜索工具查一下"

Agent的手(工具)做:
  调用 GoogleSearch("北京今天天气")
  得到结果:晴,25度

Agent的大脑(LLM)又想:
  "搜到了,让我组织一下语言回复用户"

Agent回复:"北京今天天气晴朗,气温25度,适合出门。"

看到了吧?LLM负责"想",工具负责"做"。这就是Agent和普通LLM聊天的本质区别。

在ADK-Go里,工具系统被设计得很优雅。所有工具都实现同一个 tool.Tool 接口:

// tool.Tool 接口定义 - 所有工具的基础
type Tool interface {
    // 工具的名字,LLM通过名字来调用工具
    Name() string
    // 工具的描述,LLM通过描述来理解工具能做什么
    Description() string
    // 是否是长时间运行的工具(高级用法,后面会讲)
    IsLongRunning() bool
}

ADK-Go提供了好几种工具类型,我们一个个来看。


FunctionTool:把Go函数变成工具

这是你最常用的工具类型。它的核心思想非常简单:写一个普通的Go函数,ADK帮你 把它包装成LLM能调用的工具。

第一个工具:计算器

让我们从一个简单的计算器开始:

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "google.golang.org/genai"

    "google.golang.org/adk/agent"
    "google.golang.org/adk/agent/llmagent"
    "google.golang.org/adk/cmd/launcher"
    "google.golang.org/adk/cmd/launcher/full"
    "google.golang.org/adk/model/gemini"
    "google.golang.org/adk/tool"
    "google.golang.org/adk/tool/functiontool"
)

// 第一步:定义输入结构体
// ADK会自动把这个结构体转成JSON Schema,告诉LLM这个工具需要什么参数
type CalcInput struct {
    A  float64 `json:"a" description:"第一个数字"`
    B  float64 `json:"b" description:"第二个数字"`
    Op string  `json:"op" description:"运算符: add, sub, mul, div"`
}

// 第二步:定义输出结构体
type CalcOutput struct {
    Result float64 `json:"result"`
    Error  string  `json:"error,omitempty"`
}

// 第三步:写工具函数
// 注意:第一个参数必须是 tool.Context,第二个参数是你的输入结构体
func calculate(ctx tool.Context, input CalcInput) (CalcOutput, error) {
    switch input.Op {
    case "add":
        return CalcOutput{Result: input.A + input.B}, nil
    case "sub":
        return CalcOutput{Result: input.A - input.B}, nil
    case "mul":
        return CalcOutput{Result: input.A * input.B}, nil
    case "div":
        if input.B == 0 {
            return CalcOutput{Error: "除数不能为0"}, nil
        }
        return CalcOutput{Result: input.A / input.B}, nil
    default:
        return CalcOutput{Error: "不支持的运算符"}, nil
    }
}

func main() {
    ctx := context.Background()

    // 创建模型
    model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{
        APIKey: os.Getenv("GOOGLE_API_KEY"),
    })
    if err != nil {
        log.Fatalf("创建模型失败: %v", err)
    }

    // 第四步:用 functiontool.New 把Go函数包装成工具
    calcTool, err := functiontool.New(functiontool.Config{
        Name:        "calculator",
        Description: "执行基本数学运算(加减乘除)",
    }, calculate)
    if err != nil {
        log.Fatalf("创建工具失败: %v", err)
    }

    // 第五步:把工具交给Agent
    a, err := llmagent.New(llmagent.Config{
        Name:        "math_agent",
        Model:       model,
        Description: "一个能做数学计算的助手",
        Instruction: "你是一个数学助手。当用户需要做计算时,使用calculator工具。" +
            "把计算结果用友好的方式告诉用户。",
        Tools: []tool.Tool{calcTool},
    })
    if err != nil {
        log.Fatalf("创建Agent失败: %v", err)
    }

    // 启动
    config := &launcher.Config{
        AgentLoader: agent.NewSingleLoader(a),
    }
    l := full.NewLauncher()
    if err = l.Execute(ctx, config, os.Args[1:]); err != nil {
        log.Fatalf("运行失败: %v", err)
    }
}

运行起来之后,你可以这样跟它聊:

你:帮我算一下 123.45 乘以 67.89
Agent:123.45 x 67.89 = 8,381.40(Agent内部调用了calculator工具)

看起来就这么简单。但背后发生了不少事情,让我们拆解一下。

结构体标签和JSON Schema

你可能注意到了输入结构体上的标签:

type CalcInput struct {
    A  float64 `json:"a" description:"第一个数字"`
    B  float64 `json:"b" description:"第二个数字"`
    Op string  `json:"op" description:"运算符: add, sub, mul, div"`
}

这里有两类标签:

  1. json:"a" - 标准的Go JSON标签,定义了字段在JSON中的名字
  2. description:"第一个数字" - ADK专用标签,会被用来生成JSON Schema里的描述

ADK会自动把这个结构体转成类似这样的JSON Schema:

{
    "type": "object",
    "properties": {
        "a": {
            "type": "number",
            "description": "第一个数字"
        },
        "b": {
            "type": "number",
            "description": "第二个数字"
        },
        "op": {
            "type": "string",
            "description": "运算符: add, sub, mul, div"
        }
    },
    "required": ["a", "b", "op"]
}

这个JSON Schema会被发送给LLM,LLM就知道:哦,要调用这个calculator工具, 我需要提供两个数字和一个运算符。

description标签很重要! 它直接影响LLM能不能正确使用你的工具。写得清楚明白, LLM就能更准确地传参。写得含糊不清,LLM可能会传错参数。

函数签名的约定

functiontool.New 是一个泛型函数,它的签名是这样的:

func New[TArgs, TResults any](cfg Config, handler Func[TArgs, TResults]) (tool.Tool, error)

其中 Func 的定义是:

type Func[TArgs, TResults any] func(tool.Context, TArgs) (TResults, error)

所以你的工具函数必须遵循这个模式:

func 你的函数名(ctx tool.Context, input 输入类型) (输出类型, error)

几个要点:

  • 第一个参数必须是 tool.Context - 这是ADK传给你的上下文,包含了很多有用的信息
  • 第二个参数是你的输入结构体 - 必须是struct或map类型,不能是基本类型
  • 返回值是 (输出类型, error) - 输出类型通常也是struct,但基本类型也行
  • 输入类型不能是 string、int 这种基本类型 - 必须是struct或map

为什么输入必须是struct?因为LLM调用工具时,传的是一个JSON对象,ADK需要把它 映射到你的结构体上。如果你只需要一个字符串参数,那就包一层:

// 这样不行!
// func myTool(ctx tool.Context, input string) (string, error)

// 这样才行
type MyInput struct {
    Text string `json:"text" description:"输入的文本"`
}

type MyOutput struct {
    Result string `json:"result"`
}

func myTool(ctx tool.Context, input MyInput) (MyOutput, error) {
    // ...
}

functiontool.Config 详解

创建工具时需要传一个 Config

type Config struct {
    // 工具名字 - LLM通过这个名字来调用工具
    Name string

    // 工具描述 - LLM通过描述来决定什么时候该用这个工具
    Description string

    // 可选:手动指定输入的JSON Schema
    // 如果不设置,ADK会从你的输入结构体自动推导
    InputSchema *jsonschema.Schema

    // 可选:手动指定输出的JSON Schema
    OutputSchema *jsonschema.Schema

    // 是否是长时间运行的工具(后面章节会讲)
    IsLongRunning bool

    // 是否需要用户确认才能执行(人在回路)
    RequireConfirmation bool

    // 动态判断是否需要用户确认的函数
    RequireConfirmationProvider any
}

大部分情况下,你只需要设置 NameDescription 就够了:

calcTool, err := functiontool.New(functiontool.Config{
    Name:        "calculator",
    Description: "执行基本数学运算(加减乘除)",
}, calculate)

NameDescription 怎么写很有讲究:

  • Name 要简洁、语义清晰,用小写字母和下划线,比如 get_weathersend_email
  • Description 要告诉LLM这个工具"能做什么"和"什么时候该用"。越准确越好

多个工具组合

一个Agent可以同时拥有多个工具,就像一个人可以同时会用锤子和螺丝刀一样。 LLM会根据用户的请求自动选择合适的工具。

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "strings"
    "time"

    "google.golang.org/genai"

    "google.golang.org/adk/agent"
    "google.golang.org/adk/agent/llmagent"
    "google.golang.org/adk/cmd/launcher"
    "google.golang.org/adk/cmd/launcher/full"
    "google.golang.org/adk/model/gemini"
    "google.golang.org/adk/tool"
    "google.golang.org/adk/tool/functiontool"
)

// === 工具1:计算器 ===

type CalcInput struct {
    A  float64 `json:"a" description:"第一个数字"`
    B  float64 `json:"b" description:"第二个数字"`
    Op string  `json:"op" description:"运算符: add, sub, mul, div"`
}

type CalcOutput struct {
    Result float64 `json:"result"`
    Error  string  `json:"error,omitempty"`
}

func calculate(ctx tool.Context, input CalcInput) (CalcOutput, error) {
    switch input.Op {
    case "add":
        return CalcOutput{Result: input.A + input.B}, nil
    case "sub":
        return CalcOutput{Result: input.A - input.B}, nil
    case "mul":
        return CalcOutput{Result: input.A * input.B}, nil
    case "div":
        if input.B == 0 {
            return CalcOutput{Error: "除数不能为0"}, nil
        }
        return CalcOutput{Result: input.A / input.B}, nil
    default:
        return CalcOutput{Error: "不支持的运算符"}, nil
    }
}

// === 工具2:获取当前时间 ===

type TimeInput struct {
    Timezone string `json:"timezone" description:"时区名称,如 Asia/Shanghai、America/New_York"`
}

type TimeOutput struct {
    CurrentTime string `json:"current_time"`
    Timezone    string `json:"timezone"`
    Error       string `json:"error,omitempty"`
}

func getCurrentTime(ctx tool.Context, input TimeInput) (TimeOutput, error) {
    // 默认使用上海时区
    tz := input.Timezone
    if tz == "" {
        tz = "Asia/Shanghai"
    }

    loc, err := time.LoadLocation(tz)
    if err != nil {
        return TimeOutput{Error: fmt.Sprintf("无效的时区: %s", tz)}, nil
    }

    now := time.Now().In(loc)
    return TimeOutput{
        CurrentTime: now.Format("2006-01-02 15:04:05"),
        Timezone:    tz,
    }, nil
}

// === 工具3:字符串处理 ===

type StringInput struct {
    Text      string `json:"text" description:"要处理的文本"`
    Operation string `json:"operation" description:"操作类型: upper, lower, reverse, count"`
}

type StringOutput struct {
    Result string `json:"result"`
    Error  string `json:"error,omitempty"`
}

func processString(ctx tool.Context, input StringInput) (StringOutput, error) {
    switch input.Operation {
    case "upper":
        return StringOutput{Result: strings.ToUpper(input.Text)}, nil
    case "lower":
        return StringOutput{Result: strings.ToLower(input.Text)}, nil
    case "reverse":
        // 翻转字符串
        runes := []rune(input.Text)
        for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
            runes[i], runes[j] = runes[j], runes[i]
        }
        return StringOutput{Result: string(runes)}, nil
    case "count":
        return StringOutput{Result: fmt.Sprintf("%d个字符", len([]rune(input.Text)))}, nil
    default:
        return StringOutput{Error: "不支持的操作"}, nil
    }
}

func main() {
    ctx := context.Background()

    model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{
        APIKey: os.Getenv("GOOGLE_API_KEY"),
    })
    if err != nil {
        log.Fatalf("创建模型失败: %v", err)
    }

    // 创建三个工具
    calcTool, err := functiontool.New(functiontool.Config{
        Name:        "calculator",
        Description: "执行基本数学运算(加减乘除)",
    }, calculate)
    if err != nil {
        log.Fatalf("创建计算器工具失败: %v", err)
    }

    timeTool, err := functiontool.New(functiontool.Config{
        Name:        "get_current_time",
        Description: "获取指定时区的当前时间",
    }, getCurrentTime)
    if err != nil {
        log.Fatalf("创建时间工具失败: %v", err)
    }

    stringTool, err := functiontool.New(functiontool.Config{
        Name:        "process_string",
        Description: "处理字符串:转大写、转小写、翻转、计数",
    }, processString)
    if err != nil {
        log.Fatalf("创建字符串工具失败: %v", err)
    }

    // 把所有工具都交给Agent
    a, err := llmagent.New(llmagent.Config{
        Name:  "multi_tool_agent",
        Model: model,
        Description: "一个拥有多种能力的助手",
        Instruction: "你是一个万能助手,可以做数学计算、查询时间、处理字符串。" +
            "根据用户的需求选择合适的工具来完成任务。",
        Tools: []tool.Tool{calcTool, timeTool, stringTool},
    })
    if err != nil {
        log.Fatalf("创建Agent失败: %v", err)
    }

    config := &launcher.Config{
        AgentLoader: agent.NewSingleLoader(a),
    }
    l := full.NewLauncher()
    if err = l.Execute(ctx, config, os.Args[1:]); err != nil {
        log.Fatalf("运行失败: %v", err)
    }
}

现在这个Agent可以处理各种请求了:

你:帮我算一下 256 除以 8
Agent:(调用calculator工具)256 / 8 = 32

你:现在东京几点了?
Agent:(调用get_current_time工具)东京现在是 2025-05-20 14:30:00

你:把 "Hello World" 翻转一下
Agent:(调用process_string工具)翻转后是 "dlroW olleH"

LLM是怎么知道该调用哪个工具的?因为我们给每个工具都写了清晰的 NameDescription。LLM会根据用户的请求和工具的描述来判断。这也是为什么 Description 写得好不好非常关键 -- 它直接影响Agent的"智商"。


Gemini内置工具

除了自定义的函数工具,ADK-Go还支持Gemini模型内置的工具。这些工具不需要你写 任何函数,Gemini模型本身就能执行它们。

GoogleSearch

最常用的内置工具就是Google搜索:

import "google.golang.org/adk/tool/geminitool"

a, err := llmagent.New(llmagent.Config{
    Name:        "search_agent",
    Model:       model,
    Description: "可以搜索互联网信息的助手",
    Instruction: "你是一个搜索助手,用Google搜索帮用户找信息。",
    Tools: []tool.Tool{
        geminitool.GoogleSearch{},  // 就这么简单!
    },
})

geminitool.GoogleSearch{}functiontool.New 创建的工具有本质区别:

FunctionTool GoogleSearch
执行位置 你的Go代码里 Gemini服务端
需要写代码 需要 不需要
可以自定义逻辑 可以 不可以
调用方式 LLM发出函数调用请求,ADK执行你的函数 LLM在内部直接完成搜索

简单来说,GoogleSearch是Gemini模型"自带"的能力,不需要你的代码参与执行。

用 geminitool.New 创建其他内置工具

除了 GoogleSearch,你还可以通过 geminitool.New 来使用Gemini的其他内置能力:

import (
    "google.golang.org/genai"
    "google.golang.org/adk/tool/geminitool"
)

// 创建一个自定义的Gemini原生工具
myGeminiTool := geminitool.New("data_retrieval", &genai.Tool{
    Retrieval: &genai.Retrieval{
        // 配置检索相关参数...
    },
})

这给了你使用Gemini所有原生能力的入口。

重要限制:内置工具和函数工具不能混用

这里有一个很重要的API限制:在同一个Agent里,你不能同时使用GoogleSearch和 FunctionTool。 这是Gemini API的限制,不是ADK的限制。

// 这样会出问题!
a, err := llmagent.New(llmagent.Config{
    Name:  "broken_agent",
    Model: model,
    Tools: []tool.Tool{
        geminitool.GoogleSearch{},  // 内置工具
        calcTool,                    // 函数工具
        // 两种工具混在一起,Gemini API不支持
    },
})

那怎么办?答案就是下一节要讲的 agenttool


AgentTool:把Agent当工具用

agenttool 是一个非常巧妙的设计。它的思路是:既然不同类型的工具不能放在 同一个Agent里,那就把每种工具放在一个子Agent里,再把这些子Agent包装成"工具" 交给根Agent。

这就是ADK的 multipletools 示例展示的模式:

package main

import (
    "context"
    "log"
    "os"

    "google.golang.org/genai"

    "google.golang.org/adk/agent"
    "google.golang.org/adk/agent/llmagent"
    "google.golang.org/adk/cmd/launcher"
    "google.golang.org/adk/cmd/launcher/full"
    "google.golang.org/adk/model/gemini"
    "google.golang.org/adk/tool"
    "google.golang.org/adk/tool/agenttool"
    "google.golang.org/adk/tool/functiontool"
    "google.golang.org/adk/tool/geminitool"
)

func main() {
    ctx := context.Background()

    model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{
        APIKey: os.Getenv("GOOGLE_API_KEY"),
    })
    if err != nil {
        log.Fatalf("创建模型失败: %v", err)
    }

    // ========================================
    // 子Agent 1:搜索Agent(使用Gemini内置工具)
    // ========================================
    searchAgent, err := llmagent.New(llmagent.Config{
        Name:        "search_agent",
        Model:       model,
        Description: "专门做Google搜索,获取互联网最新信息",
        Instruction: "你是Google搜索专家。根据用户的请求进行搜索,返回准确的信息。",
        Tools: []tool.Tool{
            geminitool.GoogleSearch{},
        },
    })
    if err != nil {
        log.Fatalf("创建搜索Agent失败: %v", err)
    }

    // ========================================
    // 子Agent 2:计算Agent(使用自定义函数工具)
    // ========================================
    type CalcInput struct {
        A  float64 `json:"a" description:"第一个数字"`
        B  float64 `json:"b" description:"第二个数字"`
        Op string  `json:"op" description:"运算符: add, sub, mul, div"`
    }
    type CalcOutput struct {
        Result float64 `json:"result"`
        Error  string  `json:"error,omitempty"`
    }

    calcTool, err := functiontool.New(functiontool.Config{
        Name:        "calculator",
        Description: "执行基本数学运算",
    }, func(ctx tool.Context, input CalcInput) (CalcOutput, error) {
        switch input.Op {
        case "add":
            return CalcOutput{Result: input.A + input.B}, nil
        case "sub":
            return CalcOutput{Result: input.A - input.B}, nil
        case "mul":
            return CalcOutput{Result: input.A * input.B}, nil
        case "div":
            if input.B == 0 {
                return CalcOutput{Error: "除数不能为0"}, nil
            }
            return CalcOutput{Result: input.A / input.B}, nil
        default:
            return CalcOutput{Error: "不支持的运算符"}, nil
        }
    })
    if err != nil {
        log.Fatalf("创建计算器工具失败: %v", err)
    }

    calcAgent, err := llmagent.New(llmagent.Config{
        Name:        "calc_agent",
        Model:       model,
        Description: "专门做数学计算",
        Instruction: "你是数学计算专家。使用calculator工具完成计算任务。",
        Tools:       []tool.Tool{calcTool},
    })
    if err != nil {
        log.Fatalf("创建计算Agent失败: %v", err)
    }

    // ========================================
    // 根Agent:把两个子Agent包装成工具
    // ========================================
    rootAgent, err := llmagent.New(llmagent.Config{
        Name:  "root_agent",
        Model: model,
        Description: "一个能搜索信息和做数学计算的智能助手",
        Instruction: "你是一个智能助手。" +
            "当用户需要搜索信息时,使用search_agent。" +
            "当用户需要做数学计算时,使用calc_agent。",
        Tools: []tool.Tool{
            agenttool.New(searchAgent, nil),  // 把搜索Agent包装成工具
            agenttool.New(calcAgent, nil),    // 把计算Agent包装成工具
        },
    })
    if err != nil {
        log.Fatalf("创建根Agent失败: %v", err)
    }

    config := &launcher.Config{
        AgentLoader: agent.NewSingleLoader(rootAgent),
    }
    l := full.NewLauncher()
    if err = l.Execute(ctx, config, os.Args[1:]); err != nil {
        log.Fatalf("运行失败: %v", err)
    }
}

agenttool.New 的细节

agenttool.New 的签名很简单:

func New(agent agent.Agent, cfg *Config) tool.Tool

第二个参数 cfg 可以传 nil,也可以传配置:

type Config struct {
    // 如果为true,子Agent执行完后不会让LLM再做一次总结
    SkipSummarization bool
}

SkipSummarization 是什么意思呢?默认情况下,子Agent执行完毕后,根Agent的 LLM会对子Agent的结果进行一次"总结"。如果你觉得没必要(比如子Agent返回的结果 已经很好了),可以设成 true 跳过这一步,省点tokens。

Agent当工具用的工作流程

用一张图来说明整个流程:

用户:"搜一下北京明天的天气,然后帮我算一下25度转华氏度是多少"

根Agent(LLM)想:
  "用户有两个需求,先搜天气,再做计算"

根Agent调用 search_agent 工具:
  → search_agent 收到请求 "北京明天天气"
  → search_agent 使用 GoogleSearch 搜索
  → search_agent 返回:"北京明天晴,25度"

根Agent调用 calc_agent 工具:
  → calc_agent 收到请求 "25度转华氏度"
  → calc_agent 使用 calculator 工具:25 * 9/5 + 32
  → calc_agent 返回:"77华氏度"

根Agent(LLM)整合结果:
  "北京明天天气晴朗,气温25摄氏度(约77华氏度)。"

看到了吧?通过 agenttool,根Agent可以"指挥"多个专业子Agent协作完成任务。 每个子Agent各司其职,互不干扰。


tool.Context 详解

你可能已经注意到了,每个工具函数的第一个参数都是 tool.Context。这个Context 不仅仅是一个普通的 context.Context(它嵌入了 context.Context),还提供了 很多Agent框架级别的能力。

来看看 tool.Context 接口到底有什么:

type Context interface {
    agent.CallbackContext  // 继承了回调上下文的能力

    // 获取当前函数调用的唯一ID
    FunctionCallID() string

    // 获取当前事件的Actions,可以用来修改状态、触发Agent转移等
    Actions() *session.EventActions

    // 在Agent的记忆中搜索相关信息
    SearchMemory(context.Context, string) (*memory.SearchResponse, error)

    // 检查当前是否有用户确认(人在回路)
    ToolConfirmation() *toolconfirmation.ToolConfirmation

    // 主动请求用户确认
    RequestConfirmation(hint string, payload any) error
}

因为嵌入了 agent.CallbackContext,你还可以访问这些信息:

// 来自 CallbackContext -> ReadonlyContext
ctx.AgentName()       // 当前Agent的名字
ctx.UserID()          // 当前用户的ID
ctx.AppName()         // 应用名称
ctx.SessionID()       // 当前会话ID
ctx.InvocationID()    // 当前调用的ID
ctx.Branch()          // 当前分支
ctx.UserContent()     // 用户发送的原始内容
ctx.ReadonlyState()   // 只读状态(通过ReadonlyContext时)

// 来自 CallbackContext
ctx.State()           // 可读写的Session状态
ctx.Artifacts()       // 访问工件(文件等)

FunctionCallID

每次LLM调用工具时,都会生成一个唯一的ID。你可以用它来追踪和关联请求:

func myTool(ctx tool.Context, input MyInput) (MyOutput, error) {
    callID := ctx.FunctionCallID()
    log.Printf("工具被调用,Call ID: %s", callID)
    // ...
}

通过 Actions 修改状态

Actions() 返回的 EventActions 让你可以在工具执行过程中修改Agent的状态:

func myTool(ctx tool.Context, input MyInput) (MyOutput, error) {
    // 在Session状态中记录一些信息
    actions := ctx.Actions()
    actions.StateDelta["last_tool_call"] = "myTool"
    actions.StateDelta["call_count"] = 42

    // 你也可以通过 ctx.State() 来直接操作状态
    ctx.State().Set("some_key", "some_value")

    return MyOutput{Result: "done"}, nil
}

StateDelta会被合并到Session的状态中,在后续的对话中可以读取。

SearchMemory 搜索记忆

如果你的Agent配置了MemoryService,工具可以通过 SearchMemory 搜索历史对话中的 相关信息:

func myTool(ctx tool.Context, input MyInput) (MyOutput, error) {
    // 搜索历史对话中关于"用户偏好"的记忆
    result, err := ctx.SearchMemory(ctx, "用户的偏好设置")
    if err != nil {
        return MyOutput{}, err
    }

    // result 包含匹配到的历史对话片段
    // 你可以用这些信息来个性化工具的行为
    return MyOutput{Result: "基于你之前的偏好..."}, nil
}

ToolConfirmation 和 RequestConfirmation(人在回路)

有时候工具要执行一些敏感操作,比如删除文件、发送邮件、付款。这时候你可能希望 在执行之前先问一下用户"你确定吗?"。这就是"人在回路"(Human-in-the-Loop)机制。

有两种方式可以启用确认机制。

方式一:在Config中静态设置

最简单的方式,适用于工具每次调用都需要确认的场景:

dangerousTool, err := functiontool.New(functiontool.Config{
    Name:                "delete_file",
    Description:         "删除指定文件",
    RequireConfirmation: true,  // 每次调用都需要用户确认
}, deleteFile)

方式二:动态判断是否需要确认

有时候你只想在某些条件下请求确认:

dangerousTool, err := functiontool.New(functiontool.Config{
    Name:        "transfer_money",
    Description: "转账",
    // 只有金额超过10000时才需要确认
    RequireConfirmationProvider: func(input TransferInput) bool {
        return input.Amount > 10000
    },
}, transferMoney)

方式三:在工具函数内部手动控制

这是最灵活的方式,你可以完全控制确认流程:

func requestVacation(ctx tool.Context, input VacationInput) (VacationOutput, error) {
    // 先检查有没有用户确认
    confirmation := ctx.ToolConfirmation()
    if confirmation == nil {
        // 还没有确认,主动请求确认
        err := ctx.RequestConfirmation(
            "你确定要请假吗?请批准或拒绝这个请假请求。",
            map[string]any{"days": input.Days}, // 附加信息
        )
        if err != nil {
            return VacationOutput{}, err
        }
        // 返回一个中间状态,等待用户确认
        return VacationOutput{Status: "等待审批"}, nil
    }

    // 有确认了,检查结果
    if confirmation.Confirmed {
        // 用户批准了,执行操作
        return VacationOutput{Status: "已批准", Days: input.Days}, nil
    }

    // 用户拒绝了
    return VacationOutput{Status: "已拒绝"}, nil
}

这个流程大致是这样的:

1. LLM调用 requestVacation 工具
2. 工具发现还没确认  调用 ctx.RequestConfirmation()
3. ADK把确认请求发给前端/用户
4. 用户在UI上点击"批准""拒绝"
5. ADK再次调用 requestVacation 工具这次 ctx.ToolConfirmation() 不为nil
6. 工具根据确认结果执行或拒绝操作

工具的生命周期

理解工具调用的完整生命周期,对于写出好用的工具非常重要。让我们从头到尾 走一遍:

                         Agent 循环
                    ┌─────────────────┐
                    v                 |
用户输入  [LLM分析]  决定调用工具? ──┤
                |         |           |
                |       |         |
                |         v           |
                |    [执行工具函数]    |
                |         |           |
                |    工具返回结果      |
                |         |           |
                |    [结果返回LLM]     |
                |         |           |
                |    LLM继续分析 ──────┘
                |
                v
           LLM生成最终回答  返回给用户

更详细地说,一次完整的工具调用经历这些步骤:

1. 用户发送消息

用户:"帮我算 123 + 456"

2. ADK把消息和工具信息一起发给LLM

ADK会把你注册的所有工具的JSON Schema(包括名字、描述、参数定义)和用户消息 一起发送给LLM。

3. LLM分析并决定调用工具

LLM看到工具列表和用户消息,决定:我应该调用 calculator 工具,参数是 {"a": 123, "b": 456, "op": "add"}

LLM返回的不是文字,而是一个"函数调用请求"(FunctionCall)。

4. ADK收到函数调用请求,执行你的Go函数

ADK解析LLM返回的函数调用请求,找到对应的工具,把JSON参数反序列化成你的输入 结构体,然后调用你的Go函数。

// ADK在内部大致做了这样的事情:
input := CalcInput{A: 123, B: 456, Op: "add"}
output, err := calculate(toolCtx, input)
// output = CalcOutput{Result: 579}

5. ADK把工具的返回值发回给LLM

工具的输出会被序列化成JSON,作为"函数调用响应"(FunctionResponse)发回给LLM。

6. LLM根据工具的结果生成最终回答

LLM:"123 + 456 的结果是 579。"

7. 如果LLM还需要调用其他工具,循环继续

LLM可能需要连续调用多个工具才能完成任务。比如用户说"搜一下天气然后转换温度", LLM会先调用搜索工具,再调用计算器工具。这个循环会一直持续,直到LLM认为可以 直接回答用户为止。

工具函数里发生panic怎么办?

不用担心,ADK做了保护。如果你的工具函数panic了,ADK会recover并把panic信息 包装成error返回给LLM,不会让整个程序崩溃。

// 这段代码来自ADK的functiontool源码
func (f *functionTool[TArgs, TResults]) Run(ctx tool.Context, args any) (result map[string]any, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic in tool %q: %v\nstack: %s", f.Name(), r, debug.Stack())
        }
    }()
    // ...
}

但还是建议你在工具函数里做好错误处理,不要依赖panic recovery。


内置的工具包

除了 functiontoolgeminitoolagenttool,ADK-Go还提供了一些内置工具, 帮你处理常见场景:

loadmemorytool - 加载记忆

让Agent主动搜索历史对话中的信息:

import "google.golang.org/adk/tool/loadmemorytool"

a, err := llmagent.New(llmagent.Config{
    Name:  "memory_agent",
    Model: model,
    Tools: []tool.Tool{
        loadmemorytool.New(),
    },
})

preloadmemorytool - 预加载记忆

每次对话开始时自动加载相关记忆:

import "google.golang.org/adk/tool/preloadmemorytool"

a, err := llmagent.New(llmagent.Config{
    Name:  "memory_agent",
    Model: model,
    Tools: []tool.Tool{
        preloadmemorytool.New(),
        loadmemorytool.New(),
    },
})

loadartifactstool - 加载工件

让Agent可以访问保存的工件(如文件、图片等):

import "google.golang.org/adk/tool/loadartifactstool"

// 具体用法后续章节会介绍

exitlooptool - 退出循环

在循环型工作流中,让Agent可以主动退出循环:

import "google.golang.org/adk/tool/exitlooptool"

// 配合LoopAgent使用,后续章节会介绍

mcptoolset - MCP工具集

ADK-Go支持MCP(Model Context Protocol)标准,可以连接外部MCP服务器获取工具:

import "google.golang.org/adk/tool/mcptoolset"

// 连接MCP服务器获取工具,后续章节会详细介绍

工具设计的最佳实践

写到这里,让我分享一些写工具时的经验:

1. 描述要写清楚

// 差的描述 - LLM可能搞不清什么时候该用这个工具
functiontool.Config{
    Name:        "do_thing",
    Description: "做一些事情",
}

// 好的描述 - LLM能精准判断
functiontool.Config{
    Name:        "search_product",
    Description: "在商品数据库中搜索商品。支持按名称、分类、价格范围搜索。" +
        "当用户想要查找、浏览或比较商品时使用此工具。",
}

2. 错误要优雅返回

func myTool(ctx tool.Context, input MyInput) (MyOutput, error) {
    // 方式1:业务错误放在输出结构体里(推荐)
    // LLM可以理解这个错误并告诉用户
    if input.Value < 0 {
        return MyOutput{Error: "数值不能为负数"}, nil
    }

    // 方式2:返回Go error(用于真正的系统错误)
    // 比如数据库连接失败、网络超时等
    result, err := callExternalAPI(input)
    if err != nil {
        return MyOutput{}, fmt.Errorf("调用外部API失败: %w", err)
    }

    return MyOutput{Result: result}, nil
}

区别在于:如果是"用户可以理解的错误"(比如参数不对),放在输出结构体里; 如果是"系统级的错误"(比如数据库挂了),返回Go error。

3. 工具粒度要合适

// 太细了 - 一个简单的任务要调用好几个工具
// add_numbers, subtract_numbers, multiply_numbers, divide_numbers

// 太粗了 - 一个工具做太多事,LLM搞不清该传什么参数
// do_everything

// 刚好 - 一个工具解决一类问题
// calculator (支持加减乘除)

4. description 标签别偷懒

// 偷懒版
type Input struct {
    Q string `json:"q"`
}

// 用心版
type Input struct {
    Query string `json:"query" description:"搜索关键词,支持自然语言查询"`
}

description 标签越清晰,LLM传参就越准确。


小结

这一章我们学了不少东西,来回顾一下关键点:

  1. 工具是Agent的手脚 - LLM负责思考,工具负责执行

  2. FunctionTool 是最常用的工具类型 - 写一个Go函数,ADK自动包装成工具 - 函数签名:func(tool.Context, InputStruct) (OutputStruct, error) - ADK从结构体自动生成JSON Schema - description 标签很重要

  3. Gemini内置工具 - 如 geminitool.GoogleSearch{},在Gemini服务端执行

  4. AgentTool - 把Agent包装成工具,解决不同类型工具不能混用的问题

  5. tool.Context 提供了丰富的上下文信息和操作能力: - FunctionCallID() - 调用唯一ID - Actions() - 修改状态 - State() - 读写会话状态 - SearchMemory() - 搜索记忆 - ToolConfirmation() / RequestConfirmation() - 人在回路

  6. 工具生命周期 - 用户输入 -> LLM分析 -> 调用工具 -> 结果返回LLM -> 生成回答


动手练习

练习1:翻译工具

创建一个翻译工具,让Agent能把文本在中英文之间互译。

提示:

// 输入
type TranslateInput struct {
    Text       string `json:"text" description:"要翻译的文本"`
    TargetLang string `json:"target_lang" description:"目标语言: zh(中文) 或 en(英文)"`
}

// 输出
type TranslateOutput struct {
    TranslatedText string `json:"translated_text"`
    SourceLang     string `json:"source_lang"`
    TargetLang     string `json:"target_lang"`
}

// 思考:
// 1. 你可以用一个简单的map来做"翻译"(练习目的),也可以调用真实的翻译API
// 2. 别忘了处理不支持的语言
// 3. 给Agent写一个好的Instruction,告诉它什么时候该用翻译工具

练习2:文件读取工具

创建一个能从磁盘读取文件内容的工具。

提示:

type ReadFileInput struct {
    Path string `json:"path" description:"文件路径"`
}

type ReadFileOutput struct {
    Content  string `json:"content"`
    FileSize int64  `json:"file_size"`
    Error    string `json:"error,omitempty"`
}

// 思考:
// 1. 要考虑文件不存在的情况
// 2. 要考虑文件太大的情况(比如限制只读前10KB)
// 3. 安全性:你可能想限制只能读取某些目录下的文件
// 4. 错误信息要对LLM友好,让它能告诉用户发生了什么

练习3:组合GoogleSearch和自定义工具

agenttool 把GoogleSearch和一个自定义工具组合在一起。

提示:

// 1. 创建一个搜索Agent,使用 geminitool.GoogleSearch{}
// 2. 创建一个自定义工具,比如:
//    - 一个单位换算工具(温度、长度、重量等)
//    - 或者用上面写的翻译工具
// 3. 创建一个自定义工具Agent
// 4. 用 agenttool.New 把两个子Agent包装成工具
// 5. 创建根Agent,把两个Agent工具都给它
// 6. 试着问:"搜一下东京今天的气温,然后帮我换算成华氏度"

这三个练习做完,你对ADK-Go的工具系统就有很扎实的理解了。下一章我们会深入学习 Agent之间的协作 -- 多Agent系统。