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

第八章 · 差役名录:tool-search-tool(工具的 RAG)

下一章
字体

主题

版式

4,846 字 · 约 12 分钟

第⼋章 · 差役名录:tool-search-tool(⼯具的 RAG)

"把我⼀个塞进国书,⽐把三百个差役名字全抄上去,省墨。" —— toolSearchTool 的⾃⽩上⼀章我们看完了差役调度房:模型申请派差,Advisor 驱动回路,办差、回填、再问。⼀切都很顺——只要差役不多。

可⼀旦客官的应⽤挂了三百个 @Tool ,麻烦就来了。每次递交国书,译馆都得把这三百个差役的名字、说明、参数 schema ⼀股脑抄进 prompt。轻则烧 token,重则模型被⼀⻓串近义⼯具晃花眼,挑错⼈。这是真实存在的⼯程暗礁,不是杞⼈忧天。

spring-ai-tool-search-tool 这个独⽴模块,给的解法只有⼀句话那么⼤:把 RAG 套到"⼯具发现"上。典籍能按相似度调阅(藏经阁那⼀套),差役名录凭什么不能?

于是译馆只在国书⾥留⼀个差役——⼀位专⻔"查名录"的师爷。模型想⼲活却发现⼿头没趁⼿的家伙,就⽤⼤⽩话问这位师爷:"我要个能发邮件的",师爷翻名录、报回⼏个名字,译馆再把这⼏个真差役临时塞进下⼀轮国书。

这是⼀章短打。看它怎么⽤"⼀个元⼯具"驯服⼯具爆炸,也看它为此背上了什么。

⼀、那位"查名录的师爷"⻓什么样它朴素得过分——就是⼀个普通 @Tool ,和你⾃⼰写的⼯具没有本质区别:

@Tool(name = "toolSearchTool", description = """

Search for tools in the tool registry to discover capabilities for completing the current task.Use this when you need functionality not provided by your currently available tools....Returns references to matching tools which will be expanded into full definitions you can then invoke.

""")
public List<String> toolSearchTool(
@ToolParam(description = "A natural language search query ...") String query,
@ToolParam(description = "Maximum number of tool references to return (1-10)...", required = false) Integer maxResults,
@ToolParam(description = "Optional filter ...", required = false) String categoryFilter,
ToolContext toolContext) {

( spring-ai-tool-search-tool/.../toolsearch/ToolSearchTool.java:42 )

注意第四个参数 ToolContext ——上⼀章说过,它不进 inputSchema、模型看不到,是留给框架塞业务上下⽂的暗格。师爷正是从这个暗格⾥掏出 sessionId :

String sessionId = Objects.requireNonNull(
toolContext.getContext().get(TOOL_SEARCH_TOOL_SESSION_ID_KEY)).toString();
maxResults = (maxResults != null) ? maxResults : this.advisorMaxResults;
ToolSearchResponse toolSearchResponse = this.toolIndex
.search(new ToolSearchRequest(sessionId, query, maxResults, categoryFilter));
return toolSearchResponse.toolReferences().stream().map(tr -> tr.toolName()).toList();

(同⽂件:54、:59、:61)

⼲的事⼀句话:拿 sessionId + ⾃然语⾔查询去 ToolIndex ⾥检索,只把命中的⼯具名字串( List<String> )报回去。注意它不把整套⼯具定义塞回给模型——只报名字。真差役的"全套定义"由 Advisor 在下⼀轮悄悄补上(后⾯讲)。

ToolIndex 就是那本"名录"的抽象接⼝,四个动作⽽已:

void indexTool(String sessionId, ToolReference toolReference);
default void indexTools(String sessionId, List<ToolReference> toolReferences) { ... }
ToolSearchResponse search(ToolSearchRequest toolSearchRequest);
void clearIndex(String sessionId);

( toolsearch/ToolIndex.java:39,53,64,70 )

每个动作都带 sessionId ——名录按会话隔离,客官 A 的差役不会串到客官 B 的国书⾥。三种"翻名录"的⼿法,可插拔:

index/regex/RegexToolIndex.java:55 ——把查询拆词、去停⽤词,拼成 (?i)(token1|token2|...) 的正则去匹配名字和说明,⼯具名命中权重是说明命中的 2 倍,⽣成的模式还限⻓ 200 字(⻅类头注释)。最轻量,零依赖。

index/lucene/LuceneToolIndex.java ——Lucene 全⽂检索,要倒排索引那⼀套。

index/vectorstore/VectorToolIndex.java ——向量/语义检索,把⼯具说明喂进藏经阁,按 embedding 相似度召回。这才是名副其实的"⼯具 RAG"。

🦴 ⻣灰级细节:回的那项叫 ToolReference ,是个 record—— record ToolReference(String toolName, @Nullable Double relevanceScore, String
summary) ( toolsearch/ToolReference.java:33 )。它带了 relevanceScore ,可正经师爷( ToolSearchTool )根本没⽤它排序也没把它报给模型, .map(tr -
tr.toolName()) ⼀步就把分数和 summary 全丢了( ToolSearchTool.java:64 )。检索内部按分数排好序、截到 maxResults,模型只配看到⼀串裸名字——这

设计是对的(省 token),但 relevanceScore 在这条链路上确实只是个"内部记账字段",对外不可⻅。

⼆、谁把名录铺好,⼜是谁把真差役偷偷塞回来师爷只管查。把名录建起来、把命中的差役注回国书,是那位继承⾃上⼀章 ToolCallingAdvisor 的⼯头—— ToolSearchToolCallingAdvisor 。它没有重写回

路,⽽是钻进⽗类预留的⼏个模板⽅法钩⼦( doInitializeLoop / doBeforeCall 等, ToolSearchToolCallingAdvisor.java:147,156 ),在回路的缝⾥做⼿脚。
会话开张时( initializeSession )做三件事:把当前可⽤⼯具全 resolveToolDefinitions 出来,转成 ToolReference 灌进 ToolIndex ;在 system message

尾巴上追加⼀段 systemMessageSuffix ,引导模型"没趁⼿⼯具就去问 toolSearchTool";顺⼿跑⼀遍 eviction 淘汰过期会话。

每⼀轮调模型前( prepareIteration )才是点睛:

Set<ToolCallback> selectedToolCallbacks = new HashSet<>(List.of(this.toolSearchToolCallback));
var cachedToolCallbacks = (Map<String, ToolCallback>) chatClientRequest.context()
.get(CACHED_TOOL_CALLBACKS_KEY);
if (cachedToolCallbacks != null) {
this.extractToolNameReferences(chatClientRequest.prompt().getInstructions()).forEach(toolName -> {
if (cachedToolCallbacks.containsKey(toolName)) {
selectedToolCallbacks.add(cachedToolCallbacks.get(toolName));
}
});
}

( ToolSearchToolCallingAdvisor.java:249-260 )

读这段就懂了整套魔术:每⼀轮暴露给模型的⼯具,只有"师爷本⼈ + 历史上被师爷搜出来过的那些"。 extractToolNameReferences 回头扫会话历史⾥所有

toolSearchTool 的回执,把⾥⾯的⼯具名抠出来,在缓存( CACHED_TOOL_CALLBACKS_KEY )⾥换成真正的 ToolCallback 注⼊下⼀轮( ToolSearchTool.java 报

名字 → Advisor 这⾥兑现成真差役)。三百个差役,模型从头到尾只⻅过它问出来的那⼏个。这就是⼯具版的"检索增强":不预加载全量,按需召回。

✨ 爽点:整套机制不改 ChatModel 、不改⽗类回路,纯靠继承 + 模板钩⼦ + ⼀个 context 缓存键就缝进了上⼀章的差役调度房。⼀个"元⼯具"驯服⼯具爆炸——加

法做到了减法的效果。

三、名录别天天重建:SHA-256 指纹与会话淘汰如果每轮、每个会话都 clear + reindex,那点 token 省下来全赔在索引开销上了(向量实现还得调 embedding API,真烧钱)。所以⼯头⽤指纹判断"名录到底变没变":

String fingerprint = computeFingerprint(toolReferences);
this.indexedSessionFingerprints.compute(sessionId, (id, current) -> {
if (!fingerprint.equals(current)) {
this.toolIndex.clearIndex(id);
this.toolIndex.indexTools(id, toolReferences);
}