源码小说CodeStory
登录注册
上一章

第六章 · 招募差役:ToolCallback 与 @Tool

下一章
字体

主题

版式

12,015 字 · 约 30 分钟

卷三 · 差遣(⼯具调⽤)

第六章 · 招募差役:ToolCallback 与 @Tool

"我只是⼀纸契约:告诉你我叫什么、能⼲什么、要什么⼊参;⾄于活⼉怎么⼲,那是我背后那个⼈的事。" —— ToolCallback 的⾃⽩前两卷,我们看的是译馆怎么"说话"——递国书、收回函、⾛驿丞、查藏经阁。可有⼀类活⼉,光靠嘴⽪⼦办不成。客官问"今天巴黎⼏度?"——模型再博学也不会实时测温;客官说"把这张⼯单标成已关闭"——模型再能写也碰不到你的数据库。

这时候,模型得差遣⼀个跑腿的。它不亲⾃去办,它只是举⼿喊⼀声:"我需要⼀个叫 getWeather 的差役,参数是城市名。"然后把活⼉甩给译馆。译馆得有⼀⽀招之即来、办完即回的差役队伍。

本章就讲这⽀队伍是怎么招募、怎么登记造册的。注意⼀个边界:隐喻⾥"差役听模型差遣"会让⼈以为模型在直接指挥⼯具——它没有。模型只能"申请派差",真正点名、执⾏、回填的是译馆这边的调度逻辑(那是下⼀章的事)。本章我们只盯住⼀件更基础的事:⼀个差役,到底是个什么东⻄?

⼀、差役的卖身契: ToolCallback 只认三件事打开 ToolCallback ,你会被它的"瘦"吓⼀跳。⼀个⽀撑起整个 Spring AI ⼯具体系的核⼼抽象,正经⽅法只有三个:

//给模型看的定义:名字/描述/⼊参 schema

ToolDefinition getToolDefinition();

//额外元数据(⽬前主要是 returnDirect),默认空

default ToolMetadata getToolMetadata() {
return ToolMetadata.builder().build();
}

//执⾏:JSON进, String出

String call(String toolInput);

spring-ai-model/.../tool/ToolCallback.java:40,45,53⼀份差役的卖身契,就这么三栏:你是谁( getToolDefinition ,给模型看的名⽚)、你有什么脾性( getToolMetadata ,⽐如办完是否直接回话)、怎么使唤你( call ,递⼀张纸条进去,拿⼀张纸条出来)。

最值得停⼀下的是 call 的签名:⼊参是 String ,返回也是 String ,⽽且约定都是 JSON ⽂本。为什么不直接⽤强类型 Objectcall(Map<String,Object>) ?因为差役要对接的是番邦的模型。模型那头吐出来的 tool call 参数,本来就是⼀串 JSON 字符串;办完了要回灌给模型的结果,也得是模型能读的⽂本。两头都是字符串,中间这层抽象就必须以字符串为"普通话"——这是为了和 LLM 的现实接⼝对⻬,不是设计者偷懒。

还有第四个⽅法,⼀个默认实现:

default String call(String toolInput, @Nullable ToolContext toolContext) {
if (toolContext != null && !toolContext.getContext().isEmpty()) {
if (logger.isInfoEnabled()) {
logger.info("By default the tool context is not used, ...");
}
}
return call(toolInput);
}

tool/ToolCallback.java:59-68

ToolContext 是给差役"开后⻔"⽤的——模型看不到、也不该看到的业务上下⽂(当前登录⽤户、租户 ID、本次会话句柄……)。模型只会按 schema 填它该填的参数,⽽这些"机密"由译馆从侧⻔塞进来。默认实现选择⽆视它,只在你"塞了东⻄却没⼈接"时打条 info ⽇志提醒你:喂,你给的上下⽂被丢了,想⽤就重写带ToolContext 的那个 call 。

🦴 ⻣灰级细节: logger 是声明在 interface 上的 static 字段( ToolCallback.java:35 ),所有实现类共享同⼀个以 ToolCallback.class 命名的

logger。Java 8 之后接⼝允许 static 字段+ default ⽅法,Spring AI 这⾥就让"如何处理被忽略的 ToolContext"这条策略内建进契约本身——任何不重写的实现都⾃动获得"丢弃+提醒"⾏为,不必各⾃抄⼀遍。瘦接⼝,胖默认。

