跳到主要内容

数据截至 (上游 commit c988e72ab728)

工具调用:从 @Tool 注解到多轮循环

30 秒导读: 模型只会输出文本,它不会真的去查天气。工具调用就是这套翻译机制:把一个普通 Java 方法翻译成模型能读的"说明书"(JSON Schema),模型说"我要调 getWeather(city=北京)",框架再把这句话翻译回一次真实的反射调用,把返回值塞回对话继续问模型。本章讲清楚这条链路的三段——定义侧、执行侧、循环侧——以及 Spring AI 2.0 把循环搬出 ChatModel 之后的新形态。

本章属于 Spring AI 系列的第 3 章。前置阅读:ChatClient 与请求组装Advisor 责任链(本章的循环侧完全建立在 advisor 链之上)。


1. 这是什么(零基础也能懂)

一句话定义: 工具调用(tool calling,也叫 function calling)让模型能"点名"调用你写的方法,框架负责把点名变成真实执行,再把执行结果喂回模型。

它解决什么问题。 模型知识有截止日期、不能读你的数据库、不会发邮件。你想让它回答"我们仓库还有几台 X 型号",唯一的办法是给它一个能查库存的方法,让它自己决定什么时候调。

给谁用。 写 Spring 应用、想让 LLM 触达业务系统的 Java 工程师。

用起来什么样。 加一个注解,再把对象交给 ChatClient,就完了:

// 示意,非源码
@Component
class WeatherTools {

@Tool(description = "查询某个城市当前的天气") // 这句描述是写给模型看的
String getWeather(@ToolParam(description = "城市名") String city,
@ToolParam(required = false, description = "单位:c 或 f") String unit) {
return weatherApi.query(city, unit); // 普通业务代码,不含任何 AI 概念
}
}

// 一次调用里挂上这个工具
String answer = chatClient.prompt("北京今天冷吗?")
.tools(new WeatherTools()) // 交给 ChatClient
.call().content();

重点看:方法本身完全不知道 AI 的存在。所有 AI 相关的东西(schema、参数解析、多轮循环)都被框架挡在外面。

一句话直觉。 把工具调用想成招聘@Tool 的描述和参数 schema 是岗位说明书(模型据此判断该不该用),ToolCallback接线员(把模型的一句话接到真实方法上),ToolCallingAdvisor工头(模型干完一轮还想再干,就再派一轮活,直到它说"我说完了")。


2. 顶层全景:一次工具调用的三段旅程

怎么读这张图: 从左到右是时间顺序;① 在请求组装时发生一次,②③④ 每一轮模型回合各发生一次。

① 定义侧 ② 模型回合 ③ 执行侧
───────────── ──────────── ────────────
@Tool 方法 按名字找到 ToolCallback
│ 反射 + JSON Schema 解 JSON → 反射 invoke
↓ ↑
ToolCallback ──工具清单──→ ChatModel ──ChatResponse(带 toolCalls)─┘
↑ │
│ ↓
└────── ④ 循环侧 ───── 把 AssistantMessage +
ToolCallingAdvisor ToolResponseMessage
拼好历史再问一轮 追加进消息历史

2.1 部件一句话职责

部件干什么在哪个文件(相对克隆根)
@Tool / @ToolParam给方法和参数贴上"这是工具/这个参数叫什么、必填吗"的标签spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/Tool.java
ToolDefinition模型真正看到的三元组:名字、描述、入参 schemaspring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.java
JsonSchemaGenerator把方法签名反射成 JSON Schemaspring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java
ToolCallback可执行的工具:call(JSON 字符串) → Stringspring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallback.java
MethodToolCallback用反射把 JSON 参数打到方法形参上spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java
ToolCallbackResolver运行时按名字兜底找工具(MCP、Spring Bean 等来源)spring-ai-model/src/main/java/org/springframework/ai/tool/resolution/DelegatingToolCallbackResolver.java
DefaultToolCallingManager执行一个 ChatResponse 里的所有 tool call,产出新的消息历史spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java
ToolCallingAdvisor驱动多轮循环:模型还想调工具就再问一轮spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java

2.2 主线走一遍(不进代码)

  1. .tools(new WeatherTools()) 时,@Tool 方法被扫描成 ToolCallback 数组,塞进 ToolCallingChatOptions
  2. ChatModel 只做一件跟工具有关的事:resolveToolDefinitions(...) 拿到定义,翻成供应商格式挂在请求上(例:models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java:879)。
  3. 模型返回的 AssistantMessage 里带 ToolCall(id, type, name, arguments)
  4. ToolCallingAdvisor 发现有 tool call,交给 ToolCallingManager 逐个执行,拿到 ToolExecutionResult
  5. 结果拼成新历史,回到第 2 步再打一次模型——直到模型不再点名工具。

这里有个 2.0 的关键事实: OpenAiChatModel 全文只用到 toolCallingManager.resolveToolDefinitions,没有任何 executeToolCalls 调用。也就是说 ChatModel 不再自己跑工具循环,循环完全在 advisor 链上(详见 §5)。


3. 定义侧:一个 Java 方法怎么变成模型能看懂的工具

这节讲"说明书"是怎么被反射出来的。

