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

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

一、前言

身处 AI 浪潮之中,每天都有层出不穷的新概念和新工具从我们身边呼啸而过。MCP、Skill、ReAct、Context Engineering,Auto Resarch、Agent Harness…… 当我们还在努力理解上一个技术热点时,新的浪潮已然涌来。面对这些抽象的概念,如果缺乏亲手实践带来的直观感受,我们很容易在理论的迷雾中失去方向,从一个模糊的认知漂流到另一个未知的概念,焦虑与困惑也随之而来。

看着他人在谈论如何用 Agent 经营网店、分析市场、辅助研究,那种“害怕错过”(FOMO, Fear of Missing Out)的焦虑感,或许是每个技术人的日常。
对抗这种焦虑最好的方式,就是行动。知识来自经验,如果没有经验的直观,概念就容易陷入形而上学的幻想,变成概念游戏,终究是空中楼阁。一旦掌握了底层的原理与形式,不管内容怎么变,我们都能应对自如。

因此,本文将引导你从零开始,仅使用 Go 语言的基础库,实现一个最简化的 Agent。我们将剥离所有复杂的概念外衣,聚焦其内在的核心骨架,让你对 Agent 的工作原理建立一个坚实的直观体感。当未来再遇到层出不穷的新工具、新框架时,你将能从容地洞察其本质,面对其生灭。

在开始之前:

  • 本文主要采用 Go 语言实现,但核心思想是通用的,其他语言的开发者也能轻松理解并用自己熟悉的语言进行实践。
  • 本文尤其适合那些对 Agent 工作原理感到好奇,渴望通过实践来掌握其核心概念的初学者。
  • 阅读本文几乎没有前置知识要求,你只需要了解基本的 HTTP 调用,并知道大语言模型(LLM)可以看作一个强大的“文本续写”引擎即可。
  • 若文中存在表述方面的错误,欢迎大家批评指正。

现在,让我们暂时忘掉所有关于 Agent 的宏大叙事,开启这段简单而纯粹的构建之旅。


第一部分:与 LLM 的第一次对话

本节目标:实现一个能与大语言模型(LLM)进行基础交互的客户端。
Agent 的一切智能行为,都始于和 LLM 的对话。本质上,LLM 是一个强大的“词语预测机”,它根据你输入的前文,不断预测最可能出现的下一个词,直到生成完整的回复。Agent 与 LLM 的交互,正是通过构造特定的“前文”(即 Prompt),发送给 LLM,然后解析其返回的“续写结果”,来决定后续的行动

这个交互过程通常基于 HTTP RESTful API 与 JSON 数据格式。不同模型厂商的 API 规范略有差异,但其核心思想大同小异。本文将以 OpenAI 及其兼容的 API 风格为例进行讲解。

1.1 无状态的 Chat API

OpenAI 提供了多种 API 风格,其中最常用的是 Chat Completions API。它是一种“无状态”的接口。

“无状态”意味着模型本身不会记住之前的对话历史。如果你想让它理解上下文,就必须在每次请求时,将完整的对话历史(从第一句到最近一句)都一起发送给它。这就像和一个记忆只有七秒的鱼对话,你得时刻提醒它你们刚才聊了什么。

虽然这种方式会消耗更多的 Token,但它为我们提供了一个完全透明的视角,去观察和控制 Agent 与 LLM 交互的每一步,这对于理解 Agent 的底层工作原理至关重要,因此本文采用Chat Completions API和模型进行交互。

还有一种API风格是Response API。它是一种“有状态”的接口。推理服务维护了会话的状态,每次请求时只需要携带上会话ID,模型就能找到过去的对话历史,而不再需要在请求体中手动填充。

1.2 定义通信语言:定义API 数据结构

为了与 LLM 交流,我们首先需要定义API 的请求(Request)和响应(Response)结构体。

Chat Completions API的详细参数,可以在字节跳动火山方舟平台上查看,https://www.volcengine.com/docs/82379/1494384?lang=zh,本文仅对其中重要的参数进行讲解和实现。

在这里插入图片描述

请求 (Request)

一个最基础的请求主要包含两个部分:model(你希望调用的模型名称)和 messages(对话历史)

messages 是一个数组,其中每个元素都代表一句对话,它由 role(角色)和 content(内容)组成。角色通常有以下几种:

  • system: 系统提示。用于设定 Agent 的身份、规则和行为准则(比如“你是一个乐于助人的编程助手”)。
  • user: 用户输入。即我们向 Agent提出的问题或指令。
  • assistant: 模型回复。这是 LLM 生成的回答。
  • tool: 工具返回。当 Agent 调用工具后,将工具的执行结果返回给模型时使用(我们将在后续章节深入探讨)。

