ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LangChain4j接入MCP:Java Agent工具调用标准化实战

LangChain4j接入MCP:Java Agent工具调用标准化实战

最近在给内部一个 Java 写的助手系统接大模型工具调用,一开始大家各写各的 @Tool 方法,十几个服务接完之后维护成本直线上升。后来我把工具接入统一迁到 LangChain4j 的 MCP(Model Context Protocol)方案上,代码量不增反减,后续再加新工具基本不用动宿主工程。这篇文章把 LangChain4j 接 MCP 的完整路线写清楚:MCP 解决了什么、服务端怎么来、客户端怎么装配、一次真实调用怎么跑通、以及我实测中踩到的坑。作为 LangChain4j 系列里偏协议集成的一篇,内容会比较贴近实战,适合正在用 Java 做 LLM 应用,想把外部工具和数据源标准化接进 Agent 的开发者参考。

1. MCP 到底把 Java 接入工具的什么问题解决了

1.1 没有 MCP 之前的日子

在 LangChain4j 里,要让大语言模型调用你的业务能力,最常见的写法是给一个方法贴上 @Tool 注解:

@Tool("获取指定订单的状态") public String getOrderStatus(String orderId) { return orderService.queryStatus(orderId); }

然后把它塞给 AiServices:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new OrderToolService()) .build();

这个模式在单体 Demo 里很顺手,但做产品时痛点会一个一个冒出来。

第一个痛点是工具实现和宿主代码焊死了。这个工具方法写在哪个类里,就只能跟着那个类走,换个项目想复用,要么复制代码,要么抽公共包。第二个痛点是工具没有任何统一 schema。A 团队返回 JSON,B 团队返回纯字符串,参数命名、类型、必填规则全靠人肉对齐,模型调用错了也只能报错。第三个痛点是跨团队、跨语言基本没法谈共享。Java 服务里写好的工具,Python 团队要用,只能重新实现一遍。

这些痛点本质上不是 LangChain4j 的问题,而是整个行业当时缺一个“工具接入标准”。各家框架都有自己的 @Tool、@Function、ToolExecutor,接法千奇百怪,工具却没办法流转起来。

1.2 MCP 的协议层思路:一次定义,处处调用

MCP 做的事情,说穿了就是定义了一套标准协议,让 AI 应用和“工具提供方”之间用统一的方式对话。协议里有两个核心角色:

  • MCP Host:运行大模型应用的主程序,对 LangChain4j 来说就是咱们写的 Java 服务。
  • MCP Server:暴露能力的独立服务,可以是一个本地子进程,也可以是一个远程 HTTP 服务。

Host 通过一个 MCP Client 和 Server 通信,通信内容遵循 JSON-RPC 格式。Server 对外暴露三类原语:

原语作用
Tools(工具)可被模型调用的函数,包含名称、描述、JSON Schema 参数定义
Resources(资源)带 URI 的可读取数据,比如文件、日志、数据库记录
Prompts(提示词模板)可复用的 prompt 模板,服务端定义后直接给 Host 用

我用一个日常类比来理解这件事:早年每台电脑接打印机,都要装厂商专用驱动,换一台打印机就得重来。后来有了通用驱动协议,打印机随便换,驱动不用动。MCP 就是大模型应用界的通用驱动协议。工具提供方只要按协议实现一次 Server,所有支持 MCP 的 Host 都能用;Host 这边只要实现一个标准 Client,就能接所有协议兼容的工具。

这套思路对工具方极其友好:以前写一个工具要同时适配各种框架的注解和回调,现在只要把工具描述成协议里的 schema,剩下的交给 MCP 生态去适配。

1.3 LangChain4j 在 MCP 里的位置:ToolProvider 是关键

LangChain4j 能顺利接 MCP,核心在于它的ToolProvider机制。

传统方式里,模型能调什么工具,是在构建 Assistant 时用 .tools(...) 静态注册的,注册之后就固定下来。而 ToolProvider 是一个更灵活的动态工具源接口,它可以在模型发起调用时按需提供工具列表和工具执行逻辑。

langchain4j-mcp 这个模块做的事情,就是把一个 MCP Client 包装成 ToolProvider。一旦包装完成,MCP Server 里暴露的所有工具,在 LangChain4j 眼里就和本地 @Tool 方法平起平坐,模型该调用调用,该传参传参,完全感受不到工具运行在另一个进程里。

