面试知识库

主题 9 · 工具设计与 Function Calling#

定位:从 Function Calling 协议本质到工具描述工程,再到 DocMind 的”一鱼两吃”双路工具层设计——6 个 @Tool Bean(对外 5 Bean / 6 方法走 MCP,executeCode 沙箱计算工具仅内部),agentic 白名单(4 读检索 + executeCode 计算)+ ThreadLocal 上下文实现安全隔离。


一、通用知识#

1.1 核心概念与原理#

Function Calling 协议本质#

Function Calling 的核心是一条安全边界:模型只输出结构化调用意图(tool name + arguments JSON),真正的执行在代码层。模型不触碰任何外部系统,它只是”建议”调用哪个函数、传什么参数。代码层收到意图后决定是否执行、做参数校验、控制权限,执行完把结果回喂模型上下文,模型再基于结果决定下一步或终止。这是 Agent 安全的根基——模型不执行任何操作,执行权永远在代码手上。

面试怎么讲:“Function Calling 的本质是模型只做决策、代码做执行。模型输出的是结构化的调用意图——工具名加参数 JSON,真正执行在代码侧。这条边界是 Agent 安全的根基:模型不碰外部系统,代码层做参数校验、权限控制、执行兜底。理解这一点就理解了为什么我们可以放心让 LLM 做检索规划——它选错了大不了搜不到东西,但绝不会越权操作。“

工具描述工程#

description 是 tool selection 的 prompt——它决定了模型能不能选对工具。写得好模型选得准,写得差模型乱选。工具描述工程有几个关键维度:

  • 粒度选择:太粗会有歧义(“搜索”到底是向量搜索还是关键词搜索还是联网搜索?),太细又增加模型决策负担(20 个工具模型选不过来)
  • 命名规范:verb_noun 动宾结构最清晰——searchDocsdocHandler 好,recallMemorymemoryOp
  • 描述内容:要包含适用场景(什么时候该用)和不适用场景(什么时候不该用),相当于给模型写 few-shot 的正负例
  • 中文 vs 英文:中文 LLM(如 qwen-plus)对中文描述的理解更准确,实测参数填充正确率更高

面试怎么讲:“工具描述本质上就是 prompt engineering——它决定模型选哪个工具、传什么参数。我们的经验是三点:一是命名用动宾结构让意图清晰;二是描述里写明适用场景,比如’适用于时效性信息查询’能有效引导模型在需要时才调联网搜索;三是中文 LLM 配中文描述效果更好——我们的 qwen-plus 对中文工具描述的参数填充正确率明显高于英文。“

工具结果注入#

工具执行完后,结果需要回喂 LLM 上下文用于下一步决策。这里有个工程问题:结果可能非常大(比如检索返回 20 个 chunk,每个 500 字),直接全量注入会撑爆上下文窗口。常见处理方式:截断(取前 N 条)、摘要(让 LLM 对结果做摘要后再注入)、分页(先返回概要再按需获取详情)、结构化抽取(只注入关键字段如 title + score)。

面试怎么讲:“工具结果注入的核心问题是上下文窗口管理。检索工具一次返回几十个 chunk 可能上万 token,直接全量注入会挤占生成空间。我们的做法是工具层返回完整结果写入 ThreadLocal,但模型拿到的是截断后的摘要;真正的 chunk 全集在 finalize 阶段统一 rerank + 压缩后再进 prompt。“

并行 vs 顺序工具调用#

现代 LLM 支持单轮返回多个工具调用(parallel tool calls)——比如同时搜两个不同主题。但多跳场景必须顺序执行——前一步的结果是后一步的参数。判断标准很简单:工具调用之间有没有数据依赖。独立的并行,依赖的顺序。

面试怎么讲:“并行和顺序的选择看数据依赖。‘对比 A 和 B’——搜 A 和搜 B 独立,可以并行,一轮两个 tool call 出去;‘X 的负责人在哪年入职’——先搜 X 的负责人是谁,拿到名字后再搜入职年份,必须顺序。我们的 agentic 循环开了 parallelToolCalls=true,模型自己判断哪些可以并行。“

工具调用循环的控制权#

Function Calling 涉及一个关键的工程决策:谁来控制”调用→结果→再调用”这个循环?有两种模式:

  • 框架内部循环:框架(如 Spring AI、LangChain)在 ChatModel.call() 内部自动执行工具调用、回喂结果、再次调用模型,直到模型不再请求工具为止。优点是简单;缺点是没有迭代上限——如果模型陷入死循环,框架会一直调下去
  • 手动控环:关闭框架的内部工具执行(internalToolExecutionEnabled(false)),自己写 for 循环:调模型 → 检查有无 tool calls → 有则执行 → 把结果拼进对话历史 → 再调模型。优点是可以加迭代上限、空轮护栏、逐轮可观测事件;缺点是代码多

