本系列将从零开始,用 Go 语言实现一个具备基本功能(工具调用、循环思考、MCP、Skill)的 Agent

代码仓库:https://gitee.com/lymgoforIT/learn-agent(chapter1对应第一部分的代码,以此类推)

本节目标:让 Agent 学会调用外部工具,完成仅靠语言无法完成的任务。

二、赋予 Agent 使用工具的能力

上一部分中,我们的 Agent 实现了与 LLM 的对话。但这还远远不够。一个真正的智能助理,不仅要能“说”,还要能“做”。它需要能够查询天气、读取文件、执行代码,甚至操作浏览器。这些能力,LLM 自身并不具备。

问题:如何让一个纯粹的语言模型,与外部世界的功能(如 API、数据库、命令行)进行交互?
解决方案:工具调用 (Tool Calling)。

这个想法非常巧妙:既然 LLM 擅长生成文本,那我们是否可以训练它在特定场景下,生成一段符合预设格式的、描述了“调用哪个工具”以及“传入什么参数”的特殊文本?

答案是肯定的。现代的 LLM 都经过了大量的这类训练。我们只需要在与 LLM 对话时,告诉它“你拥有以下工具可用”,并用一种结构化的方式(如 JSON Schema)描述清楚每个工具的名称、功能和参数。当用户提出的任务需要使用工具时,LLM 便会生成一段包含工具调用指令的 JSON 文本,而不是直接给出最终答案。

我们的 Agent 在收到这段指令后,解析它、执行对应的本地函数或外部 API,然后将执行结果再发送回给 LLM,让它基于这个结果继续下一步的思考。

2.1 扩展通信语言:支持工具的 API

为了支持工具调用,我们需要对第一部分中的 API 数据结构进行扩展。

请求 (Request) 中的 tools 字段

在发送给 LLM 的请求中,我们需要新增一个 tools 字段。它是一个数组,其中每个元素都详细描述了一个可用的工具。一个工具的定义通常包含:

  • type: 固定为 function。
  • function:
    • name: 工具的名称,例如 get_current_weather。
    • description: 对工具功能的清晰描述,例如“获取指定城市的当前天气情况”。这是最重要的部分,LLM 依赖它来判断何时使用该工具。
    • parameters: 一个 JSON Schema 对象,定义了该工具接受的所有参数,包括参数名、类型、描述以及哪些是必填项。
      • required:必填参数的列表
{
    "model": "gpt-4o",
    "messages": [...],
    "tools": [
        {
            "type": "function",
            "function": {
                "name": "get_current_weather",
                "description": "获取指定城市的当前天气情况",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "城市名, e.g. 北京"
                        }
                    },
                    "required": ["location"]
                }
            }
        }
    ]
}

响应 (Response) 中的 tool_calls 字段

当 LLM 判断需要调用工具时,它的回复会变得有些不同:

  • finish_reason 的值会是 tool_calls。
  • message.content 字段将为 null。
  • message 中会出现一个新的字段 tool_calls。

tool_calls 是一个数组,因为模型可能一次请求调用多个工具。每个 tool_call 对象包含了:

  • id: 一次工具调用的唯一标识。当我们将执行结果返回给模型时,需要用这个 ID 来对应。
  • type: 固定为 function。
  • function:
    • name: 模型决定调用的工具名称。
    • arguments: 一个 JSON 格式的字符串,包含了模型为该工具准备的参数。
{
    "choices": [
        {
            "finish_reason": "tool_calls",
            "message": {
                "role": "assistant",
                "content": null,
                "tool_calls": [
                    {
                        "id": "call_abc123",
                        "type": "function",
                        "function": {
                            "name": "get_current_weather",
                            "arguments": "{\"location\": \"Beijing\"}"
                        }
                    }
                ]
            }
        }
    ]
}

Agent和LLM之间的API参数讲解,到这里基本就结束了,其他的多模态、流式输出、深度推理等功能,就不在这里讲解了,大家可以自行研究一下。

2.2 更新 Go 数据结构

根据新的 API 规范,我们来更新一下之前定义的 Go 结构体。

// ChatRequest 表示LLM请求
type ChatRequest struct {
        Model       string           `json:"model"`
        Messages    []Message        `json:"messages"`
        Tools       []ToolDefinition `json:"tools,omitempty"`
        Temperature float64          `json:"temperature,omitempty"`
        MaxTokens   int              `json:"max_tokens,omitempty"`
}

// Message 表示对话消息
type Message struct {
        Role       MessageRole `json:"role"`                  // system/user/assistant/tool
        Content    string      `json:"content"`               // 消息内容
        ToolCallId string      `json:"tool_call_id,omitempty"`// 工具调用ID
        ToolCalls  []ToolCall  `json:"tool_calls,omitempty"`  // 工具调用请求
}

