文章目录

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

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

Chapter4 代码超详细新手导读

这份文档的目标只有一个:

让一个几乎没写过 Go、也没接触过 Agent/MCP 的同学,能够看懂 chapter4 目录到底在做什么。

如果先用一句话概括这套代码,它其实是在做一件很酷但也很容易绕晕的事:

它实现了一个简化版 AI Agent。这个 Agent 会先把用户问题发给大模型,大模型如果觉得自己需要“动手做事”,就会调用工具;工具执行完以后,再把结果喂回大模型,直到任务完成。

你可以把它想象成一个“会思考、会翻工具箱、还能连接外部服务”的智能助手。


1. 先建立全局印象

chapter4 的代码主要分成 5 块:

  1. main.go
    程序入口,负责把所有零件装起来。
  2. agent/
    Agent 的“大脑调度循环”。
  3. llm/
    负责和大模型接口通信。
  4. tool/
    负责管理和执行工具。
  5. mcp/
    负责连接 MCP Server,把外部工具接进来。

可以先看一张总图:

用户任务

main.go
组装系统

Agent

LLM Client

Tool Registry

Shell Tool

MCP Tool Adapter

MCP Manager

MCP Client

Transport
stdio/http

OpenAI Compatible API

这张图的意思很简单:

  • main.go 像组装工厂。
  • Agent 像总指挥。
  • LLM 像会推理的大脑。
  • Tool Registry 像工具箱管理员。
  • Shell Tool 是本地工具。
  • MCP 这一大块是“外接工具系统”。

2. 目录结构在说什么

chapter4 下的关键文件如下:

chapter4/
├── main.go
├── go.mod
├── spring_poem_goethe.md
├── agent/
│   ├── agent.go
│   └── loop.go
├── llm/
│   ├── client.go
│   └── openai.go
├── tool/
│   ├── tool.go
│   ├── registry.go
│   ├── shell.go
│   └── mcp.go
├── mcp/
│   ├── manager.go
│   ├── client.go
│   └── transport/
│       ├── transport.go
│       ├── types.go
│       ├── stdio.go
│       └── http.go
└── types/
    ├── config.go
    └── llm.go

对于新手来说,最重要的是知道:

  • types/ 放的是“公共数据结构”
  • llm/ 放的是“怎么请求模型”
  • tool/ 放的是“怎么描述和执行工具”
  • agent/ 放的是“怎么一轮一轮思考”
  • mcp/ 放的是“怎么接外部工具协议”

3. 这个程序到底想干嘛

main.go 里的这句:

answer, err := myAgent.Run(context.Background(), "以歌德的口吻,写一个春天的诗,并用git提交")

说明这个程序想让 Agent 完成一个复合任务:

  1. 写一首诗。
  2. 可能需要落到文件里。
  3. 还要执行 git 提交。

这类任务,单靠大模型“嘴上说”通常不够。

所以它给模型配了工具:

  • 本地 shell 工具
  • MCP 服务器提供的外部工具

换句话说:

这个项目不是在做“聊天机器人”,而是在做“能调用工具解决问题的行动型 Agent”。


4. 从 main.go 开始: 程序是怎么启动的

main.go 是整个程序的总装车间。

它的执行顺序可以概括成:

启动程序

初始化日志

读取配置 loadConfig

创建 LLM Client

创建 Tool Registry

注册本地 Shell 工具

创建 MCP Manager

加载并连接 MCP Server

把 MCP 工具注册进 Registry

创建 Agent

运行 Agent.Run

输出最终答案

4.1 初始化日志

log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})

这句是在设置日志格式。

你可以把日志想成程序的“自言自语”:

  • 走到哪一步了
  • 哪一步出错了
  • 当前正在调用什么工具

如果没有日志,排查问题会像在黑夜里找钥匙。

4.2 读取配置

config, err := loadConfig("../config.json")

配置文件里一般会有:

  • 模型地址
  • 模型名
  • API Key 对应的环境变量名
  • 超时时间
  • MCP 服务器配置

这里要特别注意一个小细节:

loadConfig 不是直接把 api_key 当作真实密钥使用,而是先把它当成“环境变量名”。

比如配置里可能写的是:

{
  "api_key": "OPENAI_API_KEY"
}

然后代码再去执行:

apiKey := os.Getenv(config.APIKey)

也就是去系统环境变量里找 OPENAI_API_KEY 的值。

这样做的好处是:

  • 密钥不直接写死在配置文件里
  • 更安全
  • 更方便切换环境

4.3 创建 LLM 客户端

client := llm.NewOpenAIClient(config)

这一步相当于:

给 Agent 配一个“打电话给大模型”的通信员。

Agent 自己不直接发 HTTP 请求,它只知道“我需要问模型问题”。

真正怎么发请求,是 llm 包负责的。