DocMind 选择手动控环——因为 agentic 循环必须有确定性的迭代上限(默认 4 轮)和空轮护栏(某轮零 chunk 增益时提前收尾),这些在框架内部循环中做不到。

面试怎么讲:“我们关闭了 Spring AI 的内部工具循环,自己控环。原因是框架的内部循环没有迭代上限——模型陷入重复检索时会无限调下去。手动控环可以加硬上限(4 轮)和空轮护栏(某轮没有新结果就提前收尾),还能在每轮发 SSE 事件让前端实时展示 agentic 进度。“

工具安全分类#

从安全角度,工具可以分为三类:

  • 只读工具(Read-only):检索、查询、召回——天然幂等,调多少次都不会改变系统状态。可以放心暴露给 LLM 循环
  • 有副作用的写工具(Write):存储记忆、修改配置、发送消息——执行一次就改变了系统状态。必须守卫,LLM 可能在不恰当的时机触发
  • 危险工具(Dangerous):删除数据、修改权限、执行任意代码——风险高,默认不暴露给 LLM 自动循环;除非把风险用隔离边界圈住(如代码执行放进程外沙箱 + 禁出网 + 默认关 + 仅内部,见 DocMind 的 executeCode / 文档 23),危险能力才可受控开放

面试怎么讲:“工具安全分三档:只读工具幂等可以放心暴露,写工具有副作用需要守卫(要么不暴露要么加确认),危险工具默认不暴露——除非能用隔离边界把风险圈住。我们 agentic 白名单里 4 个是只读检索工具,store_memory 这种写工具硬排除;唯一的高危能力 executeCode(代码执行)不靠’不给’、而靠进程外沙箱 + 禁出网 + 默认关 + 仅内部来管控。如果以后加 SQL 查询,只读 SELECT 可以进白名单,INSERT/UPDATE/DELETE 则要么不暴露、要么走二次确认。“

1.2 业界主流方案对比(表格形式)#

维度Custom JSON SchemaSpring AI @ToolLangChain ToolsOpenAI Function CallingAnthropic Tool Use
机制自定义 JSON 描述 + 手动解析模型输出注解驱动,自动生成 JSON SchemaPython 类继承 BaseToolAPI 层原生 functions 参数API 层原生 tools 参数
语言绑定任意语言Java/Kotlin(Spring 生态)PythonREST API(语言无关)REST API(语言无关)
Schema 格式自定义自动从方法签名 + @ToolParam 生成Pydantic model → JSON SchemaJSON Schema(strict mode 可选)JSON Schema(inputSchema)
优势灵活可控,不依赖框架零配置、类型安全、与 Spring DI 深度集成生态丰富、社区大、Agent 框架成熟原生支持、strict 模式保证输出格式强制结构化输出、支持 tool_choice
劣势维护成本高,容易 schema 与实现不一致Java 生态,社区较小Python only,版本迭代快、API 不稳定与 OpenAI 绑定与 Anthropic 绑定
典型用法自建 Agent 框架Spring Boot AI 应用LangChain/LangGraph AgentChatGPT Plugins / GPT ActionsClaude Desktop / MCP 客户端
维度白名单过滤黑名单排除全量暴露分层暴露
安全性高(默认拒绝,显式允许)中(遗漏一个就暴露)
维护成本低(新工具默认不可见)高(新工具默认暴露,需人工记得排除)
适合场景Agent 内部循环(精确控制)少量工具需排除受信任客户端多角色/多场景
DocMind 采用agentic 循环(4 读 + executeCode 计算)MCP 外部暴露内部 vs 外部双路

1.3 关键论文与技术要点#

论文/技术核心要点与工具设计的关联
Toolformer (Schick et al. 2023, arXiv:2302.04761)模型自学习何时使用工具——在训练时注入 API 调用 token,模型自行决定插入点证明了 LLM 可以学会”在正确的时机调用正确的工具”,Function Calling 的理论基础
Gorilla (Patil et al. 2023, arXiv:2305.15334)专门训练的 API 调用模型,在 1600+ API 上测试调用准确率发现工具描述质量直接影响调用准确率——description 越清晰、参数约束越明确,模型选择越准
ToolBench (Qin et al. 2023, arXiv:2305.16504)16000+ 真实 API 的大规模工具使用 benchmark工具数量超过模型决策能力时,需要分层检索(先选类别再选具体工具)降低决策复杂度
AnyTool (Du et al. 2024, arXiv:2402.04253)层次化 API 检索——先从类别库定位领域,再从领域内检索具体 API当工具数量超过 10+,模型选择准确率显著下降;层次化/分类化暴露是工程必需
Anthropic Tool Use Docs (2024)结构化 tool definition:name/description/input_schema,支持 tool_choice 强制调用MCP 协议的工具定义标准,DocMind 通过 Spring AI @Tool 自动生成兼容 schema