这个设计让 LangChain4j 站在了一个比较舒服的位置:底层协议走的是标准 MCP,上层模型交互走的是 AiServices 那套成熟抽象。工具即插即用,不需要为每一种 Server 单独写胶水代码。

2. 动手前先搞懂:依赖选型与 MCP Server 从哪来

2.1 依赖引哪几个,版本怎么对

LangChain4j 的 MCP 支持需要引入两个依赖:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-mcp</artifactId> <version>1.x(以你自己项目实际使用的版本为准)</version> </dependency> <dependency> <groupId>io.modelcontextprotocol</groupId> <artifactId>java-sdk</artifactId> <version>和 langchain4j-mcp 期望的版本对齐</version> </dependency>

第一个是 LangChain4j 的 MCP 适配模块。第二个是 MCP 官方 Java SDK,负责真正的协议通信、客户端构建、会话管理。langchain4j-mcp 不是从零实现 MCP,它是在官方 Java SDK 之上做了一层 ToolProvider 适配。

这里有个容易被忽略的点:这两个依赖的版本必须对齐。langchain4j-mcp 在某个版本里基于 java-sdk 的某个 API 编译,如果你单独把 java-sdk 升到更高版本,运行时很可能会出现 NoSuchMethodError。后面踩坑章节会细讲。

选版本的时候,我的建议是直接用你当前 LangChain4j 主版本配套的 MCP 模块版本,而不是追最新。MCP 协议本身还在演进,稳定比新功能重要。

2.2 快速准备一个能跑的 MCP Server

客户端代码写之前,得先有一个能连的 MCP Server。准备方式有三种,按“跑通速度”排序:

方式一:直接用 npx 拉现成示例包

MCP 官方提供了一批示例 Server,放在 npm 生态里。开发调试最省事的方式就是直接用 npx 命令拉起一个:

npx -y @modelcontextprotocol/server-everything

这条命令会在本地启动一个标准输入输出(stdio)模式的 MCP Server,暴露一大堆示例工具,拿来测试客户端接入非常合适。第一次执行时 npx 会先下载包,可能会等十几秒到几十秒,属于正常现象。

方式二:自己用 Java SDK 写一个小 Server

如果你不想依赖 Node 生态,也可以用 MCP 官方 Java SDK 写一个最简单的 Server。MCP Server 的本质就是按协议处理两类请求:tools/list 返回工具清单,tools/call 执行工具并返回结果。你只需要选择一种传输方式(stdio 或 HTTP),然后注册工具处理逻辑即可。自己写的好处是能完全控制工具行为,也方便加日志跟踪,但初期不建议一上来就折腾这个,先用现成包把链路跑通再说。

方式三:连一个远程 Streamable HTTP Server

MCP 还支持远程调用。很多团队会把内部工具部署成一个 HTTP 模式的 MCP Server,客户端只需要给一个 URL,就能像连数据库一样连上它:

String serverUrl = "https://your-mcp-server.example.com/mcp"; Mcp.Client httpClient = McpClient.builder(TransportType.HTTP, serverUrl) .build();

远程模式适合跨机器、跨语言共享工具,但前提是你能确认这个端点是可信的,且通信链路做了鉴权。

2.3 stdio 与 Streamable HTTP:本地调试和远程部署怎么选

MCP 传输方式里,最常用的是 stdio 和 Streamable HTTP。我做选型时的参考维度如下:

维度stdioStreamable HTTP
进程关系Server 作为子进程被 Host 拉起Server 是独立部署的 HTTP 服务
适用场景本地开发、单机部署、内网服务跨机器、跨团队、多应用共用
启动成本每次由客户端拉起,冷启动可能慢服务常驻,启动成本由服务方承担
运维复杂度依赖 Host 管理子进程生命周期需要额外部署、鉴权、限流
调试体验日志直接输出到宿主控制台,直观依赖服务端日志,往往要多跳一跳

我的习惯是:开发阶段一律用 stdio。调试的时候可以在 MCP Server 里打日志,日志直接跟着宿主进程走,排查问题极方便。等真正要上线给多个应用共享时,再考虑把高频工具部署成远程 HTTP 模式。