3.1 模型看到的只有三样东西

ToolDefinition 就三个字段,别的都是框架内部的事:

字段给模型的作用缺省时怎么办
name()模型点名时写的字符串用方法名
description()模型判断"该不该用这个工具"的唯一依据用方法名(驼峰拆成空格短语)
inputSchema()模型据此构造参数 JSON由方法签名反射生成,无缺省

DefaultToolDefinition 是个 record,三个字段都做了非空断言(spring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.java:31-37);builder 在描述为空时用 ParsingUtils.reConcatenateCamelCase 把名字拆成人话当描述(同文件 :69-77)。

3.2 名字和描述从注解里怎么取

三个 getter 都走同一个套路:AnnotatedElementUtils.findMergedAnnotation@Tool,没有就退回方法名。

  • ToolUtils.getToolNamespring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.java:57-69):注解没写 name 就用方法名,最后过一遍 validateToolName
  • ToolUtils.getToolDescription(同文件 :76-83)。
  • ToolUtils.getToolReturnDirect(同文件 :85-89)、getToolCallResultConverter:91-104,用无参构造反射实例化 @Tool(resultConverter=…) 指定的类)。

一个容易忽略的贴心设计: validateToolName(同文件 :128-134)不抛异常,只在名字不匹配 ^[a-zA-Z0-9_\.-]+$:52RECOMMENDED_NAME_PATTERN)时打 warn——因为有些供应商(OpenAI)不接受带空格或括号的工具名,但框架不想为此把应用启动搞崩。

把三样拼起来的胶水是 ToolDefinitions.builder(Method)spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolDefinitions.java:47-53):名字、描述、schema 三行搞定。

3.3 入参 schema:generateForMethodInput 的六件事

这是定义侧含金量最高的一段(spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java:136-191)。它不用一个大而全的类型反射,而是手工搭一个 {"type":"object","properties":{...},"required":[...]},逐个形参填。

对每个形参依次做:

  1. 跳过 ToolContext 形参:148-155)。这是 Spring AI 的旁路通道,用来把应用侧数据传进工具,模型不该看见它,所以不进 schema。
  2. 跳过 Kotlin suspend 函数的尾巴:156-161)。suspend 函数在字节码上多一个合成的 Continuation 形参,不属于工具契约。
  3. 判断必填,命中就进 required 数组(:162-164,逻辑见下表)。
  4. 生成该参数的子 schema,然后 JsonSchemaUtils.hoistDefsToRoot 把子 schema 自带的 $defs 提到根上(:166-171)。原因写在注释里:victools 生成的是自包含子 schema,直接内嵌到 properties.<paramName> 下面会让 #/$defs/<Name> 这类引用指向错误的根,解析不出来。
  5. 删掉 format:173),注释直说:有些模型(Mistral)处理不了 OpenAPI 的 format。
  6. 补描述:174-177,来自 getMethodParameterDescription:270-288)。

必填怎么判:isMethodParameterRequired 的五级链

按顺序命中即停(spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java:232-256):

顺序看什么命中后的结论
1@ToolParam(required = …)直接采用它的值
2@JsonProperty(required = …)直接采用它的值
3@Schema(...)(Swagger)requiredMode 为 REQUIRED 或 AUTO、或 required=true → 必填
4参数可空性 Nullness.forParameterNULLABLE → 非必填
5都没有落到 PROPERTY_REQUIRED_BY_DEFAULT

PROPERTY_REQUIRED_BY_DEFAULT = true(同文件 :87)。注释给的理由很实在:为了跨供应商的一致性和健壮性,默认所有属性必填——因为"可选参数"在不同模型上的表现差异极大,默认必填更可预测。这是一个"宁可严格、也不要各家各样"的取舍。

additionalProperties: false 是默认加的

processSchemaOptions:206-213)在没显式传 SchemaOption.ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT 时,会调 forbidAdditionalProperties:297-314)递归给每个带 properties 的对象节点补 "additionalProperties": false

SchemaOption 只有两个值(:348-358):

选项作用谁会用
ALLOW_ADDITIONAL_PROPERTIES_BY_DEFAULT不加 additionalProperties:false允许开放对象的场景
UPPER_CASE_TYPE_VALUES把所有 type 值转大写要求大写类型名的供应商(见 convertTypeValuesToUpperCase:316

注意 forbidAdditionalProperties 的守卫条件是 !node.has("additionalProperties")——这样 Map<K,V> 生成的、additionalProperties 是个类型引用而非布尔的 schema 不会被踩坏(注释见 :291-296)。

嵌套 POJO 的字段谁管?SpringAiSchemaModule

上面讲的是方法形参这一层。如果形参是个 POJO,POJO 内部字段的 required/description 由注册进 victools 的 SpringAiSchemaModule 负责(spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/SpringAiSchemaModule.java:36-56):它只实现两个钩子——resolveToolParamDescriptionresolveToolParamRequired,都用 getAnnotationConsideringFieldAndGetter 同时看字段和 getter 上的 @ToolParam

共用逻辑在父类 AbstractSpringAiSchemaModule.checkRequired(同目录 AbstractSpringAiSchemaModule.java:97-134),判定链和上表几乎一致,只多一条:如果是 Kotlin 类型就返回 false,把"有没有默认值"的判断让给 KotlinModule:127-131)。

