☰
Spring AI + MCP 实战:构建实时基金估值助手与全链路观测
2026/10/13 7:21:39 网站建设 项目流程

如果你也做指数基金定投,大概率有过这样的体验:想判断今天某个指数是贵还是便宜,得先打开行情软件,翻到估值页面,再对比历史百分位。要是盘中着急看,这一套操作相当折腾。所以我做了一个小工具:用 Spring AI 搭一个能对话的基金估值助手,再通过 MCP(Model Context Protocol)把实时估值数据接进来,让大模型直接回答“沪深300现在贵不贵”“中证500的PE百分位是多少”。这个项目跑通之后,我又把从用户提问、工具调用、数据返回再到模型回复的全链路观测一起做了,文章后面会有很大一部分在讲这件事。如果你正在研究 Spring AI 怎么接 MCP,或者想给自己的 AI 应用加一层可观测能力,这篇实战记录应该能给你省不少时间。

1. 为什么估值数据必须走 MCP,而不是让大模型硬记

1.1 大模型再大,也背不住今天的沪深300市盈率

刚开始做这个项目时,我第一反应是直接把指数估值写进 system prompt,让模型记住“沪深300当前PE是XX倍”。这个方案被我很快毙掉了,原因很实际:估值数据是典型的高频变化数据,今天和明天可能差不少,盘中甚至每分钟都在动。大模型的训练数据有截止时间,它无法知道“此刻”的估值是多少,硬塞进 prompt 的结果就是它一本正经地胡编一个数字。

你可能会说,那让 AI 联网搜一下不就行了?实测效果也很一般。搜索引擎拿回来的是一堆页面摘要,模型要从里面猜哪个数是“PE”,哪个是“PB”,还得自己判断数据新不新鲜,错误率很高。而且搜索接口不稳定,有时候模型会为了凑答案编出一个“看起来合理”的数。对于基金定投这种对数据准确性有要求的场景,这种回答是不能用的。

所以我的结论是:估值数字必须来自一个程序化的、可验证的数据源,而不是靠模型“记住”或者“猜”。这正好是 MCP 擅长的领域。

1.2 MCP到底解决了什么问题:把“查数据”变成标准动作

MCP 的全称是 Model Context Protocol,翻译过来叫模型上下文协议。它做的事情一句话总结:把外部数据源和工具统一成一种“插头”格式,AI 按需发现、按需调用。

我习惯用一个类比来解释它:MCP 就是 AI 应用里的 USB-C 接口。以前要给 AI 接一个数据源,你得单独写一套插件或者 API 对接,换一个客户端就废了;现在只要数据源实现了 MCP Server,任何支持 MCP 的客户端都能连上。对开发者来说,好处有两个。第一,工具的定义是标准化的,大模型能看到工具名称、参数说明、返回值格式;第二,数据源的接入逻辑和 AI 应用逻辑解耦了,我可以单独维护估值数据服务,再让多个应用共用。

在 Spring AI 里,这个好处体现得很直接。它提供了现成的 MCP 客户端支持,可以自动拉取远程 MCP Server 的工具列表,并把它们注册成大模型可调用的 function calling 工具。我不用自己拼 JSON Schema,也不用自己实现工具调用的循环,Spring AI 把这一层的样板代码都消化掉了。

2. 落地第一步:把指数估值数据封装成MCP Server工具

2.1 数据源接入与百分位计算

工具接到 MCP 之前,先得有数据。我调研了一圈行情数据服务,最后选用了一个带鉴权的 HTTP 接口,返回的是 JSON 格式的指数估值快照。这个接口可以按指数代码查询,返回字段包括当前 PE、PB、股息率、指数名称,以及更新时间和数据质量等级。

这里有个技术问题必须在服务端解决:估值百分位。单纯把 PE 拿给大模型意义不大,因为不同行业的 PE 中枢不一样,沪深300的 PE 和创业板指的 PE 不能直接比。业界通用的方法是算历史百分位:把某个指数过去8年的每日 PE 收集起来,找出当前 PE 落在历史区间的哪个位置。低于30%属于低估区,高于70%属于高估区,中间是正常区间。

