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

第七章 · 调度回路:ToolCallingManager 与回路上移

下一章
字体

主题

版式

14,284 字 · 约 36 分钟

🎯 三句带⾛

1. ToolCallback 只有三件事: getToolDefinition() (给模型的名⽚)、 getToolMetadata() (returnDirect 等)、 call(String,ToolContext) (JSON 进 /

String 出);两头都是字符串是为了对⻬ LLM 接⼝。

2. MethodToolCallback 靠反射+ parameter.getName() 按参数名注⼊实参,强依赖编译期 -parameters ; FunctionToolCallback 把

Function/Supplier/Consumer 统⼀适配成 BiFunction ,schema 整体来⾃输⼊ POJO 类型。

3. @Tool 经 MethodToolCallbackProvider 扫描、 ToolDefinitions.from 提取三要素:name(正则 ^[a-zA-Z0-9_.-]+$ ,只 warn 不 reject)、
description(缺省驼峰拆词)、inputSchema( JsonSchemaGenerator 出 DRAFT_2020_12,跳过 ToolContext 参、默认全 required)。

承上启下到这⼉,我们⼿⾥已经有了⼀⽀登记造册、各有名⽚的差役队伍——可它们还只是站在差役房⾥待命。模型举⼿喊"我要派 getWeather "之后,究竟是谁听⻅这声喊、谁去名册⾥点出对应的差役、谁把办差结果回填进对话、⼜是谁决定"还要不要再问模型⼀轮"?这套派差—办差—回填—再请的回路,本版本⾥已经从 ChatModel 内部上移到了⼀个全新的宿主。下⼀章「第七章 · 调度回路:ToolCallingManager 与回路上移」,我们就钻进那间灯⽕通明的差役调度房,看这台 do-while 引擎⼀圈⼀圈是怎么转起来的。

第七章 · 调度回路:ToolCallingManager 与回路上移

"我只管派⼀次差、收⼀次回执、把账本续上⼀⾏。要不要再派⼀轮,不归我管——那是驿丞的差事。" —— DefaultToolCallingManager 的⾃⽩上⼀章我们⻅识了招募差役的全过程:⼀个 @Tool 注解的⽅法,如何被扫描成 ToolDefinition ,schema 怎么从签名⾥⻓出来。可那只是"花名册"。真正到了办差时刻——模型在回函⾥写下"我要差 A 君去查天⽓、B 君去算汇率"——译馆⾥是谁拿着这张派差单跑腿、谁把跑腿结果再呈回模型、谁决定"还要不要再跑⼀轮"?

这⼀章,我们⾛进差役调度房。但先说⼀句煞⻛景的话:这间调度房,在这⼀版译馆⾥,已经被搬过家了。

⼀、调度房的⻔牌:只有两件事先看⻔牌。 ToolCallingManager 这个接⼝短得让⼈意外——拢共就两个⽅法:

public interface ToolCallingManager {

/**

* Resolve the tool definitions from the model's tool calling options.

*/

List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions);

/**

* Execute the tool calls requested by the model.

*/

ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse);
}

spring-ai-model/.../model/tool/ToolCallingManager.java:31-41⼀头⼀尾,刚好对应办差的两端:

resolveToolDefinitions ——出差前。把 options ⾥挂着的 ToolCallback 列表逐个调 getToolDefinition() ,摊成给模型看的"花名册",好让模型知

道有哪些差役可差(实现就⼀⾏ stream-map, DefaultToolCallingManager.java:114 )。

executeToolCalls ——出差后。模型回函⾥点了名,这⾥负责真把差役叫出去办差,再把回执续进会话账本。

注意这⻔牌上没有"循环"⼆字。它不负责"派完⼀轮再派⼀轮"。它是个⼀次性的执⾏器:给我⼀份模型回函,我执⾏其中的 tool call,还你⼀份ToolExecutionResult 。要不要拿这份结果再去敲⼀次模型的⻔——它管不着。这⼀点,是本章后半段"回路上移"的伏笔,先按下。

🦴 ⻣灰级细节: resolveToolDefinitions 与 executeToolCalls 读的不是同⼀个东⻄。 前者吃的是 ToolCallingChatOptions (纯 options),后者吃的是