两个 module 的默认值也是对齐的:静态块里根据 PROPERTY_REQUIRED_BY_DEFAULT 决定要不要给 module 传 Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULTJsonSchemaGenerator.java:100-101),而 module 内部用"没传这个 option 就是 requiredByDefault"的写法接住(AbstractSpringAiSchemaModule.java:50-52)。

上面那个天气方法生成出来长这样

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名" },
"unit": { "type": "string", "description": "单位:c 或 f" }
},
"required": ["city"],
"additionalProperties": false
}

(示意,非源码输出——形状按 generateForMethodInput 的建构顺序推导:$schema / type / properties / requiredadditionalPropertiesprocessSchemaOptions 补上。)

3.4 从"说明书"到"能干活":MethodToolCallback

ToolCallback 接口极简:getToolDefinition()getToolMetadata()call(String)call(String, ToolContext)spring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallback.java:33-68)。默认的双参重载会在有人传了非空 ToolContext 却没被实现覆写时打一条 info 日志提醒(:59-68)。

MethodToolCallback.call 是四步(spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java:95-116):

toolInput(模型给的 JSON 字符串)

├─① extractToolArguments → Map<String,Object> :134
├─② buildMethodArguments → Object[](按形参名取值) :148
├─③ callMethod → 反射 invoke :179
└─④ toolCallResultConverter.convert → String(回给模型的文本)

四步各自的关键点:

  • extractToolArguments:134-144):JSON 反序列化失败不是让它裸奔,而是包成 ToolExecutionException 并带上 ToolDefinition——这样执行侧才知道是哪个工具炸的。
  • buildMethodArguments:148-156):遍历方法形参,形参类型能装下 ToolContext 就直接注入上下文对象,其余按形参名去 map 里取值。名字是唯一的对齐键,所以编译时必须保留参数名(-parameters),否则形参会变成 arg0。另有一道前置校验 validateToolContextSupport:125-132):方法声明了 ToolContext 形参却没人给非空上下文,直接抛 IllegalArgumentException
  • buildTypedArgument:158-176):Class<?>convertToTypedObject,泛型类型则先 toJson 再按 Type 反序列化——因为泛型擦除后只能走 JSON 中转。
  • callMethod:179-195):非 public 的对象或方法会先 setAccessible(true):180-182);InvocationTargetException 被解包成业务异常再包进 ToolExecutionException:191-193),而 IllegalAccessException 变成 IllegalStateException——业务异常和框架错误走两条路

结果转换器 DefaultToolCallResultConverterspring-ai-model/src/main/java/org/springframework/ai/tool/execution/DefaultToolCallResultConverter.java:47-67)有三条分支:void 返回 "Done"RenderedImage 转 base64 PNG 并包成 {mimeType, data}、其余转 JSON。

3.5 不写注解的那条路:FunctionToolCallback

有时候工具不是方法而是一个 lambda(例如从配置里拼出来的)。FunctionToolCallback 走的是整体入参对象的路子。

维度MethodToolCallbackFunctionToolCallback
工具体一个 Method + 目标对象一个 BiFunction<I, ToolContext, O>
参数映射逐形参按名取值再逐个转型整个 JSON 反序列化成一个 I
schema 来源generateForMethodInput 反射方法签名builder 上显式给 inputSchemainputType
上下文形参里声明 ToolContext 就注入作为 BiFunction 第二个参数直接给
定义处spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.java:95spring-ai-model/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.java:101-116

FunctionToolCallback 还给 Function/Supplier/Consumer 各提供了一个 builder 重载(spring-ai-model/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.java:139-172),Supplier 那个会自动把 inputType 设成 Void.class。它的 callMethod:118-128)刻意把已经是 ToolExecutionException 的异常原样透传,别的才包一层——避免异常被套娃。

3.6 批量产出:MethodToolCallbackProvider

.tools(new WeatherTools()) 背后是 ToolCallbacks.from(...),一行转发给这个 provider(spring-ai-model/src/main/java/org/springframework/ai/support/ToolCallbacks.java:33-35)。

getToolCallbacks() 的扫描规则(spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallbackProvider.java:86-108):

  1. AOP 代理对象取目标类再反射(:89-90),否则拿到的是代理类的合成方法。
  2. 三层过滤:带 @Tool返回值不是 Function/Supplier/Consumer、是用户声明的方法(:91-93)。
  3. 每个方法拼成 MethodToolCallback:定义、metadata、方法、宿主对象、结果转换器(:94-100)。

两处刻意的防呆:

  • 返回函数式类型的 @Tool 方法被跳过并打 warnisFunctionalType:110-123)——这通常是把老式 function bean 写法和新注解写法搞混了。
  • 构造时如果某个对象一个 @Tool 方法都没有,抛出的异常直接把话说明白:"你是不是想传 ToolCallback 或 ToolCallbackProvider?那要用 .tools(toolCallback)"(assertToolAnnotatedMethodsPresent:67-83)。
  • 重名工具在构造时和每次 getToolCallbacks() 后都会被 validateToolCallbacks 拦下(:130-137),异常消息里列出是哪几个类冲突。

