新人笔记---浅扒一下Spring AI的chatClient的装配流程源码

一:什么是Spring AI的ChatClient配置流程

博主先演示一遍博主怎么在项目中配置chatClient的

这里面我们调用chatclient就指定了advisor,prompt,等属性,最后调用call方法获取请求,通过chatResponse返回一次AI调用的具体返回内容

这一点博主其实也挺迷惑的,为什么这么设置就可以配置chatCLient了,它内部到底发生了什么,她所需要的各种参数又是啥,刚接触AI那会不太理解为什么我们,现在博主浅浅翻阅了一下源码(只能看懂一点点)+结合AI,算是理解了一点

我们每次构造chatclient,设置prompt(),system()等属性时都会返回一个chatClientRequestSpec

我们点击Ctrl 点击advisor时,进入内部会发现chatClientRequestSpec本质是一个接口,而advisor是他的一个接口方法,翻译一下它上面的注释:返回一个 ChatClient.Builder,用它可以创建一个 新的 ChatClient,这个新 Client 的所有默认设置,都是从 当前这个 ChatClientRequest 里复制过去的。 可以知道他是通过Builder构建器来构建chatclient的

通过Ctrl H我们看到了chatCLientRequestSpec是有一个实现类的,chatclient也是有一个实现类的,说明我们上面通过lambda表达式创建的chatclient以及调用chatclient中的prompt()方法,advisor()方法.....,实际上是创建了chatClient中的默认实现类DefaultChatClient,advisor方法也是走的实现类DefalutChatClientRequestSpec中重写的实现方法

这里还能看到他们都是DefaultChatCient的内部类,DefaultChatCient内部本身就管理了需要参数的设置,配置,提示词的注入,advisor注入等等

这里通过调试的方式,我们可以看见他们底层创建的就是我们所说的上面提到的实现类,这里的advisor创建的是DefaultAdvisor,原因如下

我们调用的是这个方法注入advisor,他需要参数AdvisorSpec,封装在一个Consumer函数式接口

java 复制代码
ChatClientRequestSpec advisors(Consumer<AdvisorSpec> consumer);

具体的DefaultChatCient的内部中我们调用的advisor的重写方法如下,他手动创建了一个DefaultAdvisorSpec(AdvisorSpec的默认实现类,也是DefaultChatCient的内部类),然后调用了我们传递的Consumer的accept方法,去读取我们设置的值(如我们代码中的a->a.param(xxx)),将这些值放入一个新的DefaultAdvisorSpec(这一块不好理解,但是你自己写一下,然后翻阅一下源码加上问AI,应该能理解,大佬可以跳过)

然后调用this.advisorParams.putAll(advisorSpec.getParams());this.advisors.addAll(advisorSpec.getAdvisors());

可以看见advisors,advisorParams本质是DefaultChatCLientRequestSpec中的两个变量,将我们顾问设置的所有参数都传递到了这里。那么什么时候注册advisor呢?答案是当我们调用call方法时

我们先是构建了一个顾问执行器链BaseAdvisorChain,然后将所有请求,顾问链啥的都仍给DefaultCallResponseSpec,自此chatClient装配阶段结束,这里需要说明一下我们的prompt,tool等其他参数好像是设置后就直接封装在

java 复制代码
public CallResponseSpec call() { 
    // 步骤1: 构建 Advisor 链
    BaseAdvisorChain advisorChain = buildAdvisorChain(); 
    
    // 步骤2: 将当前 RequestSpec 转换为 ChatClientRequest
   // 他会 将当前 RequestSpec 的所有参数(system、user、options、messages 等)转换为 ChatClientRequest 对象
    ChatClientRequest request = DefaultChatClientUtils.toChatClientRequest(this);
    
    // 步骤3: 创建响应规范,准备执行
    return new DefaultCallResponseSpec(request, advisorChain, 
            this.observationRegistry, this.observationConvention);
}

这里我们解释一下为什么prompt会在RequestSpec

这里大家自行查看,博主解释一下,当我们调用prompt()方法并传入值,他会调用public ChatClientRequestSpec prompt(Prompt prompt),返回一个接口类型ChatClientRequestSpec,而实际上他的prompt()方法本质是调用我们的实现类DefaultChatClientRequestSpec的重写的prompt()方法,将所有参数都封装在自身内部的变量中(如图上,包括提示词,顾问列表,tool工具)
再来解释一下我们顾问执行链的构建buildAdvisorChain

