
字节笔记本
2026年10月24日 · 约 20 分钟读完
Tool 设计:让 Agent 真正“干活”

本文是《从 0 写 Agent》系列第 3 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Tool:让 Agent 真正"干活"」。
没有工具的 Agent,只是一个会聊天的模型。有了工具,它才能查天气、查数据库、调 API、跑代码,真正"干活"。
这一章拆解 agents-go 最优雅的设计之一,怎么用一个 Go 函数 + 泛型 + struct tag,自动生成模型能理解的工具定义。整个过程,你只需要写一个普通函数,框架帮你搞定反射、schema 生成、参数校验。
引子:工具是怎么"出现"的
第 1 章我们讲过,调用大模型时把工具定义一起发过去,模型就知道有哪些工具可用。但工具定义长什么样?
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询某个城市的天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名"}
},
"required": ["city"]
}
}
}这是 OpenAI 的工具定义格式。问题来了,如果手写这个 JSON,太痛苦了。 每加一个工具,要手写一遍 schema;改个参数,要同步改 schema 和函数签名;类型对不上,运行时才炸。
理想状态是:我写一个 Go 函数,框架自动生成这个 JSON。 这就是 NewFunctionTool 干的事。

原理:从 Go 函数到工具定义
agents-go 的工具定义思路是,你写一个强类型的 Go 函数,框架通过反射把它的参数类型变成 JSON Schema。
一个完整的工具定义
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)就这么多。 你做了三件事:
- 定义参数结构体(带 struct tag)
- 写函数(接收 ctx + ToolContext + 参数,返回结果)
- 调
NewFunctionTool
框架帮你做的,
- 反射
weatherArgs,生成 JSON Schema({"type":"object","properties":{"city":...}}) - 把 schema 发给模型
- 模型返回参数时,自动
json.Unmarshal成weatherArgs - 调用你的
getWeather函数 - 把结果序列化回给模型
你只写业务逻辑,所有胶水代码框架包了。 这是声明式编程的胜利,你声明"这是我的工具",框架推导出剩下的一切。
源码对照:NewFunctionTool 的泛型魔法
NewFunctionTool 的签名是 agents-go 里最优雅的设计之一,在 agents/function_tool.go:24:
// 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
// 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,而是返回一个"调用时报错"的工具:
// 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,自动反序列化参数
// 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)有个细节,空字符串当成空对象(如果工具参数都是可选的,模型可能不传参数):
// 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. 默认的错误回喂策略
// agents/function_tool.go:49
FailureErrorFunction: DefaultToolErrorFunction,注意这一行,默认情况下,工具出错不会中断整个 Agent run,而是把错误信息"喂回"给模型,让模型自己决定怎么办(重试、换参数、放弃)。这是第 4 章会详细讲的"错误回喂策略"。
Tool 接口:封闭类型集合
注意 FunctionTool 实现的是一个封闭接口 Tool(agents/tool.go:16):
// agents/tool.go:16
type Tool interface {
ToolName() string
isTool() // 私有方法,让接口只能在本包实现
}
func (t *FunctionTool) isTool() {} // agents/tool.go:99isTool() 是小写方法(私有),外部包没法实现。这叫"封闭接口"(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 后,可以继续修改这些字段(它是 *指针,可变):
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)
class WeatherArgs(BaseModel):
city: str = Field(description="城市名")
@function_tool
def get_weather(args: WeatherArgs) -> str:
...
# Pydantic 自动生成 schema,类型校验丝滑Go(反射 + struct tag)
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):
// 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(不用反射):
// 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,一行精确替换,没动文件其他部分。
小结
读完这一章,你应该能回答:
- 为什么要自动生成工具定义?,手写 JSON Schema 太痛苦、易错,且和函数签名脱节
NewFunctionTool的泛型魔法是什么?,两个泛型参数 A 和 R,编译期确定参数类型,反射生成 schema,调用时自动 unmarshal- 封闭 Tool 接口的作用?,防止外部造不合规工具,所有工具经过构造函数保证类型安全
- FunctionTool 能配置什么?,名字/描述/schema/超时/审批/护栏/错误策略,构造后改字段即可
- 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 移植版)。全系列目录见总览。
