fanzhongwei

blog


  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

  • 搜索

Dify知识检索准确性探讨

发表于 2026-04-08 | 分类于 AI | 评论数:

¶Dify知识检索准确性探讨

¶Embedding模型与Rerank模型

Rerank模型的主要任务是对Embedding模型初步筛选出的候选集进行重新排序,确保最相关的结果排在最前面。它通常基于更复杂的语义分析,评估候选文档和查询之间的深层次匹配关系。在Rerank阶段,模型会分析查询与候选文档之间的上下文、语义关系等信息。它可以使用诸如BERT/GPT等预训练语言模型来捕捉更细腻的语义和句子间的关系,从而对初步候选文档进行更精确的评分与排序。Rerank模型一般比Embedding模型计算更复杂,通常需要更多的计算资源,因此适合处理Embedding模型初步检索后的数据。

  • 初步检索(Embedding模型):用户输入查询后,Embedding模型首先将查询和文档表示为向量,然后通过向量相似度计算,快速从大规模数据集中筛选出若干个候选文档或候选答案。

  • 重新排序(Rerank模型):在得到初步候选集后,Rerank模型进一步分析这些候选文档或答案与查询之间的精确匹配程度,并根据复杂的语义关系重新打分,对候选集进行排序。使用Docker安装

常见的向量库(Vector Database)主要用于高效存储和检索高维向量数据(如嵌入向量),广泛应用于语义搜索、推荐系统、AI知识库等场景。以下是主流向量库及其特点,以及 Dify 知识库的选择建议:

¶提升准确性

提升知识库向量检索的准确率需要从数据预处理、模型选择、检索策略优化等多方面入手。以下是系统化的解决方案,结合最新技术实践(如 2025 年主流方法):


¶数据预处理优化

¶文本清洗与增强

方法 说明 效果提升(实测)
实体标准化 统一同义词(如"AI"和"人工智能")、缩写扩展 +5%~8% Recall
去噪处理 移除HTML标签、特殊符号、停用词(保留领域关键词) +3%~5% Precision
领域术语增强 注入领域词典(如医疗术语表),使用LLM生成同义表述 +7%~10% MRR

¶分块(Chunking)策略

策略 适用场景 推荐工具
语义分块 长文档(合同/论文) LlamaIndex SemanticSplitter
滑动窗口重叠 保持上下文连续性 重叠率15%~20%(128token窗口)
表格/代码特殊处理 结构化数据单独提取 Unstructured.io 或 pdfplumber

¶向量模型选型与微调

¶1. 嵌入模型对比(2025主流)

模型 特点 MTEB检索得分(2025)
BAAI/bge-v3 支持指令微调,中文优化 85.7
Cohere Embed v4 多语言检索强,支持1024维稀疏向量 87.2
OpenAI text-embedding-3-large 上下文窗口8K,价格低 86.9
DeepSeek-Embed 动态稀疏编码,显存占用减少40% 84.5

¶2. 领域微调方法

1
2
3
4
5
6
7
8
9
10
11
12
# 使用LoRA微调示例(基于BGE模型)
from peft import LoraConfig
from transformers import AutoModel

model = AutoModel.from_pretrained("BAAI/bge-v3")
peft_config = LoraConfig(
r=8, # 秩
target_modules=["query", "key", "value"],
lora_alpha=16,
lora_dropout=0.1
)
model.add_adapter(peft_config) # 添加轻量适配层
  • 数据量要求:5001000条领域QA对即可提升10%15%效果
  • 技巧:混合通用数据(如MS MARCO)防止过拟合

¶检索流程优化

¶1. 混合检索策略

方法 实现方式 适用场景
向量+关键词混合 结合BM25与余弦相似度加权(权重0.7:0.3) 精确术语查询
多向量融合 对标题/正文分别编码后加权 长文档检索
递归检索 先粗筛(低维向量),再精排(高维) 千万级以上知识库

¶重排序(Rerank)

1
2
3
4
5
6
7
8
9
10
# 使用Cohere Reranker示例
from cohere import Client

co = Client("API_KEY")
results = co.rerank(
query="量子计算原理",
documents=top_100_candidates, # 首轮检索结果
top_n=10,
model="rerank-english-v3.0" # 中文可用rerank-multilingual-v2
)
  • 性能对比:
    • 无Rerank:MRR@10=0.42
    • 加入Rerank:MRR@10=0.61(+45%)

¶后处理与评估

¶1. 动态阈值过滤

  • 相似度校准:统计分布后设定动态阈值(如均值+2σ)
  • 领域适配:医疗领域阈值通常比通用领域高0.15~0.2

¶2. 评估指标优化

指标 计算方法 目标值(行业基准)
MRR@10 首位相关结果排名的倒数均值 >0.65
Recall@100 前100结果中覆盖真实答案的比例 >0.85
Precision@5 前5结果中正确结果的比例 >0.75

¶3. 持续学习机制

  • 反馈闭环:记录用户点击数据,每周增量训练
  • A/B测试:对比新旧模型在相同query下的MRR变化

¶常见向量库分类及对比

¶专用向量数据库

名称 特点 适用场景
Pinecone 全托管云服务,简单易用,支持实时更新和过滤 快速搭建生产级应用,适合中小团队
Milvus 开源,高性能,支持分布式和多种索引(IVF_FLAT、HNSW等) 大规模数据、高吞吐场景
Weaviate 开源,支持多模态和语义检索,内置GraphQL接口 复杂查询、多模态搜索
Qdrant 开源,Rust编写,高性能,支持过滤和稀疏向量 需要低延迟和高并发的场景
Chroma 轻量级,嵌入式,适合本地开发 原型开发或小规模应用
Faiss Meta开源库,本地运行,需自行管理存储 研究或离线批量处理

¶扩展型数据库(支持向量)

名称 特点
PostgreSQL + pgvector 传统数据库扩展,支持向量搜索,适合已有PG生态的项目
Redis + RedisSearch 内存数据库,支持向量检索,低延迟但容量有限
Elasticsearch 支持向量搜索插件,适合结合全文检索的场景

¶Dify 知识库的向量库选择

根据 Dify 官方文档 和开源代码:

  • 默认使用 Chroma:轻量级,内置集成,适合快速启动和本地开发。
  • 支持切换其他数据库:如 Milvus、Weaviate、Qdrant 和 pgvector,用户可根据生产需求配置(需自行部署)。

推荐场景:

  • 开发测试:直接用 Chroma。
  • 生产环境:选择 Milvus 或 Qdrant(需通过Dify的配置文件中修改向量库连接参数)。

¶常见大模型对比

以下是2025年市面上常见大模型的对比表格,涵盖国内外主流模型,从技术架构、核心能力、适用场景、开源/闭源情况等维度进行对比:

¶2025年主流大模型对比表

模型名称 发布机构 参数规模 核心能力 适用场景 开源/闭源 评测表现(SuperCLUE 2025)
GPT-4o OpenAI ~1.8T 多模态(文本/图像/音频/视频)、超长上下文(100万Token)、复杂推理 科研分析、跨行业决策、全媒体生成 闭源(API) 总分80.4(理科87.3,文科77.1)
DeepSeek-V3 深度求索 6710亿 低成本训练(仅600万美元)、STEM领域强(代码正确率91%)、长文本处理 学术研究、代码生成、论文写作 开源 总分68.3(理科72.0,文科78.2)
Gemini 2.0 Ultra Google DeepMind 1.56T 原生多模态、132种语言实时翻译、超低时延(<20ms) 全球化协作、实时翻译、边缘计算 闭源 总分68.2(理科72.6,文科76.6)
Claude 3.5 Sonnet Anthropic 未公开 200K~1M Token上下文、混合推理(快思慢想)、高安全性 法律分析、医疗诊断、合规对话 闭源 总分67.7(理科71.4,文科77.2)
通义千问 3.0 阿里巴巴 720亿 中文理解领先、电商/金融优化、支持百万级上下文 企业服务、供应链管理、金融客服 部分开源 总分66.2(理科67.4,文科80.0)
豆包 1.5 Pro 字节跳动 未公开 稀疏MoE架构、低成本高效训练、擅长短视频脚本生成 社交媒体运营、短视频创作 闭源 总分66.5(理科72.3,文科76.6)
文心一言 4.0 百度 未公开 中文优化(国考题87%正确率)、知识图谱增强、多模态生成 教育、医疗、金融问答 闭源 总分62.2(理科61.4,文科79.5)
Kimi Chat 月之暗面 未公开 200万汉字长文本处理、法律/科研文档分析 法律合同、学术文献、长文本总结 闭源 总分59.4(理科58.1,文科76.6)
盘古大模型 5.5 华为 万亿级 工业级多模态(12K图像识别)、复杂推理优化、制造业/气象预测 智能制造、自动驾驶、气象分析 闭源 未上榜(行业定制化强)
Llama-3.3-70B Meta 700亿 开源高性能、推理速度提升200%、多语言优化 中小企业定制、学术研究 开源 总分59.4(理科66.4,文科72.9)

¶关键对比维度补充

  1. 开源 vs 闭源

    • 开源(DeepSeek、Llama):适合企业私有化部署、学术研究。
    • 闭源(GPT-4o、Claude):API调用方便,但依赖厂商服务。
  2. 语言侧重

    • 英文优先:GPT-4o、Gemini、Claude。
    • 中文优化:通义千问、文心一言、DeepSeek-V3。
  3. 多模态支持

    • 最强:GPT-4o(8K视频生成)、Gemini(实时多语言翻译)、盘古(工业级多模态)。
    • 较弱:DeepSeek(仅文本/代码)、Kimi(长文本处理突出)。
  4. 成本与部署

    • 低成本:DeepSeek(训练成本仅GPT-4o的1/70)、豆包(稀疏MoE架构高效)。
    • 高成本:GPT-4o(私有化部署超500万美元/年)、盘古(需华为昇腾芯片支持)。

¶选型建议

  • 企业通用AI:GPT-4o(综合最强)、Claude(安全合规)。
  • 中文场景:通义千问(电商/金融)、文心一言(教育/医疗)。
  • 开源研究:DeepSeek-V3(STEM强)、Llama-3(社区生态好)。
  • 长文本分析:Kimi Chat(200万字上下文)、Claude(200K Token)。

参考链接:

  • https://mp.weixin.qq.com/s/-7jvx_8M9l2yDyjm80kosw

MCP Gateway:零代码改造,把现有 API 发布为MCP服务

发表于 2026-03-08 | 更新于 2026-04-08 | 分类于 AI | 评论数:

¶MCP Gateway:零代码改造,把现有 API 发布为MCP服务

上篇讲了 MCP 协议与网关设计思路;本篇介绍落地实现 MCP Gateway:导入 OpenAPI/Swagger 文档即可一键发布 MCP 服务,大模型通过标准协议直接调用你的接口,业务侧零侵入。Spring Boot + MCP SDK,GitHub 已开源,欢迎 Star。


MCP Gateway.gif

¶一、MCP Gateway 是什么

MCP Gateway 是一个基于 Spring Boot 的 MCP 服务网关,核心做一件事:把 OpenAPI/Swagger 文档自动转换成 MCP 工具,让大模型通过标准 MCP 协议(tools/list、tools/call)直接调用你的业务接口,而无需改业务代码。

你可以把它理解成「API 与 MCP 之间的翻译官」:在管理端导入或手动录入 API 文档,配置好转发地址与认证,发布后就会得到一个独立的 MCP Server;客户端(如 Claude Desktop、Cursor、自研 AI 应用)按 MCP 规范连上来,就能把文档里的接口当作「工具」来调用。原有后端保持 REST/OpenAPI 形态不变,零侵入。

项目已在 GitHub 开源,地址:https://github.com/fanzhongwei/mcp-gateway,欢迎 Star 与使用。


¶二、核心能力与特性

  • API 文档来源灵活:支持通过 OpenAPI/Swagger(URL 或本地上传)导入,也支持在管理端手动录入接口信息;录入或导入后统一转换为 MCP 工具定义,配置驱动、零侵入业务服务。(Postman、cURL、Apifox 等导入方式正在开发中。)
  • 标准协议:完整支持 MCP 的 tools/list、tools/call 等能力,兼容主流 MCP 客户端。
  • 多服务 / 多租户:按「服务」维度管理多套 API-Docs,每个服务对应一个 MCP Server 端点(如按 serviceId 路由),便于区分业务域或租户。
  • 认证与鉴权:支持 Bearer Token 等认证方式,网关侧统一校验后再转发到后端,与上篇设计中的「自研传输入口 + 统一鉴权」一致。
  • 技术栈:Spring Boot 3.x、SpringDoc OpenAPI、官方 MCP Java SDK(协议层复用),便于与现有 Java 技术体系集成。