顺带一提,ChatClient.tools(Object...) 是个"什么都收"的入口:ToolCallbackToolCallbackProvider、它们的数组、集合、以及裸 POJO 分别归类,POJO 最后统一交给 ToolCallbacks.fromspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java:1028-1068)。请求组装的细节见第 1 章

3.7 运行时按名字兜底:ToolCallbackResolver 家族

不是所有工具都能在请求组装时就摆到 options 里——MCP 远端工具、Spring 容器里的 function bean 都可能是运行时才知道的。于是有了一个只有一个方法的接口:

@Nullable ToolCallback resolve(String toolName); // spring-ai-model/src/main/java/org/springframework/ai/tool/resolution/ToolCallbackResolver.java:34
实现策略位置
StaticToolCallbackResolver构造时按名字灌进一个 HashMapresolve 就是一次 getspring-ai-model/src/main/java/org/springframework/ai/tool/resolution/StaticToolCallbackResolver.java:42-55
DelegatingToolCallbackResolver按顺序问一串 resolver,第一个非 null 即返回spring-ai-model/src/main/java/org/springframework/ai/tool/resolution/DelegatingToolCallbackResolver.java:44-54

DefaultToolCallingManager 的默认 resolver 是一个空的 delegating(spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java:77-78),即"默认只认 options 里的工具"。工具检索与 MCP 相关的 resolver 见第 6 章


4. 执行侧:executeToolCalls 逐个 tool call 干了什么

这节讲什么: 模型返回了一串 ToolCall,框架怎么把它们变成一条 ToolResponseMessage。全部在 spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java

4.1 入口三步

executeToolCalls:147-174)本身很短:

  1. ChatResponse.getResults() 里找第一个带 tool call 的 generation;一个都没有直接抛 IllegalStateException("No tool call requested by the chat model"):151-158)。注意这里是 findFirst——多候选(n>1)的场景只处理第一条。
  2. buildToolContext:176-185):从 options 里把 toolContext map 拷出来包成 ToolContext
  3. 执行完之后 buildConversationHistoryAfterToolExecution:314-320)拼历史,连同 returnDirect 打包成 ToolExecutionResult

4.2 每个 ToolCall 的七道关卡

主循环在 executeToolCall:190-312),对 assistantMessage.getToolCalls() 逐个走下面七步:

ToolCall(id, type, name, arguments)

├─⓪ 工具调用次数限额检查(ToolCallLimits) :214-238
│ 超限且 THROW → 抛 ToolCallLimitExceededException
│ 超限且 RETURN_ERROR_RESPONSE → 把错误文本当工具结果,跳过该工具
├─① 参数为空? ──是──→ 兜底成 "{}" :242-253
├─② 先在 options.toolCallbacks 里按名找 :255-258
│ 找不到 → resolver.resolve(name)
├─③ 还是 null? → 打"LLM 可能改名了"warn
│ + 抛 IllegalStateException :260-266
├─④ returnDirect 折叠:第一个直接取,后续 AND :268-273
├─⑤ 包一层 TOOL_CALL observation 再执行 :275-304
└─⑥ 抛 ToolExecutionException → 交给 processor :299-301

⓪ 是 2.0.1 新增的护栏(spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallLimits.java):manager 会扫描当前这一轮(turn)历史里已执行过的 ToolResponseMessage 重建计数(countPriorToolCalls:81),再按"每工具默认上限 40、整轮总上限 150"检查(DefaultToolCallingManager.java:97:104,默认接线在 :116-121,行为 ToolCallLimitBehavior.THROW)。超限时 THROW 会抛出携带部分结果的 ToolCallLimitExceededException,由 ToolCallingAdvisor 在循环里捕获并直接把越限信息作为最终答案返回应用(见 §5.2);上限可用 DefaultToolCallingManager.builder()maxCallsPerTool / unlimitedCallsPerTool / excludeToolFromLimit 等调整。

逐条说清楚:

① 空参数兜底成 {} 流式模式下模型可能给出 null 或空串的 arguments,直接扔给 call() 会踩 Assert.hasText。这里打一条 warn 后用 "{}" 顶上(:242-253),注释明说是为了 streaming。

② 两级查找,顺序有意义。 先在本次请求的 options 里按名字线性匹配,orElseGet 才落到 resolver(:255-258)。含义是:调用点显式挂的工具优先于全局注册的同名工具

③ 找不到时的措辞值得单独看。 抛异常之前先打一条特殊 warn(常量在 :83-85):

LLM may have adapted the tool name '…', especially if the name was truncated due to length limits.

这是踩过坑之后留下的诊断线索——模型(尤其面对超长的 MCP 前缀工具名)会自己截断或改写工具名,导致按名字查不到。warn 里直接点名 McpToolNamePrefixGenerator 作为解决方向。

returnDirect 是 AND 折叠,不是 OR。 第一个工具决定初值,后续每个都 returnDirect = returnDirect && …:268-273)。语义:只有本轮所有被调用的工具都声明了 returnDirect=true,结果才直接回给应用;只要有一个需要回给模型,就整轮都回给模型。 这是保守但正确的选择——否则会丢掉一部分工具结果。全程 returnDirectBoolean 包装类,末尾 Objects.requireNonNullElse(returnDirect, false):311)处理"一个工具都没执行"的边界。

