从零到一手撸 Agent 系列 — 第 4 篇:工具的契约 — Tool 接口与注册表

从零到一手撸 Agent 系列 --- 第 4 篇:工具的契约 --- Tool 接口与注册表

系列:从零到一手撸 Agent 系列

难度:🟢 入门

对应源码:internal/tools/


开篇:Agent 有了"大脑",但没有"手"

上篇我们实现了 Agent 主循环------能请求 LLM、解析 tool_call、执行工具、结果回传。但工具本身还是空的:registry.Get(name) 返回 nil,Agent 只能干瞪眼。

本篇要给 Agent 装上"手"------工具系统。这是 Agent 和纯聊天机器人的分水岭:有了工具,Agent 才能读文件、写代码、跑命令。我们会先定义工具的契约(Tool 接口),再实现注册表(Registry),最后落地前几个基础工具。


一、Tool 接口:六个方法,一个契约

所有工具必须实现同一个接口,这是整个工具系统的基石:

go 复制代码
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:

go 复制代码
type Registry struct {
    mu    *sync.RWMutex
    tools 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() 返回按名称排序的切片:

go 复制代码
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 就废了。

还有一个重要方法:

go 复制代码
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 篇讲)不能调用 tasktodo_write 等"元工具"------否则子 Agent 会再派生子子 Agent,无限递归。FilterRegistry 创建一个排除特定工具的副本。


三、decodeArgs:map → 结构体的通用桥梁

go 复制代码
func decodeArgs(args map[string]any, target any) error {
    raw, _ := json.Marshal(args)      // map → JSON 字节
    return json.Unmarshal(raw, target) // JSON 字节 → 结构体
}

就三行,但每个工具的 Execute 都依赖它。map[string]anyjson.Marshaljson.Unmarshal 这个"绕一圈"的做法比反射更稳健------利用 Go 的 json tag 完成字段映射,类型不匹配时给出清晰的错误信息。


四、实战:ReadFileTool --- 一个完整的工具实现

我们以 read_file 为例,走一遍从接口到实现的完整流程:

4.1 结构体 + 构造函数

go 复制代码
type ReadFileTool struct {
    AllowedDirs []string  // 白名单目录,为空表示不限制
    MaxBytes    int       // 单次读取上限,默认 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 五个接口方法

go 复制代码
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:核心逻辑

go 复制代码
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"`
        Limit  int    `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→%s\n", padWidth, offset+i+1, line)
    }

    return b.String(), nil
}

两个设计亮点:

行号输出格式 42→... 不是随便定的。LLM 读到 42→package main 就知道这是第 42 行,后续调用 edit_file 替换时可以直接引用行号------read_fileedit_file 是配套设计的。

二进制检测 :简单的 NUL 字节检查覆盖 99% 的场景。比"检查文件扩展名"更可靠(.gitignore 没有扩展名但它是文本),比"检查完整 MIME type"更轻量。


五、其他基础工具一览

5.1 write_file --- 写文件

go 复制代码
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 --- 精确替换

go 复制代码
type EditFileTool struct {
    AllowedDirs []string
}

type editFileArgs struct {
    Path    string `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 --- 文件发现

go 复制代码
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 --- 搜索内容

go 复制代码
type GrepTool struct {
    AllowedDirs []string
}

func (t *GrepTool) ReadOnly() bool { return true }

核心实现:regexp.Compile(pattern)filepath.Walk 递归遍历 → 逐行匹配 → 返回 path:line:text 格式。跳过 .gitnode_modules 和隐藏文件。最多 200 条结果,可配置超时(默认 30 秒)。

5.5 bash --- Shell 执行

go 复制代码
type BashTool struct {
    DefaultTimeout time.Duration  // 默认 60s
    MaxOutputBytes int            // 默认 1MB
}

func (b *BashTool) ReadOnly() bool { return false }

跨平台适配:Windows 用 cmd /C,类 Unix 用 sh -cExecute 的核心是 exec.CommandContext------用 context 控制超时,cmd.CombinedOutput() 合并 stdout + stderr。


六、DefaultRegistry:一键装配

我们不希望每次创建 Agent 都要手动注册十几个工具。所以提供一个工厂函数:

go 复制代码
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 主循环串联起来看完整链路:

css 复制代码
1. Agent 构建请求 → 遍历 registry.List() 生成 tools 数组 → 发给 LLM

2. 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 main\n..."},
     {role:"tool", tool_call_id:"call_2", content:"main.go\n..."},
     {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.gogrep.gobash.go
DefaultRegistry 工厂函数 preset.go
工具执行全景链路:LLM 提议 → 分区 → 执行 → 回传 第七节

下篇预告 :有了工具,Agent 能做的事暴增------但也能搞破坏。我们将给 Agent 系上"安全带"------安全权限管线。你会看到如何用 DenyList(黑名单)+ BashAsk(命令确认)+ WorkdirBoundary(目录边界)三级检查,让 Agent 既能干活又不会删掉你的系统文件。


关联源码internal/tools/tool.go · registry.go · decode.go · files.go · grep.go · bash.go · preset.go


🐙 本教程所有源码均来自开源项目 github.com/wsx864321/c...,欢迎 Star ⭐ 支持!你的每一个 Star 都是持续更新的动力~

相关推荐
未曾※放弃꿈8 小时前
Cursor 添加与切换背景图片教程(附带背景图)
ai编程
WaywardOne9 小时前
Flutter组件化方案(AI总结)
前端·flutter·ai编程
武子康9 小时前
GitHub Actions `pull_request_target` 与 Pwn Request:高权限工作流里的 Fork 代码执行风险(2026)
人工智能·github·ai编程
win4r9 小时前
🚀Orca ADE彻底改变AI编程方式!多Agent并行、语音输入、定时审查、Git Worktree自动隔离+结构化编排+面板分割布局自由调整,支持手机APP查看进度并启动任务,开发者必备效率工具!
ai编程·claude·vibecoding
怕浪猫10 小时前
# 第3章 记忆系统:构建Agent的长短期记忆
openai·agent·ai编程
人间凡尔赛10 小时前
2026年云原生后端架构深度解析:微服务 + AI + Wasm 三驾马车驱动技术跃迁
开源·ai编程·开发者工具
禅思院10 小时前
Agent记忆管理,是一场工程上的平衡艺术
前端·架构·ai编程
AINative软件工程10 小时前
LLM 应用的回滚工程实践:Prompt、模型与配置变更出问题时如何快速恢复
后端·llm·ai编程