4.4 创建工具注册表

toolRegistry := tool.NewRegistry()
toolRegistry.Register(tool.NewShellTool(10 * time.Second))

可以把 Registry 理解成“工具箱目录”。

它负责记录:

  • 现在有哪些工具
  • 每个工具叫什么
  • 每个工具要什么参数
  • 真正执行时应该调用谁

这里先注册了一个本地工具:

  • shell

它能执行 Shell 命令,但设置了 10 秒超时。

4.5 创建并连接 MCP 管理器

manager := mcp.NewManager()
for _, server := range config.MCPServerConfig {
    manager.AddServer(server)
}
manager.ConnectAll(context.Background())
toolRegistry.RegisterMCPTools(manager)

这几步的意思是:

  1. 读取配置里定义的 MCP Server。
  2. 一个个加入 Manager
  3. 全部连接起来。
  4. 从这些服务器上获取工具列表。
  5. 把这些工具也注册到本地工具箱里。

也就是说,Agent 最终看到的工具,不光有本地 shell,还可能有来自远程服务器的工具。

4.6 创建 Agent 并运行

myAgent := agent.NewAgent("MyAgent", "", 10, client, toolRegistry)

这个 Agent 有几个关键参数:

  • 名字: MyAgent
  • 系统提示词: 空字符串
  • 最大迭代次数: 10
  • 使用的模型客户端: client
  • 使用的工具箱: toolRegistry

这里的“最大迭代次数”特别重要。

因为 Agent 的工作方式不是“一问一答”,而是:

  1. 问模型
  2. 模型决定要不要调工具
  3. 调工具
  4. 再问模型
  5. 再调工具
  6. 直到得到最终结果

所以需要一个上限,避免死循环。


5. Agent 是什么: agent/agent.go

先看 Agent 结构体:

type Agent struct {
    name          string
    llmClient     llm.Client
    toolRegistry  *tool.Registry
    systemPrompt  string
    maxIterations int
}

这个结构体可以理解成一个“智能员工”的档案卡:

  • name
    员工名字
  • llmClient
    他的大脑接口
  • toolRegistry
    他能用哪些工具
  • systemPrompt
    他的工作守则
  • maxIterations
    最多允许思考/行动多少轮

5.1 NewAgent 在干嘛

func NewAgent(name, systemPrompt string, maxIterations int, llmClient llm.Client, toolReg *tool.Registry) *Agent

这是一个构造函数。

通俗讲就是:

帮你造一个配置好的 Agent 实例。

Go 里很常见这种写法,因为没有像某些语言那样复杂的构造器语法,所以通常用 NewXxx(...)


6. Agent 最核心的地方: agent/loop.go

如果整套代码只能看一个文件,那最该看的就是 loop.go

因为真正的 Agent 味道,全在这里。

6.1 Run: 对外暴露的入口

func (a *Agent) Run(ctx context.Context, userInput string) (string, error)

它做了两件事:

  1. 先初始化消息列表
  2. 再进入 loop

消息初始化如下:

messages := []types.Message{
    {Role: "system", Content: a.buildSystemPrompt()},
    {Role: "user", Content: userInput},
}

这就像你第一次给一个实习生布置任务时,会先给两段信息:

  1. 你的工作规则是什么
  2. 这次具体要做什么

6.2 buildSystemPrompt: 没传系统提示词时怎么办

如果创建 Agent 时没给系统提示词,就会用默认值:

你是一个专业的AI Agent助手。你可以使用提供的工具来完成用户的任务。

这句话其实就是在告诉模型:

  • 你不是普通聊天助手
  • 你可以使用工具
  • 你的目标是完成任务

这会直接影响模型的行为模式。

6.3 loop: Agent 的心跳循环

先看大图:

开始 loop

把工具列表转成 LLM 可理解的格式

调用 LLM

LLM 要调用工具吗?

直接返回最终答案

把 assistant 的 tool_calls 记入 messages

逐个执行工具

把 tool 结果写回 messages

达到最大轮数了吗?

报错: 达到最大迭代次数

这就是 Agent 最经典的工作流:

  • 模型思考
  • 需要工具就调工具
  • 把工具结果喂回去
  • 模型继续思考

这套模式常被叫作:

  • Tool Calling Loop
  • ReAct 风格循环
  • Agent Iteration Loop

6.4 第一步: 先拿到可用工具列表

tools := a.toolRegistry.ToLLMTools()

这里非常关键。

程序内部的工具对象,大模型是看不懂的。

所以要把工具转成模型能理解的描述格式,例如:

  • 工具名
  • 工具说明
  • 参数 schema

就像你不能只把一把螺丝刀放到桌上,却不告诉新人“这玩意儿叫啥、干嘛用、怎么传参数”。

6.5 第二步: 调用大模型