¶三、快速开始

¶环境要求

  • JDK 17+
  • Maven 3.6+
  • Node.js 18+(构建前端时需要)
  • PostgreSQL 14+(用于管理端数据存储)

¶构建与运行

在项目根目录执行:

1
mvn clean package

然后启动服务:

1
java -jar mcp-gateway-server/target/mcp-gateway-server.jar

¶使用流程

启动服务后访问管理端,按以下步骤即可从 API 文档发布到在 MCP 客户端里使用。

¶1. 创建业务系统、维护环境

  • 在管理端进入业务系统管理,新建一个业务系统(例如「订单服务」「用户中心」),用于归类将要暴露为 MCP 的接口。
  • 为每个业务系统维护环境(如开发、测试、生产):在对应业务系统下进入「环境管理」,添加环境并填写环境名称、Base URL(该环境下 API 的根地址)等。后续导入或录入的接口会按「业务系统 + 环境」维度管理,发布 MCP 时也会按环境转发请求。

¶2. 导入 API 文档

  • 方式一:导入文档
    在接口管理中选择已创建的业务系统及环境,选择「导入」→ 选择 OpenAPI/Swagger(支持填写文档 URL 或本地上传),按页面提示完成导入,系统会解析并生成接口列表。
  • 方式二:手动录入
    选择「手动录入」,逐项填写接口的路径、方法、摘要、参数(Query/Header/Body)等,保存后同样进入该业务系统下的接口列表。

同一业务系统下可同时存在导入与手动录入的接口,可编辑、删除或补充。

¶3. 创建并发布 MCP Server

  • 在MCP 服务中点击「创建 MCP 服务」,填写服务名称、描述等基本信息。
  • 配置资源组合:为当前 MCP 服务选择要暴露的「业务系统 + 环境」组合,并勾选该组合下要作为 MCP 工具暴露的接口(可多选);可为接口或组合设置便于大模型识别的名称。
  • 服务端点(该 MCP Server 对外提供的 URL 路径)和访问令牌(Access Token,客户端调用时需携带)由系统自动生成,不可修改;发布后可在服务详情中查看与复制。
  • 保存后点击发布。发布成功后,该 MCP Server 处于运行中状态,即可被 MCP 客户端连接。

¶4. 如何在 MCP 客户端上使用

在管理端 MCP 服务 列表点击 查看 打开服务详情,详情页提供 访问令牌、服务端点、客户端配置 三项复制按钮,复制后填入客户端即可。

配置示例(将 url、令牌换为详情页复制的值):

  • Cursor(mcpServers 下):
1
2
3
4
5
6
"mcp-gateway-http": {
"timeout": 60,
"type": "streamableHttp",
"url": "https://your-gateway-host/mcp-gateway/your-service-endpoint",
"headers": { "Authorization": "Bearer your-access-token" }
}
  • Claude Desktop(claude_desktop_config.json 的 mcp_servers 下):
1
2
3
4
"mcp-gateway-http": {
"url": "https://your-gateway-host/mcp-gateway/your-service-endpoint",
"api_key": "your-access-token"
}

¶四、适用场景与小结

¶适用场景

  • 存量 API 快速接入 MCP:已有大量 REST/OpenAPI 接口,希望不改业务代码就接入 Claude、Cursor 等 MCP 生态,让大模型直接「会调用」你的接口。
  • 多服务 / 多租户统一出口:需要按业务域或租户暴露多套 API,每个 MCP Server 对应一个端点与令牌,便于隔离与权限控制。
  • 配置驱动、少写适配代码:希望用导入文档 + 勾选接口的方式把 OpenAPI 转成 MCP 工具,避免为每个接口手写 MCP 适配层。
  • 内部工具与自研 AI 应用:企业内已有 OpenAPI/Swagger 文档的内部系统,希望通过统一网关暴露给内部 AI 助手或自研 MCP 客户端,便于检索、调用与审计。
  • 多环境与灰度发布:同一业务系统配置开发/测试/生产等环境,按环境发布不同 MCP 服务或切换 Base URL,方便在 AI 侧做联调与发布验证。

¶小结

上篇从协议与传输层拆解了「为什么要自研网关、为何推荐无状态」;本篇介绍的 MCP Gateway 即是该思路的落地实现:从导入或录入 API 文档、配置资源组合与认证,到发布 MCP Server、在客户端拷贝配置使用,一条龙完成,业务侧零侵入,让传统 REST 服务也能无缝对接大模型生态。若你正打算把现有 API 暴露给 Claude、Cursor 或自研 AI 应用,欢迎试用并反馈。


项目地址:https://github.com/fanzhongwei/mcp-gateway

欢迎使用、提 Issue 和 PR,如果对你有帮助,也欢迎给个 Star。

深入拆解MCP协议到MCP网关设计思路

发表于 2026-01-31 | 更新于 2026-04-08 | 分类于 AI | 评论数:

¶深入拆解MCP协议到MCP网关设计思路

本文介绍自研 MCP 网关的研发思路:在网关侧如何实现 MCP 的两种传输形态(有状态 SSE 与无状态 HTTP),如何借鉴官方 Java SDK 的传输层实现,以及为何推荐采用“自研 Controller + 复用 SDK 协议层”而非直接使用 SDK 的 Servlet 提供能力。


之前的文章 Spring AI+MCP实战:零代码改造将传统服务接入大模型生态 介绍了如何使用Spring AI简单几行代码将传统服务接入大模型,但有没有更简单的方式统一管理业务接口然后组合各个业务系统接口并一键发布为MCP服务呢,接下来我们就从MCP协议入手介绍MCP网关的设计思路。

¶一、MCP 协议与传输层简述

MCP(Model Context Protocol) 是一套用于客户端与“模型上下文”服务之间通信的协议,基于 JSON-RPC 2.0:请求(Request)需要响应,通知(Notification)不需要响应。传输层负责把 JSON-RPC 消息在 HTTP 上承载,常见有三种形态:

  1. 有状态 SSE(Server-Sent Events):先通过 GET 建立一条长连接(SSE 流),服务端返回一个“消息端点” URL,客户端后续通过 POST 到该端点发送消息;服务端通过 SSE 向客户端推送响应或通知。
  2. 无状态 Streamable HTTP:不维护会话,每次请求都是独立的 POST;若为 Request 则响应 200 + JSON,若为 Notification 则响应 202 Accepted。规范要求 Accept 头同时包含 application/json 和 text/event-stream。
  3. Streamable HTTP(单端点 + Session):同一 URL 支持 GET / POST / DELETE。POST 的 initialize 会创建 Session 并返回 MCP-Session-Id;后续 POST 带该头处理消息,GET 带该头可建立 SSE 流(支持 Last-Event-ID 恢复);DELETE 用于结束 Session。

¶1.1 MCP 消息中的 method

每条 JSON-RPC 消息都带有 method 字段,用于标识请求或通知的类型。下表以 2025-11-25 规范为准:https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.json。

客户端 → 服务端(Request,需响应)

method 说明
initialize 建连时首条请求,协商协议版本与能力
ping 保活探测
resources/list 列出服务端可读资源(支持分页 cursor)
resources/templates/list 列出资源模板
resources/read 按 URI 读取资源内容
resources/subscribe 订阅资源变更通知
resources/unsubscribe 取消资源订阅
prompts/list 列出提示词/模板
prompts/get 获取指定提示词内容
tools/list 列出服务端提供的工具
tools/call 调用指定工具(可带 task 参数做异步任务)
tasks/get 查询任务状态
tasks/result 获取任务结果负载
tasks/cancel 取消任务(替代对任务的 notifications/cancelled)
tasks/list 分页列出任务
logging/setLevel 设置服务端日志级别
completion/complete 请求补全建议(如提示词/资源模板参数补全)

客户端 → 服务端(Notification,无响应)

method 说明
notifications/initialized 初始化完成后通知服务端
notifications/cancelled 取消某条已发出的请求(不含 task,task 用 tasks/cancel)
notifications/progress 进度通知(与请求中的 progressToken 关联)
notifications/tasks/status 任务状态变更通知(可选)
notifications/roots/list_changed 客户端根目录列表变更通知

服务端 → 客户端(Request,需响应)

method 说明
ping 保活探测
roots/list 向客户端请求根目录/根 URI 列表
sampling/createMessage 请求客户端代为调用 LLM 生成消息(可带 task 做异步)
elicitation/create 服务端通过客户端向用户征集信息(表单或 URL)
tasks/get 查询任务状态
tasks/result 获取任务结果负载
tasks/cancel 取消任务
tasks/list 分页列出任务

服务端 → 客户端(Notification,无响应)

method 说明
notifications/message 日志消息(logging)
notifications/progress 进度通知
notifications/cancelled 取消某条请求(不含 task)
notifications/resources/list_changed 资源列表变更
notifications/resources/updated 某资源内容更新(需先 subscribe)
notifications/prompts/list_changed 提示词列表变更
notifications/tools/list_changed 工具列表变更
notifications/tasks/status 任务状态变更通知(可选)
notifications/elicitation/complete 服务端告知某次 elicitation 已完成

网关在实现时只需按 JSON-RPC 解析 method 并路由到对应Method的Handler实现;协议层可复用 SDK 的 McpSchema 与 Session/StatelessHandler,无需为每个 method 单独实现传输逻辑。


¶二、有状态 SSE 传输的研发思路

¶2.1 整体流程

网关研发时,有状态 SSE 采用“建连”与“发消息”分离的两个端点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sequenceDiagram
participant Client as 客户端
participant Gateway as 网关 Controller
participant Session as MCP 会话

Client->>Gateway: GET /{serviceId}/sse Accept: text/event-stream Authorization: Bearer token
Gateway->>Gateway: 校验 token、服务、协议版本
Gateway->>Session: 创建 Session + Transport(SseEmitter) 存入 sessions[sessionId]
Gateway-->>Client: 200 + SSE 流 event: endpoint data: messageEndpoint?sessionId=xxx

Client->>Gateway: POST messageEndpoint Body: JSON-RPC Request/Notification sessionId 在 query
Gateway->>Session: sessions.get(sessionId) session.handle(message)
Session-->>Gateway: Transport.sendMessage(response) SseEmitter.send(event message)
Gateway-->>Client: SSE event: message data: JSON-RPC 响应
  • GET:只负责建连与下发“消息端点”;Session 在服务端用 sessionId 标识,并在内存中维护。
  • POST 到消息端点:从 query 取 sessionId,找到对应 Session,反序列化 body 为 JSON-RPC 消息,交给 session.handle(message);若有响应,通过同一 Session 的 Transport(例如封装了 SseEmitter)以 SSE 的 message 事件推回客户端。

¶2.2 SDK 实现与核心代码:HttpServletSseServerTransportProvider

研发时参考官方 Java SDK 的 HttpServletSseServerTransportProvider(mcp-core):SDK 用同一 Servlet 通过 URI 区分 建连与消息端点,单端点、单 Session 工厂,不区分服务/租户、不做 Bearer 校验。网关在实现时沿用该思路,并在入口层增加按 serviceId 和 token 校验。SDK 核心逻辑如下,供对照。

建连(GET) — doGet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ... {
// ... URI 校验、isClosing 等
response.setContentType("text/event-stream");
response.setCharacterEncoding(UTF_8);
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");

String sessionId = UUID.randomUUID().toString();
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0);
PrintWriter writer = response.getWriter();

HttpServletMcpSessionTransport sessionTransport = new HttpServletMcpSessionTransport(sessionId, asyncContext, writer);
McpServerSession session = sessionFactory.create(sessionTransport);
this.sessions.put(sessionId, session);

this.sendEvent(writer, ENDPOINT_EVENT_TYPE, buildEndpointUrl(sessionId));
}

private String buildEndpointUrl(String sessionId) {
return this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId; // 或 baseUrl 末尾去斜杠拼接
}