3. 核心装配:把 MCP 工具挂进 LangChain4j 的完整代码

3.1 创建 MCP 客户端:参数和初始化

第一步是创建 MCP 客户端。以 stdio 模式为例:

import io.modelcontextprotocol.spec.McpClient; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.TransportType; StdioServerParameters params = StdioServerParameters.builder() .command("npx") .args("-y", "@modelcontextprotocol/server-everything") .build(); Mcp.Client client = McpClient.builder(TransportType.STDIO, params) .requestTimeout(Duration.ofSeconds(30)) .build(); client.initialize();

这里有两件事值得注意。

第一,initialize() 必须显式调用。这一步完成 MCP 握手机制,客户端会发送 initialize 请求,Server 返回协议版本和服务能力信息。跳过这一步直接去拿工具列表,通常会因为状态不对而失败。

第二,requestTimeout 尽量调大一点。如果 Server 是 npx 拉起来的,第一次启动要现场下包,十秒都不一定够。默认超时往往扛不住这种冷启动场景,我直接给到 30 秒。

3.2 McpToolProvider 内部干了什么

客户端建好之后,需要用 LangChain4j 的工具提供器把它接进 AiServices:

McpToolProvider mcpToolProvider = McpToolProvider.builder() .mcpClients(List.of(client)) .build();

这句话背后的转换逻辑,拆开看是三件事:

  1. 拉取工具清单。McpToolProvider 构建之后,会向已连接的所有 MCP 客户端发 tools/list 请求,拿到 Server 暴露的每一个工具定义,包括名称、描述、参数 schema。
  2. 转换成 ToolSpecification。LangChain4j 模型层不关心 MCP 协议,它只认自己的 ToolSpecification。McpToolProvider 会把 MCP 的工具定义逐字段翻译过去。
  3. 代理工具调用。模型决定调用某个工具时,McpToolProvider 拦截请求,向 MCP Server 发 tools/call,拿到执行结果后再回传给模型。

这个机制决定了 MCP Server 新增一个工具,宿主工程几乎不需要改代码。再次构建 Assistant 时,新的工具定义会自动被拉取并暴露给模型。

3.3 一个能跑的最小 Java 工程

把上面的内容串起来,一个最小可跑的客户端接入长这样:

import dev.langchain4j.mcp.McpToolProvider; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.service.AiServices; import java.time.Duration; import java.util.List; interface Assistant { String chat(String userMessage); } public class McpDemo { public static void main(String[] args) { // 1. 构建 stdio 模式的 MCP 客户端 StdioServerParameters params = StdioServerParameters.builder() .command("npx") .args("-y", "@modelcontextprotocol/server-everything") .build(); Mcp.Client client = McpClient.builder(TransportType.STDIO, params) .requestTimeout(Duration.ofSeconds(30)) .build(); client.initialize(); // 2. 把 MCP 客户端包装成 LangChain4j 的 ToolProvider McpToolProvider mcpToolProvider = McpToolProvider.builder() .mcpClients(List.of(client)) .build(); // 3. 构造一个支持 function calling 的模型 // 这里用你实际使用的模型服务构造 ChatLanguageModel 即可 ChatLanguageModel model = ...; // 4. 装配 Assistant Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .toolProvider(mcpToolProvider) .build(); // 5. 和助手对话,模型会自动决定是否调用 MCP Server 上的工具 String answer = assistant.chat("帮我查一下某城市今天的天气"); System.out.println(answer); } }

需要注意,模型构造部分我没有写死具体实现。你可以用任何支持 function calling 的 ChatLanguageModel,不管是自部署模型还是云端接口,构造方式以你实际用的模型服务为准。只要模型支持工具调用,这段代码的装配逻辑完全通用。

3.4 多 Server 汇聚与生命周期管理

实际项目里,一个 Agent 往往需要挂多个 MCP Server。McpToolProvider 的 builder 支持传入多个客户端:

McpToolProvider mcpToolProvider = McpToolProvider.builder() .mcpClients(List.of(weatherClient, databaseClient, fileClient)) .build();

所有客户端暴露的工具会被合并成一个工具池,模型面对的是一个统一的工具集合。这样组织代码的好处是,按职责拆分 Server,宿主工程只做工具汇聚和编排。

生命周期方面,我的建议是:MCP Client 尽量做成单例,和应用同生命周期。每个 MCP Client 背后可能拉着一个子进程,如果业务请求每次进来都 new 一个 Client,服务跑半天就会堆出一堆孤儿进程。开发时把 Client 挂在服务启动阶段,退出时统一 close,能省掉大量排查进程泄漏的时间。

4. 从“模型想用工具”到“拿到真结果”:完整调用链路与验证

4.1 跑一次最小 Demo,先看现象

代码写完后,直接跑 main 方法,然后把日志级别打开:

<logger name="io.modelcontextprotocol" level="DEBUG"/>

让 MCP 协议层的日志输出到控制台。你会发现输入那句“帮我查一下某城市今天的天气”之后,整个调用过程是有明显阶段的。

我实测时看到的第一个现象是:模型并没有直接去“查天气”,而是先返回了一个 ToolCall。日志里能看到模型认为应该调用某个工具,并且带上了参数。真正执行工具的是框架和 MCP Server,模型本身只是表达了“我打算用这个工具”。这个认知很重要,很多人误以为模型会直接触发工具执行,实际上模型永远只负责决策,执行是框架干的事。

4.2 链路拆解:模型请求、工具调用、结果回填

一次完整的 MCP 工具调用,拆开看是四个阶段:

第一阶段:模型请求阶段。用户输入经过 AiServices 进入模型请求。模型看到系统里已经注册了一堆工具定义,会判断当前问题是否需要调用工具。如果需要,它就在响应的 toolCalls 部分返回一个调用意图,携带工具名和参数,但不会直接执行。

第二阶段:框架择路阶段。LangChain4j 收到模型返回的 ToolCall 后,会根据工具名去 ToolProvider 里找对应实现。因为 McpToolProvider 已经把所有 MCP 工具注册进去了,这里会直接命中并生成一次 MCP 协议调用。

第三阶段:协议执行阶段。MCP Client 向 Server 发送 tools/call 请求。Server 执行真实业务逻辑,比如查询数据库、调用第三方接口,然后把结构化结果返回。

第四阶段:结果回填阶段。LangChain4j 把 Server 返回的工具执行结果作为 ToolMessage 放回会话上下文,再次调用模型。这时模型掌握了工具的真实输出,会基于这个结果组织一段自然语言答复给你。

我理解这套链路时,脑子里始终绷着一根弦:MCP Server 和模型之间没有直接关系。模型只看到工具的名字、描述和参数 schema,至于工具在哪台机器上跑、用什么语言实现,模型完全感知不到。所有的转发、等待、结果回填,都是 LangChain4j 和 MCP 协议栈在工作。

4.3 MCP 工具和本地 @Tool 共存的一个写法

接完 MCP 之后再接本地工具,需要注意写法上的区分。MCP 工具通过 ToolProvider 动态提供,本地工具通过 .tools(...) 静态注册,两者可以同时指定:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .toolProvider(mcpToolProvider) .tools(new LocalTools()) .build();

我在项目里的经验是:外部通用能力走 MCP,内部私有逻辑走本地 @Tool。比如订单系统、用户中心这些内部服务,直接由领域服务提供方法即可;而天气查询、文件处理、外部 API 对接这类相对通用能力,放 MCP Server 里统一管理。

这种混排方式对代码组织很友好。本地 @Tool 方法可以自由使用 Spring 注入的 Bean,调试起来也直接;MCP 工具则天然具备跨系统复用能力。两者互补,而不是互相替代。

5. 跑通之后值得记下来的坑

5.1 版本错位导致 NoSuchMethodError

这是接入 MCP 最常见的运行时问题。现象是应用启动没问题,一发起工具调用就抛 NoSuchMethodError,报错信息指向某个 MCP 内部类的方法。

根因基本可以锁定在 langchain4j-mcp 和 java-sdk 版本不匹配。langchain4j-mcp 是围绕某个 java-sdk 版本编译的,你用 Maven 单独指定了一个更高的 java-sdk 版本,协议层 API 已经变了,运行时就容易踩到不存在的旧方法。

解决方式很简单:langchain4j-mcp 的 pom 里通常声明了它依赖的 java-sdk 版本,用 Maven 的依赖树检查一下,把 java-sdk 版本调到和它一致。或者干脆只在 pom 里写 langchain4j-mcp,让传递依赖自动决定 java-sdk 版本。

5.2 npx 冷启动把 initialize 拖超时

用 npx 拉 Server 时,第一次执行需要下载 npm 包,这个过程在网络一般的情况下可能超过一分钟。而 Client 默认的 requestTimeout 往往只有几秒到十几秒,initialize 阶段直接超时。

处理手段有几个,按优先级排列:

  1. 把 requestTimeout 拉大到 30 秒以上,至少保证首次冷启动能扛过去。
  2. 在正式跑程序之前,先手动执行一次npx -y @modelcontextprotocol/server-everything并立刻退出,让 npm 包缓存到本地。
  3. 如果 Server 用的包是你们团队自己的,可以提前打成可执行文件,或者用全局安装方式绕开 npx 的下载逻辑。

这个坑只出现在 stdio 模式的本地子进程场景。远程 HTTP 模式 Server 常驻,反而不存在冷启动问题。

5.3 工具挂上了,模型却“视而不见”

工具列表明明拉取到了,日志里也能看到 ToolProvider 返回了一堆工具定义,但模型回答问题时压根不调用。

这种情况我排查过多次,原因一般集中在三处:

一是工具名太抽象。MCP Server 里如果工具名长得像内部变量名,模型很难理解它的用途。工具名要短、要直白,描述里写清楚“什么时候该调用它”,不要在描述里堆修饰词。

二是模型本身不支持 function calling。工具调用能力不是所有模型都有,有些模型你给它塞一堆工具 schema,它直接忽略,当作普通聊天处理。开发期一定要先用具备 function calling 能力的模型验证,跑通之后再根据预算换模型。

三是挂载的工具太多。一个 Provider 汇聚了几十个工具,模型在计算时会有选择困难,也可能因为上下文空间被工具定义挤占而放弃调用。控制单次请求暴露的工具数量,比把所有工具一股脑交给模型更实际。

5.4 stdio 子进程泄漏与 close 管理

MCP stdio 模式的 Server 是宿主进程的子进程。如果宿主反复创建 Client 而不关闭,子进程会越积越多,最终出现“僵尸进程一堆,机器负载莫名其妙升高”的诡异现象。

正确做法是:

  • 复用 Client 单例,不要每次请求都构建。
  • 应用退出前关闭 Client,比如 Spring Boot 里注册一个 DisposableBean 或在 shutdown hook 中调用 client.close()。
  • 如果确实需要动态按需创建 Client,那就要成对管理,创建后放到一个会话池里,会话结束统一关。

MCP Server 是有真实资源开销的独立进程,不是线程池里一个任务那么简单。把它当成连接池资源来管理,问题就能避免大半。

5.5 远程 MCP Server 别乱接,权限要收敛

远程 HTTP 模式用起来很方便,但也容易把安全边界搞丢。MCP Server 暴露的工具对于模型来说都是可执行能力,如果 Server 里有删除数据、执行命令这类高危操作,而你又没有做权限限制,等于把高危能力直接交给了模型的随机决策。

我给自己定了几条规矩:

  • 只连可信内网的 MCP Server,不在生产环境直接连外部地址。
  • 工具权限遵循最小化原则,Server 只暴露当前业务真正需要的能力。
  • 敏感操作的工具,在 Server 侧加参数校验、操作确认或审计日志,而不是寄希望于模型“不会误调用”。

工具接入越顺畅,越要留意边界。毕竟协议本身不判断工具危险性,判断责任在接入方。

最后说说我现在的习惯。做 Java 侧的 LLM 应用时,凡是内部、私有、和业务强绑定的工具,我仍然直接用 @Tool 写在服务类里,调试直观,性能开销小。凡是需要跨系统复用、或者要开放给多个应用共享的能力,我统一挂到 MCP Server 上,配置文件里列 Server 清单,由 McpToolProvider 批量接进来。MCP 的 Resources 和 Prompts 在 LangChain4j 里的支持还在起步,等这几个原语稳定了,接数据资源和共享提示词还会有更多玩法。前期先用工具把链路跑通,收益就已经很明显了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进