一个网页AI聊天,图像识别项目,使用到的技术栈有,Gin框架,GORM,rabbitmq,redis,eino框架,Vue框架等
项目地址:https://github.com/youngyangyang04/GopherAI
GopherAI第二版在第一版的基础上扩展了RAG服务,文件上传服务,MCP服务调用工具,TTS语言识别功能,下面进行一一梳理
MCPServer
首先是定义了用于接收wttr.in返回的JSON的结构体,解析后可获取天气,气温,湿度等数据
//wttr.in JSON 响应结构
type WttrResponse struct {
CurrentCondition []struct {
TempC string `json:"temp_C"`
Humidity string `json:"humidity"`
WindspeedKmph string `json:"windspeedKmph"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
} `json:"current_condition"`
NearestArea []struct {
AreaName []struct {
Value string `json:"value"`
} `json:"areaName"`
} `json:"nearest_area"`
}
以及标准响应结构
//统一对外天气结构
type WeatherResponse struct {
Location string `json:"location"`
Temperature float64 `json:"temperature"`
Condition string `json:"condition"`
Humidity int `json:"humidity"`
WindSpeed float64 `json:"windSpeed"`
}
WeatherAPIClient工具类,用于调用天气API
//Weather API Client
type WeatherAPIClient struct{}
func NewWeatherAPIClient() *WeatherAPIClient {
return &WeatherAPIClient{}
}
类的调用天气API并将返回数据封装成刚刚的结构体的函数
//Weather API Client
type WeatherAPIClient struct{}
func NewWeatherAPIClient() *WeatherAPIClient {
return &WeatherAPIClient{}
}
func (c *WeatherAPIClient) GetWeather(ctx context.Context, city string) (*WeatherResponse, error) {
apiURL := fmt.Sprintf( // 构造URL
"https://wttr.in/%s?format=j1&lang=zh",
city,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) // 构造HTTP请求报文
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
client := &http.Client{}
resp, err := client.Do(req) // 发送HTTP请求
if err != nil {
return nil, fmt.Errorf("http request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) // 读取返回数据
if err != nil {
return nil, fmt.Errorf("read response failed: %w", err)
}
var wttrResp WttrResponse
if err := json.Unmarshal(body, &wttrResp); err != nil { // 将JSON转为结构体
return nil, fmt.Errorf("json parse failed: %w", err)
}
if len(wttrResp.CurrentCondition) == 0 {
return nil, fmt.Errorf("no weather data")
}
cc := wttrResp.CurrentCondition[0]
temp, _ := strconv.ParseFloat(cc.TempC, 64)
humidity, _ := strconv.Atoi(cc.Humidity)
wind, _ := strconv.ParseFloat(cc.WindspeedKmph, 64)
location := city
if len(wttrResp.NearestArea) > 0 &&
len(wttrResp.NearestArea[0].AreaName) > 0 {
location = wttrResp.NearestArea[0].AreaName[0].Value
}
condition := "未知"
if len(cc.WeatherDesc) > 0 {
condition = cc.WeatherDesc[0].Value
}
return &WeatherResponse{
Location: location,
Temperature: temp,
Condition: condition,
Humidity: humidity,
WindSpeed: wind,
}, nil
}
随后就是MCP服务器部分
func NewMCPServer() *server.MCPServer {
weatherClient := NewWeatherAPIClient() // 天气API结构体
mcpServer := server.NewMCPServer( // 创建MCP服务器
"weather-query-server",
"1.0.0",
server.WithToolCapabilities(true),
server.WithLogging(),
)
mcpServer.AddTool( // 添加工具
mcp.NewTool(
"get_weather",
mcp.WithDescription("获取指定城市的天气信息"),
mcp.WithString(
"city",
mcp.Description("城市名称,如 Beijing、上海"),
mcp.Required(),
),
),
// 工具handler函数
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := request.GetArguments()
city, ok := args["city"].(string)
if !ok || city == "" {
return nil, fmt.Errorf("invalid city argument")
}
weather, err := weatherClient.GetWeather(ctx, city)
if err != nil {
return nil, err
}
resultText := fmt.Sprintf(
"城市: %s\n温度: %.1f°C\n天气: %s\n湿度: %d%%\n风速: %.1f km/h",
weather.Location,
weather.Temperature,
weather.Condition,
weather.Humidity,
weather.WindSpeed,
)
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: resultText,
},
},
}, nil
},
)
return mcpServer
}
// StartServer 启动MCP服务器
// httpAddr: HTTP服务器监听的地址(例如":8080")
func StartServer(httpAddr string) error {
mcpServer := NewMCPServer()
httpServer := server.NewStreamableHTTPServer(mcpServer)
log.Printf("HTTP MCP server listening on %s/mcp", httpAddr)
return httpServer.Start(httpAddr)
}
MCPClient
客户端结构体封装
// MCPClient 是MCP客户端的封装
// 它提供了一个类对象接口来与MCP服务器交互
type MCPClient struct {
c *client.Client
}
创建MCP客户端
// NewMCPClient 创建一个新的MCP客户端实例
// httpURL: HTTP传输的URL
func NewMCPClient(httpURL string) (*MCPClient, error) {
fmt.Println("正在初始化HTTP客户端...")
// 创建HTTP传输
httpTransport, err := transport.NewStreamableHTTP(httpURL)
if err != nil {
return nil, fmt.Errorf("创建HTTP传输失败: %w", err)
}
// 使用传输创建客户端
c := client.NewClient(httpTransport)
return &MCPClient{c: c}, nil
}
初始化客户端
// Initialize 初始化客户端
func (m *MCPClient) Initialize(ctx context.Context) (*mcp.InitializeResult, error) {
// 设置通知处理程序(回调函数)
m.c.OnNotification(func(notification mcp.JSONRPCNotification) {
fmt.Printf("收到通知: %s\n", notification.Method)
})
// 初始化客户端
fmt.Println("正在初始化客户端...")
initRequest := mcp.InitializeRequest{}
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initRequest.Params.ClientInfo = mcp.Implementation{
Name: "MCP-Go Weather Client",
Version: "1.0.0",
}
initRequest.Params.Capabilities = mcp.ClientCapabilities{}
serverInfo, err := m.c.Initialize(ctx, initRequest)
if err != nil {
return nil, fmt.Errorf("初始化失败: %w", err)
}
// 显示服务器信息
fmt.Printf("连接到服务器: %s (版本 %s)\n",
serverInfo.ServerInfo.Name,
serverInfo.ServerInfo.Version)
return serverInfo, nil
}
调用工具
// CallTool 调用MCP工具
func (m *MCPClient) CallTool(ctx context.Context, toolName string, args map[string]any) (*mcp.CallToolResult, error) {
callToolRequest := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: toolName,
Arguments: args,
},
}
result, err := m.c.CallTool(ctx, callToolRequest)
if err != nil {
return nil, fmt.Errorf("调用工具失败: %w", err)
}
return result, nil
}
// CallWeatherTool 调用get_weather工具
func (m *MCPClient) CallWeatherTool(ctx context.Context, city string) (*mcp.CallToolResult, error) {
fmt.Printf("正在查询城市 %s 的天气...\n", city)
callToolRequest := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "get_weather",
Arguments: map[string]any{
"city": city,
},
},
}
result, err := m.c.CallTool(ctx, callToolRequest)
if err != nil {
return nil, fmt.Errorf("调用工具失败: %w", err)
}
return result, nil
}
提取结果文本
// GetToolResultText 获取工具结果中的文本内容
func (m *MCPClient) GetToolResultText(result *mcp.CallToolResult) string {
var text string
for _, content := range result.Content {
if textContent, ok := content.(mcp.TextContent); ok {
text += textContent.Text + "\n"
}
}
return text
}
MCP main.go
这个main.go既可以当server又可以当client,取决于命令行传入的参数
func main() {
// 定义命令行标志
mode := flag.String("mode", "", "运行模式: server 或 client")
httpAddr := flag.String("http-addr", ":8081", "HTTP服务器地址")
city := flag.String("city", "", "要查询天气的城市名称")
flag.Parse()
if *mode == "" {
fmt.Println("Error: 您必须指定模式使用--mode (server 或 client)")
flag.Usage()
os.Exit(1)
}
if *mode == "server" {
// 启动服务器
fmt.Println("启动MCP服务器...")
if err := mcpserver.StartServer(*httpAddr); err != nil {
log.Fatalf("服务器错误: %v", err)
}
} else if *mode == "client" {
// 运行客户端
if *city == "" {
fmt.Println("Error: 您必须指定城市名称使用--city")
flag.Usage()
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 创建客户端
httpURL := "http://localhost:8081/mcp"
mcpClient, err := mcpclient.NewMCPClient(httpURL)
if err != nil {
log.Fatalf("创建客户端失败: %v", err)
}
defer mcpClient.Close()
// 初始化客户端
if _, err := mcpClient.Initialize(ctx); err != nil {
log.Fatalf("初始化失败: %v", err)
}
// 执行健康检查
if err := mcpClient.Ping(ctx); err != nil {
log.Fatalf("健康检查失败: %v", err)
}
// 调用天气工具
result, err := mcpClient.CallWeatherTool(ctx, *city)
if err != nil {
log.Fatalf("调用工具失败: %v", err)
}
// 显示天气结果
fmt.Println("\n天气查询结果:")
fmt.Println(mcpClient.GetToolResultText(result))
fmt.Println("\n客户端初始化成功。正在关闭...")
}
}
MCPModel实现
在common/aihelper/model.go中新增了AIModel接口的MCP实现,结构体封装了基本的Eino提供的chatmodel和一个上面写的MCP客户端以及用户名和MCP服务器地址(这里硬编码为localhost:8081)
// MCPModel MCP模型实现,集成MCP服务
type MCPModel struct {
llm model.ToolCallingChatModel
mcpClient *client.Client
username string
mcpBaseURL string
}
创建MCPModel,内部创建了chatmodel,设置了MCP服务器地址
// NewMCPModel 创建MCP模型实例
func NewMCPModel(ctx context.Context, username string) (*MCPModel, error) {
key := os.Getenv("OPENAI_API_KEY")
conf := config.GetConfig()
modelName := conf.RagModelConfig.RagChatModelName
baseURL := conf.RagModelConfig.RagBaseUrl
// 创建LLM
llm, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
BaseURL: baseURL,
Model: modelName,
APIKey: key,
})
if err != nil {
return nil, fmt.Errorf("create mcp model failed: %v", err)
}
mcpBaseURL := "http://localhost:8081/mcp"
return &MCPModel{
llm: llm,
mcpBaseURL: mcpBaseURL,
username: username,
}, nil
}
获取模型内部的MCP客户端
// getMCPClient 获取或创建MCP客户端
func (m *MCPModel) getMCPClient(ctx context.Context) (*client.Client, error) {
if m.mcpClient == nil {
// 创建MCP客户端
httpTransport, err := transport.NewStreamableHTTP(m.mcpBaseURL)
if err != nil {
return nil, fmt.Errorf("create mcp transport failed: %v", err)
}
m.mcpClient = client.NewClient(httpTransport)
// 初始化MCP客户端
initRequest := mcp.InitializeRequest{}
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initRequest.Params.ClientInfo = mcp.Implementation{
Name: "MCP-Go AIHelper Client",
Version: "1.0.0",
}
initRequest.Params.Capabilities = mcp.ClientCapabilities{}
if _, err := m.mcpClient.Initialize(ctx, initRequest); err != nil {
return nil, fmt.Errorf("mcp client initialize failed: %v", err)
}
}
return m.mcpClient, nil
}
要让AI懂得调用MCP工具,首先需要设计一套prompt
// buildFirstPrompt 构建第一次调用的提示词
func (m *MCPModel) buildFirstPrompt(query string) string {
return fmt.Sprintf(`你是一个智能助手,可以调用MCP工具来获取信息。
可用工具:
- get_weather: 获取指定城市的天气信息,参数: city(城市名称,支持中文和英文,如北京、Shanghai等)
重要规则:
1. 如果需要调用工具,必须严格返回以下JSON格式:
{
"isToolCall": true,
"toolName": "工具名称",
"args": {"参数名": "参数值"}
}
2. 如果不需要调用工具,直接返回自然语言回答
3. 请根据用户问题决定是否需要调用工具
用户问题: %s
请根据需要调用适当的工具,然后给出综合的回答。`, query)
}
// buildSecondPrompt 构建第二次调用的提示词
func (m *MCPModel) buildSecondPrompt(query, toolName string, args map[string]interface{}, toolResult string) string {
return fmt.Sprintf(`你是一个智能助手,可以调用MCP工具来获取信息。
工具执行结果:
工具名称: %s
工具参数: %v
工具结果: %s
用户问题: %s
请根据工具结果和用户问题,给出最终的综合回答。`, toolName, args, toolResult, query)
}
设计了一个AIToolCall结构体,用于表示是否需要工具调用,调用的工具名称,调用参数
// AIToolCall 表示AI工具调用请求
type AIToolCall struct {
IsToolCall bool `json:"isToolCall"`
ToolName string `json:"toolName"`
Args map[string]interface{} `json:"args"`
}
解析AI响应的函数,返回上面的AIToolCall结构体
// parseAIResponse 解析AI响应,检查是否包含工具调用
func (m *MCPModel) parseAIResponse(response string) (*AIToolCall, error) {
// 尝试解析为JSON
var toolCall AIToolCall
if err := json.Unmarshal([]byte(response), &toolCall); err == nil {
return &toolCall, nil
}
// 如果不是JSON,检查是否包含工具调用关键词
if strings.Contains(response, "get_weather") {
// 尝试提取城市名称
city := m.extractCityFromResponse(response)
if city != "" {
return &AIToolCall{
IsToolCall: true,
ToolName: "get_weather",
Args: map[string]interface{}{"city": city},
}, nil
}
}
// 不是工具调用
return &AIToolCall{IsToolCall: false}, nil
}
// extractCityFromResponse 从响应中提取城市名称
// 直接从AI返回的JSON中提取城市,不预留城市列表
func (m *MCPModel) extractCityFromResponse(response string) string {
// 尝试从JSON中提取城市
var toolCall AIToolCall
if err := json.Unmarshal([]byte(response), &toolCall); err == nil {
if args, ok := toolCall.Args["city"].(string); ok {
return args
}
}
// 如果JSON解析失败,尝试从文本中提取城市名称
// 这部分可以根据实际需要扩展,但不再预留固定城市列表
return ""
}
调用工具并返回结果
// callMCPTool 调用MCP工具
func (m *MCPModel) callMCPTool(ctx context.Context, client *client.Client, toolName string, args map[string]interface{}) (string, error) {
callToolRequest := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: toolName,
Arguments: args,
},
}
result, err := client.CallTool(ctx, callToolRequest)
if err != nil {
return "", fmt.Errorf("mcp tool call failed: %v", err)
}
// 提取工具结果文本
var text string
for _, content := range result.Content {
if textContent, ok := content.(mcp.TextContent); ok {
text += textContent.Text + "\n"
}
}
return text, nil
}
最后就是使用上面的众多工具函数写出的GenerateResponse接口函数,流程如下:
- 首先获取用户最后一条消息,封装成第一次调用工具的提示词格式,并创建更新后的schema切片
- 第一次调用AI,使用刚刚的schema切片,这样会告诉ai去判断是否需要调用工具,并封装成JSON格式返回
- 解析第一次AI调用的回复,拿出JSON
- 如果AI认为不需要使用工具,直接返回第一次生成的结果;如果AI认为需要,就帮AI调用,最后把结果封装进第二次AI调用的提示词里,再次调用AI并返回最终结果
// GenerateResponse 生成响应,集成MCP工具
func (m *MCPModel) GenerateResponse(ctx context.Context, messages []*schema.Message) (*schema.Message, error) {
if len(messages) == 0 {
return nil, fmt.Errorf("no messages provided")
}
// 获取最后一条消息
lastMessage := messages[len(messages)-1]
query := lastMessage.Content
// 第一次调用AI:告诉AI使用固定的JSON格式
firstPrompt := m.buildFirstPrompt(query)
firstMessages := make([]*schema.Message, len(messages))
copy(firstMessages, messages)
firstMessages[len(firstMessages)-1] = &schema.Message{
Role: schema.User,
Content: firstPrompt,
}
// 调用LLM生成第一次响应
firstResp, err := m.llm.Generate(ctx, firstMessages)
if err != nil {
return nil, fmt.Errorf("mcp first generate failed: %v", err)
}
log.Println("first resp is ", firstResp)
// 解析AI响应
aiResult := firstResp.Content
toolCall, err := m.parseAIResponse(aiResult)
if err != nil {
log.Printf("Failed to parse AI response: %v", err)
return firstResp, nil
}
// 情况1:AI不调用工具,直接返回响应
if !toolCall.IsToolCall {
log.Println("toolCall IsToolCall is false ", firstResp)
return firstResp, nil
}
log.Println("toolCall IsToolCall is true ", firstResp)
// 情况2:AI要调用工具
// 获取MCP客户端
mcpClient, err := m.getMCPClient(ctx)
if err != nil {
log.Printf("MCP client error: %v", err)
return firstResp, nil
}
// 调用MCP工具
toolResult, err := m.callMCPTool(ctx, mcpClient, toolCall.ToolName, toolCall.Args)
if err != nil {
log.Printf("MCP tool call failed: %v", err)
return firstResp, nil
}
// 第二次调用AI:将工具结果告诉AI
secondPrompt := m.buildSecondPrompt(query, toolCall.ToolName, toolCall.Args, toolResult)
secondMessages := make([]*schema.Message, len(messages))
copy(secondMessages, messages)
secondMessages[len(secondMessages)-1] = &schema.Message{
Role: schema.User,
Content: secondPrompt,
}
// 调用LLM生成最终响应
finalResp, err := m.llm.Generate(ctx, secondMessages)
if err != nil {
return nil, fmt.Errorf("mcp second generate failed: %v", err)
}
log.Println("最终响应为:", finalResp)
return finalResp, nil
}
Factory注册
// MCP 模型(集成MCP服务)
f.creators["3"] = func(ctx context.Context, config map[string]interface{}) (AIModel, error) {
username, ok := config["username"].(string)
if !ok {
return nil, fmt.Errorf("MCP model requires username")
}
return NewMCPModel(ctx, username)
} 

