AI Agent 项目学习笔记(二):Spring AI 与 ChatClient 主链路解析
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 主链路上增加检索增强 Advisor18. 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 智能体中的“对话增强中间件”。
