1. 多模型流式输出时,Key 和 Base URL 到底该怎么管
做 Java 后端接入大模型对话服务,绕不开一个现实问题:项目里往往不止一个模型。写代码补全想用 Claude,做中文摘要想用 DeepSeek,跑结构化抽取又想换 GPT 系列。每换一个模型,就要改一次application.yml里的base-url和api-key,改完还要重启服务,本地调试时来回折腾,线上灰度时更是提心吊胆。
Spring AI 的ChatClient本身设计得很优雅,stream().content()一行就能拿到Flux<String>,配合 WebFlux 的text/event-stream就能实现打字机效果。但它的配置层是绑定在ChatModel上的,而ChatModel又由spring.ai.openai.base-url、spring.ai.openai.api-key这些属性驱动。也就是说,流式响应写起来简单,真正麻烦的是多模型切换时的凭证与端点管理。
我试过在项目里维护三套 profile,每套写不同的 key,结果本地跑测试时经常忘了激活哪个 profile,报 401 排查半天。后来把 Base URL 和 Key 统一收敛到一个入口,用同一套凭证去路由不同模型,配置量直接降下来。这篇就围绕这个思路,讲清楚 Spring AI ChatClient 流式响应在多模型场景下怎么配、怎么验、怎么排错。
核心检索词先明确:Spring AI ChatClient 流式响应,指的是通过ChatClient.prompt().stream().content()返回Flux<String>,由 WebFlux 以 SSE 形式逐块推送给前端。它适合谁?适合正在用 Spring Boot 构建大模型对话服务、需要多模型切换、又不想为每个模型单独维护一套密钥配置的 Java 后端开发者。
下面从依赖、配置、代码、验证到排错,一步步来。所有配置都以可复制为准,你照着改路径和 Key 就能跑。
2. TaoToken 前置:一个 Key 打通多模型流式输出
在讲具体配置之前,先把 TaoToken 的定位说清楚。它是一个大模型 API 的统一接入层,对外提供 OpenAI 兼容的接口格式。你拿到一个 API Key,配一个 Base URL,就能在 Spring AI 里调用多个模型,模型名通过请求参数区分,而不是靠改配置文件。
这对 Spring AI 的流式响应特别友好。因为 Spring AI 的 OpenAI starter 走的就是 OpenAI 的/v1/chat/completions协议,只要 Base URL 指向兼容端点,stream()返回的 SSE 数据流就能被正常解析成Flux<String>。你不需要为每个模型写一套ChatModelBean,只需要在调用时把 model 参数传进去。
具体来说,TaoToken 提供两个地址:
- 官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- API 端点:https://taotoken.net/api
注意 API 端点不带 UTM 参数,配置里填的就是这个。官网入口用于注册、查看文档、管理 Key。
你需要准备的东西只有三样:
第一,一个 TaoToken 的 API Key。在控制台的 API Keys 页面创建,格式通常是sk-开头的一串字符。创建后立刻复制保存,页面刷新后不再完整显示。
第二,确认你要用的模型 ID。比如claude-sonnet-4-20250514、deepseek-chat、gpt-4o这类。模型 ID 是请求时传的,不是配置里写死的,这一点很关键。
第三,Spring Boot 项目里引入spring-ai-starter-model-openai和spring-boot-starter-webflux。前者提供 ChatClient,后者提供 SSE 所需的响应式容器。
这里有个容易踩的坑:很多人以为多模型切换必须配多个 starter,其实不用。Spring AI 的 OpenAI starter 是协议级的,只要端点兼容,一个 starter 就能调多个模型。真正需要动态处理的是 model 参数,而不是 base-url。
另外提醒一句,TaoToken 的 Key 要放在环境变量或配置中心,不要硬编码进代码提交到仓库。下面配置示例里我会用占位符,你替换成自己的即可。
3. 可复制配置:application.yml 与 ChatClient 流式代码
这一节是全文的核心,直接给可复制的配置和代码。先看application.yml:
spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: deepseek-chat temperature: 0.7这里base-url指向 TaoToken 的 API 端点,api-key从环境变量读取。model给一个默认值,实际调用时可以覆盖。注意base-url不要带/v1,Spring AI 的 OpenAI 客户端会自动拼接/v1/chat/completions。如果你填成https://taotoken.net/api/v1,会变成/api/v1/v1/chat/completions,直接 404。
如果你更习惯用 properties 格式,等价写法是:
spring.ai.openai.base-url=https://taotoken.net/api spring.ai.openai.api-key=${TAOTOKEN_API_KEY} spring.ai.openai.chat.options.model=deepseek-chat接下来是 Maven 依赖,两个就够:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>然后是 ChatClient 的构建和流式服务。这里我把它写成一个可注入的 Service,支持运行时指定模型:
@Component public class StreamChatService { private final ChatClient chatClient; public StreamChatService(ChatModel chatModel) { this.chatClient = ChatClient.builder(chatModel).build(); } public Flux<String> stream(String prompt, String model) { return chatClient.prompt() .user(u -> u.text(prompt)) .options(OpenAiChatOptions.builder() .model(model) .build()) .stream() .content(); } }关键点在.options(OpenAiChatOptions.builder().model(model).build())。这一行让你在每次请求时动态指定模型,而不用改配置文件。Base URL 和 Key 始终是同一套,模型切换只发生在请求参数层。
Controller 层返回Flux<String>,并声明produces = MediaType.TEXT_EVENT_STREAM_VALUE:
@RestController public class StreamChatController { private final StreamChatService streamChatService; public StreamChatController(StreamChatService streamChatService) { this.streamChatService = streamChatService; } @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestBody ChatRequest request) { return streamChatService.stream(request.getPrompt(), request.getModel()); } }请求体ChatRequest包含prompt和model两个字段。这样前端传什么模型,后端就用什么模型,Key 和 Base URL 完全不用动。
如果你用的是spring-boot-starter-web而不是 webflux,流式响应会退化成一次性返回,因为 Servlet 栈对Flux的支持有限。要做真正的 SSE,必须用 webflux。这一点在排错章节还会展开。
配置和代码到这里就齐了。下面用 curl 和单元测试验证流式 chunk 是否正常返回。
4. 验证请求:curl 与单元测试确认流式 chunk
配置写完,先别急着写前端。用 curl 直接打接口,看 SSE 数据是不是一块一块回来的。
启动 Spring Boot 后,执行:
curl -N -X POST http://localhost:8080/chat/stream \ -H "Content-Type: application/json" \ -d '{"prompt":"用三句话介绍 Spring AI 的流式响应","model":"deepseek-chat"}'-N参数关闭 curl 的缓冲,这样你能实时看到每个 chunk。正常输出类似:
data:Spring data: AI data: 的 data: 流式 data: 响应 ... data:[DONE]每个data:行就是一个 SSE 事件,对应Flux<String>里的一个元素。如果所有内容一次性刷出来,说明缓冲没关或者服务端没走流式。如果卡住不动,多半是 Base URL 或 Key 有问题,下一节排错会讲。
再换一个模型验证多模型切换:
curl -N -X POST http://localhost:8080/chat/stream \ -H "Content-Type: application/json" \ -d '{"prompt":"写一个 Java 的 Hello World","model":"claude-sonnet-4-20250514"}'同样的 Key,同样的 Base URL,只改了 model 字段,就能切到另一个模型。这就是统一 Key 的价值。
接着写单元测试。用 WebTestClient 验证流式返回:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class StreamChatControllerTest { @Autowired private WebTestClient webTestClient; @Test void shouldReturnStreamChunks() { webTestClient.post() .uri("/chat/stream") .contentType(MediaType.APPLICATION_JSON) .bodyValue("{\"prompt\":\"说一句话\",\"model\":\"deepseek-chat\"}") .exchange() .expectStatus().isOk() .returnResult(String.class) .getResponseBody() .take(3) .collectList() .block(); } }这个测试只取前三个 chunk 就结束,避免等完整响应。如果 Base URL 或 Key 配错,exchange()阶段就会抛异常,测试直接失败,比手工 curl 更快定位。
实测下来,流式 chunk 的粒度取决于模型和网络。有的模型按 token 推,有的按词推,Spring AI 会原样透传。你不需要关心粒度,只要确认Flux有多个元素、不是单个大字符串即可。
验证通过后,前端用EventSource或fetch的流式读取就能实现打字机效果。后端这边,Key 和 Base URL 的管理问题已经收敛成一套配置。
5. 本篇常见错排查:401、local proxy failed 与 reading choices
流式接入最容易在三个地方翻车,我按真实报错逐个说。
第一个:401 Unauthorized。报错长这样:
401 Unauthorized: {"error":{"message":"Invalid API key provided"}}原因通常是api-key没读到环境变量,或者 Key 复制时带了空格。检查application.yml里是不是${TAOTOKEN_API_KEY},然后确认启动时环境变量确实存在。在 IDE 里跑的话,Run Configuration 的 Environment variables 要手动加。另一个可能是 Key 被禁用或额度耗尽,去控制台 API Keys 页面确认状态。
第二个:local proxy failed 或连接超时。报错类似:
org.springframework.web.client.ResourceAccessException: I/O error on POST request for "https://taotoken.net/api/v1/chat/completions": Connection timed out先确认base-url写的是https://taotoken.net/api,没有多余路径。再确认本机网络能正常访问该域名,可以用curl -I https://taotoken.net/api测连通性。如果公司网络有出口限制,需要让运维放行。注意不要在任何配置里写代理相关的地址,Spring AI 默认走系统网络设置即可。
第三个:reading choices 相关解析错误。报错类似:
com.fasterxml.jackson.databind.JsonMappingException: Cannot deserialize value of type `java.util.ArrayList` from Object value (token `JsonToken.START_OBJECT`)或者日志里出现reading choices字样。这通常是响应格式和 Spring AI 预期的不一致。检查两点:一是base-url是否指向了兼容 OpenAI 协议的端点,TaoToken 的/api是兼容的;二是 model 字段传的模型 ID 是否有效,传了一个不存在的模型,服务端可能返回错误结构,导致解析失败。把 model 换成文档里确认存在的 ID 再试。
还有一个隐蔽的坑:WebFlux 下用call()而不是stream(),会报:
block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-3这是因为 WebFlux 是非阻塞模型,call()内部会阻塞。流式场景必须用stream(),非流式场景如果坚持用 WebFlux,要把Flux收集成Mono再返回,而不是直接call()。
排查顺序建议:先 curl 确认端点通,再看 Key 是否有效,最后看 model 是否合法。三步走完,九成问题能定位。
6. 统一 Key 之后,流式服务的下一步
把 Key 和 Base URL 收敛到 TaoToken 之后,Spring AI ChatClient 的流式响应代码本身没有变复杂,反而因为不用维护多套配置而更清爽。你可以在同一个 Service 里根据业务场景传不同 model,前端拿到的一直是标准的 SSE 流。
如果后面要做更复杂的 Agent 或长链路编码任务,可以考虑用 Coding Plan 来管理调用配额和模型路由,入口在 https://taotoken.net/api 对应的控制台里能找到。需要查看可用模型列表和接入文档,直接去 API Keys 页面旁边的文档入口,里面有各模型的 ID 和参数说明。
流式响应只是对话服务的第一步,接下来还有记忆管理、工具调用、结构化输出这些环节。但无论后面加什么,凭证和端点的统一管理都是地基。地基打好了,上层怎么搭都稳。