夜雨聆风学习资料网

ARTICLE · 1056441

Spring AI 源码阅读(一)——ChatClient.prompt().call():一次请求到底怎么走到模型

Spring AI 源码阅读(一)——ChatClient.prompt().call():一次请求到底怎么走到模型

配套仓库:https://github.com/iweidujiang/spring-ai-source-notes

对照源码:Spring AI 2.0.1(与当前 main 主干类名一致)

样例:仓库内 ChatClientCallController

本文导航

业务代码往往只有这么几行:

@GetMapping("/chat")
public String chat(@RequestParam(defaultValue = "用一句话介绍 Spring AI 的 ChatClient") String q) {
returnthis.chatClient.prompt()
            .user(q)
            .call()
            .content();
}

看起来像 拼个字符串,直接问大模型 ,若真按这个理解去读源码,很快就会迷路:明明写了 call(),为什么还没碰到 ChatModelPrompt 又是在哪冒出来的?

那么本文的重点就是:

Spring AI 用怎样的分层,把写起来像 RestClient、跑起来像可扩展管道这两件事同时做对。

读完你应能明白:

  • 看见 fluent API,先问:哪一步只是组装,哪一步才是执行?
  • 看见统一入口,先问:真正干活的对象有没有被包进可扩展点?
  • 看见返回值很  ,先问:对外简化成 String 时,中间剥了几层?

一、它要解决什么问题

把大模型接进 Spring 应用,至少有三件麻烦事:

麻烦
如果做成 Controller 里直接调厂商 SDK
换模型
OpenAI / Ollama / 通义各写一套
横切能力
日志、记忆、RAG、鉴权、重试……全往业务里堆
调用形态
只要文本、要结构化对象、要流式,入口各不相同

Spring AI 的对策可以概括成三点:

  1. **ChatModel**:不同模型厂商差异停在这一层(像 Servlet)。
  2. ChatClient + Advisor:应用侧统一入口,横切能力挂在链上(像 Filter / Gateway)。
  3. RequestSpec / ResponseSpec:构建与执行分离(像 RestClient 先 build、再 retrieve)。

后面都围绕这三点,看它们如何落在一次同步的 call().content() 上。

二、构建与执行分离

误区

很多人下意识认为:

.call() = 已经向模型发请求了。

实际设计

fluent 链里,**.call() 只负责把这一次要说什么、用哪条管道,收束成可执行对象真正触发管道的是 .content()(或 chatResponse() / entity() 等终端方法)**。

记一句就够:

.call() = 组装管道;终端方法(如 .content())= 拧开水龙头。

这不是抠字眼。分不清 组装 / 执行 ,读任何 WebClient、RestClient、以及以后的 MCP Client,都会在半路上以为请求已经发出去了。

源码落点

位置 1:DefaultChatClient.DefaultChatClientRequestSpec#call

  • 做两件事:buildAdvisorChain(),再 new DefaultCallResponseSpec(...)
  • 这里没有ChatModel.call

位置 2:DefaultCallResponseSpec 内触发链的那一句

advisorChain.nextCall(chatClientRequest)

可以看到注释里写得很清楚了:advisor chain terminates with the ChatModelCallAdvisor,意思就是链路的最后一环落到 ChatModelCallAdvisor。

三、把 ChatModel 放进 Advisor 链底

要解决的问题

如果 ChatClient 内部写死:

拼 Prompt → chatModel.call → 返回

那记忆、RAG、审计、限流就只能:

  • 改框架,或
  • 在业务外面再包一层 伪 ChatClient 。

可扩展点和 调模型 若是两条路,以后所有横切能力都会长歪。

设计选择

把调用 ChatModel 也做成一个 Advisor——ChatModelCallAdvisor,并在建链时自动追加到链尾Ordered.LOWEST_PRECEDENCE)。

你没有配置任何 Advisor 时,链上依然至少有它。于是:

  • 自定义 Advisor 与调模型走 同一套管线 ;
  • 顺序用 getOrder() 说话;
  • 同步 / 流式可以各有一套链(CallAdvisor vs StreamAdvisor),互不假装对称。

链如何往下传

DefaultAroundAdvisorChain.nextCall 的思路很 Spring:

  1. 从当前剩余 Advisor 队列里取出一个;
  2. 调用它的 adviseCall(request, chain)
  3. Advisor 若要继续,内部再调 chain.nextCall(...)
  4. 链尾的 ChatModelCallAdvisor不再往后传,而是调 chatModel.call(prompt),把结果包成 ChatClientResponse 返回。

这和 Filter 里必须 chain.doFilter、Gateway 里必须 chain.filter 是同一类契约:

