动手写个agent(四:加餐详解):实现接入MCP 协议
文章目录
- Chapter4 代码超详细新手导读
-
- 1. 先建立全局印象
- 2. 目录结构在说什么
- 3. 这个程序到底想干嘛
- 4. 从 main.go 开始: 程序是怎么启动的
- 5. Agent 是什么: `agent/agent.go`
- 6. Agent 最核心的地方: `agent/loop.go`
- 7. types 包: 这些结构体像“统一表格”
- 8. llm 包: 怎么和大模型说话
- 9. tool 包: 工具系统是怎么设计的
- 10. ShellTool: 真正能在本地跑命令的工具
- 11. MCP 是什么: 先用人话讲明白
- 12. mcp/manager.go: 像“服务器总管”
- 13. mcp/client.go: 真正和 MCP Server 对话的人
- 14. transport 层: 真正“传话”的快递系统
- 15. tool/mcp.go: 把远程 MCP 工具伪装成本地工具
- 16. 一次完整任务是怎么跑完的
- 17. 这份代码里值得学习的设计点
- 18. 这份代码里你可能会疑惑的点
- 19. 如果你是新手,建议按什么顺序读源码
- 20. 你可以把 chapter4 记成一句口诀
- 21. 最后做一个总复盘
- 22. 附: 核心模块关系总图
- 23. 一句话结束版
本系列将从零开始,用 Go 语言实现一个具备基本功能(工具调用、循环思考、MCP、Skill)的 Agent
代码仓库:https://gitee.com/lymgoforIT/learn-agent(chapter1对应第一部分的代码,以此类推)
Chapter4 代码超详细新手导读
这份文档的目标只有一个:
让一个几乎没写过 Go、也没接触过 Agent/MCP 的同学,能够看懂 chapter4 目录到底在做什么。
如果先用一句话概括这套代码,它其实是在做一件很酷但也很容易绕晕的事:
它实现了一个简化版 AI Agent。这个 Agent 会先把用户问题发给大模型,大模型如果觉得自己需要“动手做事”,就会调用工具;工具执行完以后,再把结果喂回大模型,直到任务完成。
你可以把它想象成一个“会思考、会翻工具箱、还能连接外部服务”的智能助手。
1. 先建立全局印象
chapter4 的代码主要分成 5 块:
main.go
程序入口,负责把所有零件装起来。agent/
Agent 的“大脑调度循环”。llm/
负责和大模型接口通信。tool/
负责管理和执行工具。mcp/
负责连接 MCP Server,把外部工具接进来。
可以先看一张总图:
这张图的意思很简单:
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 完成一个复合任务:
- 写一首诗。
- 可能需要落到文件里。
- 还要执行
git提交。
这类任务,单靠大模型“嘴上说”通常不够。
所以它给模型配了工具:
- 本地
shell工具 - MCP 服务器提供的外部工具
换句话说:
这个项目不是在做“聊天机器人”,而是在做“能调用工具解决问题的行动型 Agent”。
4. 从 main.go 开始: 程序是怎么启动的
main.go 是整个程序的总装车间。
它的执行顺序可以概括成:
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)
这几步的意思是:
- 读取配置里定义的 MCP Server。
- 一个个加入
Manager。 - 全部连接起来。
- 从这些服务器上获取工具列表。
- 把这些工具也注册到本地工具箱里。
也就是说,Agent 最终看到的工具,不光有本地 shell,还可能有来自远程服务器的工具。
4.6 创建 Agent 并运行
myAgent := agent.NewAgent("MyAgent", "", 10, client, toolRegistry)
这个 Agent 有几个关键参数:
- 名字:
MyAgent - 系统提示词: 空字符串
- 最大迭代次数:
10 - 使用的模型客户端:
client - 使用的工具箱:
toolRegistry
这里的“最大迭代次数”特别重要。
因为 Agent 的工作方式不是“一问一答”,而是:
- 问模型
- 模型决定要不要调工具
- 调工具
- 再问模型
- 再调工具
- 直到得到最终结果
所以需要一个上限,避免死循环。
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)
它做了两件事:
- 先初始化消息列表
- 再进入
loop
消息初始化如下:
messages := []types.Message{
{Role: "system", Content: a.buildSystemPrompt()},
{Role: "user", Content: userInput},
}
这就像你第一次给一个实习生布置任务时,会先给两段信息:
- 你的工作规则是什么
- 这次具体要做什么
6.2 buildSystemPrompt: 没传系统提示词时怎么办
如果创建 Agent 时没给系统提示词,就会用默认值:
你是一个专业的AI Agent助手。你可以使用提供的工具来完成用户的任务。
这句话其实就是在告诉模型:
- 你不是普通聊天助手
- 你可以使用工具
- 你的目标是完成任务
这会直接影响模型的行为模式。
6.3 loop: Agent 的心跳循环
先看大图:
这就是 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)
也就是说,每一轮都把两类信息发给模型:
- 到目前为止的全部对话消息
- 当前可用工具清单
模型收到后,通常会做二选一:
- 直接回答
- 生成
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 时序图: 一轮工具调用怎么走
6.9 为什么要设置最大迭代次数
for i := 0; i < a.maxIterations; i++ {
这是一个“保险丝”。
否则如果模型一直这样:
- 调工具
- 结果回来
- 再调工具
- 无限重复
程序就可能跑不完。
因此这里设置:
- 最多只允许循环 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: 传输方式,stdio或httpEnabled: 是否启用Command/Args/Env: 给stdio用URL/Headers/Timeout: 给http用
7.2 types/llm.go
这个文件定义了模型请求和响应的格式。
最关键的是这几个:
ChatRequestChatResponseMessageToolCallToolDefinition
可以画成简化 UML:
你可以把 Message 理解成聊天记录中的一条消息。
而 Role 表示这条消息是谁说的:
systemuserassistanttool
这里特别有 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 逻辑可以概括成:
8.2.1 默认值补齐
if req.Model == "" {
req.Model = c.config.Model
}
这属于很常见的“兜底逻辑”。
意思是:
- 如果这次请求没填模型
- 就用配置文件里的默认模型
同理 Temperature 和 MaxTokens 也是这样。
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 个问题:
- 你叫什么名字
- 你能干什么
- 你要什么参数
- 真正执行时你怎么干
9.2 BaseTool: 减少重复代码
type BaseTool struct {
name string
description string
parameters map[string]interface{}
}
很多工具的名字、描述、参数 schema 都是固定字段。
所以作者做了一个 BaseTool,相当于:
把大家共有的那部分先抽出来,后面的工具直接复用。
这样 ShellTool、MCPToolAdapter 就不用每次都重复写 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)
工作流程:
- 先按名字找工具
- 找不到就报错
- 找到就执行
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
做的事:
- 如果服务器没启用,直接跳过
- 检查名字是否重复
- 根据配置创建
Client - 放进
clientsmap
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
底层怎么传消息,可能是stdio或httpserverInfo
对方服务器是谁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 连接不是“连上就完了”,还要走初始化流程。
流程图如下:
这像什么呢?
像两家公司第一次合作时先交换名片:
- 我是谁
- 我支持什么
- 你支持什么
- 我们之后按什么协议说话
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
它会做这些事:
exec.CommandContext(...)创建子进程- 配置环境变量
- 拿到
stdin/stdout/stderr - 启动进程
- 开后台协程持续读消息
这非常像:
- 你启动了一个助手进程
- 通过管道给它写纸条
- 它通过另一根管道回你消息
14.3.2 readLoop
这个循环一直从 stdout 读一行 JSON:
line, err := t.stdout.ReadBytes('\n')
解析后分两种:
- 有
Method没ID: 通知 - 有
ID: 响应
对响应来说,代码会根据 msg.ID 找到等待这个响应的 channel。
这是一种很常见的“请求-响应匹配”模式。
可以看图:
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 的主要逻辑
- 把消息序列化成 JSON
- 发 POST 请求
- 读取响应头
- 保存
Mcp-Session-Id - 判断响应类型
- 如果是 JSON 就直接解析
- 如果是
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 像开着一个持续接收广播的频道
代码里 handleSSEResponse 和 readSSELoop 就是在读这种流式事件。
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_searchmcp_serverB_search
很清楚。
15.3 Execute 做了什么
它会:
- 把 LLM 传来的 JSON 参数解析成
map[string]interface{} - 调用
manager.CallTool(...) - 把
ToolsCallResult格式化成字符串
这里的 formatContent 也值得看一下。
MCP 返回的内容不是单纯字符串,而可能是多种内容块:
- text
- image
- audio
- resource
代码会把它们都尽量整理成一个字符串返回给 Agent。
例如:
- 文本就直接取文本
- 图片会变成
[图片: mimeType] - 资源会拼出 URI 和文本
这一步相当于把“多模态内容块”压平成“模型下一轮能继续读的文本”。
16. 一次完整任务是怎么跑完的
现在我们把所有模块串起来。
16.1 全链路时序图
16.2 用生活比喻再讲一遍
把整个系统想成一家“智能代办事务所”:
main.go
像老板,负责招人、配工具、连外部合作方Agent
像项目经理,负责理解任务并分配动作LLM
像智囊顾问,负责决定下一步该做什么ToolRegistry
像工具管理员,负责告诉大家“有哪些工具可用”ShellTool
像本地执行员,可以直接在机器上操作MCP Manager/Client
像外包渠道经理,负责联系外部合作团队Transport
像电话、邮件、快递这些通信方式
所以用户下达一个任务后,并不是“AI 一口气凭空做完”。
而更像:
- 项目经理先问顾问怎么做
- 顾问说先去调用某个工具
- 工具管理员把任务发给合适工具
- 工具做完把结果回传
- 顾问基于结果继续判断
- 最终形成答复
17. 这份代码里值得学习的设计点
17.1 接口解耦
例如:
llm.Clienttransport.Transporttool.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. 如果你是新手,建议按什么顺序读源码
推荐顺序:
main.go
先看系统是怎么装起来的。agent/agent.go
认识 Agent 本体。agent/loop.go
看核心循环。tool/tool.go
看工具接口长什么样。tool/registry.go
看工具怎么注册和执行。tool/shell.go
看一个具体工具实现。llm/client.go和llm/openai.go
看模型接口和实现。mcp/manager.go和mcp/client.go
看外部工具系统怎么接入。mcp/transport/*
最后再看底层通信实现。
这个顺序的好处是:
- 先抓主流程
- 再补细节
- 不容易一开始掉进协议和通信细节里
20. 你可以把 chapter4 记成一句口诀
送给纯新手一个非常简化的记忆版:
main负责组装,agent负责循环思考,llm负责问模型,tool负责执行动作,mcp负责接外部工具。
如果再缩成一条主线:
用户提任务 -> Agent 问模型 -> 模型要工具 -> 工具执行 -> 结果回灌 -> 模型继续 -> 最终完成任务
21. 最后做一个总复盘
chapter4 本质上是在实现一个迷你版通用 Agent 框架。
它已经具备了 Agent 系统最核心的几个能力:
- 有统一消息模型
- 能调用大模型
- 能注册和执行工具
- 能把工具结果回灌给模型
- 能通过 MCP 接外部工具生态
- 能控制最大迭代次数防止死循环
虽然它还不是完整的生产级系统,但它已经把 Agent 的骨架搭出来了。
所以你学完这一章后,最应该带走的不是某一行 Go 语法,而是这个核心思想:
Agent 不是“模型一次性回答问题”,而是“模型在循环中决定是否调用工具,并基于工具结果继续推理直到完成任务”。
一旦你真正理解这句话,这个目录的大部分代码你就不会再觉得神秘了。
22. 附: 核心模块关系总图
23. 一句话结束版
如果有一天你忘了这章代码细节,只要记住这句:
这套代码是在用 Go 手写一个会“思考 + 调工具 + 接外部工具协议”的迷你 Agent 框架。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)