⑤ 每次调用包一层 observation。ToolCallingObservationDocumentation.TOOL_CALL 起一个观测(:282-304),上下文里带工具定义、metadata、call id、type、参数,执行完把结果也 set 进去。父观测的取法很讲究(:279-280):

Observation parent = ToolCallReactiveContextHolder.getContext()
.getOrDefault(ObservationThreadLocalAccessor.KEY, this.observationRegistry.getCurrentObservation());

注释解释了为什么要两条路(:275-278):流式/响应式模式下父观测藏在 Reactor context 里(由 ToolCallReactiveContextHolder 这个 ThreadLocal 桥接,spring-ai-model/src/main/java/org/springframework/ai/model/tool/internal/ToolCallReactiveContextHolder.java:30-48);阻塞模式下这个 holder 从没被写过,只能退回当前线程上的 current observation。少了任一条,观测树就会断成两截。可观测性全貌见第 6 章

⑥ 业务异常不冒泡,变成给模型的文本。 catch (ToolExecutionException ex) 之后交给 toolExecutionExceptionProcessor.process(ex),返回值当成工具结果继续走(:299-301)。默认实现 DefaultToolExecutionExceptionProcessor.processspring-ai-model/src/main/java/org/springframework/ai/tool/execution/DefaultToolExecutionExceptionProcessor.java:58-85)的三条规则:

情况行为
cause 在 rethrowExceptions 允许名单里解包原样抛出
cause 不是 RuntimeException(如 IOExceptionOutOfMemoryError直接抛 ToolExecutionException
其余,且 alwaysThrow=false(默认)返回异常消息文本,让模型看见错误并自行重试或改口

最后一条是这套设计的精髓:工具报错默认不是应用崩溃,而是给模型的一句反馈

4.3 回灌:历史长出两条消息

buildConversationHistoryAfterToolExecution:314-320)只有三行:拷贝原有 instructions,追加 AssistantMessage(模型那句"我要调工具"),再追加 ToolResponseMessage(所有工具的结果)。

[system, user] ← 第 1 轮送进去的
[system, user, assistant(toolCalls), toolResponse] ← 第 2 轮送进去的

ToolResponseMessage.ToolResponse 三元组是 (id, name, responseData),其中 id 必须是模型给的那个 call id(:306-307),否则供应商无法把结果和请求对上。结果为 null 时用空串兜底。


5. 循环侧:2.0 把循环搬进了 advisor 链

这节讲什么: 谁来决定"再问模型一轮",以及这个决定在 2.0 里搬了家之后带来的连锁设计。

5.1 改动的形状

关注点2.0 的位置证据
工具定义送进请求仍在 ChatModelOpenAiChatModel.java:879 只调 resolveToolDefinitions
执行一批 tool callToolCallingManagerDefaultToolCallingManager.java:147
决定要不要再来一轮ToolCallingAdvisor(advisor 链上)ToolCallingAdvisor.java:149-216 的 do-while
旧的 ToolCallAdvisor@Deprecated(since="2.0.0", forRemoval=true),只是空壳继承spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallAdvisor.java:24-34

搬家换来的东西,类注释自己写了(ToolCallingAdvisor.java:47-56):循环体在 advisor 链内部,所以链上后续的 advisor 可以拦截每一轮工具调用——日志、限流、审计、逐轮改 prompt 都成了普通 advisor 的活儿。

ChatClient 会自动注册它,不用手写(spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java:1217-1238):除非显式关掉自动注册、或链上已有 ToolAdvisor。另有 validateSingleToolAdvisor:1240-1248)保证链上最多一个 ToolAdvisor

5.2 do-while 主循环

adviseCallspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java:123-220):

options 不是 ToolCallingChatOptions? ──是──→ 直接 nextCall 透传 :127-131
│否
doInitializeLoop :133

┌──→ 用当前 instructions 重建 Prompt :149-155
│ ↓ doBeforeCall :158
│ callAdvisorChain.copy(this).nextCall(...) :167
│ ↓ doAfterCall :169
│ usageAccumulator.addRoundResponse(chatResponse) :173
│ ↓
│ 还有 toolCalls? ──否──→ 出循环 :174
│ │是
│ executeToolCalls :181
│ ↓
│ 工具次数超限(ToolCallLimitExceededException)? ──是──→ break :184-194
│ │否
│ returnDirect? ──是──→ buildGenerations + break :198-209
│ │否
└── instructions = doGetNextInstructionsForToolCall :211

applyAccumulatedUsage → doFinalizeLoop :218-219

一个容易忽略的入口分支:模型不支持工具调用(options 不是 ToolCallingChatOptions)时,这个 advisor 变成一根直通管:127-131),什么都不做地转发。所以把它放进默认链是安全的。

5.3 copy(this).nextCall 为什么是循环的关键

普通 advisor 调 nextCall 是"往下走一步",走完这条链就结束了。要循环,就得每轮都拿到一条不含自己的新链

chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest); // :167