req := &types.ChatRequest{
    Messages: messages,
    Tools:    tools,
}
resp, err := a.llmClient.Chat(ctx, req)

也就是说,每一轮都把两类信息发给模型:

  1. 到目前为止的全部对话消息
  2. 当前可用工具清单

模型收到后,通常会做二选一:

  • 直接回答
  • 生成 tool_calls

6.6 第三步: 判断模型是不是要调工具

if len(choice.Message.ToolCalls) == 0 {
    finalAnswer := choice.Message.Content
    return finalAnswer, nil
}

这段逻辑特别重要。

意思是:

  • 如果模型没有发起工具调用
  • 那就认为它已经有最终答案了
  • 直接结束整个 Agent

换句话说:

“有没有 ToolCalls” 就是这个 Agent 判断“该继续干活还是该收工”的核心信号。

6.7 第四步: 执行工具

如果模型请求调用工具,代码会先把这条 assistant 消息记下来:

messages = append(messages, types.Message{
    Role:      "assistant",
    ToolCalls: choice.Message.ToolCalls,
})

为什么要记这条消息?

因为后续模型需要知道:

  • 刚才是谁发起了工具调用
  • 调了哪个工具
  • 参数是什么

然后开始逐个执行:

for _, toolCall := range choice.Message.ToolCalls {
    result, err := a.toolRegistry.Execute(...)
}

执行完以后,再把结果作为 tool 角色消息写回消息列表:

messages = append(messages, types.Message{
    Role:       "tool",
    ToolCallId: toolCall.ID,
    Content:    toolContent,
})

这里的 ToolCallId 很像“快递单号”。

因为有时模型一口气会调多个工具,所以需要靠 ID 把“哪个结果对应哪个调用”对应起来。

6.8 时序图: 一轮工具调用怎么走

Tool ToolRegistry 大模型 Agent 用户 Tool ToolRegistry 大模型 Agent 用户 提交任务 messages + tools assistant(tool_calls) Execute(toolName, args) Execute(params) result result 追加 tool 结果后再次请求 最终答案 返回答案

6.9 为什么要设置最大迭代次数

for i := 0; i < a.maxIterations; i++ {

这是一个“保险丝”。

否则如果模型一直这样:

  1. 调工具
  2. 结果回来
  3. 再调工具
  4. 无限重复

程序就可能跑不完。

因此这里设置:

  • 最多只允许循环 10 轮

一旦超过就报错:

达到最大迭代次数 (10),任务未完成

7. types 包: 这些结构体像“统一表格”

新手读代码时,常常会被各种结构体吓到。

其实你可以把结构体理解成:

为不同对象设计的“固定表格模板”。

7.1 types/config.go

这里定义了配置结构:

type Config struct {
    APIKey          string
    BaseURL         string
    Model           string
    Temperature     float64
    MaxTokens       int
    Timeout         int
    MCPServerConfig []*ServerConfig
}

可以把它想成“程序启动配置总表”。

其中 ServerConfig 是“MCP 服务器分表”:

  • Name: 服务器名
  • Transport: 传输方式,stdiohttp
  • Enabled: 是否启用
  • Command/Args/Env: 给 stdio
  • URL/Headers/Timeout: 给 http

7.2 types/llm.go

这个文件定义了模型请求和响应的格式。

最关键的是这几个:

  • ChatRequest
  • ChatResponse
  • Message
  • ToolCall
  • ToolDefinition

可以画成简化 UML:

ChatRequest

+string Model

+[]Message Messages

+[]ToolDefinition Tools

+float64 Temperature

+int MaxTokens

Message

+MessageRole Role

+string Content

+string ToolCallId

+[]ToolCall ToolCalls

ToolCall

+string ID

+string Type

+Function Name/Arguments

ToolDefinition

+string Type

+Function Name/Description/Parameters

ChatResponse

+string ID

+[]Choice Choices

Choice

你可以把 Message 理解成聊天记录中的一条消息。

Role 表示这条消息是谁说的:

  • system
  • user
  • assistant
  • tool

这里特别有 Agent 味道的一点是:

工具结果也会被当成一种“消息”塞回对话上下文。

这就让“大模型思考”和“外部工具执行”被统一到了一条消息链里。


8. llm 包: 怎么和大模型说话

8.1 llm/client.go: 先定义接口

type Client interface {
    Chat(ctx context.Context, req *types.ChatRequest) (*types.ChatResponse, error)
}

这叫接口。

新手可以先这样理解:

接口不是具体实现,而是一份“能力合同”。

只要谁实现了 Chat(...) 这个方法,谁就能当 LLM 客户端用。

好处是以后很容易替换:

  • 今天接 OpenAI
  • 明天接别的兼容模型平台
  • Agent 代码都不用大改

8.2 llm/openai.go: 具体实现

OpenAIClient 里面保存了:

  • 配置 config
  • HTTP 客户端 httpClient

它的 Chat 逻辑可以概括成:

收到 ChatRequest

补默认值: model/temperature/max_tokens

把请求转成 JSON

构造 HTTP POST 请求

设置 Header: Content-Type + Authorization

发送请求

读取响应体

HTTP 状态码是 200 吗?

返回 API 错误

JSON 反序列化为 ChatResponse

返回结果

8.2.1 默认值补齐
if req.Model == "" {
    req.Model = c.config.Model
}

这属于很常见的“兜底逻辑”。

意思是:

  • 如果这次请求没填模型
  • 就用配置文件里的默认模型

同理 TemperatureMaxTokens 也是这样。

8.2.2 发 HTTP 请求
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.config.APIKey)

