Agent 工具调用模块:从发现需求到设计实现,再到 Spring AI 帮你做了什么
Agent 工具调用模块:从发现需求到设计实现,再到 Spring AI 帮你做了什么
做 NVC(非暴力沟通)练习 Agent 的时候,我遇到了一个很扎心的问题:Agent 只能聊天,干不了别的。
用户想查自己的练习成绩,Agent 说「我无法访问你的数据」。用户想搜索 NVC 理论,Agent 拿训练数据硬答,知识可能过时。用户让生成一个家庭场景的练习,Agent 编了一个,但没法持久化,下次对话就丢了。
核心矛盾很清楚:LLM 的知识是静态的,但系统里有大量动态数据——用户档案、练习记录、场景库、知识库。Agent 需要一种机制,在对话过程中主动调用这些外部能力。
这个机制就是工具调用(Function Calling)。
下面是我从发现问题、自己设计四层架构、实现 10 个工具,到最后发现 Spring AI 已经帮你做了大部分工作的完整过程。如果你也在做类似的事情,希望能省点弯路。
为什么 Agent 需要工具
① 纯对话 Agent 的三个碰壁场景
最初我们的 Agent 就是个聊天机器人,用户说一句,AI 回一句。很快三个场景就让它露馅了:
用户: "我想查一下我之前的练习成绩"
AI: "抱歉,我无法访问你的练习数据..."
用户: "帮我搜索一下关于'观察'的NVC理论"
AI: "根据我的训练数据,NVC的观察是..."
用户: "给我生成一个家庭场景的练习"
AI: "好的,我来编一个场景..."
第一个场景,Agent 没法访问用户数据库。第二个,Agent 拿训练数据硬答,知识可能过时或不准确。第三个,Agent 编的内容没有持久化,下次对话就没了。
这三个问题的根源一样:LLM 只能处理文本,碰不到外部系统。
② Function Calling 让 LLM 主动「伸手」
现代 LLM(GPT-4、Qwen3、DeepSeek 等)都支持 Function Calling 机制。核心思路是:
关键点:不是我们硬编码「什么时候调什么工具」,而是 LLM 自己决定是否需要调用工具、调用哪个、传什么参数。我们只需要做三件事:
- 告诉 LLM 有哪些工具可用(工具描述 + 参数 Schema)
- 实现工具的执行逻辑
- 把工具结果返回给 LLM
这就是 Function Calling 的本质。接下来的问题是:怎么设计一套工具框架,让工具好写、好注册、好管理?
工具模块的四层架构设计
① 为什么需要分层
最简单的做法是写一堆工具方法,然后在调用的地方 hardcode。但工具多了以后问题就来了:工具怎么注册?怎么发现?哪些 Agent 能用哪些工具?工具的上下文(userId、sessionId)怎么安全传递?
这些问题混在一起,代码会变成一坨。所以我把工具模块拆成了四层,每层只管一件事:
┌──────────────────────────────────────────────────────────┐
│ 编排层 (Orchestrator) │
│ NvcAgentOrchestrator → ModeRouter → AgentDecision │
│ 决定哪个 Agent 场景、可用哪些工具 │
├──────────────────────────────────────────────────────────┤
│ 映射层 (Scene Mapping) │
│ NvcToolSceneMapping — 场景 ↔ 工具 的对应关系 │
├──────────────────────────────────────────────────────────┤
│ 注册层 (Registry) │
│ NvcToolRegistry — 自动发现所有工具,适配为 Spring AI 回调 │
├──────────────────────────────────────────────────────────┤
│ 工具层 (Tools) │
│ NvcTool 接口 + 10 个具体实现 │
│ RagSearchTool / EvaluateNvcTool / ProfileQueryTool ... │
└──────────────────────────────────────────────────────────┘
② 每层只管一件事
工具层:定义工具的契约(接口)和具体实现。每个工具只关心自己做什么。
注册层:自动发现所有实现了 NvcTool 接口的工具,把它们适配成 Spring AI 能理解的格式。
映射层:定义「哪个 Agent 场景能用哪些工具」。不是所有工具都给所有 Agent,按需分配。
编排层:接收用户消息,决定走哪个 Agent 场景,把对应的工具集合注入进去,调用 LLM。
分层的好处是每层可以独立替换。比如想换一个 LLM 提供商,编排层改一下就行,工具层不用动。想加一个新工具,写个实现类加上 @Component,自动被注册层发现。
NvcTool 接口——工具的契约
① 为什么是这四个字段
工具层的核心是一个接口。我需要定义「一个工具长什么样」。想了几个问题:LLM 怎么知道这个工具是干什么的?需要传什么参数?执行完了返回什么?
最后定下来四个字段:
public interface NvcTool {
String name(); // 工具名称,LLM 用这个来引用工具
String description(); // 工具描述,LLM 靠这个决定要不要调用
String inputSchema(); // 参数的 JSON Schema,告诉 LLM 需要传什么
NvcToolResult execute(Map<String, Object> args, NvcToolContext ctx);
}
name() 和 description() 是给 LLM 看的。LLM 拿到工具列表后,靠这两个字段判断「这个工具跟我当前的任务有没有关系」。description 写得好不好,直接决定 LLM 会不会正确调用这个工具。
inputSchema() 返回一个 JSON Schema 字符串,告诉 LLM 这个工具接受哪些参数、每个参数是什么类型、哪些是必填的。LLM 会根据这个 schema 来构造调用参数。
execute() 是实际执行逻辑。接收 LLM 传过来的参数和我们自己的上下文,返回执行结果。
② 为什么用 NvcToolResult record
执行结果用 Java 21 的 record 来定义:
public record NvcToolResult(boolean success, String data, String errorMessage) {
public static NvcToolResult ok(String data) {
return new NvcToolResult(true, data, null);
}
public static NvcToolResult fail(String errorMessage) {
return new NvcToolResult(false, null, errorMessage);
}
}
用 record 的原因是不可变。工具执行完,结果就是结果,不会再被修改。而且 record 是 Java 21 的语法糖,代码量少,语义清晰。
两个静态工厂方法 ok() 和 fail() 让工具的返回代码更简洁。成功就 NvcToolResult.ok(data),失败就 NvcToolResult.fail(msg)。
③ 为什么上下文是自定义的 NvcToolContext
这是个安全设计。工具执行的时候需要知道「是谁在调用」——需要 userId 来查用户数据,需要 sessionId 来关联会话。但如果把这些信息放在 LLM 的参数里,LLM 就可能被恶意提示词诱导,传一个别人的 userId 进去(这就是 IDOR 攻击)。
所以我在工具层和 LLM 之间加了一道墙:
public record NvcToolContext(String userId, String sessionId, String mode) {
// userId 从 SecurityContext 获取,不由 LLM 传入
}
NvcToolContext 的内容由编排层从安全上下文中提取,完全绕过 LLM。LLM 只负责传业务参数(比如搜索关键词),userId 这类安全相关的参数由系统自动注入。
@Override
public NvcToolResult execute(Map<String, Object> args, NvcToolContext ctx) {
String query = (String) args.get("query"); // LLM 传的
String userId = ctx.userId(); // 系统注入的,LLM 不可控
// ...
}
NvcToolRegistry——工具注册与发现
① 自动发现:@Component + Spring IoC
最笨的办法是手动注册每个工具:registry.register(new RagSearchTool())。工具多了以后,漏注册一个就是一个 Bug。
我用的是 Spring 的 @Component + 自动注入。只要工具类加了 @Component,Spring IoC 容器启动时会自动创建实例。Registry 通过构造函数注入拿到所有 NvcTool 实现:
@Component
public class NvcToolRegistry {
private final Map<String, NvcTool> tools = new HashMap<>();
private final Map<String, FunctionToolCallback> callbacks = new HashMap<>();
public NvcToolRegistry(List<NvcTool> toolImplementations) {
for (NvcTool tool : toolImplementations) {
tools.put(tool.name(), tool);
callbacks.put(tool.name(), adaptToSpringAI(tool));
}
}
}
List<NvcTool> 是 Spring 自动注入的——所有实现了 NvcTool 接口的 Bean 都会被收集进来。加一个新工具,只需要写个类加上 @Component,零配置。
② 适配器模式:NvcTool → FunctionToolCallback
Spring AI 不认识我们的 NvcTool 接口,它只认自己的 FunctionToolCallback。所以 Registry 内部做了一层适配器转换:
private FunctionToolCallback adaptToSpringAI(NvcTool tool) {
return FunctionToolCallback.builder()
.function(tool.name(), (request) -> {
NvcToolContext ctx = extractContext();
NvcToolResult result = tool.execute(request.arguments(), ctx);
return result.success() ? result.data() : "Error: " + result.errorMessage();
})
.description(tool.description())
.inputSchema(tool.inputSchema())
.build();
}
这个转换对工具的实现者完全透明。写工具的人只需要关心 NvcTool 接口,不需要知道 Spring AI 的 FunctionToolCallback 是什么。
NvcToolSceneMapping——场景化工具分配
① 为什么不让 LLM 自己选工具
最简单的方案是把所有工具都扔给 LLM,让它自己选。但这么做有三个问题:
Token 成本:10 个工具的描述 + 参数 Schema 大约占 2000-3000 token。每次对话都带上,成本不低。
准确率:工具越多,LLM 选错的概率越高。给它 3 个相关工具,准确率 95%;给它 10 个不相关的工具,准确率可能掉到 70%。
安全边界:有些工具涉及写操作(比如 ProfileUpdateTool),不应该在所有场景下都可用。如果 LLM 在不该更新档案的时候调了更新接口,那就是安全事故。
② 最小权限原则
我的做法是按场景分配工具。每个 Agent 场景(自由对话、场景练习、结构化四步)只拿到它需要的工具,不多给:
@Component
public class NvcToolSceneMapping {
private final Map<String, List<String>> sceneToolMap = Map.of(
"free_dialog", List.of("rag_search", "profile_query"),
"scenario", List.of("scenario_search", "scenario_generate", "practice_start"),
"structured", List.of("rag_search", "evaluate_nvc", "profile_query", "profile_update")
);
public List<NvcTool> getToolsForScene(String scene) {
List<String> toolNames = sceneToolMap.getOrDefault(scene, List.of());
return toolNames.stream()
.map(registry::getTool)
.filter(Objects::nonNull)
.toList();
}
}
自由对话模式只需要知识库搜索和档案查询,不需要评估工具。场景练习模式需要场景搜索、场景生成和启动练习,不需要知识库搜索。结构化四步模式需要全套评估和档案操作,因为这是正式的学习流程。
这就是最小权限:每个场景只拿到它需要的工具,减少误调用和安全风险。
编排层——Agent 如何调用工具
① 完整调用链路
把前面三层串起来,一次完整的工具调用是这么走的:
② 每个环节的作用
Orchestrator 是入口。接收用户消息后,第一步不是直接调 LLM,而是先问 Router「这个消息应该走哪个模式」。
ModeRouter 根据用户消息的意图和当前状态,决定走自由对话、场景练习还是结构化四步。返回一个 AgentDecision,包含场景标识和 Agent 类型。
ChatService 拿到 AgentDecision 后,通过 NvcToolSceneMapping.getToolsForScene(scene) 获取该场景的工具列表,然后构造 Spring AI 的 ChatClient 请求。
Spring AI 把工具列表转换成 LLM 能理解的格式(JSON Schema),连同用户消息一起发给 LLM。
LLM 推理后决定是否调用工具。如果需要,返回工具名称和参数。Spring AI 通过 FunctionToolCallback 执行对应工具,把结果注入上下文,让 LLM 生成最终回答。
整个链路中,工具的实现者只需要关心自己的 execute 方法。注册、发现、适配、场景分配、上下文传递,全部由框架处理。
10 个工具的逐一设计缘由
① 工具总览
| 工具 | 作用 | 触发场景 | 分配的 Agent |
|---|---|---|---|
| RagSearchTool | 知识库语义搜索 | 用户问 NVC 理论问题 | 自由对话、结构化 |
| EvaluateNvcTool | 评估用户 NVC 表达 | 用户完成一步练习 | 结构化四步 |
| ProfileQueryTool | 查询用户档案 | 需要个性化建议 | 自由对话、结构化 |
| ProfileUpdateTool | 更新用户档案 | 用户表达偏好 | 结构化四步 |
| ScenarioSearchTool | 搜索练习场景 | 用户选场景 | 场景练习 |
| ScenarioGenerateTool | AI 生成新场景 | 现有场景不匹配 | 场景练习 |
| DashboardQueryTool | 查询练习统计 | 用户看数据 | 全场景 |
| PracticeStartTool | 启动练习会话 | 用户选好场景 | 场景练习 |
| WikiSearchTool | Wiki 知识搜索 | 知识库补充 | 预留 |
| WikiWriteTool | Wiki 知识写入 | 知识沉淀 | 预留 |
② 每个工具的设计思路
RagSearchTool 是第一个被实现的工具。原因很简单:用户问 NVC 理论问题时,Agent 不能拿训练数据硬答,得去知识库里搜。这个工具调用 NvcRagService 做语义搜索,返回最相关的知识片段。
@Component
public class RagSearchTool implements NvcTool {
private final NvcRagService ragService;
@Override public String name() { return "rag_search"; }
@Override public String description() {
return "搜索NVC知识库,获取相关理论和技巧";
}
@Override public String inputSchema() {
return "{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}";
}
@Override
public NvcToolResult execute(Map<String, Object> args, NvcToolContext ctx) {
String query = (String) args.get("query");
List<String> results = ragService.search(query, 3);
return NvcToolResult.ok(String.join("\n---\n", results));
}
}
EvaluateNvcTool 是最复杂的工具。用户在结构化四步练习中表达了一句话,Agent 需要评估这句表达是否符合 NVC 的四个要素(观察、感受、需要、请求)。这个工具内部会调用 LLM 做评估,返回结构化的评估报告。
ProfileQueryTool 和 ProfileUpdateTool 是一对。查询工具让 Agent 在生成建议前先了解用户背景(学习进度、偏好、历史练习数据)。更新工具让 Agent 在用户表达偏好时自动更新档案。关键是 ProfileUpdateTool 的 userId 来自 NvcToolContext,不由 LLM 传入——这是防 IDOR 的安全设计。
ScenarioSearchTool 和 ScenarioGenerateTool 也是互补的。搜索工具先在场景库里找匹配的场景,找不到时才让生成工具创建新场景。这比每次让 LLM 凭空编场景要靠谱得多——库里的场景经过人工审核,质量有保证。
DashboardQueryTool 看起来简单,但很实用。用户说「我最近练得怎么样」,Agent 调这个工具查统计数据,然后用自然语言解读。比让用户自己去翻报表友好得多。
PracticeStartTool 负责启动练习会话。用户选好场景后,这个工具创建一个练习实例,初始化会话状态,返回场景描述。后续的练习交互都在这个会话上下文中进行。
WikiSearchTool 和 WikiWriteTool 目前是存根实现。设计这两个工具是因为我发现,有些知识是用户在练习过程中沉淀下来的——比如某个用户总结了一个很好的 NVC 表达方式。Wiki 工具可以让 Agent 把这些有价值的内容写入知识库,供其他用户搜索。但目前优先级不高,先占位。
Spring AI 的工具调用体系
① 我们自研了一套框架,然后发现 Spring AI 已经做了很多
前面讲的四层架构是我们自己设计的。但在实现过程中,我逐渐发现 Spring AI 本身已经提供了一套完整的工具调用体系。区别在于:Spring AI 是通用框架,我们的是业务定制。
Spring AI 提供了三种注册工具的方式:
方式一:@Tool 注解
最简单的方式,加个注解就行:
public class MathTools {
@Tool(description = "计算两个数的和")
public int add(int a, int b) {
return a + b;
}
}
Spring AI 自动扫描这个类,把每个 @Tool 方法注册为一个工具。零配置,适合简单场景。但它没有「这个 Agent 只能用这些工具」的概念——所有工具都是全局的。
方式二:FunctionToolCallback
更灵活的方式,手动构造工具回调:
@Bean
public FunctionToolCallback ragSearchCallback(NvcRagService ragService) {
return FunctionToolCallback.builder()
.function("rag_search", (request) -> {
String query = request.arguments().get("query").toString();
return ragService.search(query, 3).toString();
})
.description("搜索NVC知识库")
.inputSchema(schemaJson)
.build();
}
这种方式适合需要精细控制的场景,比如我们的 NvcToolRegistry 内部就是用这种方式做适配的。
方式三:ToolCallbackProvider
动态加载工具,适合工具列表存在数据库或远程服务的场景:
@Bean
public ToolCallbackProvider dynamicToolProvider() {
return () -> {
// 从数据库或远程服务加载工具列表
return loadToolsFromDB().stream()
.map(this::toToolCallback)
.toList();
};
}
② Advisor 机制:工具调用的扩展点
Spring AI 的 Advisor 是一个拦截器模式,可以在工具调用的前后插入自定义逻辑。三个常用的 Advisor:
ToolCallAdvisor:在 LLM 决定调用工具时触发,可以做日志记录、权限检查、调用拦截。
MessageChatMemoryAdvisor:管理对话记忆,确保多轮对话中工具调用的上下文连贯。
SafeGuardAdvisor:安全防护,可以拦截危险的工具调用(比如限制某些工具只能在特定条件下被调用)。
ChatClient.builder(chatModel)
.defaultAdvisors(
new MessageChatMemoryAdvisor(memory),
new SafeGuardAdvisor(Set.of("profile_update"))
)
.build();
这些 Advisor 和我们的 NvcToolSceneMapping 解决的是同一类问题——控制工具的调用边界。区别在于 Advisor 是通用的拦截机制,SceneMapping 是业务级别的场景分配。
③ 自研 vs Spring AI 的本质区别
| 维度 | 自研 NvcTool 体系 | Spring AI @Tool |
|---|---|---|
| 工具分配 | 按场景分配,最小权限 | 全局注册,所有 Agent 共享 |
| 上下文传递 | NvcToolContext,安全隔离 | ToolContext,通用但需自行处理安全 |
| 接口语言 | 业务语言(NVC 领域) | 通用框架语言 |
| 自动发现 | @Component + NvcTool 接口 | @Tool 注解扫描 |
| 适配层 | NvcTool → FunctionToolCallback | 直接使用 |
说白了,Spring AI 提供的是积木,我们做的是用积木搭出来的产品。积木灵活但需要自己组装,产品开箱即用但定制性受限。
MCP 协议——跨进程工具协议
① 为什么需要 MCP
前面讲的工具调用都在同一个 JVM 进程内。但如果两个团队各有一个 Agent,都想共享对方的工具怎么办?或者你写了一个很好用的数据库查询工具,想让其他项目的 Agent 也能用怎么办?
MCP(Model Context Protocol) 就是为了解决这个问题。它是一个开放协议,让 Agent 可以通过网络调用远程的工具服务器。
② MCP vs 直接调用
| 维度 | 直接调用(我们的 NvcTool) | MCP |
|---|---|---|
| 部署方式 | 同进程 | 跨进程/跨网络 |
| 语言限制 | 仅 Java | 任意语言 |
| 复用性 | 项目内复用 | 跨团队/社区复用 |
| 协议标准 | 私有接口 | 开放标准 |
| 适用场景 | 单体应用、微服务内部 | 多团队协作、工具市场 |
MCP 的最大价值是生态复用。社区已经有大量的 MCP Server 实现(GitHub、Slack、数据库、文件系统等),你的 Agent 只要实现 MCP Client,就能调用这些社区工具,不需要自己重新实现。
③ Spring AI 对 MCP 的支持
Spring AI 提供了 MCP 的 Java SDK,可以快速接入:
@Bean
public McpClient mcpClient() {
return McpClient.builder()
.serverUrl("http://tools-server:8080")
.build();
}
接入后,远程工具会自动出现在 Agent 的工具列表中,和本地工具的使用方式完全一样。对 LLM 来说,它分不清这个工具是本地的还是远程的。
选型建议:自研 vs Spring AI vs MCP
① 什么时候用什么
做完整个项目,我的体会是没有银弹,得看场景:
| 场景 | 推荐方案 |
|---|---|
| 简单 CRUD 工具,不需要场景化分配 | Spring AI @Tool,零配置 |
| 需要动态加载工具(从数据库/远程服务) | Spring AI ToolCallbackProvider |
| 需要跨进程/跨团队共享工具 | MCP Server + Client |
| 需要复用社区工具(GitHub、Slack、数据库等) | MCP Client 连接社区 Server |
| 需要精细化的场景化工具分配 + 安全上下文 | 自研(如我们的 NvcTool 体系) |
② 最佳实践:混合使用
我们的项目已经在混合使用三种方案了:
// 1. 自研工具体系 — 业务工具
NvcToolRegistry → NvcTool 实现们
// 场景化分配、安全上下文、业务抽象
// 2. Spring AI @Tool — 通用工具
@Bean ToolCallback interviewSkillsToolCallback
// spring-ai-agent-utils 的 SkillsTool,零配置
// 3. Spring AI Advisor — 扩展点
ToolCallAdvisor / MessageChatMemoryAdvisor / SafeGuardAdvisor
// 日志、记忆、安全防护
// 4. 未来可接入 MCP — 远程工具
// McpClient → 连接外部 MCP Server
自研解决业务定制问题(场景分配、安全上下文、领域抽象),Spring AI 解决通用能力问题(工具注册、LLM 交互、记忆管理),MCP 解决生态复用问题(跨进程、跨团队、社区工具)。
三者不冲突,可以按需组合。
③ 设计思路总结
发现需求 → 设计抽象 → 实现工具 → 注册发现 → 场景分配 → 编排调用
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
"Agent NvcTool 10个具体 NvcTool NvcTool Orchestrator
需要查 接口 实现 Registry Scene → ChatService
知识库" 自动发现 Mapping → Spring AI
+ 适配 最小权限 ChatClient
核心设计原则:
- 面向接口编程:NvcTool 是抽象,具体工具是实现。换一个工具只需要换实现类。
- 自动发现 + 零配置:@Component + Spring IoC,加个注解就被发现。
- 适配器模式:NvcTool → FunctionToolCallback 的转换对工具透明,写工具的人不需要知道 Spring AI 的细节。
- 最小权限:SceneMapping 控制每个 Agent 的工具边界,不多给。
- 安全上下文:userId 从安全上下文获取,不让 LLM 控制,防 IDOR。
写在最后
从「Agent 只能聊天」到「Agent 能调用 10 种外部工具」,这个过程的核心不是代码量,而是设计思路。
先发现需求(Agent 查不了数据),再设计抽象(NvcTool 接口),然后实现工具(10 个具体类),接着解决注册发现(Registry + @Component),再解决场景分配(SceneMapping),最后串起编排链路(Orchestrator → Router → ChatService → Spring AI → LLM)。
做完以后再看 Spring AI 的文档,发现它已经提供了 @Tool、FunctionToolCallback、ToolCallbackProvider、Advisor 这些机制。我们的自研框架本质上是在 Spring AI 的基础上加了一层业务抽象——场景化工具分配和安全上下文传递。
如果你也在做 Agent 工具调用,建议先用 Spring AI 的方案快速跑通,等遇到「全局工具太多」「需要场景化分配」「需要安全隔离」这些问题时,再考虑自研一层。不要一开始就搞四层架构,过度设计比不设计更危险。
项目依赖版本:Spring Boot 4.0.1、Spring AI 2.0.0-M4、spring-ai-agent-utils 0.7.0、Java 21。
更多推荐


所有评论(0)