AI Agent 项目学习笔记(二):Spring AI 与 ChatClient 主链路解析

1. 本期目标

上一篇文章整体介绍了 ai_agent 项目。我们已经知道,这个项目不是一个简单聊天机器人,而是一个基于 Spring Boot 3Spring AI 的智能体能力实践项目,主要覆盖多轮对话、RAG 检索增强、工具调用、结构化输出和会话记忆等能力。项目 README 中也明确说明,它的目标是通过 ChatClient + Advisor + ChatMemory 组织对话主流程,再结合 RAG 和 Tool Calling 扩展智能体能力。(GitHub)

这一期进入项目的核心类:LoveApp

本期主要解决几个问题:

复制代码
1. LoveApp 在项目中起什么作用?
2. ChatModel 和 ChatClient 分别是什么?
3. ChatClient.builder() 如何构建智能体主链路?
4. defaultSystem() 如何固定智能体角色?
5. defaultAdvisors() 如何接入记忆和日志增强?
6. doChat() 的完整调用流程是什么?
7. chatResponse() 和 entity() 分别适合什么场景?
8. 这个主链路设计有什么优点和不足?

2. 为什么先分析 ChatClient?

学习这个项目,不能一上来就直接看 RAG 或工具调用。

因为 RAG、记忆、工具调用这些能力,本质上都是挂在模型对话主链路上的增强能力。

也就是说,项目的核心不是:

复制代码
RAG 单独运行
工具单独运行
记忆单独运行

而是:

复制代码
用户输入
    ↓
ChatClient 组织对话请求
    ↓
Advisor 加入记忆、日志、RAG 等增强
    ↓
模型生成结果
    ↓
必要时调用工具
    ↓
返回最终回答

所以,ChatClient 是整个项目的主干。

如果没有理解 ChatClient,后面看 AdvisorChatMemoryQuestionAnswerAdvisorToolCallback[] 时就会比较散。


3. ChatModel 和 ChatClient 的关系

LoveApp 构造函数中,项目注入的是:

复制代码
public LoveApp(ChatModel dashscopeChatModel)

然后通过:

复制代码
ChatClient.builder(dashscopeChatModel)

构建 ChatClient。源码中可以看到,LoveApp 接收 ChatModel,随后使用 ChatClient.builder(dashscopeChatModel) 设置默认系统提示词和默认 Advisor,最后调用 .build() 得到 chatClient。(GitHub)

可以这样理解:

复制代码
ChatModel:
底层模型接口,负责真正和大模型交互。

ChatClient:
Spring AI 提供的高级客户端,负责把 prompt、system message、advisor、tool、response 等能力组织起来。

LoveApp:
面向业务场景的智能体封装类。

也就是说,ChatModel 更底层,ChatClient 更适合业务开发。

项目没有直接在每个方法里调用 ChatModel,而是先构造一个带有默认配置的 ChatClient。这样后续普通对话、结构化输出、RAG 对话、工具调用都可以复用同一条主链路。


4. ChatClient 是什么?

Spring AI 官方文档中,ChatClient 被定义为一种用于和 AI 模型通信的 Fluent API,它支持同步和流式调用,并可以逐步构造传给模型的 Prompt。Prompt 中通常包含系统消息、用户消息以及其他上下文信息。(spring-doc.cn)

可以把 ChatClient 理解成 Spring AI 里的"模型调用编排器"。

它不是只负责发送一句话,而是负责组织:

复制代码
system prompt
user message
chat memory
advisor
tool callback
response format

普通模型调用可能是:

复制代码
String answer = model.call("你好");

ChatClient 更像:

复制代码
chatClient
    .prompt()
    .system("你是谁")
    .user("用户问题")
    .advisors(...)
    .toolCallbacks(...)
    .call()
    .chatResponse();

这就是 Fluent API 的好处:模型调用链路可以一步一步拼出来,代码的可读性更强。


5. LoveApp 的定位

LoveApp 位于:

复制代码
src/main/java/com/ai/aiagent/app/LoveApp.java

它被标注为:

复制代码
@Component
@Slf4j
public class LoveApp

