ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring AI MCP Sampling Server 从 OpenAI 切到 Anthropic:同一把 TaoToken Key

Spring AI MCP Sampling Server 从 OpenAI 切到 Anthropic:同一把 TaoToken Key Spring AI MCP Sampling Server 案例里WeatherService.getTemperature 先查 Open-Meteo 实时温度再连发两次 createMessage用 openai 和 anthropic 两个 ModelPreferences hint 各写一首天气诗。它原来的前提是两套官方 KeyOPENAI_API_KEY 和 ANTHROPIC_API_KEY。TaoToken 把这一步收成一把在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_content 创建 YOUR_API_KEY再让 OpenAI 与 Anthropic 两个 ChatClient 的 base-url 都写 https://taotoken.net/apihints 的路由逻辑一行都不用改。真正让事情变麻烦的不是 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-urlAnthropic 那条线要 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_sourcetaotoken_aicg_blog_endutm_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 末尾为什么不能自己补 /v1Spring AI 的 OpenAI 客户端内部默认补全路径是 /v1/chat/completionsAnthropic 是 /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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-anthropic/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-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}currenttemperature_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 分别发一次 createMessageprivate 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 决定走哪个 ChatClientprivate 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_sourcetaotoken_aicg_blog_endutm_content 的控制台看一下这两笔采样是否都记上了账顺便确认模型名与你选的一致。7. 排障Sampling not supported、401 与 404 分别查什么三个报错覆盖了这条链路九成以上的失败场景而且它们出现的层级完全不同按顺序排查能省很多时间。现象大概率原因先查哪里服务端抛 Sampling 相关异常客户端没声明 sampling 能力ClientCapabilities 是否带 sampling()采样回调返回 401Key 不对或没带上application.yml 的 api-key 与环境变量请求 404base-url 多写了 /v1两段 base-url 是否都是 https://taotoken.net/api两首诗完全相同两个 hint 走到同一个分支handler 的 contains 判断与模型 ID7.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 里重新读一遍路由逻辑。
返回列表