Spring AI 2 实战:订票聊天 Demo 的架构、ReAct 与 ToolCallingAdvisor 链

用户说「我要订票」,大模型不应凭空编造结果——它应调用 Java 方法改数据库,前端列表随之刷新。本文以 springai_demo 为样本,对照 Spring AI 2.0 源码,说明这套 ReAct 全栈 Demo 怎么搭、亮点在哪,以及 2.0 相对 1.x 到底改了什么。

引言:从聊天框到数据库的一次订票

Spring AI 1.x 时代,很多入门示例把 Tool Calling 写进 ChatModel.call() 的黑盒里:业务代码一行 .call(),中间几轮「模型返回 tool_calls → 执行 Java 方法 → 把结果塞回 Prompt → 再调模型」在模型实现内部悄悄完成,控制台往往只能看到首尾两次 HTTP 请求。

Spring AI 2.0 把这条循环抬进 Advisor 链ToolCallingAdvisor@since 2.0.0 引入,注释写得很直白:「Recursive Advisor that disables the internal tool execution flow and instead implements the tool calling loop as part of the advisor chain.」1 与此同时,各 Provider 的 ChatModel 不再自带内部工具执行循环2

springai_demo 正是为吃透这一变化而写的 Fullstack 学习项目:React 双栏订票列表 + 自然语言聊天,后端 Spring Boot 4.1 + Spring AI 2.0 + DeepSeek deepseek-chat,用 @Tool 把「订票 / 取消 / 查列表」暴露给大模型,ReAct 循环由框架驱动,业务侧仍只需一行 chatClient.prompt().user().call().content()

全文脉络:

  1. 项目架构 — 前后端分层、写入口收敛、一次聊天的时序
  2. 前端设计 — 容器/展示分离、API 封装、聊天后刷新列表
  3. 实现亮点 — 业务兜底、PromptLoggingAdvisor 调用链与 before/after 时机、E2E 与 Docker Compose 编排
  4. Spring AI 2 核心变动 — 1.x 三种写法回溯、Advisor 链源码走读(nextCallChatModel)、Upgrade Notes
  5. 变动在项目中的落地ChatConfigPromptLoggingAdvisor、日志如何读 ReAct

一、项目架构:经典三层 + AI 工具层

1.1 技术栈与职责划分

层级 技术 职责
前端 React 18、TypeScript、Vite 双栏列表 + 聊天 UI;/api 代理到 8080
后端 Spring Boot 4.1、Spring Data JPA、H2 REST API、订票业务、ChatClient 入口
AI Spring AI 2.0、spring-ai-starter-model-deepseek DeepSeek ChatModel、Tool Calling、Advisor 链
测试 Playwright E2E 列表、聊天订票、取消、健康检查

后端 Maven 坐标见 backend/pom.xmlspring-boot-starter-parent 4.1.0spring-ai-bom 2.0.0。Spring AI 2.0 基于 Spring Boot 4 依赖模型,不能与 Boot 3.x 混用3

1.2 整体数据流

flowchart TB
  subgraph browser [浏览器 localhost:5173]
    App[App.tsx]
    List[BookingList 双栏]
    Chat[ChatPanel 聊天]
    App --> List
    App --> Chat
  end

  subgraph vite [Vite Dev Server]
    Proxy["/api → :8080"]
  end

  subgraph backend [Spring Boot localhost:8080]
    BC[BookingController]
    CC[ChatController]
    BS[BookingService]
    CS[ChatService]
    BT[BookingTools @Tool]
    BR[BookingRepository]
    CFG[ChatConfig → ChatClient]
    DB[(H2 bookings)]
    BC --> BS
    CC --> CS
    CS --> CFG
    CFG --> ChatModel[DeepSeekChatModel]
    CFG --> BT
    BT --> BS
    BS --> BR
    BR --> DB
  end

  subgraph external [外部]
    DS[DeepSeek API]
  end

  App -->|fetch /api| Proxy
  Proxy --> BC
  Proxy --> CC
  ChatModel --> DS

设计原则:所有写数据库的操作只经过 BookingService。REST 的 BookingController 与 AI 的 BookingTools 都调它——避免「聊天改了一套、列表查另一套」的分叉。

后端包结构可概括为:

层次 能否直接碰 DB
表现层 controller/
业务层 service/ 通过 Repository
AI 工具层 tools/ 否,只调 Service
配置层 config/advisor/
数据层 repository/model/

1.3 领域模型与状态机

实体 Booking 只有 idtitlestatusSUBSCRIBED / UNSUBSCRIBED)。启动时 data.sql 插入 3 条未订阅票;H2 内存库,进程退出即清空。

前端 App.tsx 在挂载时并行拉两栏列表;聊天成功后必须再次 loadBookings()——因为数据库已被 @Tool 修改,React state 不会自动同步。

1.4 一次「我要订票」的时序

sequenceDiagram
  participant User as 用户
  participant FE as React App
  participant CC as ChatController
  participant CS as ChatService
  participant Client as ChatClient
  participant Adv as ToolCallingAdvisor
  participant Log as PromptLoggingAdvisor
  participant DS as DeepSeek
  participant Tools as BookingTools
  participant Svc as BookingService
  participant DB as H2

  User->>FE: 输入「我要订票」
  FE->>CC: POST /api/chat
  CC->>CS: chat(message)
  CS->>Client: prompt().user().call()
  Client->>Adv: adviseCall(TCA 远离 ChatModel,驱动 ReAct)
  Note over Adv,Log: 第 1 轮 copy(this).nextCall()
  Adv->>Log: before() 第1步
  Log->>DS: ChatModel HTTP
  DS-->>Log: tool_call subscribeTicket
  Log->>Adv: after() 第1步
  Adv->>Tools: executeToolCalls subscribeTicket
  Tools->>Svc: subscribeTicket(null)
  Svc->>DB: UPDATE status=SUBSCRIBED
  Note over Adv,Log: 第 2 轮 copy(this).nextCall()
  Adv->>Log: before() 第2步 含 TOOL_RESPONSE
  Log->>DS: ChatModel HTTP
  DS-->>Log: 最终中文 reply
  Log->>Adv: after() 第2步
  Adv-->>CS: content()
  CS-->>FE: JSON reply
  FE->>FE: GET /api/bookings 刷新双栏

