图解|Go语言实现 Agent|LLM+MCP+RAG

写在前面

agent 这个话题其实是近年来最火的话题之一,这篇文章就来讲讲如何用go语言通过 mcp+llm+rag 做一个agent demo。

代码都在github上 https://github.com/CocaineCong/llm-mcp-rag ,过段时间B站会出coding视频,感兴趣的同学可以关注同名的B站账号~

整体架构

  1. PromptMCP Client 的所有 Tools 以及 RAG内容 给到 LLM
  2. LLM会根据 Prompt + Tools 给出使用步骤以及 tools 名字和对应的参数
  3. 如果有多个MCP Client,要先找到对应的 Tool 是哪个MCP Client
  4. 接着 MCP Client 会调用 Call Tool 使用工具
  5. 真正执行是在 MCP Server 中,大模型不会调用,而是需要我们写代码进行 ToolCall

举个例子:如果是想爬取一个网页的内容

LLM

新建一个LLM,我们就选用 OpenAI 的 ChatGPT 了

  • NewChatOpenAI:New一个LLM对象,必须要传一个model name
go 复制代码
type ChatOpenAI struct {
	Ctx     context.Context                          
	Model   string                                   // 模型名称
	Tools   []mcp.Tool                               // 所需要的工具
	Message []openai.ChatCompletionMessageParamUnion // LLM的上下文信息(会话历史)
	LLM     openai.Client                            // 具体的客户端
}

func NewChatOpenAI(ctx context.Context, model string) *ChatOpenAI {
	options := []option.RequestOption{
		option.WithAPIKey(os.Getenv(ChatGPTOpenAPIKEY)),
		option.WithBaseURL(os.Getenv(ChatGPTBaseURL)),
	}
	cli := openai.NewClient(options...)
	llm := &ChatOpenAI{
		Ctx:     ctx,
		Model:   model,
		LLM:     cli,
		Message: make([]openai.ChatCompletionMessageParamUnion, 0),
	}
	return llm
}
  • Chat:实现具体和LLM进行Chat,而这里我们 Stream 流式进行模型的通信。而这个Chat函数的返回值有两个,一个是模型的结果,另一个是模型让我们调用的工具。
go 复制代码
func (c *ChatOpenAI) Chat(prompt string) (result string, toolCall []openai.ToolCallUnion) {
	// 将prompt保存到message中,作为模型的上下文通信的消息,相当于本次session的缓存动作
	if prompt != "" {
		c.Message = append(c.Message, openai.UserMessage(prompt))
	}
	// 将MCP Tool转为OpenAI所理解的Tool的格式
	toolsParam := MCPTool2OpenAITool(c.Tools)
	stream := c.LLM.Chat.Completions.NewStreaming(c.Ctx, openai.ChatCompletionNewParams{
		Messages: c.Message,
		Seed:     openai.Int(0),
		Model:    c.Model,
		Tools:    toolsParam,
	})
	// 流式结果的累加器,用来将每个分片chunk拼接成完整的消息
	acc := openai.ChatCompletionAccumulator{}
	var toolCalls []openai.ToolCallUnion // LLM 返回的Tool的结果
	for stream.Next() {
		chunk := stream.Current() // 当前的stream流的chunk分片
		acc.AddChunk(chunk)       // 解析每一个chunk,将chunk进行结构化
		// 模型完整生成的一个工具调用
		if tool, ok := acc.JustFinishedToolCall(); ok {
			toolCalls = append(toolCalls, openai.ToolCallUnion{
				ID: tool.ID,
				Function: openai.FunctionToolCallFunction{
					Name:      tool.Name,
					Arguments: tool.Arguments,
				},
			})
		}
		// 模型所吐出的结果,一点一点进行拼接结果
		if len(chunk.Choices) > 0 {
			result += chunk.Choices[0].Delta.Content
		}
	}
	if len(acc.Choices) > 0 { // 将本次模型的最终回复写入会话历史
		c.Message = append(c.Message, acc.Choices[0].Message.ToParam())
	}
	return result, toolCalls
}

MCP

我们MCP用的是这个库 github.com/mark3labs/mcp-go,主要有三个组成部份

  1. MCP 的Start启动连接,这会拉起一个MCP Server进程并建立连接
  2. MCP 的初始化 Initialize,这会进行协议握手,双方进行确认。
  3. MCP 的 CallTool 将 ToolName 和 Args 发给MCP Server。
go 复制代码
type MCPClient struct {
	Ctx    context.Context // 上下文
	Client *client.Client  // mcp的服务client
	Tools  []mcp.Tool      // mcp 工具
	Cmd    string          // 命令
	Args   []string        // 参数
	Env    []string
}

func NewMCPClient(ctx context.Context, cmd string, env, args []string) *MCPClient {
	stdioTransport := transport.NewStdio(cmd, env, args...) // 使用stdio进行连接通信
	cli := client.NewClient(stdioTransport)
	m := &MCPClient{Ctx: ctx, Client: cli, Cmd: cmd, Args: args, Env: env}
	return m
}
  • Start:MCP Client的初始化
