为什么你的 AI 助手只会回一句话——用 Spring AI Alibaba 实现真正的多步推理 Agent

34 阅读22分钟

从 ChatClient 到 Agent Runtime:Spring AI Alibaba ReAct 实现原理全解析

大多数人在让 AI 完成多步任务时,最先碰到的不是模型能力问题,而是一个很具体的运行时问题:ChatClient 一次只能推进一步,看不到中间结果,也没有继续决策的入口。

加 Memory、加 Tool Calling 能勉强多做一些,但一旦任务需要"先查航班、再看政策、再给推荐"这种动态链路,就会暴露框架边界。

真正要补上的,不是多一个工具,而是引入一层能自主推进目标的运行时——也就是 Agent。

读完这一篇,你应该能把 Spring AI Alibaba 的 ReactAgent 最小闭环跑通,搞清楚 ReAct、CodeAct、Workflow 三种模式的边界在哪,并且能定位到 StateGraphAgentLlmNodeMemorySaver 的源码,知道 ReAct 在框架里到底是怎么跑起来的。

系列目标:从零构建一个机票客服型 Agent「票小蜜」 本篇目标:讲清 Agent 模式全景,重点吃透 ReAct 与 CodeAct,再落回 Spring AI Alibaba 的 ReactAgent 最小闭环 前置知识:已完成前文的 ChatClient、Tool Calling、Memory、RAG 基础


理论篇

一、ChatClient 开始吃力的地方——一类新问题的出现

做到这一章之前,前面的能力其实都已经很能打了。

我们有 ChatClient,有 Tool Calling,有 Memory,也有 RAG。单独看每一个点,都说得过去。查航班可以,查知识库也可以,做一个看起来像样的 AI 助手也不难。

真正到了「该进入 Agent 了」这个判断,不是因为看到了某个新名词,而是因为碰到了一类 ChatClient 明显开始吃力的问题。

比如这句话:

帮我找一张明天北京到上海性价比高、退改签别太苛刻的机票。

这句话表面上像问答,实际上不是。

它不是在问一个明确事实,也不是让系统执行一个固定动作。它真正要求系统做的,是一条运行时决策链:

  1. 先查有哪些候选航班
  2. 看完候选结果,再决定要不要补查退改签政策
  3. 如果用户条件不全,先追问,而不是瞎猜
  4. 信息够了,再给出推荐和理由

问题就出在这里。

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 的真正起点。它不是最先进的模式,但它第一次把下面这个能力变成了稳定结构:

模型不再只是回答,而是在运行时交替做两件事:思考下一步、执行下一步。

没有这一层,你后面谈:

  • 多轮工具调用
  • 运行时状态推进
  • 记忆续接
  • 中间结果驱动决策

都会飘在空中。

主轴是:

  1. 先把 Agent 模式全景立起来
  2. 再把 ReAct 作为第一性原理讲透
  3. 然后把 CodeAct 作为“动作空间升级”的方向讲清楚
  4. 最后回到 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

关键不在这段伪代码有多复杂,而在它会立刻引出四个工程级问题:

  1. 消息历史怎么保存
  2. 工具怎么注册、怎么描述、怎么路由
  3. 循环什么时候停
  4. 不同会话怎么隔离

一旦把 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-agentCodeAct(如 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 循环)
  │   ├── AgentLlmNodeLLM 推理节点
  │   └── 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 在这之上再加一层:跨请求持久化。每轮执行结束,MemorySaverOverAllState 的快照存下来,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_AGENTAFTER_AGENTBEFORE_MODELAFTER_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"

理想现象不是只回一句泛泛建议,而是:

  1. 先查航班
  2. 再查退改签相关知识
  3. 最后给出推荐与理由

如果你只看到一次工具调用,常见原因通常是:

  • 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 的隔离在生效。


十、真正该带走的,不是类名,是运行时心智

这一章最该带走的不是哪个类名,而是下面这几个判断:

  1. Agent 不是一个 API,而是一组运行时设计模式
  2. ReAct 是当前很多 Agent Runtime 的第一块地基
  3. CodeAct 代表了更开放的环境交互方向,但代价也更高
  4. ReactAgent 的价值,不是让模型更聪明,而是把 ReAct Runtime 托管起来
  5. threadIdMemorySavertool descriptionsystem 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