用户只说「我要订票」、未指定票名时,BookingService.subscribeTicket(null) 会订第一张未订阅票(按 id 排序)——这是刻意为 AI 传参不准准备的兜底,降低 Tool 调用成功但业务失败的概率。

1.5 前端设计:刻意保持「薄 UI、厚后端」

前端没有引入 Redux、React Router 或 UI 库,目的是把学习成本压在 Spring AI 与 ReAct 上。结构遵循经典的容器组件 / 展示组件分离:

文件 角色 是否调 API
App.tsx 页面总控:state、拉列表、处理聊天回调
BookingList.tsx 双栏列表纯展示
ChatPanel.tsx 聊天 UI;发送逻辑由父组件 onSend 注入
api/client.ts 通用 getJson / postJson
api/bookingApi.ts GET /api/bookings?status=...
api/chatApi.ts POST /api/chat

状态设计App.tsxuseState 维护两栏列表(subscribed / unsubscribed)、聊天历史(messages)、加载与错误态。挂载时 Promise.all 并行请求两个 status,减少首屏等待。

与 AI 路径的关键衔接:聊天成功后必须再次 loadBookings()。ReAct 改的是 H2 数据库,React state 不会自动感知 @Tool 的副作用——handleSendMessage 在收到 ChatResponse 后显式刷新双栏,E2E 用例也正是靠「左栏 +1」来验证 Tool 真正执行。

跨域策略:开发态所有请求走相对路径 /api/...,由 Vite dev server 代理到 Spring Boot,浏览器始终认为前后端同源。

// vite.config.ts: 11-19
server: {
  proxy: {
    '/api': {
      target: apiProxyTarget,  // 本地默认 http://localhost:8080
      changeOrigin: true,
    },
  },
},

本地 pnpm devapiProxyTarget 默认为 http://localhost:8080;Docker Compose 里通过环境变量 VITE_API_PROXY_TARGET=http://backend:8080 把代理目标改成 Compose 网络内的服务名。后端 WebConfig 另配了 CORS,允许 http://localhost:5173 直连 8080——便于 curl、Playwright 或不走代理的调试;日常开发仍以 Vite proxy 为主4

pnpm workspace:根目录 package.json + pnpm-workspace.yaml 统一管理 frontende2e,根脚本 pnpm dev 等价于 pnpm --dir frontend dev


二、实现亮点

2.1 单一写入口 + 面向 AI 的业务规则

BookingTools@Tool 暴露四个方法:查未订阅、查已订阅、订票、取消。每个方法只打日志并委托 BookingService,不碰 Repository。

取消逻辑比「模糊匹配 title」更进一步:BookingService.cancelSubscription 支持航班号(G123)、「北京到上海」与「北京-上海 G123」的归一化、用户只说「取消订票」且仅一张已订阅票时的兜底。这些规则对 REST 与 AI 共用,大模型只需选对工具,不必背 SQL。

System Prompt 在 ChatConfig 里用 defaultSystem 约束:禁止未调工具就声称成功、订票/取消/查列表必须走对应 @Tool。配合 temperature: 0.1application.yml),让模型更稳定地触发 function calling。

2.2 PromptLoggingAdvisor:把 ReAct 从黑盒里拽出来

Spring AI 2.0 的关键收益是:order 大于 ToolCallingAdvisor(+300)的 Advisor,会挂在每一轮 copy(this).nextCall() 子链上、更靠近 ChatModelPromptLoggingAdvisorgetOrder() 返回 HIGHEST_PRECEDENCE + 400,比 +300 更接近 ChatModelCallAdvisor,因此在每一轮「发 Prompt → 收 Response → 执行 Tool → 再发 Prompt」时都能逐步观测。更细的调用链、before/after 时机与日志解读见项目 docs/PROMPT_LOGGING_ADVISOR.md

2.2.1 核心结论

问题 答案
谁调用 before / after Spring AI Advisor 链,不是业务代码
业务代码直接碰这个类做什么? PromptLoggingAdvisor.resetSteps() 重置 [AI 第N步] 计数
before 何时触发? 每一轮调用 DeepSeek 之前
after 何时触发? DeepSeek 刚返回之后执行 @Tool Java 方法之前
[Tool 被调用] 何时出现? after 之后,由 ToolCallingAdvisorToolCallingManager 触发
为何要 order = +400 必须 大于 ToolCallingAdvisor(+300),才能进入每一轮 ReAct 的 copy().nextCall() 子链(更靠近 ChatModel

一句话before / after 包的是 单次模型 HTTP 往返,不包含工具 Java 执行本身。

2.2.2 注册与入口

Advisor order 如何注册
ToolCallingAdvisor HIGHEST_PRECEDENCE + 300 defaultTools(bookingTools)Spring AI 2.0 自动注册
PromptLoggingAdvisor HIGHEST_PRECEDENCE + 400 ChatConfig 显式 .defaultAdvisors(new PromptLoggingAdvisor())
// ChatConfig.java: 61-62
.defaultTools(bookingTools)   // 工具 schema 写入 options.toolCallbacks,非 SYSTEM 文本
.defaultAdvisors(new PromptLoggingAdvisor())  // INFO=messages,DEBUG=tools

注意:不要再手动 .advisors(ToolCallingAdvisor.builder()...),否则会与自动注册的实例 重复,链中出现两个工具 Advisor。

ChatService.chat 在每次用户聊天前调用 PromptLoggingAdvisor.resetSteps(),保证一次 HTTP 请求从 [AI 第1步] 重新计数;业务代码 从不 直接调用 before() / after()

// ChatService.java: 60-67
PromptLoggingAdvisor.resetSteps();
String reply = chatClient
        .prompt()
        .user(userMessage.trim())
        .call()
        .content();

2.2.3 Spring AI 如何调用 before / after

PromptLoggingAdvisor 实现 BaseAdvisor。框架对 BaseAdvisor 的默认 adviseCall 模板是:

// BaseAdvisor.java: 47-53
default ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
    ChatClientRequest processedChatClientRequest = before(chatClientRequest, callAdvisorChain);
    ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(processedChatClientRequest);
    return after(chatClientResponse, callAdvisorChain);
}