// ToolCall 表示工具调用请求
type ToolCall struct {
        ID       string `json:"id"`
        Type     string `json:"type"` // function
        Function struct {
                Name      string `json:"name"`
                Arguments string `json:"arguments"` // JSON字符串
        } `json:"function"`
}

// ToolDefinition 定义工具描述
type ToolDefinition struct {
        Type     string `json:"type"` // function
        Function struct {
                Name        string                 `json:"name"`
                Description string                 `json:"description"`
                Parameters  map[string]interface{} `json:"parameters"`
        } `json:"function"`
}

2.3 定义与实现第一个工具:Shell

现在,让我们来实现第一个,也是最强大的工具之一:shell。这个工具将允许 Agent 执行任意的 shell 命令。

首先,我们定义一个通用的 Tool 接口,任何工具都必须实现它。一个工具需要提供四样东西:名称、描述、参数定义和一个执行方法。并定义一个实现了名称、描述、参数定义的基础工具类BaseTool。

// Tool 工具接口
type Tool interface {
        // Name 返回工具名称
        Name() string

        // Description 返回工具描述
        Description() string

        // Parameters 返回参数Schema(JSON Schema格式)
        Parameters() map[string]interface{}

        // Execute 执行工具
        Execute(ctx context.Context, params json.RawMessage) (string, error)
}

// BaseTool 基础工具实现
type BaseTool struct {
        name        string
        description string
        parameters  map[string]interface{}
}

func (t *BaseTool) Name() string {
        return t.name
}

func (t *BaseTool) Description() string {
        return t.description
}

func (t *BaseTool) Parameters() map[string]interface{} {
        return t.parameters
}

基于这个接口,我们来实现 ShellTool。它的 Execute 方法会使用 Go 的 os/exec 包来执行一个 shell 命令,并返回其标准输出和标准错误。

// ShellTool Shell命令执行工具
type ShellTool struct {
        BaseTool
        timeout time.Duration
}

func NewShellTool(timeout time.Duration) *ShellTool {
        return &ShellTool{
                name:        "shell",
                description: "执行Shell命令并返回输出结果",
                parameters: map[string]interface{}{
                        "type": "object",
                        "properties": map[string]interface{}{
                                "command": map[string]interface{}{
                                        "type":        "string",
                                        "description": "要执行的Shell命令",
                                },
                        },
                        "required": []string{"command"},
                },
                timeout: timeout,
        }
}

func (t *ShellTool) Execute(ctx context.Context, params json.RawMessage) (string, error) {
        var input struct {
                Command string `json:"command"`
        }

        if err := json.Unmarshal(params, &input); err != nil {
                log.Error().Err(err).Msg("参数解析失败")
                return "", fmt.Errorf("参数解析失败: %w", err)
        }

        // 创建带超时的上下文
        ctx, cancel := context.WithTimeout(ctx, t.timeout)
        defer cancel()

        cmd := exec.CommandContext(ctx, "sh", "-c", input.Command)

        var stdout, stderr bytes.Buffer
        cmd.Stdout = &stdout
        cmd.Stderr = &stderr

        err := cmd.Run()

        output := stdout.String()
        if stderr.Len() > 0 {
                output += "\nStderr: " + stderr.String()
        }

        if err != nil {
                log.Error().Err(err).Str("command", input.Command).Msg("命令执行失败")
                return output, fmt.Errorf("命令执行失败: %w", err)
        }

        return output, nil
}

2.4 工具的注册与管理

当工具越来越多时,我们需要一个“工具箱”来统一管理它们。下面这个 Registry 结构体就扮演了这个角色。它负责:

  • Register: 注册一个新工具。
  • ToLLMTools: 将所有已注册的工具转换成 LLM 需要的 ToolDefinition 格式。
  • Execute: 根据工具名称和参数,找到并执行对应的工具。
// Registry 工具注册表
type Registry struct {
        tools map[string]Tool
        mu    sync.RWMutex
}

// NewRegistry 创建工具注册表
func NewRegistry() *Registry {
        return &Registry{
                tools: make(map[string]Tool),
        }
}

// Register 注册工具
func (r *Registry) Register(tool Tool) error {
        r.mu.Lock()
        defer r.mu.Unlock()

        name := tool.Name()
        if _, exists := r.tools[name]; exists {
                log.Warn().Str("tool", name).Msg("工具已存在")
                return fmt.Errorf("工具已存在: %s", name)
        }

        r.tools[name] = tool
        return nil
}

// Get 获取工具
func (r *Registry) Get(name string) (Tool, bool) {
        r.mu.RLock()
        defer r.mu.RUnlock()

        tool, exists := r.tools[name]
        return tool, exists
}

// List 列出所有工具
func (r *Registry) List() []Tool {
        r.mu.RLock()
        defer r.mu.RUnlock()

        tools := make([]Tool, 0, len(r.tools))
        for _, tool := range r.tools {
                tools = append(tools, tool)
        }
        return tools
}

