2025年七月月 发布的文章

停止构建AI Agent!这里有5个更简单的LLM工作流模式,能解决90%的问题

本文永久链接 – https://tonybai.com/2025/07/10/stop-building-ai-agents

大家好,我是Tony Bai。

如果你正在开发 AI 应用,你很可能听说过、尝试过,甚至正在挣扎于构建一个“AI Agent”。

我们都看过那些令人心潮澎湃的 Demo:一个 AI Agent 被赋予一个目标,然后它就能自主地规划、调用工具、浏览网页、编写代码,最终完成任务。于是,我们纷纷投身其中,搭建记忆系统、定义工具、编写角色背景……感觉就像在创造一个真正的数字生命,充满了力量和进步感。

但现实往往是残酷的。正如资深 AI 教育者 Hugo Bowne-Anderson 在他那篇引爆讨论的文章《Stop Building AI Agents》中描述的,他曾用 CrewAI 构建了一个“研究小组”:三个 Agent、五个工具,纸面上完美,实践中一塌糊涂。

  • 研究员 Agent 忽略了 70% 的网页抓取工具。
  • 摘要员 Agent 在处理长文档时完全忘记了使用引用工具。
  • 协调员 Agent 在任务不明确时直接“撂挑子不干了”。

这是一个“美丽的计划,以壮观的方式分崩离析”。这个故事听起来熟悉吗?

Hugo 一针见血地指出:问题的根源,可能不是你的实现细节,而是你从一开始就选择去构建一个 Agent。

AI Agent 的真正“魔鬼”:失控的工作流

要理解为什么 Agent 如此脆弱,我们必须先弄清它的定义。一个 LLM 应用通常具备四个特性:
1. 记忆 (Memory): 让 LLM 记住过去的交互。
2. 信息检索 (Information Retrieval): 通过 RAG 等方式为 LLM 提供上下文。
3. 工具使用 (Tool Usage): 赋予 LLM 调用函数和 API 的能力。
4. 工作流控制 (Workflow Control): 让 LLM 的输出来决定下一步使用哪个工具以及何时使用。

这第四点,正是“Agent”的定义,也是问题的核心!

当我们构建一个 Agent 时,我们实际上是把系统的控制权交给了 LLM。我们希望它能像一个自主的决策者一样,动态地编排整个工作流程。

但这就像是让一个充满创造力、才华横溢但情绪不定的艺术家去担任整个交响乐团的指挥。他可能会即兴发挥出惊人的乐章,但更可能的是,他会忘记看乐谱,让整个演奏陷入混乱。

大多数 Agent 系统崩溃,不是因为功能太少,而是因为复杂度太高、控制权失控。

Hugo 用一张简单的决策图告诉我们,在绝大多数场景下,我们需要的根本不是 Agent。

那么,如果不是 Agent,我们应该构建什么?

你应该构建的 5 个 LLM 工作流模式

答案是:用更简单的、由你(开发者)的代码来控制流程的工作流模式。 下面这 5 个模式,源自 Anthropic 的研究,并由 Hugo 在实践中验证,足以解决 90% 的真实世界问题。

(1) 提示词链 (Prompt Chaining)

用例: 根据领英资料,撰写个性化的推广邮件。

这是一个典型的顺序任务。你先用一个 LLM 调用将非结构化的个人资料文本,转换为结构化的数据(姓名、公司、职位),然后再用第二个 LLM 调用,基于这些结构化数据和公司背景,生成一封定制邮件。

  • 适用场景: 任务有明确的先后顺序。
  • 失败模式: 链条中的任何一环失败,整个流程就会中断。
  • 优点: 流程可预测,简单,易于调试。

(2) 并行化 (Parallelization)

用例: 从一份简历中,同时提取多个部分的信息。

当你想一次性处理多个独立的子任务时,并行化是最佳选择。你可以定义多个并行的任务,如提取工作经历、提取技能列表、提取教育背景,然后让它们同时运行,最后汇总结果。

  • 适用场景: 多个独立任务可以并发执行以提高速度。
  • 失败模式: 可能出现竞态条件或超时问题。
  • 优点: 极大地提升数据抽取的效率。

(3) 路由 (Routing)

用例: 一个客户支持工具,根据用户问题类型分发到不同的处理流程。