java 复制代码
// 在链的末端添加模型调用顾问。
// 它们在顾问链中扮演最后一个角色。
this.advisors.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build());
this.advisors.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build()); 

准备"终端"顾问 (Terminal Advisors)

  • 目的 :这两行代码将两个最核心的 Advisor(顾问)添加到处理链的末端。它们是真正与 AI 模型通信的执行者。
  • ChatModelCallAdvisor :负责处理非流式的 AI 调用,即一次性获取完整的回复。
  • ChatModelStreamAdvisor :负责处理流式的 AI 调用,即逐步获取回复内容。
  • this.chatModel:这是 Spring AI 对不同大模型(如 OpenAI, Ollama 等)的统一抽象接口,具体的模型调用逻辑由它实现。

构建并返回责任链

java 复制代码
return DefaultAroundAdvisorChain.builder(this.observationRegistry) .pushAll(this.advisors) .templateRenderer(this.templateRenderer) .build();  
  • 目的:将所有配置好的 Advisor(包括上面添加的两个,以及可能由用户自定义添加的其他 Advisor)组装成一个完整的、可执行的链条。
  • DefaultAroundAdvisorChain.builder(...) :创建一个责任链的构建器。
  • .pushAll(this.advisors) 将所有 Advisor 实例按顺序压入链中。这个顺序至关重要,它决定了请求和响应被处理的先后次序。
  • .templateRenderer(...):设置模板渲染器,用于处理提示词模板。
  • .build():最终构建出 BaseAdvisorChain 实例。

最后解释一下什么时候调用AI,我们上方调用call返回一个CallResponseSpec,此时我们的chatclient完全装配好了,下一步就是调用AI了,那么什么时候触发呢

这是CallResponseSpec内部,可以看见他是一个接口,内部有很多方法,其中调用他的chatResponsecontent方法就会调用AI生成结果,content是获取响应内容,chatResponse是获取一次完整的AI相应结果,内部包含Token消耗,响应结果等更加详细的参数。这里可以发现他是一个接口,那么自然我们要去看他的接口实现类

巧了,他的实现类也是chatclient实现类的内部类,原来我们的chatclient已经封装好了我们所需要的一切内容,chatclient也太省心了(゚∀゚)

可以看见这里面封装了所有的调用call方法传递的参数

这一段是是他重写的我们上面提交的方法。可以看见content方法本质也是先获取chatResponse,然后手动帮我们提取出内部的content

核心还是doGetObservableChatClientResponse这个方法,源码扒到这里,后面其实博主就不太能看懂了,能力有限,大家可以自己去自己尝试扒一下,这里解释引用一下AI的解释

这段代码是 Spring AI 中真正 "扣动扳机" 的地方。

当你调用 .content().chatResponse() 时,最终都会汇聚到这个 doGetObservableChatClientResponse 方法。

它的核心作用有两个:

  1. 包裹监控(Observation) :利用 Micrometer 框架记录这次 AI 调用的指标(如耗时、Token 消耗等)。
  2. 触发执行 :通过 this.advisorChain.nextCall(...) 真正启动责任链,最终调用 AI 模型。

下面我为你逐段解析这段源码的逻辑: . 处理输出格式上下文

1. 处理输出格式上下文

java 复制代码
if (outputFormat != null) {
    chatClientRequest.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), outputFormat);
}
  • 作用:如果你使用了结构化输出(比如 .entity(User.class)),这里会将目标格式存入请求的上下文(Context)中。
  • 目的:让后续的拦截器(Advisor)知道你需要什么格式的数据,以便进行相应的转换。

. 2.构建监控上下文 (Observation Context)

java 复制代码
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
    .request(chatClientRequest)
    .advisors(this.advisorChain.getCallAdvisors())
    .stream(false) // 标记这不是流式调用
    .format(outputFormat)
    .build();
  • 作用:创建一个容器,用来存放这次调用相关的所有元数据(请求对象、拦截器链、是否流式等)。
  • 目的:这是为了配合 Spring 的 Observability(可观测性)系统,方便后续生成 Trace ID 或统计指标。

3.创建监控实例 (Observation)

java 复制代码
var observation = ChatClientObservationDocumentation.AI_CHAT_CLIENT.observation(
    this.observationConvention,
    DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION, 
    () -> observationContext, 
    this.observationRegistry
);
  • 作用:从注册中心获取一个 Observation 实例。
  • 通俗理解:这就像是在开始工作前,先拿出一个"秒表"和"记录本",准备记录这次任务的开始时间、结束时间和状态。