Prompt + ChatResponse (完整请求 + 模型回函)。为什么不统⼀?因为它们在时间线上隔着⼀整次模型往返:解析定义发⽣在"构造请求"那⼀刻,只需要知道有哪些⼯具;⽽执⾏ tool call 发⽣在"模型已经回话"之后,既要拿到模型点名的 toolCalls (在 ChatResponse ⾥),⼜要拿到原始会话历史(在 Prompt ⾥)好把回执续上去。

两个⽅法看似都在"处理⼯具",实则站在模型往返的两岸。

⼆、办差现场: executeToolCalls 的七步流⽔

DefaultToolCallingManager.executeToolCalls 是这间调度房的⼼脏( DefaultToolCallingManager.java:117-145 )。它不⻓,但每⼀步都在解决⼀个真问

题。我们顺着读。

第⼀步:在回函⾥翻出那张派差单

Optional<Generation> toolCallGeneration = chatResponse.getResults()
.stream()
.filter(g -> !CollectionUtils.isEmpty(g.getOutput().getToolCalls()))
.findFirst();
if (toolCallGeneration.isEmpty()) {
throw new IllegalStateException("No tool call requested by the chat model");
}
AssistantMessage assistantMessage = toolCallGeneration.get().getOutput();

DefaultToolCallingManager.java:122-131模型⼀次回函可能含多个 Generation (多个候选),调度房只认第⼀个带 toolCalls 的那条。翻不到就直接 IllegalStateException ——它信任调⽤⽅:"你既然把我叫来,就该确认这回函⾥真有差要派。"这份信任后⾯会由 ToolExecutionEligibilityChecker 来兜底(它是把⻔的)。

第⼆步:备好"差役看得⻅、模型看不⻅"的随身⾏囊

ToolContext toolContext = buildToolContext(prompt, assistantMessage);

DefaultToolCallingManager.java:133buildToolContext (L147-156)从 options 的 toolContext map ⾥取出业务上下⽂,塞进⼀个 ToolContext 。这是上⼀章提过的"随身⾏囊"——⽐如当前登录⽤户、租户 ID,这些不该进 prompt 让模型看⻅、但⼯具办差时⼜要⽤的东⻄。没配就给个空 map。

第三步:逐个叫差役出⻔——先翻options、再翻名录这是整段最该细看的地⽅( executeToolCall , L161-249)。模型点了⼀串名,调度房挨个处理。谁来执⾏这个名字? 两步⾛:

ToolCallback toolCallback = toolCallbacks.stream()
.filter(tool -> toolName.equals(tool.getToolDefinition().name()))
.findFirst()
.orElseGet(() -> this.toolCallbackResolver.resolve(toolName));
if (toolCallback == null) {
if (logger.isWarnEnabled()) {
logger.warn(POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING_START + toolName
+ POSSIBLE_LLM_TOOL_NAME_CHANGE_WARNING_END);
}
throw new IllegalStateException("No ToolCallback found for tool name: " + toolName);
}

DefaultToolCallingManager.java:196-207先在本次请求 options ⾥挂着的 toolCallbacks 按名精确匹配;匹配不到,才退⽽求其次⾛ toolCallbackResolver.resolve(toolName) ——那是个可链式委派的全局解析器(⽐如从 Spring 容器⾥按 bean 名找)。两道都没找着,才报错。

⚠ 暗礁:这条匹配链脆得很,⽽且源码⾃⼰⼼⾥有数。 那⾏ warn ⽂案值得逐字读——它拼出来是:

LLM may have adapted the tool name '<名字>', especially if the name was truncated due to length limits. ...
( DefaultToolCallingManager.java:81-83 )

翻译过来:"找不到⼯具,⼋成不是你配错了,是 LLM 把名字改了/截断了。" 整个 tool calling 的命⻔,卡在⼀个字符串相等⽐较( toolName.equals(...) )上。你这边⽼⽼实实注册了名叫 get_current_weather_in_city 的差役,模型那边⼀个⼿抖输出成 get_current_weather ,或者因为⼚商对⼯具名有⻓度上限被悄悄截断,这次办差就当场崩给你看。没有模糊匹配,没有相似度兜底,就是硬碰硬的 equals 。源码体贴地给了 warn 和 McpToolNamePrefixGenerator 的指引,但本质上,"差役名录的检索是脆的"这件事,正是下⼀章要专⻔救场的题眼。