go 复制代码
func (m *MCPClient) Start() error {
  // 负责把 MCP 服务端真正跑起来并连上
	err := m.Client.Start(m.Ctx)
	if err != nil {
		return err
	}
	mcpInitReq := mcp.InitializeRequest{}
	mcpInitReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
	mcpInitReq.Params.ClientInfo = mcp.Implementation{
		Name:    "example-client",
		Version: "0.0.1",
	}
	// 让服务端认可我们的客户端与协议版本,再进入"可工作"的状态。
	if _, err = m.Client.Initialize(m.Ctx, mcpInitReq); err != nil {
		fmt.Println("mcp init error:", err)
		return err
	}
	return err
}
  • CallTool: MCP进行工具调用,也就是调用MCP Server,具体的实现是在MCP Server中实行。
go 复制代码
func (m *MCPClient) CallTool(name string, args any) (string, error) {
	res, err := m.Client.CallTool(m.Ctx, mcp.CallToolRequest{
		Params: mcp.CallToolParams{
			Name:      name,
			Arguments: args,
		},
	})
	if err != nil {
		return "", err
	}
	return mcp.GetTextFromContent(res.Content), nil
}

Agent

接下来到我们的agent,agent主要是整合了上面的LLM和MCP。

  • NewAgent:新建一个Agent
  1. 激活所有的mcp client 拿到所有的tools
  2. 激活并告诉llm有哪些tools
go 复制代码
type Agent struct {
	Ctx          context.Context
	MCPClient    []*MCPClient
	LLM          *ChatOpenAI
	Model        string
}

func NewAgent(ctx context.Context, model string, mcpCli []*MCPClient) *Agent {
	// 1. 激活所有的mcp client 拿到所有的tools
	tools := make([]mcp.Tool, 0)
	for _, item := range mcpCli {
		// 启动 stdio 传输
		err := item.Start()
		if err != nil {
			continue
		}
		err = item.SetTools()
		if err != nil {
			continue
		}
		tools = append(tools, item.GetTool()...)
	}
	// 2. 激活并告诉llm有哪些tools
	llm := NewChatOpenAI(ctx, model, WithLLMTools(tools))
	return &Agent{
		Ctx:       ctx,
		MCPClient: mcpCli,
		LLM:       llm,
		Model:     model,
	}
}
  • Invoke:具体和LLM通信
  1. 从上面我们知道 LLM 的 Chat 方法会返回响应和所需要用到的工具 ToolCalls,ToolCalls 里面有对应的ToolName 和所需要的 Args。
  2. 根据ToolName找到对应的MCP Client,并进行CallTool
  3. CallTool就是调用MCP Server去真正的干活

⚠️注意:大模型是不会帮我们执行的,大模型只会给出一个步骤(也就是 tool name 和 tool args),而这个执行步骤是需要我们去写的,也就是真正的MCP Server

核心代码逻辑如下:

go 复制代码
func (a *Agent) Invoke(prompt string) string {
	if a.LLM == nil {
		return ""
	}
	response, toolCalls := a.LLM.Chat(prompt)
	for len(toolCalls) > 0 {
		// 省略了一些代码... 找到是哪个MCP Client,并进行CallTool
		if mcpTool.Name == toolCall.Function.Name {
			toolText, err := mcpClient.CallTool(toolCall.Function.Name, toolCall.Function.Arguments)
			if err != nil {
				continue
			}
			a.LLM.Message = append(a.LLM.Message, openai.ToolMessage(toolText, toolCall.ID))
		}
	}
	return response
}

运行结果:

相关推荐
l0sgAi2 小时前
SpringAI 整合MCP实现联网搜索 (基于tavily)
java·后端
朝新_2 小时前
【统一功能处理】从入门到源码:拦截器学习指南(含适配器模式深度解读)
数据库·后端·mybatis·适配器模式·javaee
思茂信息2 小时前
CST电动车EMC仿真(二)——电机控制器MCU的EMC仿真
开发语言·javascript·单片机·嵌入式硬件·cst·电磁仿真
q***7482 小时前
私有化部署DeepSeek并SpringBoot集成使用(附UI界面使用教程-支持语音、图片)
spring boot·后端·ui
Java水解2 小时前
Spring WebFlux 核心操作符详解:map、flatMap 与 Mono 常用方法
后端·spring
Java水解2 小时前
MySQL 慢查询 debug:索引没生效的三重陷阱
后端·mysql
开始了码2 小时前
关于qt运行程序点击几下未响应的原因
开发语言·qt
QT 小鲜肉2 小时前
【QT/C++】Qt样式设置之CSS知识(系统性概括)
linux·开发语言·css·c++·笔记·qt
洛克希德马丁2 小时前
Qt配置安卓开发环境
android·开发语言·qt