这就是在告诉大模型服务:

  • 我发的是 JSON
  • 我的身份凭证在这里
8.2.3 错误处理
if resp.StatusCode != http.StatusOK {
    return nil, fmt.Errorf("API返回错误: %d - %s", resp.StatusCode, string(body))
}

这里很像去银行办业务:

  • 如果柜台回执不是成功
  • 就别假装没事
  • 要把错误信息带回来

这能帮助你快速知道:

  • 是地址错了
  • 是 API Key 错了
  • 还是服务端报错了

9. tool 包: 工具系统是怎么设计的

如果说 LLM 是“大脑”,那 tool 包就是“手脚和工具箱”。

9.1 tool/tool.go: 先约定工具长什么样

type Tool interface {
    Name() string
    Description() string
    Parameters() map[string]interface{}
    Execute(ctx context.Context, params json.RawMessage) (string, error)
}

只要一个对象满足这 4 个方法,它就能被当成工具。

从新手角度看,这 4 个方法刚好回答了 4 个问题:

  1. 你叫什么名字
  2. 你能干什么
  3. 你要什么参数
  4. 真正执行时你怎么干

9.2 BaseTool: 减少重复代码

type BaseTool struct {
    name        string
    description string
    parameters  map[string]interface{}
}

很多工具的名字、描述、参数 schema 都是固定字段。

所以作者做了一个 BaseTool,相当于:

把大家共有的那部分先抽出来,后面的工具直接复用。

这样 ShellToolMCPToolAdapter 就不用每次都重复写 Name()Description()Parameters()

9.3 tool/registry.go: 工具管理员

Registry 是一个非常核心的对象。

它内部有:

tools map[string]Tool
mu    sync.RWMutex

意思是:

  • 用一个 map 存工具,key 是工具名
  • 用读写锁保证并发安全

为什么要锁?

因为真实系统里,多个 goroutine 可能同时:

  • 注册工具
  • 读取工具
  • 执行工具

如果不加锁,数据可能乱掉。

9.3.1 Register: 注册工具
func (r *Registry) Register(tool Tool) error

它会先检查:

  • 这个名字是不是已经存在

如果已存在,就拒绝注册。

原因也很直观:

  • 工具名相当于唯一标识
  • 如果两个工具同名,模型调用时就会混乱
9.3.2 ToLLMTools: 把工具翻译给模型看
func (r *Registry) ToLLMTools() []types.ToolDefinition

这个方法的作用非常像:

把程序员内部的工具对象,翻译成大模型能看懂的“工具说明书”。

因为模型并不认识 Go 对象,它只认识 JSON 形式的工具定义。

9.3.3 Execute: 按名字执行工具
func (r *Registry) Execute(ctx context.Context, name string, params json.RawMessage) (string, error)

工作流程:

  1. 先按名字找工具
  2. 找不到就报错
  3. 找到就执行 tool.Execute(...)

可以把它想成公司前台:

  • “请帮我找 shell 工具”
  • 前台找到对应部门
  • 再转接过去执行

10. ShellTool: 真正能在本地跑命令的工具

文件: tool/shell.go

这是本项目最直观的工具。

10.1 它向模型暴露了什么参数

parameters: map[string]interface{}{
    "type": "object",
    "properties": map[string]interface{}{
        "command": map[string]interface{}{
            "type": "string",
            "description": "要执行的Shell命令",
        },
    },
    "required": []string{"command"},
}

翻译成人话:

  • 这是一个对象参数
  • 里面必须有一个字段叫 command
  • 它是字符串

比如模型可以传:

{
  "command": "ls -la"
}

10.2 Execute 怎么工作的

先解析 JSON 参数:

var input struct {
    Command string `json:"command"`
}
json.Unmarshal(params, &input)

再加一个超时上下文:

ctx, cancel := context.WithTimeout(ctx, t.timeout)

这是为了避免命令卡死。

然后执行:

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

这句可以理解成:

  • 打开一个 shell
  • 让 shell 执行传进来的命令字符串

最后收集:

  • 标准输出 stdout
  • 错误输出 stderr

