1. 为什么要在 Spring AI + Ollama 里加一层统一 Key
本地跑 Ollama 做私有化 AI 助手,最舒服的地方是数据不出内网,qwen2.5:7b-instruct 这类模型直接ollama serve就能用。但真把项目铺开,问题就来了:多角色助手要同时调对话模型和 embedding 模型,RAG 知识库要单独走向量化通道,MCP 控制台还要对工具调用做鉴权。每个模块各写一套 base-url、各存一份密钥,改一个模型名要翻五六个配置文件,本地调试和生产切换时特别容易漏。
我试过把 Ollama 的http://localhost:11434直接硬编码进application.yml,单机 demo 没问题,可一旦要接多个模型来源、或者团队里几个人共用一套 MCP 工具服务,鉴权就没法统一管。这时候用 TaoToken 做一层统一 Key 和 API 通道,把模型调用和 MCP 控制台鉴权都收口到一个入口,配置量能砍掉一大半。
TaoToken 在这里扮演的是「统一网关」角色:它对外提供标准的 OpenAI 兼容接口,对内可以路由到 Ollama 本地模型,也可以路由到其他模型通道。你只需要在application.yml里配一个 base-url 和一个 Key,Spring AI 的 ChatClient、EmbeddingClient、以及 MCP 服务端的鉴权都走这一套。对私有化项目来说,好处是本地模型继续跑在内网,而调用入口、密钥轮换、模型切换这些运维动作集中在一处。
这篇文章面向的是已经在用 Spring Boot + Spring AI 的 Java 开发者,尤其是做私有化知识库、多角色助手、MCP 工具平台的场景。下面从环境准备讲到可复制的application.yml,再到 MCP 服务端配置、启动后验证多角色路由和 RAG 检索命中的具体请求,最后把常见报错逐个排掉。全程命令和配置都能直接抄。
需要先说明一点:Ollama 本地模型和 TaoToken 通道是互补关系,不是替代关系。本地模型负责数据敏感的推理,TaoToken 负责统一入口和鉴权管理。两者通过标准 API 对接,你不需要改动 Ollama 本身的部署方式。
2. TaoToken 前置准备:Key、通道与 MCP 鉴权
在动 Spring AI 代码之前,先把 TaoToken 这边的入口理清楚。核心是三样东西:API Key、Base URL、以及你要用的 Model ID。这三件套在后面的application.yml和 MCP 配置里会反复出现。
先到控制台创建 API Key。打开 https://taotoken.net/api-keys ,登录后新建一个 Key,复制出来存好。这个 Key 就是 Spring AI 里spring.ai.openai.api-key的值,也是 MCP 控制台做 Bearer 鉴权用的凭证。注意 Key 只在创建时完整显示一次,丢了就重新建一个。
Base URL 用https://taotoken.net/api,这是 OpenAI 兼容接口的根路径。Spring AI 的 OpenAI starter 会自动在这个根路径后面拼/v1/chat/completions、/v1/embeddings这些标准端点,所以配置里不要自己再加/v1,否则会拼成/api/v1/v1/...直接 404。
Model ID 这块要看你实际路由到哪个模型。如果走本地 Ollama 的 qwen2.5:7b-instruct,Model ID 就填qwen2.5:7b-instruct;embedding 用nomic-embed-text。如果你在 TaoToken 侧配置了模型映射,就填映射后的名称。建议先在模型对话页面 https://taotoken.net/chat 里手动发一条消息,确认这个 Model ID 能正常返回,再去写代码,能省掉很多「配置没错但就是不通」的排查时间。
MCP 控制台的鉴权也复用同一个 Key。MCP 服务端对外暴露 SSE 连接和工具调用端点时,用Authorization: Bearer <你的Key>做校验。这样多角色助手、RAG、MCP 三条链路共用一套凭证,轮换时只改一个地方。
如果你打算长期跑编码类 Agent 或者多角色自动化任务,可以了解下 Coding Plan https://taotoken.net/coding-plan ,它在额度模型上更适合持续调用场景。接入文档在 https://taotoken.net/doc ,里面有各语言 SDK 的对接示例,Java 侧主要参考 OpenAI 兼容部分。
这里有个容易踩的坑:不要把 TaoToken 当成「必须联网才能用」的组件。你的 Ollama 本地模型依然跑在内网,TaoToken 只是统一了调用入口和鉴权。断网环境下如果只调本地模型,理论上可以绕过网关直连 Ollama;但一旦涉及 MCP 工具鉴权和多模型路由,走统一入口会更省心。两种模式可以按环境切换,配置里用 profile 区分即可。
3. 可复制配置:application.yml 与 MCP 服务端片段
这一节是全文最核心的部分,配置直接抄。先给完整的application.yml,再给 MCP 服务端的配置片段。
server: port: 8080 spring: application: name: spring-ai-ollama-multi-role ai: openai: # TaoToken 统一入口,OpenAI 兼容 base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: qwen2.5:7b-instruct temperature: 0.7 embedding: options: model: nomic-embed-text # 本地 Ollama 直连通道,作为备用/离线模式 ollama: base-url: http://localhost:11434 chat: options: model: qwen2.5:7b-instruct embedding: options: model: nomic-embed-text data: redis: host: localhost port: 6379 timeout: 3000ms ai: vectorstore: milvus: client: uri: http://localhost:19530 username: root password: milvus collection-name: private_kb embedding-dimension: 768 # MCP 控制台鉴权配置 mcp: server: enabled: true sse-path: /mcp/sse tools-list-path: /mcp/tools/list tools-call-path: /mcp/tools/call auth: type: bearer token: ${TAOTOKEN_API_KEY} console: status-refresh-ms: 5000 clear-tools-on-disconnect: true # 多角色隔离前缀 assistant: roles: - id: general name: 通用助手 memory-prefix: "chat:general:" - id: java-advisor name: Java 技术顾问 memory-prefix: "chat:java:" - id: wedding name: 婚礼顾问 memory-prefix: "chat:wedding:" - id: companion name: 陪伴助手 memory-prefix: "chat:companion:"几个关键点解释一下。spring.ai.openai.base-url指向 TaoToken,api-key用环境变量注入,别写死在文件里。spring.ai.ollama那段保留本地直连能力,方便离线调试。Milvus 的embedding-dimension必须和nomic-embed-text的输出维度一致,这里是 768,写错会导致向量写入失败。
MCP 服务端的鉴权用mcp.server.auth.token引用同一个 Key。clear-tools-on-disconnect: true解决控制台断开后工具列表残留的问题,这个在原文里也提到过。
接下来是 MCP 服务端的 Java 配置类,负责把 SSE 端点和工具注册起来:
@Configuration public class McpServerConfig { @Value("${mcp.server.auth.token}") private String authToken; @Bean public RouterFunction<ServerResponse> mcpRoutes(McpToolRegistry registry) { return RouterFunctions.route() .GET("/mcp/sse", request -> { if (!checkAuth(request)) { return ServerResponse.status(401).build(); } return ServerResponse.ok() .contentType(MediaType.TEXT_EVENT_STREAM) .body(registry.sseFlux()); }) .GET("/mcp/tools/list", request -> { if (!checkAuth(request)) { return ServerResponse.status(401).build(); } return ServerResponse.ok().body(registry.listTools()); }) .POST("/mcp/tools/call", request -> { if (!checkAuth(request)) { return ServerResponse.status(401).build(); } return ServerResponse.ok().body(registry.callTool(request)); }) .build(); } private boolean checkAuth(ServerRequest request) { String header = request.headers().firstHeader("Authorization"); return header != null && header.equals("Bearer " + authToken); } }这段代码把三个 MCP 端点都挂上 Bearer 校验,Key 从配置读,和 Spring AI 用的是同一个。工具注册表McpToolRegistry负责维护工具列表和 SSE 推送,断开时清空。
多角色记忆隔离靠 Redis Key 前缀实现,配置里每个角色一个memory-prefix。写入时拼上角色前缀,读取时按前缀取,天然不串话:
public void saveMessage(String roleId, String userMsg, String aiMsg) { String key = rolePrefixMap.get(roleId) + "history"; redisTemplate.opsForList().leftPush(key, userMsg); redisTemplate.opsForList().leftPush(key, aiMsg); redisTemplate.opsForList().trim(key, 0, 19); }trim(key, 0, 19)限制上下文 20 条,防止 Redis 无限增长。这个手搓方案比 Spring AI 官方 ChatMemory 更可控,尤其是多角色场景。
4. 启动验证:多角色路由与 RAG 检索命中
配置写完,启动主类,然后逐个验证。先确认服务起来了:
curl -s http://localhost:8080/actuator/health返回{"status":"UP"}说明 Spring 上下文加载正常。如果这里就挂了,多半是 Milvus 或 Redis 连不上,先去看第 5 节的排错。
验证多角色路由,发一条带角色标识的请求:
curl -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{ "roleId": "java-advisor", "message": "Spring AI 里怎么自定义 ChatMemory?" }'预期返回里能看到模型基于 Java 技术顾问的人设作答,并且这次对话被写进了chat:java:history。再发一条给wedding角色,问同样的问题,返回风格应该完全不同,且记忆互不影响。你可以用 Redis 命令确认隔离:
redis-cli keys "chat:*:history"应该看到chat:general:history、chat:java:history等多个独立 Key。
验证 RAG 检索命中,先上传一个文档:
curl -X POST http://localhost:8080/api/rag/upload \ -F "file=@./docs/产品手册.pdf"上传成功后,文档会被 Tika 解析、分块、用nomic-embed-text向量化,写入 Milvus 的private_kb集合。然后提问:
curl -X POST http://localhost:8080/api/rag/query \ -H "Content-Type: application/json" \ -d '{ "question": "产品手册里提到的部署要求是什么?", "topK": 3 }'返回结果里应该包含retrievedChunks字段,列出命中的原文片段和相似度分数。如果retrievedChunks为空,说明向量检索没命中,检查 embedding 维度是否 768、Milvus 集合是否真的写入了数据:
curl -s http://localhost:9091/healthzMilvus 健康检查通过后,用它的控制台或 SDK 查一下private_kb的实体数量。数量为 0 就是写入环节断了,回到上传接口看日志。
验证 MCP 控制台,先拿工具列表:
curl -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ http://localhost:8080/mcp/tools/list返回工具数组说明鉴权和注册都正常。再测 SSE 连接:
curl -N -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ http://localhost:8080/mcp/sse应该看到持续的事件流。断开后,控制台的工具列表会自动清空,这是clear-tools-on-disconnect生效的表现。
5. 常见报错排查:401、local proxy failed 与 choices 解析
这一节把实际会撞到的报错逐个拆开。第一个高频问题是 401:
401 Unauthorized: {"error":{"message":"invalid api key"}}原因通常是 Key 没注入或者拼错了。检查TAOTOKEN_API_KEY环境变量是否真的传进了 JVM,Spring 里${TAOTOKEN_API_KEY}如果解析不到会变成字面量字符串。用System.getenv("TAOTOKEN_API_KEY")打日志确认。另一个可能是 Key 复制时带了空格,或者用了已删除的旧 Key。到 https://taotoken.net/api-keys 重新生成一个,替换后重启。
第二个是本地代理相关报错,日志里出现local proxy failed或连接被拒绝:
java.net.ConnectException: Connection refused: localhost:11434这说明 Spring AI 尝试直连 Ollama 但服务没起来。先确认ollama serve在跑,curl http://localhost:11434/api/tags能返回模型列表。如果你已经切到 TaoToken 通道,检查spring.ai.openai.base-url是否被 profile 覆盖成了本地地址。两个通道的配置不要同时激活,用spring.profiles.active明确指定。
第三个是解析响应时报reading choices相关错误:
com.fasterxml.jackson.databind.JsonMappingException: Cannot deserialize value of type `java.util.List` from Object value (token `JsonToken.START_OBJECT`)这通常是返回体结构和 Spring AI 期望的不一致。OpenAI 兼容接口的 chat 响应里choices是数组,如果网关返回了非标准结构就会这样。先确认 base-url 是https://taotoken.net/api而不是带/v1的路径,重复的版本段会导致路由到非预期端点。再用 curl 直接打一次接口,看原始返回:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"qwen2.5:7b-instruct","messages":[{"role":"user","content":"hi"}]}'返回里choices[0].message.content存在就说明接口正常,问题在 Spring AI 的配置或版本。Spring AI 2.0.0-M4 对 OpenAI 兼容层的字段映射比较严格,升级到稳定版或对齐 starter 版本能解决一部分。
第四个是 MCP 的 OAuth 或鉴权失败:
MCP SSE connection rejected: missing or invalid bearer token检查请求头是不是Authorization: Bearer <Key>,注意 Bearer 后面有一个空格。控制台前端如果用了 OAuth 流程,确认mcp.server.auth.type和实际校验逻辑一致。用同一个 Key 同时做模型调用和 MCP 鉴权时,确保两边读的是同一个配置项,别一个读环境变量一个读硬编码。
第五个是 RAG 检索返回空。除了维度问题,还要看分块大小。分块太大,embedding 会稀释语义;太小,检索不到完整上下文。建议 500 到 800 字符一块,重叠 100 字符。Milvus 的collection-name如果和写入时不一致,也会查不到数据,配置里统一成private_kb。
6. 统一 Key 接入后的扩展与收尾
把 TaoToken 作为统一入口接进来之后,整个项目的运维面收窄了很多。模型切换、密钥轮换、MCP 鉴权都集中在一处,Spring AI 侧只认一个 base-url 和一个 Key。本地 Ollama 继续负责数据敏感的推理,RAG 的向量化走nomic-embed-text,多角色记忆靠 Redis 前缀隔离,MCP 控制台复用同一套凭证。
后续要扩展的话,几个方向比较自然。一是多路召回加 Rerank,在 Milvus 检索之后接一个重排序模型,提升 RAG 命中质量。二是把 MCP 工具从当前的列表/调用扩展到真正的 Agent 任务执行,让多角色助手能主动调工具。三是用 Docker Compose 把 Ollama、Redis、Milvus、Spring Boot 应用打包成一键启动,降低部署门槛。
如果你在接入过程中卡在鉴权或通道配置上,接入文档 https://taotoken.net/doc 里有各端点的详细说明。需要看模型实际返回效果,可以直接在模型对话 https://taotoken.net/chat 里试。长期跑编码类 Agent 的话,Coding Plan https://taotoken.net/coding-plan 在额度上更合适。Key 管理统一在 https://taotoken.net/api-keys 。
最后提醒一个实操细节:application.yml里的 Key 一定用环境变量注入,别提交到 Git。本地开发可以用.env或者 IDE 的运行配置注入,CI 环境用密钥管理服务。MCP 的 Bearer token 同理,配置里引用变量而不是明文。这样即使配置文件泄露,凭证也不会跟着出去。