收消息(POST messageEndpoint) — doPost

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ... {
// ... isClosing、URI 校验
String sessionId = request.getParameter("sessionId");
if (sessionId == null) { /* 400 + McpError */ return; }

McpServerSession session = sessions.get(sessionId);
if (session == null) { /* 404 + McpError */ return; }

// 读取 body
StringBuilder body = new StringBuilder();
while ((line = reader.readLine()) != null) { body.append(line); }

McpTransportContext transportContext = this.contextExtractor.extract(request);
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());

session.handle(message).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block();
response.setStatus(HttpServletResponse.SC_OK);
}

Transport 职责 — 内部类 HttpServletMcpSessionTransport

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
return Mono.fromRunnable(() -> {
try {
String jsonText = jsonMapper.writeValueAsString(message);
sendEvent(writer, MESSAGE_EVENT_TYPE, jsonText); // event: message \n data: <json>
} catch (Exception e) {
sessions.remove(sessionId);
asyncContext.complete();
}
});
}

private void sendEvent(PrintWriter writer, String eventType, String data) throws IOException {
writer.write("event: " + eventType + "\n");
writer.write("data: " + data + "\n\n");
writer.flush();
}

@Override
public Mono<Void> closeGracefully() {
return Mono.fromRunnable(() -> {
sessions.remove(sessionId);
asyncContext.complete();
});
}

研发思路:协议层(Session、JSON-RPC 路由)与传输层(SSE、HTTP)分离——Session 与业务逻辑复用 SDK 的 McpServerSession,网关只实现“按 serviceId + token 校验”的入口与基于 Spring SseEmitter 的 Transport,不重复造轮子。

SDK 另一种有状态实现:HttpServletStreamableServerTransportProvider 采用 Streamable HTTP 单端点 + Session:同一 URL 支持 GET / POST / DELETE;POST 的 initialize 创建 Session 并返回 MCP-Session-Id,后续 POST 带 mcp-session-id 头处理消息,GET 带该头可建立 SSE 流(支持 Last-Event-ID 恢复),DELETE 结束 Session。仍是单端点、单工厂、无按服务/租户的路由与鉴权。


¶三、无状态传输的研发思路

¶3.1 整体流程

网关采用无状态模式:不维护 Session,每次 POST 独立处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sequenceDiagram
participant Client as 客户端
participant Controller as 无状态 Controller
participant Handler as StatelessHandler

Client->>Controller: GET /{serviceId}/stateless
Controller-->>Client: 405 Method Not Allowed

Client->>Controller: POST /{serviceId}/stateless Accept+Authorization Body: JSON-RPC Request 或 Notification
Controller->>Controller: 校验 token、服务、Accept 反序列化 Body
alt Request
Controller->>Handler: handler.handleRequest(...)
Handler-->>Controller: 200 + JSON 响应
else Notification
Controller->>Handler: handler.handleNotification(...)
Handler-->>Controller: 202 Accepted
end
Controller-->>Client: 200 + JSON 或 202
  • GET:直接返回 405,表示该端点不支持“建连”。
  • POST:
    • 校验 Accept 必须同时包含 application/json 和 text/event-stream(与规范一致)。
    • 若为 Request:调用无状态 Handler 的 handleRequest,将返回的 JSONRPCResponse 序列化后 200 写出。
    • 若为 Notification:调用 handleNotification,返回 202 Accepted,无 body。

无状态不保存任何会话,无需 Session ID 与内存 Map,便于网关水平扩展,是自研网关的推荐默认形态。

¶3.2 SDK 实现与核心代码:HttpServletStatelessServerTransport

研发时参考官方 Java SDK 的 HttpServletStatelessServerTransport(mcp-core):SDK 仅处理 POST、GET 返回 405,端点固定、无 serviceId 路由、无内置认证。网关在实现时沿用该逻辑,在入口增加按 serviceId 和 token 校验。SDK 核心逻辑如下,供对照。

GET — doGet

1
2
3
4
5
6
7
8
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ... {
if (!requestURI.endsWith(mcpEndpoint)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
}

POST — doPost

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ... {
// ... URI、isClosing 校验
McpTransportContext transportContext = this.contextExtractor.extract(request);

String accept = request.getHeader(ACCEPT);
if (accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM))) {
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
new McpError("Both application/json and text/event-stream required in Accept header"));
return;
}

// 读取 body
StringBuilder body = new StringBuilder();
while ((line = reader.readLine()) != null) { body.append(line); }
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());

if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
McpSchema.JSONRPCResponse jsonrpcResponse = this.mcpHandler
.handleRequest(transportContext, jsonrpcRequest)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
response.setContentType(APPLICATION_JSON);
response.setCharacterEncoding(UTF_8);
response.setStatus(HttpServletResponse.SC_OK);
String jsonResponseText = jsonMapper.writeValueAsString(jsonrpcResponse);
response.getWriter().write(jsonResponseText);
response.getWriter().flush();
} else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
this.mcpHandler.handleNotification(transportContext, jsonrpcNotification)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
response.setStatus(HttpServletResponse.SC_ACCEPTED);
} else {
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
new McpError("The server accepts either requests or notifications"));
}
}

研发思路:无状态 Controller 不持有 Session,只做“路由 + 鉴权 + 反序列化/序列化 + 调用 Handler”,与 SDK 的 McpStatelessServerHandler 接口对齐,协议层完全复用 SDK。


¶四、网关研发中的选型思路:为何自研传输入口而非直接用 SDK

¶4.1 研发时的业务诉求

  • 多服务/多租户:网关设计上需要按 serviceId 路由到不同 MCP 服务(不同配置、不同工具集),即 URL 形如 /mcp/service/{serviceId}/sse 或 .../stateless。SDK 的 Servlet 是“单端点、单工厂”,无法在同一个 Servlet 里根据 path 动态选择不同 Session 工厂或 Handler,因此传输入口需要自研。
  • 统一认证与鉴权:研发时要求在处理 MCP 前完成校验:校验 Bearer Token、校验该 Token 是否可访问指定 serviceId(例如服务是否存在、是否已发布)。SDK 的 Transport 层不负责认证;若用 Filter 做,需要把“服务信息”等注入请求再在 Session/Handler 里使用。与“先验服务再建 Session/调 Handler”的流程更契合的做法是:在自研 Controller 里先校验,再创建或调用对应工厂/Handler。
  • 与现有技术栈一致:网关基于 Spring MVC(RestController、统一异常、Swagger 等),希望 MCP 端点也是普通 Controller 方法,便于监控、限流、日志。SDK 提供的是独立 @WebServlet,直接挂载则路由、文档、异常处理都要额外适配;自研 Controller 可完全按现有规范实现。

¶4.2 SDK 的适用边界(为何不能直接当网关用)

  • 单服务、单端点:SDK 的 Builder 配置的是“一个 baseUrl、一个 messageEndpoint、一个 sseEndpoint”或“一个 mcpEndpoint”,没有“路径中的 serviceId”概念,无法满足多服务网关的路由需求。
  • 认证与上下文:通过 McpTransportContextExtractor 可以从 HttpServletRequest 里取上下文,但“校验 token、查库校验服务”仍要在 Servlet 外自己做(例如 Filter 或包装一层),且 Session 工厂/Handler 需根据“当前请求属于哪个服务”来创建,这部分 SDK 不提供。
  • 会话存储:SSE/Streamable 的 Session 存在进程内 Map 中,多实例部署时需自行解决会话亲和或共享存储;无状态则无此问题。网关研发时采用“无状态优先、有状态 SSE 不作为默认推荐”的策略,在扩展性与功能之间做权衡。

¶4.3 研发选型对比

维度 直接使用 SDK Servlet 自研网关
路由 单端点,难以按 serviceId 拆成多服务 /mcp/service/{serviceId}/sse 或 .../stateless
认证与鉴权 需在 Filter 或外层实现,与 Session 解耦 Controller 内统一:token + service 校验后再建 Session/调 Handler
会话存储 进程内 Map,多实例需自行考虑 有状态 SSE 同样用内存 Map;无状态不存会话,易扩展
协议版本 随 SDK 固定 可在 Controller 中支持多版本(如 2025-11-25)
与 Spring 集成 需额外配置 Servlet、文档、异常 与现有 RestController、Swagger、全局异常一致
维护成本 低,跟随 SDK 升级即可 需随 MCP 规范与 SDK 接口演进做少量适配

结论:自研网关的价值在于 按服务路由、统一认证和与 Spring 体系一致;代价是 需要自行维护传输层与协议版本的兼容。协议层仍大量复用 SDK:协议解析(McpSchema)、Session 逻辑(McpServerSession / McpStreamableServerSession)、无状态处理(McpStatelessServerHandler)、JSON 映射(McpJsonMapper)等,网关研发只做“换一层 HTTP 入口和鉴权”。


¶五、有状态与无状态对比及选型建议

¶5.1 有状态 SSE 与无状态实现方式对比

维度 有状态 SSE 实现 无状态实现
会话 服务端维护 Session(内存 Map),按 sessionId 关联请求与响应 无 Session,每次 POST 独立,请求与响应一一对应
扩展性 多实例需会话亲和或共享 Session 存储,扩展复杂 天然无状态,可任意水平扩展,无需亲和
资源占用 长连接与 Session 占用内存和连接数,连接断开需清理 请求结束即释放,无长连接与 Session 占用
服务端推送 支持通过 SSE 主动向客户端推送通知 不支持服务端主动推送,仅请求-响应
实现复杂度 GET 建连 + POST 消息端点 + Session 管理 + Transport 封装 仅 POST 单端点 + 鉴权 + 调用 Handler,逻辑简单
适用场景 必须“服务端主动推送”时才考虑 绝大多数 MCP 调用(工具列表、工具调用等)

有状态 SSE 优势:支持服务端通过 SSE 长连接主动推送消息(如进度、通知)。
有状态 SSE 劣势:会话在内存、多实例不共享,扩容需会话亲和或共享存储;长连接与 Session 增加资源压力和运维复杂度;实现与排查问题都更复杂。

无状态优势:实现简单、易水平扩展、无 Session 与长连接占用、与现有 HTTP 运维体系一致;协议层直接复用 SDK 的 McpStatelessServerHandler。
无状态劣势:无法实现“服务端主动推送”,仅适合请求-响应模式(而多数 MCP 能力正是此类)。

¶5.2 选型建议:推荐采用无状态

综合优劣势与典型 MCP 使用场景(如 initialize、tools/list、tools/call 等均为请求-响应),推荐自研网关默认采用无状态实现方案,理由如下:

  1. 场景匹配:绝大部分 MCP 交互是“客户端发请求、服务端返结果”,不需要服务端主动推送,无状态即可满足。
  2. 扩展与运维:无状态便于水平扩容、无需会话亲和或共享存储,部署与故障转移更简单。
  3. 实现与维护成本:单端点 + 鉴权 + Handler,逻辑清晰,与 SDK 的 HttpServletStatelessServerTransport 行为一致,易于维护。
  4. 有状态按需使用:仅在业务明确需要“服务端主动推送”时再启用有状态 SSE,并接受其扩展与运维成本。

实现要点:网关在 Controller 中按 serviceId 路由与 token 校验后,直接复用 SDK 的 McpStatelessServerHandler;有状态 SSE 可作为可选能力保留,但不作为默认推荐。

¶5.3 小结

  • 有状态 SSE:适合必须“服务端通过长连接主动推送”的场景;需维护 GET 建连、message 端点、Session 及基于 SseEmitter 的 Transport,多实例需考虑会话亲和或共享存储,建议仅在确有推送需求时使用。
  • 无状态:每次请求独立、无 Session,易水平扩展,与 SDK 的 HttpServletStatelessServerTransport 行为对齐;网关增加 serviceId 路由与认证后复用 McpStatelessServerHandler。推荐作为自研网关的默认实现方案。
  • 研发思路:自研网关只做“路由 + 认证 + 与 Spring 的集成”,协议层依托 SDK;在“多服务、统一鉴权、RestController 风格”下,自研传输入口更贴合需求。

以上即为自研 MCP 网关的研发思路说明:两种传输形态的对比与选型、推荐采用无状态作为默认方案,以及如何参考 SDK 实现自研传输入口。后续再结合openapi等格式的接口定义,通过简单配置即可将已有业务接口通过MCP网关发布为MCP服务,让原本不支持MCP的传统服务获得与大模型生态无缝对接的能力。