还有一些控制模型参数的字段,例如:

  • temperature:温度值。这是控制模型答案的随机程度。
  • max_tokens:最大长度。这是控制模型的最大token长度。
{
  "model": "gpt-4o",
  "messages": [
    {
      "role": "system",
      "content": "你是一个有用的智能助手,可以调用外部工具。"
    },
    {
      "role": "user",
      "content": "北京现在的天气怎么样?"
    }
  ],
  "temperature": 0.7,
  "max_tokens": 100000
}

下面是对应的 Go 结构体定义。我们暂时忽略与工具调用相关的字段。

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

// MessageRole 消息角色
type MessageRole string

const (
        RoleUser      MessageRole = "user"
        RoleAssistant MessageRole = "assistant"
        RoleSystem    MessageRole = "system"
        RoleTool      MessageRole = "tool"
)

// Message 表示对话消息
type Message struct {
        Role    MessageRole `json:"role"`    // system/user/assistant/tool
        Content string      `json:"content"` // 消息内容
}

响应 (Response)
当 LLM 处理完请求后,会返回一个 JSON 对象。其中,我们最关心的是 choices 字段,它是一个数组,包含了模型生成的回复。每个 choice 内部的 message 字段,就是我们需要的答案。

  • finish_reason:是模型当前的状态,有以下几个状态:
    • stop:模型输出自然结束,或因命中请求参数 stop 中指定的字段而被截断。
    • length:模型输出因达到模型输出限制而被截断
    • content_filter:模型输出被内容审核拦截。
    • tool_calls:模型调用工具。
  • message:本次状态,模型返回的具体内容
    • content:模型返回的输出内容,当有工具调用时,该字段为空。
    • tool_calls:模型本次要调用的工具(具体的定义后续再展开)。
{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {
        "content": "Hello! How can I help you today?",
        "role": "assistant",
        "tool_calls":null
      }
    }
  ],
  "id": "0217426318107460cfa43dc3f3683b1de1c09624ff49085a456ac",
  "usage": {
    "completion_tokens": 9,
    "prompt_tokens": 19,
    "total_tokens": 28
  }
}

对应的 Go 结构体定义如下:

// ChatResponse 表示LLM响应
type ChatResponse struct {
        ID      string   `json:"id"`
        Choices []Choice `json:"choices"`
        Usage   struct {
                PromptTokens     int `json:"prompt_tokens"`
                CompletionTokens int `json:"completion_tokens"`
                TotalTokens      int `json:"total_tokens"`
        } `json:"usage"`
}

// Choice 表示LLM返回的选择
type Choice struct {
        Index        int     `json:"index"`
        Message      Message `json:"message"`
        FinishReason string  `json:"finish_reason"`
}

此外,我们还需要一个结构体来管理调用模型所需的配置,如 API Key、服务器地址等。

// Config LLM配置
type Config struct {
        APIKey      string  `yaml:"api_key"`
        BaseURL     string  `yaml:"base_url"`
        Model       string  `yaml:"model"`
        Temperature float64 `yaml:"temperature"`
        MaxTokens   int     `yaml:"max_tokens"`
        Timeout     int     `yaml:"timeout"`
}

其中

  • BaseURL:调用LLM时的http路由在这里插入图片描述
  • APIKey:是请求大模型的重要凭证,登录火山方舟引擎,可以创建一个APIKey,注意key不要泄露,也不要硬编码到代码中,而是要作为环境变量读取
    在这里插入图片描述
  • Model:模型ID,我们需要开通模型,然后找到模型ID
  • 在这里插入图片描述
    在这里插入图片描述

1.3 实现 HTTP 客户端

定义好数据结构后,我们就可以编写一个客户端来发送 HTTP 请求了。这个客户端的核心职责是:将 ChatRequest 序列化为 JSON,发送给 LLM API,然后接收返回的 JSON 并反序列化为 ChatResponse。

首先,我们定义一个清晰的 Client 接口。

// Client 定义LLM客户端接口
type Client interface {
        // Chat 发送对话请求
        Chat(ctx context.Context, req *types.ChatRequest) (*types.ChatResponse, error)
}

接着,我们来实现这个接口。下面的代码封装了构建请求、发送、错误处理和解析响应的完整流程。

// OpenAIClient OpenAI客户端实现
type OpenAIClient struct {
        config     *types.Config
        httpClient *http.Client
}