// ToLLMTools 转换为LLM工具定义
func (r *Registry) ToLLMTools() []types.ToolDefinition {
        r.mu.RLock()
        defer r.mu.RUnlock()

        definitions := make([]types.ToolDefinition, 0, len(r.tools))
        for _, tool := range r.tools {
                def := types.ToolDefinition{
                        Type: "function",
                }
                def.Function.Name = tool.Name()
                def.Function.Description = tool.Description()
                def.Function.Parameters = tool.Parameters()
                definitions = append(definitions, def)
        }
        return definitions
}

// Execute 执行指定工具
func (r *Registry) Execute(ctx context.Context, name string, params json.RawMessage) (string, error) {
        tool, exists := r.Get(name)
        if !exists {
                log.Error().Str("tool", name).Msg("工具不存在")
                return "", fmt.Errorf("工具不存在: %s", name)
        }

        return tool.Execute(ctx, params)
}

2.5 实战:让 Agent 执行 Shell 命令

现在,我们将 ShellTool 注册到我们的工具箱中,并让 Agent 尝试完成一个需要使用该工具的任务:“请帮我查看 …/config.json 文件”。

我们修改 main 函数,将工具信息添加到 LLM 请求中。如果 LLM 的回复包含了 tool_calls,我们就调用工具注册器来执行它,并打印结果。

func main() {
        config, err := loadConfig("../config.json")
        if err != nil {
                log.Fatal().Err(err).Msg("加载配置失败")
        }

        client := llm.NewOpenAIClient(config)
        toolRegistry := tool.NewRegistry()
        toolRegistry.Register(tool.NewShellTool(10 * time.Second))

        req := &types.ChatRequest{
                Messages: []types.Message{
                        {
                                Role:    types.RoleSystem,
                                Content: "你是一个专业的Agent,可以使用工具完成任务",
                        },
                        {
                                Role:    types.RoleUser,
                                Content: "请帮我查看../config.json文件",
                        },
                },
                // 将可用工具传给LLM
                Tools: toolRegistry.ToLLMTools(), 
        }

        ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
        defer cancel()

        fmt.Println("=== 输入消息 ===")
        for _, msg := range req.Messages {
                fmt.Printf("[%s] %s\n", msg.Role, msg.Content)
        }
        fmt.Println("================")

        fmt.Println("\n=== 调用LLM ===")
        resp, err := client.Chat(ctx, req)
        if err != nil {
                log.Fatal().Err(err).Msg("调用LLM失败")
        }

        if len(resp.Choices) == 0 {
                log.Fatal().Msg("LLM返回空响应")
        }

        choice := resp.Choices[0]
        fmt.Printf("=== LLM响应 (finish_reason: %s) ===\n", choice.FinishReason)

        if len(choice.Message.ToolCalls) > 0 {
                fmt.Printf("[ToolCalls] 共 %d 个工具调用\n", len(choice.Message.ToolCalls))

                for _, toolCall := range choice.Message.ToolCalls {
                        fmt.Printf("\n--- 工具调用 ---\n")
                        fmt.Printf("工具名称: %s\n", toolCall.Function.Name)
                        fmt.Printf("调用参数: %s\n", toolCall.Function.Arguments)

                        result, err := toolRegistry.Execute(ctx, toolCall.Function.Name, json.RawMessage(toolCall.Function.Arguments))
                        if err != nil {
                                result = fmt.Sprintf("工具执行错误: %v", err)
                        }

                        fmt.Printf("执行结果: %s\n", result)
                }
        } else if choice.Message.Content != "" {
                fmt.Printf("内容: %s\n", choice.Message.Content)
        }
}

func loadConfig(path string) (*types.Config, error) {
        file, err := os.Open(path)
        if err != nil {
                return nil, fmt.Errorf("打开配置文件失败: %w", err)
        }
        defer file.Close()

        var config types.Config
        if err := json.NewDecoder(file).Decode(&config); err != nil {
                return nil, fmt.Errorf("解析配置文件失败: %w", err)
        }

        apiKey := os.Getenv(config.APIKey)
        if apiKey == "" {
                return nil, fmt.Errorf("环境变量 %s 未设置", config.APIKey)
        }
        config.APIKey = apiKey

        return &config, nil
}

运行程序,你会看到 LLM 准确地输出了调用 shell 工具的指令,参数为 cat …/config.json。我们的程序成功执行了该命令,并打印出了文件的内容。
在这里插入图片描述

第二部分小结

至此,我们的 Agent 已经不再是一个只能“纸上谈兵”的对话模型。它通过 shell 工具,拥有了与本地文件系统交互的实际能力。

然而,当前的交互还停留在“一问一答”的单步执行。在上面的例子中,Agent 只是把 cat 命令的结果打印了出来,并没有基于这个结果给出人性化的总结(比如“文件内容如上所示…”)。如果任务需要多个步骤才能完成(例如,先列出文件,再读取文件,最后总结文件内容),Agent 还无能为力。

下一部分,我们将教会它如何“持续思考”。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