参考文档:

  • MCP JSON-RPC消息格式:https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.json
  • MCP JAVA SDK:https://github.com/modelcontextprotocol/java-sdk

给 Agent 搞个干活“工具人”:一个 HTTP 小服务带来的改变

发表于 2026-01-20 | 更新于 2026-04-08 | 分类于 AI | 评论数:

¶给 Agent 搞个干活“工具人”:一个 HTTP 小服务带来的改变

这篇不是讲“如何零代码搭一个 AI 工作流平台”,也不是讲“某个大模型能力有多牛”。
它讲的是一件更朴素的事:
我为什么宁愿多写一个看起来“很普通”的 HTTP 小服务,也不想再在编排工具里堆一堆复杂节点。

如果你也在做 Agent / 工作流,已经开始被流程图、脚本逻辑和大模型输出共同折磨,这篇可能会对你有点参考价值。


¶一、先把话说白:这个 Agent 工具服务具体在干什么?

我这个服务的定位特别简单粗暴:
给 Agent 和工作流,提供一组“能干具体活”的 HTTP 工具接口。

它大概是在做这几类事情(抽象说法,不贴项目细节):

  • 一类:复杂一步活“打包”为一次 HTTP 调用

    • 上游发来一个结构化请求(JSON),里面写清楚:你想干什么,需要多少条数据,需要什么字段。
    • 服务内部会:
      • 按要求调大模型做内容生成或理解;
      • 用普通 Java 代码去重、补字段、格式整理;
      • 把结果导出成文件(比如表格),存到临时存储里;
      • 生成一个下载 token + 一份 Markdown 预览,方便人或其他系统查看。
    • 对调用方来说:
      • 只看到“一次请求 → 一个结构化响应”,不需要关心中间调了几次大模型、做了多少后处理。
  • 另一类:配套的小工具能力

    • 自检大模型配置:
      • 发一个简单问句,确认 baseUrl / model / apiKey 这些都没问题,把延迟和部分响应内容打出来。
    • 做一些和 Agent 工作流强相关的基础活:
      • 日志记录和脱敏;
      • 临时文件存储;
      • 统一的错误结构和返回码。

用一句话讲,这个服务就在做两件事:

  1. 帮你跟大模型、文件、复杂逻辑打交道;
  2. 把最后的结果通过一个 HTTP 接口,干净地交给上游。

¶二、为什么这些逻辑不适合全堆在编排工具 / Agent 脚本里?

一开始我也以为:“有了编排平台,很多东西拖一拖节点就完了,何必再写服务?”

实践下来,几个很具体的问题把我劝退了。

¶1. 某些步骤内部太复杂,用节点堆出来“理论可行、实际难维护”

想象一个很现实的需求:

  • 需要批量生成一批结构化内容(不一定是题库,也可能是某种配置、表格数据等);
  • 要求数量要对;
  • 内容不能重复;
  • 字段要统一;
  • 结果要导出成 Excel / CSV 给运营同学用。

如果你完全在编排工具里做,大概会变成这样:

  • 一堆“大模型调用”节点,控制生成数量;
  • 中间插着各种“json 解析”、“分支判断”、“重试”节点;
  • 后面再加一堆“数组处理”、“格式转换”、“上传文件”的节点。

能跑起来,但一眼看过去就知道:
一旦需求稍微变形,这条流程图谁都不敢动。

¶2. 需要精细的资源与异常控制,编排层很难做细

比如:

  • 大模型调用是 IO 密集 + CPU 也会吃一点,想控制并发度和线程池大小;
  • 某些步骤你希望有明确的“总超时时间”和“每批超时时间”;
  • 解析 JSON 失败时,希望能给出可读的错误信息,而不是一个“未知异常”。

这些事情,用一门普通语言(Java/Go/Python 都行)写在服务里,逻辑、日志、测试都有办法做得比较细。
但如果你全靠编排工具,能拼出来,
但很难做到“这一块就交给一个人负责、写单元测试、看日志、做性能调优”。

¶3. 逻辑复制粘贴问题:同一套“大模型 + 解析 + 清洗”,到处都是

如果你把所有逻辑写在 Agent 脚本里,会发生什么?

  • 每个 Agent 都有一段类似的“模型调用 + 解析 + 清洗 + 导出”逻辑;
  • 需要改格式 / 换模型 / 调整规则时,你得去每个地方改一遍;
  • 很快就没有人弄得清“线上到底在跑哪一版逻辑”。

这其实是一个很典型的“应该收敛成服务能力,却被复制成脚本逻辑”的问题。

所以最后的落地选择是:

把那些“步骤内部很复杂,但对外可以收敛成一个动作”的事情,抽进一个 HTTP 工具服务里。
工作流只需要把它当“黑盒步骤”去调用。


¶三、设计这个服务时,我只盯着三个特别务实的目标

我没有想着做一个所谓“Agent 能力平台”,只盯着三件很务实的事:

  1. 单一职责,别搞成另一个大一统平台
  2. 可复用,任何会发 HTTP 的东西都能用
  3. 好运维,能按正常微服务那一套去部署监控

¶1. 单一职责:谁该干啥,就只干那点事

在服务内部,我基本按下面这种划分来做:

  • 控制器(Controller):

    • 只做 HTTP 协议相关的事情:参数接收、校验、返回 200/400/500 这种状态;
    • 打一日志:谁调了这个接口,带了什么参数;
    • 捕获异常、包装成统一的错误响应结构。
  • 业务服务(Service):

    • 聚焦某一类“工具能力”,比如“批量生成结构化内容 + 去重 + 导出文件”;
    • 内部可以包含多次模型调用、各种小算法,但对外就是“一次 HTTP 请求”。
  • 基础设施(Infra):

    • LLM 调用适配:
      • 屏蔽不同厂商 API 差异;
      • 统一控制超时时间、重试策略;
      • 统一打日志。
    • 临时文件存储:
      • 管理文件路径、过期时间、下载 token;
    • 日志配置、OpenAPI 文档、Docker 打包等。

这样做的好处是:

  • 新需求进来时,大概率只改某个 Service;
  • 控制器层不用被业务细节污染;
  • 和大模型的交互逻辑集中在一处,可调可观测。

¶2. 可复用:只要能发 HTTP,就能用

相比起“某个 SDK”或者“某个本地插件”,HTTP + JSON 是目前最通用的一种能力暴露方式:

  • 后端服务直接用自己熟悉的 HTTP 客户端库就能调用;
  • 前端 / BFF 可以通过网关转发;
  • 传统工作流平台,可以直接加一个 HTTP 节点调用;
  • 命令行、脚本、CI/CD 也都能用 curl 之类调试。

这意味着:

只要我把这套能力打包成 HTTP 接口,就不怎么依赖上游用的是哪一套 Agent 框架或编排平台。

¶3. 好运维:当成一个普通微服务来管

这一点很现实:

  • 我们已经有一套成熟的 Spring Boot + Docker 的体系;
  • 监控平台、日志平台、网关、灰度发布都围绕 HTTP 微服务建好的。

把工具能力做成 HTTP 服务,最大的好处之一就是:

  • 它能直接接入这套现有体系:
    • 用容器参数来控制 JVM 内存、GC 策略;
    • 使用统一的日志格式、日志滚动策略;
    • 用健康检查接口接入监控;
    • 用网关来做鉴权、限流、熔断。

如果我上来就做一个 MCP 服务,反而会脱离当前这套稳定的基础设施,这其实是我暂时没有这么做的关键原因之一。


¶四、和大模型合作这件事,我在服务里都做了什么

说点细节,会更清晰一些。

¶1. 把 Prompt 写成“协议说明”,不是随便聊两句

比如我要让模型生成一批结构化结果,我不会只说:

“请帮我生成 XX 条数据,用 JSON 返回。”

我会在系统提示和用户提示里,把约束写得很“工程化”,类似于:

  • 必须返回严格的 JSON 数组;
  • 数组长度必须等于 N,不能多也不能少;
  • 每个元素必须包含哪些字段(字段名要严格一致);
  • 不允许有额外解释文字、不要加 Markdown 代码块;
  • 某个字段应该包含什么语义上的信息。

这样做的目的不是“提高成功率”这么空泛,而是:

让服务端有足够的信息做机械的校验,比如数量、字段名、某些必填字段。

¶2. 默认认为模型输出“不干净”,所以要有清洗流程

现实里,模型非常喜欢这样输出:

1
2
3
4
5
6
好的,下面是为你生成的内容:


[
...
]

你让它只给 JSON,它依然会在前后加评价、加说明。

在服务里,我会做这几步:

  1. 从原始文本中“抠出” JSON 数组:
    • 找到 [ 和 ] 的合理区间;
    • 去掉 Markdown 代码块包裹;
    • 去掉 <think> 之类非 JSON 内容(如果模型会加)。
  2. 用 JSON 解析器解析成对象列表;
  3. 解析失败时,记录原始文本 + 错误堆栈,返回一个结构化的错误响应,而不是直接 500。

这件事如果散落在每个 Agent 里,就会到处都是“差不多的正则 + try-catch”;
集中在工具服务里,就变成一个可以单独演进、统一维护的“清洗模块”。

¶3. 把业务规则放在代码里,而不是只写在 Prompt 里祈祷

例如,有几个常见的情况:

  • 数量不对

    • 说好要 50 条,它给了 47 条;
    • 做法:
      • 服务端算一下数量,如果不足,就再触发一次补生成;
      • 最终截断或补齐到需要的数量。
  • 字段不统一

    • 有的元素多一个字段,有的少一个字段,字段顺序不一致;
    • 做法:
      • 用第一条记录的字段作为“列模板”;
      • 扫一遍所有记录,把额外字段加到末尾;
      • 对缺少字段的记录,用空值补齐。
  • 内容重复

    • 看起来不同,其实表达的是类似的内容;
    • 最基础的一步,先用一个简单的“签名”(比如基于某个主字段的字符串)做去重,
      更复杂的相似度检测可以后面再加。

这些逻辑,如果只写在 Prompt 里:“请不要重复、请确保数量正确”,
你会一遍遍发现现实和你想象的不一样。
把它收在服务里以后,就变成了可见、可测、可调的“业务规则”。


¶五、为什么我现在选的是 HTTP 接口,而不是直接做 MCP 服务?

这部分我尽量讲具体一点,不绕术语。

¶1. 谁来用这个服务?只靠 MCP 生态不够

目前实际情况大致是这样:

  • 一部分 Agent / 工具确实跑在支持 MCP 的环境里(比如某些编辑器、框架);
  • 但还有很多调用方非常“传统”:
    • 后端 REST 服务;
    • BFF / 网关;
    • 现有的工作流平台(它只认识 HTTP 节点);
    • 定时任务、脚本、CI 任务等。

如果我一开始就只暴露 MCP 接口,这些调用方几乎都用不上。

而 HTTP + JSON 的好处是:

  • 任何环境,只要能发 HTTP 请求,就能用;
  • 调试也简单:curl / Postman / 浏览器都行。

所以我的实际选择是:

把能力的“底层实现”和“统一接口形态”先落在 HTTP 上,
MCP 以后可以作为“这套 HTTP 能力的其中一个适配壳”。

也就是说,HTTP 是“内核接口”,MCP 反而是可以在外面再加的一层皮,这样不会把自己绑死在某个生态里。

¶2. 部署与运维:HTTP 服务可以无缝接入现有体系

现在的基础设施大多围绕“HTTP 微服务”建好的:

  • Spring Boot 项目 → 打成 Jar 或 Docker 镜像;
  • 发布到 K8s 或虚机上;
  • 网关 / API 网关 做统一鉴权、路由、限流;
  • Prometheus / Grafana / ELK 做监控和日志。

把工具能力做成 HTTP 服务的结果是:

  • 运维侧几乎不需要学习新东西,按“普通微服务”来管就行;
  • 可以很容易插入:
    • 请求级别的限流;
    • 鉴权策略;
    • 灰度策略(按一些 Header 或路径分流)。

而 MCP 服务的运行模型更多是:

  • 被某个宿主应用拉起来;
  • 宿主负责和 MCP 通信、转发请求;
  • 运维、监控、日志更多依赖宿主对 MCP 的支持。

在我现有的团队环境里,HTTP 服务的接入成本要小得多,这就是为什么我现在先选了 HTTP。

¶3. 安全模型和边界控制更清晰