⼆、两种出身的差役契约只有⼀份,但能签这份契约的"⼈"有两种出身。Spring AI 给了两套内建实现:⼀种是从现成业务⽅法招安的( MethodToolCallback ),⼀种是临时雇个lambda的( FunctionToolCallback )。先看前者,它是 @Tool 注解背后真正⼲活的家伙。

2.1 反射出身: MethodToolCallback它持有两样东⻄——⼀个 Method ,⼀个 Object :

private final Method toolMethod;
private final @Nullable Object toolObject;

tool/method/MethodToolCallback.java:64,66toolObject 为什么可空?因为静态⽅法没有"this"。构造器⾥那句 Assert 把规矩写死了:⾮静态⽅法必须有对象,静态⽅法可以为 null( MethodToolCallback.java:74-75 )。

call(String, ToolContext) 是整章最该逐帧慢放的⼀段。它把"⼀串 JSON"变成"⼀次真实的⽅法调⽤",中间⾛了四步:

this.validateToolContextSupport(toolContext); // 1.校验
Map<String, Object> toolArguments = this.extractToolArguments(toolInput); // 2. JSON→Map
Object[] methodArguments = this.buildMethodArguments(toolArguments, toolContext); // 3.拼实参
Object result = this.callMethod(methodArguments); // 4.反射 invoke

// ...

return this.toolCallResultConverter.convert(result, returnType); // 5.转回 String

MethodToolCallback.java:107-122

第⼆步把模型给的 JSON 反序列化成 Map<String,Object> ,失败就包成 ToolExecutionException ( L134-144 )。

第三步最⻅功夫。它遍历⽅法的每个参数,做⼀次"对号⼊座":

return Stream.of(this.toolMethod.getParameters()).map(parameter -> {
if (parameter.getType().isAssignableFrom(ToolContext.class)) {
return toolContext; // ToolContext直接注⼊
}
Object rawArgument = toolInputArguments.get(parameter.getName()); //按参数名取值
return buildTypedArgument(rawArgument, parameter.getParameterizedType());
}).toArray();

MethodToolCallback.java:148-156看⻅ parameter.getName() 没有?这是整个 method-based ⼯具体系的命⻔——它靠参数的真实名字去 Map ⾥取值。

⚠ 暗礁:Java 默认编译会把⽅法参数名擦成 arg0 、 arg1 。如果你的⼯具⽅法编译时没开 -parameters ,这⾥ parameter.getName() 拿到的就是 arg0 ,⽽模

型按 schema 填的 key 是 city , toolInputArguments.get("arg0") 必然取到 null ——差役接到⼀张空⽩⼯单,还不报错( buildTypedArgument ⻅ null

直接返回 null , L159-160 )。这个坑的阴险之处在于:它不抛异常,只是默默把参数喂成 null。Spring Boot 的插件默认替你开了 -parameters ,可⼀旦你脱离starter 裸⽤ spring-ai-model 、或⾃⼰搭构建链,这就是头号悬案。整套"声明式⼯具"的优雅,押在⼀个编译开关上。

第四步才真正动⼿反射:

if (isObjectNotPublic() || isMethodNotPublic()) {
this.toolMethod.setAccessible(true);
}

// ...

result = this.toolMethod.invoke(this.toolObject, methodArguments);

// ...

catch (InvocationTargetException ex) {
throw new ToolExecutionException(this.toolDefinition, ex.getCause());
}

MethodToolCallback.java:180-192两个细节:其⼀,对象或⽅法⾮ public 时主动 setAccessible(true) ——所以你的 @Tool ⽅法写成 private 也能跑(虽然不推荐)。其⼆,反射 invoke 抛的是InvocationTargetException (它把业务⽅法真正的异常裹了⼀层),这⾥解包,只把⾥头那个真实 cause 重新包成 ToolExecutionException 。如果不解包,模型拿到的将是⼀坨毫⽆意义的"反射调⽤失败",⽽不是"城市不存在"这种它能据以⾃纠的信息。

第五步, ToolCallResultConverter 把返回对象转回 String(默认实现: void 返回 "Done" 、 RenderedImage 转 base64 PNG、其余⾛ JSON)。⾄此,⼀串