第四步:returnDirect 的"三态与逻辑"

差役⾥有⼀类特殊的,招募时就标了 @Tool(returnDirect=true) ——意思是"我办完的结果直接呈给客官,别再回模型嘴⾥嚼⼀遍"。可⼀轮⾥点了好⼏个差役,有的returnDirect、有的不是,听谁的?

if (returnDirect == null) {
returnDirect = toolCallback.getToolMetadata().returnDirect();
}
else {
returnDirect = returnDirect && toolCallback.getToolMetadata().returnDirect();
}

DefaultToolCallingManager.java:209-214注意这⾥⽤的是 Boolean returnDirect = null (L172)的三态写法:第⼀个⼯具直接赋值,后续⼯具⼀律 && 累乘。换句话说——取 AND,全员 returnDirect 才returnDirect。只要有⼀个⼯具说"我的结果要回给模型",整轮就⽼⽼实实回模型。

⚠ 暗礁(承上⼀章):这条 AND 规则是隐式约定,⽂档不显眼,极易踩坑。 设想你有⼀个 returnDirect=true 的"下单⼯具"和⼀个普通的"查库存⼯具",某轮模型同时

点了这两个,你以为下单结果会直接返回客户——错,因为查库存不是 returnDirect,整轮取 AND 后 returnDirect=false ,下单结果照样被回灌给模型再绕⼀圈。这种⾏为不写在⽅法签名上,只藏在这三⾏ && ⾥。要稳,就别让 returnDirect ⼯具和普通⼯具在同⼀轮被同时召唤。

第五步:套⼀层观测、真正调⽤、当场接住异常

String toolCallResult = ToolCallingObservationDocumentation.TOOL_CALL
.observation(...)
.observe(() -> {
String toolResult;
try {
toolResult = toolCallback.call(finalToolInputArguments, toolContext);
}
catch (ToolExecutionException ex) {
toolResult = this.toolExecutionExceptionProcessor.process(ex);
}
observationContext.setToolCallResult(toolResult);
return toolResult;
});

DefaultToolCallingManager.java:227-241toolCallback.call(...) 才是真正的"差役出⻔办差"(回到上⼀章:反射调你的⽅法,或跑你的 lambda)。外⾯包⼀层 Micrometer Observation——账房在记账,这趟差跑了多久、⼊参出参是什么,可被链路追踪捞到。

最妙的是那个 catch 。差役在外⾯摔了跤(抛了 ToolExecutionException ),调度房不让它把整间译馆⼀起拖垮,⽽是交给toolExecutionExceptionProcessor.process(ex) ,把异常翻译成⼀句字符串当作"办差结果"。这就是本章第⼀个真正的爽点。

第六步:封回执

toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), toolName,
toolCallResult != null ? toolCallResult : ""));

DefaultToolCallingManager.java:243-244每个差役的结果,连同 toolCall.id() (模型给这次调⽤发的回执编号,⽤来对账)和⼯具名,封成⼀条 ToolResponse 。注意 null 结果会被兜底成空串——不让null 流进会话历史。

第七步:把账本续上两⾏——"回填再请求"的真身

private List<Message> buildConversationHistoryAfterToolExecution(List<Message> previousMessages,
AssistantMessage assistantMessage, ToolResponseMessage toolResponseMessage) {
List<Message> messages = new ArrayList<>(previousMessages);
messages.add(assistantMessage);
messages.add(toolResponseMessage);
return messages;
}

DefaultToolCallingManager.java:251-257

这七⾏,是整个 tool calling 机制最朴素也最核⼼的⼀招。所谓"回填再请求",落到代码⾥就是:在原会话历史的尾巴上,先续⼀条 AssistantMessage (模型那条带 toolcall 的请求)、再续⼀条 ToolResponseMessage (⼯具办差的结果)。

顺序很关键:模型先"说"它要调⼯具(AssistantMessage),⼯具才"答"结果(ToolResponseMessage)——这⼀问⼀答补全成⼀段完整对话,下⼀轮模型读到这段历史,就知道"我刚才点的差役已经回来了,结果是这个",于是能接着往下推理。这就是 agent 能"调完⼯具继续思考"的全部秘密——没有魔法,只是把⼀来⼀回⽼⽼实实写进对话历史。

最后封装成 ToolExecutionResult (L141-144),带上刚拼好的 conversationHistory 和那个 AND 出来的 returnDirect ,交差。