HTTP 服务这条路上的安全模型,相对成熟:

  • 可以挂在内网,只通过网关或特定服务访问;
  • 可以验 Token / JWT / mTLS 等;
  • 可以做 IP 白名单、Rate Limit、审计日志。

而 MCP 更多设计在“本地工具对接 Agent”这条路上,
如果我要把它暴露到跨团队、跨服务的调用场景里,
安全边界如何划、谁能调、从哪儿调、怎么审计,要重新设计一套东西。

对我当前的需求来说:

一个明确的 HTTP 边界 + 网关 + 内网访问控制,就已经能满足“只给可信方调用”的要求了。

MCP 的协议更偏向“宿主 ↔ 工具”的能力协商,对使用它的 Agent 来说很友好,
但对不支持 MCP 的系统来说,要用就得先引一整套 MCP 客户端逻辑,这对很多传统系统是不现实的。

所以我的顺序是:

  1. 先用最简单的 HTTP + JSON,把核心能力抽象稳定;
  2. 等 MCP 在团队内部的使用场景足够多,再考虑在外面包一层 MCP 适配。

¶六、这个 HTTP 工具服务在实际里的用法

说几个已经在用、而且挺顺手的模式。

¶模式一:工作流里当成“黑盒步骤”

  • 上游节点:准备好参数(通常是一个 JSON 对象),填到 HTTP 调用节点里;
  • 调这个服务的某个接口,等待返回结构化结果;
  • 后续节点只处理这些结果,不关心里面调了几次大模型。

效果是:

流程图变短了,
很多原本拆成 7、8 个节点的东西,收敛成一个 HTTP 节点。

¶模式二:Agent 把“难啃的骨头”一次甩给服务

  • Agent 负责决定“什么时候需要用这个能力”;
  • 一旦确定要用,就把上下文和配置打包成请求,丢给工具服务;
  • 工具服务负责和大模型、文件系统、规则逻辑打交道,Agent 只看最终结果和状态。

效果是:

Agent 的 Prompt 不用写得特别长,
它只需要知道:“这个接口会帮我完成某类标准化的工作”。

¶模式三:纯后端 / 运营任务也直接用

有些场景甚至不走 Agent:

  • 后台管理页直接调用这个服务生成一批结构化结果;
  • 定时任务每天跑一次,用来产出某种报表或模板。

这时候,这个工具服务就成了一个通用的“结构化生成 + 转换”后端能力,
顺带也减轻了 Agent 那边的负担。


¶七、写在最后:给 Agent 找到自己的“工具人”

回头看,这个小小的 HTTP 工具服务并没有什么“炫技”的地方,更多是一些很朴素的取舍:

  • 复杂逻辑不再硬塞进流程图和 Agent 脚本,而是收敛到一个专门干活的服务里;
  • 和大模型打交道的坑(格式不稳、数量不准、内容重复)集中在一处解决,而不是到处复制 paste;
  • 把大家已经熟悉的 HTTP 微服务体系继续用下去,而不是为了“跟风”换一整套栈。

如果你现在也在做 Agent / 工作流,已经感觉到编排工具和脚本越来越难 hold 住复杂步骤,
也许可以考虑:先别急着再造一个平台,先像这样抽一个“小工具人服务”出来,把最难啃的那几块活接过去。
等这块基础垫稳了,后面不论是接 MCP、换模型、上新的编排框架,都会轻松很多。

性能优化-解决MyBatis高并发下OGNL反射调用方法的锁竞争

发表于 2025-12-31 | 更新于 2026-04-08 | 分类于 性能优化 | 评论数:

¶性能优化-解决MyBatis高并发下OGNL反射调用方法的锁竞争

摘要: 继解决OGNL安全属性检查的性能问题后,线上服务再次因MyBatis查询阻塞而超时。分析发现,近千个线程阻塞在OgnlRuntime.invokeMethod方法,竞争同一个Method对象的锁。根本原因在于,MyBatis-Plus动态SQL在解析时,会高并发地通过反射调用Wrapper对象的固定几个Getter方法(如getSqlSegment)。OGNL为缓存每个方法的访问权限,在首次检查时使用了synchronized(method),导致严重锁竞争。解决方案是为其缓存机制引入“双重检查锁定”优化,避免后续调用仍需同步,从而根治此瓶颈。该修复已向OGNL社区提交并已于3.4.10版本发布。

¶背景

经过上一篇文章,性能优化-巧解MyBatis高并发下OGNL安全检查导致的全局锁瓶颈.md,我们成功解决了OGNL高并发调用System.getProperty("ognl.security.manager")导致的性能瓶颈。

但好景不长,没过几天线上环境又频繁报接口响应超时,很多节点同时报[HSF-Provider] HSF thread pool is full.,dump线程信息进行分析,发现大部分线程还是卡在mybatis查询数据库,这又是为什么呢?

¶问题排查过程

¶程堆栈分析

于是我们又dump线程信息进行分析,结果还是OgnlRuntime这个类导致,不过这次有981个线程阻塞在org.apache.ibatis.ognl.OgnlRuntime.invokeMethod(OgnlRuntime.java:1151),具体线程堆栈信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 980个线程被阻塞
"default-17571" #91915 prio=5 os_prio=0 tid=0x00007f7b7c05d800 nid=0x167f9 waiting for monitor entry [0x00007f757f772000]
java.lang.Thread.State: BLOCKED (on object monitor)
at org.apache.ibatis.ognl.OgnlRuntime.invokeMethod(OgnlRuntime.java:1151)
- waiting to lock <0x00000004a55aaf48> (a java.lang.reflect.Method)
at org.apache.ibatis.ognl.OgnlRuntime.getMethodValue(OgnlRuntime.java:2146)
at org.apache.ibatis.ognl.ObjectPropertyAccessor.getPossibleProperty(ObjectPropertyAccessor.java:66)
at org.apache.ibatis.ognl.ObjectPropertyAccessor.getProperty(ObjectPropertyAccessor.java:160)
at org.apache.ibatis.ognl.OgnlRuntime.getProperty(OgnlRuntime.java:3356)
...

# 获取到锁的线程
"HSFBizProcessor-DEFAULT-6-thread-5179" #90650 daemon prio=10 os_prio=0 tid=0x00007f794c319000 nid=0x16214 waiting for monitor entry [0x00007f758366e000]
java.lang.Thread.State: BLOCKED (on object monitor)
at org.apache.ibatis.ognl.OgnlRuntime.invokeMethod(OgnlRuntime.java:1151)
- locked <0x00000004a55aaf48> (a java.lang.reflect.Method)
at org.apache.ibatis.ognl.OgnlRuntime.getMethodValue(OgnlRuntime.java:2146)
at org.apache.ibatis.ognl.ObjectPropertyAccessor.getPossibleProperty(ObjectPropertyAccessor.java:66)
at org.apache.ibatis.ognl.ObjectPropertyAccessor.getProperty(ObjectPropertyAccessor.java:160)
at org.apache.ibatis.ognl.OgnlRuntime.getProperty(OgnlRuntime.java:3356)
...

¶源码分析

从上面的线程堆栈信息可以看到所有的线程都阻塞在org.apache.ibatis.ognl.OgnlRuntime.invokeMethod(OgnlRuntime.java:1151),看看源码这里在干啥呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105

public static Object invokeMethod(Object target, Method method, Object[] argsArray)
throws InvocationTargetException, IllegalAccessException
{
boolean syncInvoke;
boolean checkPermission;
Boolean methodAccessCacheValue;
Boolean methodPermCacheValue;

...

// only synchronize method invocation if it actually requires it
// 这里就是堆栈中的1151行,对method加锁,然后将method的方法可见性和方法执行权限缓存
synchronized(method) {
methodAccessCacheValue = _methodAccessCache.get(method);
if (methodAccessCacheValue == null) {
// 检查方法是否是public的
if (!Modifier.isPublic(method.getModifiers()) || !Modifier.isPublic(method.getDeclaringClass().getModifiers()))
{
// 检查是否是可访问的
if (!(((AccessibleObject) method).isAccessible()))
{
methodAccessCacheValue = Boolean.TRUE;
_methodAccessCache.put(method, methodAccessCacheValue);
} else
{
methodAccessCacheValue = Boolean.FALSE;
_methodAccessCache.put(method, methodAccessCacheValue);
}
} else
{
methodAccessCacheValue = Boolean.FALSE;
_methodAccessCache.put(method, methodAccessCacheValue);
}
}
// 如果不可访问标记为同步执行
syncInvoke = Boolean.TRUE.equals(methodAccessCacheValue);

methodPermCacheValue = _methodPermCache.get(method);
if (methodPermCacheValue == null) {
if (_securityManager != null) {
try
{
// 检查方法执行权限
_securityManager.checkPermission(getPermission(method));
methodPermCacheValue = Boolean.TRUE;
_methodPermCache.put(method, methodPermCacheValue);
} catch (SecurityException ex) {
methodPermCacheValue = Boolean.FALSE;
_methodPermCache.put(method, methodPermCacheValue);
throw new IllegalAccessException("Method [" + method + "] cannot be accessed.");
}
}
else {
methodPermCacheValue = Boolean.TRUE;
_methodPermCache.put(method, methodPermCacheValue);
}
}
checkPermission = Boolean.FALSE.equals(methodPermCacheValue);
}

Object result;

if (syncInvoke) //if is not public and is not accessible
{
// 加锁同步反射调用method,因为需要先将方法设置为可访问,反射调用完,再将其设置为不可访问
// 如果不加锁,并发时可能导致方法反射调用失败
// 线程A -> _accessibleObjectHandler.setAccessible(method, false);
// 线程B -> result = invokeMethodInsideSandbox(target, method, argsArray);
synchronized(method)
{
if (checkPermission)
{
try
{
_securityManager.checkPermission(getPermission(method));
} catch (SecurityException ex) {
throw new IllegalAccessException("Method [" + method + "] cannot be accessed.");
}
}

_accessibleObjectHandler.setAccessible(method, true);
try {
result = invokeMethodInsideSandbox(target, method, argsArray);
} finally {
_accessibleObjectHandler.setAccessible(method, false);
}
}
} else
{
if (checkPermission)
{
try
{
_securityManager.checkPermission(getPermission(method));
} catch (SecurityException ex) {
throw new IllegalAccessException("Method [" + method + "] cannot be accessed.");
}
}

result = invokeMethodInsideSandbox(target, method, argsArray);
}

return result;
}

完整源码请查看:https://github.com/orphan-oss/ognl/blob/OGNL_3_3_0/src/main/java/ognl/OgnlRuntime.java#L1151

我们项目mybatis的版本是3.5.9,对应的ognl版本是3.3.0,从/mybatis-3.5.9.jar!/META-INF/maven/ognl/ognl/pom.xml中可以看到对应的版本,mybatis是将ognl的class直接构建到了mybatis的jar包中了。

从上面的源码分析结合堆栈分析,所有的线程都阻塞在1151行synchronized(method) {,等待获取method的访问权限和执行权限,证明有大量线程要去调用同一个method对象,这是为什么呢?

¶问题剖析

为什么有大量线程在mybatis的查询中都在反射调用相同的方法呢,这就要从MyBatis-Plus说起了,项目使用的是MyBatis-Plus做动态SQL拼装,例如下面的动态SQL:

1
2
3
4
5
6
7
8
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getName, "A")
.eq(User::getAge, 20)
.like(User::getEmail, "a")
.gt(User::getScore, 60)
.orderByAsc(User::getId);

service.list(wrapper);

实际上调用的是com.baomidou.mybatisplus.core.mapper.BaseMapper.selectList方法,其动态SQL的XML如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<script>
<if test="ew != null and ew.sqlFirst != null">
${ew.sqlFirst}
</if>
SELECT
<choose>
<when test="ew != null and ew.sqlSelect != null">
${ew.sqlSelect}
</when>
<otherwise>ID,NAME,AGE,EMAIL,SCORE</otherwise>
</choose>
FROM user
<if test="ew != null">
<where>
<if test="ew.entity != null">
<!-- -->
<if test="ew.entity['id'] != null"> AND ID=#{ew.entity.idB}</if>
</if>
<if test="ew.sqlSegment != null and ew.sqlSegment != '' and ew.nonEmptyOfWhere">
<if test="ew.nonEmptyOfEntity and ew.nonEmptyOfNormal"> AND</if>
${ew.sqlSegment}
</if>
</where>
<if test="ew.sqlSegment != null and ew.sqlSegment != '' and ew.emptyOfWhere">
${ew.sqlSegment}
</if>
</if>
<if test="ew != null and ew.sqlComment != null">
${ew.sqlComment}
</if>
</script>

