Day 2 - 添加第一个工具,让 Agent 能与文件系统交互

项目地址: https://github.com/JiayuXu0/MiniCode 欢迎大家 Star,感谢感谢

本日目标

  • 理解 Tool 的工作原理
  • 实现 Glob 工具(文件匹配)
  • 让 Agent 调用工具并使用结果

最终效果

$ go run . "列出当前目录所有 Go 文件"

让我帮你查找 Go 文件。

找到了 2 个 Go 文件:
- main.go
- tools.go

为什么需要工具?

LLM 本身无法访问本地文件系统、进程或网络,只能基于提示词“猜测”答案。
这会带来两个问题:

  • 容易幻觉:凭空猜文件名、目录结构或代码内容
  • 不可验证:用户无法确认回答是否基于真实数据

工具让 Agent 具备“先查再答”的能力:

用户问题 -> LLM 决策 -> 调用工具 -> 返回真实数据 -> LLM 组织回答

目标:让回答可验证、可复现、可调试。

什么是 Tool?

在 AI Agent 中,Tool(工具) 是 LLM 可以调用的函数。

┌─────────────────────────────────────────────────────────────┐
│                对话流程                                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  User: "列出所有 Go 文件"                                    │
│           │                                                 │
│           ▼                                                 │
│  ┌─────────────────┐                                        │
│  │      LLM        │  "我需要用 glob 工具查找文件"           │
│  │   (Model)       │                                        │
│  └────────┬────────┘                                        │
│           │ 调用工具                                         │
│           ▼                                                 │
│  ┌─────────────────┐                                        │
│  │   Glob Tool     │  glob("*.go") → ["main.go", "tools.go"]│
│  └────────┬────────┘                                        │
│           │ 返回结果                                         │
│           ▼                                                 │
│  ┌─────────────────┐                                        │
│  │      LLM        │  "找到了 2 个 Go 文件..."               │
│  │   (Model)       │                                        │
│  └─────────────────┘                                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

LLM 本身无法访问文件系统,但通过 Tool,它可以:

  1. 决定何时需要调用工具
  2. 选择正确的工具和参数
  3. 使用工具返回的结果来回答问题

实现步骤

Step 1: 理解 Tool 结构

Fantasy 中的 Tool 由三部分组成:

fantasy.NewAgentTool(
    name,        // 工具名称(LLM 看到的)
    description, // 工具描述(LLM 用来决定是否使用)
    handler,     // 处理函数(实际执行的代码)
)

参数结构 通过 Go struct + tag 定义:

type GlobParams struct {
    Pattern string `json:"pattern" description:"The glob pattern (e.g., *.go)"`
}

Fantasy 会自动生成 JSON Schema,告诉 LLM:

  • 有哪些参数
  • 每个参数的类型
  • 每个参数的作用

例如,上面的结构体会被转成类似这样的 Schema:

{
  "type": "object",
  "properties": {
    "pattern": {
      "type": "string",
      "description": "The glob pattern (e.g., *.go)"
    }
  },
  "required": ["pattern"]
}

如果你希望 Schema 显式标记必填,可以使用 jsonschema tag:

Pattern string `json:"pattern" jsonschema:"required,description=The glob pattern (e.g., *.go)"`

即便有 Schema,仍然建议在 handler 里做参数校验,保证工具稳健。

小提示:带 omitempty 的字段会变成可选参数。

Step 2: 创建项目结构

在 Day 1 的基础上,新增 tools.go

MiniCode/
├── main.go      # 主程序(更新)
├── tools.go     # 工具定义(新增)
└── go.mod

后续我们会把工具拆分到 tools/ 目录,这里先用单文件最直观。

Step 3: 实现 Glob 工具

创建 tools.go

package main

import (
	"context"
	"fmt"
	"path/filepath"
	"strings"

	"charm.land/fantasy"
)

// GlobParams 定义 glob 工具的参数
type GlobParams struct {
	// Pattern 是 glob 模式,如 "*.go"
	Pattern string `json:"pattern" description:"The glob pattern to match files in the current directory (e.g., *.go)"`
}

// NewGlobTool 创建 glob 工具
func NewGlobTool() fantasy.AgentTool {
	return fantasy.NewAgentTool(
		"glob",
		"Find files matching a glob pattern in the current directory. Example: '*.go'.",
		handleGlob,
	)
}

// handleGlob 是 glob 工具的处理函数
func handleGlob(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
	// 1. 参数验证
	if params.Pattern == "" {
		return fantasy.NewTextErrorResponse("pattern is required"), nil
	}

	// 2. 执行 glob 匹配
	matches, err := filepath.Glob(params.Pattern)
	if err != nil {
		return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid pattern: %v", err)), nil
	}

	// 3. 格式化结果
	if len(matches) == 0 {
		return fantasy.NewTextResponse("No files found matching the pattern"), nil
	}

	var result strings.Builder
	result.WriteString(fmt.Sprintf("Found %d file(s):\n", len(matches)))
	for _, match := range matches {
		result.WriteString(fmt.Sprintf("- %s\n", match))
	}

	return fantasy.NewTextResponse(result.String()), nil
}