三、异常默认回灌:为 agent ⾃纠留的活路

回头细看第五步那个 catch 。 DefaultToolExecutionExceptionProcessor.process 的逻辑,值得单拎出来( spring-ai-
model/.../tool/execution/DefaultToolExecutionExceptionProcessor.java:57-85 ):
public String process(ToolExecutionException exception) {
Assert.notNull(exception, "exception cannot be null");
Throwable cause = exception.getCause();
if (cause instanceof RuntimeException runtimeException) {
if (this.rethrownExceptions.stream().anyMatch(rethrown -> rethrown.isAssignableFrom(cause.getClass()))) {
throw runtimeException;
}
}
else {
// If the cause is not a RuntimeException (e.g., IOException,
// OutOfMemoryError), rethrow the tool exception.
throw exception;
}
if (this.alwaysThrow) {
throw exception;
}
String message = exception.getMessage();

...

return message;
}

默认 alwaysThrow=false (L41)。于是默认⾏为是:差役办砸了,不抛异常中断整个调⽤,⽽是把异常的 message 当成⼯具的"返回结果"回灌给模型。

✨ 爽点:这⼀招把"程序崩溃"变成了"agent 的⼀次反馈"。 ⼯具抛了 IllegalArgumentException("city 不能为空") ,模型下⼀轮读到这句话,完全可以⾃⼰反应过

来"哦我参数填错了",重新组织⼀次调⽤。框架默认不替你做"失败即终⽌"的决定,⽽是把失败也变成对话的⼀部分,交还给模型去⾃纠——这对构建能容错、能重试的agent 太友好了。

但别把它当万灵药,这⾥有三层精细的边界,源码写得很克制:

⽩名单照样抛(L62-64): 你可以⽤ rethrowExceptions 配⼀份"这些异常必须原样抛出"的⽩名单,⽐如权限校验失败这种不该让模型看⻅、也不该被模型"绕过"的异常,就该硬抛,⽽不是回灌给模型让它有机会换个说法再试。

alwaysThrow=true ⼀⼑切(L72-73): 想退回"失败即终⽌"的传统语义,把这个开关打开即可。

🦴 ⻣灰级细节:⾮ RuntimeException 的 cause ⼀律强制上抛(L66-70)。 这是最容易被忽略、却最体现设计分⼨的⼀处。如果⼯具底下抛的是

IOException 、甚⾄ OutOfMemoryError 这类不是 RuntimeException 的东⻄,处理器不管你 alwaysThrow 设没设、⽩名单配没配,直接 throwexception 上抛。理由很硬: OutOfMemoryError 这种系统级故障,你把它 message 化成⼀句话喂给模型"内存不够了哦",模型能⼲嘛?它⽆能为⼒,你却把⼀个该让整个进程感知的致命信号悄悄吞成了对话⾥的⼀句闲话。所以框架在这⾥划了死线:业务异常可以软化成反馈,系统异常必须穿透。这条 else 分⽀不起眼,但它是"异常回灌"这个聪明设计的安全护栏。

四、回路搬家:从 ChatModel 内,搬到 Advisor 上到这⾥,调度房的活⼉讲完了——但你应该已经发现⼀个窟窿: executeToolCalls 只跑⼀轮。模型点名 → 办差 → 回填,然后呢?谁拿着这份回填好的历史,再去敲⼀次模型的⻔?谁判断"模型这次⼜点了新的差,还得再来⼀轮"?谁来终⽌这个循环?

这就是本章真正的"新旧之变"。

在⽼版本的译馆⾥,这个 do-while 多轮回路是焊死在 ChatModel 内部的——每个 provider(OpenAiChatModel、AnthropicChatModel…)⾃⼰在 call() ⾥写⼀圈 while,⾃⼰判断要不要继续。还有个布尔开关叫 internalToolExecutionEnabled 挂在 options 上,告诉模型"这圈循环你⾃⼰跑还是交给我"。

这⼀版,回路被整个搬出了 ChatModel,上移到了 ChatClient 的 advisor 链⾥—— ToolCallingAdvisor 。 那个 internalToolExecutionEnabled 布尔也随之被重构掉了,如今 ToolCallingChatOptions 接⼝⾥已经找不到它。

