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 推理 Agent(编排层) 用户 工具执行 LLM 推理 Agent(编排层) 用户 发送消息 消息 + 工具描述列表 推理:需要调用工具? 返回工具调用请求(名称+参数) 执行工具 返回执行结果 工具结果注入上下文 基于结果生成回答 返回最终回答

关键点:不是我们硬编码「什么时候调什么工具」,而是 LLM 自己决定是否需要调用工具、调用哪个、传什么参数。我们只需要做三件事:

  1. 告诉 LLM 有哪些工具可用(工具描述 + 参数 Schema)
  2. 实现工具的执行逻辑
  3. 把工具结果返回给 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 如何调用工具

① 完整调用链路

把前面三层串起来,一次完整的工具调用是这么走的:

工具执行 LLM Spring AI ChatService ModeRouter Orchestrator 用户 工具执行 LLM Spring AI ChatService ModeRouter Orchestrator 用户 发送消息 分析模式(自由/场景/结构化) AgentDecision(scene, agent) 调用对应 Agent SceneMapping 获取该场景的工具列表 消息 + 工具列表 + 上下文 构造请求(含工具 Schema) 工具调用请求(名称+参数) 通过 FunctionToolCallback 执行 NvcToolResult 工具结果注入 最终回答 返回回答 返回回答 返回给用户
② 每个环节的作用

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 做评估,返回结构化的评估报告。

ProfileQueryToolProfileUpdateTool 是一对。查询工具让 Agent 在生成建议前先了解用户背景(学习进度、偏好、历史练习数据)。更新工具让 Agent 在用户表达偏好时自动更新档案。关键是 ProfileUpdateTool 的 userId 来自 NvcToolContext,不由 LLM 传入——这是防 IDOR 的安全设计。

ScenarioSearchToolScenarioGenerateTool 也是互补的。搜索工具先在场景库里找匹配的场景,找不到时才让生成工具创建新场景。这比每次让 LLM 凭空编场景要靠谱得多——库里的场景经过人工审核,质量有保证

DashboardQueryTool 看起来简单,但很实用。用户说「我最近练得怎么样」,Agent 调这个工具查统计数据,然后用自然语言解读。比让用户自己去翻报表友好得多。

PracticeStartTool 负责启动练习会话。用户选好场景后,这个工具创建一个练习实例,初始化会话状态,返回场景描述。后续的练习交互都在这个会话上下文中进行。

WikiSearchToolWikiWriteTool 目前是存根实现。设计这两个工具是因为我发现,有些知识是用户在练习过程中沉淀下来的——比如某个用户总结了一个很好的 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 可以通过网络调用远程的工具服务器。

Agent 应用

MCP 协议

MCP 协议

工具服务器 B

知识库搜索工具

邮件发送工具

工具服务器 A

数据库查询工具

文件操作工具

MCP Client

② 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. 自研工具体系 — 业务工具
NvcToolRegistryNvcTool 实现们
// 场景化分配、安全上下文、业务抽象

// 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

核心设计原则

  1. 面向接口编程:NvcTool 是抽象,具体工具是实现。换一个工具只需要换实现类。
  2. 自动发现 + 零配置:@Component + Spring IoC,加个注解就被发现。
  3. 适配器模式:NvcTool → FunctionToolCallback 的转换对工具透明,写工具的人不需要知道 Spring AI 的细节。
  4. 最小权限:SceneMapping 控制每个 Agent 的工具边界,不多给。
  5. 安全上下文: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。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