调用链:

ChatService.chat()
    └─ chatClient.prompt().user(...).call()
        └─ DefaultChatClient.doGetObservableChatClientResponse()
            └─ DefaultAroundAdvisorChain.nextCall()     【链调度器】
                └─ advisor.adviseCall(...)              【按 order 逐个 Advisor】
                    └─ PromptLoggingAdvisor
                        ├─ before()   ← 日志
                        ├─ nextCall() → 更靠近 ChatModel
                        └─ after()    ← 日志

DefaultAroundAdvisorChain.nextCall 从 deque 弹出 order 最小最远离 ChatModel)的 Advisor,执行其 adviseCall;该 Advisor 若继续 nextCall(),则向 更靠近 ChatModel 的方向递进。完整链路见 §3.2.1

2.2.4 order 与距 ChatModel 远近:为何必须是 +400

速记:在 Advisor 链上,order 数值越大 → 越靠近 ChatModelorder 越小 → 越远离 ChatModel(到达模型前要经过更多 Advisor)。链终点是 ChatModelCallAdvisorOrdered.LOWEST_PRECEDENCE,order 最大)。

Advisor order 距 ChatModel
ToolCallingAdvisor +300 较远:驱动 ReAct do-while,自身不直接调模型
PromptLoggingAdvisor +400 更近:每轮 copy().nextCall() 子链上,紧贴 ChatModelCallAdvisor 之前
ChatModelCallAdvisor LOWEST_PRECEDENCE 最近:链终点,chatModel.call()

PromptLoggingAdvisor 的 order ≤ +300(与 ToolCallingAdvisor 同级或更远离 ChatModel),它不会进入每轮 copy(this).nextCall() 子链,往往 只能看到 ReAct 首尾,看不到中间 [AI 第1步] tool_calls[AI 第2步] TOOL_RESPONSE 的完整往返。

2.2.5 ReAct 每轮:before / after 的精确时机

ToolCallingAdvisor 不使用 BaseAdvisor 的默认 adviseCall,而是自定义 do { ... } while (isToolCall) 循环。每一轮核心逻辑(简化):

// ToolCallingAdvisor.java(简化)
do {
    processedChatClientRequest = doBeforeCall(...);
    chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);
    // ↑ 子链(更靠近 ChatModel):PromptLoggingAdvisor.before → ChatModel → PromptLoggingAdvisor.after
    chatClientResponse = doAfterCall(...);

    isToolCall = toolExecutionEligibilityChecker.isToolCallResponse(chatResponse);
    if (isToolCall) {
        toolExecutionResult = toolCallingManager.executeToolCalls(...);  // ← BookingTools 在这里
        instructions = doGetNextInstructionsForToolCall(...);
    }
} while (isToolCall);

单轮时序:

ToolCallingAdvisor 第 N 轮
    │
    ├─ PromptLoggingAdvisor.before()          【打 [AI 第N步] 发送 Prompt】
    ├─ ChatModel → DeepSeek HTTP              【真正模型请求】
    ├─ PromptLoggingAdvisor.after()           【打 [AI 第N步] 收到 Response】
    │       (可能是 tool_calls,也可能是最终中文)
    └─ 回到 ToolCallingAdvisor
            ├─ 若 hasToolCalls → executeToolCalls()
            │       └─ MethodToolCallback → BookingTools  【打 [Tool 被调用]】
            └─ 更新 instructions,进入第 N+1 轮

易错点[Tool 被调用] 不在 beforeafter 之间,而在 after 返回之后

以「我要订票」为例的完整日志顺序:

顺序 日志 / 事件 发生环节
1 [Chat] 收到用户消息: 我要订票 ChatService
2 [AI 第1步] 发送 Prompt — SYSTEM + USER before() 第 1 轮
3 DEBUG:注册的工具 … subscribeTicket 等 before() 第 1 轮
4 [AI 第1步] 收到 Response — ASSISTANT (tool_calls) after() 第 1 轮
5 [Tool 被调用] subscribeTicket ToolCallingAdvisor 执行工具
6 Hibernate UPDATE bookings BookingService
7 [AI 第2步] 发送 Prompt — 含 TOOL_RESPONSE before() 第 2 轮
8 [AI 第2步] 收到 Response — 最终中文 after() 第 2 轮
9 [Chat] AI 回复: ... ChatService 拿到 content()

一次用户 POST /api/chat 对应 一次 chatClient.call(),但 Advisor 链内可能有 多轮 DeepSeek 请求。

sequenceDiagram
  autonumber
  participant CS as ChatService
  participant TCA as ToolCallingAdvisor
  participant PLA as PromptLoggingAdvisor
  participant DS as DeepSeek
  participant BT as BookingTools

  CS->>CS: resetSteps()
  CS->>TCA: chatClient.call()

  Note over TCA,DS: 第 1 轮
  TCA->>PLA: copy(this).nextCall()
  PLA->>PLA: before() 第1步
  PLA->>DS: ChatModel HTTP
  DS-->>PLA: tool_calls subscribeTicket
  PLA->>PLA: after() 第1步
  PLA-->>TCA: ChatClientResponse

  TCA->>BT: executeToolCalls()
  BT-->>TCA: BookingResponse

  Note over TCA,DS: 第 2 轮
  TCA->>PLA: copy(this).nextCall()
  PLA->>PLA: before() 第2步 含 TOOL_RESPONSE
  PLA->>DS: ChatModel HTTP
  DS-->>PLA: 最终中文
  PLA->>PLA: after() 第2步
  PLA-->>TCA: ChatClientResponse

  TCA-->>CS: content()

2.2.6 before()after() 各打印什么?

before() — 发送前:

日志级别 内容 对应 DeepSeek 请求
INFO messages:SYSTEM / USER / ASSISTANT / TOOL_RESPONSE 请求体 messages[]
DEBUG ToolDefinition:name / description / inputSchema 请求体 tools[]不在 messages 里

after() — 模型返回后:

模型输出 日志表现
要调工具 ASSISTANT (tool_calls) + - subscribeTicket({})
最终回复 ASSISTANT: 已为您成功订票...
工具结果(作为下一轮输入) 出现在 下一轮 before()TOOL_RESPONSE,不在本轮 after()

after() 不修改 request/response,只观测并原样返回。还有一个容易踩坑的细节:@Tool 注册结果不在 INFO 日志的 messages 里,而是进入 ToolCallingChatOptions.toolCallbacks,由 DeepSeekChatModel 转成 HTTP 请求体的 tools 字段——仅看 INFO 会误以为「没注册工具」。

2.2.7 与 ToolCallingAdvisor 的分工

组件 职责 本 Demo 是否自定义
ToolCallingAdvisor ReAct do-while、检测 tool_calls、调用 executeToolCalls、拼下一轮 messages 否(框架自动)
PromptLoggingAdvisor 每轮模型往返前后打日志 是(本项目实现)
BookingTools 真正执行业务 @Tool

Spring AI 2.0 相对 1.x:ReAct 循环从 ChatModel 内部上移到 Advisor 链,因此 PromptLoggingAdvisor 才能挂在 更靠近 ChatModel 的子链上逐步观测;1.x 路径 A 下,远离 ChatModel 的 Advisor 往往只能看到首尾两次调用(§3.0)。

2.2.8 日志配置与验证关键字

环境 配置 能看到什么
本地默认 application.ymlcom.demo.booking: INFO 仅 INFO:messages + Response
Docker Compose LOGGING_LEVEL_COM_DEMO_BOOKING=DEBUG 额外 DEBUG:tools schema
临时本地 DEBUG logging.level.com.demo.booking.advisor.PromptLoggingAdvisor=DEBUG 同上

application.yml 里单独把 PromptLoggingAdvisor 锁为 INFO,否则会覆盖 Docker 的 DEBUG,看不到 inputSchema

关键字 含义
[AI 第N步] 发送 Prompt 第 N 轮 before()
[AI 第N步] 注册的工具 第 N 轮 before() DEBUG
[AI 第N步] 收到 Response 第 N 轮 after()
ASSISTANT (tool_calls) 模型决定调工具
TOOL_RESPONSE 下一轮 before() 中可见工具返回值
[Tool 被调用] Java @Tool 真正执行(在 after 之后)

2.2.9 常见误解

误解 事实
ChatService 调用了 before() 只调 resetSteps()before/after 由 Advisor 链触发
工具执行在 beforeafter 之间 工具在 after 之后,由 ToolCallingAdvisor 执行
工具定义在 INFO 的 SYSTEM 里 工具在 DEBUG 的 options → API tools 字段
一次 chat 只对应一步 [AI 第1步] 有 tool call 时通常 至少两步
order 越大越靠近 ChatModel +400 > +300PromptLoggingAdvisorToolCallingAdvisor 更靠近 ChatModelChatModelCallAdvisor 的 order 最大,是链终点

2.3 ChatConfig 刻意保持精简

ChatConfig 只做三件事:

// ChatConfig.java: 49-63
return ChatClient.builder(chatModel)
        .defaultSystem(""" ... """)
        .defaultTools(bookingTools)
        .defaultAdvisors(new PromptLoggingAdvisor())
        .build();

2.4 Docker Compose:开发态双容器编排

项目刻意不做前端静态资源 + Nginx 的生产打包镜像,而是 Compose 里跑 Vite dev server + Spring Boot JAR——与本地 mvn spring-boot:run + pnpm dev 行为一致,改代码 rebuild 即可,适合 Demo 与学习。

docker-compose.yml 定义两个 service:

flowchart LR
  subgraph host [宿主机]
    Browser[浏览器 :5173]
  end
  subgraph compose [Docker Compose 网络]
    FE[frontend 容器 Vite dev]
    BE[backend 容器 Spring Boot]
  end
  Browser -->|/api 经 Vite proxy| FE
  FE -->|VITE_API_PROXY_TARGET| BE
  BE -->|DEEPSEEK_API_KEY| DS[DeepSeek API]
设计点 实现
启动顺序 frontend.depends_on.backend.condition: service_healthy — 等 backend 通过 HEALTHCHECK 后再起 Vite,避免首屏 /api 502
Backend 镜像 backend/Dockerfile 多阶段:Maven 编译 → JRE 21 Alpine;内置 curl 供 HEALTHCHECK
存活探针 vs 完整检查 HEALTHCHECK 调 GET /api/health/liveHealthController 只返回 up调 DeepSeek);手动验收 Key 用 GET /api/health(会 ping 一次模型,勿拿来做高频探针)
Frontend 镜像 frontend/Dockerfile仓库根复制 pnpm-lock.yaml 与 workspace 配置,pnpm install --filter frontend...pnpm dev --host 0.0.0.0
密钥注入 DEEPSEEK_API_KEY 从宿主机环境或 .env 传入 backend 容器, bake 进镜像
调试日志 Compose 将 LOGGING_LEVEL_COM_DEMO_BOOKINGLOGGING_LEVEL_ORG_SPRINGFRAMEWORK_AI 设为 DEBUG,便于在容器日志里看到 PromptLoggingAdvisor 打印的工具 schema

一键启动:

export DEEPSEEK_API_KEY=your-key-here
docker compose up --build
# 前端 http://localhost:5173  后端 http://localhost:8080

2.5 工程化:E2E 与健康检查

更细的模块说明见项目 docs/ARCHITECTURE.md


三、Spring AI 2.0 核心变动(对照源码)

官方 Upgrade Notes 2.0 篇幅很长;对本 Demo 与 Tool Calling 场景,下面几条最关键。先回答一个常见误解:Spring AI 1.x 并非做不了订票 ReAct——@Tool + ChatClient 在 1.x 就能跑通业务;变的是循环放在哪、Advisor 能否插进中间轮次、以及配置要手写多少

3.0 回溯:1.x 下同一 Demo 怎么写?(已对照 v1.1.8 / v2.0.0 tag 源码)