新调度回路的真身,在 ToolCallingAdvisor.adviseCall ( spring-ai-client-chat/.../chat/client/advisor/ToolCallingAdvisor.java:141-187 ):
boolean isToolCall = false;
do {

// Before Call

var processedChatClientRequest = ChatClientRequest.builder()
.prompt(new Prompt(instructions, toolCallingChatOptions))
.context(chatClientRequest.context())
.build();
processedChatClientRequest = this.doBeforeCall(processedChatClientRequest, callAdvisorChain);
chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest); //调模型
chatClientResponse = this.doAfterCall(chatClientResponse, callAdvisorChain);
ChatResponse chatResponse = chatClientResponse.chatResponse();
usageAccumulator.addRoundResponse(chatResponse);
isToolCall = this.toolExecutionEligibilityChecker.isToolCallResponse(chatResponse); //检测
if (isToolCall) {
ToolExecutionResult toolExecutionResult = this.toolCallingManager
.executeToolCalls(processedChatClientRequest.prompt(), chatResponse); //办差
if (toolExecutionResult.returnDirect()) {
chatClientResponse = chatClientResponse.mutate()
.chatResponse(ChatResponse.builder()
.from(chatResponse)
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
.build())
.build();

break; // returnDirect:直接返回客官,不再回模型

}
instructions = this.doGetNextInstructionsForToolCall(processedChatClientRequest, chatClientResponse,
toolExecutionResult); //新历史作下轮输⼊
}
} while (isToolCall); //直到⽆ tool call
ToolCallingAdvisor.java:141-187 (为聚焦回路主⼲,已省略 usage 累计等枝节)

把这段拆成四个⻆⾊,你会发现它们各司其职,边界清清楚楚:

1. 谁调模型? —— callAdvisorChain.copy(this).nextCall(...) (L152)。注意是 copy(this) :复制⼀条排除⾃⼰的 advisor 链去调下游,避免⾃⼰递归套⾃⼰。

2. 谁检测还有没有差要派? —— ToolExecutionEligibilityChecker.isToolCallResponse(chatResponse) (L159)。这是个 Function<ChatResponse,
Boolean> ,默认实现就⼀句 chatResponse != null && chatResponse.hasToolCalls() ( ToolCallingAdvisor.java:76-77 ;接⼝的 null 兜底在
ToolExecutionEligibilityChecker.java:38-40 )。它是循环的刹⻋⽚:返回 false, do-while 就停。

3. 谁办差? —— toolCallingManager.executeToolCalls(...) (L163-164)。对,就是前三节那间调度房。它只跑⼀轮,循环由 advisor 喂给它。

4. 谁把回执送回模型? —— doGetNextInstructionsForToolCall(...) (L182)。它把 executeToolCalls 拼好的 conversationHistory (含那条
ToolResponseMessage )取出来,作为下⼀轮 Prompt 的 instructions,循环顶部 new Prompt(instructions, ...) 再调⼀次模型。"回填→再问"的闭环,

就在这⼀进⼀出之间合上。

⽽ returnDirect 在这⾥兑现:⼀旦 toolExecutionResult.returnDirect() 为真,advisor 不再回灌模型,⽽是⽤
ToolExecutionResult.buildGenerations ( ToolExecutionResult.java:67-84 )把⼯具结果直接搓成 Generation 返回, finishReason 标成
"returnDirect" ,然后 break (L166-180)。

隐喻边界提醒(本书纪律): 我们⼀直把⼯具叫"差役",但请注意,模型并不能直接命令差役——模型只是在回函⾥"申请"派差,真正决定派不派、派⼏轮、何时收兵的,是 advisor 这位驿丞。差役听的是驿丞的调度,不是模型的命令。"差役"这个词容易让⼈以为模型是发号施令的主⼈,其实模型更像个"打申请单的客官"。这层错位,正是"回路上移到 advisor"在语义上的精髓。

为什么这次搬家是进步?

✨ 爽点:回路与模型彻底解耦,横切能⼒得以插队。 回路在 advisor 链⾥跑,意味着每⼀轮⼯具往返都能被其它 advisor 拦截。⽇志、记忆、RAG、限流……这些横切