也就是说,它是一个 Spring Bean,可以被其他组件注入和调用。源码中 LoveApp 内部维护了一个 private final ChatClient chatClient;,并通过构造函数完成初始化。(GitHub)

从项目结构看,LoveApp 是智能体应用层的入口。

它提供了四类能力:

复制代码
doChat:普通多轮对话
doChatWithReport:结构化报告输出
doChatWithRag:RAG 检索增强对话
doChatWithTools:工具调用对话

README 中也将 LoveApp 作为智能体主链路核心入口,并明确列出普通对话、结构化报告、RAG 对话和工具调用这四种方法。(GitHub)

所以可以这样理解:

复制代码
LoveApp 不是普通 Service
而是一个封装了智能体核心能力的应用类

6. SYSTEM_PROMPT:先固定智能体身份

LoveApp 中,最先值得关注的是:

复制代码
private static final String SYSTEM_PROMPT = ...

这个系统提示词要求模型扮演"深耕恋爱心理领域的专家",开场向用户表明身份,并围绕单身、恋爱、已婚三种状态引导用户描述问题。源码中也明确写了单身、恋爱、已婚三类场景下应该关注的提问方向。(GitHub)

这说明项目不是让模型自由聊天,而是先用系统提示词限定角色。

可以理解为:

复制代码
没有 system prompt:
模型只是一个通用助手。

有 system prompt:
模型被限定为恋爱心理咨询方向的智能体。

这一步很关键。

因为智能体首先要有"角色边界"。如果没有系统提示词,用户问什么模型就答什么,系统就很难形成垂直场景能力。


7. defaultSystem():设置默认系统提示词

在构造 ChatClient 时,项目使用:

复制代码
.defaultSystem(SYSTEM_PROMPT)

这表示后续通过这个 chatClient 发起的默认对话,都会带上这个系统提示词。

也就是说,doChat() 里虽然没有重新写 system prompt,但它仍然会继承构造时配置的默认系统提示词。

可以理解为:

复制代码
构造 ChatClient 时:
设置默认角色

每次 doChat 调用时:
自动带着这个角色去回答

这种写法比每个方法都重复写 system prompt 更清晰。

如果后续项目要从"恋爱咨询 Agent"扩展成多个 Agent,可以为不同业务类配置不同 system prompt:

复制代码
LoveApp:恋爱咨询智能体
PaperApp:论文阅读智能体
CodeApp:代码分析智能体
SecurityApp:安全分析智能体

每个 App 都可以有自己的 defaultSystem()


8. ChatMemory:先构造对话记忆

LoveApp 构造函数中,项目创建了一个 ChatMemory

复制代码
ChatMemory chatMemory = MessageWindowChatMemory.builder()
        .chatMemoryRepository(new InMemoryChatMemoryRepository())
        .build();

同时源码中还能看到一行被注释掉的文件式记忆实现:

复制代码
// ChatMemory chatMemory = new FileBasedChatMemory(fileDir);

这说明项目当前默认使用内存版 MessageWindowChatMemory,但也预留了文件持久化记忆的切换方式。(GitHub)

可以理解为:

复制代码
MessageWindowChatMemory:
负责保存最近一段对话窗口。

InMemoryChatMemoryRepository:
把对话历史保存在内存中。

FileBasedChatMemory:
把对话历史保存到本地文件中。

本期不展开记忆实现细节,只要先理解一点:

复制代码
ChatClient 本身不等于记忆
记忆是通过 Advisor 接入 ChatClient 的

9. defaultAdvisors():给所有对话加默认增强

LoveApp 构造 ChatClient 时还使用了:

复制代码
.defaultAdvisors(
    MessageChatMemoryAdvisor.builder(chatMemory).build(),
    new MyLoggerAdvisor()
)

也就是说,项目默认给所有对话加了两个增强:

复制代码
MessageChatMemoryAdvisor:
负责把会话记忆接入模型调用。

MyLoggerAdvisor:
负责打印 AI 请求和响应日志。

源码中 LoveAppdefaultAdvisors() 正是这样配置的。(GitHub)

这说明 Advisor 可以理解成模型调用链路中的增强器。