路由模式就像一个智能交换机。你先用一个 LLM 或简单的逻辑来对输入进行分类(例如,这是“账单问题”还是“技术问题”),然后将请求“路由”到相应的专有处理函数或工作流中。控制权一旦交出,就不再收回。

  • 适用场景: 不同的输入需要完全不同的处理逻辑。
  • 失败模式: 边界情况可能无法匹配任何路由,需要有默认的“兜底”方案。
  • 优点: 结构清晰,逻辑解耦。

(4) 编排器-工作者 (Orchestrator-Worker)

用例: 一个需要将任务动态分解成多步的邮件生成器。

这看起来像路由,但有一个关键区别:控制权始终在“编排器”手中。编排器(可以是 LLM 或你的代码)负责做决策和协调,而“工作者”(通常是具体的函数)负责执行。例如,编排器先调用 LLM 将目标公司分类为“科技”或“非科技”,然后选择一个专门的“科技邮件工作者”或“非科技邮件工作者”来撰写邮件,并管理整个流程的始终。

  • 适用场景: 任务需要动态决策和受控的步骤执行。
  • 失败模式: 编排器错误地分解或委托了子任务。
  • 优点: 完美地将决策与执行分离,兼具灵活性和可控性。

(5) 评估器-优化器 (Evaluator-Optimizer)

用例: 优化一封营销邮件的语气和结构,以满足特定标准。

当你对输出质量有极高要求时,这个模式非常有用。一个“生成器”LLM 先生成初始内容,然后一个“评估器”LLM 对其进行打分。如果分数不达标,“评估器”会提供反馈,然后“生成器”根据反馈进行优化,如此循环,直到满足质量要求或达到重试上限。

  • 适用场景: 输出质量比速度更重要。
  • 失败模式: 可能陷入无限的优化循环。
  • 优点: 能持续打磨,产出高质量的结果。

那么,什么时候才真正需要 Agent?

读到这里,你可能会问,Agent 是否就一无是处?并非如此。Hugo 指出,Agent 在一类特定场景中表现出色:当有一个敏锐的人类在环中(Human-in-the-Loop)时。

  • 数据科学助手: Agent 探索性地写 SQL、生成图表,你来评估结果、修正逻辑。
  • 创意写作伙伴: Agent 负责头脑风暴、提供结构,你来判断质量、引导方向。
  • 代码重构助手: Agent 发现潜在模式、提出优化建议,你来审查、批准变更。

在这些场景中,Agent 是一个创造力的放大器,而非一个自主的工人。它适用于不稳定的、探索性的工作,而非需要稳定可靠的自动化流程。

小结:放弃对 Agent 的执念,回归简单

AI Agent 的概念被过度炒作和滥用。在大多数真实世界的应用中,我们并不需要一个拥有自主意识、能动态控制一切的复杂系统。

我们需要的,是更清晰、更简单、更可控的工作流结构。上述 5 种模式,为我们提供了强大的武器库。它们提醒我们软件工程的第一原则:从简单开始,逐步增加复杂性,并始终将控制权留在最可靠的地方——你自己的代码里。

所以,下一次当你准备构建下一个 LLM 应用时,请先停下来问自己:我真的需要一个 Agent 吗?还是一个简单的“提示词链”或“路由器”就足够了?

这个问题的答案,可能会为你节省下数周甚至数月的调试时间。

资料地址:https://decodingml.substack.com/p/stop-building-ai-agents


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

上手MCP官方Go SDK:一份面向实战的入门指南

本文永久链接 – https://tonybai.com/2025/07/10/mcp-official-go-sdk

大家好,我是Tony Bai。

随着大型语言模型(LLM)的能力边界不断扩展,“function calling”或“tool use”已成为释放其潜力的关键。MCP(Model Context Protocol)正是为此而生,它定义了一套标准的、与模型无关的通信规范,使得任何应用都能以“工具”的形式被 LLM 调用。

长期以来,mcp官方都没有发布go-sdk,Go社区也一直在使用像mark3labs/mcp-go这样的流行的第三方库。直到Google Go团队安排专人协助mcp组织进行了Go SDK的设计

7月初,该Go SDK正式以modelcontextprotocol/go-sdk仓库的形式对外开源发布,这是Go 语言在这一浪潮中的一个里程碑事件。它的意义远超一个普通的库:

  • 标准化与权威性:作为官方 SDK,它为 Go 开发者提供了与 MCP 规范紧密同步的、最权威的实现。这意味着更少的兼容性问题和更可靠的长期维护。
  • Go 语言哲学:该 SDK 的设计充满了 Go 的味道——简洁、高效、强类型和高并发。它鼓励开发者编写惯用的 Go 代码,而不是将其他语言的范式生搬硬套过来。
  • 生态系统的基石:官方 SDK 的出现,将极大地促进 Go AI 生态的繁荣。开发者可以基于这个稳定的基石,构建出更复杂、更健壮的上层应用、框架和平台。