三条工程原则#

  1. 原子职责(Atomic Responsibility):一个工具做一件事。searchDocs 只做向量检索,不在内部偷偷做 rerank 或过滤——那是管线后续阶段的事。原子工具可组合性强,模型可以灵活编排
  2. 自文档化描述(Self-Documenting Description):工具描述是模型唯一的”说明书”,必须包含:做什么、适用什么场景、参数含义和约束。描述越精确,模型调用越准确
  3. 读幂等写守卫(Read-Idempotent, Write-Guarded):读操作天然幂等可以安全暴露给 LLM 循环;写操作(如 store_memory)必须守卫——要么不暴露给循环,要么加人工确认。DocMind 的做法是白名单只放读工具,写操作走代码路径

1.4 常见面试问答#

Q1:Function Calling 和直接 prompt 拼接有什么区别?

A:本质区别在结构化和可控性。prompt 拼接是把工具描述和调用指令塞进 system prompt,让模型用自然语言输出”我要调用 xxx”,你再正则解析——脆弱、不可靠、模型可以拒绝执行或输出格式不对。Function Calling 是 API 层原生支持的结构化协议:模型输出的是确定性的 JSON(tool name + arguments),API 层保证格式正确(尤其 OpenAI 的 strict mode 能保证 100% 符合 schema)。另外 FC 有明确的执行边界——模型输出意图,代码决定是否执行;而 prompt 拼接这条边界是模糊的。还有一个实际好处:参数类型可以校验,topK 必须是 int 而不是”大概 5 个左右”。

Q2:工具描述怎么写才让模型选得准?

A:四个要点。第一,写明适用场景而非只写功能——“语义向量文档检索:根据语义相似度从知识库中检索最相关的文档切片,适用于通用知识查询、概念解释等场景”比”文档搜索”好十倍。第二,参数加约束描述——“返回文档切片数量,建议 5-20”比”数量”好。第三,必要时加负例——系统提示里写”知识库结果已足够时不要调 webSearch”。第四,工具间差异要在描述中体现——如果 searchDocs 和 keywordSearch 描述太像,模型就分不清什么时候用哪个。我们的做法是在描述里明确标”精确条款查询用 keyword,语义理解用 searchDocs”。

Q3:并行 vs 顺序工具调用怎么选?

A:判断标准是数据依赖。独立任务并行——“对比产品 A 和产品 B”,搜 A 和搜 B 没有依赖关系,一轮两个 tool call 同时出去,延迟减半。依赖任务顺序——“X 项目负责人在哪年入职”,先搜 X 的负责人是谁(得到”张三”),再搜”张三入职年份”,前一步结果是后一步参数,必须两轮。混合场景也有——“A 和 B 各自的负责人分别在哪年入职”,先并行搜 A、B 的负责人,再并行搜两个负责人的入职年份,两轮四次调用。我们开了 parallelToolCalls=true 让模型自己判断,实测模型在简单场景判断得很准。

Q4:工具返回结果太大怎么处理?

A:四种策略按场景选。截断:只返回 top-N 结果,简单粗暴但可能丢掉长尾信息。摘要:让 LLM 对结果做一轮压缩再注入上下文——效果好但多一次 LLM 调用,成本翻倍。分页:先返回标题列表,模型按需请求详情——适合交互式场景。结构化抽取:只返回关键字段(title/score/snippet)而非全文。我们的做法是工具把完整 chunks 写入 ThreadLocal,模型拿到的是 Spring AI 自动序列化的摘要结果,但 finalize 阶段从 ThreadLocal 取全集做 rerank + 压缩,两全其美。

Q5:工具数量太多模型选不准怎么办?

A:工具超过 5-7 个模型选择准确率会显著下降(ToolBench 论文有数据支撑)。解决方案有三种:一是分层选择——先让模型选类别(检索/写入/查询),再在类别内选具体工具;二是场景化暴露——agentic 循环只暴露 4 个检索工具 + executeCode 计算工具,规模仍在可控阈值内,不需要模型在全部工具里选;三是工具描述区分度——让每个工具的 description 明确写出和其他工具的差异(如 searchDocs 语义 vs keywordSearch 逐字命中)。我们的做法就是场景化暴露——agentic 循环白名单 4 个只读工具 + executeCode 沙箱计算工具(默认关),one-shot 路径由 RetrievalPlanner 规则引擎选工具组合,MCP 外部暴露 6 个(executeCode 仅内部不对外)。

