【SAA实战】第 2 篇:模型与消息——ReactAgent 怎么挑模型、怎么传消息

① 先别学概念,先接个活儿

第 1 篇我们把一个 ChatModel 包进了 ReactAgent,跑通了天气助手。这会儿产品又来了:

「这个助手,我希望遇到严肃问题(比如法律、医疗)时切到能力更强、更稳的 qwen-max,平时闲聊用 qwen-plus 就够;另外,客服系统要把「用户 ID、来源渠道」这类信息带在每条消息上,方便后面统计;还有,有些场景我手里已经有一整段历史对话,想一次性塞给 Agent 让它接着聊。」

你看,这活儿里没有一个是 ReactAgent 凭空发明的:

  • 切模型 ------ 你用 ChatClient 时也会切模型(改 ChatModel 或每次 Prompt 选项);
  • 每条消息带点附属信息 ------ UserMessage 本来就能挂 metadata
  • 塞历史 ------ ChatClient.call(Prompt(List<Message>)) 也能干。

所以这一篇的真相是:模型和消息的接口 90% 都是 Spring AI 原生的,ReactAgent 只是把它们「原样接住」了。真正值得记的只有两件事:

  1. ReactAgent 在「Agent 级」给模型选项提供了一处便捷的挂载点(.chatOptions(...));
  2. 跨调用的对话状态会被框架自动管起来(靠 saver + threadId),你不用每轮自己拼历史。

第 2 点要到第 4 篇《记忆体系》才深挖,本篇你先记住「有这回事」即可。

⚠️ 一个必须先撕掉的误解:UserMessagemetadata 不会自动进模型看到的 prompt 。它只是挂在消息对象上的「随行小纸条」,你能读回来、自己的 hook / 工具也能读,但模型看不见它。想让模型知道「用户是 u1、来自 app 渠道」,得把它写进 textsystemPrompt,而不是塞进 metadata。这一点下面 Demo B 会当面验证。

② 本文目标

读完后你能回答四件事:

  1. 在 ReactAgent 里,模型怎么配ChatModelChatOptions 的关系,以及 Agent 级的 .chatOptions(...));
  2. metadata 到底去哪了(为什么模型看不到它);
  3. call() 返回里到底有什么 ------重点剧透:是最终的 AssistantMessage(文本答案),不是这一轮调过的工具。

③ 原理一张图

先建立心智模型。把 ChatModel 想成你项目里那个底层客户端 (跟 RestTemplateDataSource 一个性质:负责跟远端大模型打交道,本身无状态);ReactAgent 是在它外面套的一层 Service,负责把「调工具、管上下文、可观测」这些脏活揽下来。

scss 复制代码
          ┌──────────────────────────────────────────┐
   配置项  │  DashScopeChatOptions                    │
 (模型/温度/ │  .withModel("qwen-plus")                │
   token)  │  .withTemperature(0.7)  ──┐               │
          └──────────────────────────┼───────────────┘
                                      ▼
   ChatModel(底层客户端,无状态,跟 RestTemplate 一样)
                                      │  .model(chatModel)
                                      ▼
   ┌─────────────────────────────────────────────────┐
   │  ReactAgent(Service 层)                          │
   │                                                   │
   │   消息入口: call(String)                          │
   │            call(UserMessage)                      │
   │            call(List<Message>)  ── 一组消息作为本轮输入│
   │                          │                        │
   │                          ▼                        │
   │   跨调用状态(threadId 串起,靠 saver 持久化,追加) │
   │                          │                        │
   │                          ▼                        │
   │   ChatModel 一轮轮跑,直到能回答                     │
   │                          │                        │
   │                          ▼                        │
   │   返回【最终】AssistantMessage(纯文本答案)         │
   │   ← 注意:这一轮调过的工具不在这条消息上              │
   └─────────────────────────────────────────────────┘

对比 ChatClient:它也是一个无状态管道,多轮得你自己把 List<Message> 重新拼一遍call。ReactAgent 的差别就在中间那块「跨调用状态」------同一 threadId 下的多次 call 会自动把历史累积起来,你不用每轮手动拼。这一点正是第 4 篇要深挖的。

④ 最小可运行代码

下面这段代码把上面几件事一次演示完。每个 Demo 都用独立 threadId,互不污染,输出可复现。

第一步:配一个带选项的 ChatModel 。模型名、温度这些不是 ReactAgent 的事,是 ChatModel 的事,通过 DashScopeChatOptions 设:

java 复制代码
@Bean
public ChatModel chatModel(@Value("${spring.ai.dashscope.api-key}") String apiKey) {
    DashScopeApi api = DashScopeApi.builder().apiKey(apiKey).build();
    // 默认选项:平时闲聊用 qwen-plus,温度调高一点更活泼
    DashScopeChatOptions options = DashScopeChatOptions.builder()
            .withModel("qwen-plus")
            .withTemperature(0.7)
            .withMaxToken(2000)
            .build();
    return DashScopeChatModel.builder()
            .dashScopeApi(api)
            .defaultOptions(options)
            .build();
}

第二步:组装两个 Agent------一个日常、一个严肃 。第二个用了 .chatOptions(...),这是 ReactAgent 提供的一处便捷挂载点:把模型选项在构建时 固定到 Agent 上,之后每次 call 都自动带这组选项,不用去动底层 ChatModel,也不用每次 call 都传 Prompt 选项(ChatClient 的等价写法是在每次 call.options(...))。

java 复制代码
// 日常 Agent:用 ChatModel 的默认选项(qwen-plus)
@Bean
public ReactAgent assistantAgent(ChatModel chatModel) {
    return ReactAgent.builder()
            .name("assistant")
            .model(chatModel)
            .systemPrompt("你是一个乐于助人的中文助手,需要时可以调用 demo_query 工具查询资料。")
            .tools(DemoTools.queryTool())
            .saver(new MemorySaver())
            .build();
}

// 严肃 Agent:在 Agent 级覆盖模型为 qwen-max、温度调低更严谨
@Bean
public ReactAgent strictAgent(ChatModel chatModel) {
    DashScopeChatOptions strictOpts = DashScopeChatOptions.builder()
            .withModel("qwen-max")
            .withTemperature(0.3)
            .build();
    return ReactAgent.builder()
            .name("strict")
            .model(chatModel)
            .chatOptions(strictOpts)          // 构建时固定,每次 call 自动带
            .systemPrompt("你是一个严谨的专业顾问,回答要准确克制,需要时可调用 demo_query 工具。")
            .tools(DemoTools.queryTool())
            .saver(new MemorySaver())
            .build();
}

第三步:用几种入口调它,并看返回 。这里把「产品要的几件事」逐一兑现,最后用一个 Demo 当面戳破 call() 返回里没有工具调用的误解:

java 复制代码
// A. 最小入口:直接丢字符串(内部包成 UserMessage)
AssistantMessage r1 = assistantAgent.call("你好,帮我查一下「Spring AI」的资料", cfg("demo-a"));

// B. 带上下文:UserMessage 挂 metadata(用户 ID、渠道等随消息走,但模型看不到)
UserMessage um = UserMessage.builder()
        .text("请查「Java 并发」")
        .metadata(Map.of("user_id", "u1", "channel", "app"))
        .build();
AssistantMessage r2 = assistantAgent.call(um, cfg("demo-b"));
// metadata 挂在消息对象上,自己能读回来;但它不会自动进模型看到的 prompt
System.out.println("回读 metadata: " + um.getMetadata());

// C. 一组消息作为本轮输入一次提交(会追加进该 thread 的累积历史)
List<Message> history = List.of(
        new UserMessage("我喜欢篮球"),
        new UserMessage("给我讲讲打篮球的好处"));
AssistantMessage r3 = assistantAgent.call(history, cfg("demo-c"));

// D. 切模型:同一个问题丢给 strictAgent,构建时已用 .chatOptions 定好 qwen-max
AssistantMessage r4 = strictAgent.call("用一句话严谨地定义什么是大模型", cfg("demo-d"));

// E. 避坑:call() 返回的是"最终答案"消息,这一轮调过的工具不在它身上
AssistantMessage r5 = assistantAgent.call("帮我查「Spring AI」的资料", cfg("demo-e"));
System.out.println("getToolCalls() = " + r5.getToolCalls());   // 输出 []
System.out.println("hasToolCalls() = " + r5.hasToolCalls());   // 输出 false

cfg("demo-x") 只是 RunnableConfig.builder().threadId("demo-x").build() 的简写,用来给每个 Demo 一个独立会话,互不干扰。 提醒:call(...) 会抛受检异常 GraphRunnerException,实际代码里要 try-catch 或 throws(仓库样例里统一包了一层)。

返回里你到底能拿到什么? call() 返回的是 Spring AI 那个 AssistantMessage,不是新类型。但请记住它是最终的答案消息

java 复制代码
System.out.println("文本: " + r1.getText());
// r1.getToolCalls() 永远是空列表 ------ 因为最终答案消息上本来就没有工具调用

⑤ 跑起来看效果

spring-boot:run 后,控制台大致是这样(省略模型具体输出):

swift 复制代码
=== Demo A: call(String) ===
文本: 根据查询结果,「Spring AI」是一条演示数据(来自 demo_query 工具)。目前没有更多详细信息。如果你有更具体的问题或需要进一步了解的内容,欢迎告诉我!
-----  
{"metadata":{"finishReason":"STOP","search_info":"","role":"ASSISTANT","id":"5960a6d7-bddb-9841-9010-9cfcc225eea0","messageType":"ASSISTANT","reasoningContent":""},"messageType":"ASSISTANT","media":[],"text":"根据查询结果,「Spring AI」是一条演示数据(来自 demo_query 工具)。目前没有更多详细信息。如果你有更具体的问题或需要进一步了解的内容,欢迎告诉我!","toolCalls":[]}
------

=== Demo B: call(UserMessage + metadata) ===
文本: 关于「Java 并发」,这是一条演示数据(来自 demo_query 工具)。  

如需更深入的内容,例如:  
- Java 中的线程创建与生命周期  
- `synchronized` 与 `ReentrantLock` 的区别  
- `volatile` 关键字的作用与内存模型(JMM)  
- 线程池(`ThreadPoolExecutor`)的核心参数与使用场景  
- `ConcurrentHashMap`、`CopyOnWriteArrayList` 等并发集合原理  
- `CompletableFuture` 与现代异步编程  

欢迎随时提出具体问题,我可以为您详细讲解!
-----  
{"metadata":{"finishReason":"STOP","search_info":"","role":"ASSISTANT","id":"285ff313-12bc-9aa6-ba9b-a266e24a81fa","messageType":"ASSISTANT","reasoningContent":""},"messageType":"ASSISTANT","media":[],"text":"关于「Java 并发」,这是一条演示数据(来自 demo_query 工具)。  \n\n如需更深入的内容,例如:  \n- Java 中的线程创建与生命周期  \n- `synchronized` 与 `ReentrantLock` 的区别  \n- `volatile` 关键字的作用与内存模型(JMM)  \n- 线程池(`ThreadPoolExecutor`)的核心参数与使用场景  \n- `ConcurrentHashMap`、`CopyOnWriteArrayList` 等并发集合原理  \n- `CompletableFuture` 与现代异步编程  \n\n欢迎随时提出具体问题,我可以为您详细讲解!","toolCalls":[]}
------

   回读 metadata: {channel=app, messageType=USER, user_id=u1}
=== Demo C: call(List<Message>) ===
文本: 打篮球是一项非常受欢迎的运动,它不仅有趣,还对身体、心理和社交方面都有诸多益处。以下是一些主要好处:

### 🏀 身体健康方面:
- **增强心肺功能**:持续跑动、跳跃和快速变向能有效提升心肺耐力。
- **提高协调性与灵活性**:运球、投篮、防守等动作需要手眼协调、平衡感和敏捷反应。
- **增强肌肉力量与耐力**:尤其锻炼下肢(大腿、小腿)、核心肌群和上肢力量。
- **促进骨骼健康**:跳跃类运动有助于增加骨密度,预防骨质疏松。
- **帮助控制体重**:一场激烈比赛或训练可消耗大量热量(约500--700千卡/小时)。

### 🧠 心理健康方面:
- **缓解压力与焦虑**:运动促使大脑释放内啡肽,带来愉悦感和放松效果。
- **提升专注力与执行力**:比赛中需快速判断、决策和应变,长期练习可改善认知功能。
- **增强自信心与抗挫能力**:通过不断练习和比赛成长,建立成就感和心理韧性。

### 👥 社交与团队协作:
- **培养团队精神与沟通能力**:篮球是典型的团体运动,强调配合、信任与战术执行。
- **拓展人际圈**:加入球队或参与业余联赛,有助于结识志同道合的朋友。
- **学习尊重与规则意识**:在公平竞争中理解规则、尊重对手与裁判。

如果你有兴趣,我还可以为你提供:
- 初学者入门技巧(如运球、投篮姿势)
- 科学训练建议(每周几次?如何避免受伤?)
- 经典球员或球队介绍
- 国内业余篮球赛事信息

需要哪方面的内容呢? 😊
-----  
{"metadata":{"finishReason":"STOP","search_info":"","role":"ASSISTANT","id":"a6488d2b-7837-9756-977f-9ed4ab5326ed","messageType":"ASSISTANT","reasoningContent":""},"messageType":"ASSISTANT","media":[],"text":"打篮球是一项非常受欢迎的运动,它不仅有趣,还对身体、心理和社交方面都有诸多益处。以下是一些主要好处:\n\n### 🏀 身体健康方面:\n- **增强心肺功能**:持续跑动、跳跃和快速变向能有效提升心肺耐力。\n- **提高协调性与灵活性**:运球、投篮、防守等动作需要手眼协调、平衡感和敏捷反应。\n- **增强肌肉力量与耐力**:尤其锻炼下肢(大腿、小腿)、核心肌群和上肢力量。\n- **促进骨骼健康**:跳跃类运动有助于增加骨密度,预防骨质疏松。\n- **帮助控制体重**:一场激烈比赛或训练可消耗大量热量(约500--700千卡/小时)。\n\n### 🧠 心理健康方面:\n- **缓解压力与焦虑**:运动促使大脑释放内啡肽,带来愉悦感和放松效果。\n- **提升专注力与执行力**:比赛中需快速判断、决策和应变,长期练习可改善认知功能。\n- **增强自信心与抗挫能力**:通过不断练习和比赛成长,建立成就感和心理韧性。\n\n### 👥 社交与团队协作:\n- **培养团队精神与沟通能力**:篮球是典型的团体运动,强调配合、信任与战术执行。\n- **拓展人际圈**:加入球队或参与业余联赛,有助于结识志同道合的朋友。\n- **学习尊重与规则意识**:在公平竞争中理解规则、尊重对手与裁判。\n\n如果你有兴趣,我还可以为你提供:\n- 初学者入门技巧(如运球、投篮姿势)\n- 科学训练建议(每周几次?如何避免受伤?)\n- 经典球员或球队介绍\n- 国内业余篮球赛事信息\n\n需要哪方面的内容呢? 😊","toolCalls":[]}
------

=== Demo D: 切到 qwen-max 的 strictAgent ===
文本: 大模型是指具有大量参数(通常数以亿计)的深度学习模型,它通过在大规模数据集上进行训练来学习复杂的模式和特征,从而能够执行各种任务,如自然语言处理、图像识别等。
-----  
{"metadata":{"finishReason":"STOP","search_info":"","role":"ASSISTANT","id":"a7a6394d-978e-9e06-a755-9b25f1a6b343","messageType":"ASSISTANT","reasoningContent":""},"messageType":"ASSISTANT","media":[],"text":"大模型是指具有大量参数(通常数以亿计)的深度学习模型,它通过在大规模数据集上进行训练来学习复杂的模式和特征,从而能够执行各种任务,如自然语言处理、图像识别等。","toolCalls":[]}
------

=== Demo E: call() 返回里有没有工具调用? ===
getToolCalls() = []
hasToolCalls() = false

注意几点,它们正好对应前面几个容易迷糊的地方:

  • Demo B 的 回读 metadata 有值,但模型的回答里并没有「用户 u1 / app 渠道」这种信息 ------因为 metadata 没进 prompt。它真正有用的地方是你的 hook、工具或统计代码去读它,而不是让模型「看到」。
  • Demo C 你只调了一次 call,但传进去的是「两条用户消息」 ,Agent 读到了完整片段------这就是 List<Message> 入口的用途:把一组消息作为「本轮」的输入一次提交。它和「跨调用的自动历史」是两码事(见坑 3)。
  • Demo D 用的是另一个 Agent Bean ,模型选项在构建时就定死了 qwen-max,你不需要改任何 ChatModel
  • Demo E 当面证明 :这一轮明明调了工具,但 call() 返回的 AssistantMessage.getToolCalls() 依然是空。想看工具调用,得用 invoke() 拿完整状态或 stream()

⑥ Spring AI 原写法 vs SAA 写法(老实对照)

这块不夸大,差异其实不大,主要是「多一层封装 + 一处构建时挂载点 + 跨调用状态」。

核心结论:模型和消息这块,SAA 基本是「拿来主义」------接口全是 Spring AI 的。它真正多出来的只有:

  1. Agent 级 .chatOptions(...) :把「每次 call 都带同一组选项」提前到构建时固定,免去每次传 Prompt 选项;
  2. 跨调用的对话状态 :同一 threadId 下你不用每轮手动拼历史(这点第 4 篇展开)。

至于「工具调用在返回里拿不到」------这不是 SAA 的缺陷,是 ReAct 这类 Agent 的共性call() 只把成品(最终答案)递出来,过程里的工具调用留在运行时的完整状态里,要专门用 invoke() / stream() 去取。

所以如果你已经会用 ChatClient + ChatModel + Message,这一篇几乎没有新概念,只是换了个入口、多了个构建时挂载点。

⑦ 小结 & 下一篇预告

本篇要点收一下:

  • 模型和消息的 API 绝大多数是 Spring AI 原生的,ReactAgent 原样接住,没发明新轮子;
  • SAA 多出来的两处:构建时固定的 Agent 级 .chatOptions(...) (换模型不碰 ChatModel),以及跨调用对话状态 (同一 threadId 下历史自动累积,第 4 篇展开);
  • call 有三种入口(String / UserMessage / List<Message>),返回统一的 AssistantMessage------但它是最终答案工具调用不在它身上
  • UserMessage.metadata 是「随行小纸条」,模型看不见,别拿来当 prompt 上下文。

下一篇我们终于要碰 工具调用 了------第 3 篇《工具调用全攻略:@Tool 入门 + Agent Tool 嵌套》。@Tool / FunctionToolCallback 你在主系列第 5~6 篇已经熟了,所以本篇重点放在 SAA 独一份的 Agent Tool(把一个 Agent 注册成另一个 Agent 的工具) 上,并顺便把「call() 拿不到、但 invoke() / stream() 怎么拿到工具调用」这件事彻底讲透。

相关推荐
qo_tn1 小时前
Docker_02-容器操作_11-怎样进入容器执行命令
后端
架构精进之路1 小时前
Claude Code 深度使用指南:从"会用"到"用好"的7个进阶心法
后端·openai·ai编程
码匠许师傅1 小时前
【C++ 面试真题】35. 聊聊 C++ 的万能引用(T&&)和完美转发(std::forward)
java·c++·面试
明月_清风1 小时前
看完 DSH 文档后,我总结了这 7 个关键点
前端·后端·deepseek
元界metalite1 小时前
Spring-MVC微服务接口怎么分层-网关Controller与Service边界
后端
十年Java程序媛1 小时前
注解已经加上,但是事务没有回滚?拆解 AOP 代理底层原因 + 代码示例
java
μθημα1 小时前
K8s Pod 管理实战:虚拟机环境下的具体命令与步骤
java·docker·kubernetes
数据狐(Datafox)1 小时前
1688.item_search工程实战:1688商品列表API分页采集与B2B货源数据分析落地
java·前端·数据分析
省长1 小时前
不想写代码,但想要集成一个登录页面?Sa-Token-Quick-Login 帮你实现!
java·后端·开源