简而言之,它不仅仅是一个工具,更是 Go 语言与 AI 模型世界之间的一座标准化桥梁。

MCP 服务架构:多种通信模式

MCP 协议设计了灵活的通信方式,以适应不同的部署场景。官方 Go SDK 对此提供了出色的支持。主要包括以下几种类型:

  • 标准输入/输出 (Stdio):这是最简单的模式,客户端通过启动一个子进程(MCP Server),并通过其 stdin 和 stdout 进行 JSON-RPC 通信。这种模式非常适合本地工具、CLI 插件或 Sidecar 模型的场景。我们将使用此模式构建基础工具服务文件系统服务

  • HTTP 流式传输 (Streamable HTTP):这是 MCP 规范中最新、最推荐的 HTTP 模式。它通过一系列的 GET 和 POST 请求实现了一个可恢复的、无状态的会话管理机制,非常适合构建可扩展、高可用的网络服务。我们将使用此模式构建多路复用 HTTP 服务

  • 服务器发送事件 (SSE):这是早期 MCP 规范中的一种 HTTP 模式,在社区版 SDK 中较为常见。官方 SDK 也提供了 SSEHandler 以支持这种模式,但新的 StreamableHTTPHandler 功能更强大,是未来的方向。

核心概念速览

尽管我们在此不深入探讨其完整的设计文档,但理解以下几个核心概念对于后续的实践至关重要:

  • Server:代表一个 MCP 服务实例。它本身是无状态的,是工具(Tools)、提示(Prompts)和资源(Resources)等能力的集合。
  • Client:代表一个 MCP 客户端。
  • Session:无论是 ServerSession 还是 ClientSession,它都代表一个已经建立的、具体的、有状态的连接。所有的交互都通过会话(Session)进行。
  • Transport:负责建立底层通信的抽象层。它定义了客户端和服务器如何交换 JSON-RPC 消息。

Server 定义了“能做什么”,而 Session 则是“正在与谁通信”的实例。这种解耦设计为构建灵活、可扩展的服务提供了基础。

实战:构建三种典型的 MCP 服务

现在,让我们动手构建几个实用的 MCP 服务,来体验官方 SDK 的强大功能。

场景一:基础工具服务 (Greeter)

这是最经典的“Hello, World”场景,通过 stdio 运行,用于展示如何定义一个简单的工具。

完整代码:greeter/main.go

// mcp-go-sdk/greeter/main.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

// HiParams 定义了工具的输入参数,强类型保证
type HiParams struct {
    Name string json:"name"
}

// SayHi 是工具的具体实现
func SayHi(ctx context.Context, _ *mcp.ServerSession, params *mcp.CallToolParamsFor[HiParams]) (*mcp.CallToolResultFor[any], error) {
    resultText := fmt.Sprintf("Hi %s, welcome to the Go MCP world!", params.Arguments.Name)
    return &mcp.CallToolResultFor[any]{
        Content: []mcp.Content{
            &mcp.TextContent{Text: resultText},
        },
    }, nil
}

func main() {
    // 1. 创建 Server 实例
    server := mcp.NewServer("greeter-server", "1.0.0", nil)

    // 2. 添加工具
    // NewServerTool 利用泛型和反射自动生成输入 schema
    server.AddTools(
        mcp.NewServerTool("greet", "Say hi to someone", SayHi),
    )

    // 3. 通过 StdioTransport 运行服务,它会监听标准输入/输出
    log.Println("Greeter server running over stdio...")
    if err := server.Run(context.Background(), mcp.NewStdioTransport()); err != nil {
        log.Fatalf("Server run failed: %v", err)
    }
}

在不依赖任何特殊客户端的情况下,我们可以通过管道向这个基于 stdio 的服务发送一系列原生的 JSON-RPC 消息,来模拟完整的客户端握手和工具调用流程。

步骤一:运行服务并发送请求序列

打开你的终端,执行以下命令。这行命令会使用 printf 来确保每个 JSON 对象都以换行符分隔,模拟一个完整的会话流程:
1. 发送 initialize 请求,启动会话。
2. 发送 initialized 通知,确认会话建立。
3. 发送 tools/call 请求,调用 greet 工具。

