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()。
全文脉络:
- 项目架构 — 前后端分层、写入口收敛、一次聊天的时序
- 前端设计 — 容器/展示分离、API 封装、聊天后刷新列表
- 实现亮点 — 业务兜底、PromptLoggingAdvisor 调用链与 before/after 时机、E2E 与 Docker Compose 编排
- Spring AI 2 核心变动 — 1.x 三种写法回溯、Advisor 链源码走读(
nextCall→ChatModel)、Upgrade Notes - 变动在项目中的落地 —
ChatConfig、PromptLoggingAdvisor、日志如何读 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.xml:spring-boot-starter-parent 4.1.0,spring-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 只有 id、title、status(SUBSCRIBED / 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.tsx 用 useState 维护两栏列表(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 dev 时 apiProxyTarget 默认为 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 统一管理 frontend 与 e2e,根脚本 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.1(application.yml),让模型更稳定地触发 function calling。
2.2 PromptLoggingAdvisor:把 ReAct 从黑盒里拽出来
Spring AI 2.0 的关键收益是:order 大于 ToolCallingAdvisor(+300)的 Advisor,会挂在每一轮 copy(this).nextCall() 子链上、更靠近 ChatModel。PromptLoggingAdvisor 的 getOrder() 返回 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 之后,由 ToolCallingAdvisor → ToolCallingManager 触发 |
为何要 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 数值越大 → 越靠近 ChatModel;order 越小 → 越远离 ChatModel(到达模型前要经过更多 Advisor)。链终点是 ChatModelCallAdvisor(Ordered.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 被调用] 不在 before 与 after 之间,而在 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.yml → com.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 链触发 |
工具执行在 before 和 after 之间 |
工具在 after 之后,由 ToolCallingAdvisor 执行 |
| 工具定义在 INFO 的 SYSTEM 里 | 工具在 DEBUG 的 options → API tools 字段 |
一次 chat 只对应一步 [AI 第1步] |
有 tool call 时通常 至少两步 |
| order 越大越靠近 ChatModel | +400 > +300:PromptLoggingAdvisor 比 ToolCallingAdvisor 更靠近 ChatModel;ChatModelCallAdvisor 的 order 最大,是链终点 |
2.3 ChatConfig 刻意保持精简
ChatConfig 只做三件事:
// ChatConfig.java: 49-63
return ChatClient.builder(chatModel)
.defaultSystem(""" ... """)
.defaultTools(bookingTools)
.defaultAdvisors(new PromptLoggingAdvisor())
.build();
defaultTools(bookingTools)— 注册@Tool,并触发ToolCallingAdvisor自动装配(见第三节)- 不再手动
new ToolCallAdvisor/ToolCallingAdvisor— 显式再加会导致链中出现两个ToolAdvisor,DefaultChatClient会校验并报错 ToolCallingManager仍由 Spring Boot 自动配置,业务代码无需注入
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/live(HealthController 只返回 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_BOOKING 与 LOGGING_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 与健康检查
- Playwright(
e2e/tests/app.spec.ts):初始 3 未订阅 / 0 已订阅、聊天订票、聊天取消、健康检查;capture-screenshots脚本生成 README 效果截图 - API Key 仅通过环境变量注入,仓库内无密钥;跨域与 Proxy 实测见
docs/CORS.md
更细的模块说明见项目 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 内部仍有版本差:ToolCallAdvisor 自 v1.1.0 才出现;v1.0.x 只有「ChatModel 内部循环」或手写 while,没有 Advisor 链式 ReAct。
本 Demo 的三块能力拆开看:
| 能力 | 1.x 能否实现 | 说明(对照 tag) |
|---|---|---|
@Tool 订票 / 取消 / 查列表 |
✅ 可以 | 业务代码与版本无关 |
chatClient.prompt().user().call() 驱动 ReAct |
✅ 可以(路径 A) | v1.1.8 的 DeepSeekChatModel 在 call() 内递归 internalCall,由 toolExecutionEligibilityPredicate 触发 executeToolCalls5 |
PromptLoggingAdvisor 逐步打印每轮 Prompt |
⚠️ 分路径 | 路径 A 默认不行;v1.1.0+ 路径 B 手动注册 ToolCallAdvisor 后可以(见下)6 |
defaultTools 后自动注册 Tool Advisor |
❌ 无 | autoRegisterToolCallingAdvisor 仅 v2.0.0 起存在于 DefaultChatClient;v1.1.8 的 buildAdvisorChain() 只追加 ChatModelCallAdvisor,不会自动加 ToolCallAdvisor7 |
路径 A:最常见——依赖 ChatModel 内部循环(v1.0.x~v1.1.8 默认)
v1.1.8 中,对带 tools 的 Prompt 调用 chatModel.call() 时,DeepSeekChatModel 在 internalCall 末尾判断 toolExecutionEligibilityPredicate.isToolExecutionRequired(...),执行 toolCallingManager.executeToolCalls,再递归 internalCall 把 tool 结果塞回 Prompt——循环在模型类内部完成5。v2.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() 内部:若只加一层 远离 ChatModel 的 BaseAdvisor(如 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 + 300,do { … callAdvisorChain.copy(this).nextCall(…) … } while (isToolCall)6。
与文章旧稿不同、以 v1.1.8 源码为准的几点:
- 不必手动设
internalToolExecutionEnabled(false)——ToolCallAdvisor.adviseCall在循环开始前会对 options 副本执行setInternalToolExecutionEnabled(false)8。 ToolCallingManager通常不必注入——ToolCallAdvisor.builder()默认ToolCallingManager.builder().build()。- 必须手动
.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 + 400 的 BaseAdvisor,且其 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.8 的 ToolCallingChatOptions 默认 true9),再用 ToolCallingManager + while (response.hasToolCalls()) 驱动循环。Upgrade Notes 在 2.0 仍给出等价示例10;编排代码全在业务里,不适合本 Demo「一行 .call()」的目标。
版本与路径速查
| Tag | ToolCallAdvisor | ChatModel 内 tool 循环 | 本 Demo 默认路径 |
|---|---|---|---|
v1.0.x |
❌ 无 | ✅ 有 | 仅路径 A 或 C |
v1.1.0~v1.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 实现中的这一内部循环2。ChatModel.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 + 30012。MessageChatMemoryAdvisor 默认 order 从 +1000 改为 +200,比 +300 更远离 ChatModel——不参与每一轮 tool 迭代的子链,只存最终 user/assistant 交换,不把 tool_call / tool_response 写进多数 ChatMemoryRepository 不支持的存储13。
3.2.1 源码走读:从 nextCall 到 ChatModel.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 做三件事:
autoRegisterToolCallingAdvisor()— 若链中尚无ToolAdvisor,自动加入ToolCallingAdvisor(+300)- 把用户注册的 Advisor(本项目的
PromptLoggingAdvisor+400)与自动注册的ToolCallingAdvisor合并 - 在栈底追加终结 Advisor:
ChatModelCallAdvisor(Ordered.LOWEST_PRECEDENCE)与ChatModelStreamAdvisor(流式用)
DefaultAroundAdvisorChain.Builder.pushAll 按 OrderComparator 排序后装入 双端队列:order 越小,越先被 pop()——即 越远离 ChatModel(请求要先经过它,才能继续 nextCall 靠近模型)。
本 Demo 同步调用链(从远离 ChatModel → 靠近 ChatModel)可概括为:
| 顺序 | Advisor | order | 距 ChatModel | 角色 |
|---|---|---|---|---|
| 1 | ToolCallingAdvisor |
+300 | 较远 | 驱动 ReAct do-while;不走 BaseAdvisor 默认模板 |
| 2 | PromptLoggingAdvisor |
+400 | 更近 | 观测;走 BaseAdvisor:before → 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 时也不执行工具),工具执行由 更远离 ChatModel 的 ToolCallingAdvisor 在子链 after 返回后调用 toolCallingManager.executeToolCalls 完成。
流式路径:handleToolCallRecursion 做了什么?
流式时 ToolCallingAdvisor 不走 do-while,而是:
internalStream→chainCopy.nextStream()把 chunk 流式传给客户端streamWithToolCallResponses在流结束后调用handleToolCallRecursion- 若聚合后的
ChatResponse含tool_calls:在boundedElastic上executeToolCalls,再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()
读图要点:
- 第 4–6 步:
DefaultChatClient只调一次nextCall,首先进入的是 最远离 ChatModel 的ToolCallingAdvisor(+300),不是PromptLoggingAdvisor。 - 第 8–9 步:
copy(TCA)是关键——每一轮 ReAct 都重新构造「PLA → CMA」子链(更靠近 ChatModel),因此PromptLoggingAdvisor的before/after在每一轮都会触发。 - 第 17–18 步:
ChatModelCallAdvisor是链终点(最近);ToolCallingAdvisor与PromptLoggingAdvisor都不会直接碰ChatModel。 - 第 20–22 步:工具执行在
PLA.after()之后、下一轮copy().nextCall()之前,与 §2.2.5 日志顺序一致。
三条路径对照
| 路径 | ReAct 驱动 | 向 ChatModel 递进 | 调 ChatModel 的类 |
|---|---|---|---|
同步 .call() |
adviseCall 的 do-while |
copy(this).nextCall() 子链 |
ChatModelCallAdvisor |
流式 .stream() |
handleToolCallRecursion → internalStream |
copy(this).nextStream() 子链 |
ChatModelStreamAdvisor |
| 1.x 路径 A | ChatModel.internalCall 内递归 |
无 Advisor 子链重入 | DeepSeekChatModel 自身 |
3.3 defaultTools 与自动注册
DefaultChatClient.Spec.autoRegisterToolCallingAdvisor 在每次 call() 前检查:
- 若
AdvisorParams.toolCallingAdvisorAutoRegister(false)未禁用 - 且链中尚无
ToolAdvisor实例
则自动加入 ToolCallingAdvisor。全局开关:spring.ai.chat.client.tool-calling.enabled(默认 true)15。
defaultToolCallbacks 等在 2.0 deprecated;defaultTools(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 真的发生
按优先级排查:
- 控制台
[Tool 被调用] subscribeTicket— 工具层日志 [AI 第N步]— Advisor 链内每轮 Prompt/Response- Hibernate
update bookings set status=...— JPAshow-sql: true - 前端左栏「已订阅」+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
-
ToolCallingAdvisor类注释 — Recursive Advisor… implements the tool calling loop as part of the advisor chain ↩ -
Upgrade Notes — Removed: Internal Tool Execution Loop in
ChatModel↩ ↩2 ↩3 -
Upgrade Notes — Upgrading to 2.0.0;本项目
pom.xml使用 Spring Boot 4.1.0 + spring-ai-bom 2.0.0 ↩ -
DeepSeekChatModel@ v1.1.8 —internalCall内executeToolCalls+ 递归(约 208–219 行);v2.0.0同文件已移除该段 ↩ ↩2 -
ToolCallAdvisor@ v1.1.8 — Recursive Advisor、copy(this).nextCall、DEFAULT_ORDER +300(v1.1.0引入;v1.0.xtag 下无此文件) ↩ ↩2 -
DefaultChatClient.buildAdvisorChain@ v1.1.8 仅 pushChatModelCallAdvisor;autoRegisterToolCallingAdvisor@ v2.0.0 ↩ -
ToolCallAdvisor.adviseCall@ v1.1.8 —setInternalToolExecutionEnabled(false)(约 119–122 行) ↩ -
ToolCallingChatOptions.DEFAULT_TOOL_EXECUTION_ENABLED = true@ v1.1.8 ↩ -
Upgrade Notes — User-controlled loop 示例;ToolCallingAdvisor 参考 ↩ ↩2
-
ToolCallingAdvisor.DEFAULT_ORDER—HIGHEST_PRECEDENCE + 300↩ -
Upgrade Notes —
DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER与 Memory 在工具循环外 ↩ -
ToolCallingAdvisor.handleToolCallRecursion@ v2.0.0 与DefaultAroundAdvisorChain.nextCall、ChatModelCallAdvisor↩ -
Upgrade Notes —
spring.ai.chat.client.tool-calling.enabled↩ -
DefaultChatClientBuilder.defaultTools@ v1.0.9 与v1.1.8均已存在 ↩