如果命令出错,代码仍然会尽量把已有输出返回回来。

这点很实用,因为很多时候:

  • 出错信息本身就很有价值

10.3 ShellTool 的风险

这一点非常值得新手注意。

shell 工具很强大,但也很危险,因为它意味着模型可以执行本地命令。

比如理论上它可以执行:

  • 读文件
  • 写文件
  • 调 git
  • 启动程序

所以真实生产环境里,通常会加更多安全限制,比如:

  • 白名单命令
  • 沙箱
  • 工作目录限制
  • 权限隔离

本章代码更偏教学演示,因此实现比较直接。


11. MCP 是什么: 先用人话讲明白

看到 mcp/ 目录时,新手最容易懵。

先别急着看代码,先理解概念。

MCP 可以先粗略理解为:

一套让 AI Agent 用统一方式接外部工具的协议。

你可以把它想成“工具世界的 USB 接口标准”。

为什么需要它?

因为如果没有统一协议,每接一个外部工具都得单独写一套对接方式:

  • 这个服务用 HTTP
  • 那个工具走子进程
  • 另一个返回格式又不同

写着写着系统会越来越乱。

而有了 MCP,Agent 只要懂这套协议,就能用比较统一的方式:

  • 列出对方有哪些工具
  • 调用某个工具
  • 接收通知

12. mcp/manager.go: 像“服务器总管”

Manager 内部维护:

clients map[string]*Client

意思是:

  • 一个服务器名
  • 对应一个 MCP Client

12.1 AddServer

func (m *Manager) AddServer(config *types.ServerConfig) error

做的事:

  1. 如果服务器没启用,直接跳过
  2. 检查名字是否重复
  3. 根据配置创建 Client
  4. 放进 clients map

12.2 ConnectAll

这个方法很值得注意,因为它用了 goroutine 并发连接:

go func(c *Client) {
    if err := c.Connect(ctx); err != nil {
        ...
    }
}(client)

这相当于:

  • 不一个一个慢慢连
  • 而是多个服务器一起连

如果有 5 个 MCP Server,这样通常会更快。

同时它还做了一件成熟一点的事:

  • 把错误收集到 errCh
  • 统一打印 warning

而不是“一台服务器挂了,整个程序立刻崩掉”。

这说明作者的设计倾向是:

MCP 是增强能力,不一定是绝对核心依赖。

12.3 GetAllTools

这个方法会遍历每个客户端,把所有工具收集起来,并包装成:

type MCPTool struct {
    ServerName string
    Tool       transport.Tool
}

为什么要带上 ServerName?

因为不同服务器可能有同名工具,或者调用时需要知道应该发给哪台服务器。

12.4 CallTool

它的作用就是:

  • 根据服务器名找到对应客户端
  • 让那个客户端去调用真正的 MCP 工具

13. mcp/client.go: 真正和 MCP Server 对话的人

Client 是 MCP 侧最关键的类。

可以先看它的主要字段:

type Client struct {
    config    *types.ServerConfig
    transport transport.Transport

    serverInfo   *transport.ServerInfo
    capabilities *transport.ServerCapabilities
    tools        []transport.Tool

    requestID int64
    initialized bool
}

这几个字段可以这样理解:

  • config
    连接配置
  • transport
    底层怎么传消息,可能是 stdiohttp
  • serverInfo
    对方服务器是谁
  • capabilities
    对方支持哪些能力
  • tools
    对方有哪些工具
  • requestID
    每次请求的编号生成器
  • initialized
    是否已经完成初始化握手

13.1 NewClient: 根据配置选择传输方式

switch config.Transport {
case "stdio":
    trans = transport.NewStdioTransport(config)
case "http":
    trans = transport.NewHTTPTransport(config)
}

这说明 Client 并不关心底层到底是:

  • 启一个子进程通讯
  • 还是发 HTTP 请求通讯

它只关心:

  • 你要给我一个满足 Transport 接口的对象

这就是“面向接口编程”的典型例子。

13.2 Connect: 先握手初始化

MCP 连接不是“连上就完了”,还要走初始化流程。

流程图如下:

MCP Server MCP Client MCP Server MCP Client connect initialize(protocolVersion, capabilities, clientInfo) InitializeResult(serverInfo, capabilities) notifications/initialized tools/list tool list

这像什么呢?

像两家公司第一次合作时先交换名片:

  1. 我是谁
  2. 我支持什么
  3. 你支持什么
  4. 我们之后按什么协议说话
13.2.1 initialize 请求

代码里会构造:

initParams := transport.InitializeParams{
    ProtocolVersion: "2025-03-26",
    Capabilities: ...,
    ClientInfo: ...,
}

这里说明客户端会告诉服务端:

  • 我支持的协议版本
  • 我的能力
  • 我的名称与版本