在greeter目录下执行下面命令:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"test-cli","version":"0.1"},"protocolVersion":"2025-03-26"}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"Go MCP Enthusiast"}}}' \
  | go run main.go

预期输出:

服务会处理这三个消息,并对两个有 ID 的请求(initialize 和 tools/call)作出响应。你将看到两个 JSON-RPC 响应对象被打印到标准输出(顺序可能会因并发处理而不同,但内容是固定的):

2025/07/08 17:05:46 Greeter server running over stdio...
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"listChanged":true},"tools":{"listChanged":true}},"protocolVersion":"2025-03-26","serverInfo":{"name":"greeter-server","version":"1.0.0"}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Hi Go MCP Enthusiast, welcome to the Go MCP world!"}]}}

看到这两个响应,证明我们的 Greeter 服务已经成功地完成了握手并正确响应了工具调用。

场景二:文件系统服务 (File System Server)

这个场景也通过 stdio 运行,展示了如何通过 Resource 机制,安全地向 LLM 暴露本地文件系统的读写能力。

// fileserver/main.go

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "path/filepath"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

func main() {
    server := mcp.NewServer("filesystem-server", "1.0.0", nil)

    pwd, err := os.Getwd()
    if err != nil {
        log.Fatalf("Failed to get current directory: %v", err)
    }
    log.Printf("File server serving from directory: %s", pwd)

    // 使用我们自己实现的 File Handler
    handler := createFileHandler(pwd)

    // 添加一个虚构的资源,用于列出目录内容
    server.AddResources(&mcp.ServerResource{
        Resource: &mcp.Resource{
            URI:         "mcp://fs/list",
            Name:        "list_files",
            Description: "List all non-directory files in the current directory.",
        },
        Handler: listDirectoryHandler(pwd),
    })

    // 添加一个资源模板,用于读取指定的文件
    server.AddResourceTemplates(&mcp.ServerResourceTemplate{
        ResourceTemplate: &mcp.ResourceTemplate{
            Name:        "read_file",
            URITemplate: "file:///{+filename}",
            Description: "Read a specific file from the directory. 'filename' is the relative path to the file.",
        },
        Handler: handler,
    })

    log.Println("File system server running over stdio...")
    if err := server.Run(context.Background(), mcp.NewStdioTransport()); err != nil {
        log.Fatalf("Server run failed: %v", err)
    }
}

// createFileHandler 是一个简化的、用于演示的 ResourceHandler 工厂函数。
func createFileHandler(baseDir string) mcp.ResourceHandler {
    return func(ctx context.Context, ss *mcp.ServerSession, params *mcp.ReadResourceParams) (*mcp.ReadResourceResult, error) {
        // 注意:在生产环境中,这里必须调用 ss.ListRoots() 来获取客户端授权的
        // 根目录,并进行严格的安全检查。
        // 为了让这个入门示例能用简单的管道命令验证,我们暂时省略了这个双向调用。
        requestedPath := filepath.Join(baseDir, filepath.FromSlash(params.URI[len("file:///"):]))

        data, err := os.ReadFile(requestedPath)
        if err != nil {
            if os.IsNotExist(err) {
                return nil, mcp.ResourceNotFoundError(params.URI)
            }
            return nil, fmt.Errorf("failed to read file: %w", err)
        }

        return &mcp.ReadResourceResult{
            Contents: []*mcp.ResourceContents{
                {URI: params.URI, MIMEType: "text/plain", Text: string(data)},
            },
        }, nil
    }
}

// listDirectoryHandler 是一个自定义的 ResourceHandler,用于实现列出目录的功能
func listDirectoryHandler(dir string) mcp.ResourceHandler {
    return func(ctx context.Context, ss *mcp.ServerSession, params *mcp.ReadResourceParams) (*mcp.ReadResourceResult, error) {
        // 同样,为简化本地验证,暂时省略对 ss.ListRoots() 的调用。

        entries, err := os.ReadDir(dir)
        if err != nil {
            return nil, fmt.Errorf("failed to read directory: %w", err)
        }

        var fileList string
        for _, e := range entries {
            if !e.IsDir() {
                fileList += e.Name() + "\n"
            }
        }
        if fileList == "" {
            fileList = "(The directory is empty or contains no files)"
        }

        return &mcp.ReadResourceResult{
            Contents: []*mcp.ResourceContents{
                {URI: params.URI, MIMEType: "text/plain", Text: fileList},
            },
        }, nil
    }
}