以下结论来自本地 spring-ai 仓库 tag 比对:v1.1.8(1.x 末版)、v2.0.0(2.0 首发)。1.x 内部仍有版本差:ToolCallAdvisorv1.1.0 才出现v1.0.x 只有「ChatModel 内部循环」或手写 while,没有 Advisor 链式 ReAct。

本 Demo 的三块能力拆开看:

能力 1.x 能否实现 说明(对照 tag)
@Tool 订票 / 取消 / 查列表 ✅ 可以 业务代码与版本无关
chatClient.prompt().user().call() 驱动 ReAct ✅ 可以(路径 A) v1.1.8DeepSeekChatModelcall() 内递归 internalCall,由 toolExecutionEligibilityPredicate 触发 executeToolCalls5
PromptLoggingAdvisor 逐步打印每轮 Prompt ⚠️ 分路径 路径 A 默认不行v1.1.0+ 路径 B 手动注册 ToolCallAdvisor可以(见下)6
defaultTools 后自动注册 Tool Advisor ❌ 无 autoRegisterToolCallingAdvisorv2.0.0存在于 DefaultChatClientv1.1.8buildAdvisorChain() 只追加 ChatModelCallAdvisor不会自动加 ToolCallAdvisor7

路径 A:最常见——依赖 ChatModel 内部循环(v1.0.xv1.1.8 默认)

v1.1.8 中,对带 tools 的 Prompt 调用 chatModel.call() 时,DeepSeekChatModelinternalCall 末尾判断 toolExecutionEligibilityPredicate.isToolExecutionRequired(...),执行 toolCallingManager.executeToolCalls,再递归 internalCall 把 tool 结果塞回 Prompt——循环在模型类内部完成5v2.0.0 的同文件已删掉这段 tool 执行逻辑,internalCall 只返回单次 HTTP 响应2

因此路径 A 的 ChatConfig 往往比 2.0 更短——不必注册 ToolCallAdvisor

// Spring AI 1.x + Spring Boot 3.x — 路径 A(v1.1.8 验证)
@Bean
public ChatClient chatClient(ChatModel chatModel, BookingTools bookingTools) {
    return ChatClient.builder(chatModel)
            .defaultSystem(""" ... 订票助手 system prompt ... """)
            .defaultTools(bookingTools)   // v1.0.9+ 已有;亦可用 defaultToolCallbacks
            .build();
    // 无 ToolCallAdvisor;ReAct 在 DeepSeekChatModel.internalCall 内完成
}

ChatService.call().content() 写法可以不变。差别在 .call() 内部:若只加一层 远离 ChatModelBaseAdvisor(如 PromptLoggingAdvisor 但 order ≤ +300),它通常只在整次 .call() 的首尾各执行一次 before/after——中间多轮 internalCall 发生在 ChatModel 黑盒里,看不见 [AI 第1步] … (tool_calls)[AI 第2步] … TOOL_RESPONSE

路径 B:v1.1.0+ 手动注册 ToolCallAdvisor(Advisor 链 ReAct,2.0 前身)

v1.1.0 起,ToolCallAdvisor 与 2.0 的 ToolCallingAdvisor 同类:类注释即写明 Recursive Advisor… implements the tool calling loop as part of the advisor chain,默认 advisorOrder = HIGHEST_PRECEDENCE + 300do { … callAdvisorChain.copy(this).nextCall(…) … } while (isToolCall)6

与文章旧稿不同、以 v1.1.8 源码为准的几点:

  1. 不必手动设 internalToolExecutionEnabled(false)——ToolCallAdvisor.adviseCall 在循环开始前会对 options 副本执行 setInternalToolExecutionEnabled(false)8
  2. ToolCallingManager 通常不必注入——ToolCallAdvisor.builder() 默认 ToolCallingManager.builder().build()
  3. 必须手动 .defaultAdvisors(ToolCallAdvisor.builder()...)——1.x 没有 2.0 的 autoRegisterToolCallingAdvisor
// Spring AI 1.1.x — 路径 B(v1.1.8 验证)
@Bean
public ChatClient chatClient(ChatModel chatModel, BookingTools bookingTools) {
    return ChatClient.builder(chatModel)
            .defaultSystem(""" ... """)
            .defaultTools(bookingTools)
            .defaultAdvisors(ToolCallAdvisor.builder().build())
            .build();
}

PromptLoggingAdvisor 在 1.1.x 并非「根本无法实现」:若像本项目一样再注册一个 order = HIGHEST_PRECEDENCE + 400BaseAdvisor,且其 order 大于 ToolCallAdvisor(+300),则 copy(this).nextCall 重入链时会带上该 Advisor——逐步日志可以做到。难在 1.x 默认走路径 A,且路径 B 需手动组装 ToolCallAdvisor;2.0 则是 defaultTools 即自动启用 ToolCallingAdvisor,本项目只加观测 Advisor 即可。

路径 C:完全手写 while(ChatModel 直调)

chatModel.call()internalToolExecutionEnabled(false)v1.1.8ToolCallingChatOptions 默认 true9),再用 ToolCallingManager + while (response.hasToolCalls()) 驱动循环。Upgrade Notes 在 2.0 仍给出等价示例10;编排代码全在业务里,不适合本 Demo「一行 .call()」的目标。

版本与路径速查

Tag ToolCallAdvisor ChatModel 内 tool 循环 本 Demo 默认路径
v1.0.x ❌ 无 ✅ 有 仅路径 A 或 C
v1.1.0v1.1.8 ✅ 有(需手动注册) ✅ 有(路径 A 默认) 路径 A 最常见;路径 B 可观测 ReAct
v2.0.0+ 更名为 ToolCallingAdvisor + 自动注册 已移除 defaultTools → Advisor 链

1.x 与 2.0 配置对照(本 Demo 视角)

graph TB
  subgraph v1_default [1.x 路径 A 默认 v1.1.8]
    CS1[ChatService.call]
    CC1[ChatClient + defaultTools]
    CM1[DeepSeekChatModel.internalCall 内 while]
    CS1 --> CC1 --> CM1
  end
  subgraph v1_b [1.x 路径 B v1.1.0+]
    CS1b[ChatService.call]
    CC1b[ChatClient + ToolCallAdvisor +300]
    PLA1[PromptLoggingAdvisor +400 可选]
    CS1b --> CC1b --> PLA1
  end
  subgraph v2 [2.0 本项目 v2.0.0]
    CS2[ChatService.call]
    CC2[ChatClient + defaultTools]
    TCA[ToolCallingAdvisor 自动 +300]
    PLA[PromptLoggingAdvisor +400]
    CS2 --> CC2 --> TCA --> PLA
  end