JSON 进、⼀串 String 出,中间是⼀次有⾎有⾁的⽅法调⽤。

还有个第⼀步我们跳过了,补上——它是个守⻔⼈:

if (isToolContextAcceptedByMethod && !isNonEmptyToolContextProvided) {
throw new IllegalArgumentException("ToolContext is required by the method as an argument");
}

MethodToolCallback.java:129-131如果你的⽅法签名⾥声明要 ToolContext ,但调⽤时根本没传(或传了空),它当场翻脸抛异常。这是⼀种"契约前置校验":别等到反射 invoke 时塞个 null 进去引发更晦涩的 NPE,趁早在⻔⼝拦下。

2.2 函数出身: FunctionToolCallback第⼆种差役不需要⼀个"对象+⽅法",它直接雇⼀段函数。核⼼字段是⼀个 BiFunction :

private final BiFunction<@Nullable I, @Nullable ToolContext, O> toolFunction;

tool/function/FunctionToolCallback.java:67它的 call ⽐反射那套清爽得多——没有"按名取参"的体操,因为整个输⼊就是⼀个 POJO:

I request = jsonHelper.fromJson(toolInput, this.toolInputType); //整串 JSON反序列化成⼀个对象
O response = callMethod(request, toolContext); //直接 apply

// ...

return this.toolCallResultConverter.convert(response, null);

FunctionToolCallback.java:108-115

✨ 爽点:它真正聪明的地⽅在⼯⼚⽅法。 BiFunction<I, ToolContext, O> 是内部统⼀形态,但你⽇常写⼯具谁会拿这么别扭的双参函数?于是它准备了三个适配

器,把 Function / Supplier / Consumer 都"翻译"成这个统⼀形态:

// Function:补⼀个被忽略的 context形参

return new Builder<>(name, (request, context) -> function.apply(request));

// Supplier:没有输⼊,强制把输⼊类型钉成 Void

Function<Void, O> function = input -> supplier.get();
return builder(name, function).inputType(Void.class);

// Consumer:有输⼊没输出,包⼀层返回 null

Function<I, Void> function = (I input) -> { consumer.accept(input); return null; };
return builder(name, function);

FunctionToolCallback.java:147-172这是教科书级的 Adapter 模式:四种函数式接⼝(BiFunction/Function/Supplier/Consumer),对外四个⼊⼝,对内收敛成⼀种 BiFunction 。 Supplier 那句inputType(Void.class) 尤其点睛——⼀个不要参数的⼯具,它的 inputSchema 就该是个"空对象",⽽不是缺失。

最关键的分⽔岭在 build() :

.inputSchema(StringUtils.hasText(this.inputSchema) ? this.inputSchema
: JsonSchemaGenerator.generateForType(this.inputType))

FunctionToolCallback.java:234-235注意是 generateForType(inputType) ——schema 从你声明的输⼊ POJO 类型推导。这正是它和 method-based 的根本差异:method-based 的 schema 来⾃⽅法签名+ @ToolParam 注解,⽽ function-based 没有 @ToolParam 可挂,schema 只能整体从那个输⼊类型⽣成。所以⽤ FunctionToolCallback 时,你必须⼿

动 builder(name, fn).inputType(...) 把类型告诉它( build() 开头那句 Assert.notNull(this.inputType, ...) 会在你忘了时当场拦下, L229 )。

⼀句话对⽐:method-based 是"给现成业务⽅法贴个注解就能⽤",function-based 是"为 lambda 现场签⼀份契约"。前者省事在声明,后者灵活在注册。

三、 @Tool 是怎么变成⼀张"差役名⽚"的讲到这⾥,你可能还没看⻅ @Tool 出场。因为它根本不参与运⾏时执⾏——它只是⼀份招募启事,在装配阶段被扫描、被翻译成 ToolDefinition 。这⼀节看这条"从注解到名⽚"的流⽔线。

3.1 谁来扫描: MethodToolCallbackProvider你把⼀个挂满 @Tool ⽅法的 bean 交给它,它反射这个类的所有⽅法,层层过滤,把合格的造成 MethodToolCallback :