文件服务同样需要完整的握手流程。我们将用与上面类似的方式来验证其功能。

步骤一:准备测试文件

首先,在你的项目根目录下创建一个简单的文本文件。

echo "Hello from the File System MCP Server!" > my-test-file.txt

步骤二:验证“列出文件”功能

我们发送包含 initialize、initialized 和 resources/read 的请求序列。

在fileserver下执行下面命令:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"test-cli","version":"0.1"},"protocolVersion":"2025-03-26"}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \
  '{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"mcp://fs/list"}}' \
  | go run main.go

预期输出:

你将看到 initialize 的响应,以及 resources/read 的响应,后者包含了目录文件列表。

2025/07/08 18:13:47 File system server running over stdio...
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"listChanged":true},"tools":{"listChanged":true}},"protocolVersion":"2025-03-26","serverInfo":{"name":"filesystem-server","version":"1.0.0"}}}
{"jsonrpc":"2.0","id":2,"result":{"contents":[{"uri":"mcp://fs/list","mimeType":"text/plain","text":"go.mod\ngo.sum\nmain.go\nmy-test-file.txt\n"}]}}

步骤三:验证“读取文件”功能

现在,我们发送请求序列来读取 my-test-file.txt 的内容。

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"test-cli","version":"0.1"},"protocolVersion":"2025-03-26"}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \
  '{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"file:///my-test-file.txt"}}' \
  | go run main.go

预期输出:

除了 initialize 的响应外,你将看到包含文件内容的 resources/read 响应。

2025/07/08 18:15:12 File server serving from directory: /Users/tonybai/go/src/github.com/bigwhite/experiments/mcp-go-sdk/fileserver
2025/07/08 18:15:12 File system server running over stdio...
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"listChanged":true},"tools":{"listChanged":true}},"protocolVersion":"2025-03-26","serverInfo":{"name":"filesystem-server","version":"1.0.0"}}}
{"jsonrpc":"2.0","id":3,"result":{"contents":[{"uri":"file:///my-test-file.txt","mimeType":"text/plain","text":"Hello from the File System MCP Server\n"}]}}

步骤四:清理

测试完成后,可以删除测试文件。

rm my-test-file.txt

场景三:多路复用 HTTP 服务 (Multi-Service HTTP Server)

这个场景展示了如何使用 StreamableHTTPHandler 在单个 HTTP 端点上提供多个不同的 MCP 服务。

完整代码:httpserver/main.go

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

// HiParams 和 SayHi 函数与场景一相同
type HiParams struct{ Name string json:"name" }
func SayHi(ctx context.Context, _ *mcp.ServerSession, params *mcp.CallToolParamsFor[HiParams]) (*mcp.CallToolResultFor[any], error) {
    resultText := fmt.Sprintf("Hi %s, this response is from the HTTP server!", params.Arguments.Name)
    return &mcp.CallToolResultFor[any]{
        Content: []mcp.Content{&mcp.TextContent{Text: resultText}},
    }, nil
}

// AddParams 和 Add 工具的实现
type AddParams struct{ A, B int }
func Add(_ context.Context, _ *mcp.ServerSession, params *mcp.CallToolParamsFor[AddParams]) (*mcp.CallToolResultFor[any], error) {
    result := params.Arguments.A + params.Arguments.B
    return &mcp.CallToolResultFor[any]{
        Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("The sum is: %d", result)}},
    }, nil
}

func main() {
    // 1. 创建 Greeter 服务实例
    greeterServer := mcp.NewServer("greeter-service", "1.0", nil)
    greeterServer.AddTools(mcp.NewServerTool("greet", "Say hi", SayHi))

    // 2. 创建 Math 服务实例
    mathServer := mcp.NewServer("math-service", "1.0", nil)
    mathServer.AddTools(mcp.NewServerTool("add", "Add two integers", Add))

    // 3. 创建 StreamableHTTPHandler
    handler := mcp.NewStreamableHTTPHandler(func(request *http.Request) *mcp.Server {
        log.Printf("Routing request for URL: %s\n", request.URL.Path)
        switch request.URL.Path {
        case "/greeter":
            return greeterServer
        case "/math":
            return mathServer
        default:
            return nil // 返回 nil 将导致 404 Not Found
        }
    }, nil)

    // 4. 启动标准的 Go HTTP 服务器
    addr := ":8080"
    log.Printf("Multi-service MCP server listening at http://localhost%s\n", addr)
    if err := http.ListenAndServe(addr, handler); err != nil {
        log.Fatalf("HTTP server failed: %v", err)
    }
}