它有点像 Web 后端中的拦截器:

复制代码
请求进入模型前:
Advisor 可以修改或增强请求。

模型返回后:
Advisor 可以记录、观察或处理响应。

当然,Advisor 不只是日志。后面 RAG 中的 QuestionAnswerAdvisor 也是 Advisor。

所以整个项目的设计思路是:

复制代码
ChatClient 负责主流程
Advisor 负责增强流程

10. MyLoggerAdvisor:日志增强

MyLoggerAdvisor 是项目自定义的日志 Advisor。

从源码看,它实现了 CallAdvisorStreamAdvisor,在请求前打印 AI Request,在响应后打印 AI Response。同步调用时,它通过 adviseCall() 包裹模型调用;流式调用时,它通过 ChatClientMessageAggregator 聚合流式响应后再观察输出。(GitHub)

可以简单理解为:

复制代码
用户请求进入模型前:
打印请求内容

模型生成结果后:
打印响应内容

这对学习项目很有帮助。

因为初学 Agent 项目时,最容易困惑的是:

复制代码
最终传给模型的 prompt 到底长什么样?
模型返回了什么?
Advisor 有没有生效?
RAG 有没有把资料拼进去?

日志 Advisor 就是为了帮助观察这些过程。


11. doChat():普通对话主链路

现在来看最基础的方法:

复制代码
public String doChat(String message, String chatId)

源码中 doChat() 的链路是:

复制代码
ChatResponse response = chatClient
        .prompt()
        .user(message)
        .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, chatId))
        .call()
        .chatResponse();

String content = response.getResult().getOutput().getText();
return content;

源码显示,doChat() 接收用户消息和 chatId,通过 prompt() 开始构造请求,使用 .user(message) 设置用户输入,使用 Advisor 参数传入 ChatMemory.CONVERSATION_ID,然后调用 .call().chatResponse() 获取模型响应,并从响应对象中取出文本。(GitHub)

这个方法就是整个项目最基础的对话链路。


12. doChat() 流程拆解

可以把 doChat() 拆成六步:

复制代码
第一步:chatClient.prompt()
开始构造一次模型请求。

第二步:user(message)
把用户输入作为 user message。

第三步:advisors(...)
给本次请求设置 conversationId。

第四步:call()
同步调用模型。

第五步:chatResponse()
获取完整 ChatResponse 对象。

第六步:getText()
提取模型最终文本回答。

画成流程就是:

复制代码
message + chatId
    ↓
chatClient.prompt()
    ↓
.user(message)
    ↓
.advisors(conversationId = chatId)
    ↓
.call()
    ↓
.chatResponse()
    ↓
response.getResult().getOutput().getText()

这就是 LoveApp 的普通对话主流程。


13. conversationId 在 doChat() 中的作用

doChat() 中最关键的一行是:

复制代码
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, chatId))

这行代码不是普通参数,而是告诉 MessageChatMemoryAdvisor

复制代码
这次对话属于哪个会话

也就是说:

复制代码
chatId = 会话编号

同一个 chatId 下的消息会被认为属于同一段连续对话。

例如:

复制代码
chatId = user_001_session_001
第一轮:我和女朋友吵架了
第二轮:那我应该怎么开口?
第三轮:她一直不回消息怎么办?

这些消息可以被同一个会话记忆串起来。

如果换成另一个 chatId,上下文就会隔离。

所以 chatId 的作用可以概括为:

复制代码
让多轮对话有边界

14. call() 和 chatResponse() 的含义

doChat() 中,项目使用的是:

复制代码
.call()
.chatResponse()

其中:

复制代码
call():
表示同步调用模型,等待模型完整返回。

chatResponse():
表示返回完整的 ChatResponse 对象。

Spring AI 文档中也给出了类似用法:通过 chatClient.prompt().user(...).call().chatResponse() 可以拿到包含模型响应和元数据的 ChatResponse。(spring-doc.cn)

为什么项目不用更简单的:

复制代码
.call()
.content()

因为 chatResponse() 拿到的是完整响应对象,后续可以扩展更多信息,比如:

复制代码
模型输出文本
token 使用情况
generation metadata
finish reason

虽然当前项目最后只取了文本:

复制代码
response.getResult().getOutput().getText()

但使用 ChatResponse 给后续扩展留下了空间。


15. doChatWithReport():结构化输出链路

除了普通文本回答,项目还提供了结构化输出方法:

复制代码
public LoveReport doChatWithReport(String message, String chatId)

在这个方法中,项目定义了一个 Java record:

复制代码
record LoveReport(String title, List<String> suggestions) {}

然后通过:

复制代码
.entity(LoveReport.class)

把模型结果映射成 LoveReport 对象。源码中 doChatWithReport() 还会在 system prompt 后追加"每次对话后都要生成恋爱结果,标题为{用户名}的恋爱报告,内容为建议列表"这类结构化要求。(GitHub)

Spring AI 文档中也说明,entity() 方法可以把模型返回结果映射成 Java 对象,例如将模型输出映射为一个 record。(spring-doc.cn)

所以,doChatWithReport()doChat() 的区别是:

复制代码
doChat:
返回 String,自然语言回答。

doChatWithReport:
返回 LoveReport,结构化业务对象。

16. 为什么结构化输出很重要?

在普通聊天场景中,返回字符串就够了。

但在业务系统中,经常需要模型返回可解析结果。

例如:

复制代码
标题
建议列表
风险等级
计划步骤
推荐地点
待办事项

如果只返回字符串,后端很难稳定解析。

而结构化输出可以让后端继续处理:

复制代码
模型输出
    ↓
映射成 Java 对象
    ↓
保存数据库
    ↓
前端卡片展示
    ↓
生成 PDF 报告

在这个项目中,LoveReport 只是一个简单示例。

但它背后的思想很重要:

复制代码
AI 的输出不只是给人看
也可以给程序继续使用

17. doChatWithRag():在主链路上接入 RAG

虽然本期重点是 ChatClient 主链路,但可以简单看一下 RAG 方法。

doChatWithRag() 的流程是:

复制代码
先对用户问题进行查询重写
    ↓
把重写后的问题作为 user message
    ↓
传入 conversationId
    ↓
添加 MyLoggerAdvisor
    ↓
添加 QuestionAnswerAdvisor
    ↓
调用模型

源码中可以看到,方法先调用 queryRewriter.doQueryRewrite(message),然后使用 new QuestionAnswerAdvisor(loveAppVectorStore) 接入本地向量知识库。(GitHub)

这里要注意:RAG 并不是另起一套模型调用流程。

它仍然是:

复制代码
chatClient.prompt()

只是额外加了:

复制代码
.advisors(new QuestionAnswerAdvisor(loveAppVectorStore))

所以 RAG 的本质是:

复制代码
在 ChatClient 主链路上增加检索增强 Advisor

18. QueryRewriter:RAG 前的查询改写

QueryRewriter 负责在检索前改写用户问题。

从源码看,它通过 ChatClient.builder(dashscopeChatModel) 创建查询重写所需的客户端构造器,再使用 RewriteQueryTransformer.builder().chatClientBuilder(builder).build() 构造查询转换器。执行时,它把用户 prompt 封装成 Query,调用 queryTransformer.transform(query) 得到改写后的查询文本。(GitHub)

可以理解为:

复制代码
用户原始问题:
我和她最近关系有点冷淡怎么办?

改写后可能变成:
恋爱关系冷淡、沟通减少、亲密关系修复建议

改写后的问题更适合去知识库里检索相关内容。

这个模块后面可以单独写一篇,这里只需要知道:它也是服务于 ChatClient + RAG Advisor 主链路的。


19. doChatWithTools():在主链路上接入工具

工具调用方法是:

复制代码
public String doChatWithTools(String message, String chatId)

它和普通对话的主要区别是多了一行:

复制代码
.toolCallbacks(allTools)

源码中 allTools 是通过 @Resource 注入的 ToolCallback[]doChatWithTools() 会把这些工具回调注入到本次模型调用中。(GitHub)

可以理解为:

复制代码
普通 doChat:
模型只能回答。