Q6:模型选错工具或传错参数怎么办?

A:三层防御。第一层参数校验——代码侧校验参数类型和范围,topK <= 0 直接返回空列表而非报错;kbIds 被 AgentToolContext 强制覆盖,LLM 传什么都不影响。第二层结果兜底——工具返回空或异常时不抛错,返回空列表/空 Map,让模型在下一轮决策时知道”这条路没搜到”从而换策略。第三层循环兜底——agentic 循环有迭代上限(默认 4 轮)和空轮护栏,即使模型持续选错也会在有限步内终止,外层再 fallback 到一次性检索。


二、DocMind 实践#

30 秒口述版#

“DocMind 的工具层做了两件事:一是把检索/记忆原语 + 沙箱计算封装成 6 个 @Tool Bean,一鱼两吃——内部 Agent 直接 Java 调用,外部通过 MCP 协议暴露给 IDE/AI 客户端(executeCode 仅内部,不对外);二是用 AgentToolContext ThreadLocal 做双路隔离——内部调用时用户的 kbIds 强制覆盖 LLM 传入的参数防越权,外部 MCP 调用以客户端传入为准。agentic 循环暴露 4 个只读工具(searchDocs/keywordSearch/webSearch/recall_memory)+ executeCode 沙箱计算工具(默认关),store_memory 硬排除——因为实测发现模型在多跳检索中会把中间结果误当’记忆’存储。one-shot 路径不走 LLM 选工具,而是 RetrievalPlanner 规则引擎确定性选工具组合——doc_search 始终包含,BM25 在 ambiguous 或 PRECISE 时追加,web_search 需要 timeAware 且二次校验时效性关键词。recall_memory always-on,因为成本只有 1 次 embedding + 1 次 ANN,P95 不到 100ms。“

详细展开#

背景/痛点(Situation)#

DocMind 需要 5 个检索/记忆原语(语义向量检索、BM25 关键词检索、联网搜索、记忆召回、记忆写入)同时服务三条路径(后续又加了一个沙箱计算原语 executeCode,仅 agentic 内部,见文档 23):

  1. 内部 agentic 循环:LLM 通过 Function Calling 自驱调用工具,模型决定搜什么、搜几次、何时停止
  2. 内部 one-shot 路径:RetrievalPlanner 规则引擎选工具组合,Worker 并行执行
  3. 外部 MCP 协议:IDE 插件、Claude Desktop 等 AI 客户端通过 MCP Server 直接调用

三条路径的权限模型不同,面临三个核心挑战:

  • 代码复用:同一个检索逻辑不想写两遍(甚至三遍),但不同路径的上下文获取方式不同
  • 安全隔离:内部路径 LLM 可能在工具参数里幻觉出任意 kbIds(如传一个不属于当前用户的知识库 ID),必须用用户会话的 kbIds 强制覆盖;外部路径由已鉴权客户端决定查询范围
  • 工具暴露控制:agentic 循环不应暴露写操作(store_memory 实测被模型误触发——把多跳检索的中间结果当”记忆”存储),但 MCP 外部需要完整暴露所有工具

做了什么(Action)#

1. 六个 @Tool Bean,一鱼两吃(executeCode 仅内部)

每个工具是一个 Spring @Component,核心方法加 @Tool 注解:

  • DocSearchTool.searchDocs()@Tool(description = "语义向量文档检索:根据语义相似度从知识库中检索最相关的文档切片,适用于通用知识查询、概念解释等场景")
  • KeywordSearchTool.keywordSearch()@Tool(description = "关键词BM25文档检索:根据关键词精确匹配从知识库中检索文档切片,适用于精确条款查询、编号检索等场景")
  • WebSearchTool.webSearch()@Tool(description = "互联网实时检索:调用 Tavily 联网搜索获取最新网页标题、链接和摘要,适用于新闻、政策、时效性信息查询")
  • MemoryTool.recallMemory()@Tool(name = "recall_memory", description = "语义召回用户长期记忆:基于当前问题在向量库中检索最相关的偏好、事实、上下文记忆")
  • MemoryTool.storeMemory()@Tool(name = "store_memory", description = "写入用户长期记忆:当用户明确声明偏好、事实或上下文信息时调用")
  • KbMetaTool.listKnowledgeBases()@Tool(name = "kb_meta", description = "查询知识库元信息:列出可用的知识库文档名称、分类、切片数和状态")
  • CodeExecTool.executeCode()@Tool(description = "代码执行(Python):在隔离沙箱中运行一段 Python 代码做精确计算/聚合/统计…")仅内部(挂 @Tool 供 agentic 反射,但不加入 McpToolsConfig,不对外暴露),且仅 AGENTIC 模式可用、受 sandbox.enabled 默认关

