从 ChatClient 到 Agent Runtime:Spring AI Alibaba ReAct 实现原理全解析
大多数人在让 AI 完成多步任务时,最先碰到的不是模型能力问题,而是一个很具体的运行时问题:ChatClient 一次只能推进一步,看不到中间结果,也没有继续决策的入口。
加 Memory、加 Tool Calling 能勉强多做一些,但一旦任务需要"先查航班、再看政策、再给推荐"这种动态链路,就会暴露框架边界。
真正要补上的,不是多一个工具,而是引入一层能自主推进目标的运行时——也就是 Agent。
读完这一篇,你应该能把 Spring AI Alibaba 的 ReactAgent 最小闭环跑通,搞清楚 ReAct、CodeAct、Workflow 三种模式的边界在哪,并且能定位到 StateGraph、AgentLlmNode、MemorySaver 的源码,知道 ReAct 在框架里到底是怎么跑起来的。
系列目标:从零构建一个机票客服型 Agent「票小蜜」 本篇目标:讲清 Agent 模式全景,重点吃透 ReAct 与 CodeAct,再落回 Spring AI Alibaba 的
ReactAgent最小闭环 前置知识:已完成前文的 ChatClient、Tool Calling、Memory、RAG 基础
理论篇
一、ChatClient 开始吃力的地方——一类新问题的出现
做到这一章之前,前面的能力其实都已经很能打了。
我们有 ChatClient,有 Tool Calling,有 Memory,也有 RAG。单独看每一个点,都说得过去。查航班可以,查知识库也可以,做一个看起来像样的 AI 助手也不难。
真正到了「该进入 Agent 了」这个判断,不是因为看到了某个新名词,而是因为碰到了一类 ChatClient 明显开始吃力的问题。
比如这句话:
帮我找一张明天北京到上海性价比高、退改签别太苛刻的机票。
这句话表面上像问答,实际上不是。
它不是在问一个明确事实,也不是让系统执行一个固定动作。它真正要求系统做的,是一条运行时决策链:
- 先查有哪些候选航班
- 看完候选结果,再决定要不要补查退改签政策
- 如果用户条件不全,先追问,而不是瞎猜
- 信息够了,再给出推荐和理由
问题就出在这里。
ChatClient 可以调工具,但它天然更像“一次输入 → 一次生成 → 一次结束”的模型。
ChatClient 很擅长:
- 回答一个明确问题
- 调一次工具后做总结
- 在当前输入范围内给出一轮响应
但它不天然擅长:
- 看完工具结果后再决定下一步
- 在信息不完整时先澄清再行动
- 围绕用户目标持续推进,而不是只回一句话
这是第一个真实的拐点。
工程判断:从这里开始,问题已经不再是“模型够不够强”,而是“运行时是否允许系统继续判断下一步”。
这一步不是“再学一个类”,而是课程主线第一次从能力接入进入决策运行时。
二、Agent 不是一个统一物种——先把五种模式的全景立起来
很多人不是不会用 Agent,而是太早用 Agent。
一旦你把什么都往 Agent 里塞,系统会立刻变得难测、难控、难审计。真正靠谱的做法,是先把常见 Agent 设计模式的地形图看清楚,再决定这一章到底该站在哪个位置。
2.1 从系统视角看,常见 Agent 模式可以分成五类
| 模式 | 核心思想 | 决策权归谁 | 适合什么场景 | 代价 |
|---|---|---|---|---|
| Router / Tool-Use | 模型判断要不要调某个工具 | 模型 | 单步工具选择、单轮增强问答 | 低 |
| Workflow / Plan Script | 开发者把流程提前写死 | 开发者 | 流程稳定、规则清晰、可审计业务 | 低 |
| ReAct | 推理与行动交替进行 | 模型 + 运行时循环 | 步骤不固定、要看中间结果继续判断 | 中 |
| Plan-and-Execute | 先拆计划,再逐步执行 | 模型先规划,执行阶段半受控 | 长任务、分阶段目标推进 | 中到高 |
| Reflection / Self-Refine | 先做,再自检,再修正 | 模型 | 需要迭代打磨质量的生成任务 | 中到高 |
| CodeAct | 用代码作为通用动作接口与环境交互 | 模型 + 可执行环境 | 复杂环境操作、开放式任务、coding agent | 高 |
把它们放进一条能力递进链,大概是这样的:
这张图很重要,因为它解释了一个常见误解:
Agent 不是一个统一物种,而是一整套设计模式的总称。
2.2 为什么不从 Plan-and-Execute 开始,而是先压 ReAct
顺序上,先压 ReAct,再谈更复杂的模式。
在工程里,ReAct 是很多 Agent Runtime 的真正起点。它不是最先进的模式,但它第一次把下面这个能力变成了稳定结构:
模型不再只是回答,而是在运行时交替做两件事:思考下一步、执行下一步。
没有这一层,你后面谈:
- 多轮工具调用
- 运行时状态推进
- 记忆续接
- 中间结果驱动决策
都会飘在空中。
主轴是:
- 先把 Agent 模式全景立起来
- 再把 ReAct 作为第一性原理讲透
- 然后把 CodeAct 作为“动作空间升级”的方向讲清楚
- 最后回到
ReactAgent,看框架到底托管了什么
这样顺序才顺。
2.3 这张全景图里,当前重点落在哪一层
这里不需要把所有模式一口气讲完,重点是先建立一个稳定判断:
ChatClient解决的是一轮生成问题Workflow解决的是流程提前已知的问题ReAct解决的是运行时决策问题CodeAct代表的是环境交互能力更强的 Agent 形态
其它模式先建立地标,不在这一章里展开到失焦。
工程判断:能用 Workflow 解决的,就别先上 Agent。真正值得引入 ReAct 的,是“固定流程写不住”的那一段。
三、ReAct 为什么重要——它不是一个技巧,而是 Agent Runtime 的第一块地基
真正把 Agent 看明白,不是在看到 builder() 的时候,而是在看懂 ReAct 闭环的时候。
因为 Agent 和普通问答系统的本质差别,不在有没有工具,不在 prompt 写得多长,而在于:
系统有没有一个允许“继续做下一步”的运行时循环。
3.1 ReAct 到底是什么
ReAct 是 Reasoning + Acting 的缩写。
很多文章会把它翻成“推理与行动结合”,这句话没错,但太轻了。真正有工程意义的理解应该是:
模型的输出不再只有答案,还可能是一个动作决定。
每一轮模型都在做两选一:
- 我要不要调用工具
- 我是不是已经可以给最终回答了
这看起来像一句废话,但它恰好是普通问答和 Agent 的分水岭。
普通问答里,模型输出的是“内容”。
ReAct 里,模型输出的是“下一步”。
一旦进入 ReAct,系统的本质就变了。
3.2 ReAct 的真正载体,不是“神秘推理”,而是一份不断增长的消息列表
很多讨论会把注意力全部放在“Reason”这部分,以为关键是模型在脑子里想了什么。
工程上更关键的,是它把什么留下来了。
ReAct 的核心载体其实是完整消息历史。
以“帮我找一张退改签别太苛刻的机票”为例,消息会这样演进:
[0] system: 你是票小蜜,你的目标不是只回答一句话,而是推进用户目标
[1] user: 帮我找一张明天北京到上海退改签别太苛刻的机票
→ 第 1 轮
[2] assistant: tool_call searchFlights(...)
[3] tool: 航班列表...
→ 第 2 轮
[4] assistant: tool_call searchKnowledge(...)
[5] tool: 退改签政策...
→ 第 3 轮
[6] assistant: 推荐 MU5678,原因是价格和退改签更均衡...
注意这里最关键的不是“调了两个工具”,而是:
- 第 2 轮模型能看到第 1 轮工具结果
- 第 3 轮模型能看到前面所有中间产物
- 当前决策永远是基于“到目前为止的完整上下文”做的
所以 ReAct 更像一个 Runtime,而不是一个 prompt 小技巧。
3.3 用伪代码看,ReAct 的结构其实很朴素
把表面花哨都去掉,ReAct 的骨架其实非常清楚:
messages = [system, user]
while true:
response = model(messages, tools)
messages.append(response)
if response is final_text:
return response
if response contains tool_call:
tool_result = execute_tool(response)
messages.append(tool_result)
continue
关键不在这段伪代码有多复杂,而在它会立刻引出四个工程级问题:
- 消息历史怎么保存
- 工具怎么注册、怎么描述、怎么路由
- 循环什么时候停
- 不同会话怎么隔离
一旦把 ReAct 当成工程对象看,它后面连着的就不再只是 prompt,而是完整运行时设计。
3.4 为什么 ReAct 是 Agent Runtime 的最小完备形态
ReAct 是很多现代 Agent 的最小完备形态。
原因是它第一次同时满足了四件事:
- 模型可以做运行时决策
- 决策可以触发外部动作
- 动作结果可以回流到上下文
- 系统可以在“继续行动”与“停止回答”之间切换
有了这四件事,你才真正有了“围绕目标推进”的基本能力。
没有它,系统顶多算增强版聊天。
3.5 ReAct 的边界也必须说透
但也不能把 ReAct 写成银弹。
它很强,但它也有非常真实的边界。
| ReAct 的优点 | ReAct 的代价 |
|---|---|
| 非常适合多轮工具决策 | 可预测性下降 |
| 中间结果可回流,能继续思考 | 调试难度明显上升 |
| 天然支持“先查、再判断、再补查” | Token 消耗更高 |
| 很适合作为 Agent Runtime 起点 | 一旦工具描述和 prompt 写差,行为会飘 |
最重要的一点是:
ReAct 并不擅长提前做全局规划。
它更像一个“边走边看”的执行闭环,而不是一个强计划系统。
所以当任务变得更长、更开放、更依赖环境操作时,ReAct 往往还不够。这就引出了下一步:CodeAct。
四、把 CodeAct 这个坐标立在这里,再看 ReAct 的边界
CodeAct 有个常见的误解,以为它只是”会写代码的 Agent”。
实际场景更能说明问题。Claude Code 在修一个 Bug 时,做的事情是:查文件 → 读代码 → 写修改 → 运行测试 → 看失败原因 → 再改。整个过程系统自己决定下一步,没有提前定义好的 runTests 工具,也没有外部指令在旁边指定”现在运行 grep”。
这就是 CodeAct。
它的核心不是「会写代码」,而是「动作空间从几个预定义工具变成了一个可编程环境」。
| 维度 | ReAct(当前 ticket-agent) | CodeAct(如 Claude Code) |
|---|---|---|
| 模型的动作选项 | searchFlights / compareFlights / searchKnowledge(你定义的) | shell 命令 / 文件读写 / 代码执行(环境提供的) |
| 动作空间 | 几个固定接口 | 接近任意可执行操作 |
| 任务边界 | 提前划定 | 运行时动态扩展 |
| 失控风险 | 较低(工具受控) | 较高(环境副作用难预测) |
ReAct 像:给系统几把固定的钥匙,由系统决定什么时候用哪把。
CodeAct 像:给系统一个工具箱加一个沙盒,系统自己决定造什么。
这个坐标提前插进来,不是因为当前章节要实现 CodeAct。ticket-agent 现在是标准的工具受控型单 Agent,三个工具,动作空间清晰,正好适合把 ReAct Runtime 讲透。
工程判断:先把 ReAct + 受控工具做稳,再评估是否需要 CodeAct 形态。很多业务需求在前者阶段就已经够用了,盲目引入开放环境只会放大不确定性。
提前插入 CodeAct,是为了在学 ReAct 的同时,建立一个更大的坐标系——Agent 的动作空间还可以继续开放。
五、四种模式的边界对比:ChatClient / Workflow / ReAct / CodeAct
把这一章的几种系统再压缩成一句判断:
| 形态 | 最关键的问题 |
|---|---|
ChatClient | 我能不能把这一轮话答好 |
Workflow | 我能不能把固定流程跑稳 |
ReAct Agent | 我能不能根据中间结果继续决定下一步 |
CodeAct Agent | 我能不能在开放环境里构造通往目标的动作链 |
这四句话很适合作为选型判断。
一旦你把问题问对了,框架选型反而不难。
六、最终落到 ReactAgent——因为它正好是 ReAct Runtime 在当前框架里的承载体
前面把模式讲清楚后,现在再看 ReactAgent,就不会只把它当一个 API 名字了。
它在这一章里的价值,不是“Spring AI Alibaba 提供了一个现成类”,而是:
它把 ReAct 运行时里最容易出错、最容易散落的那一堆工程细节,集中托管起来了。
6.1 如果你自己手写 while,真正难的从来不是 while 本身
ReAct 很容易给人一种错觉:
“这不就是一个循环吗?我自己写也行。”
理论上当然行。
自己手搓时真正会把人拖死的,不是循环,而是循环周围那一圈运行时问题:
| 需要自己扛的东西 | 为什么麻烦 |
|---|---|
| 工具描述注入 | 要把 Java 函数变成模型能理解的工具协议 |
| 工具参数反序列化 | 模型输出 JSON,Java 里要正确还原类型 |
| 工具路由 | 要按 name 找到对应实现 |
| 消息历史累积 | 要决定每轮追加什么、存什么 |
| 停止条件 | 要防止死循环 |
| 会话隔离 | 不同用户不能串历史 |
| 最终答案提取 | 要区分 tool call 与 final text |
所以你不是在写一个 while。
你是在手搓一个 Agent Runtime。
6.2 ReactAgent 真正替你托管了什么
看当前工程里启动 ticket-agent 只需要这一段配置:
return ReactAgent.builder()
.name("ticket_agent")
.model(chatModel)
.tools(searchFlightsTool, compareFlightsTool, searchKnowledgeTool)
.saver(new MemorySaver())
.systemPrompt("""
你是机票客服型智能体「票小蜜」。
你的目标不是只回答一句话,而是围绕用户目标推进下一步动作。
如果查询航班所需信息不完整,先追问缺失字段,再调用工具。
遇到退改签、行李、儿童票等政策问题,必须调用 searchKnowledge。
信息已经足够时,直接给出推荐结论和理由。
""")
.build();
五行配置背后,框架帮你接管的:
| 你写的 | 框架帮你做的 |
|---|---|
.tools(...) | 把 Java 函数序列化成 LLM 能理解的工具协议,管路由和参数反序列化 |
.saver(new MemorySaver()) | 跨请求持久化消息历史,按 threadId 隔离不同用户的会话 |
.systemPrompt(...) | 每轮推理都带进去,约束 Agent 的决策边界 |
| (隐含)ReAct 循环 | 推理→调工具→结果回流→再推理,直到给出最终答案 |
| (隐含)停止判断 | 防死循环,识别何时退出循环 |
所以它不是”让模型更聪明”,而是让运行时有了骨架。
七、进去看 ReactAgent 源码——ReAct 在这里是怎么跑起来的
按照前面伪代码的逻辑,实现应该是一个 while 循环——判断有没有工具调用,有就执行,没有就返回。
但打开 ReactAgent.java,看到的是完全不同的东西。先把整体结构放出来:
ReactAgent
├── StateGraph ← 图结构运行时(不是 while 循环)
│ ├── AgentLlmNode ← LLM 推理节点
│ └── AgentToolNode ← 工具执行节点
├── OverAllState ← 状态容器(Map<String, Object>,不是 List<Message>)
└── MemorySaver ← 跨请求持久化(按 threadId 隔离会话)
四个类,分工清晰:
StateGraph是骨架,定义了节点和边的拓扑结构AgentLlmNode是推理节点,每次进来就调一次模型AgentToolNode是执行节点,把工具调用结果写回状态OverAllState是共享黑板,节点之间靠它传数据MemorySaver负责把黑板的快照存下来,供下次请求恢复
这个结构跟 Spring 里的 Filter Chain 有几分像——不是业务逻辑直接调业务逻辑,而是框架在中间编排流转。
7.1 initGraph():两个节点、两条边——ReAct 循环就藏在这里
进到 ReactAgent.initGraph() 看,核心就是搭图:
第 1 步:注册两个节点
// ReactAgent.initGraph()(源码简化)
StateGraph graph = new StateGraph(name, ...);
graph.addNode(AGENT_MODEL_NAME, node_async(this.llmNode)); // LLM 推理节点
graph.addNode(AGENT_TOOL_NAME, node_async(this.toolNode)); // 工具执行节点
这两个节点对应 ReAct 的两个阶段:Reason(LLM 推理)和 Act(工具执行)。
第 2 步:注册边——其中一条是”循环回边”
// LLM 节点出来 → 条件判断:有工具调用去工具节点,没有就结束
graph.addConditionalEdges(loopExitNode,
edge_async(agentInstance.makeModelToTools(...)),
Map.of(AGENT_TOOL_NAME, AGENT_TOOL_NAME,
exitNode, exitNode));
// 工具节点出来 → 路由回 LLM 节点(这条回边就是”循环”)
graph.addConditionalEdges(AGENT_TOOL_NAME,
edge_async(agentInstance.makeToolsToModelEdge(...)),
Map.of(loopEntryNode, loopEntryNode,
exitNode, exitNode));
路由函数 makeModelToTools() 内部只做一件事:
// makeModelToTools() 路由逻辑(源码简化)
if (assistantMessage.hasToolCalls()) {
return AGENT_TOOL_NAME; // 模型想调工具 → 继续执行
} else {
return endDestination; // 没有工具调用 → 循环结束,输出答案
}
这就是 ReAct 循环的完整实现:工具节点 → 回边 → LLM 节点,加上 LLM 节点出来的一个路由判断。
为什么选图而不是 while?图结构让每个节点可以独立替换,每条边的路由逻辑可以单独修改。更重要的是:图本身的状态是可观察、可审计的——这是下一章能讲可观测性的底层基础。
7.2 AgentLlmNode.apply():每一轮 LLM 推理发生了什么
先看清楚 apply() 是怎么被触发的。
AgentLlmNode 实现了 NodeActionWithConfig 接口,apply() 就是这个接口定义的回调方法。在 initGraph() 里注册节点时:
graph.addNode(AGENT_MODEL_NAME, node_async(this.llmNode));
// node_async() 把 NodeActionWithConfig.apply() 包成 CompletableFuture,交给图引擎异步调度
StateGraph 执行引擎流转到 AGENT_MODEL_NAME 节点时,就会触发这个回调——不是 ReactAgent 直接调 apply(),而是图引擎在节点入口处通过接口回调驱动。机制上类似 Spring MVC 的 HandlerAdapter:框架持有接口引用,在合适的时机调它。
所以每次图流转到 LLM 节点,AgentLlmNode.apply() 就被调一次。进去看这个方法做了什么:
第 1 步:从 OverAllState 里取出历史消息
// AgentLlmNode.apply()(源码简化)
public Map<String, Object> apply(OverAllState state) {
List<Message> messages = state.value(“messages”, List.class);
// ↑ 第一轮有 system + user,后续轮次历史已经累积在这里
第 2 步:把所有工具的名字和描述提取出来,重新构建给模型看
for (ToolCallback callback : toolCallbacks) {
toolNames.add(callback.getToolDefinition().name());
toolDescriptions.put(
callback.getToolDefinition().name(),
callback.getToolDefinition().description() // ← 你在 AgentConfig 里写的那段描述
);
}
requestBuilder.tools(toolNames);
requestBuilder.toolDescriptions(toolDescriptions);
第 3 步:带着历史消息 + 工具描述,调用模型,把结果写回 OverAllState
ChatResponse response = chatModel.call(requestBuilder.build());
return Map.of(“messages”, List.of(response.getResult().getOutput()));
// OverAllState 的 AppendStrategy 会把这条消息追加到历史,而不是覆盖
}
关键在第 2 步:每一轮推理,工具描述都会被重新构建一次。模型每次决定”要不要调工具、调哪个”,依据都是这段描述。
所以当 Agent 出现”工具调用时机不对”的问题,第一个要查的就是 .description()——不是模型的问题,是描述没给模型足够的信息。
💡 工程建议:
description里要写清楚”这个工具适合什么场景”和”不适合什么场景”。当前工程里searchKnowledge的描述写了”不适用于查实时航班价格”,就是为了防止模型在价格查询时走错工具。
7.3 OverAllState + MemorySaver:状态是图的快照,不是消息列表
普通的 Memory 实现通常是维护一个 List<Message>,每次请求前追加历史,每次请求后追加回复。
ReactAgent 的状态容器 OverAllState 不一样——它是一个 Map<String, Object>,”messages” 只是其中一个 key,而且它的更新策略是 AppendStrategy(追加),不是覆盖。
// OverAllState 的 messages 更新策略(概念示意)
// 节点写入新消息时:
// 不是 state[“messages”] = newMessages
// 而是 state[“messages”].addAll(newMessages)
// ← 消息历史自动累积,不需要手动维护
MemorySaver 在这之上再加一层:跨请求持久化。每轮执行结束,MemorySaver 把 OverAllState 的快照存下来,key 是 threadId。下次同一个 threadId 进来,图会从快照恢复,接着上次状态继续跑。
所以会话续接的完整链路是:
HTTP 请求 → Controller 取 sessionId
→ RunnableConfig.builder().threadId(sessionId).build()
→ MemorySaver 按 threadId 恢复上次 OverAllState
→ 图从上次快照继续执行,历史消息已经在状态里
这就是为什么在 AgentConfig 里只需要 .saver(new MemorySaver()),不需要手动管 history.add()——框架在状态层面把追加和恢复都帮你处理了。
7.4 调用入口:从 AgentController.call() 到图开始执行
最后看一下整条调用链,从 Controller 出发:
// AgentController(真实代码)
RunnableConfig config = RunnableConfig.builder()
.threadId(sessionId)
.build();
reactAgent.call(q, config);
call() 往下走:
ReactAgent.call(input, config)
└── doMessageInvoke(userMessage, config)
└── doInvoke(state, config)
└── StateGraph.compile().invoke(state, config)
└── 图开始运行:AgentLlmNode → (条件边) → AgentToolNode → (回边) → AgentLlmNode ...
在 doInvoke() 里,框架还注册了 Hook 点:BEFORE_AGENT、AFTER_AGENT、BEFORE_MODEL、AFTER_MODEL。这四个 Hook 是下一章能做可观测性的挂载入口——日志、监控、链路追踪,都可以在这里接入,不需要改节点内部的代码。
💡 工程建议:当前 demo 里的工具底层是 mock 数据,但框架 Runtime 这一层已经是完整的多步推理 + 工具编排 + 会话续接。要升级到生产,只需要替换工具实现,Runtime 不需要动。
八、原理落回工程——一条请求的完整生命周期
到这里,我们可以把一次真实请求的生命周期压成这样:
1. Controller 接收 q 和 sessionId
2. sessionId 被包装成 RunnableConfig.threadId
3. ReactAgent 根据 threadId 加载历史消息
4. 模型拿到完整消息历史 + 工具描述,决定下一步
5. 如果输出 tool_call,框架执行对应工具
6. 工具结果以消息形式追加回上下文
7. 模型继续推理,直到输出最终文本
8. 历史保存回 MemorySaver,供下一轮续接
把这条链看懂了,最核心的东西其实就已经拿到了。
因为这一章真正建立起来的,不是某个类的记忆,而是一个稳定的运行时心智模型:
系统已经从“答一道题”,变成“围绕目标持续推进下一步动作”。
实战篇
九、动手把当前 ReactAgent 最小闭环跑起来
9.1 启动项目
mvn -pl ticket-agent spring-boot:run
当前工程端口配置在:
server:
port: 8089
接口入口:
GET /api/v9/agent/chat?q=...&sessionId=...
如果你重点想验证 searchKnowledge 的效果,先确认知识库样本已经完成入库;否则政策类问题即使触发了检索,也可能只得到兜底回复。
9.2 场景一:验证多步目标是否会触发连续决策
curl "http://localhost:8089/api/v9/agent/chat?q=帮我找一张明天北京到上海性价比高、退改签别太苛刻的机票&sessionId=s1"
理想现象不是只回一句泛泛建议,而是:
- 先查航班
- 再查退改签相关知识
- 最后给出推荐与理由
如果你只看到一次工具调用,常见原因通常是:
- system prompt 没把“遇到政策约束必须补查”说清楚
- 工具 description 太短,模型不容易在正确时机调它
- 当前工具返回信息已经让模型误判为“足够回答”
9.3 场景二:验证缺字段时是否先追问
curl "http://localhost:8089/api/v9/agent/chat?q=帮我找一张明天去上海的机票&sessionId=s2"
更合理的行为应该是:
- 先追问“您从哪个城市出发?”
- 而不是默认从北京出发
- 更不是直接编一个航班推荐
这一步非常能看出 Agent 和普通问答的差别。
追问不是失败,而是在推进任务。
9.4 场景三:验证同一 sessionId 的上下文续接
# 第一次
curl "http://localhost:8089/api/v9/agent/chat?q=帮我找一张明天北京到上海性价比高的机票&sessionId=s3"
# 第二次
curl "http://localhost:8089/api/v9/agent/chat?q=把你刚才推荐的两个航班对比一下&sessionId=s3"
第二次请求之所以成立,不是因为控制器层做了什么特殊魔法,而是因为:
- 第一轮历史已经被
MemorySaver保存 - 第二轮通过同一个
threadId把历史重新接回 - 模型得以继续基于前文判断是否调用
compareFlights
9.5 场景四:验证会话隔离是否生效
curl "http://localhost:8089/api/v9/agent/chat?q=把你刚才推荐的两个航班对比一下&sessionId=s99"
如果 s99 没有任何历史,合理结果应该是:
- 系统追问“您要对比哪几个航班?”
- 而不是引用别的会话结果
这说明 threadId 的隔离在生效。
十、真正该带走的,不是类名,是运行时心智
这一章最该带走的不是哪个类名,而是下面这几个判断:
- Agent 不是一个 API,而是一组运行时设计模式
- ReAct 是当前很多 Agent Runtime 的第一块地基
- CodeAct 代表了更开放的环境交互方向,但代价也更高
ReactAgent的价值,不是让模型更聪明,而是把 ReAct Runtime 托管起来threadId、MemorySaver、tool description、system prompt都不是细枝末节,它们共同决定 Agent 行为边界
把这些话压成一句:
这一章不是在学“怎么调一个 Agent 类”,而是在第一次学会用 Runtime 的眼光看待智能体。
这才是从 ChatClient 走到 ReactAgent 的真正意义。
十一、下一章应该接什么
最小闭环跑起来以后,真正有挑战的问题才开始出现:
- 为什么它这次多调了一次工具,上次没有
- 工具调用前后怎么做日志、打点、审计
- 运行时状态能不能结构化,而不是全靠自然语言消息
- 停止条件能不能更可控,而不是完全交给模型
这些问题,都会把我们带向下一章:
从“能跑的 Agent”进入“可观测、可控制、可调试的 Agent Runtime”。
这也是课程从概念进入工程的下一步。
代码入口
ticket-agent/src/main/java/com/ai/course/ticketagent/config/AgentConfig.java
ticket-agent/src/main/java/com/ai/course/ticketagent/controller/AgentController.java
ticket-agent/src/main/java/com/ai/course/ticketagent/tool/FlightTools.java
ticket-agent/src/main/java/com/ai/course/ticketagent/tool/KnowledgeTools.java
ticket-agent/src/main/resources/application.yml