扩展点负责自己的事,并决定是否交给下一环;框架负责按顺序把环串起来。

源码落点

位置 3:buildAdvisorChain

  • 复制你配置的 advisors;
  • chain.add(ChatModelCallAdvisor...)
  • 再 DefaultAroundAdvisorChain.builder().pushAll(chain).build()

位置 4:ChatModelCallAdvisor#adviseCall

ChatResponse chatResponse = this.chatModel.call(formattedChatClientRequest.prompt());

运行时若 starter 是 Ollama,这里的 chatModel 实现类就是 OllamaChatModel

应用层第一次跨到具体模型实现的分界线,就在这一行——之上谈的是可移植管道,之下才是厂商协议。

位置 5:OllamaChatModel#call(Prompt)

只为确认:厂商 HTTP、路径、JSON,都收敛在 ChatModel 实现里。

四、Prompt 何时成形,是否就是最终入参

要解决的问题

fluent API 上,system、user、messages、options、tool 是一点点叠上去的

执行期需要一份稳定的 此次对话输入 ——这就是 Prompt(再被放进 ChatClientRequest)。

如果过早定稿,后面的 Advisor 没法改请求;

如果从不定稿,模型适配器就要去理解整棵 RequestSpec,可移植性会烂掉。

设计选择:两阶段

阶段一(业务定稿)

toChatClientRequest 把散装状态收成 Prompt

  • system → SystemMessage(可走模板变量)
  • 已有 messages
  • user → UserMessage(可走模板 + media)
  • options(含 tool 等)

阶段二(管道内)

Advisor 仍可改 ChatClientRequest。链尾还可能 augmentWithFormatInstructions(结构化输出等场景会改写 user 文本或 options)。没有这类需求时,阶段二常等于原样传递——但机制上必须允许再改,否则 Advisor 模型不成立。

源码落点

位置 6:DefaultChatClientUtils.toChatClientRequest

五、返回值为什么要剥几层

要解决的问题

一次模型调用,框架侧至少要能表达:

  • 多候选项(generation)
  • 元数据(token、模型名……)
  • Advisor 之间共享的 context
  • 观测(Observation)需要的结构化响应

若 ChatModel 直接返回 String,上面这些只能靠 ThreadLocal 或副作用——那是退步。

设计选择

由内向外大致是:

ChatModel
  → ChatResponse                // 模型层:结果 + 元数据
       → Generation
            → Message.getText()

Advisor / ChatClient
  → ChatClientResponse          // 再包一层:ChatResponse + context

业务终端方法 content()
  → String                      // 只要正文时,剥到 getText()

.content() 走的路径是:

ChatResponse → getResult() → getOutput() → getText()

需要完整结果时用 chatResponse();需要带 context 的框架视图时看 ChatClientResponse

对外 API 提供偏薄的便利方法,对内保留偏厚的结构——这和 ResponseEntity vs body() 是同一类权衡。

六、总览:一次同步调用的分层

可与 Spring 对照着看:

Spring AI
可对照的熟悉概念
ChatClient
 fluent
RestClient
 / WebClient 请求构建
call()
 → ResponseSpec
请求已描述完,尚未 retrieve
Advisor 链
Filter / Gateway 过滤器链
ChatModelCallAdvisor
链尾真正发起调用的那一环
ChatModel
可替换的传输 / 协议实现

七、读源码复盘

以后碰到 Spring AI(以及类似框架),可以固定问四句:

  1. 哪一步是 build,哪一步是 run?

    分清了,就不会在组装阶段找网络请求。

  2. 可扩展点挂在执行路径的哪一段?

    这里是 Advisor;换项目可能是 Interceptor、HandlerChain、Event。

  3. 可移植边界画在哪?

    这里是 ChatModel:之上应与厂商无关,之下才谈 HTTP/SDK。

  4. 对外简化时,内部保留了什么?

    content() 很方便,但方便来自对 ChatResponse 的剥皮,不是模型只返回了字符串。

这几个问题搞懂了,一切就都通了。

八、小结

一次 prompt().user().call().content(),Spring AI 实际做了:

  1. 构建与执行分离——call 定稿请求并建链,终端方法才跑链;
  2. 用 Advisor 统一横切与调模型——ChatModel 坐在链底,而不是旁路特判;
  3. Prompt 两阶段——业务先收束,管道仍可改,再交给可移植的 ChatModel
  4. 响应分层——对内结构完整,对外按需变薄。

下期预告:Advisor 的顺序契约、adviseCall 为什么必须(或何时不必)调用 chain.nextCall,以及自定义 Advisor 该插在链的哪一段。

相关学习资料