McpToolsConfigMethodToolCallbackProvider.builder().toolObjects(...) 统一注册 5 个对外 Bean(DocSearch / KbMeta / KeywordSearch / WebSearch / Memory,共 6 个 @Tool 方法),Spring AI MCP Server Starter 自动转换为 MCP 协议工具定义对外暴露;CodeExecTool 在其中(仅内部,对外跑任意代码安全面太大)。

2. AgentToolContext ThreadLocal 双路隔离

AgentToolContext.activate(kbIds, userId)  // 进入 agentic/one-shot 前激活
  → 工具 Bean 内部检查 AgentToolContext.isActive()
    → true: 从 ctx 取 kbIds 强制覆盖 LLM 传入参数(防越权),userId 从 ctx 取(防伪造)
    → false: 按外部传入的参数执行(MCP 路径)
  → 工具执行后 addChunks(toolName, results) / putMemory() 写入 ctx
AgentToolContext.clear()  // finally 中清除,防 ThreadLocal 泄漏
plaintext

关键安全设计:MemoryTool.resolveUserId()AgentToolContext.isActive() 时只信 ctx 的 userId,绝不信任外部/LLM 传入的 userId,防止越权读写他人记忆。外部 MCP 调用无 AgentToolContext,resolveUserId 返回空字符串,记忆操作降级为 no-op。

3. 白名单硬排除写操作

AgenticSearchOrchestrator 中:

private static final Set<String> ALLOWED_TOOLS = Set.of("searchDocs", "keywordSearch", "webSearch", "recall_memory", "executeCode");
java

whitelistedToolCallbacks() 方法从 ToolCallbacks.from(docSearchTool, keywordSearchTool, webSearchTool, memoryTool, codeExecTool) 生成所有 callback 后,在 stream 中过滤只保留白名单内的工具。不用 Spring AI 的 toolNames——因为 toolNames 是追加语义(与 toolCallbacks 取并集),不是过滤语义,即使不写 store_memory 它仍然会被暴露。executeCode 虽在白名单内,但额外受 sandbox.enabled 动态开关门控:关闭(默认)时直接不进 toolCallbacks,模型连这个工具都看不到。

store_memory 排除原因:实测发现 qwen-plus 在多跳检索中会触发 store_memory——把中间检索结果当作”发现的事实”写入用户长期记忆,污染记忆池。写操作走 DocMindAgent.stageMemoryWrite() 的代码路径,由提取逻辑(而非 LLM 即时决策)控制写入时机。

4. RetrievalPlanner 规则引擎(one-shot 路径)

one-shot 路径不走 LLM 选工具,而是 RetrievalPlanner.plan() 纯规则确定性选工具组合:

  • doc_search 始终包含
  • keyword_searchisAmbiguous=true(保守兜底防漏召)或 specificity=PRECISE(精确查询 BM25 更强)时追加
  • web_search 需要 timeAware=true 原始/改写问题中包含时效性关键词(双源二次校验,防分类器误判)
  • recall_memory always-on——成本仅 1 次 embedding + 1 次 Milvus ANN(P95 < 100ms),无记忆时快速返回空列表

5. 工具描述设计策略

工具描述采用”功能 + 适用场景”双段式结构。以 DocSearchTool 为例:“语义向量文档检索:根据语义相似度从知识库中检索最相关的文档切片,适用于通用知识查询、概念解释等场景”——冒号前是功能定义,冒号后是场景引导。参数描述同样加约束:“返回文档切片数量,建议 5-20”让模型知道合理范围,不会传 100 或 1000。

系统提示中还有负例引导:“知识库结果已足够回答时立即停止”、“不要调用任何写入类工具”、“不要在工具参数里传 kbIds”。这些负例和工具描述配合,形成完整的模型行为引导体系。

6. McpToolRegistry 自动注册 + 调用统计

每个工具调用后通过 updateCallStats(latencyMs) 更新 mcp_tool_registry 表:callCount 自增,avgLatencyMs 滑动平均(newAvg = (oldAvg * (n-1) + latency) / n)。新工具首次调用时自动注册(INSERT),后续调用只更新统计(UPDATE)。用于线上监控和工具使用分布分析。

7. 工具异常处理策略