项目 1.x 路径 A(v1.1.8 默认) 1.x 路径 B(v1.1.0+ 2.0(本项目)
Boot 3.x 3.x 4.1.0
注册工具 defaultTools / defaultToolCallbacks 同左 defaultTools
ReAct 驱动者 ChatModel.internalCall ToolCallAdvisor ToolCallingAdvisor(自动)
逐步 Prompt 日志 ❌ 远离 ChatModel 的 Advisor 不可见中间轮 ✅ 可(order +400,更靠近 ChatModel) ✅ 自动链 + PromptLoggingAdvisor
internalToolExecutionEnabled 默认 true;路径 B 由 Advisor 自动关 Advisor 内自动 false 选项已删除11
业务 .call() 相同 相同 相同

结论:订票 Demo 的 业务层在 1.x 完全可以实现;2.0 的实质变化是 v2.0.0 移除 ChatModel 内循环,Tool 循环统一由 Advisor 链承担,且 defaultTools 自动注册 ToolCallingAdvisor——因此本项目的 ChatConfig 才能只写 defaultTools + PromptLoggingAdvisor。从 v1.1.8 迁移:去掉手动 ToolCallAdvisor、删除 internalToolExecutionEnabled 配置、升级 Boot 4 + AI 2.0 即可11

3.1 ChatModel 内部循环被移除

1.x 中,对带 tools 的 Prompt 调用 chatModel.call(),各 Provider 会在实现内部自动执行 tool calls 并循环,直到模型返回纯文本。

2.0 删除了所有 ChatModel 实现中的这一内部循环2ChatModel.call() / stream() 现在返回原始模型响应——若有 tool_calls,不会自动执行。

迁移路径:改用 ChatClient + 自动注册的 ToolCallingAdvisor(推荐),或自行用 ToolCallingManager 写 while 循环。

3.2 ToolCallingAdvisor 拥有工具执行生命周期

ToolCallingAdvisor.adviseCall 的核心是一个 do { ... } while (isToolCall) 循环:

// ToolCallingAdvisor.java: 141-187
do {
    processedChatClientRequest = this.doBeforeCall(processedChatClientRequest, callAdvisorChain);
    chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);
    chatClientResponse = this.doAfterCall(chatClientResponse, callAdvisorChain);
    isToolCall = this.toolExecutionEligibilityChecker.isToolCallResponse(chatResponse);
    if (isToolCall) {
        ToolExecutionResult toolExecutionResult = this.toolCallingManager
            .executeToolCalls(processedChatClientRequest.prompt(), chatResponse);
        instructions = this.doGetNextInstructionsForToolCall(...);
    }
} while (isToolCall);

注意 callAdvisorChain.copy(this).nextCall(...):每一轮 ReAct 重新进入 order 更大(更靠近 ChatModel)的 Advisor 子链——这正是 PromptLoggingAdvisor 能逐步打印的原因;1.x 路径 A 下,中间轮次发生在 ChatModel 内部,远离 ChatModel 的 Advisor 不可见

默认 order:ToolCallingAdvisor.DEFAULT_ORDER = HIGHEST_PRECEDENCE + 30012MessageChatMemoryAdvisor 默认 order 从 +1000 改为 +200,比 +300 更远离 ChatModel——不参与每一轮 tool 迭代的子链,只存最终 user/assistant 交换,不把 tool_call / tool_response 写进多数 ChatMemoryRepository 不支持的存储13

3.2.1 源码走读:从 nextCallChatModel.call()(含 handleToolCallRecursion

先澄清一个常见混淆:handleToolCallRecursion 只出现在流式路径ToolCallingAdvisor.adviseStream)。本 Demo 的 ChatService.call() 同步路径,ReAct 递归由 adviseCall 里的 do-while 完成(约 141–187 行)。两条路径语义等价:都是「更靠近 ChatModel 的子链调模型 → 检测 tool_calls → 执行工具 → 更新 instructions → 再进子链」,只是流式在 SSE chunk 聚合完毕后才进入 handleToolCallRecursion,再递归调用 internalStream

链是怎么组出来的?

每次 chatClient.prompt().user(...).call() 时,DefaultChatClient.buildAdvisorChain 做三件事:

  1. autoRegisterToolCallingAdvisor() — 若链中尚无 ToolAdvisor,自动加入 ToolCallingAdvisor(+300)
  2. 把用户注册的 Advisor(本项目的 PromptLoggingAdvisor +400)与自动注册的 ToolCallingAdvisor 合并
  3. 在栈底追加终结 AdvisorChatModelCallAdvisorOrdered.LOWEST_PRECEDENCE)与 ChatModelStreamAdvisor(流式用)

DefaultAroundAdvisorChain.Builder.pushAllOrderComparator 排序后装入 双端队列order 越小,越先被 pop()——即 越远离 ChatModel(请求要先经过它,才能继续 nextCall 靠近模型)。

本 Demo 同步调用链(从远离 ChatModel → 靠近 ChatModel)可概括为:

顺序 Advisor order 距 ChatModel 角色
1 ToolCallingAdvisor +300 较远 驱动 ReAct do-whileBaseAdvisor 默认模板
2 PromptLoggingAdvisor +400 更近 观测;走 BaseAdvisorbefore → nextCall → after
3 ChatModelCallAdvisor LOWEST_PRECEDENCE 最近(终点) 直接 chatModel.call(prompt)不再 nextCall
DefaultAroundAdvisorChain.nextCall:调度器如何「层层递进」?

nextCall 逻辑极简:

var advisor = this.callAdvisors.pop();          // 弹出 order 最小(最远离 ChatModel)的 Advisor
return advisor.adviseCall(chatClientRequest, this);