Stream.of(ReflectionUtils.getDeclaredMethods(
AopUtils.isAopProxy(toolObject) ? AopUtils.getTargetClass(toolObject) : toolObject.getClass()))
.filter(this::isToolAnnotatedMethod) //必须有 @Tool
.filter(toolMethod -> !isFunctionalType(toolMethod)) //排除返回 Function/Supplier/Consumer的
.filter(ReflectionUtils.USER_DECLARED_METHODS::matches)
.map(toolMethod -> MethodToolCallback.builder()
.toolDefinition(ToolDefinitions.from(toolMethod))
.toolMetadata(ToolMetadata.from(toolMethod))
.toolMethod(toolMethod)
.toolObject(toolObject)
.toolCallResultConverter(ToolUtils.getToolCallResultConverter(toolMethod))
.build())

tool/method/MethodToolCallbackProvider.java:88-100三个 filter 各有讲究。 isToolAnnotatedMethod 好理解。 isFunctionalType 有意思——⼀个 @Tool ⽅法如果返回的是 Function / Supplier / Consumer ,会被跳过并打 warn( L110-123 ):这种⽅法是"注册⼀个函数式⼯具"的⽼写法,不该当成普通⼯具⽅法直接反射调⽤。第⼀句的 AopUtils.getTargetClass 也别⼩看:如果你的⼯具 bean 被 Spring 包成了 AOP 代理(⽐如加了 @Transactional ),直接反射代理类是扫不到注解的,必须拿⽬标类。

🦴 ⻣灰级细节:扫完之后有⼀道 validateToolCallbacks ,它对重名零容忍:

List<String> duplicateToolNames = ToolUtils.getDuplicateToolNames(toolCallbacks);
if (!duplicateToolNames.isEmpty()) {
throw new IllegalArgumentException("Multiple tools with the same name (%s) found in sources: %s"...);
}

MethodToolCallbackProvider.java:130-137⼯具名是模型点差役的唯⼀凭据,两个差役同名,模型⼀喊就歧义——所以这⾥宁可启动失败也不许带病上岗。这是个快速失败的好设计:把"运⾏时模型乱点鸳鸯谱"的隐患,提前到装配期暴露。

3.2 名⽚三要素: ToolDefinitions.from

toolDefinition(ToolDefinitions.from(toolMethod)) 这⼀句背后,是名⽚三栏的提取:
return DefaultToolDefinition.builder()
.name(ToolUtils.getToolName(method))
.description(ToolUtils.getToolDescription(method))
.inputSchema(JsonSchemaGenerator.generateForMethodInput(method));

tool/support/ToolDefinitions.java:49-52name: @Tool.name 优先,没填就⽤⽅法名( ToolUtils.java:57-69 )。取完还要过⼀道正则校验:

private static final Pattern RECOMMENDED_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_\\.-]+$");

ToolUtils.java:52

⚠ 暗礁(顺带⼀处批评):这个校验只 warn,不 reject——看 validateToolName 的实现,名字不合规时它只是 logger.warn(...) ⼀句"may not be compatible

with some LLMs",然后照样放⾏( ToolUtils.java:128-134 )。更微妙的是,这条 warn 还裹在 if (logger.isWarnEnabled()) ⾥:⽣产环境若把⽇志级别调到

ERROR,这条唯⼀的提醒会被彻底吞掉。于是⼀个带空格或中⽂的⼯具名,本地起服务时可能连⽇志都没⼈看,直到对接 OpenAI 那头报 400 才暴雷。把"会不会触发番邦报错"这种硬约束,降级成⼀条可被静⾳的软提醒,这是 Spring AI 这块为了"宽容"⽽牺牲"确定性"的⼀个争议取舍——它赌的是"你迟早会看到⽇志",但分布式系统⾥这个赌注并不总赢。

description:同理, @Tool.description 优先,缺省时⽤驼峰拆词把 getWeather 拆成 get weather ( ToolUtils.java:76-83 ,底层

ParsingUtils.reConcatenateCamelCase )。别⼩看这条描述——它是模型判断"什么时候该派这个差役"的唯⼀线索,写不好,⼯具就算注册了也没⼈⽤。

inputSchema:最重的⼀栏,下⼀节单独说。

3.3 ⾃动⽣成 schema: JsonSchemaGenerator.generateForMethodInput这是"声明式⼯具"最爽的⼀环:你只管写⽅法签名,JSON Schema 它替你算。逐参数遍历,产出 DRAFT_2020_12 规范的 schema:

schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier());
schema.put("type", "object");

// ...

for (int i = 0; i < method.getParameterCount(); i++) {

// ToolContext参数:跳过,不进 schema

if (parameterType instanceof Class<?> parameterClass
&& ClassUtils.isAssignable(ToolContext.class, parameterClass)) {
continue;
}

// Kotlin suspend的尾随 Continuation:跳过

if (KotlinDetector.isSuspendingFunction(method) && i == method.getParameterCount() - 1) {
continue;
}
if (isMethodParameterRequired(method, i)) {
required.add(parameterName);
}

// ...

parameterNode.remove("format"); //去掉 OpenAPI format,某些模型(如 Mistral)不认
}

util/json/schema/JsonSchemaGenerator.java:137-172三处细节,处处是为"跨番邦兼容"擦的屁股:

1. ToolContext 参数不进 schema( L147-154 )。这和前⾯ MethodToolCallback 把 ToolContext 直接注⼊是⼀对⼉:既然它是译馆从侧⻔塞的机密,模型就不该在 schema ⾥看⻅它、更不该去填它。⼀处隐藏,两处呼应。

2. Kotlin suspend 函数的尾随 Continuation 参数被跳过( L158-160 )。Kotlin 协程编译后会偷偷在⽅法尾巴加个 Continuation 参数,那是编译器的事,不是⼯具契约的⼀部分,得抹掉。

3. format 字段被移除( L171-172 ,注释直接点名 Mistral)。victools ⽣成的 schema 带 OpenAPI ⻛格的 format ,有些番邦不认,索性删掉求个最⼤公约数。

🦴 ⻣灰级细节——默认全 required:看 required.add(parameterName) 那句,它依赖 isMethodParameterRequired 。⽽这个判断的默认值是

true ( PROPERTY_REQUIRED_BY_DEFAULT ,源码 L86 ⼀带),也就是说你⽅法的每个参数,默认都是必填的。想让某个参数可选,得显式 @ToolParam(required =false) (或 @Nullable / @JsonProperty 等)去覆盖。这和"Java ⽅法参数本来个个都得传"的直觉⼀致,合理;但和 JSON Schema 圈⼦⾥"不写 required 就是可选"的惯例正好相反,从前端/OpenAPI 背景转来的⼈极易在这翻⻋——以为不标就是可选,结果模型被告知样样必填,该省的参数死活不肯省。

底层这些活⼉是 victools 的 jsonschema-generator + Jackson/Swagger 模块⼲的( L96-120 ⼀带),Spring AI 只是在它产出的 schema 上做"番邦适配"的精

修。站在巨⼈肩上,再补⼏块短板。

四、退⼀步看这套设计把镜头拉远。⼀个差役,⽆⾮"名⽚(给模型看)+ 执⾏体(实际⼲活)"两半。Spring AI ⽤ ToolDefinition 装名⽚、⽤ call(String,String) 装执⾏,中间以 JSON字符串为统⼀货币,于是 method-based 和 function-based 这两种出身完全不同的差役,能挂在同⼀根 ToolCallback 契约上,被同⼀套调度逻辑驱使。抽象统⼀,这是它最⼤的本事。

但统⼀是有代价的。我把账摊开:

没有编译期类型安全。method-based 全程反射+运⾏期 JSON 反序列化,参数对不对、类型合不合,要到 call 那⼀刻才知道。编译器帮不了你。

押注⼀个编译开关。整套"按参数名取值"押在 -parameters 上,脱离 Spring Boot starter 时这是头号隐雷(⻅ §2.1 暗礁)。

schema 以 String 流转。 inputSchema 全程是⼀坨 JSON ⽂本,不是结构化对象。要在中途改它、缓存它、diff 它,都得先解析回来——调试期尤其难受。

名校验过于宽容。不合规的⼯具名只 warn 不拦,还可能被⽇志级别静⾳(⻅ §3.2 暗礁)。

这些不是"bug",是"⽤动态换灵活"这条路必然背的债。值不值,看你的场景:做 agent、要快速接⼊⼀堆现成业务⽅法,这套声明式机制爽得⻜起;要极致的类型安全和可预测性,你得⾃⼰在上⾯再加⼀层校验。