计算逻辑并不复杂,核心步骤是这三段:

  1. 拉取历史估值序列,按天排序,剔除停牌或者数据异常的日期。
  2. 取出今天的 PE,统计历史序列中“小于等于今天 PE”的天数。
  3. 用这个天数除以总的有效天数,乘以100,得到百分位。

2.2 工具参数与返回结构设计

MCP 工具的核心是“让大模型看得懂、调得对”。我把工具设计成一个同步查询接口,工具名称叫get_index_fund_valuation,入参只有一个code,用来接收指数代码。为了让模型不传错参数,我在工具描述里明确注明了支持的指数代码列表,比如沪深300对应000300,中证500对应000905,中证红利对应000922。

返回结构我踩过一个小坑,后面会细说,这里先说最终的形态。为了让模型容易“读懂”,我把返回结构尽量拉平,避免多层嵌套:

{ "code": "000300", "name": "沪深300", "pe": 11.68, "pb": 1.27, "dividendYield": 3.15, "valuationPercentile": 41.2, "valuationLevel": "正常估值", "updatedAt": "2025-06-15 14:30:00", "source": "valuation-api" }

字段名全部用英文小驼峰,因为大模型对常见英文 token 的理解更稳定;数值字段不带单位,单位在字段说明里定义清楚,比如 PE 是倍数,dividendYield 是百分数。valuationLevel字段由服务端直接算好,模型不需要自己判断“41%算不算低估”,减少一层推理出错的风险。

2.3 启动MCP服务并进行本地联调

服务端我直接用 MCP Java SDK 起了个轻量服务,进程独立于 Web 应用,端口监听在本地 8081。下面这段代码是核心流程的示意,不同版本的 SDK 在包名上会有些变化,但整体思路一致:

McpServer server = McpServer.sync(transport) .tool("get_index_fund_valuation", "获取指定指数基金的实时估值数据,返回PE、PB、股息率、估值百分位和估值结论", args -> { String code = args.get("code").toString(); return valuationService.fetchValuation(code); }) .build(); server.start();

启动后我先用一个 MCP 调试客户端做了联调,确认工具能返回正确数据,也确认了鉴权头能正确传递。这一步很重要,因为很多问题不是出在 AI 侧,而是数据服务本身在该死的边界条件上翻车,比如传了个不存在的指数代码。我的策略是在服务端做白名单验证,如果指数代码不在支持列表里,直接返回一条结构化的错误信息,而不是抛空指针异常。

3. 落地第二步:Spring AI客户端接入与工具自动发现

3.1 依赖配置与MCP客户端连接

Spring AI 侧的事情分成两块:一块是模型接入,另一块是 MCP 客户端。我用的模型 API 是兼容 OpenAI 接口的服务,所以直接引入了 Spring AI 的 OpenAI Starter;MCP 部分则引入了官方的 MCP 客户端 Starter。

核心依赖大概长这样:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> </dependency>

在配置文件里指定 MCP Server 地址。因为我是本地调试,所以只配了本机端口:

spring: ai: mcp: client: connections: - name: valuation-server url: http://localhost:8081

这里我想强调一个容易忽略的点:客户端连接配置好后,Spring AI 默认会在启动时向 MCP Server 拉取工具列表。如果你的 MCP Server 挂掉了,应用启动会失败。我一开始不知道这个细节,测试时先停了服务端再启动应用,结果直接报连接拒绝。解决方案是给连接配置设长一点的超时时间,并在业务代码里做好降级。

3.2 模型如何决定“该调用工具”

Spring AI 内部把从 MCP Server 拉到的工具转换成ToolCallback,统一交给模型侧的 function calling 机制。也就是说,当用户提问“沪深300估值怎么样”时,模型不是直接回答,而是先判断自己缺少实时数据,然后生成一个工具调用指令,请求里的参数是{"code": "000300"}。

Spring AI 收到工具返回结果后,会把它作为上下文再喂回给模型,模型基于这段真实数据生成最终回答。这一整个循环对开发者来说相当于自动完成,你只需要在构建 ChatClient 时把 MCP 工具注册进去:

@Bean McpToolCallbackProvider mcpToolCallbackProvider(List<McpClient> mcpClients) { return new McpToolCallbackProvider(mcpClients); }

然后构建 ChatClient 时挂上:

ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(mcpToolCallbackProvider) .build();

这部分是 Spring AI 做得比较舒服的地方。以前我自己实现 function calling 时,光是维护参数 JSON Schema 就够头疼的了,现在 MCP 工具定义直接描述了入参和返回结构,模型可以自动适配。

3.3 跑通一次完整的基金估值提问

全部接通后,我在真实对话里做了一个验证。用户输入是:“今天沪深300的估值处于什么水平?”

我观察到的完整链路是:

  1. 模型判断需要估值数据,生成工具调用get_index_fund_valuation,参数为000300。
  2. MCP 客户端把请求转发给本地 8081 端口的 MCP Server。
  3. 服务端拉取估值接口,计算百分位,返回 JSON 给 Spring AI。
  4. 模型拿到 JSON 后生成回答:“沪深300当前PE为11.68,处于近8年正常估值区间,百分位41.2%。”

从用户提问到最终回复,整个过程快的时候不到3秒。如果不走 MCP,这个回答的准确性没有任何保障。这也验证了我最初的判断:MCP 的价值不只是“能接数据”,更是“让模型拿到它能信任的、结构化的实时数据”。

4. 全链路观测:提问、取数、生成,每一步都留痕

4.1 接通之前先想清楚要观测哪些指标

AI 应用和新接入的数据链路打通之后,会遇到一个实际的问题:用户说“回答变慢了”,你很难判断是模型 API 慢,还是 MCP Server 慢,还是用户网络问题。传统的单体接口监控只能看到总耗时,看不到 AI 应用内部的工具调用环节。

所以我在项目里专门加了一套全链路观测,目标不是做复杂的大数据平台,而是先把几个最关键的指标量出来。根据这个项目的实际环节,我选了下面这些指标:

指标说明埋点位置
整轮问答耗时用户提问到模型回复的总时间ChatClient 外层
MCP 工具调用耗时工具请求发出到拿到返回值的时间MCP 客户端或服务端
数据源接口耗时估值 HTTP 接口的实际响应时间MCP Server 内部
缓存命中率有多少次请求走缓存而不是真实接口缓存工具类
Token 消耗每次问答的输入和输出 token 数模型应答结果

4.2 用拦截器和指标注册表完成埋点

埋点的具体实现我用的是两层方案。

第一层是日志贯穿。我在项目里加了一个简单的 TraceId 生成器,在收到用户请求时生成一个唯一 ID,塞进 MDC,之后所有相关日志都会带上这个 traceId。这样一次完整提问的所有日志都能被串起来,哪怕分布在多个模块里。

第二层是指标采集。我用 Micrometer 注册了耗时计量器和计数器,具体是在数据源 Service 层做了一次包装:

public Valuation fetchWithObservation(String code) { Timer.Sample sample = Timer.start(registry); try { return fetchFromSourceOrCache(code); } finally { sample.stop(VALUATION_TIMER); } }

Token 消耗指标我放在模型返回之后读取。Spring AI 的 ChatClient 返回结果里带Usage信息,我把它取出后记录到计数器,顺便算了一笔账:一次完整的估值问答大约消耗的 token 量,这样能估算出整个系统的运行成本。

4.3 从一次异常反推链路日志如何定位

讲一个实测中遇到的例子。某天我发现 MCP 工具调用耗时的 P95 从 300ms 涨到了 1800ms,但数据源接口耗时没有明显变化。通过链路日志串查,发现问题发生在 MCP Server 的线程池等待上:服务端只有一个默认连接器,同时多个指数查询进来时排队严重。

我当时在日志里打的每个环节耗时是这样的:

[traceId=abc123] 接收请求,开始时间=14:02:01.100 [abc123] MCP客户端发送工具调用,耗时结束=14:02:01.450 [abc123] MCP Server收到请求,标记开始=14:02:01.452 [abc123] 数据源HTTP调用开始=14:02:01.500,结束=14:02:01.560 [abc123] 服务端线程池排队耗时 = 0.9s