13.2.2 初始化完成通知

初始化成功后,还会发:

Method: transport.MethodInitialized

这是一种通知消息,意思近似于:

“收到你的初始化响应了,我这边已经准备好了。”

13.2.3 refreshTools: 拉取工具列表

如果服务端声明自己支持 Tools,客户端就会去调用 tools/list

而且这里考虑到了分页:

for {
    ...
    if result.NextCursor == "" {
        break
    }
    cursor = result.NextCursor
}

这说明工具可能很多,不一定一次能拿完。

这个细节挺好的,说明作者不是只写了一个“玩具 happy path”,而是稍微考虑了协议实际情况。

13.3 handleNotification: 处理服务端通知

目前主要处理的是:

notifications/tools/list_changed

意思是:

  • 服务端工具列表变了
  • 客户端应该重新刷新工具列表

这就像工具商店动态上新,前台目录也要同步刷新。

13.4 CallTool: 远程调用 MCP 工具

这里会构造一个 tools/call 请求,参数包括:

  • 工具名
  • 参数 arguments

调用成功后,再把返回的 JSON 解析成:

transport.ToolsCallResult

14. transport 层: 真正“传话”的快递系统

14.1 transport/transport.go

这里定义了传输层接口:

type Transport interface {
    Connect(ctx context.Context) error
    Close() error
    Send(ctx context.Context, msg *JSONRPCMessage) error
    SendRequest(ctx context.Context, msg *JSONRPCMessage) (*JSONRPCMessage, error)
    OnNotification(handler func(method string, params []byte))
}

也就是说,不管你是 stdio 还是 http,只要你能提供这些能力,你就能被上层 MCP Client 使用。

这是很经典的分层思想:

  • 上层关心“能不能发消息”
  • 下层自己决定“具体怎么发”

14.2 transport/types.go: 协议数据字典

这个文件本质上是 MCP + JSON-RPC 的数据模型。

你可以把它理解为:

所有通信包裹的格式说明书。

例如最核心的 JSONRPCMessage:

type JSONRPCMessage struct {
    JSONRPC string
    ID      RPCID
    Method  string
    Params  json.RawMessage
    Result  json.RawMessage
    Error   *JSONRPCError
}

这就像一张标准快递单:

  • JSONRPC: 协议版本
  • ID: 单号
  • Method: 你要做什么
  • Params: 你带了什么参数
  • Result: 返回结果
  • Error: 出错信息
14.2.1 为什么 RPCID 自定义了解析逻辑
func (id *RPCID) UnmarshalJSON(data []byte) error

这里允许 id 既能是字符串,也能是数字。

原因通常是:

  • 不同实现对 JSON-RPC 的 id 处理方式可能不完全一样
  • 有些发 "1"
  • 有些发 1

作者这里做了兼容处理,属于比较实战的写法。

14.3 transport/stdio.go: 用子进程标准输入输出通信

这种方式适合什么场景?

  • MCP Server 是本地可执行程序
  • 我们把它启动起来
  • 通过 stdin/stdout 跟它说话
14.3.1 Connect

它会做这些事:

  1. exec.CommandContext(...) 创建子进程
  2. 配置环境变量
  3. 拿到 stdin/stdout/stderr
  4. 启动进程
  5. 开后台协程持续读消息

这非常像:

  • 你启动了一个助手进程
  • 通过管道给它写纸条
  • 它通过另一根管道回你消息
14.3.2 readLoop

这个循环一直从 stdout 读一行 JSON:

line, err := t.stdout.ReadBytes('\n')

解析后分两种:

  • MethodID: 通知
  • ID: 响应

对响应来说,代码会根据 msg.ID 找到等待这个响应的 channel。

这是一种很常见的“请求-响应匹配”模式。

可以看图:

pending map MCP子进程 StdioTransport pending map MCP子进程 StdioTransport pending[id] = respCh 发送 JSON-RPC 请求 返回带相同 id 的响应 找到 pending[id] 把响应塞进 respCh
14.3.3 pending map 是干嘛的
pending map[RPCID]chan *JSONRPCMessage

它可以理解成“快递代收柜”。

每发一个请求,就放一个格子:

  • key 是请求 ID
  • value 是等响应的 channel

等服务端回消息时,再按 ID 投递进对应格子。

14.3.4 readStderr

子进程的 stderr 不参与协议通信,而是被当成日志打印出来。

这点很好理解:

  • stdout 用来传正式 JSON-RPC 消息
  • stderr 用来输出调试/错误日志

14.4 transport/http.go: 用 HTTP/SSE 通信

有些 MCP Server 不是本地进程,而是远程 HTTP 服务。

这时就用 HTTPTransport

它支持:

  • POST 发 JSON-RPC 请求
  • 普通 JSON 响应
  • SSE 流式响应
  • Session ID 维护