copy(CallAdvisor after) 的实现是 copyAdvisorsAfterspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java:169-195):在原始 advisor 列表里找到自己的下标,取 subList(idx+1, size) 建一条全新的链。不含自己 → 不会无限递归;每轮新建 → 每轮下游 advisor 都从头跑一遍。

5.4 order = HIGHEST_PRECEDENCE + 300 说明了什么

public static final int DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 300; // :75

对照 Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = HIGHEST_PRECEDENCE + 200spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/Advisor.java:39),差值 100 是有意的(注释在 :69-74):

请求方向 ↓(order 越小越靠外)

[MemoryAdvisor order = +200] ← 循环之外:整次调用只进出一次
[ToolCallingAdvisor order = +300] ← 循环的主人
┌──── 循环体 = copy(this) 之后的那条链 ────┐
│ [其它 advisor …] 每轮都会跑一遍 │
│ [ChatModelCallAdvisor] → 真正打模型 API │
└───────────────────────────────────────────┘

默认位置让记忆型 advisor 待在循环外,不然每一轮工具调用都会往聊天记忆里写一遍中间态。构造函数还断言 order 必须严格落在 HIGHEST_PRECEDENCELOWEST_PRECEDENCE 之间(:100-101),避免有人把它排到边界上。Advisor 排序模型见第 2 章

5.5 "还要不要再来一轮":ToolExecutionEligibilityChecker

判定被抽成一个函数式接口 Function<ChatResponse, Boolean>spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolExecutionEligibilityChecker.java:31-40),默认实现就一句:

chatResponse -> chatResponse != null && chatResponse.hasToolCalls(); // ToolCallingAdvisor.java:77-78

抽出来的意义在 builder 的注释里(:524-530):供应商各自的 stop reason 语义不同,可以换掉这个 checker 用 finish reason 判定,而不是只看有没有 toolCalls。

5.6 returnDirect:中断循环,把工具输出当成回答

命中 returnDirect 时(:198-209)不再问模型,而是用 ToolExecutionResult.buildGenerations 把工具结果伪装成模型回答返回:

ChatResponse.builder().from(chatResponse)
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) // :205

buildGenerationsspring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolExecutionResult.java:67-84)取历史最后一条 ToolResponseMessage,把每条工具响应包成一个 Generation,其 AssistantMessage 内容就是工具原始输出,metadata 带上 toolId/toolName,finish reason 固定为字符串 "returnDirect":36)。所以应用侧拿到的仍是标准 ChatResponse,只是内容没经过模型润色——省一次 token,也保证输出逐字不被改写(适合返回 JSON、工单号这类不能被模型改动的内容)。

5.7 UsageAccumulator:多轮 token 要汇总

一轮循环打了 3 次模型,如果只报最后一次的 usage,账就错了。UsageAccumulatorspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/UsageAccumulator.java:62-114)负责把每轮折叠进一个累计值:

方法时机作用
addRoundResponse每轮拿到响应后(:83-86把本轮 usage 折进累计
applyAccumulatedUsage非流式循环收尾、流式 returnDirect(:96-98把累计总量盖到最终响应上
applyPreviousAccumulatedUsageToChunk流式每个 chunk(:126-142自带 usage 的 chunk 加上之前各轮的量
emitFinalUsageCorrectionIfNecessary流式最后一轮无 usage 时(:157-171补发一条只带 usage、generations 为空的响应

最后一条是流式独有的边角:最后一轮如果自己没报 usage,之前几轮的累计就没机会盖到任何已发出的 chunk 上,于是补一条空内容的 usage-only 响应,既不重复内容又不丢账(注释在 :145-155)。类文档还特别声明它有状态且非线程安全,必须每次 adviseCall 一个、或每个流订阅一个(放在 Flux.defer 里,见 ToolCallingAdvisor.java:269-276)。

5.8 conversationHistoryEnabled:和 MemoryAdvisor 的互斥

循环每一轮的 instructions 由 doGetNextInstructionsForToolCall 决定(ToolCallingAdvisor.java:222-234):

conversationHistoryEnabled下一轮送什么谁负责完整历史
true(默认)toolExecutionResult.conversationHistory() 全量ToolCallingAdvisor 自己
false只留 system message + 历史最后一条:230下游的 MemoryAdvisor

为什么要有 false 这条路?因为如果链上有一个排在它下游的 memory advisor,那个 advisor 每轮都会把完整历史重新拼一遍;此时 ToolCallingAdvisor 再送一份全量历史就会重复。所以只送"system + 最新的那条 ToolResponseMessage",让 memory advisor 补齐前面的部分。

这个判断是自动做的(DefaultChatClient.java:1230-1237):

boolean hasDownstreamMemoryAdvisor = this.advisors.stream()
.anyMatch(a -> a instanceof MemoryAdvisor && a.getOrder() > configuredOrder);
this.advisors.add(this.toolCallingAdvisorBuilder.copy()
.conversationHistoryEnabled(!hasDownstreamMemoryAdvisor).build());

注意这里用了 builder.copy()ToolCallingAdvisor.java:603-611)——per-call 覆写不能污染共享的模板 builder。记忆机制本身见第 5 章

5.9 流式侧:不是循环,是递归

流式没法用 do-while(每轮是一条 Flux,要等它 complete 才知道有没有 tool call),于是改成递归(ToolCallingAdvisor.java:258-404):

adviseStream
└─ Flux.defer ── doInitializeLoopStream + 每订阅一个 UsageAccumulator :269-276
└─ internalStream :274
├─ 重建 Prompt + doBeforeStream + chain.copy(this).nextStream :285-297
└─ streamWithToolCallResponses :311
├─ aggregateChatClientResponse:边转发 chunk 边聚合成整条 :319
├─ 给 chunk 补上之前各轮的 usage :320
├─ concatWith(handleToolCallRecursion) ← 流结束后才跑 :322
└─ filter:带 toolCalls 的响应不外泄给应用 :324

handleToolCallRecursion:331-404)在上游流结束后拿聚合结果做判断:

  • 没有 tool call → 走 emitFinalUsageCorrectionIfNecessary 补账,再交给 doFinalizeLoopStream 收尾(:345-354)。
  • 有 tool call → 在 Flux.deferContextual 里执行工具,执行前后一对 try/finally 把 Reactor context 写进/清出 ToolCallReactiveContextHolder:360-383)——这就是 §4.2 第 ⑤ 条里父观测的来源。
  • returnDirect → 同 §5.6,只是额外盖上累计 usage(:385-394)。
  • 工具次数超限 → 同步侧一样把越限信息直接回给应用(:367-380)。
  • 否则internalStream(...) 递归下一轮(:399-400)。

两个工程细节:整段 toolCallFlux.subscribeOn(Schedulers.boundedElastic()):403),因为工具执行是阻塞的,不能占着响应式线程;.filter(...):324)保证带 tool call 的中间响应不会漏给应用——用户不该看见"我要调工具"这类内部消息。