关注点,现在能名正⾔顺地嵌进"模型 ↔ ⼯具"的每⼀次往返,⽽不必每个 provider 各⾃重新发明⼀遍。provider 的 ChatModel.call() 由此瘦身成只管单次请求-响应的纯函数式⻆⾊,再也不背循环这⼝锅——职责单⼀了,新接⼀个 provider 也省事了。

🦴 ⻣灰级细节: DEFAULT_ORDER = HIGHEST_PRECEDENCE + 300 ,这个数字是精算过的。 看 ToolCallingAdvisor.java:68-74 的注释:advisor 的默认 order 故

意定得⽐记忆类 advisor 的默认 order(+200)更⾼。order 越⾼在链上越靠外。这样安排的结果是:记忆 advisor 站在⼯具回路的"外⾯"——它只在整个多轮⼯具调⽤全部跑完、尘埃落定后记⼀次账,⽽不会被卷进每⼀轮⼯具往返、把中间那些 ToolResponseMessage ⼀条条都写进起居注。⼀个 +300 的常量,精确地把"记忆"和"⼯具回路"的嵌套关系给摆正了。这种⽤ order 数字编排 advisor 嵌套层级的⼿法,是 Spring AI advisor 体系⾥很值得玩味的设计。

⼀句必要的批评回路上移确实优雅,但代价是理解成本和版本漂移。

⚠ 暗礁:回路宿主在版本间反复搬家,跨版本的⽂档和代码极易对不上。 同⼀个"多轮⼯具调⽤",在不同版本⾥循环可能在 ChatModel 内、可能在 ChatClient 的

advisor 上;控制开关也从 internalToolExecutionEnabled 布尔变成了"advisor 在不在链上 + EligibilityChecker"。你按⼀篇半年前的博客配internalToolExecutionEnabled ,在这⼀版编译都过不去。更微妙的是: adviseCall 开头有⼀句——如果 options 不是 ToolCallingChatOptions ,直接nextCall 跳过整个回路( ToolCallingAdvisor.java:127-130 )。也就是说,⼯具回路⽣效与否,隐式依赖于你的 options 是不是那个特定⼦类。没报错、没警告,⼯具就是不被执⾏——这种"静默跳过"对新⼿是相当不友好的坑。它换来了灵活,但也把"回路到底在哪、归谁管"这件本该⼀⽬了然的事,摊薄进了 advisor 链、options 类型、checker 三处,得拼起来才看得全。

🎯 三句带⾛

1. ToolCallingManager.executeToolCalls 只跑⼀轮:找出含 toolCalls 的 Generation → 按名在 options 匹配 ToolCallback (匹配不到再⾛
toolCallbackResolver )→ 执⾏ → 把 AssistantMessage(请求) + ToolResponseMessage(结果) 续进会话历史,即"回填再请求"。

2. 多轮 do-while 回路在本基线已从 ChatModel 上移到 ToolCallingAdvisor.adviseCall :由 ToolExecutionEligibilityChecker 判断是否还有tool call,有则执⾏并把新历史作下轮输⼊; @Tool(returnDirect=true) 短路返回(⼀轮多⼯具取 AND)。旧的 internalToolExecutionEnabled 已被重构掉。

3. ⼯具异常默认转成字符串回灌模型(利于 agent ⾃纠),但⽩名单异常、 alwaysThrow=true 、以及⾮ RuntimeException 的 cause 会强制上抛;⼯具名匹配靠String.equals ,LLM 改名/截断会当场失配(源码已 warn)。

承上启下我们刚刚反复撞上同⼀块暗礁:⼯具名匹配是脆的、 equals 是硬的、LLM ⼀改名就失配。⽽这还只是"名字对不上"的⼩麻烦——真正的⼤麻烦在数量上:当⼀座译馆招募了成百上千个差役,把整本花名册⼀次性塞进 prompt,既烧 token ⼜让模型挑花眼、点错名。

怎么办?既然译馆⾥有"按相似度调阅典籍"的藏经阁,为什么不能也给差役建⼀本可检索的名录——平时只暴露⼀个"查名录"的差役,模型⽤⾃然语⾔说出它想要什么,再按需把相关差役召唤进来?这正是把 RAG 那套搬到⼯具世界的思路。下⼀章,我们就⾛进 「第⼋章 · 差役名录:tool-search-tool(⼯具的 RAG)」,看 Spring AI 如何⽤⼀个独⽴模块,把"⼯具的检索"做成⼀等公⺠。