与基于 stdio 的简单服务不同,验证 Streamable HTTP 服务使用 curl 等工具会非常繁琐。这是因为 MCP 是一个有状态的协议,要求客户端在发送工具调用之前,必须先完成一个包含 initialize 请求和 initialized 通知的多步“握手”流程来建立会话。

一个简单的 curl 命令无法管理这种有状态的交互。因此,最理想的验证方式是使用一个真正的 MCP 客户端。我们将在下一节构建这样一个客户端——agent,然后用集成了大模型的它来统一验证我们创建的所有三个服务,包括这个 HTTP 服务。

集成大模型:让 Go Agent 直接成为 MCP 客户端

在前面的章节中,我们成功构建了三种不同类型的 MCP 服务。现在,是时候将它们与 AI 大模型(以 DeepSeek 为例)集成,构建一个能够调度这些mcp server工具的智能 Agent 了。

一个常见的思路可能是创建一个通用的命令行工具(CLI)来调用这些服务,然后让我们的 Go Agent 程序去执行这个 CLI。然而,既然我们的 Agent 本身就是用 Go 编写的,一个更优雅、更高效、更符合 Go 语言习惯的方式是:让 Agent 程序直接导入 modelcontextprotocol/go-sdk,将自己作为原生的 MCP 客户端来与服务通信。

这种方法避免了不必要的进程开销和数据序列化,使得整个系统更加内聚和高性能。接下来,我们将编写这样一个 Go Agent。

Part 1: 编写 Go Agent 程序

这个程序将承担所有角色:它既是与 DeepSeek 模型对话的主循环,也是调用我们 MCP 服务的客户端。

准备工作

  1. 安装 OpenAI Go SDK:go get github.com/openai/openai-go
  2. 获取 DeepSeek API Key,并设置为环境变量:export DEEPSEEK_API_KEY=”your-api-key”

完整代码:agent/main.go

// agent/main.go

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "os"
    "os/exec"
    "strings"

    "github.com/modelcontextprotocol/go-sdk/mcp"
    "github.com/openai/openai-go"
    "github.com/openai/openai-go/option"
)

// serverConfig 结构体用于管理不同 MCP 服务的连接信息
type serverConfig struct {
    ServerCmd string // 用于 stdio 服务
    HTTPAddr  string // 用于 http 服务
}

// toolRegistry 映射对 LLM 友好的工具别名到其服务配置
var toolRegistry = map[string]serverConfig{
    "greet":      {ServerCmd: "go run ../greeter/main.go"},
    "add":        {HTTPAddr: "http://localhost:8080/math"},
    "list_files": {ServerCmd: "go run ../fileserver/main.go"},
    "read_file":  {ServerCmd: "go run ../fileserver/main.go"},
}

// invokeMCPTool 是 Agent 的核心函数,负责直接与 MCP 服务通信
func invokeMCPTool(toolAlias string, arguments map[string]interface{}) (string, error) {
    config, ok := toolRegistry[toolAlias]
    if !ok {
        return "", fmt.Errorf("unknown tool alias: %s", toolAlias)
    }

    // 1. 将 LLM 友好的别名和参数,转换为真正的 MCP 请求
    mcpToolName := toolAlias
    mcpArguments := arguments
    if toolAlias == "list_files" {
        mcpToolName = "resources/read"
        mcpArguments = map[string]interface{}{"uri": "mcp://fs/list"}
    } else if toolAlias == "read_file" {
        mcpToolName = "resources/read"
        if filename, ok := arguments["filename"].(string); ok {
            mcpArguments = map[string]interface{}{"uri": "file:///" + filename}
        } else {
            return "", fmt.Errorf("tool 'read_file' requires a 'filename' argument")
        }
    }

    // 2. 创建 MCP 客户端实例
    client := mcp.NewClient("go-agent", "1.0", nil)

    // 3. 根据配置选择并创建 Transport
    var transport mcp.Transport
    if config.ServerCmd != "" {
        cmdParts := strings.Fields(config.ServerCmd)
        transport = mcp.NewCommandTransport(exec.Command(cmdParts[0], cmdParts[1:]...))
    } else {
        transport = mcp.NewStreamableClientTransport(config.HTTPAddr, nil)
    }

    // 4. 授权客户端访问本地文件系统(仅对文件服务调用有效)
    client.AddRoots(&mcp.Root{URI: "file://./"})

    // 5. 连接到服务器,建立会话
    ctx := context.Background()
    session, err := client.Connect(ctx, transport)
    if err != nil {
        return "", fmt.Errorf("failed to connect to MCP server for tool %s: %w", toolAlias, err)
    }
    defer session.Close() // 每次调用都是一个独立的会话,确保关闭

    // 6. 执行调用并处理结果
    var resultText string
    if mcpToolName == "resources/read" {
        res, err := session.ReadResource(ctx, &mcp.ReadResourceParams{
            URI: mcpArguments["uri"].(string),
        })
        if err != nil {
            return "", fmt.Errorf("ReadResource failed: %w", err)
        }
        var sb strings.Builder
        for _, c := range res.Contents {
            sb.WriteString(c.Text)
        }
        resultText = sb.String()
    } else {
        res, err := session.CallTool(ctx, &mcp.CallToolParams{
            Name:      mcpToolName,
            Arguments: mcpArguments,
        })
        if err != nil {
            return "", fmt.Errorf("CallTool failed: %w", err)
        }
        if res.IsError {
            return "", fmt.Errorf("tool execution failed: %s", res.Content[0].(*mcp.TextContent).Text)
        }
        resultText = res.Content[0].(*mcp.TextContent).Text
    }

    return resultText, nil
}