所有工具 Bean 内部 catch 所有异常,返回空列表/空 Map 而非抛异常。原因:在 agentic 循环中,工具抛异常会中断整个循环甚至导致 ToolCallingManager.executeToolCalls() 失败;返回空结果则让模型在下一轮自然感知到”这条路没搜到”,从而换策略或停止。同时每个异常都 log.error/log.warn 记录,不吞掉错误信息。WebSearchTool 还有 enabled 开关和 apiKey 检查——配置缺失时直接返回空而非调用外部 API。

量化结果(Result)#

同一套 @Tool Bean 代码同时服务 agentic 循环、one-shot Worker、MCP 外部三条路径,零代码重复。agentic 循环暴露 4 个只读工具 + executeCode 沙箱计算工具(默认关),store_memory 误触发率从实测发生降到 0。AgentToolContext 107 行代码实现了完整的双路隔离。工具异常不会中断循环,空结果自然引导模型换策略。

代码锚点#

类/方法路径职责
DocSearchTool.searchDocs()mcp/DocSearchTool.java语义向量检索,AgentToolContext 双路 kbIds 覆盖
KeywordSearchTool.keywordSearch()mcp/KeywordSearchTool.javaBM25 精确匹配检索,支持 metadata filter
WebSearchTool.webSearch()mcp/WebSearchTool.javaTavily 联网检索,mapResults 标准化为 RetrievedChunk
MemoryTool.recallMemory()mcp/MemoryTool.java语义记忆召回,resolveUserId 只信 AgentToolContext
MemoryTool.storeMemory()mcp/MemoryTool.java记忆写入,仅外部 MCP 暴露,agentic 循环硬排除
KbMetaTool.listKnowledgeBases()mcp/KbMetaTool.javaKB 元信息查询,仅外部 MCP 暴露
CodeExecTool.executeCode()mcp/CodeExecTool.java沙箱内 Python 计算,仅内部 + 仅 agentic + sandbox.enabled 默认关
SandboxCodeExecutor.run()service/sandbox/SandboxCodeExecutor.javaOpenSandbox 进程外执行:禁出网 + 超时 / 输出 / 代码长度护栏
McpToolsConfig.mcpToolCallbackProvider()config/McpToolsConfig.javaMethodToolCallbackProvider 注册 5 个对外 Bean(不含 CodeExecTool)
AgentToolContextagent/AgentToolContext.javaThreadLocal 双路上下文:kbIds/userId/chunks/calledTools
AgenticSearchOrchestrator.whitelistedToolCallbacks()agent/supervisor/AgenticSearchOrchestrator.java白名单过滤(4 读 + executeCode),ALLOWED_TOOLS 集合 + executeCode 受 sandbox.enabled 门控,stream filter 实现
RetrievalPlanner.plan()service/rag/RetrievalPlanner.java规则引擎:按 classification 确定性选工具组合

工具双路调用架构图(文字版)#

量化数据#

