从零到一手撸 Agent 系列 — 第 4 篇:工具的契约 — Tool 接口与注册表
从零到一手撸 Agent 系列 — 第 4 篇:工具的契约 — Tool 接口与注册表
开篇:Agent 有了"大脑",但没有"手"
上篇我们实现了 Agent 主循环——能请求 LLM、解析 tool_call、执行工具、结果回传。但工具本身还是空的:registry.Get(name) 返回 nil,Agent 只能干瞪眼。
本篇要给 Agent 装上"手"——工具系统。这是 Agent 和纯聊天机器人的分水岭:有了工具,Agent 才能读文件、写代码、跑命令。我们会先定义工具的契约(Tool 接口),再实现注册表(Registry),最后落地前几个基础工具。
一、Tool 接口:六个方法,一个契约
所有工具必须实现同一个接口,这是整个工具系统的基石:
type Tool interface {Name() string // 工具名,如 "read_file"Description() string// 功能描述(给 LLM 看的)Schema() json.RawMessage// 参数 JSON Schema(告诉 LLM 怎么调)Execute(ctx context.Context, args map[string]any) (string, error)// 执行ReadOnly() bool // 是否只读(决定能否并行)}
五个方法,各司其职:
| 方法 | 谁用 | 作用 |
|---|---|---|
Name() | Registry + System Prompt | 工具的唯一标识,LLM 回复里的 tool_calls[].name 对应的就是它 |
Description() | System Prompt | 告诉 LLM 这个工具能干什么,写得好坏直接影响 LLM 调用准确率 |
Schema() | Provider → LLM | JSON Schema 格式的参数定义,LLM 据此生成合法参数 JSON |
Execute() | Agent 主循环 | 真正"干活"的地方——传入参数 map,返回字符串结果 |
ReadOnly() | executeBatch | 决定是否可并行:true 的连续调用可以并发,false 必须串行 |
为什么 Execute 的入参是 map[string]any 而不是结构化类型?因为工具是运行时动态注册的,编译期不知道有哪些工具,map[string]any 是 Go 里唯一能表示"任意 JSON 对象"的类型。每个工具内部用 decodeArgs 把 map 转成自己的参数结构体。
返回值也很关键——固定是 (string, error)。成功和失败都返回字符串:成功返回工具输出,失败返回 "Error: ..."。这样调用方不需要区分"工具报错"和"工具正常返回了一句话",统一当作文本追加到对话历史。
二、Registry:线程安全 + 排序 + 过滤
工具注册表就是一个并发安全的 map:
type Registry struct {mu*sync.RWMutextools map[string]Tool}func (r *Registry) Register(tool Tool){ /* 加锁写 */ }func (r *Registry) Unregister(name string) { /* 加锁删 */ }func (r *Registry) Get(name string) Tool { /* 读锁查 */ }
RWMutex 而非 Mutex——因为 Get 调用频率远高于 Register,读锁不互斥,并发查询不阻塞。
List() 返回按名称排序的切片:
func (r *Registry) List() []Tool {tools := make([]Tool, 0, len(r.tools))for _, tool := range r.tools {tools = append(tools, tool)}sort.Slice(tools, func(i, j int) bool {return tools[i].Name() < tools[j].Name()})return tools}
排序不是为了好看——System Prompt 的顺序固定,LLM 的 prompt cache 才能命中。如果每次生成的 prompt 中工具顺序随机,cache 就废了。
还有一个重要方法:
func FilterRegistry(parent *Registry, exclude ...string) *Registry {child := NewRegistry()for name, tool := range parent.tools {if !excluded(name) {child.tools[name] = tool}}return child}
用途:子 Agent(第 9 篇讲)不能调用 task 和 todo_write 等"元工具"——否则子 Agent 会再派生子子 Agent,无限递归。FilterRegistry 创建一个排除特定工具的副本。
三、decodeArgs:map → 结构体的通用桥梁
func decodeArgs(args map[string]any, target any) error {raw, _ := json.Marshal(args)// map → JSON 字节return json.Unmarshal(raw, target) // JSON 字节 → 结构体}
就三行,但每个工具的 Execute 都依赖它。map[string]any → json.Marshal → json.Unmarshal 这个"绕一圈"的做法比反射更稳健——利用 Go 的 json tag 完成字段映射,类型不匹配时给出清晰的错误信息。
四、实战:ReadFileTool — 一个完整的工具实现
我们以 read_file 为例,走一遍从接口到实现的完整流程:
4.1 结构体 + 构造函数
type ReadFileTool struct {AllowedDirs []string// 白名单目录,为空表示不限制MaxBytesint // 单次读取上限,默认 10MB}func NewReadFileTool(workdir string) *ReadFileTool {return &ReadFileTool{AllowedDirs: allowedDirsFromWorkdir(workdir),MaxBytes:10 * 1024 * 1024,}}
AllowedDirs 是安全边界:如果设置了 /home/user/project,LLM 就不能读 /etc/passwd。空切片代表不限制——在本地开发场景够用,后面第 6 篇会有更精细的权限控制。
4.2 五个接口方法
func (t *ReadFileTool) Name() string{ return "read_file" }func (t *ReadFileTool) ReadOnly() bool{ return true }// 纯读,可并行func (t *ReadFileTool) Description() string {return "读取文本文件,返回带行号的输出..." // 用英文描述,LLM 原生语言}func (t *ReadFileTool) Schema() json.RawMessage {// 返回 {"type":"object","properties":{"path":...},"required":["path"]}}
Schema() 硬编码返回 JSON,虽然手写 JSON 有点丑,但它就一个职责——告诉 LLM 参数格式。一旦定义好就不怎么变了。
4.3 Execute:核心逻辑
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) (string, error) {// 1. 解码参数var p struct {Path string `json:"path"`Offset int`json:"offset"`Limitint`json:"limit"`}decodeArgs(args, &p)// 2. 校验路径t.checkPath(p.Path)// 3. 二进制检测:前 8KB 有 NUL 字节 → 判定为二进制,拒绝读取peek := make([]byte, 8192)f.Read(peek)if bytes.IndexByte(peek, 0) >= 0 {return "", errors.New("可能是二进制文件")}// 4. 读取并格式化:每行前缀 " 42→..."lines := strings.SplitAfter(string(content), "n")for i, line := range lines[offset : offset+limit] {fmt.Fprintf(&b, "%*d→%sn", padWidth, offset+i+1, line)}return b.String(), nil}
两个设计亮点:
行号输出格式 42→... 不是随便定的。LLM 读到 42→package main 就知道这是第 42 行,后续调用 edit_file 替换时可以直接引用行号——read_file 和 edit_file 是配套设计的。
二进制检测:简单的 NUL 字节检查覆盖 99% 的场景。比"检查文件扩展名"更可靠(.gitignore 没有扩展名但它是文本),比"检查完整 MIME type"更轻量。
五、其他基础工具一览
5.1 write_file — 写文件
type WriteFileTool struct {AllowedDirs []string}func (t *WriteFileTool) ReadOnly() bool { return false }// 写操作,不可并行
核心逻辑就三步:os.MkdirAll(filepath.Dir(path), 0755) 创建父目录 → os.Create 打开文件 → file.WriteString(content)。支持 append=true 追加模式。
5.2 edit_file — 精确替换
type EditFileTool struct {AllowedDirs []string}type editFileArgs struct {Pathstring `json:"path"`OldText string `json:"old_text"` // 必须在文件中精确匹配(包括空白字符)NewText string `json:"new_text"`All bool `json:"all"`// true=替换全部匹配,false=只替换唯一匹配}
关键设计:默认只替换唯一匹配。如果 old_text 在文件中出现多次且 all=false,工具返回错误。这强制 LLM 给出足够的上下文来唯一定位——比如替换的不是 fmt 而是 fmt.Sprintf("user: %s", name)。
5.3 glob_file — 文件发现
func (t *GlobFileTool) Name() string { return "glob_file" }func (t *GlobFileTool) ReadOnly() bool { return true }
内部调用 filepath.Glob(pattern)。Agent 在"找文件"时不用让 LLM 猜路径,直接 glob_file("**/*.go") 一条命令搞定。
5.4 grep — 搜索内容
type GrepTool struct {AllowedDirs []string}func (t *GrepTool) ReadOnly() bool { return true }
核心实现:regexp.Compile(pattern) → filepath.Walk 递归遍历 → 逐行匹配 → 返回 path:line:text 格式。跳过 .git、node_modules 和隐藏文件。最多 200 条结果,可配置超时(默认 30 秒)。
5.5 bash — Shell 执行
type BashTool struct {DefaultTimeout time.Duration// 默认 60sMaxOutputBytes int// 默认 1MB}func (b *BashTool) ReadOnly() bool { return false }
跨平台适配:Windows 用 cmd /C,类 Unix 用 sh -c。Execute 的核心是 exec.CommandContext——用 context 控制超时,cmd.CombinedOutput() 合并 stdout + stderr。
六、DefaultRegistry:一键装配
我们不希望每次创建 Agent 都要手动注册十几个工具。所以提供一个工厂函数:
func DefaultRegistry(workdir string) *Registry {r := NewRegistry()r.Register(NewBashTool(workdir))r.Register(NewReadFileTool(workdir))r.Register(NewWriteFileTool(workdir))r.Register(NewEditFileTool(workdir))r.Register(NewGlobFileTool(workdir))r.Register(NewGrepTool(workdir))r.Register(NewWebFetchTool())r.Register(NewTodoWriteTool())r.Register(NewCompleteStepTool())// ... 更多工具return r}
调用方只需一行 registry := tools.DefaultRegistry(workdir),就能得到一个预装好全部基础工具的注册表。如果想加自定义工具,registry.Register(myTool) 追加即可;想删掉某个工具,registry.Unregister("bash") 即可。
七、工具执行全景:从 LLM 提议到结果回传
把工具系统和 Agent 主循环串联起来看完整链路:
1. Agent 构建请求 → 遍历 registry.List() 生成 tools 数组 → 发给 LLM2. LLM 返回 tool_calls: [ {id:"call_1", name:"read_file", arguments:'{"path":"main.go"}'}, {id:"call_2", name:"glob_file",arguments:'{"pattern":"*.go"}'}, {id:"call_3", name:"write_file", arguments:'{"path":"out.txt","content":"..."}'} ]3. partitionToolCalls 按 ReadOnly 分组: [{read_file, glob_file} 并行] → [{write_file} 串行]4. 每个工具依次执行 invokeTool → registry.Get(name) → tool.Execute(args)5. 所有结果作为 tool 消息追加到对话历史: [ {role:"tool", tool_call_id:"call_1", content:"1→package mainn..."}, {role:"tool", tool_call_id:"call_2", content:"main.gon..."}, {role:"tool", tool_call_id:"call_3", content:"写入成功"}, ]6. 循环回到 loopStep → 再次请求 LLM(带工具结果)
至此,你的 Agent 真正拥有了"动手能力"——能读能写能搜能跑命令。它不再是一个只会说话的 LLM wrapper,而是一个能主动操作代码库的编码助手。
小结
| 你学到了什么 | 对应代码 |
|---|---|
| Tool 接口的五方法契约 | tool.go |
| Registry:RWMutex 并发安全 + 排序保 cache + FilterRegistry 排除 | registry.go |
| decodeArgs:map → 结构体的 JSON 桥接 | decode.go |
| ReadFileTool 的完整实现(行号格式、二进制检测、白名单) | files.go |
| write_file/edit_file/glob_file/grep/bash 的关键设计 | files.go、grep.go、bash.go |
| DefaultRegistry 工厂函数 | preset.go |
| 工具执行全景链路:LLM 提议 → 分区 → 执行 → 回传 | 第七节 |
下篇预告:有了工具,Agent 能做的事暴增——但也能搞破坏。我们将给 Agent 系上"安全带"——安全权限管线。你会看到如何用 DenyList(黑名单)+ BashAsk(命令确认)+ WorkdirBoundary(目录边界)三级检查,让 Agent 既能干活又不会删掉你的系统文件。
-
07.21
月亮影视大全app如何下载电视剧
-
07.21
炉石兆示萨卡组3月2026一览
-
07.21
炉石打脸法卡组3月2026详情
-
07.21
金铲铲之战16.7b版本更新全部内容详情
-
07.21
江南百景图同乡会馆建造位置介绍
-
07.21
原神冬极白星属性及突破材料介绍
-
-
下载
- |
-
-
下载
- 《行尸走肉第一章》免安装中文汉化硬盘版下载
- 单机|436 MB
- 一款以动作冒险为主题的游戏
-
-
下载
- 《街头霸王X铁拳》免安装中文汉化硬盘版下载
- 单机|111MB
- 一款非常好玩的格斗游戏
-
-
下载
- |
-
-
下载
- 《暗黑破坏神3》免安装繁体中文正式版下载
- 单机|7630 MB
- 一款以角色扮演为主题的游戏
-
-
下载
- 《马克思佩恩3》免安装硬盘版下载
- 单机|27033 MB
- 一款以第三人称射击为主题的游戏