func main() {
    apiKey := os.Getenv("DEEPSEEK_API_KEY")
    if apiKey == "" {
        log.Fatal("DEEPSEEK_API_KEY environment variable not set.")
    }

    client := openai.NewClient(
        option.WithAPIKey(apiKey),
        option.WithBaseURL("https://api.deepseek.com/v1"),
    )

    // 为所有工具使用合法的名称,特别是为 resources/read 创建别名
    tools := []openai.ChatCompletionToolParam{
        {
            Function: openai.FunctionDefinitionParam{
                Name:        "greet",
                Description: openai.String("Say hi to someone."),
                Parameters: openai.FunctionParameters{
                    "type": "object", "properties": map[string]interface{}{"name": map[string]string{"type": "string", "description": "Name of the person to greet"}}, "required": []string{"name"},
                },
            },
        },
        {
            Function: openai.FunctionDefinitionParam{
                Name:        "add",
                Description: openai.String("Add two integers."),
                Parameters: openai.FunctionParameters{
                    "type": "object", "properties": map[string]interface{}{"A": map[string]string{"type": "integer"}, "B": map[string]string{"type": "integer"}}, "required": []string{"A", "B"},
                },
            },
        },
        {
            Function: openai.FunctionDefinitionParam{
                Name:        "list_files",
                Description: openai.String("List all non-directory files in the current project directory."),
                Parameters:  openai.FunctionParameters{"type": "object", "properties": map[string]interface{}{}},
            },
        },
        {
            Function: openai.FunctionDefinitionParam{
                Name:        "read_file",
                Description: openai.String("Read the content of a specific file."),
                Parameters: openai.FunctionParameters{
                    "type": "object", "properties": map[string]interface{}{"filename": map[string]string{"type": "string", "description": "The name of the file to read."}}, "required": []string{"filename"},
                },
            },
        },
    }

    messages := []openai.ChatCompletionMessageParamUnion{
        openai.SystemMessage("You are a helpful assistant with access to local tools. You must call tools by using the tool_calls response format. Don't make assumptions about what values to plug into functions. Ask for clarification if a user request is ambiguous."),
        openai.UserMessage("Hi, can you greet my friend Alex, add 5 and 7, and then list the files in my project?"),
    }

    ctx := context.Background()

    for i := 0; i < 5; i++ {
        log.Println("--- Sending request to DeepSeek ---")

        resp, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{Model: "deepseek-chat", Messages: messages, Tools: tools})
        if err != nil {
            log.Fatalf("ChatCompletion error: %v\n", err)
        }
        if len(resp.Choices) == 0 {
            log.Fatal("No choices returned from API")
        }

        msg := resp.Choices[0].Message
        messages = append(messages, msg.ToParam())

        if msg.ToolCalls != nil {
            for _, toolCall := range msg.ToolCalls {
                functionName := toolCall.Function.Name
                var arguments map[string]interface{}
                if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &arguments); err != nil {
                    log.Fatalf("Failed to unmarshal function arguments: %v", err)
                }

                log.Printf("--- LLM wants to call tool: %s with args: %v ---\n", functionName, arguments)

                // 直接调用我们的 Go 函数,该函数内建了 MCP 客户端逻辑
                toolResult, err := invokeMCPTool(functionName, arguments)
                if err != nil {
                    log.Printf("Tool call failed: %v\n", err)
                    toolResult = fmt.Sprintf("Error executing tool: %v", err)
                }

                log.Printf("--- Tool result: ---\n%s\n---------------------\n", toolResult)

                messages = append(messages, openai.ToolMessage(toolResult, toolCall.ID))
            }
            continue
        }

        log.Println("--- Final response from LLM ---")
        log.Println(msg.Content)
        return
    }
    log.Println("Reached max conversation turns.")
}