其中BaseMapper.selectList方法的SQL脚本是由com.baomidou.mybatisplus.core.injector.methods.SelectList注入的。
SQL注入器的详细文档请参考:https://baomidou.com/guides/sql-injector/

从上面的动态SQL分析可以发现,每一个动态SQL在mybatis解析OGNL表达式时都必然会通过反射获取Wrapper对象这些属性的值:sqlFirst、sqlSelect、sqlSegment、nonEmptyOfEntity、sqlComment。再高并发的时候,都会通过反射调用这些属性对应的get方法获取这些属性的值,那么synchronized(method)锁的都是相同的方法也就不奇怪了。

¶解决方案

从上面的线程堆栈以及源码分析,我们知道了问题产生的原因,那么我们将synchronized(method)锁的的竞争降低就可以解决问题,于是我们可以这样修改Ognl的源码,给method加上Double null ckeck:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public static Object invokeMethod(Object target, Method method, Object[] argsArray)
throws InvocationTargetException, IllegalAccessException {
boolean syncInvoke;
Boolean methodAccessCacheValue;

...

// only synchronize method invocation if it actually requires it
methodAccessCacheValue = _methodAccessCache.get(method);
// double null check to avoid synchronizing on the method
if (methodAccessCacheValue == null) {
synchronized (method) {
methodAccessCacheValue = _methodAccessCache.get(method);
if (methodAccessCacheValue == null) {
if (!Modifier.isPublic(method.getModifiers()) || !Modifier.isPublic(method.getDeclaringClass().getModifiers())) {
var obj = Modifier.isStatic(method.getModifiers()) ? null : target;
if (method.canAccess(obj)) {
methodAccessCacheValue = Boolean.FALSE;
_methodAccessCache.put(method, methodAccessCacheValue);
} else {
methodAccessCacheValue = Boolean.TRUE;
_methodAccessCache.put(method, methodAccessCacheValue);
}
} else {
methodAccessCacheValue = Boolean.FALSE;
_methodAccessCache.put(method, methodAccessCacheValue);
}
}
syncInvoke = Boolean.TRUE.equals(methodAccessCacheValue);

_methodPermCache.putIfAbsent(method, Boolean.TRUE);
}
} else {
syncInvoke = Boolean.TRUE.equals(methodAccessCacheValue);
}
...
}

上面是基于https://github.com/orphan-oss/ognl最新的源码修改,和本文项目中用到的3.3.0版本有细微差异,但问题也同样存在。

经过如上修改OgnlRuntime#invokeMethod源码升级项目后,再也没出现过OgnlRuntime.invokeMethod阻塞的问题了。

具体的issue和Pull request请查看:

  • https://github.com/mybatis/mybatis-3/issues/3589
  • https://github.com/orphan-oss/ognl/pull/521

OGNL社区已采纳,已合并到3.4.10版本发布。

参考资料:

  • OGNL 源码:https://github.com/orphan-oss/ognl/blob/OGNL_3_3_0/src/main/java/ognl/OgnlRuntime.java#L1151

  • MyBatis-Plus 官方文档(SQL注入器):https://baomidou.com/guides/sql-injector/

  • 向MyBatis社区提交的Issue:https://github.com/mybatis/mybatis-3/issues/3589

  • 向OGNL社区提交的Pull Request:https://github.com/orphan-oss/ognl/pull/521

性能优化-巧解MyBatis高并发下OGNL安全检查导致的全局锁瓶颈

发表于 2025-12-30 | 更新于 2026-04-08 | 分类于 性能优化 | 评论数:

¶性能优化-巧解MyBatis高并发下OGNL安全检查导致的全局锁瓶颈

摘要:线上接口频繁超时,HSF线程池爆满。经排查,发现大量线程并非阻塞在数据库,而是卡在System.getProperty方法上。其根本原因是MyBatis使用的OGNL表达式引擎在每次解析动态SQL时,都会同步查询ognl.security.manager系统属性。由于Properties使用synchronized全局锁,高并发下引发严重性能瓶颈。最终通过为JVM添加-Dognl.security.manager=forceDisableOnInit参数,在初始化时即禁用安全检查,从而彻底避免了锁竞争,系统性能得到显著恢复。

¶背景

线上环境频繁报接口响应超时,很多节点同时报[HSF-Provider] HSF thread pool is full.,于是我们dump线程信息进行分析,发现居然大部分线程都卡在mybatis查询数据库了,我们难道遇到mybatis的性能问题了吗?

¶问题排查过程

¶线程堆栈分析

分析dump出来的线程堆栈信息,发现有752个线程都BLOCKED在java.lang.System.getProperty方法,该方法是获取JVM或操作系统中的系统属性值,具体堆栈信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 752个等待获取锁的线程
"HSFBizProcessor-DEFAULT-6-thread-4702" #27229 daemon prio=10 os_prio=0 tid=0x00007f032c110800 nid=0x6a50 waiting for monitor entry [0x00007efeeaae8000]
java.lang.Thread.State: BLOCKED (on object monitor)
at java.util.Hashtable.get(Hashtable.java:363)
- waiting to lock <0x00000004401b0198> (a java.util.Properties)
at java.util.Properties.getProperty(Properties.java:969)
at java.lang.System.getProperty(System.java:741)
at org.apache.ibatis.ognl.OgnlRuntime.invokeMethodInsideSandbox(OgnlRuntime.java:1244)
at org.apache.ibatis.ognl.OgnlRuntime.invokeMethod(OgnlRuntime.java:1230)
at org.apache.ibatis.ognl.OgnlRuntime.getMethodValue(OgnlRuntime.java:2146)
....

# 获取到锁的线程
"task-46" #834 prio=5 os_prio=0 tid=0x00007f02a4075000 nid=0x336 waiting for monitor entry [0x00007eff47135000]
java.lang.Thread.State: BLOCKED (on object monitor)
at java.util.Hashtable.get(Hashtable.java:363)
- locked <0x00000004401b0198> (a java.util.Properties)
at java.util.Properties.getProperty(Properties.java:969)
at java.lang.System.getProperty(System.java:741)
at org.apache.ibatis.ognl.OgnlRuntime.invokeMethodInsideSandbox(OgnlRuntime.java:1244)
at org.apache.ibatis.ognl.OgnlRuntime.invokeMethod(OgnlRuntime.java:1230)
at org.apache.ibatis.ognl.OgnlRuntime.getMethodValue(OgnlRuntime.java:2146)
...

¶源码分析

从上面的堆栈信息可以看到全部都阻塞在java.util.Hashtable.get方法,Properties是继承Hashtable的,而众所周知Hashtable是线程安全的,我们来一起看看JDK中这两个类涉及的部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Properties extends Hashtable<Object,Object> {

public String getProperty(String key) {
Object oval = super.get(key);
String sval = (oval instanceof String) ? (String)oval : null;
return ((sval == null) && (defaults != null)) ? defaults.getProperty(key) : sval;
}

}

public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {

public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}

}

synchronized是悲观锁,在高并发情况下确实会有性能问题,那么为什么会有这么大的并发量同时获取系统属性呢,我们一起来看mybatis中OgnlRuntime.invokeMethodInsideSandbox方法中1244行在干什么呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static Object invokeMethodInsideSandbox(Object target, Method method, Object[] argsArray)
throws InvocationTargetException, IllegalAccessException {

if (_disableOgnlSecurityManagerOnInit) {
return method.invoke(target, argsArray); // Feature was disabled at OGNL initialization.
}

try {
// 这里就是1244行,所有线程都阻塞到这里了
if (System.getProperty("ognl.security.manager") == null) {
return method.invoke(target, argsArray);
}
} catch (SecurityException ignored) {
// already enabled or user has applied a policy that doesn't allow read property so we have to honor user's sandbox
}
...
}

ognl.security.manager属性是为了增强OGNL表达式执行的安全性而引入的。通过启用它,可以防止恶意的OGNL表达式执行危险操作。

项目使用的是MyBatis-Plus进行动态SQL拼装,大量数据库查询都要解析OGNL表达式,每一次OGNL表达式解析都要获取一次ognl.security.manager属性,所以出现了大量线程阻塞在java.util.Hashtable.get这个方法。

完整源码请查看:https://github.com/orphan-oss/ognl/blob/OGNL_3_3_0/src/main/java/ognl/OgnlRuntime.java#L1236

我们项目mybatis的版本是3.5.9,对应的ognl版本是3.3.0,从/mybatis-3.5.9.jar!/META-INF/maven/ognl/ognl/pom.xml中可以看到对应的版本,mybatis是将ognl的class直接构建到了mybatis的jar包中了。

¶解决方案

上面我们通过源码分析到是由于大量的线程都在同时执行System.getProperty("ognl.security.manager")获取属性,而java.util.Hashtable.get这个方法是同步的,我们可以从减少System.getProperty("ognl.security.manager")的调用量的思路入手。

