Spring AI MCP Sampling Server 案例里,WeatherService.getTemperature 先查 Open-Meteo 实时温度,再连发两次 createMessage,用 openai 和 anthropic 两个 ModelPreferences hint 各写一首天气诗。它原来的前提是两套官方 Key:OPENAI_API_KEY 和 ANTHROPIC_API_KEY。TaoToken 把这一步收成一把:在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content= 创建 YOUR_API_KEY,再让 OpenAI 与 Anthropic 两个 ChatClient 的 base-url 都写 https://taotoken.net/api,hints 的路由逻辑一行都不用改。
真正让事情变麻烦的不是 createMessage 本身,而是两套凭证带出的一串连带改动:两个环境变量名、两个 base-url、两个模型 ID、两套额度计费,再加上 MCP 链路本身还要确认客户端有没有声明 sampling 能力,报错一出来根本分不清挂在哪一层。换成统一入口后,变量名只剩 YOUR_API_KEY 一个,模型差异退回到 ModelPreferences 的 hint 上,排查面一下窄了很多。下面顺着原始案例的目录走,把替换动作落到 application.yml、WeatherService 和客户端 SamplingHandler 三个位置。
1. 两次 createMessage 之前,原来的双 Key 准备有多碎
原始案例的精髓是「一次工具调用里采样两次」,所以要先把这条链路拆开看,才知道为什么换供应商会牵动这么多地方。WeatherService.getTemperature 被客户端调用时,服务端先拿城市坐标请求 Open-Meteo,拿到 current.temperature_2m,然后两次调用 exchange.createMessage,每次请求体里塞一份不同的 ModelPreferences。整个代码结构非常干净,脏的部分全在它外部:你得先让两个 ChatClient 都能构建起来,才轮到 hints 说话。
1.1 WeatherService.getTemperature 一次调用里发生了两次采样
第一次 createMessage 的 hint 叫 openai,第二次叫 anthropic。hint 不是模型 ID,它只是服务端给客户端的一句「我倾向这种风格的模型」。客户端收到请求后读 ModelPreferences,自己决定把这次采样交给哪个 ChatClient。也就是说,模型选择权在客户端,服务端只表达偏好。原文把这个边界划得很清楚,也正是这点让「换成同一把 Key」变得可行:服务端代码不用碰,改的是客户端那两个 ChatClient 怎么构建。
这种设计的好处是解耦,代价是配置点变多。OpenAI 那条线要 spring.ai.openai.api-key 和 spring.ai.openai.base-url,Anthropic 那条线要 spring.ai.anthropic.api-key 和 spring.ai.anthropic.base-url,外加各自的 model 名。四个值里有三个是「每家不一样」的,写错任意一个,报错都出现在同一处采样回调里,看上去像是 MCP 协议坏了,其实只是 YAML 填错。
1.2 双官方 Key 带来的三个具体麻烦
第一是命名容易串。环境变量一旦是 OPENAI_API_KEY 和 ANTHROPIC_API_KEY 并存,本地 shell、IDE 启动配置、容器 env 三处都要同步维护,换台机器就漏一个。第二是排错信息混在一起,openai 那条 hint 报 401 和 anthropic 那条 hint 报 401,日志里长得几乎一样,得靠 URL 前缀去分辨。第三是额度分散,做小实验时两边都只充一点点,哪边先见底,整个 getTemperature 就变成单模型输出,而代码层面看不出任何异常。
1.3 同一把 Key 替换掉两套凭证后,哪些代码不用动
要动的只有两处:配置文件里的 api-key 和 base-url。不动的包括 ModelPreferences 的 hints 列表、CreateMessageRequest 的构造、Open-Meteo 请求、两首诗的字符串拼装,以及客户端 SamplingHandler 里读 hint 再选 ChatClient 的分支判断。换句话说,业务逻辑和协议逻辑都不动,动的只是「请求从哪条通道出去」。这也是把供应商切换单独拎出来写一篇的原因——它不需要重构,只需要改对三个字段。
2. 先在 TaoToken 模型广场挑出两个 hint 要用的模型
hint 是抽象的名字,最终落到网络上还是一个具体模型 ID,所以配置之前先把 Key 和模型 ID 都拿到手。这一步是整篇里唯一需要打开浏览器的地方,其余都在编辑器里完成。
2.1 创建 YOUR_API_KEY
打开 TaoToken,注册登录后进控制台,在 API Keys 页面新建一把 Key。这串值在本文里一律写成 YOUR_API_KEY,实际使用时替换成你自己的。建完之后不要急着关页面,后面验证日志、核对用量都还要回来,建议把这把 Key 记在密码管理器里,而不是直接贴进 application.yml 提交到仓库。
顺带提醒一句:Key 只在创建时完整显示一次,页面关掉再想看就得重新建。本地开发更稳的做法是写进环境变量,YAML 里用占位符引用,这样即使配置进版本库也不会泄露。
2.2 在模型广场记下两个模型 ID
先去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content= 的模型广场,按自己的能力需求挑两个可用模型:一个给 openai 这条 hint 用,一个给 anthropic 这条 hint 用。不要凭记忆写模型名,Spring AI 启动时会拿配置里的 model 去发请求,名字对不上就是一次采样直接失败,而失败点藏在 sampling 回调里,很容易被误判成 MCP 问题。
模型 ID 会随平台上架情况变化,本文不写死任何具体名字,以模型广场当时的列表为准。挑的时候顺手看一眼每个模型是不是支持对话补全,采样请求本质就是一次 chat 调用,只支持 embedding 的模型放进去会报参数错误。
2.3 hint 名与模型 ID 不是一回事
原始案例里 hint 写的是 openai 和 anthropic,这是两条供应商线索,而不是两个模型标识。客户端 SamplingHandler 读到 hint 以后,用 contains 之类的宽松匹配决定走哪个 ChatClient,再由那个 ChatClient 带上自己配置的模型 ID 发请求。所以模型广场里你实际选了什么模型,只影响提示词风格和输出质量,不影响 hint 字符串本身。把这两层混成一层,就会出现「hint 改了但模型没改」的假切换。
3. application.yml 里两段 base-url 都填同一个 API 入口
配置是这篇的落点。Spring AI 的 OpenAI 和 Anthropic starter 各自维护一套属性,互不干扰,所以要让它们走同一个入口,就得分别把 base-url 指过去。
3.1 OpenAI 与 Anthropic 的完整配置
下面这份 YAML 可以直接抄,只把模型名换成你在模型广场挑的那两个:
spring: ai: openai: api-key: ${TAOTOKEN_API_KEY:YOUR_API_KEY} base-url: https://taotoken.net/api chat: options: model: your-openai-hint-model-id anthropic: api-key: ${TAOTOKEN_API_KEY:YOUR_API_KEY} base-url: https://taotoken.net/api chat: options: model: your-anthropic-hint-model-id两段共用同一个环境变量 TAOTOKEN_API_KEY,本地导出一次即可,省掉了原来两套变量同步的步骤。模型名两个字段保持独立,因为 openai 和 anthropic 两条 hint 本来就该走不同的模型,否则两首诗会一模一样,验证那一步就失去意义了。
3.2 末尾为什么不能自己补 /v1
Spring AI 的 OpenAI 客户端内部默认补全路径是 /v1/chat/completions,Anthropic 是 /v1/messages,这个 /v1 由框架拼,不需要你在 base-url 里写。如果你把 base-url 写成 https://taotoken.net/api/v1,最终请求会变成 /api/v1/v1/chat/completions,服务端直接 404。这个问题在切换供应商时高频出现,因为不少人是从 curl 示例里把带 /v1 的地址直接复制过来的,而 curl 里那一段是完整路径,不是 base。
3.3 Maven 依赖要同时保留两个 starter
有一点容易忽略:既然现在只有一个入口,是不是可以只留一个 starter?不可以。两个 ChatClient 的类型不同,OpenAiChatModel 和 AnthropicChatModel 是各自的实现,SamplingHandler 里要按 hint 分别注入。依赖照旧:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-anthropic</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId> </dependency>版本交给 Spring AI BOM 管,别手写三个可能对不上的版本号。
4. WeatherService 里 ModelPreferences 的两个 hint 保持原样
配置改完,服务端代码其实一个字都不用动。这一节把原始案例里最核心的两段贴出来,顺便说清哪些地方是刻意不动的。
4.1 Open-Meteo 查询部分
@Service public class WeatherService { private static final double LAT = 39.9042; private static final double LON = 116.4074; private final RestClient restClient = RestClient.create(); @McpTool(name = "getTemperature", description = "查询城市当前温度,并让两个模型各写一首天气诗") public String getTemperature( @McpToolParam(description = "城市名") String city, McpSyncServerExchange exchange) { OpenMeteoResponse body = restClient.get() .uri("https://api.open-meteo.com/v1/forecast" + "?latitude={lat}&longitude={lon}¤t=temperature_2m", LAT, LON) .retrieve() .body(OpenMeteoResponse.class); double temperature = body.current().temperature_2m(); String openaiPoem = sample(exchange, "openai", city, temperature); String anthropicPoem = sample(exchange, "anthropic", city, temperature); return """ 城市:%s 当前温度:%.1f 摄氏度 OpenAI 视角: %s Anthropic 视角: %s """.formatted(city, temperature, openaiPoem, anthropicPoem); } }坐标写死在常量里是为了缩短示例,真实项目里应该按城市名解析经纬度,但那属于业务逻辑,和本篇的供应商切换无关。
4.2 两个 hint 分别发一次 createMessage
private String sample(McpSyncServerExchange exchange, String hint, String city, double temperature) { CreateMessageRequest request = CreateMessageRequest.builder() .maxTokens(256) .modelPreferences(ModelPreferences.builder() .hints(List.of(ModelHint.builder().name(hint).build())) .build()) .messages(List.of(new SamplingMessage( Role.USER, List.of(new TextContent( "以 %s 的表达风格,为 %s 当前 %.1f 摄氏度的天气" .formatted(hint, city, temperature) + "写一首四行短诗,只输出诗本身。"))))) .build(); CreateMessageResult result = exchange.createMessage(request); return ((TextContent) result.content()).text(); }SamplingMessage 的构造参数在不同 MCP Java SDK 版本里可能是单个 Content 或 List ,以你工程里引入的版本为准,其余字段含义不变。这里唯一和供应商有关的就是 hint 字符串,它一个字都没改,所以两条采样请求走统一通道之后,服务端行为完全可复现。
4.3 提示词里不要写死品牌名
有人喜欢在提示词里写「你是 OpenAI 的模型」,这是给自己挖坑。第一,hint 只是偏好,客户端最终可能因为成本策略选了别的模型,提示词和实际模型不一致会让人误判验证结果。第二,切换供应商时你会想复用这段提示词,写死品牌就得改代码。保持「以 X 风格」这种轻描述,模型差异由 ModelPreferences 和客户端路由承担,提示词只负责表达任务。
5. 客户端 SamplingHandler 才是按 hint 选 ChatClient 的地方
服务端发完请求,接力棒交给客户端。SamplingHandler 是整个链路里唯一需要写路由逻辑的位置,也是最容易漏配 sampling 能力的地方。
5.1 声明 sampling 能力
McpSyncClient client = McpClient.sync(transport) .requestTimeout(Duration.ofSeconds(90)) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(this::onSampling) .build();capabilities 里必须带 sampling(),否则服务端发起 createMessage 时会被拒绝,异常信息通常出现在服务端那一侧,看起来像是 Spring AI MCP Server 的问题,实际是客户端没举手。
5.2 读 hints 决定走哪个 ChatClient
private CreateMessageResult onSampling(CreateMessageRequest request) { boolean preferAnthropic = request.modelPreferences() != null && request.modelPreferences().hints() != null && request.modelPreferences().hints().stream() .map(ModelHint::name) .filter(Objects::nonNull) .anyMatch(name -> name.toLowerCase(Locale.ROOT) .contains("anthropic")); String prompt = request.messages().stream() .flatMap(message -> message.content().stream()) .filter(TextContent.class::isInstance) .map(content -> ((TextContent) content).text()) .collect(Collectors.joining("\n")); ChatClient chatClient = preferAnthropic ? anthropicChatClient : openAiChatClient; String text = chatClient.prompt().user(prompt).call().content(); return new CreateMessageResult( Role.ASSISTANT, new TextContent(text), preferAnthropic ? anthropicModelId : openaiModelId, "endTurn"); }匹配用 contains 而不是 equals,是为了让 hint 支持带后缀的写法,比如 openai-gpt 或 anthropic-claude 都能落到正确分支。CreateMessageResult 的字段顺序按你所用 SDK 版本的 record 定义核对一次即可。
5.3 两个 ChatClient 都从同一把 Key 构建
@Bean ChatClient openAiChatClient(OpenAiChatModel model) { return ChatClient.builder(model).build(); } @Bean ChatClient anthropicChatClient(AnthropicChatModel model) { return ChatClient.builder(model).build(); }这两行看着毫无技术含量,但它是「同一把 Key」真正生效的地方:OpenAiChatModel 和 AnthropicChatModel 都由 Spring AI 自动配置构建,读取的正是第 3 节那份 YAML 里的 api-key 与 base-url。所以在客户端侧你不需要手写任何鉴权 header,也不需要在 handler 里传 Key。
6. 日志出现 Start sampling / Finish sampling 才算这条链路通了
配置对不对,不看编译结果,看日志和输出。原始案例的验证标准非常明确:两次采样各打一组开始与结束日志,返回内容里两首诗风格不同。
6.1 触发一次工具调用
启动 MCP Server 与你的客户端,让客户端调用 getTemperature 这个工具,参数给一个城市名。触发方式取决于你用的客户端形态,命令行对话、IDE 插件或自己写的测试类都行,关键是这次调用必须走到服务端的 @McpTool 方法里,而不是被客户端本地的缓存或兜底逻辑接走。
6.2 逐行对照日志
预期能看到两组配对日志:第一组 Start sampling 之后紧接 Finish sampling,再出现第二组 Start sampling 和 Finish sampling。两组之间应该能看到 hints 相关的调试信息,或者至少能看到两次请求的模型字段不同。如果只有一组,说明客户端把两次 createMessage 合并处理了,通常是 handler 里忽略了 modelPreferences 直接复用同一个 ChatClient。如果一组都没有,回到 5.1 检查 capabilities。
验证时顺手看一眼两次请求的路径,应该是 https://taotoken.net/api 下面自动补全的 /v1/chat/completions 和 /v1/messages 两条。路径不对,先查 base-url 有没有多写尾巴。
6.3 两首诗必须真的不一样
最终返回值里应该有「OpenAI 视角」和「Anthropic 视角」两段,措辞、意象、句式节奏最好能看出差别。如果两段一模一样,先怀疑两边配了同一个模型 ID,再怀疑 handler 的 hint 判断写反了。这一条是整个案例的验收点:它证明 ModelPreferences 的偏好真的穿透了 MCP 协议,落到了两个不同的后端模型上,而不只是日志好看。
做完这一步,可以回到 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content= 的控制台看一下这两笔采样是否都记上了账,顺便确认模型名与你选的一致。
7. 排障:Sampling not supported、401 与 404 分别查什么
三个报错覆盖了这条链路九成以上的失败场景,而且它们出现的层级完全不同,按顺序排查能省很多时间。
| 现象 | 大概率原因 | 先查哪里 |
|---|---|---|
| 服务端抛 Sampling 相关异常 | 客户端没声明 sampling 能力 | ClientCapabilities 是否带 sampling() |
| 采样回调返回 401 | Key 不对或没带上 | application.yml 的 api-key 与环境变量 |
| 请求 404 | base-url 多写了 /v1 | 两段 base-url 是否都是 https://taotoken.net/api |
| 两首诗完全相同 | 两个 hint 走到同一个分支 | handler 的 contains 判断与模型 ID |
7.1 客户端没声明 sampling
这类异常的具体文案会随 MCP Java SDK 版本变化,但位置很固定:它出现在服务端 createMessage 调用处,而不是客户端。看到 sampling 字样的报错,先去客户端补 capabilities。补完重启客户端,别只重启服务端,能力声明是建连时协商的。
7.2 base-url 尾巴多了 /v1
这是切换供应商时最常见的自伤。从 curl 文档复制地址、或者凭直觉觉得「API 都要带 /v1」都会中招。正确写法是 https://taotoken.net/api,末尾不带斜杠也不带版本段。Anthropic 那段同理,Spring AI 的 Anthropic 客户端会自己拼 /v1/messages。
7.3 Key 与模型 ID 对不上
401 和 404 之外还有一类静默失败:请求发出去了,但返回的模型名和你预期的不一致。这通常是模型 ID 写错但恰好被服务端兜底成了默认模型,或者两个 hint 配了同一个模型。把 application.yml 里两段 model 字段逐字对一遍模型广场的列表,别靠记忆。
8. 跑通之后去控制台对一下这两笔采样
看到两首诗和两组 Start sampling / Finish sampling 之后,这件事其实还剩最后一步没做完:确认这两次调用在账上是对的,以及下一次切 hint 时不用再改配置。可以先用 TaoToken 模型对话 拿同一把 Key 各发一条消息,确认两个模型 ID 都能正常响应,再回到项目里跑 getTemperature,把「配置错误」和「代码错误」彻底分开。
如果你准备把这个 Sampling Server 长期挂在开发环境里,比如每天让 Agent 自己采几次天气诗,那按调用量估算一下套餐会更省心,Coding Plan 页面能看到适合高频调试的档位;需要再建一把 Key 做隔离测试,就在 控制台 API Keys 里新建,别拿生产环境的 Key 来跑实验。
最后留一个习惯上的建议:把 hint 字符串抽成常量,和 application.yml 里的模型 ID 放在一起注释。下次再换供应商时,你要动的位置就只有这两行,而不是回到 SamplingHandler 里重新读一遍路由逻辑。