14.4.1 SendRequest 的主要逻辑
  1. 把消息序列化成 JSON
  2. 发 POST 请求
  3. 读取响应头
  4. 保存 Mcp-Session-Id
  5. 判断响应类型
  6. 如果是 JSON 就直接解析
  7. 如果是 text/event-stream 就按 SSE 处理
14.4.2 Session ID 是什么
if sid := resp.Header.Get("Mcp-Session-Id"); sid != "" {
    t.sessionID = sid
}

可以把它理解成一次会话的“临时工牌”。

后续请求带上它,服务端就知道:

  • 这些请求属于同一场对话/同一会话
14.4.3 SSE 又是什么

SSE 是 Server-Sent Events,服务端推送事件的一种方式。

你可以简单理解为:

  • 普通 HTTP 像打一通电话,说完就挂
  • SSE 像开着一个持续接收广播的频道

代码里 handleSSEResponsereadSSELoop 就是在读这种流式事件。


15. tool/mcp.go: 把远程 MCP 工具伪装成本地工具

这个文件很巧妙。

因为 Agent 和 ToolRegistry 只认识本地 Tool 接口,不认识 MCP 的那套结构。

怎么办?

作者用了“适配器模式”。

15.1 MCPToolAdapter

type MCPToolAdapter struct {
    BaseTool
    manager    *mcp.Manager
    serverName string
    tool       transport.Tool
}

它的作用可以概括成:

表面上它是一个本地 Tool,背地里它会转头去调用 MCP Server。

这就是经典适配器:

  • 对上游隐藏差异
  • 对下游负责转译

15.2 为什么工具名要拼成这样

name: fmt.Sprintf("mcp_%s_%s", serverName, mcpTool.Name)

这样做是为了避免冲突。

比如两个服务器都提供了叫 search 的工具,那如果只叫 search 就撞名了。

现在会变成:

  • mcp_serverA_search
  • mcp_serverB_search

很清楚。

15.3 Execute 做了什么

它会:

  1. 把 LLM 传来的 JSON 参数解析成 map[string]interface{}
  2. 调用 manager.CallTool(...)
  3. ToolsCallResult 格式化成字符串

这里的 formatContent 也值得看一下。

MCP 返回的内容不是单纯字符串,而可能是多种内容块:

  • text
  • image
  • audio
  • resource

代码会把它们都尽量整理成一个字符串返回给 Agent。

例如:

  • 文本就直接取文本
  • 图片会变成 [图片: mimeType]
  • 资源会拼出 URI 和文本

这一步相当于把“多模态内容块”压平成“模型下一轮能继续读的文本”。


16. 一次完整任务是怎么跑完的

现在我们把所有模块串起来。

16.1 全链路时序图

MCP Server MCP Manager ShellTool ToolRegistry OpenAIClient Agent main.go MCP Server MCP Manager ShellTool ToolRegistry OpenAIClient Agent main.go alt [调用本地 shell] [调用 MCP 工具] AddServer + ConnectAll initialize / tools/list tool list Register(shell) RegisterMCPTools(...) NewAgent(...) Run("写诗并 git 提交") Chat(messages, tools) tool_calls Execute(tool) Execute(command) stdout/stderr CallTool(server, tool, args) tools/call result result tool result 再次 Chat(messages + tool result) final answer answer fmt.Println(answer)

16.2 用生活比喻再讲一遍

把整个系统想成一家“智能代办事务所”:

  • main.go
    像老板,负责招人、配工具、连外部合作方
  • Agent
    像项目经理,负责理解任务并分配动作
  • LLM
    像智囊顾问,负责决定下一步该做什么
  • ToolRegistry
    像工具管理员,负责告诉大家“有哪些工具可用”
  • ShellTool
    像本地执行员,可以直接在机器上操作
  • MCP Manager/Client
    像外包渠道经理,负责联系外部合作团队
  • Transport
    像电话、邮件、快递这些通信方式

所以用户下达一个任务后,并不是“AI 一口气凭空做完”。

而更像:

  1. 项目经理先问顾问怎么做
  2. 顾问说先去调用某个工具
  3. 工具管理员把任务发给合适工具
  4. 工具做完把结果回传
  5. 顾问基于结果继续判断
  6. 最终形成答复

17. 这份代码里值得学习的设计点

17.1 接口解耦

例如:

  • llm.Client
  • transport.Transport
  • tool.Tool

它们都在表达同一种设计思想:

先约定能力,再替换实现。

这会让代码更容易扩展。

以后如果要增加:

  • 新的大模型供应商
  • 新的工具类型
  • 新的 MCP 传输方式

都比较自然。

17.2 分层清晰

上层不关心下层细节:

  • Agent 不关心 HTTP 怎么发
  • LLM 不关心工具怎么执行
  • Registry 不关心工具是本地还是 MCP
  • MCP Client 不关心底层到底是 stdio 还是 http