5.10 四个钩子:子类的扩展点

阻塞侧四个 protected 空实现(默认原样返回):

钩子触发时机位置
doFinalizeLoop出循环后一次:236-239
doInitializeLoop进循环前一次:241-244
doBeforeCall每轮打模型前:246-248
doAfterCall每轮拿到响应后:250-252

流式侧对称地有 doInitializeLoopStream / doBeforeStream / doAfterStream / doFinalizeLoopStream:413-452),外加两个可覆写的历史策略 doGetNextInstructionsForToolCall / …Stream

配套的是 builder 的自引用泛型 Builder<T extends Builder<T>> + self() + newCopy():492-621):子类可以加自己的字段并保持链式调用的返回类型,copy() 也能保住子类型。这套模板方法 + 可扩展 builder 的组合,就是"自定义 agent 循环"的官方扩展面——继承它、覆写钩子,就能在每一轮工具调用前后插入自己的逻辑(步数上限、审批、轨迹记录)。


6. 巧妙之处(可带走的技术)

1. 用"默认必填"换跨供应商的确定性。 所有属性默认进 required,用注解显式退出(JsonSchemaGenerator.java:87PROPERTY_REQUIRED_BY_DEFAULT + :232-256 的判定链)。可选参数在各家模型上的行为差异太大,与其赌,不如统一收紧。

2. 异常分三类走三条路。 业务异常 → 变成给模型的文本让它自愈;非 RuntimeException → 直接抛(IOExceptionOutOfMemoryError 这类不该让模型"看着办");允许名单里的 → 原样抛给应用(DefaultToolExecutionExceptionProcessor.java:58-85)。

3. returnDirect 用 AND 折叠而不是 OR。 混合工具时保守取"回给模型",避免丢结果(DefaultToolCallingManager.java:268-273)。

4. 用 copy(this) 把责任链掰成循环。 复用现成的 advisor 链基础设施做多轮,代价只是每轮建一条 subList 链(DefaultAroundAdvisorChain.java:178-195)。

5. 观测父节点的双路兜底。 一条走 Reactor context,一条走线程当前观测,保证阻塞和流式两种模式下 trace 都不断(DefaultToolCallingManager.java:275-280)。

6. 把"要不要再来一轮"抽成一个 Function 让供应商特有的 stop-reason 语义有地方落(ToolExecutionEligibilityChecker.java:31)。

7. 诊断信息写在最痛的地方。 工具名找不到时那条 warn 直接指向"LLM 可能截断/改写了工具名"和 McpToolNamePrefixGeneratorDefaultToolCallingManager.java:83-85)——这是把排障经验固化进代码。