4.核心执行:启动责任链 这是最关键的部分:

java 复制代码
var chatClientResponse = observation.observe(() -> {
    // Apply the advisor chain that terminates with the ChatModelCallAdvisor.
    return this.advisorChain.nextCall(chatClientRequest);
});
observation.observe(...):
  • observation.observe(...)

    • 它会先启动计时。
    • 然后执行大括号 {} 里的代码。
    • 如果代码执行成功或抛出异常,它会记录结果并停止计时。
  • this.advisorChain.nextCall(chatClientRequest)

    • 这就是真正的触发点!
    • 它调用了责任链的入口。请求会像接力棒一样,从第一个 Advisor 传到最后一个。
    • 最终,链条末端的 ChatModelCallAdvisor 会收到请求,并调用底层的 ChatModel(如 OpenAI 的 API),拿到结果后原路返回

5.返回结果

java 复制代码
return chatClientResponse != null ? chatClientResponse : ChatClientResponse.builder().build();
  • 作用:做一个非空检查,确保即使出现意外情况,也返回一个空的对象而不是 null,防止空指针异常。

(2)其他一些博主疑惑的解释

为什么它能通过.prompt(),.advisor()都形式封配置多个chatclient参数
核心还是DefaultChatCLientRequestSpec,他是DefaultChatClient的静态内部类,我们不论设置prompt(),advisor(),还是别的啥,我们最终都是往这个类中设置,比如我们设置prompt时,他先拿到我们的 DefaultChatCLientRequestSpec引用,将prompt设置到它内部的变量属性中,设置完后返回这个DefaultChatCLientRequestSpec引用,后续的设置都是继续拿到这个引用,然后继续设置值,这就是"链式调用"

设置prompt时他是先获取到当前DefaultChatClient的静态内部类DefaultChatCLientRequestSpec,然后往里面设置值,最后返回这个静态内部类都引用

这是静态内部类DefaultChatCLientRequestSpec内部自身的设置advisor的方法,最后的return this也是返回他这个类的引用。this 永远代表 当前方法所属的那个对象 这个方法写在哪个类里,this 就是哪个类的实例!

(3)一些自己的思考点

通过浅读源码,博主发现我们的设置的prompt()等值,其实没必要强制返回引用DefaultChatCLientRequestSpec,因为我们本质设置值时都将数据设置到这个静态内部类内部了,也没有怎么对外暴露,当然这只是博主的一些当前读取后的思考,也仅仅是浅度,对于他设置返回值返回引用,可能后面的代码中他的用处才显现出来。目前我觉得是官方特意设置的,简化我们的配置步骤

java 复制代码
chatClient.prompt(prompt).advisors().call();

通过返回引用,可以支持链式调用,直接操作上一个对象返回的引用对象(本质传递的还是同一个对象引用)

java 复制代码
// 第一步:调用 prompt,返回 spec ChatClientRequestSpec   
spec = chatClient.prompt(prompt);   
// 第二步:用 spec 调用 advisors,返回 spec   
ChatClientRequestSpec spec2 = spec.advisors(...);   
// 第三步:用 spec2 调用 call   
CallResponseSpec callResult = spec2.call();

可以看见这种不返回引用的写法,确实有点麻烦,需要配置一个值,调用一次ChatClientRequestSpec内部的对应的prompt(),advisor()方法设置值

(4)梳理的流程图

这是一部分chatclient的内部体系

这是用户通过.advisor(Consumer<AdvisorSpec> xx)形式设置顾问时的一些调用流程图

这是用户调用.call方法时的调用流程图

这是用户调用.chatResponse方法后的调用流程图

相关推荐
嘉伟桑25 分钟前
Python调用电价API返回JSON示例:分时电价、现货电价和字段解析
后端
武子康41 分钟前
Shippy:确定性工具、会话级 Sandbox 与 Live-Data Eval(4 类收敛 + 7 步实现方案 + 6 类评测指标)
前端·人工智能·后端
我叫黑大帅1 小时前
我为什么单一消费者的场景下,要用 Redis List 当消息队列?
redis·后端·面试
AskHarries1 小时前
文件上传系统
后端
止语Lab2 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活2 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困2 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn2 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530142 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908402 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端