doChatWithTools:
模型可以根据需要调用工具。

完整流程是:

复制代码
用户提出复杂需求
    ↓
ChatClient 构造请求
    ↓
注入 ToolCallback[]
    ↓
模型判断是否需要工具
    ↓
后端执行工具
    ↓
工具结果返回模型
    ↓
模型整理最终回答

这也是 Agent 和普通聊天机器人最大的区别之一。


20. 四条链路的共同点

现在可以把 LoveApp 中的四条链路放在一起看:

复制代码
doChat:
ChatClient + System Prompt + Memory + Logger

doChatWithReport:
ChatClient + System Prompt + Memory + Structured Output

doChatWithRag:
ChatClient + Memory + Logger + Query Rewrite + QuestionAnswerAdvisor

doChatWithTools:
ChatClient + Memory + Logger + ToolCallback[]

它们看起来功能不同,但底层主线是一样的:

复制代码
chatClient
    .prompt()
    .user(...)
    .advisors(...)
    .call()

区别只在于每条链路额外加了什么能力:

复制代码
结构化输出:
加 entity()

RAG:
加 QuestionAnswerAdvisor

工具调用:
加 toolCallbacks()

多轮记忆:
加 conversationId 参数

所以学习这个项目时,要抓住一个核心:

复制代码
不同智能体能力都是围绕 ChatClient 主链路扩展出来的

21. 这个主链路设计有什么优点?

21.1 代码结构清晰

LoveApp 把智能体能力集中封装起来。

外部调用时,不需要关心底层怎么拼 prompt、怎么传 advisor、怎么取 response,只需要调用:

复制代码
doChat
doChatWithReport
doChatWithRag
doChatWithTools

这让项目结构更清晰。


21.2 默认配置复用性强

系统提示词、记忆 Advisor、日志 Advisor 都在构造 ChatClient 时配置。

这样普通对话、结构化输出、RAG 对话和工具调用都可以复用基础能力。


21.3 扩展能力比较自然

需要 RAG 时,加一个 QuestionAnswerAdvisor

需要工具调用时,加一个 ToolCallback[]

需要结构化输出时,加 .entity()

这种写法很符合 Spring AI 的链式风格,也便于逐步学习和扩展。


21.4 适合从简单到复杂演进

项目不是一开始就写一个特别复杂的 Agent,而是分成几条方法:

复制代码
先做普通对话
再做结构化输出
再接入 RAG
最后接入工具调用

这很适合学习者理解 Agent 的演进过程。


22. 当前实现中可以改进的地方

22.1 LoveApp 承担的职责有点多

目前 LoveApp 同时包含:

复制代码
普通对话
结构化报告
RAG 对话
工具调用

对于学习项目来说,这样写很直观。

但如果后续业务复杂,可以拆成:

复制代码
LoveChatService
LoveReportService
LoveRagService
LoveToolAgentService

这样每个类职责更单一。


22.2 system prompt 可以外部配置化

当前 SYSTEM_PROMPT 写死在代码中。

后续可以把它放到配置文件或数据库中,例如:

复制代码
agent:
  love:
    system-prompt: "扮演深耕恋爱心理领域的专家..."

这样修改智能体角色时,就不用重新改代码。


22.3 chatId 需要更规范的生成规则

当前 doChat() 直接接收 chatId

后续如果接入正式接口,需要明确:

复制代码
chatId 从哪里来?
一个用户能不能访问另一个用户的 chatId?
chatId 是否需要和用户 ID 绑定?
会话历史是否需要持久化?

否则会话隔离会存在风险。


22.4 日志需要注意隐私

MyLoggerAdvisor 会打印用户输入和 AI 输出。

学习阶段这样很方便观察。

但正式系统中,恋爱咨询内容可能包含隐私信息,所以需要考虑:

复制代码
敏感信息脱敏
日志级别控制
生产环境关闭详细日志
用户隐私合规

22.5 工具调用链路需要安全边界

doChatWithTools() 把所有工具都注入给模型。

后续可以按场景区分工具权限:

复制代码
普通咨询:
只允许文本回答

约会规划:
允许网页搜索和网页抓取

报告生成:
允许 PDF 生成

高级任务:
才允许文件操作和终端执行

这样比"一次性注入所有工具"更安全。


23. 本期重点理解

这一期最重要的是理解 LoveApp 的主链路。

可以总结为五点:

复制代码
第一,ChatModel 是底层模型接口,ChatClient 是更高层的对话编排客户端。
第二,LoveApp 通过 ChatClient.builder(dashscopeChatModel) 构建智能体主流程。
第三,defaultSystem(SYSTEM_PROMPT) 用于固定智能体角色。
第四,defaultAdvisors() 默认接入对话记忆和日志增强。
第五,doChat、doChatWithReport、doChatWithRag、doChatWithTools 都是在 ChatClient 主链路上的不同扩展。

一句话概括:

复制代码
LoveApp 的核心作用,是把 Spring AI 的 ChatClient 封装成一个面向恋爱咨询场景的智能体应用入口。

24. 我的理解

我认为 LoveApp 是这个项目最值得先读懂的类。

它展示了一个 Agent 项目的基本组织方式:

复制代码
先用 system prompt 定义角色
再用 ChatClient 统一模型调用
再用 Advisor 接入记忆和日志
再按需要扩展 RAG、结构化输出和工具调用

这个思路比直接写"调用模型 API"更工程化。

普通模型调用只解决:

复制代码
怎么问模型?

LoveApp 解决的是:

复制代码
怎么把模型封装成一个可持续扩展的智能体?

这也是学习 Spring AI Agent 项目的关键。


25. 本期小结

本期主要分析了 ai_agent 项目中的 Spring AI 与 ChatClient 主链路。

项目通过 LoveApp 封装智能体核心能力。LoveApp 构造函数接收 ChatModel,并通过 ChatClient.builder(dashscopeChatModel) 创建对话客户端;通过 defaultSystem(SYSTEM_PROMPT) 固定恋爱心理专家角色;通过 defaultAdvisors() 默认接入 MessageChatMemoryAdvisorMyLoggerAdvisor;在 doChat() 中,通过 prompt()user()advisors()call()chatResponse() 完成一次支持多轮记忆的普通对话。同时,项目还基于同一条主链路扩展了结构化输出、RAG 检索增强和工具调用能力。

这一期可以用一句话总结:

复制代码
ChatClient 主链路的作用,是把模型调用、系统提示词、会话记忆、日志增强、结构化输出、RAG 和工具调用统一组织到一条可扩展的智能体调用流程中。

下一期可以继续分析:

AI Agent 项目学习笔记(三):Advisor 机制与对话增强设计

下一期重点分析 MessageChatMemoryAdvisorMyLoggerAdvisorQuestionAnswerAdvisorReReadingAdvisor,理解 Advisor 为什么可以看作 Spring AI 智能体中的"对话增强中间件"。

相关推荐
zhangxingchao2 小时前
AI应用开发六:企业知识库
前端·人工智能·后端
Terrence Shen3 小时前
关于传统软件工程后端技术和当代AI智能体agent构建的harness engineering的一点思考
人工智能·软件工程
冬奇Lab3 小时前
RAG 系列(二十二):长上下文 vs RAG——要不要 RAG
人工智能·llm
福客AI智能客服3 小时前
电商AI客服进入物流场景,服务响应开始靠近履约环节
人工智能·ai智能客服机器人
闵孚龙3 小时前
Claude Code Ultraplan 远程多代理规划全解析:AI Agent、CCR远程容器、异步规划、状态机、计划传送与企业级自动化治理
运维·人工智能·自动化
冬奇Lab3 小时前
一天一个开源项目(第105篇):Academic Research Skills - 学术研究全流程 AI 代理套件,及其工作流设计的启示
人工智能·开源·资讯
冬奇Lab3 小时前
RAG 系列(二十一):性能优化——又快又省钱
人工智能·llm
Robot_Nav3 小时前
深度学习与强化学习面试八股文知识点汇总
人工智能·深度学习·强化学习
C+++Python3 小时前
C++ 进阶学习完整指南
java·c++·学习