我们继续来看OgnlRuntime.invokeMethodInsideSandbox方法,当_disableOgnlSecurityManagerOnInit属性为true时直接反射调用方法返回结果。_disableOgnlSecurityManagerOnInit初始化逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* Control usage of the OGNL Security Manager using the JVM option:
* -Dognl.security.manager=true (or any non-null value other than 'disable')
*
* Omit '-Dognl.security.manager=' or nullify the property to disable the feature.
*
* To forcibly disable the feature (only possible at OGNL Library initialization, use the option:
* -Dognl.security.manager=forceDisableOnInit
*
* Users that have their own Security Manager implementations and no intention to use the OGNL SecurityManager
* sandbox may choose to use the 'forceDisableOnInit' flag option for performance reasons (avoiding overhead
* involving the system property security checks - when that feature will not be used).
*/
static final String OGNL_SECURITY_MANAGER = "ognl.security.manager";
static final String OGNL_SM_FORCE_DISABLE_ON_INIT = "forceDisableOnInit";

/**
* Hold environment flag state associated with OGNL_SECURITY_MANAGER. See
* {@link OgnlRuntime#OGNL_SECURITY_MANAGER} for more details.
* Default: false (if not set).
*/
private static final boolean _disableOgnlSecurityManagerOnInit;
static {
boolean initialFlagState = false;
try {
final String propertyString = System.getProperty(OGNL_SECURITY_MANAGER);
if (propertyString != null && propertyString.length() > 0) {
initialFlagState = OGNL_SM_FORCE_DISABLE_ON_INIT.equalsIgnoreCase(propertyString);
}
} catch (Exception ex) {
// Unavailable (SecurityException, etc.)
}
_disableOgnlSecurityManagerOnInit = initialFlagState;
}

因此在jvm参数中添加-Dognl.security.manager=forceDisableOnInit即可在初始化时禁用
OGNL SecurityManager。

由于目前项目并没有配置ognl.security.manager属性,if (System.getProperty("ognl.security.manager") == null)始终为true,大量无效调用System.getProperty("ognl.security.manager")。因此修改应用的jvm配置添加-Dognl.security.manager=forceDisableOnInit即可解决高并发下OGNL获取ognl.security.manager属性导致的性能问题。

注意:OGNL SecurityManager处于关闭状态,为保证系统安全,在编写动态SQL时应该避免在 OGNL 表达式中使用用户可控的参数并结合其它手段防止恶意注入

参考资料:

  • https://github.com/orphan-oss/ognl/blob/OGNL_3_3_0/src/main/java/ognl/OgnlRuntime.java#L1236

Spring AI MCP服务内存泄漏排查实录:从堆分析到源码修复

发表于 2025-07-03 | 更新于 2026-04-08 | 分类于 AI | 评论数:

¶Spring AI MCP服务内存泄漏排查实录:从堆分析到源码修复

Spring AI构建的MCP服务频繁OOM?本文完整记录问题排查全链路:
1️⃣ 通过MAT精准定位WebMvcSseServerTransport中未释放的会话占99.59%内存
2️⃣ 发现SDK 0.7.0版本仅在异常时清理会话的设计缺陷
3️⃣ 升级1.0.0版本后仍存在异步连接残留问题
4️⃣ 最后采用"心跳检测+异常熔断"双保险机制
👉 关键方案:定时发送轻量级消息sendNotification,实现自动回收失效连接,彻底解决内存泄漏。附完整堆分析截图、源码对比!

上一篇文章我们介绍了如何使用Spring AI快速构建一个MCP Server:Spring AI+MCP实战:零代码改造将传统服务接入大模型生态,但是服务启动一段时间后,总是是内存溢出,导致MCP服务时不时就不可用,必须得重启才能解决。

配置java参数当内存溢出时自动转储堆,然后分析堆内存,终于发现了罪魁祸首,接下来就让我们一起来看看罪魁祸首是谁。

¶分析堆内存

使用MAT(Eclipse Memory Analyzer)打开自动转储的堆文件,加载完成后打开Leak Suspects可以发现内存泄露的可疑点:

内存泄露疑点.png

从上图可以发现,由io.modelcontextprotocol.server.transport.WebMvcSseServerTransport @ 0x700730098对象持有的java.util.concurrent.ConcurrentHashMap$Node[]占用了99.59%的内存。

到这里基本就可以确定内存泄露的罪魁祸首就是WebMvcSseServerTransport,具体是其中的哪个对象呢,让我们继续分析。

点击Eclipse Memory Analyzer上的dominator_tree可以看到堆内存中对象的树形结构信息,这里根据Retained Heap降序排列,可以看到占用内存最多的对象java.util.concurrent.ConcurrentHashMap$Node[],右键 -》Path To GC Roots -》with all references,可以看到泄露对象到gc roots的路径,可以清晰的看到是被谁持有但一直未释放。

内存泄露GcRoots.png

到这里我们知道了是WebMvcSseServerTransport#sessions属性持有了有大量的Map节点,但一直没释放,最终导致JVM内存溢出了。

MAT工具的文档详见文末的参考链接

¶源码分析

之前2025年3月份根据官方文档: https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html集成的时候,引入starter为:

1
2
3
4
5
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-server-webmvc-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>

其中引入的io.modelcontextprotocol.sdk:mcp-spring-webmvc的版本为0.7.0,WebMvcSseServerTransport的**核心实现(省略部分与本次内存溢出问题无关的代码)**如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class WebMvcSseServerTransport implements ServerMcpTransport {

private final ConcurrentHashMap<String, ClientSession> sessions;

private ServerResponse handleSseConnection(ServerRequest request) {
if (this.isClosing) {
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");
} else {
String sessionId = UUID.randomUUID().toString();
logger.debug("Creating new SSE connection for session: {}", sessionId);

try {
return ServerResponse.sse((sseBuilder) -> {
ClientSession session = new ClientSession(sessionId, sseBuilder);
this.sessions.put(sessionId, session);

try {
session.sseBuilder.id(session.id).event("endpoint").data(this.messageEndpoint);
} catch (Exception e) {
logger.error("Failed to poll event from session queue: {}", e.getMessage());
sseBuilder.error(e);
}

});
} catch (Exception e) {
logger.error("Failed to send initial endpoint event to session {}: {}", sessionId, e.getMessage());
// 只有出现异常的时候才将session移除
this.sessions.remove(sessionId);
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

private static class ClientSession {
private final String id;
private final ServerResponse.SseBuilder sseBuilder;

ClientSession(String id, ServerResponse.SseBuilder sseBuilder) {
this.id = id;
this.sseBuilder = sseBuilder;
WebMvcSseServerTransport.logger.debug("Session {} initialized with SSE emitter", id);
}

void close() {
WebMvcSseServerTransport.logger.debug("Closing session: {}", this.id);

try {
// session关闭时,只将sseBuilder设置为完成
this.sseBuilder.complete();
WebMvcSseServerTransport.logger.debug("Successfully completed SSE emitter for session {}", this.id);
} catch (Exception e) {
WebMvcSseServerTransport.logger.warn("Failed to complete SSE emitter for session {}: {}", this.id, e.getMessage());
}

}
}

}

从上面代码可以发现,在MCP的ClientSession关闭时,只是将sseBuilder设置为完成;仅当handleSseConnection中出现异常时才会将ClientSession从sessions中移除,估计是想客户端一直复用这个连接吧。

那么正常情况下,这个session就会一直存在于WebMvcSseServerTransport#sessions属性中,而WebMvcSseServerTransport对象在MCP服务运行时会一直存活,因此一段时间后MCP服务就会因为WebMvcSseServerTransport#sessions属性内存泄露最终导致jvm的内存溢出。

¶SDK升级

经过上面的源码分析,我们知道了内存泄露的具体原因是WebMvcSseServerTransport#sessions的ClientSession一直在增长,因此只需要在ClientSession完成或异常的时候将其从sessions中移除即可。

经查看最新的官方文档: https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html,其中对于MCP Server Boot Starter已经做了升级,升级到1.0.0版本后,可以看到最新的版本是由WebMvcSseServerTransportProvider来管理see请求的,处理sse请求的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private ServerResponse handleSseConnection(ServerRequest request) {
if (this.isClosing) {
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");
} else {
String sessionId = UUID.randomUUID().toString();
logger.debug("Creating new SSE connection for session: {}", sessionId);

try {
return ServerResponse.sse((sseBuilder) -> {
sseBuilder.onComplete(() -> {
logger.debug("SSE connection completed for session: {}", sessionId);
this.sessions.remove(sessionId);
});
sseBuilder.onTimeout(() -> {
logger.debug("SSE connection timed out for session: {}", sessionId);
this.sessions.remove(sessionId);
});
WebMvcMcpSessionTransport sessionTransport = new WebMvcMcpSessionTransport(sessionId, sseBuilder);
McpServerSession session = this.sessionFactory.create(sessionTransport);
this.sessions.put(sessionId, session);

try {
sseBuilder.id(sessionId).event("endpoint").data(this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId);
} catch (Exception e) {
logger.error("Failed to send initial endpoint event: {}", e.getMessage());
sseBuilder.error(e);
}

}, Duration.ZERO);
} catch (Exception e) {
logger.error("Failed to send initial endpoint event to session {}: {}", sessionId, e.getMessage());
this.sessions.remove(sessionId);
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

其中可以看到,在请求完成、超时和异常情况下都会将session移除,这样应该就能解决内存溢出问题了。

问题到这里真的解决了吗?

¶解决方案

经过验证,发现升级后的SDK里面WebMvcSseServerTransport#sessions中存放的McpServerSession还是会一直存在,并没有移除。

可能是异步请求的请求,Cursor之类的客户端在创建MCP连接后,即使Cursor关闭后也没有主动去告诉服务端断开连接,也就是不会触发onComplete、onTimeout方法去将session移除。

于是,我们可以定时去检测WebMvcSseServerTransport#sessions中的McpServerSession是否还存活,如果客户端已经把连接关闭了,那么就将session移除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
* session管理,避免内存溢出
*
* @date 2025/07/01 16:18
**/
@Slf4j
@Configuration
@EnableScheduling
public class McpSessionConfig {

@Autowired
private WebMvcSseServerTransportProvider sseServerTransportProvider;

@Value("${mcp.session.health-check-enabled:true}")
private boolean healthCheckEnabled;

@Value("${mcp.session.health-check-interval:1800000}")
private long healthCheckInterval;

@Value("${mcp.session.health-check-timeout:5000}")
private long healthCheckTimeout;

/**
* 定时任务:定期执行session存活检测
* 从sseServerTransportProvider中获取sessions,遍历检测session是否存活,
* 使用sendNotification进行检测,检测失败的session需要自动移除
*/
@Scheduled(fixedRateString = "${mcp.session.health-check-interval:1800000}")
public void checkSessionHealth() {
// 检查是否启用健康检测
if (!healthCheckEnabled) {
log.debug("MCP session健康检测已禁用");
return;
}
try {
log.info("开始执行MCP session存活检测任务");

// 获取所有活跃的sessions
Map<String, McpServerSession> sessionsMap = (Map<String, McpServerSession>) getFieldValue(sseServerTransportProvider, "sessions");
if (null == sessionsMap || sessionsMap.isEmpty()) {
log.info("当前没有活跃的MCP sessions");
return;
}

log.info("检测到 {} 个活跃sessions,开始进行存活检测", sessionsMap.size());

// 遍历检测每个session的存活状态
List<CompletableFuture<Void>> futures = new CopyOnWriteArrayList<>();
for (McpServerSession session : sessionsMap.values()) {
// 使用sendNotification进行检测,getCurrentTime是一个MCP的Tool方法
// 发送一个轻量级的ping消息来检测连接是否有效
Mono<Void> mono = session.sendNotification("getCurrentTime");
futures.add(mono.toFuture());
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
log.info("MCP session存活检测任务执行完成,剩余session数量:{}", sessionsMap.size());
} catch (Exception e) {
log.error("执行MCP session存活检测任务时发生异常", e);
}
}

private Object getFieldValue(Object obj, String fieldName) {
try {
Field field = FieldUtils.getField(obj.getClass(), fieldName, true);
field.setAccessible(true);
return field.get(obj);
} catch (Exception e) {
log.error("获取字段值时发生异常", e);
return null;
}
}
}

发送检测消息后session.sendNotification("getCurrentTime"),Tomcat中间件会检测到该sse连接是否还存活,如果连接已断开会有如下异常信息(省略部分堆栈):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2025-07-02 14:01:31.064 [http-nio-8089-exec-13] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] threw exception
java.io.IOException: 断开的管道
at java.base/sun.nio.ch.FileDispatcherImpl.write0(Native Method)
at java.base/sun.nio.ch.SocketDispatcher.write(SocketDispatcher.java:62)
at java.base/sun.nio.ch.IOUtil.writeFromNativeBuffer(IOUtil.java:132)
at java.base/sun.nio.ch.IOUtil.write(IOUtil.java:97)
at java.base/sun.nio.ch.IOUtil.write(IOUtil.java:53)
at java.base/sun.nio.ch.SocketChannelImpl.write(SocketChannelImpl.java:532)
...
at io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider$WebMvcMcpSessionTransport.lambda$sendMessage$0(WebMvcSseServerTransportProvider.java:364)
at reactor.core.publisher.MonoRunnable.subscribe(MonoRunnable.java:49)
at reactor.core.publisher.Mono.subscribe(Mono.java:4576)
at reactor.core.publisher.Mono.subscribeWith(Mono.java:4641)
at reactor.core.publisher.Mono.toFuture(Mono.java:5153)
at com.teddy.smd.mcp.config.McpSessionConfig.checkSessionHealth(McpSessionConfig.java:74)

Tomcat中间件在org.apache.catalina.core.AsyncContextImpl#doInternalDispatch中检测到异常后,会将连接设置为完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
protected void doInternalDispatch() throws ServletException, IOException {
if (log.isTraceEnabled()) {
this.logDebug("intDispatch");
}

try {
Runnable runnable = this.dispatch;
this.dispatch = null;
runnable.run();
if (!this.request.isAsync()) {
this.fireOnComplete();
}

} catch (RuntimeException x) {
AtomicBoolean result = new AtomicBoolean();
this.request.getCoyoteRequest().action(ActionCode.IS_IO_ALLOWED, result);
if (!result.get()) {
// 将连接设置为完成
this.fireOnComplete();
}

if (x.getCause() instanceof ServletException) {
throw (ServletException)x.getCause();
} else if (x.getCause() instanceof IOException) {
throw (IOException)x.getCause();
} else {
throw new ServletException(x);
}
}
}

连接设置为完成后,最终会触发WebMvcSseServerTransport#handleSseConnection方法中的sseBuilder.onComplete回调中将session进行移除

1
2
3
4
sseBuilder.onComplete(() -> {
logger.debug("SSE connection completed for session: {}", sessionId);
sessions.remove(sessionId);
});

检测的日志输出情况如下:

1
2
3
4
5
2025-07-02 14:01:31.053 [scheduling-1] INFO  c.t.smd.mcp.config.McpSessionConfig - 检测到 15 个活跃sessions,开始进行存活检测
2025-07-02 14:01:31.062 [scheduling-1] ERROR i.m.s.t.WebMvcSseServerTransportProvider - Failed to send message to session a9af3b98-35fc-4769-b84f-fedbdc384977: ServletOutputStream failed to flush: java.io.IOException: 断开的管道
2025-07-02 14:01:31.063 [scheduling-1] ERROR i.m.s.t.WebMvcSseServerTransportProvider - Failed to send message to session 7b88ebdc-abd3-41fe-be98-75ed217dcfe6: ServletOutputStream failed to flush: java.io.IOException: 断开的管道
...
2025-07-02 14:01:31.063 [scheduling-1] INFO c.t.smd.mcp.config.McpSessionConfig - MCP session存活检测任务执行完成,剩余session数量:4

到这里终于完美解决MCP服务因为连接泄露导致的内存溢出问题。

参考链接:

  • https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html
  • https://help.eclipse.org/latest/index.jsp?topic=/org.eclipse.mat.ui.help/welcome.html

MCP Server开发实践

发表于 2025-05-16 | 更新于 2026-04-08 | 分类于 AI | 评论数:

¶MCP Server开发实践

上一篇文章我们介绍了MCP的基本原理,但是对于开发者来说更关心如何实现我们的自己的MCP Server,接下来我们将使用MCP提供的java sdk和spring-ai来实现一个MCP Server。

¶构建Spring Boot服务

技术选型:

  • Spring Boot 3.4.2,因为Spring AI支持的Spring Boot版本为3.4.x

Spring AI supports Spring Boot 3.4.x. When Spring Boot 3.5.x is released, we will support that as well.

  • JDK 17,Spring Boot3需要使用JDK 17
  • spring-ai-mcp-server-webmvc-spring-boot-starter 1.0.0-M6,Spring AI支持三种方式提供服务,这里采用webmvc
    • Standard Input/Output (STDIO) - spring-ai-starter-mcp-server
    • Spring MVC (Server-Sent Events) - spring-ai-starter-mcp-server-webmvc
    • Spring WebFlux (Reactive SSE) - spring-ai-starter-mcp-server-webflux
  • httpclient5,将现有服务的HTTP接口暴露为MCP Server

pom.xml文件示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.4.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!-- MCP -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-server-webmvc-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- Apache HttpClient -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5-h2</artifactId>
</dependency>

<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

¶MCP配置

  • application.yml
1
2
3
4
5
6
7
8
9
10
spring:
application:
name: mcp-name
ai:
mcp:
server:
name: mcp-name
version: 1.0.0
type: SYNC
sse-endpoint: /sse
  • Server实现,这里直接通过转发的方式将现有的HTTP接口暴露为MCP Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* @date 2025/03/27 14:52
**/
@Service
public class SmdMcpService {

@Autowired
private RestTemplate restTemplate;

@Value("${smd.service.url}")
private String smdServiceUrl;

@Tool(name = "getSmdInfo", description = "获取表结构信息")
public String getSmdInfo(@ToolParam(description = "业务系统") String businessSystem,
@ToolParam(description = "表名") Set<String> tableNames) {
Map<String, Object> params = new HashMap<>();
params.put("businessSystem", businessSystem);
params.put("tableNames", tableNames);

ResponseEntity<String> response = restTemplate.postForEntity(
smdServiceUrl + "/mcp/api/getSmdInfo",
params,
String.class);
return response.getBody();
}

@Tool(name = "getCRUDCode", description = "根据表名生成增删改查代码")
public List<Map<String, Object>> getCRUDByTable(@ToolParam(description = "业务系统") String businessSystem,
@ToolParam(description = "表名") Set<String> tableNames,
@ToolParam(description = "模块名,非必填") String moduleName
) {
Map<String, Object> params = new HashMap<>();
params.put("businessSystem", businessSystem);
params.put("tableNames", tableNames);
params.put("moduleName", moduleName);
params.put("author", "smd-mcp");

HttpEntity<Map<String, Object>> httpEntity = new HttpEntity<>(params);
ResponseEntity<List<Map<String, Object>>> response = restTemplate.exchange(
smdServiceUrl + "/mcp/api/crud",
HttpMethod.POST,
httpEntity,
new ParameterizedTypeReference<List<Map<String, Object>>>() {});
return response.getBody();
}
}
  • 将对应服务暴露为Mcp Tools
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* MCP配置类
*
* @author AI Assistant
* @date 2024/03/21
*/
@Configuration
@Slf4j
public class McpConfig {

@Bean
public ToolCallbackProvider smdToolCallbackProvider(SmdMcpService smdMcpService, RulesMcpService rulesMcpService) {
return MethodToolCallbackProvider.builder()
.toolObjects(smdMcpService, rulesMcpService)
.build();
};
}

¶测试MCP

使用支持MCP的客户端进行测试,客户端支持情况可查看:https://modelcontextprotocol.io/clients

这里使用Cursor进行测试:

配置mcp.json

1
2
3
4
5
6
7
8
9
10
{
"mcpServers": {
"mcp-name": {
"url": "http://localhost:8089/sse",
"env": {
"API_KEY": "value"
}
}
}
}

配置后即可看到MCP Server提供的Tools,如下图所示:
Cursor MCP配置.png

¶MCP思考

MCP 还处于发展初期,现阶段更重要的是生态构建,基于统一标准下构筑的生态也会正向的促进整个领域的发展。

对于普通开发者我们可以直接使用已有的MCP工具平台:https://mcp.so/

对于企业,我们可以通过代理的方式将已有HTTP接口暴露为MCP Server:

  • 零侵入改造:无需修改原有HTTP服务代码即可获得MCP能力
  • 跨模型兼容:让原本不支持MCP的传统服务获得与大模型生态无缝对接的能力
  • 低成本投入:已有业务接入MCP生态的改造周期大幅缩短

后续考虑将其做成MCP代理服务,通过简单配置即可将已有业务转换为MCP Server,为AI智能体打开潘多拉的魔盒

参考文档:

  • https://modelcontextprotocol.io/sdk/java/mcp-server
  • https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html
  • https://docs.spring.io/spring-ai/reference/getting-started.html

MCP网关

发表于 2025-05-14 | 更新于 2026-04-08 | 分类于 AI | 评论数:

¶MCP网关

类似开源项目:
https://github.com/mcp-ecosystem/mcp-gateway

技术选型:

  • 后端:
    • SpringBoot 3
    • spring-ai-mcp-server-webmvc-spring-boot-starter
    • PostgreSql
    • MybatisPlus
    • druid管理数据库连接
    • flyway管理数据库脚本,脚本放在resources/db目录下,初始化版本为V0.0
  • 前端:
    • vue3
    • 使用vite进行包管理
  • 数据库:脚本放在resources/db目录下,初始化版本为V0.0
    • t_user:存储用户信息
    • t_mcp_project:存储项目信息
    • t_mcp_project_user:存储项目中的人员信息,关联t_user
    • t_mcp_tools:存储代理的tools,关联到每个具体的项目信息

后端目录:server
前端目录:web

实现功能:

  • 登录页面:输入用户名密码进行登录,支持手动注册用户

MCP简介及其工作原理

发表于 2025-05-14 | 更新于 2026-04-08 | 分类于 AI | 评论数:

摘要:Anthropic推出的模型上下文协议(MCP)通过标准化接口实现大语言模型与内外部工具的安全互联。本文详解MCP工作原理,揭示Prompt工程如何成为工具调用的关键枢纽,为AI应用生态提供"即插即用"式扩展能力。

¶模型上下文协议(Model Context Protocol,MCP)

模型上下文协议(Model Context Protocol,MCP),是由 Anthropic推出的开源协议,旨在实现大语言模型与外部数据源和工具的集成,用来在大模型和数据源之间建立安全双向的连接。

MCP 是一个开放协议,它标准化了应用程序向 LLM 提供上下文的方式。可以将 MCP 视为AI应用的 USB-C 端口。正如USB-C提供了一种标准化的方式将您的设备连接到各种外围设备和配件一样,MCP提供了一种标准化的方式将 AI 模型连接到不同的数据源和工具。

¶MCP架构

MCP遵循客户端 - 服务器架构,包含以下几个核心部分:

  • MCP 主机(MCP Hosts):发起请求的 AI 应用程序,比如聊天机器人、AI 驱动的 IDE 等。
  • MCP 客户端(MCP Clients):在主机程序内部,与 MCP 服务器保持 1:1 的连接。
  • MCP 服务器(MCP Servers):为 MCP 客户端提供上下文、工具和提示信息。
  • 本地资源(Local Resources):本地计算机中可供 MCP 服务器安全访问的资源,如文件、数据库。
  • 远程资源(Remote Resources):MCP 服务器可以连接到的远程资源,如通过 API 提供的数据。

MCP架构

¶大模型如何识别并调用MCP

LLM(模型)是在什么时候确定使用哪些工具的呢?Anthropic为我们提供了详细的解释,当用户提出一个问题时:

  • 客户端(Claude Desktop / Cursor)将问题发送给 LLM。
  • LLM 分析可用的工具,并决定使用哪一个(或多个),实际上模型是依靠prompt来识别当前可用的工具有哪些。
  • 客户端通过 MCP Server 执行所选的工具。
  • 工具的执行结果被送回给 LLM。LLM 结合执行结果,归纳总结后生成自然语言展示给用户!

MCP工作原理

我们可以参考MCP官方提供的python-sdk client example为讲解示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
async def start(self) -> None:
"""Main chat session handler."""
try:
# 初始化所有的 mcp server
for server in self.servers:
try:
await server.initialize()
except Exception as e:
logging.error(f"Failed to initialize server: {e}")
await self.cleanup_servers()
return

# 获取所有的 tools 命名为 all_tools
all_tools = []
for server in self.servers:
tools = await server.list_tools()
all_tools.extend(tools)

# 将所有的 tools 的功能描述格式化成字符串供 LLM 使用
tools_description = "\n".join([tool.format_for_llm() for tool in all_tools])

# 询问 LLM(Claude) 应该使用哪些工具。
system_message = (
"You are a helpful assistant with access to these tools:\n\n"
f"{tools_description}\n"
"Choose the appropriate tool based on the user's question. "
"If no tool is needed, reply directly.\n\n"
"IMPORTANT: When you need to use a tool, you must ONLY respond with "
"the exact JSON object format below, nothing else:\n"
"{\n"
' "tool": "tool-name",\n'
' "arguments": {\n'
' "argument-name": "value"\n'
" }\n"
"}\n\n"
"After receiving a tool's response:\n"
"1. Transform the raw data into a natural, conversational response\n"
"2. Keep responses concise but informative\n"
"3. Focus on the most relevant information\n"
"4. Use appropriate context from the user's question\n"
"5. Avoid simply repeating the raw data\n\n"
"Please use only the tools that are explicitly defined above."
)

messages = [{"role": "system", "content": system_message}]

while True:
try:
user_input = input("You: ").strip().lower()
if user_input in ["quit", "exit"]:
logging.info("\nExiting...")
break

messages.append({"role": "user", "content": user_input})

# 将 system_message 和用户消息输入一起发送给 LLM
llm_response = self.llm_client.get_response(messages)
logging.info("\nAssistant: %s", llm_response)

# 处理 LLM 的输出(如果有 tool call 则执行对应的工具)
result = await self.process_llm_response(llm_response)

# 如果 result 与 llm_response 不同,说明执行了 tool call (有额外信息了)
# 则将 tool call 的结果重新发送给 LLM 进行处理。
if result != llm_response:
messages.append({"role": "assistant", "content": llm_response})
messages.append({"role": "system", "content": result})

final_response = self.llm_client.get_response(messages)
logging.info("\nFinal response: %s", final_response)
messages.append(
{"role": "assistant", "content": final_response}
)
# 否则代表没有执行 tool call,则直接将 LLM 的输出返回给用户。
else:
messages.append({"role": "assistant", "content": llm_response})

except KeyboardInterrupt:
logging.info("\nExiting...")
break

finally:
await self.cleanup_servers()

根据上面的源码分析,可以看出工具文档至关重要。模型依赖于工具描述文本来理解和选择适用的工具,这意味着精心编写的工具名称、文档字符串(docstring)以及参数说明显得尤为重要。鉴于MCP的选择机制基于prompt实现,理论上任何模型只要能够提供相应的工具描述就能与MCP兼容使用。

参考文档:

  • https://modelcontextprotocol.io/introduction
  • https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py
12…4

fanzhongwei

个人经验总结:java、JavaScript、HTML、VUE等等
36 日志
13 分类
53 标签
GitHub E-Mail
© 2019 – 2026 fanzhongwei
由 Hexo 强力驱动 v3.9.0
|
主题 – NexT.Pisces v7.1.0
蜀ICP备17004833号-1
0%