注意:filepath.Glob 只匹配当前目录,不支持 ** 递归。
如果需要递归匹配,可以在扩展练习中使用 doublestar

Step 4: 更新 main.go

在 Day 1 的基础上,添加工具并更新 system prompt:

var systemPrompt = `You are a helpful coding assistant.

You have access to tools that help you interact with the file system.
When the user asks about files, use the appropriate tool to find information.

Always respond in the same language as the user.`

func main() {
	// 1. 检查命令行参数(同 Day 1)

	// 2. 创建语言模型(同 Day 1)

	// 3. 创建工具列表
	tools := []fantasy.AgentTool{
		NewGlobTool(),
	}

	// 4. 创建 Agent(带工具)
	agent := fantasy.NewAgent(
		model,
		fantasy.WithSystemPrompt(systemPrompt),
		fantasy.WithTools(tools...),
	)

	// 5. 发送消息
	messages := []fantasy.Message{
		fantasy.NewUserTextMessage(prompt),
	}

	result, err := agent.Generate(context.Background(), messages)
	if err != nil {
		// 错误处理
	}

	// 6. 打印响应
	fmt.Println(result.Text())

	// 7. 打印统计(可选)
	fmt.Println()
	fmt.Printf("--- Tokens: %d ---\n", result.Usage().TotalTokens)
}

工具列表会随着消息一起发送给模型。模型根据 namedescription
决定是否调用,以及如何填写参数,因此描述越清晰,调用越准确。

Step 5: 运行测试

# 测试 1: 查找 Go 文件
go run . "列出当前目录所有 Go 文件"

预期输出:

让我帮你查找当前目录的 Go 文件。

找到了 2 个 Go 文件:
- main.go
- tools.go

--- Tokens: 234 ---
# 测试 2: 查找不存在的文件
go run . "有没有 Python 文件?"

预期输出:

让我检查一下是否有 Python 文件。

当前目录没有找到 Python 文件(.py)。

--- Tokens: 198 ---
# 测试 3: 复杂查询
go run . "这个项目有哪些源代码文件?"

预期输出:

让我查看一下项目中的源代码文件。

找到了以下源代码文件:
- main.go - 主程序入口
- tools.go - 工具定义

这是一个 Go 项目,目前有 2 个源文件。

--- Tokens: 312 ---

代码解析

Tool 参数定义

type GlobParams struct {
    Pattern string `json:"pattern" description:"The glob pattern..."`
}
  • json:"pattern" - 告诉 Fantasy 这个字段在 JSON 中叫 pattern
  • description:"..." - 参数描述(LLM 会看到)

Handler 函数签名

func handleGlob(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error)

参数:

  • ctx - Go 上下文(用于取消、超时)
  • params - Fantasy 自动解析的参数(从 LLM 传来的 JSON)
  • call - 工具调用信息(包含调用 ID)

返回:

  • fantasy.ToolResponse - 返回给 LLM 的结果
  • error - 系统错误(不是工具执行错误)

重要区别

  • 工具执行错误(如文件不存在)→ 返回 ToolResponse 包含错误信息
  • 系统错误(如内存不足)→ 返回 error

AgentResult 是什么?

agent.Generate() 返回的是 fantasy.AgentResult,它包含:

  • Text() - 最终的输出文本
  • Usage() - Token 统计信息

深入理解:Tool 调用流程

当你运行 go run . "列出 Go 文件" 时,发生了什么?

┌────────────────────────────────────────────────────────────────────────┐
│ 1. 用户输入 "列出 Go 文件"                                               │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│ 2. Fantasy 将消息 + 工具定义发送给模型                                  │
│                                                                        │
│    Messages:                                                           │
│    [{ role: "user", content: "列出 Go 文件" }]                          │
│                                                                        │
│    Tools:                                                              │
│    [{ name: "glob", description: "...", parameters: {...} }]           │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│ 3. 模型决定调用 glob 工具                                                │
│                                                                        │
│    Response:                                                           │
│    {                                                                   │
│      "type": "tool_use",                                               │
│      "name": "glob",                                                   │
│      "input": { "pattern": "*.go" }                                    │
│    }                                                                   │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│ 4. Fantasy 调用我们的 handleGlob 函数                                    │
│                                                                        │
│    handleGlob(ctx, GlobParams{Pattern: "*.go"}, call)                  │
│    → "Found 2 file(s):\n- main.go\n- tools.go"                         │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│ 5. Fantasy 将工具结果发送回模型                                          │
│                                                                        │
│    Messages:                                                           │
│    [                                                                   │
│      { role: "user", content: "列出 Go 文件" },                         │
│      { role: "assistant", tool_use: {...} },                           │
│      { role: "user", tool_result: "Found 2 file(s):..." }              │
│    ]                                                                   │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│ 6. 模型生成最终响应                                                      │
│                                                                        │
│    "找到了 2 个 Go 文件:main.go 和 tools.go"                            │
└────────────────────────────────────────────────────────────────────────┘
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