// NewOpenAIClient 创建OpenAI客户端
func NewOpenAIClient(config *types.Config) *OpenAIClient {
        return &OpenAIClient{
                config: config,
                httpClient: &http.Client{
                        Timeout: time.Duration(config.Timeout) * time.Second,
                },
        }
}

// Chat 实现对话接口
func (c *OpenAIClient) Chat(ctx context.Context, req *types.ChatRequest) (*types.ChatResponse, error) {
        // 设置默认值
        if req.Model == "" {
                req.Model = c.config.Model
        }
        if req.Temperature == 0 {
                req.Temperature = c.config.Temperature
        }
        if req.MaxTokens == 0 {
                req.MaxTokens = c.config.MaxTokens
        }

        // 序列化请求
        reqBody, err := json.Marshal(req)
        if err != nil {
                log.Error().Err(err).Msg("序列化请求失败")
                return nil, fmt.Errorf("序列化请求失败: %w", err)
        }

        // 构建HTTP请求
        url := c.config.BaseURL
        httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(reqBody))
        if err != nil {
                log.Error().Err(err).Str("url", url).Msg("创建请求失败")
                return nil, fmt.Errorf("创建请求失败: %w", err)
        }

        // 设置请求头
        httpReq.Header.Set("Content-Type", "application/json")
        httpReq.Header.Set("Authorization", "Bearer "+c.config.APIKey)

        resp, err := c.httpClient.Do(httpReq)
        if err != nil {
                log.Error().Err(err).Msg("发送请求失败")
                return nil, fmt.Errorf("发送请求失败: %w", err)
        }
        defer resp.Body.Close()

        // 读取响应
        body, err := io.ReadAll(resp.Body)
        if err != nil {
                log.Error().Err(err).Msg("读取响应失败")
                return nil, fmt.Errorf("读取响应失败: %w", err)
        }

        // 检查状态码
        if resp.StatusCode != http.StatusOK {
                log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("API返回错误")
                return nil, fmt.Errorf("API返回错误: %d - %s", resp.StatusCode, string(body))
        }

        // 解析响应
        var chatResp types.ChatResponse
        if err := json.Unmarshal(body, &chatResp); err != nil {
                log.Error().Err(err).Msg("解析响应失败")
                return nil, fmt.Errorf("解析响应失败: %w", err)
        }

        return &chatResp, nil
}

1.4 Hello, World!

万事俱备,让我们把所有部分串联起来,向 LLM 发送第一条消息:“Hello World!”。

下面的 main 函数演示了如何加载配置、创建客户端、构建请求并打印模型的回复。

安全提示:为了安全起见,我们不应将 API_KEY 直接写在配置文件中。推荐的做法是,在配置文件里指定一个环境变量的名称,然后在程序运行时从环境中读取实际的密钥。

例如:我配置的环境变量名是MY_VOLCENGINE_TOKEN,在运行代码前,需要export MY_VOLCENGINE_TOKEN=“xxxx”,或者将环境变量写入.bashrc / .zshrc里,配完后如果是用Goland进行开发,还需要重启Goland,否则会报未找到该环境变量

配置文件config.json

{
    "base_url": "https://ark-cn-beijing.bytedance.net/api/v3/chat/completions",
    "api_key": "MY_VOLCENGINE_TOKEN",
    "model": "doubao-seed-2-0-code-preview-260215",
    "temperature": 0.7,
    "max_tokens": 10000,
    "timeout": 120
}

main函数

package main

import (
        "chapter1/llm"
        "chapter1/types"
        "context"
        "encoding/json"
        "fmt"
        "os"
        "time"

        "github.com/rs/zerolog/log"
)

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

        client := llm.NewOpenAIClient(config)

        req := &types.ChatRequest{
                Messages: []types.Message{
                        {
                                Role:    types.RoleSystem,
                                Content: "你是一个专业的Agent",
                        },
                        {
                                Role:    types.RoleUser,
                                Content: "Hello World!",
                        },
                },
        }

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

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

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

        if len(resp.Choices) > 0 {
                fmt.Printf("=== 输出回复 ===\n%s\n", resp.Choices[0].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 通信的桥梁!通过定义清晰的数据结构和实现一个健壮的 HTTP 客户端,我们的程序已经可以像一个真正的聊天应用一样与 LLM 对话。
但这只是第一步。目前的 Agent 还只是一个纯粹的“聊天机器人”,它无法感知和影响外部世界。下一部分,我们将赋予它“动手”的能力——使用工具。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