ByteNoteByteNote
Tool 设计:让 Agent 真正“干活”
字

字节笔记本

2026年10月24日 · 约 20 分钟读完

Tool 设计:让 Agent 真正“干活”

API中转
¥120

Claude Code 文档:Agent 循环的工作方式

本文是《从 0 写 Agent》系列第 3 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Tool:让 Agent 真正"干活"」。

没有工具的 Agent,只是一个会聊天的模型。有了工具,它才能查天气、查数据库、调 API、跑代码,真正"干活"。

这一章拆解 agents-go 最优雅的设计之一,怎么用一个 Go 函数 + 泛型 + struct tag,自动生成模型能理解的工具定义。整个过程,你只需要写一个普通函数,框架帮你搞定反射、schema 生成、参数校验。


引子:工具是怎么"出现"的

第 1 章我们讲过,调用大模型时把工具定义一起发过去,模型就知道有哪些工具可用。但工具定义长什么样?

json
{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "查询某个城市的天气",
    "parameters": {
      "type": "object",
      "properties": {
        "city": {"type": "string", "description": "城市名"}
      },
      "required": ["city"]
    }
  }
}

这是 OpenAI 的工具定义格式。问题来了,如果手写这个 JSON,太痛苦了。 每加一个工具,要手写一遍 schema;改个参数,要同步改 schema 和函数签名;类型对不上,运行时才炸。

理想状态是:我写一个 Go 函数,框架自动生成这个 JSON。 这就是 NewFunctionTool 干的事。


Agent SDK:把 Claude Code 当库用

原理:从 Go 函数到工具定义

agents-go 的工具定义思路是,你写一个强类型的 Go 函数,框架通过反射把它的参数类型变成 JSON Schema。

一个完整的工具定义

go
type weatherArgs struct {
    City string `json:"city" jsonschema:"title=城市名,description=要查询的城市"`
}

func getWeather(ctx context.Context, tc *agents.ToolContext, args weatherArgs) (string, error) {
    // 这里写真正的业务逻辑
    return "晴," + args.City + " 26 度", nil
}

// 一行,把函数变成工具
tool := agents.NewFunctionTool("get_weather", "查询天气", getWeather)

就这么多。 你做了三件事:

  1. 定义参数结构体(带 struct tag)
  2. 写函数(接收 ctx + ToolContext + 参数,返回结果)
  3. 调 NewFunctionTool

框架帮你做的,

  • 反射 weatherArgs,生成 JSON Schema({"type":"object","properties":{"city":...}})
  • 把 schema 发给模型
  • 模型返回参数时,自动 json.Unmarshal 成 weatherArgs
  • 调用你的 getWeather 函数
  • 把结果序列化回给模型

你只写业务逻辑,所有胶水代码框架包了。 这是声明式编程的胜利,你声明"这是我的工具",框架推导出剩下的一切。


源码对照:NewFunctionTool 的泛型魔法

NewFunctionTool 的签名是 agents-go 里最优雅的设计之一,在 agents/function_tool.go:24:

go
// agents/function_tool.go:24
func NewFunctionTool[A any, R any](
    name, description string,
    fn func(ctx context.Context, tc *ToolContext, args A) (R, error),
) *FunctionTool {

两个泛型参数 A 和 R,A 是参数类型,R 是返回类型。这让 Go 编译器在编译期就知道了你的函数签名,函数里 args A 是强类型的。

内部做了三件事:

1. 反射 A 生成 JSON Schema

go
// agents/function_tool.go:28
schema, err := SchemaFor[A](true)  // true = strict 模式

SchemaFor[A] 通过反射 A 的类型,读 struct tag(json、jsonschema),生成对应的 JSON Schema。关键,这一步是编译期确定类型的(因为泛型 A 在实例化时是具体类型),所以反射不会失败。

如果遇到无法生成 schema 的"病态类型"(比如嵌套 channel),不 panic,而是返回一个"调用时报错"的工具:

go
// agents/function_tool.go:30-42(简化)
if err != nil {
    return &FunctionTool{
        Name: name, Description: description,
        OnInvoke: func(...) (any, error) {
            return nil, fmt.Errorf("schema generation failed: %w", err)
        },
    }
}

这是工程纪律,构造时不 panic(可能影响启动),但调用时明确报错。 容错优于崩溃。

2. 包装 OnInvoke,自动反序列化参数

go
// agents/function_tool.go:50-56
OnInvoke: func(ctx context.Context, tc *ToolContext, argsJSON string) (any, error) {
    var args A
    if err := unmarshalToolArgs(argsJSON, &args); err != nil {
        return nil, fmt.Errorf("invalid arguments: %w", err)
    }
    return fn(ctx, tc, args)  // 调用你写的强类型函数
}

注意,模型传来的参数是原始 JSON 字符串(argsJSON string),不是强类型的。框架在这里做了"翻译":把 JSON 字符串 unmarshal 成强类型 A,再调你的 fn。

unmarshalToolArgs(function_tool.go:63)有个细节,空字符串当成空对象(如果工具参数都是可选的,模型可能不传参数):

go
// agents/function_tool.go:63-75(简化)
func unmarshalToolArgs(argsJSON string, dst any) error {
    if strings.TrimSpace(argsJSON) == "" {
        if reflect.TypeOf(dst).Elem().Kind() == reflect.Struct {
            return nil  // 空 JSON + 结构体参数 = 零值,不报错
        }
    }
    return json.Unmarshal([]byte(argsJSON), dst)
}

这是宽容处理,模型有时会省略参数,框架兜住,不报错。

3. 默认的错误回喂策略

go
// agents/function_tool.go:49
FailureErrorFunction: DefaultToolErrorFunction,

注意这一行,默认情况下,工具出错不会中断整个 Agent run,而是把错误信息"喂回"给模型,让模型自己决定怎么办(重试、换参数、放弃)。这是第 4 章会详细讲的"错误回喂策略"。


Tool 接口:封闭类型集合

注意 FunctionTool 实现的是一个封闭接口 Tool(agents/tool.go:16):

go
// agents/tool.go:16
type Tool interface {
    ToolName() string
    isTool()  // 私有方法,让接口只能在本包实现
}

func (t *FunctionTool) isTool() {}  // agents/tool.go:99

isTool() 是小写方法(私有),外部包没法实现。这叫"封闭接口"(sealed interface),只有 agents 包内部定义的工具类型,才能成为 Tool。

为什么要封闭?防止外部代码"造"一个不合规的 Tool 破坏 Agent 运行时。所有 Tool 都要经过 agents 包的构造函数(NewFunctionTool 等),保证类型安全。这是 Go 里"控制类型集合"的惯用法。


FunctionTool 的完整字段

一个工具能配置的东西远不止名字和函数。看 FunctionTool 的字段(agents/tool.go:38-79),这是工具的全部能力:

字段干什么
Name / Description暴露给模型的名字和描述
ParamsJSONSchema参数的 JSON Schema
Strict是否启用 OpenAI strict 模式
OnInvoke真正执行业务逻辑的函数
IsEnabled运行时门控(动态隐藏工具)
InputGuardrails执行前的输入护栏(第 14 章)
OutputGuardrails执行后的输出护栏
Timeout单次调用超时
NeedsApproval / NeedsApprovalFunc是否需要人工审批(第 12 章)
FailureErrorFunction错误回喂策略

这是个"配置对象",你拿到 NewFunctionTool 返回的 *FunctionTool 后,可以继续修改这些字段(它是 *指针,可变):

go
tool := agents.NewFunctionTool("delete_user", "删除用户", deleteUser)
tool.NeedsApproval = true   // 高危操作,要审批
tool.Timeout = 30 * time.Second
tool.Strict = false

这就是 agents-go 的设计哲学,构造函数给合理默认值,复杂场景下直接改字段。 不搞一堆 builder 方法、不搞链式调用、不搞配置文件,拿到指针直接改。简单粗暴但有效。


设计权衡:Go 反射 vs Python Pydantic

agents-go 的作者在 README 里坦白承认:"Go 的反射写起来没 Python 的 Pydantic 舒服。"

对比一下:

Python(Pydantic)

python
class WeatherArgs(BaseModel):
    city: str = Field(description="城市名")

@function_tool
def get_weather(args: WeatherArgs) -> str:
    ...
# Pydantic 自动生成 schema,类型校验丝滑

Go(反射 + struct tag)

go
type weatherArgs struct {
    City string `json:"city" jsonschema:"description=城市名"`
}
tool := agents.NewFunctionTool("get_weather", "...", getWeather)

Python 的优势:

  • 类型即声明,不用写 tag
  • Pydantic 校验更智能(必填/可选、范围、自定义校验器)
  • 错误信息更友好

Go 的劣势:

  • struct tag 写起来啰嗦(jsonschema:"title=...,description=...")
  • 反射性能比 Pydantic 编译期生成的差一点
  • 复杂 schema(嵌套、union、条件)要写更多 tag

但这是 Go 的真实代价,不是能力缺失。 agents-go 用 google/jsonschema-go 库做反射,已经做到主流场景够用。而且 Go 换来了,

  • 编译期类型检查(你的函数签名错了,编译就炸,不用等运行时)
  • 没有运行时依赖(不像 Pydantic 要装一堆)
  • 零成本性能(强类型函数调用比动态 dispatch 快)

对生产环境,这个交换是值得的。


Raw 模式:动态 schema 也要支持

有时候 schema 不是编译期决定的,比如从数据库读工具定义,或者从 MCP server 动态拿工具列表。这种情况下,泛型 NewFunctionTool 用不了(A 是编译期类型)。

agents-go 提供了 NewRawFunctionTool(function_tool.go:81):

go
// agents/function_tool.go:81
func NewRawFunctionTool(
    name, description string,
    paramsSchema map[string]any,  // 运行时传入 schema
    fn func(ctx context.Context, tc *ToolContext, argsJSON string) (any, error),
) *FunctionTool

这个版本接收 schema 作为参数(map[string]any),函数也接收原始 JSON 字符串。 适合动态场景,MCP server、配置驱动的工具、低代码平台。

这是 agents-go 的设计平衡,90% 场景用泛型版本(强类型、自动 schema),10% 动态场景用 raw 版本(手动 schema、手动解析)。两个都要支持。


实战对照:mini-claude-code 的工具

mini-claude-code 的 Tool 接口(tool.go)比 agents-go 更精简,只有 4 个方法,schema 直接写死成 json.RawMessage(不用反射):

go
// mini-claude-code/tool.go
type Tool interface {
    Name() string
    Description() string
    Schema() json.RawMessage              // 直接写死 JSON Schema 字符串
    Execute(argsJSON string) (string, error)
}

三个工具(tools.go):ReadFileTool、EditFileTool、RunCommandTool。每个的结构体只带一个 WorkDir 字段,用 safeJoin 限制文件操作在工作目录内(防 ../../etc/passwd 逃逸),这是第 31 章 os.Root 的简化版。

edit_file 用 str_replace,strings.Replace(content, old, new, 1),精确替换第一个匹配。old_string 找不到时不报错,回喂"未找到要替换的内容",让模型重新 read_file 看准内容再改。这就是第 4 章的错误回喂策略的实战。

实测:Agent 用 edit_file 把 return a - b 改成 return a / b,一行精确替换,没动文件其他部分。


小结

读完这一章,你应该能回答:

  1. 为什么要自动生成工具定义?,手写 JSON Schema 太痛苦、易错,且和函数签名脱节
  2. NewFunctionTool 的泛型魔法是什么?,两个泛型参数 A 和 R,编译期确定参数类型,反射生成 schema,调用时自动 unmarshal
  3. 封闭 Tool 接口的作用?,防止外部造不合规工具,所有工具经过构造函数保证类型安全
  4. FunctionTool 能配置什么?,名字/描述/schema/超时/审批/护栏/错误策略,构造后改字段即可
  5. Go 反射 vs Python Pydantic?,Go 啰嗦但类型安全+零依赖,Python 丝滑但运行时;生产环境 Go 的交换值得

工具让 Agent 从"会聊天"变成"会干活"。而 NewFunctionTool 让"造一个工具"从"手写一堆 JSON"变成"写一个普通函数"。


下一篇

第 4 章,我们看工具调用的完整生命周期,从模型返回"我要调工具",到工具执行,到结果回喂,到下一轮循环。agents-go 的 run_step.go 把这个过程拆成了精细的几步,其中"工具错误怎么处理"是个有意思的设计,错误是中断整个 run,还是喂回模型让它自愈?这取决于一个字段。


本文是《从 0 写 Agent》系列第 3 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: