1. 为什么我要手写 OpenRouter Starter:从 ChatClient 自动配置说起
如果你正在用 Spring AI 接 OpenRouter,大概率踩过这个坑:模型明明返回了思考内容,AssistantMessage.getMetadata()里却怎么都取不到reasoning字段。我试过直接用spring-ai-starter-model-openai去调 OpenRouter 的接口,请求能通、文本能出,但思考链路整段丢失,日志里干干净净。原因不复杂——Spring AI 的 OpenAI 实现只认自己声明过的字段,OpenRouter 返回体里那些额外字段在反序列化阶段就被丢掉了。
这篇是系列第二篇,聚焦ChatClient的自动配置与源码拆解,同时把 TaoToken 作为统一 Key/API 通道接进来。TaoToken 在这里扮演的角色很明确:一个兼容 OpenAI 请求格式的统一入口,你不需要为每个模型厂商单独维护一套 Key 和 baseUrl,config.toml和settings.json里写一份配置就能覆盖多模型调用。适合谁看?正在写自定义 Starter、需要理解ChatClient装配链路、或者单纯想让 OpenRouter 多模型调用跑通的中高级 Java 开发者。
我会先讲清楚ChatClient.create()到prompt()再到options()这条链上每一步到底发生了什么,然后给出可直接复制的配置骨架,最后用一次真实请求验证结果,并把常见的报错逐个拆开。源码部分基于 Spring AI 1.1.4,ChatClient的装配逻辑在这个版本里已经比较稳定。
2. TaoToken 前置:统一 Key 与 API 通道准备
在动手改源码之前,先把通道打通。TaoToken 的 API 地址是https://taotoken.net/api,它兼容 OpenAI 的请求格式,所以 Spring AI 里所有基于 OpenAI 协议的模型实现都能直接指向它。这一步的意义在于:你不需要在代码里硬编码 OpenRouter 的 baseUrl,也不用为每个模型单独配 Key,统一走一个通道即可。
先拿到 API Key。进入控制台创建密钥,路径是 console 页面下的 api-keys 管理。创建完成后复制出来,后面配置里会用到。如果你还没决定用哪个模型,可以先去模型对话页面试跑几次,确认目标模型(比如google/gemini-3.1-pro-preview这类强思考模型)在通道里能正常返回。
这里要强调一个设计取舍:TaoToken 作为统一通道,好处是 Key 管理和 baseUrl 收敛到一处,坏处是你需要确认目标模型是否在通道的支持列表里。我实测下来,主流的多模型路由基本都能覆盖,但小众模型建议先在对话页面验证一次再写进配置。
配置层面,我建议把 Key 和 baseUrl 都外部化到application.yml或环境变量,不要写死在 Java 代码里。下面给出一份config.toml骨架,适用于支持 TOML 配置的工具链场景:
# config.toml - TaoToken 统一通道配置骨架 [provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key = "${TAOTOKEN_API_KEY}" # 从环境变量注入,不要硬编码 [provider.headers] Content-Type = "application/json" [models.default] id = "google/gemini-3.1-pro-preview" reasoning = true # 显式开启思考内容返回 [models.fallback] id = "deepseek/deepseek-chat" reasoning = true对应的settings.json骨架,适合 IDE 插件或本地工具读取:
{ "ai.provider": "taotoken", "ai.baseUrl": "https://taotoken.net/api", "ai.apiKeyEnv": "TAOTOKEN_API_KEY", "ai.defaultModel": "google/gemini-3.1-pro-preview", "ai.reasoning.enabled": true, "ai.reasoning.field": "reasoning", "ai.timeoutMs": 60000, "ai.stream": true }注意:
api_key一律走环境变量注入。把 Key 写进config.toml再提交到仓库,是接入阶段最常见的安全事故。
3. 可复制配置:ChatClient 自动配置与源码装配链路
现在进入源码部分。先看ChatClient.create(chatModel)这一行到底做了什么。它是静态工厂入口,内部通过重载链自动补全默认参数:没传ObservationRegistry就补NOOP,没传观察约定就补null,最终委托给DefaultChatClientBuilder。
// ChatClient 静态工厂:补全默认参数后交给 Builder static ChatClient create(ChatModel chatModel) { return create(chatModel, ObservationRegistry.NOOP); } static ChatClient create(ChatModel chatModel, ObservationRegistry observationRegistry) { return create(chatModel, observationRegistry, (ChatClientObservationConvention) null, (AdvisorObservationConvention) null); } static ChatClient create(ChatModel chatModel, ObservationRegistry observationRegistry, @Nullable ChatClientObservationConvention chatClientObservationConvention, @Nullable AdvisorObservationConvention advisorObservationConvention) { Assert.notNull(chatModel, "chatModel cannot be null"); Assert.notNull(observationRegistry, "observationRegistry cannot be null"); return builder(chatModel, observationRegistry, chatClientObservationConvention, advisorObservationConvention).build(); }DefaultChatClientBuilder的构造方法里会创建一个DefaultChatClientRequestSpec,此时所有字段几乎都是空的——userText=null、chatOptions=null、advisors=[]。这个对象就是全局默认配置的“母版”,被塞进DefaultChatClient里保存。
关键点在prompt()。它不会把母版直接给你,而是调用拷贝构造方法深克隆出一个全新的DefaultChatClientRequestSpec。这个副本才是你后续.options()、.user()操作的对象。这样设计的好处是全局默认配置不会被单次请求污染。
// DefaultChatClient.prompt():克隆母版,生成运行时副本 public ChatClient.ChatClientRequestSpec prompt() { return new DefaultChatClientRequestSpec(this.defaultChatClientRequest); } // 拷贝构造:逐字段深拷贝 DefaultChatClientRequestSpec(DefaultChatClientRequestSpec ccr) { this(ccr.chatModel, ccr.userText, ccr.userParams, ccr.userMetadata, ccr.systemText, ccr.systemParams, ccr.systemMetadata, ccr.toolCallbacks, ccr.toolCallbackProviders, ccr.messages, ccr.toolNames, ccr.media, ccr.chatOptions, ccr.advisors, ccr.advisorParams, ccr.observationRegistry, ccr.chatClientObservationConvention, ccr.toolContext, ccr.templateRenderer, ccr.advisorObservationConvention); }接着是.options()和.user()。options()直接把传入的ChatOptions赋值到副本的chatOptions字段,user()把文本赋给userText。advisors()追加到advisors列表,tools()则经过ToolCallbacks.from()转成ToolCallback列表。
public <T extends ChatOptions> ChatClientRequestSpec options(T options) { Assert.notNull(options, "options cannot be null"); this.chatOptions = options; return this; } public ChatClientRequestSpec user(String text) { Assert.hasText(text, "text cannot be null or empty"); this.userText = text; return this; } public ChatClientRequestSpec tools(Object... toolObjects) { Assert.notNull(toolObjects, "toolObjects cannot be null"); Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements"); this.toolCallbacks.addAll(Arrays.asList(ToolCallbacks.from(toolObjects))); return this; }真正触发调用的是.call()或.stream()。两者都会先执行buildAdvisorChain(),这个方法会自动在链尾追加ChatModelCallAdvisor和ChatModelStreamAdvisor,确保无论用户是否添加自定义 Advisor,最终一定会走到模型调用。
private BaseAdvisorChain buildAdvisorChain() { this.advisors.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build()); this.advisors.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build()); return DefaultAroundAdvisorChain.builder(this.observationRegistry) .observationConvention(this.advisorObservationConvention) .pushAll(this.advisors) .build(); }理解这条链路后,你就能明白为什么自定义 Starter 需要在ChatModel层面做文章:ChatClient本身只是编排器,它不关心返回体里有哪些字段,字段的解析和保留发生在ChatModel的实现里。OpenRouter 的reasoning字段丢失,根因就在OpenAiChatModel的反序列化逻辑没有保留未知字段。
4. 验证请求:一次真实的多模型调用与结果确认
配置和源码都清楚了,现在跑一次真实请求。下面这段代码用ChatClient流式调用,目标模型走 TaoToken 通道,重点观察思考内容能否取到。
@Resource private OpenAiChatModel openAiChatModel; @GetMapping(value = "/generateStream", produces = "text/event-stream;charset=utf-8") public Flux<String> generateStream(@RequestParam(defaultValue = "你是谁?") String message) { OpenAiChatModel model = OpenAiChatModel.builder() .openAiApi(OpenAiApi.builder() .baseUrl("https://taotoken.net/api") .apiKey(System.getenv("TAOTOKEN_API_KEY")) .build()) .build(); ChatClient.ChatClientRequestSpec spec = ChatClient.create(model) .prompt() .options(OpenAiChatOptions.builder() .model("google/gemini-3.1-pro-preview") .build()) .user(message); return spec.stream() .chatResponse() .mapNotNull(chatResponse -> { AssistantMessage msg = chatResponse.getResult().getOutput(); String text = msg.getText(); Object reasoning = msg.getMetadata().get("reasoning"); log.info("==> 输出内容: {}", text); log.info("==> 思考内容: {}", reasoning); return text; }); }启动后访问接口,观察日志。如果reasoning打印为null,说明当前ChatModel实现没有保留该字段——这正是需要自定义 Starter 的信号。如果打印出了思考内容,说明通道和模型都正常,可以继续往下做多模型切换。
验证成功的标志有三个:文本内容正常流式返回、reasoning字段非空、切换模型(比如换成deepseek/deepseek-chat)后行为一致。我实测下来,走 TaoToken 通道时,只要模型本身支持思考输出,reasoning字段是能稳定拿到的。
5. 本篇常见错排查:reasoning 取不到与配置踩坑
第一个高频问题:getMetadata().get("reasoning")返回null。原因通常是ChatModel实现丢弃了未知字段。Spring AI 的OpenAiChatModel在反序列化时只映射自己声明的字段,OpenRouter 返回体里的reasoning不在其中。解决办法是在自定义 Starter 里扩展AssistantMessage或改用能保留原始 JSON 的解析路径。
第二个问题:baseUrl配错导致 404。TaoToken 的 API 地址是https://taotoken.net/api,注意结尾不要多加/v1或斜杠,否则路径拼接会出错。如果你用的是 OpenAI 兼容客户端,确认它是否会自动追加/chat/completions。
第三个问题:Key 注入失败。System.getenv("TAOTOKEN_API_KEY")返回null时,请求会直接 401。检查环境变量是否在启动进程里可见,IDE 里跑的话要在 Run Configuration 里显式配置。
第四个问题:流式响应中断。Flux在mapNotNull里抛异常会静默终止流。建议在mapNotNull内部加 try-catch,把异常日志打出来,否则你只会看到流突然结束,没有任何报错。
第五个问题:工具方法被忽略。如果你的@Tool方法返回值是Function、Supplier或Consumer,MethodToolCallbackProvider会直接过滤掉并打警告日志。这是设计如此,AI 无法消费函数对象,改成返回字符串或普通数据类即可。
提示:排查顺序建议从 Key → baseUrl → 模型 ID → 字段解析逐层往下,不要一上来就改源码。
6. 语义一致 CTA:把通道和源码两条线接起来
源码读到这一步,ChatClient的装配链路已经清晰:工厂补默认参数、Builder 建母版、prompt()克隆副本、options()/user()/tools()填参数、call()/stream()触发 Advisor 链。真正决定返回体字段能否保留的,是ChatModel实现层。这也是手写 OpenRouter Starter 的核心价值——你可以在ChatModel层把reasoning这类字段完整接住。
通道侧,TaoToken 把 Key 和 baseUrl 收敛成一份配置,config.toml和settings.json骨架可以直接复制使用。如果你在接入阶段遇到 401 或 404,先去 API Keys 页面确认密钥状态,再对照接入文档检查 baseUrl 拼接。想先验证模型行为,用模型对话页面跑几次最直接。如果你打算长期做多模型编码或 Agent 场景,Coding Plan 那条线更适合把通道和工具链一起管起来。
下一篇我会继续往下拆ChatModel层的请求构造与响应解析,把reasoning字段的保留方案落到代码里。