7. 边界与局限(诚实)

  • 循环步数上限在 manager 而不在 advisor。 adviseCall 的 do-while 本身仍只有"模型不再点名工具"和 returnDirect 两个自然出口(ToolCallingAdvisor.java:149-216);但 2.0.1 起 DefaultToolCallingManager 会按"每工具 40 次、整轮 150 次"的默认限额拦截(见 §4.2 第 ⓪ 条),超限默认直接终止本轮。要按"轮数"而非"工具调用次数"设限,仍得继承 advisor 覆写钩子。
  • 同一轮内工具是串行执行的。 executeToolCall 是一个普通 for 循环(DefaultToolCallingManager.java:206-308),没有并行。模型一次点了 5 个工具,总耗时是 5 个之和。
  • 多候选(n>1)只处理第一条。 executeToolCallsfindFirst 取带 tool call 的 generation(:151-154),其余候选里的 tool call 不会被执行。
  • 参数对齐依赖编译保留形参名。 buildMethodArgumentsparameter.getName() 取值(MethodToolCallback.java:153),没开 -parameters 就会变成 arg0/arg1 而对不上 schema。
  • 工具名不合规只 warn 不拦。 带空格或括号的名字能一路走到供应商那里再报错(ToolUtils.java:128-134)。
  • ToolContext 一旦被方法声明就必须非空。 validateToolContextSupport 会在缺上下文时抛 IllegalArgumentExceptionMethodToolCallback.java:125-132),而不是传 null 进去。
  • 默认 resolver 是空的。 不接 MCP / bean resolver 时,options 里没有的工具名一律走到"找不到"分支(DefaultToolCallingManager.java:77-78)。

8. 代码地图(导航索引)

主题文件路径(相对克隆根)符号名
工具注解spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/Tool.javaToolname / description / returnDirect / resultConverter
参数注解spring-ai-model/src/main/java/org/springframework/ai/tool/annotation/ToolParam.javaToolParamrequired / description
名字/描述/returnDirect 提取spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolUtils.javagetToolName · getToolDescription · getToolReturnDirect · getToolCallResultConverter · validateToolName · RECOMMENDED_NAME_PATTERN
定义组装spring-ai-model/src/main/java/org/springframework/ai/tool/support/ToolDefinitions.javabuilder(Method) · from(Method)
定义数据结构spring-ai-model/src/main/java/org/springframework/ai/tool/definition/DefaultToolDefinition.javaDefaultToolDefinition · Builder.build
方法入参 schemaspring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.javagenerateForMethodInput · isMethodParameterRequired · forbidAdditionalProperties · processSchemaOptions · SchemaOption · PROPERTY_REQUIRED_BY_DEFAULT
嵌套 POJO 字段规则spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/SpringAiSchemaModule.javaresolveToolParamDescription · resolveToolParamRequired
上述规则的共用实现spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/AbstractSpringAiSchemaModule.javacheckRequired · Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT
可执行工具接口spring-ai-model/src/main/java/org/springframework/ai/tool/ToolCallback.javaToolCallback.call
反射执行spring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallback.javacall · extractToolArguments · buildMethodArguments · buildTypedArgument · callMethod · validateToolContextSupport
函数式工具spring-ai-model/src/main/java/org/springframework/ai/tool/function/FunctionToolCallback.javacall · callMethod · Builder.inputType
批量扫描 @Toolspring-ai-model/src/main/java/org/springframework/ai/tool/method/MethodToolCallbackProvider.javagetToolCallbacks · isFunctionalType · assertToolAnnotatedMethodsPresent · validateToolCallbacks
一行式入口spring-ai-model/src/main/java/org/springframework/ai/support/ToolCallbacks.javafrom
按名解析spring-ai-model/src/main/java/org/springframework/ai/tool/resolution/DelegatingToolCallbackResolver.java · StaticToolCallbackResolver.javaresolve
结果转换spring-ai-model/src/main/java/org/springframework/ai/tool/execution/DefaultToolCallResultConverter.javaconvert
异常处理策略spring-ai-model/src/main/java/org/springframework/ai/tool/execution/DefaultToolExecutionExceptionProcessor.javaprocess · Builder.alwaysThrow · Builder.rethrowExceptions
执行侧核心spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.javaexecuteToolCalls · executeToolCall · buildToolContext · buildConversationHistoryAfterToolExecution · POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING_START
工具调用次数限额spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolCallLimits.javacountPriorToolCalls · check(配套 ToolCallLimitBehavior / ToolCallLimitExceededException
执行结果spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolExecutionResult.javabuildGenerations · FINISH_REASON
循环终止判定spring-ai-model/src/main/java/org/springframework/ai/model/tool/ToolExecutionEligibilityChecker.javaisToolCallResponse
响应式上下文桥spring-ai-model/src/main/java/org/springframework/ai/model/tool/internal/ToolCallReactiveContextHolder.javasetContext · getContext · clearContext
循环侧核心spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.javaadviseCall · adviseStream · internalStream · streamWithToolCallResponses · handleToolCallRecursion · doGetNextInstructionsForToolCall · DEFAULT_ORDER · doInitializeLoop / doBeforeCall / doAfterCall / doFinalizeLoop
token 汇总spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/UsageAccumulator.javaaddRoundResponse · applyAccumulatedUsage · applyPreviousAccumulatedUsageToChunk · emitFinalUsageCorrectionIfNecessary
自动注册与互斥判定spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.javaautoRegisterToolCallingAdvisor · validateSingleToolAdvisor · tools(Object...)
链复制(循环的机制)spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.javacopy · copyAdvisorsAfter
观测埋点spring-ai-model/src/main/java/org/springframework/ai/tool/observation/ToolCallingObservationDocumentation.javaTOOL_CALL

继续读: 工具定义怎么被翻成各家供应商的线上格式 → 可移植模型层;工具从 MCP 服务器来、以及工具太多时的检索 → Boot 集成、可观测性与 MCP