当前位置: 首页 > news >正文

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 智能体中的“对话增强中间件”。

http://www.cnnetsun.cn/news/2437257.html

相关文章:

  • codex出现Reconnecting和stream disconnected before completion:stream closed before response.complete解决方案
  • 紧急通知:FAO 2024渔业AI伦理新规已生效!NotebookLM合规使用红线清单(含数据脱敏、模型可解释性、渔民知情权三重校验表)
  • 新时代的信息茧房
  • 开源威胁检测工具openclaw-nie-guard部署与实战指南
  • 保姆级图解:用MMDetection3D复现SMOKE3D时,DLA34骨干网络的特征图到底怎么传?
  • 终极指南:5步掌握Rusted PackFile Manager打造Total War模组
  • 如何高效解密QQ音乐文件:QMCDump工具完整使用指南
  • 5步解锁显卡隐藏性能:NVIDIA Profile Inspector全面指南
  • 5分钟快速上手:用FakeLocation实现Android应用级虚拟定位
  • 如何免费获取米哈游11款游戏字体:完整安装与创意应用指南
  • 如何快速部署FastGithub:终极GitHub加速配置指南
  • 基于Python+OpenCV+MediaPipe的手势识别实战:从环境搭建到实时标注
  • 微信读书笔记助手完整教程:3分钟掌握高效阅读笔记技巧
  • 终极B站会员购抢票神器:5分钟掌握自动化抢票完整攻略
  • 从BERT到GPT-4:大语言模型的技术演进与应用实践
  • 嵌入式调试器核心原理与实战技巧:从JTAG到HardFault排查
  • 利用Taotoken多模型能力为智能客服场景选型
  • 3分钟快速上手:FigmaCN中文界面插件终极安装指南
  • 从M到D:深入解析C#操作汇川PLC不同寄存器(X,Y,M,D,R)的代码实战
  • 从HPAanalyze到QuPath:构建R语言驱动的IHC图像自动化半定量分析流程
  • AppleRa1n深度解析:iOS 15-16设备激活锁绕过终极指南
  • WinRing0深度解析:Windows硬件访问的终极解决方案
  • 避开Signal Tap的坑:Quartus Prime 18.1下嵌入式逻辑分析仪从安装到抓波的完整配置流程
  • 在虚拟机中快速部署大模型调用环境,使用Taotoken的Python SDK实现稳定接入
  • 别再用旧粒子系统了!试试Unity VFX Graph:制作可交互场景特效的5个实战技巧
  • 信步SCM-6100U嵌入式主板:Elkhart Lake平台在边缘计算与工业物联网中的实战应用
  • Play Integrity API验证工具:3分钟快速检测Android设备安全状态
  • 终极音频智能切片工具:5分钟快速处理长音频文件
  • 基于MCP协议构建AI支付网关:连接Clawd与智能体的实践指南
  • 别再只会用memset初始化数组了!C语言内存块初始化函数还有这些隐藏用法