注:上述代码使用了OpenAI的function calling api,不过即便不用function calling api,通过prompt依然可以实现mcp server接口的调用(需要自行解析response),大家可以自行实现一下。

Part 2: 集成验证

现在,我们的 agent 程序已经是一个功能齐全的、内建了 MCP 客户端的智能体。让我们来验证它的工作流程。

  1. 启动 httpserver
    agent 会通过 HTTP 调用 math 服务,所以我们必须先在后台运行它。

    go run ./httpserver/main.go & HTTP_PID=$!
    
  2. 创建测试文件
    为文件服务准备一个可供读取的文件。

    echo "This file will be read by our Go AI Agent." > agent-test.txt
    
  3. 运行 agent 程序
    确保你的 DEEPSEEK_API_KEY 已经设置。

    go run ./agent/main.go
    

预期的输出流程:

你的终端将清晰地展示 AI Agent 的思考和行动链。它直接在内部与各个 MCP 服务进行高效的 Go-to-Go通信。

$DEEPSEEK_API_KEY=<your_deepseek_api_key> go run main.go
2025/07/08 19:17:42 --- Sending request to DeepSeek ---
2025/07/08 19:17:53 --- LLM wants to call tool: greet with args: map[name:Alex] ---
2025/07/08 19:17:53 --- Tool result: ---
Hi Alex, welcome to the Go MCP world!
---------------------
2025/07/08 19:17:53 --- LLM wants to call tool: add with args: map[A:5 B:7] ---
2025/07/08 19:17:53 --- Tool result: ---
The sum is: 12
---------------------
2025/07/08 19:17:53 --- LLM wants to call tool: list_files with args: map[] ---
2025/07/08 19:17:53 --- Tool result: ---
go.mod
go.sum
main.go

---------------------
2025/07/08 19:17:53 --- Sending request to DeepSeek ---
2025/07/08 19:18:07 --- Final response from LLM ---
2025/07/08 19:18:07 Here's what you asked for:

1. **Greeting for Alex**: Hi Alex, welcome to the Go MCP world!
2. **Addition of 5 and 7**: The sum is 12.
3. **Files in your project**:
   - go.mod
   - go.sum
   - main.go

Let me know if you'd like to do anything else!

最后,做一下清理工作:

kill $HTTP_PID
rm agent-test.txt

小结

通过本次从零到一的实践,我们不仅学习了如何使用 modelcontextprotocol/go-sdk 构建支持不同通信协议的 MCP 服务,更重要的是,我们探索并实现了将 Go Agent 程序直接作为原生 MCP 客户端的实践。

这种直接通过库调用的内聚架构,相比于通过外部 CLI 工具进行解耦的方式,充分发挥了 Go 语言的优势:

  • 高性能:避免了不必要的进程创建和数据序列化开销,使得工具调用和响应链条更短、更高效。
  • 强类型与健壮性:整个调用链路都在 Go 的类型系统内完成,错误处理清晰,代码更易于维护和调试。
  • 简洁的工程实现:它展示了一种更加优雅和符合 Go 语言习惯的工程模式,让 AI Agent 的构建过程如同编写任何一个普通的 Go 应用一样自然。

modelcontextprotocol/go-sdk 不仅仅是一个协议的实现,它更像一个宣言:Go 语言凭借其出色的并发模型、强大的类型系统和简洁的工程哲学,完全有能力成为构建下一代高性能、高可靠性 AI Agent 和工具化应用的首选后端语言。

虽然官方 SDK 仍在快速迭代中,但其展现出的潜力和清晰的设计哲学已经足够令人振奋。我们鼓励所有对 Go 和 AI 结合感兴趣的开发者,立即上手体验。这个 SDK 无疑将成为连接你的 Go 程序与广阔智能模型世界之间最坚固、最标准的桥梁。

本文涉及源码可以在这里下载。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats