1. 本期目标
上一篇文章整体介绍了 ai_agent 项目。我们已经知道,这个项目不是一个简单聊天机器人,而是一个基于 Spring Boot 3 和 Spring 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,后面看 Advisor、ChatMemory、QuestionAnswerAdvisor、ToolCallback[] 时就会比较散。
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 请求和响应日志。
源码中 LoveApp 的 defaultAdvisors() 正是这样配置的。(GitHub)
这说明 Advisor 可以理解成模型调用链路中的增强器。
它有点像 Web 后端中的拦截器:
请求进入模型前:
Advisor 可以修改或增强请求。
模型返回后:
Advisor 可以记录、观察或处理响应。
当然,Advisor 不只是日志。后面 RAG 中的 QuestionAnswerAdvisor 也是 Advisor。
所以整个项目的设计思路是:
ChatClient 负责主流程
Advisor 负责增强流程
10. MyLoggerAdvisor:日志增强
MyLoggerAdvisor 是项目自定义的日志 Advisor。
从源码看,它实现了 CallAdvisor 和 StreamAdvisor,在请求前打印 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() 默认接入 MessageChatMemoryAdvisor 和 MyLoggerAdvisor;在 doChat() 中,通过 prompt()、user()、advisors()、call()、chatResponse() 完成一次支持多轮记忆的普通对话。同时,项目还基于同一条主链路扩展了结构化输出、RAG 检索增强和工具调用能力。
这一期可以用一句话总结:
ChatClient 主链路的作用,是把模型调用、系统提示词、会话记忆、日志增强、结构化输出、RAG 和工具调用统一组织到一条可扩展的智能体调用流程中。
下一期可以继续分析:
AI Agent 项目学习笔记(三):Advisor 机制与对话增强设计
下一期重点分析 MessageChatMemoryAdvisor、MyLoggerAdvisor、QuestionAnswerAdvisor 和 ReReadingAdvisor,理解 Advisor 为什么可以看作 Spring AI 智能体中的"对话增强中间件"。