每个 Advisor 的 adviseCall 决定是否继续向 ChatModel 递进:

类型 adviseCall 行为
BaseAdvisor(如 PromptLoggingAdvisor before()nextCall()after()模板 :47-53
ToolCallingAdvisor 自定义 do-while;每轮 copy(this).nextCall() 重入 更靠近 ChatModel 的子链
ChatModelCallAdvisor 链终点(最近)chatModel.call(prompt),返回 ChatClientResponse

copy(CallAdvisor after)完整 Advisor 列表里截取 after 之后的剩余节点,构造一条新链ToolCallingAdvisor 调用 copy(this).nextCall() 时,this 是 +300,剩余子链为 PromptLoggingAdvisor (+400)ChatModelCallAdvisor——即每一轮 ReAct 都会走一遍 更靠近 ChatModel 的观测链。

同步路径:.call() 如何最终落到 ChatModel

入口在 DefaultChatClient.doGetObservableChatClientResponse

// 注释写明:Apply the advisor chain that terminates with the ChatModelCallAdvisor.
var response = advisorChain.nextCall(chatClientRequest);

ChatModelCallAdvisor.adviseCall 是链上唯一真正调用大模型的地方:

ChatResponse chatResponse = this.chatModel.call(formattedChatClientRequest.prompt());
return ChatClientResponse.builder().chatResponse(chatResponse).context(...).build();

对本项目即 DeepSeekChatModel.call(Prompt) → HTTP 请求 DeepSeek API。2.0 下这次 call() 只返回单轮原始响应(含 tool_calls 时也不执行工具),工具执行由 更远离 ChatModelToolCallingAdvisor 在子链 after 返回后调用 toolCallingManager.executeToolCalls 完成。

流式路径:handleToolCallRecursion 做了什么?

流式时 ToolCallingAdvisor 不走 do-while,而是:

  1. internalStreamchainCopy.nextStream() 把 chunk 流式传给客户端
  2. streamWithToolCallResponses 在流结束后调用 handleToolCallRecursion
  3. 若聚合后的 ChatResponsetool_calls:在 boundedElasticexecuteToolCalls,再 return internalStream(...) 进入下一轮——与同步的 do-while 同构14
// ToolCallingAdvisor.java: 343-348(流式递归核心)
List<Message> nextInstructions = this.doGetNextInstructionsForToolCallStream(...);
return this.internalStream(streamAdvisorChain, originalRequest, optionsCopy,
        nextInstructions, usageAccumulator);
Mermaid:本 Demo 同步 .call() 完整时序(第 1 轮 + 工具 + 第 2 轮)
sequenceDiagram
  autonumber
  participant CS as ChatService
  participant DCC as DefaultChatClient
  participant Chain as DefaultAroundAdvisorChain
  participant TCA as ToolCallingAdvisor +300
  participant PLA as PromptLoggingAdvisor +400
  participant CMA as ChatModelCallAdvisor
  participant CM as DeepSeekChatModel
  participant TCM as ToolCallingManager
  participant BT as BookingTools

  CS->>DCC: prompt().user().call()
  DCC->>DCC: buildAdvisorChain()<br/>自动注册 TCA + 栈底 CMA
  DCC->>Chain: nextCall(request)

  Note over Chain,TCA: 先 pop order 最小(远离 ChatModel)→ TCA
  Chain->>TCA: adviseCall(req, chain)

  Note over TCA,CM: 第 1 轮 ReAct
  TCA->>TCA: doBeforeCall()
  TCA->>Chain: copy(TCA).nextCall(req₁)
  Note over Chain: 子链 PLA → CMA(更靠近 ChatModel)

  Chain->>PLA: adviseCall(BaseAdvisor 模板)
  PLA->>PLA: before() 日志第1步
  PLA->>Chain: nextCall(req₁)
  Chain->>CMA: adviseCall
  CMA->>CM: chatModel.call(prompt)
  CM-->>CMA: ChatResponse tool_calls
  CMA-->>PLA: ChatClientResponse
  PLA->>PLA: after() 日志第1步
  PLA-->>TCA: ChatClientResponse

  TCA->>TCA: doAfterCall()
  TCA->>TCM: executeToolCalls(prompt, response)
  TCM->>BT: subscribeTicket @Tool
  BT-->>TCM: BookingResponse
  TCM-->>TCA: ToolExecutionResult + 新 instructions

  Note over TCA,CM: 第 2 轮 ReAct(instructions 含 TOOL_RESPONSE)
  TCA->>Chain: copy(TCA).nextCall(req₂)
  Chain->>PLA: before() 第2步 → CMA → CM.call()
  CM-->>PLA: 最终中文
  PLA->>PLA: after() 第2步
  PLA-->>TCA: ChatClientResponse

  TCA->>TCA: isToolCall=false,退出 do-while
  TCA-->>DCC: ChatClientResponse
  DCC-->>CS: content()

读图要点

三条路径对照
路径 ReAct 驱动 向 ChatModel 递进 ChatModel 的类
同步 .call() adviseCalldo-while copy(this).nextCall() 子链 ChatModelCallAdvisor
流式 .stream() handleToolCallRecursioninternalStream copy(this).nextStream() 子链 ChatModelStreamAdvisor
1.x 路径 A ChatModel.internalCall 内递归 无 Advisor 子链重入 DeepSeekChatModel 自身

3.3 defaultTools 与自动注册

DefaultChatClient.Spec.autoRegisterToolCallingAdvisor 在每次 call() 前检查:

则自动加入 ToolCallingAdvisor。全局开关:spring.ai.chat.client.tool-calling.enabled(默认 true15

defaultToolCallbacks 等在 2.0 deprecateddefaultTools(Object...)v1.0.9 已存在16,2.0 是将其作为唯一推荐入口。

3.4 删除与新增的选项(迁移清单)

1.x 2.0
internalToolExecutionEnabled 已删除 — 不再有「模型内执行 / 用户执行」双模式
ToolExecutionEligibilityPredicate 已删除 — 改用 ToolExecutionEligibilityChecker 挂在 ToolCallingAdvisor.Builder
ToolCallAdvisor(手动注册) ToolCallingAdvisor 自动注册取代
streamToolCallResponses 已删除 — 中间 tool 块不再以特殊流式选项泄露;需观测循环请用自定义 Advisor 或手动 loop

若需完全自控循环,可 AdvisorParams.toolCallingAdvisorAutoRegister(false),配合 ToolCallingManager.executeToolCalls 手写 while——Upgrade Notes 给出了完整示例10

3.5 Advisor 链 order 速查(距 ChatModel 远近)

graph LR
  Mem["MessageChatMemoryAdvisor +200<br/>远离 ChatModel"]
  TCA["ToolCallingAdvisor +300"]
  PLA["PromptLoggingAdvisor +400<br/>更靠近 ChatModel"]
  CMA["ChatModelCallAdvisor<br/>最近(链终点)"]
  Mem --> TCA --> PLA --> CMA

速记order 越大 → 越靠近 ChatModel。要把自定义观测逻辑插进每一轮 tool 迭代,order 必须 > 300(比 ToolCallingAdvisor 更近),这样才会进入 copy(this).nextCall() 子链,在每次 chatModel.call() 前后执行。


四、变动在本项目中的落地

4.1 业务代码几乎不变,可观测性大变

ChatService 仍是一行:

// ChatService.java: 63-67
String reply = chatClient
        .prompt()
        .user(userMessage.trim())
        .call()
        .content();

差异在 .call() 内部:2.0 由自动注册的 ToolCallingAdvisor 驱动多轮 DeepSeek 请求;1.x 路径 A 则在 DeepSeekChatModel.internalCall 内循环(§3.0),远离 ChatModel 的 Advisor 只见首尾——1.1.x 路径 B 手动加 ToolCallAdvisor 后,子链也可多轮靠近 ChatModel。

4.2 配置层:从「手动 Advisor + Manager」到「defaultTools 即可」

1.x 下路径 A/B/C 的代码级对比见 §3.0(已对照 v1.1.8 tag)。本节只保留迁移速查表:

维度 Spring AI 1.x(v1.1.8 Spring AI 2.0(本项目)
Boot 版本 3.x 4.1.0
工具循环位置 路径 A:ChatModel 内部;路径 B:ToolCallAdvisor ToolCallingAdvisor 链(默认)
Tool Advisor 注册 v1.1.0+ 路径 B 手动v1.0.x 无此类 defaultTools 自动注册
逐步日志 路径 A:远离 ChatModel 不可见;路径 B:可(order +400) PromptLoggingAdvisor + 自动链
ToolCallingManager ToolCallAdvisor.builder() 自带默认实例 Boot 自动配置

application.yml 无需额外打开 tool-calling 开关——默认已启用。

4.3 如何验证 ReAct 真的发生

按优先级排查:

  1. 控制台 [Tool 被调用] subscribeTicket — 工具层日志
  2. [AI 第N步] — Advisor 链内每轮 Prompt/Response
  3. Hibernate update bookings set status=... — JPA show-sql: true
  4. 前端左栏「已订阅」+1 — 聊天后 loadBookings()

若只有 AI 文字、无 Tool 日志、列表不变 → 模型可能未调工具(LLM 常见情况);可调 system prompt、temperature,或重试。

4.4 与 Spring AI 文档的对应关系

项目文件 Spring AI 概念
BookingTools @Tool / ToolCallback
ChatConfig.defaultTools 静态工具注册 + 触发 auto-register
PromptLoggingAdvisor 更靠近 ChatModel 的观测;对应文档 Recursive Advisors
ChatService ChatClient 唯一调用点
HealthController 与 AI 路径解耦的连通性探测

五、快速上手与延伸

export DEEPSEEK_API_KEY=your-key-here
cd backend && mvn spring-boot:run
# 另开终端
pnpm install && pnpm dev

浏览器打开 http://localhost:5173,说「我要订票」或「取消订票 G123」。不启前端也可 curl:

curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message":"我要订票"}'
curl "http://localhost:8080/api/bookings?status=SUBSCRIBED"

仓库 README 还列出了 SSE 流式、SQLite 持久化、意图规则兜底等扩展方向17


References

  1. ToolCallingAdvisor 类注释Recursive Advisor… implements the tool calling loop as part of the advisor chain 

  2. Upgrade Notes — Removed: Internal Tool Execution Loop in ChatModel  2 3

  3. Upgrade Notes — Upgrading to 2.0.0;本项目 pom.xml 使用 Spring Boot 4.1.0 + spring-ai-bom 2.0.0 

  4. springai_demo docs/CORS.md — Vite Proxy 与 Spring CORS 

  5. DeepSeekChatModel @ v1.1.8 — internalCallexecuteToolCalls + 递归(约 208–219 行);v2.0.0 同文件已移除该段  2

  6. ToolCallAdvisor @ v1.1.8 — Recursive Advisor、copy(this).nextCallDEFAULT_ORDER +300v1.1.0 引入;v1.0.x tag 下无此文件)  2

  7. DefaultChatClient.buildAdvisorChain @ v1.1.8 仅 push ChatModelCallAdvisorautoRegisterToolCallingAdvisor @ v2.0.0 

  8. ToolCallAdvisor.adviseCall @ v1.1.8 — setInternalToolExecutionEnabled(false)(约 119–122 行) 

  9. ToolCallingChatOptions.DEFAULT_TOOL_EXECUTION_ENABLED = true @ v1.1.8 

  10. Upgrade Notes — User-controlled loop 示例ToolCallingAdvisor 参考  2

  11. Upgrade Notes — Removed: internalToolExecutionEnabled  2

  12. ToolCallingAdvisor.DEFAULT_ORDERHIGHEST_PRECEDENCE + 300 

  13. Upgrade Notes — DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER 与 Memory 在工具循环外 

  14. ToolCallingAdvisor.handleToolCallRecursion @ v2.0.0DefaultAroundAdvisorChain.nextCallChatModelCallAdvisor 

  15. Upgrade Notes — spring.ai.chat.client.tool-calling.enabled 

  16. DefaultChatClientBuilder.defaultTools @ v1.0.9v1.1.8 均已存在 

  17. springai_demo README — 可扩展方向