1. 从一次 401 说起:为什么我要读 ChatClient 与 Advisor 的源码
如果你正在用 Spring AI 对接 OpenRouter,大概率遇到过这种场景:ChatClient.create(chatModel).prompt().user("你好").call().content()这行代码跑通了,但你想加一个「请求前打印日志、请求后统计 token」的逻辑,却不知道往哪儿塞。官方文档告诉你用 Advisor,可 Advisor 到底在链路的哪一环生效、stream()和call()走的链是不是同一条、自定义 Advisor 的getOrder()设多少才不会被内置的ChatModelCallAdvisor抢先——这些问题文档里写得比较散。
我试过直接照抄官方示例写一个SimpleLoggerAdvisor,结果发现日志打了两遍,排查半天才明白是call()和stream()两条链各自持有 Advisor 引用导致的。所以这一篇我们不谈怎么注册账号、怎么拿 Key,而是把ChatClient从prompt()到真正发出 HTTP 请求之间的那段路拆开看:请求是怎么被组装成ChatClientRequest的,Advisor 责任链是怎么排序和递归的,以及这些机制落到 OpenRouter 这种「OpenAI 兼容但又有自己字段」的场景时,你该在哪个扩展点插入自己的逻辑。
这篇适合已经能跑通 Spring AI + OpenRouter 最小 Demo、想进一步做请求拦截、日志埋点、多模型路由的开发者。读完之后你应该能自己写出一个可复用的 Advisor,并且知道它为什么排在那个位置。下面所有配置片段都可以直接复制到你的application.yml或 Java 配置类里,配合 TaoToken 的 OpenAI 兼容端点做本地验证。
2. TaoToken 前置:把 OpenRouter 兼容端点接进 Spring AI
在拆源码之前,得先有一个能跑的ChatModel实例,否则后面所有 Advisor 都无从谈起。Spring AI 目前对 OpenAI 兼容协议的支持是通过spring-ai-openai-spring-boot-starter提供的,而 OpenRouter 以及 TaoToken 这类聚合网关都遵循 OpenAI 的/v1/chat/completions协议,所以我们可以复用同一套 starter,只改base-url和api-key。
先说清楚 TaoToken 在这里扮演的角色:它是一个 OpenAI 兼容的模型调用网关,对外暴露统一的/v1/chat/completions、/v1/models等端点,你拿到的 Key 可以调用它背后挂载的多个模型。对 Spring AI 来说,它就是一个标准的 OpenAI 端点,不需要写任何自定义ChatModel实现。这一点很关键——很多人以为接 OpenRouter 要自己实现OpenRouterChatModel,其实只要协议兼容,直接复用OpenAiChatModel就行。
依赖方面,pom.xml里加两样东西:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>版本号按你项目里 Spring AI 的 BOM 来,M6 是目前 Advisor 链路比较稳定的一个里程碑。WebFlux 是流式调用必须的,如果你只用同步call()可以不加,但既然要读stream()的源码,建议一起引入。
然后是配置文件。这里有个坑:Spring AI 的 OpenAI starter 默认读spring.ai.openai.api-key和spring.ai.openai.base-url,但不同小版本的属性名有过调整,M6 用的是下面这套:
spring: ai: openai: api-key: ${TAOTOKEN_API_KEY} base-url: https://taotoken.net/api chat: options: model: gpt-4o-mini temperature: 0.7base-url写https://taotoken.net/api,不要带/v1,因为 starter 内部会自动拼/v1/chat/completions。如果你写成https://taotoken.net/api/v1,最后会变成/api/v1/v1/chat/completions,直接 404。这个错误我在第一次接的时候踩过,日志里只看到 404 不显示完整 URL,排查了十几分钟。
Key 的获取走 TaoToken 的 API Keys 页面,生成后建议用环境变量注入,别硬编码进application.yml。如果你还没配好,可以先到模型对话页面确认一下 Key 能正常调通某个模型,再回来配 Spring AI,这样能把「Key 本身有问题」和「Spring AI 配置有问题」两类错误分开。
配好之后写一个最小的 Controller 验证ChatModel能注入:
@RestController public class PingController { private final ChatModel chatModel; public PingController(ChatModel chatModel) { this.chatModel = chatModel; } @GetMapping("/ping") public String ping() { return chatModel.call("说一句你好"); } }启动后访问/ping,如果返回模型输出,说明ChatModel这条最底层的路通了。注意这里用的是ChatModel.call()而不是ChatClient,因为我们要先确认模型层没问题,再往上叠ChatClient和 Advisor。这一步过了,后面的源码分析才有意义。
3. 可复制配置:ChatClient 与 Advisor 链路的完整装配
现在进入正题。ChatClient的构建有两种方式:一种是ChatClient.create(chatModel)每次手动创建,另一种是通过ChatClient.Builder注入。后者更适合生产,因为 Builder 里可以预设默认的 Advisor、默认的 system 文本、默认的 options。我们先看 Builder 的配置类:
@Configuration public class ChatClientConfig { @Bean public ChatClient chatClient(ChatModel chatModel, ObservationRegistry observationRegistry) { return ChatClient.builder(chatModel) .defaultSystem("你是一个严谨的技术助手,回答尽量给出可执行的步骤。") .defaultAdvisors( new SimpleLoggerAdvisor(), new TokenUsageAdvisor() ) .build(); } }这里defaultAdvisors传入的 Advisor 会参与每一次call()和stream()。注意SimpleLoggerAdvisor是 Spring AI 自带的,TokenUsageAdvisor是我们下面要手写的。Builder 内部会把这两个 Advisor 和内置的ChatModelCallAdvisor、ChatModelStreamAdvisor一起压入责任链,并按getOrder()排序。
接下来是自定义 Advisor 的完整实现。Advisor 分两类:CallAdvisor管同步调用,StreamAdvisor管流式调用。如果你两个都要拦截,可以实现CallAdvisor和StreamAdvisor两个接口,或者直接继承BaseAdvisor。下面这个TokenUsageAdvisor同时拦截两种调用,统计每次请求的 token 消耗:
public class TokenUsageAdvisor implements CallAdvisor, StreamAdvisor { private static final Logger log = LoggerFactory.getLogger(TokenUsageAdvisor.class); @Override public String getName() { return "TokenUsageAdvisor"; } @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 100; } @Override public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) { long start = System.currentTimeMillis(); ChatClientResponse response = chain.nextCall(request); long cost = System.currentTimeMillis() - start; logUsage(request, response, cost); return response; } @Override public Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain) { long start = System.currentTimeMillis(); return chain.nextStream(request) .doOnComplete(() -> log.info("stream 完成,耗时 {}ms", System.currentTimeMillis() - start)); } private void logUsage(ChatClientRequest request, ChatClientResponse response, long cost) { if (response.chatResponse() == null) { return; } var usage = response.chatResponse().getMetadata().getUsage(); log.info("模型={} 输入token={} 输出token={} 耗时={}ms", request.prompt().getOptions() != null ? request.prompt().getOptions().getModel() : "default", usage.getPromptTokens(), usage.getCompletionTokens(), cost); } }几个关键点。第一,getOrder()返回HIGHEST_PRECEDENCE + 100,意味着它排在链的最前面(值越小越先执行),但比HIGHEST_PRECEDENCE本身靠后一点,给框架预留位置。第二,adviseCall里调用chain.nextCall(request)才会把请求传给下一个 Advisor,如果你不调用,链就断了,模型永远不会被调用。第三,adviseStream返回的是Flux,doOnComplete在流结束时触发,适合做收尾统计。
如果你只想拦截流式调用,可以只实现StreamAdvisor。但要注意,ChatClient在stream()时只会从链里取StreamAdvisor,CallAdvisor不会参与;反之call()只取CallAdvisor。这就是为什么前面说「两条链各自持有 Advisor 引用」——DefaultAroundAdvisorChain内部维护了callAdvisors和streamAdvisors两个独立的Deque。
再补一个请求级别的配置片段,展示如何在单次调用里临时覆盖 Advisor 和 options:
ChatClient.ChatClientRequestSpec spec = chatClient.prompt() .system("这次用简洁模式回答") .user("解释一下 Advisor 的责任链模式") .options(ChatOptions.builder() .model("gpt-4o-mini") .temperature(0.3) .build()) .advisors(new SimpleLoggerAdvisor()); String content = spec.call().content();注意.advisors()传入的 Advisor 是追加到默认 Advisor 之后的,不是替换。它们的getOrder()会一起参与排序。如果你想让某个 Advisor 只在这次调用生效,就放在这里;想全局生效,就放在 Builder 的defaultAdvisors里。
4. 验证请求:从 prompt() 到 HTTP 请求的完整链路
配置写完了,怎么确认 Advisor 真的按预期执行了?最直接的办法是打开 Spring AI 的 debug 日志,同时用 curl 对照。先在application.yml里加:
logging: level: org.springframework.ai: DEBUG org.springframework.ai.chat.client: TRACE启动后调用一次/ping,你会在日志里看到类似这样的输出:
DEBUG o.s.a.c.c.DefaultChatClient - Creating ChatClientRequest with 2 messages TRACE o.s.a.c.c.DefaultAroundAdvisorChain - Executing advisor: TokenUsageAdvisor, order: -2147483548 TRACE o.s.a.c.c.DefaultAroundAdvisorChain - Executing advisor: SimpleLoggerAdvisor, order: 0 TRACE o.s.a.c.c.DefaultAroundAdvisorChain - Executing advisor: ChatModelCallAdvisor, order: 2147483647 INFO c.e.TokenUsageAdvisor - 模型=gpt-4o-mini 输入token=18 输出token=42 耗时=812ms这三行Executing advisor就是责任链的执行顺序。TokenUsageAdvisor的 order 是Integer.MIN_VALUE + 100,所以排第一;SimpleLoggerAdvisor默认 order 是 0;ChatModelCallAdvisor的 order 是Integer.MAX_VALUE,永远排最后,因为它要负责真正调用chatModel.call()。这个顺序和源码里buildAdvisorChain()的逻辑完全对应:先把用户 Advisor 压入,再把ChatModelCallAdvisor和ChatModelStreamAdvisor压入,最后pushAll触发reOrder()按 order 升序排列。
流式调用验证稍微复杂一点,因为响应是 SSE。写一个 Controller:
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam(defaultValue = "讲个笑话") String message) { return chatClient.prompt() .user(message) .stream() .content(); }用 curl 访问:
curl -N http://localhost:8080/stream?message=讲个笑话你会看到内容一段段吐出来,同时日志里TokenUsageAdvisor的doOnComplete会在流结束时打印耗时。这里有个细节:流式调用时ChatModelStreamAdvisor会调用chatModel.stream(),返回的Flux经过每一层 Advisor 的adviseStream包装,最后在DefaultStreamResponseSpec.chatResponse()里被mapNotNull提取成ChatResponse。如果你在 Advisor 里对Flux做了map操作,要注意别把ChatClientResponse转成别的类型,否则下游的mapNotNull(ChatClientResponse::chatResponse)会拿到 null。
再验证一个关键行为:Advisor 的响应处理顺序是反的。请求阶段 order 小的先执行,响应阶段 order 小的后执行。你可以在TokenUsageAdvisor的adviseCall里,在chain.nextCall()前后各打一行日志,会看到:
TokenUsageAdvisor 请求前 SimpleLoggerAdvisor 请求前 ChatModelCallAdvisor 执行 SimpleLoggerAdvisor 响应后 TokenUsageAdvisor 响应后这就是责任链的「栈式」行为,和 Spring AI 官方文档里描述的一致。理解这一点,你才能正确决定把日志埋点放在请求前还是响应后。
5. 常见报错排查:401、local proxy failed 与 reading choices
接入过程中最容易撞上的几类错误,我按出现频率排一下。
第一类是 401。日志里通常长这样:
401 Unauthorized: {"error":{"message":"Invalid API key provided","type":"invalid_request_error"}}原因基本是 Key 没配对。检查三处:spring.ai.openai.api-key是否真的读到了环境变量(可以在启动日志里搜api-key看它打印的值,Spring AI 会脱敏但能看出长度);Key 有没有多余空格或换行;Key 是不是在 TaoToken 控制台被禁用或额度耗尽。如果 Key 没问题,检查base-url是不是写成了https://taotoken.net/api/v1,多出来的/v1会导致路径拼接错误,有些网关会返回 401 而不是 404。
第二类是local proxy failed或连接超时。这个报错在不同 HTTP 客户端下措辞不一样,Reactor Netty 下可能是Connection refused或UnknownHostException。先确认base-url的域名能解析、端口能连通。如果你在公司内网,检查是否需要走内网 DNS。注意不要在任何配置里写代理相关的设置,Spring AI 的 OpenAI starter 默认走系统代理,如果环境变量里有HTTP_PROXY之类的配置,可能会干扰请求。排查时可以先在命令行用 curl 直接打https://taotoken.net/api/v1/models带上 Key,确认网络层通不通。
第三类是reading choices相关的反序列化错误,典型日志:
com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.util.ArrayList<...ChatCompletionChunk$Choice>` from Object value这个通常出现在流式调用时,网关返回的 SSE 数据格式和 Spring AI 期望的 OpenAI 格式有细微差异。比如某些网关在流式响应的最后一个 chunk 里返回data: [DONE]之外还带了额外字段,或者choices数组为空。解决办法有两个:一是确认你用的模型确实支持流式,有些模型在网关侧被配置成只支持非流式;二是检查 Spring AI 版本,M6 对 SSE 的解析比 M5 健壮很多,如果还在用 M5 建议升级。如果升级后仍有问题,可以在 Advisor 里对Flux加一层onErrorResume做兜底,但更推荐先确认网关侧的响应格式。
第四类是 Advisor 不生效。表现是日志里看不到你的 Advisor 名字。检查两点:Advisor 有没有被注册进 Builder 或单次请求;getOrder()有没有返回一个极端值导致被其他 Advisor 覆盖。还有一种情况是你实现了CallAdvisor但调用的是stream(),或者反过来,这时候 Advisor 根本不会被选中。记住call()只走CallAdvisor链,stream()只走StreamAdvisor链。
第五类是 OAuth 或鉴权头被覆盖。如果你在 Advisor 里手动改了ChatClientRequest的 header,可能会把 starter 自动加的Authorization: Bearer xxx覆盖掉,导致 401。Advisor 里修改请求要谨慎,尤其是adviseCall里对request做mutate操作时,确认没有丢掉原有的 header。
6. 语义一致 CTA:把源码理解落到可运行的工程里
读源码的最终目的是少踩坑。上面这套配置——ChatClient.Builder预设 Advisor、自定义TokenUsageAdvisor同时实现CallAdvisor和StreamAdvisor、用getOrder()控制执行顺序——可以直接搬进你的项目。如果你还没拿到可用的 Key,先去 TaoToken 的 API Keys 页面生成一个,然后照着第 2 节的application.yml配好base-url和api-key,跑通/ping再回来调 Advisor。
想先确认模型本身能不能调通,可以用模型对话页面手动发几条消息,确认网关侧没问题。接入文档里有 OpenAI 兼容端点的完整说明,包括/v1/chat/completions的请求体和响应字段,对照着看源码里ChatClientRequest的组装逻辑会更清楚。如果你打算把 Advisor 用在长期运行的编码 Agent 或自动化流程里,Coding Plan 那边有更完整的工程化配置示例,包括多模型路由和重试策略。
最后留一个实用技巧:调试 Advisor 顺序时,把org.springframework.ai.chat.client的日志级别开到 TRACE,它会打印每个 Advisor 的 name 和 order,比你自己在代码里打日志更直观。等你看到ChatModelCallAdvisor永远排在最后、ChatModelStreamAdvisor的 order 是Integer.MAX_VALUE,就说明你已经把这条责任链吃透了。