这就是“各司其职”。

17.3 错误处理比较完整

代码里很多地方不是简单 panic,而是:

  • 记录日志
  • 包装错误信息
  • 往上返回

对初学者来说,这是个好习惯。

17.4 并发连接 MCP 服务器

ConnectAll 使用 goroutine 并发连接多个 server。

这说明作者已经开始把程序当成“真实系统”在写,而不只是“单线程脚本”。


18. 这份代码里你可能会疑惑的点

18.1 为什么 Agent 不直接执行 shell,而要通过 Registry

因为 Agent 不应该依赖具体工具实现。

否则以后每多一个工具,Agent 都要加一段 if/else

那会越来越乱。

通过 Registry,Agent 只需要说:

“帮我执行名字叫 X 的工具。”

18.2 为什么工具结果也塞回 messages

因为大模型下一轮推理时,需要知道工具刚才返回了什么。

如果不放回消息里,模型相当于“失忆”。

18.3 为什么 shell 返回的是字符串,不是结构化对象

因为最终这些结果还要再喂给大模型。

模型最容易直接消费文本,因此这里选择把结果统一成字符串。

18.4 为什么 MCP 工具也要适配成字符串

同理。

MCP 原始结果可能是复杂内容块,但下一步仍然是“让模型读结果然后继续思考”,所以作者把它们整理成文本。


19. 如果你是新手,建议按什么顺序读源码

推荐顺序:

  1. main.go
    先看系统是怎么装起来的。
  2. agent/agent.go
    认识 Agent 本体。
  3. agent/loop.go
    看核心循环。
  4. tool/tool.go
    看工具接口长什么样。
  5. tool/registry.go
    看工具怎么注册和执行。
  6. tool/shell.go
    看一个具体工具实现。
  7. llm/client.gollm/openai.go
    看模型接口和实现。
  8. mcp/manager.gomcp/client.go
    看外部工具系统怎么接入。
  9. mcp/transport/*
    最后再看底层通信实现。

这个顺序的好处是:

  • 先抓主流程
  • 再补细节
  • 不容易一开始掉进协议和通信细节里

20. 你可以把 chapter4 记成一句口诀

送给纯新手一个非常简化的记忆版:

main 负责组装,agent 负责循环思考,llm 负责问模型,tool 负责执行动作,mcp 负责接外部工具。

如果再缩成一条主线:

用户提任务 -> Agent 问模型 -> 模型要工具 -> 工具执行 -> 结果回灌 -> 模型继续 -> 最终完成任务


21. 最后做一个总复盘

chapter4 本质上是在实现一个迷你版通用 Agent 框架。

它已经具备了 Agent 系统最核心的几个能力:

  1. 有统一消息模型
  2. 能调用大模型
  3. 能注册和执行工具
  4. 能把工具结果回灌给模型
  5. 能通过 MCP 接外部工具生态
  6. 能控制最大迭代次数防止死循环

虽然它还不是完整的生产级系统,但它已经把 Agent 的骨架搭出来了。

所以你学完这一章后,最应该带走的不是某一行 Go 语法,而是这个核心思想:

Agent 不是“模型一次性回答问题”,而是“模型在循环中决定是否调用工具,并基于工具结果继续推理直到完成任务”。

一旦你真正理解这句话,这个目录的大部分代码你就不会再觉得神秘了。


22. 附: 核心模块关系总图

Agent

-name string

-llmClient Client

-toolRegistry Registry

-systemPrompt string

-maxIterations int

+Run(ctx, userInput)

-loop(ctx, messages)

«interface»

Client

+Chat(ctx, req)

OpenAIClient

-config Config

-httpClient http.Client

+Chat(ctx, req)

Registry

-tools map[string]Tool

+Register(tool)

+Execute(ctx, name, params)

+ToLLMTools()

«interface»

Tool

+Name()

+Description()

+Parameters()

+Execute(ctx, params)

ShellTool

-timeout time.Duration

+Execute(ctx, params)

MCPToolAdapter

-manager Manager

-serverName string

-tool transport.Tool

+Execute(ctx, params)

Manager

-clients map[string]Client

+AddServer(config)

+ConnectAll(ctx)

+GetAllTools()

+CallTool(ctx, serverName, toolName, args)

MCPClient

-transport Transport

-tools []Tool

+Connect(ctx)

+GetTools()

+CallTool(ctx, name, args)

«interface»

Transport

+Connect(ctx)

+Send(ctx, msg)

+SendRequest(ctx, msg)

+Close()


23. 一句话结束版

如果有一天你忘了这章代码细节,只要记住这句:

这套代码是在用 Go 手写一个会“思考 + 调工具 + 接外部工具协议”的迷你 Agent 框架。

Logo

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

更多推荐