一眼就能看出来,瓶颈不在数据源,而在 MCP Server 的连接器配置。我把服务端改成支持并发请求的 NIO 传输,并给线程池设置了合理的大小之后,P95 回到了 500ms 以内。这就是全链路观测的直接收益:不是靠猜,而是看数据定位。

5. 我踩过的四个坑和对应的优化方案

5.1 数据源限流把AI“卡死”,只能上缓存和预热

第一个坑发生在真实流量进来之后。第三方估值接口只允许每秒请求1次,但我的应用为了让模型回答准确,每次提问都要调用工具。两个人同时问问题,第二个就超时了。

解决方案是加了一层带 TTL 的本地缓存。我用 Caffeine 实现了缓存,TTL 设成 5 分钟,主流指数的数据在启动时做一次预热加载。这样即便是同一个指数连续被问,也只有第一次真正打第三方接口。缓存命中率我给到了 90% 以上,接口压力大幅度下降。

Cache<String, Valuation> cache = Caffeine.newBuilder() .maximumSize(200) .expireAfterWrite(Duration.ofMinutes(5)) .build();

这里要说明一个点:缓存会损失一点“实时性”,但对指数估值这个场景是完全可以接受的。估值数据五分钟内的变化不会影响低估/高估判断,实在要更准,就把 TTL 调到 1 分钟,按自己的业务容忍度来定。

5.2 工具返回嵌套太深,模型把字段读岔了

第二次调整来自一个看起来不是问题的设计问题。我最初把返回结构设计成了嵌套标签,比如{"index": {"code": "000300", "name": "沪深300"}}。模型在回答时偶尔会把index对象里的name字段当成用户问的“指数名称”,或者干脆说“该指数名为 index”,明显是对 JSON 结构产生了误读。

后来我把返回结构拉平,所有字段放在同一层,并且在工具描述里加了一句:“所有字段均为指数自身的估值属性,没有子对象。”问题立刻减少了很多。这条经验让我意识到,MCP 工具返回的数据结构要面向“大模型的理解习惯”来设计,而不是面向 Java 对象设计。

5.3 MCP服务单点故障,用户对话直接瘫痪

第三个坑是某天我部署新版本时,MCP Server 重启了几十秒,期间所有带了估值工具的应用都回答不了基金相关的问题。原因是 Spring AI 客户端在模型生成工具调用后,如果拿不到服务,整个对话流程就中断了。

我的处理分两级。第一级是客户端配置虚拟机参数,给连接设置合理的连接超时和请求超时;第二级是兜底逻辑:如果工具调用失败,就返回缓存中的旧数据,并明确告知用户“本次数据来源于缓存,可能不是最新”。这样至少保证了对话不崩溃,用户也能知道数据的可信度边界。

5.4 盲目追求“实时”,反而拖垮了回答速度

最后一个坑是我自己的理解偏差。一开始我看到标题里写着“实时估值”,下意识认为每次问答都必须打到第三方接口拿最新数。结果实测下来,整个服务既慢又不稳。

后来我想明白了一个道理:这里的“实时”是相对于“大模型训练数据滞后几个月”说的。指数估值这个指标,天然是慢变量,核心看区间判断,而不是看分秒变化。把缓存设成 5 分钟,既回答了“是不是低估”,又兼顾了响应速度。真正需要秒级数据的场景不是估值查询,那应该走行情快照,而不是走 AI 问答链路。


做完这个小助手,我最大的体感是:MCP 把 AI 应用的数据接入门槛拆开了。以前做工具调用,要自己搭工具协议、做鉴权、写描述文件,现在一套标准协议能走到底。而全链路观测这件事,千万不要等项目上线后再补,一开始就把链路日志打全,后面排查问题会轻松很多。接下来我打算把估值助手扩展到更多指数和更多数据源,再把观测指标接到统一的监控面板上。这一期先分享到这里,如果你也在折腾 Spring AI 和 MCP,可以按这个思路动手试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询