指标数值基线来源
MCP 对外工具总数5 Bean / 6 个 @Tool 方法(executeCode 仅内部,不计入)McpToolsConfig
Agentic 白名单工具数5 个(4 读 + executeCode 计算,executeCode 受 sandbox.enabled 默认关)6 个 callback 未过滤(含 store_memory)ALLOWED_TOOLS
recall_memory P95 延迟<100ms1 embedding + 1 ANN
RetrievalPlanner 规则路径覆盖~85% 查询SIMPLE && !ambiguous
AgentToolContext 代码量107 行wc -l
store_memory agentic 误触发率0%(白名单排除后)实测发生(多跳中间结果被存储)trace 分析
工具描述语言全中文@Tool description 字段
Agentic 最大迭代轮数4 轮(agentic.max_iterationsAgenticSearchOrchestrator
工具异常传播率0%(全部 catch 返回空结果)所有 @Tool 方法
时效性关键词库大小17 个关键词RetrievalPlanner.TIME_AWARE_KEYWORDS

三、追问应对#

面试官想听到的信号#

  • 双路设计思维:一套代码服务两条路径,通过 ThreadLocal 上下文切换内部/外部行为,零代码重复
  • 安全优先的工具暴露策略:白名单而非黑名单,写操作不进循环——是从实测教训(store_memory 误触发)驱动出的设计决策,不是理论推导
  • 理解”description 即 prompt”:工具描述质量直接影响模型调用准确率,中文 LLM 配中文描述是实测验证的选择
  • 工具粒度的工程判断:不把 BM25 暴露给 agentic(减少模型决策复杂度),one-shot 路径用 RetrievalPlanner + RRF 融合两路,agentic 的 finalize 统一 Cross-Encoder rerank 保证效果
  • 框架踩坑经验:toolNames 追加语义而非过滤语义、手动控环 vs 框架内部循环的选型——说明对 Spring AI 源码有深入理解

追问预判与应答#

Q1:@Tool 的描述为什么用中文不用英文?

A:实测驱动的选择,不是拍脑袋。我们的 LLM 是 qwen-plus/qwen-max,本质上是中文优先的模型(阿里通义千问系列)。实测对比发现两个明显差异:一是中文描述下模型对参数约束的遵循更准确——比如”返回文档切片数量,建议 5-20”模型基本传 10 左右,英文描述”number of results, suggest 5-20”模型偶尔传 50 或 100,约束感知明显弱一档;二是场景引导更有效——“适用于通用知识查询、概念解释等场景”比”for general knowledge queries”对 qwen-plus 的引导力更强,模型在该用向量搜索的时候选 searchDocs 的正确率更高。这里有个 tradeoff:如果用 GPT-4 或 Claude 做 LLM,英文描述效果可能反而更好。描述语言要匹配模型的语言偏好——这不是通用准则,是模型特定的。

Q2:为什么 store_memory 不暴露给 agentic 循环?

A:来自真实 trace 的教训。实测发现 qwen-plus 在多跳检索中——比如用户问”A 项目的负责人做了哪些贡献”,模型第一跳搜到”A 项目负责人是张三”,然后会触发 store_memory 把”A 项目负责人是张三”作为”发现的事实”写入用户长期记忆。这不是用户的记忆偏好,是检索中间结果,写进去会污染记忆池。而且这个行为用 prompt 约束不够可靠——系统提示写了”不要调用写入类工具”模型有时仍然调用。所以我们的做法是代码层硬排除:whitelistedToolCallbacks() 在生成 ToolCallback 阶段就过滤掉 store_memory,模型连看都看不到这个工具。写操作走 DocMindAgent.stageMemoryWrite() 的代码路径,用确定性逻辑控制写入时机。

Q3:Spring AI 的 toolNames 和 toolCallbacks 有什么坑?

A:最大的坑是 toolNames 是追加语义而不是过滤语义。很多人以为在 ChatOptions.toolNames() 里只写 searchDocswebSearch,模型就只能看到这两个工具——但实际上 Spring AI 会把 toolNamestoolCallbacks 做并集。也就是说,如果你的 toolCallbacks 里有 store_memory,即使 toolNames 不包含它,它仍然会被暴露给模型。正确的做法是在 toolCallbacks 生成阶段就做过滤——我们用 Arrays.stream(all).filter(tc -> ALLOWED_TOOLS.contains(tc.getToolDefinition().name())) 在源头把不该暴露的工具剔除。这是读了 Spring AI 源码后才发现的,文档里没写清楚。

Q4:AgentToolContext 为什么用 ThreadLocal 不用 Request Scope?

A:两个原因。第一,Spring AI 的 @Tool 方法调用发生在 ChatModel.call() 内部的工具执行链路中,这个链路不一定在原始的 HTTP request thread 上(尤其如果有异步调用或线程池切换)。Spring 的 Request Scope 依赖 RequestContextHolder,线程切换后就拿不到了。ThreadLocal 跟的是执行线程本身,只要确保工具调用和 activate/clear 在同一线程上就可靠——我们的 agentic 循环是同步的所以没问题。第二,AgentToolContext 的生命周期比 HTTP request 短——它只在检索阶段活跃,prompt assembly 和 generation 阶段不需要。用 ThreadLocal + try-finally clear 精确控制生命周期,比 Request Scope 的整个请求周期更精确。但 ThreadLocal 的代价是必须在 finally 中 clear,否则线程池复用时会泄漏——这在代码里是强制的。

Q5:内部调用和外部 MCP 调用的 kbIds 策略为什么不同?

A:因为信任模型不同。内部调用时 LLM 是执行者,它可能在参数里幻觉出任意 kbIds(比如传一个不存在的 ID 或者不属于当前用户的 ID),所以必须用用户会话上下文的 kbIds 强制覆盖,LLM 传什么都不看。外部 MCP 调用者是已通过 API-Key 鉴权的 AI 客户端(IDE 插件、Claude Desktop),它们是”第一方调用者”——由客户端决定查询哪些知识库,就像调 REST API 一样。当然这个模型也有风险(外部调用可传任意 kbIds),生产加固方案是从 auth context 注入 userId 后校验 kbIds 归属,但 MVP 阶段先用 API-Key 鉴权兜底。

Q6:BM25 关键词检索要不要暴露给 agentic 循环?(一个改过的设计决策)

A:这是个改过的决策,正好讲讲取舍。早期我们把 BM25 暴露给 agentic,理由是减少决策复杂度——怕 qwen-plus 在向量 vs BM25 间纠结,犯两种错:一是两个都调(对比类问题先 searchDocs 再对同一 query keywordSearch,浪费一轮);二是选错(精确编号该用 BM25 却用向量,漏召回)。当时让 agentic 只暴露 searchDocs,把向量/BM25 的融合交给下层。后来改成把 keywordSearch 也放进白名单,因为当初担心的问题可以用两个更轻的手段摁住,而不必牺牲”循环内做精确逐字命中”的能力:① 工具描述写清分工——searchDocs 管语义、keywordSearch 管精确术语/条款编号/型号/代码标识符的逐字命中;② system prompt 给纪律——“纯概念只用 searchDocs,含精确术语才加用 keywordSearch,不要对同一意图无差别重复检索”。这样模型遇到精确编号能在循环里直接 BM25 命中,而不必等到 finalize。one-shot 路径仍由 RetrievalPlanner 规则同时派发两路 + RRF 融合;两条路径的 chunk 最后都汇到 finalize 的统一 Cross-Encoder rerank。教训:「最小工具集」是原则不是教条——当一个工具收益明确、且能用描述/纪律把它的决策成本压下去时,就该放进来。

Q7:工具调用统计(McpToolRegistry)有什么用?

A:两个用途。一是线上监控:每个工具的 callCount 和 avgLatencyMs 实时更新,如果 web_search 延迟从 2s 飙到 10s 说明 Tavily API 有问题,可以快速定位。二是工具使用分布分析:如果发现 web_search 调用率非常低,可能是描述不够引导——“适用于新闻、政策、时效性信息查询”这个描述是否足够让模型在需要时想到用它?反过来如果调用率过高,说明描述太宽泛模型什么问题都去联网搜。这些统计数据是优化工具描述的反馈信号。实现上用滑动平均算延迟 newAvg = (oldAvg * (n-1) + latency) / n,避免被单次异常值拉偏。

Q8:如果以后要加更多工具(比如 SQL 查询、代码执行),架构怎么扩展?

A:扩展分两步,DocMind 自己就把”代码执行”这条走了一遍。第一步加 @Tool Bean:写个带 @Tool 的方法(如 SqlQueryTool / CodeExecTool);要对外就在 McpToolsConfig.toolObjects() 追加注册,不想对外就不加——executeCode 就是只挂 @Tool 供 agentic 反射、但不进 McpToolsConfig,从而不对外暴露。第二步评估 agentic 白名单:只读工具(如 SELECT-only 查询)幂等可直接进 ALLOWED_TOOLS;高危/有副作用的工具看风险能不能被”隔离”住,而不是一刀切不给。我最初在这份文档里写过”代码执行绝对不加”——后来改了:在 JVM 进程内 eval LLM 写的代码确实绝对不行(等于把任意代码执行权交给模型输出),但放进进程外沙箱(OpenSandbox)+ 禁出网 + 默认关(sandbox.enabled)+ 仅内部不对外之后,风险被容器边界圈住,executeCode 就成了可控的计算工具(细节见文档 23)。所以更精确的原则是:危险能力不靠”不暴露”、而靠”隔离”来管控;真正绝不进白名单的是对系统自身状态有写副作用又无法隔离的操作(store_memory / kb_meta 仍硬排除)。工具数量逼近 5-7 个准确率阈值时,再引入分层暴露——先选类别(检索/计算/写入)再选具体工具,参考 AnyTool 的层次化检索。

反击引导#

“工具白名单的背后是 Agent 安全约束的分层设计——软约束(系统提示引导’不要调用写入类工具’)vs 硬约束(代码层 whitelistedToolCallbacks 直接过滤)的完整体系。为什么需要两层?因为 prompt 约束不够可靠——实测模型有时仍然调用被’禁止’的工具。安全设计不能只靠 prompt。”

“RetrievalPlanner 的规则引擎和 PathDecision 三模式路由紧密相连——SIMPLE 且非 ambiguous 走规则引擎选工具组合(确定性、零 LLM 调用),MEDIUM/COMPLEX 走 agentic 让 LLM 自驱工具调用。什么时候让 LLM 做决策、什么时候用规则代替——这是整体编排的核心选型。”

“这些工具通过 MCP 协议对外暴露给 IDE 插件和 Claude Desktop——Spring AI MCP Server Starter 的集成方式、SSE vs WebSocket 的传输选择、以及 McpApiKeyAuthFilter 鉴权层的设计是另一个故事。”

“白名单里唯一的非只读工具 executeCode(代码执行) 是单独一条线——为什么不可信代码必须进程外沙箱、计算结果如何走『计算结果通道』回流 prompt 而不被 rerank 丢弃、四重护栏怎么 fail-closed,见文档 23《代码执行工具接入》。”