二哥项目资料

求职派:深度拆解 Spring AI 的工具调用机制,彻底搞懂理解 Function Call

正文 25,069 字 #08 原文
本文目录 · 36 节

星球:Java进阶之路&二哥编程星球 | 专栏:求职派 OpenClaw 架构 | 作者:沉默王二 | 发布:2026-07-14

求职派:深度拆解 Spring AI 的工具调用机制,彻底搞懂理解 Function Call

求职派:深度拆解 Spring AI 的工具调用机制,彻底搞懂理解 Function Call

大模型的知识是训练出来的,训练完成之后就确定了。

你问它“帮我查一下明天北京的天气”,它答不上来——不是它笨,是因为他拿不到实时的天气数据。

Function Call 解决的就是这个问题:让大模型在对话过程中能调用外部工具,拿到训练数据里没有的信息。

这篇内容从工作原理讲起,拆解 Spring AI 的 @Tool 注解和编程式 API,再看求职派在生产环境里怎么用。技术派: https://paicoding.com/jobclaw-function-call

图片.png

我们实现了一套自定义的 ReAct 循环,并通过 ToolContext 给每个工具传递用户身份。读完你会明白,从定义一个工具到它在多 Agent 系统里跑起来,中间到底经历了什么。

01、Function Call 的工作原理

一次工具调用的完整流程分四步。

图片.png

第一步,应用把工具清单随请求一起发给大模型。清单里包含每个工具的名称、功能描述和参数格式,大模型通过这份清单来了解自己有哪些“外挂”可以用。

第二步,大模型判断当前的问题需不需要调用工具。如果需要,它会在响应里返回一条工具调用指令,包含工具名和对应的参数值。

第三步,应用程序根据这条指令找到对应的工具去执行,拿到执行结果。

第四步,把结果回传给大模型,大模型结合工具返回的信息,生成最终的回复交给用户。

注意:大模型自己不执行任何工具。它只负责两件事——决定该调哪个工具,以及生成调用参数。真正干活的始终是应用程序这一侧。

Spring AI 在这个流程之上做了一层封装,把“定义工具、注册工具、执行工具、回传结果”这几步全部自动化了。

图片.png

上图中橙色的部分就是 Spring AI 的工具封装层。

它提供了两种方式把一个 Java 方法变成大模型可以调用的工具:声明式(通过 @Tool 注解)和编程式(通过底层的回调方法)。

先从最简单的声明式开始说起。

02、用 @Tool 注解定义工具

定义一个获取当前时间的工具,只需要在方法上加一个 @Tool 注解就够了。

Java

class DateTimeTools {
    @Tool(description = "Get the current date and time")
    String getCurrentDateTime() {
        return LocalDateTime.now()
            .atZone(LocaleContextHolder.getTimeZone().toZoneId())
            .toString();
    }
}

description 这个属性是写给大模型看的说明书,告诉大模型什么场景下该用这个工具。

它不是代码注释,而是直接影响大模型调用决策的关键信息。描述得不清楚,大模型可能会在该调用的时候不调用,或者在不该调用的时候乱调用。

图片.png

把工具注入到对话中也很简单,调用 ChatClient 时通过 tools() 方法传进去就行了。

Java

chatClient.prompt(msg)
    .tools(new DateTimeTools())
    .call().content();

图片.png

上图对比了两个接口的返回结果。

带工具的接口正确返回了当前时间,不带工具的接口只能给出一个模糊的回答。

差别一目了然。

除了每次对话时手动注入,Spring AI 还支持在创建 ChatClient 时通过 defaultTools() 方法设置默认工具,这样每次对话都会自动带上,不需要重复配置。

如果不用 ChatClient 而是直接用底层的 ChatModel,则需要通过 ToolCallingChatOptions 把工具传入。

三种方式选哪个取决于场景:

  • 工具固定不变就用默认工具

  • 按需切换就用逐次注入

  • 底层调用就走配置项

03、参数和返回值怎么处理

工具方法带参数时,用 @ToolParam 注解给参数加上描述,帮助大模型理解该怎么传值。

Java

@Tool(description = "传入时区,返回对应时区的当前时间")
String getTimeByZoneId(
    @ToolParam(description = "需要查询时间的时区") ZoneId area) {
    return LocalDateTime.now().atZone(area)
        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}

图片.png

用户问“纽约现在几点”,大模型会自动把“纽约”映射到对应的时区参数,然后调用工具拿到结果。

大模型怎么知道参数该传什么格式?

靠 JSON Schema。Spring AI 内置了一个 Schema 生成器,会自动把方法的参数类型转成 JSON Schema 发给大模型。上面这个时区参数生成出来大概是这样的:

JSON

{
  "type": "object",
  "properties": {
    "area": { "type": "string", "description": "需要查询时间的时区" }
  },
  "required": ["area"]
}

除了 @ToolParam,Spring AI 还支持用 Jackson 的 @JsonPropertyDescription 和 @JsonProperty(required = false) 来标注参数描述和是否必传,也支持 Swagger 的 @Schema 注解和 Spring 的 @Nullable 注解。

图片.png

选一种用就行。

返回值方面,大模型只认字符串。Spring AI 定义了一个结果转换器,默认用 Jackson 把返回对象序列化成 JSON 字符串再交给大模型。大多数场景下开箱即用,不需要额外处理。

这里有一个值得注意的实测现象。

同样是获取当前时间的方法, description 写“Get the current date and time”时,大模型偶尔会跳过这个工具;把描述改成“不需要关注用户时区,直接返回当前的时间给用户”之后,调用率明显提升了。

图片.png

工具描述的精确程度,直接决定了大模型的调用行为。

写 description 的时候要站在大模型的角度想:它看到这段描述,能不能准确判断什么时候该调、什么时候不该调?

04、编程式工具定义

@Tool 注解适合工具方法完全由自己实现、可控的场景。但如果工具来自第三方 SDK,或者参数需要动态生成,就得用编程式定义。

图片.png

Spring AI 提供了两个编程式入口。

MethodToolCallback

把一个已有的 Java 方法手动构建成工具。需要自己指定工具定义(名称、描述、参数格式)、方法引用和方法所在的对象实例。

Java

Method method = ReflectionUtils.findMethod(DateTimeTools.class, "getTimeByZoneId", ZoneId.class);
ToolCallback callback = MethodToolCallback.builder()
    .toolDefinition(ToolDefinition.builder()
        .name("getTimeByZoneId").description("传入时区,返回对应时区的当前时间")
        .inputSchema(JsonSchemaGenerator.generateForMethodInput(method)).build())
    .toolMethod(method).toolObject(new DateTimeTools()).build();

比 @Tool 注解写起来繁琐不少,但控制力更强,名称、描述、参数格式全都可以在运行时动态决定。

FunctionToolCallback

如果工具逻辑是通过函数式接口(比如 Function)实现的,可以用函数级的构建器。

输入和输出必须是 POJO 类型(或 void),不支持基本类型和集合类型。

Java

record AreaReq(@ToolParam(description = "时区") ZoneId zoneId) {}
record AreaResp(String time) {}

ToolCallback callback = FunctionToolCallback
    .builder("nowDateByArea", new NowService())
    .description("传入时区,返回对应时区的当前时间")
    .inputType(AreaReq.class).build();

方法工具也有类似的类型限制:不支持 Optional、异步类型(CompletableFuture)、响应式类型(Mono、Flux)和函数式类型作为参数或返回值。

声明式能覆盖绝大多数场景,编程式留给那些需要在运行时动态控制的情况。

二者最终生成的都是同一套工具回调对象,大模型那边看不出任何区别。

05、Spring AI 背后的工具调用流程

工具定义好了,也注入到了对话中。Spring AI 内部到底是怎么完成“拦截调用请求 → 执行工具 → 回传结果”这一整套流程的?

图片.png

完整的流程分五步。

第一步,工具定义随 Prompt 一起发给大模型。大模型拿到的不是 Java 方法本身,而是一份 JSON 格式的工具清单,包含每个工具的名称、描述和参数的 JSON Schema。

第二步,大模型的响应里如果包含了工具调用请求(工具名 + 参数),ChatModel 会拦截这个响应,不直接返回给用户。

第三步,ChatModel 把调用请求转交给内部的工具调用管理器。管理器负责根据工具名找到对应的工具回调对象。

第四步,管理器调用工具的执行方法,把参数传进去,拿到执行结果。结果会被封装成一条工具响应消息,回传给大模型。

第五步,大模型拿到工具返回的信息后,把它作为额外的上下文,生成最终的回复。这条回复通过 ChatModel 返回给调用方。

图片.png

默认情况下,这个过程对开发者完全透明。

Spring AI 自动完成工具选择、参数解析、方法调用和结果回传,不需要手动干预。

但是,Spring AI 也留了一个入口:把 ToolCallingChatOptions 里的 internalToolExecutionEnabled 属性设为 false,框架就不再自动执行工具了,开发者可以自己掌控整个工具调用的生命周期。

什么时候需要手动掌控?

当你想监控每次工具调用的耗时、想控制最大调用轮数、想给工具传递用户身份信息的时候——这些在生产环境里几乎是刚需。

06、求职派的工具调用实战

求职派是一个多 Agent 求职系统,不同的业务 Agent 需要截然不同的工具集。

任务管理 Agent 需要创建定时任务的工具,岗位抓取 Agent 需要操控浏览器的工具,岗位推荐 Agent 需要搜索岗位库的工具。

图片.png

Agent 级工具注册

每个业务 Agent 通过一个 getTools() 方法声明自己需要的工具。工具类里的方法用 @Tool 注解标注,注册时用 Spring AI 的反射扫描工具一次性提取出所有工具方法,转成回调对象数组。

以任务管理 Agent 的工具为例。

注意最后一个参数 ToolContext,它是 Spring AI 提供的上下文传递机制,底层就是一个不可变的 Map。

Java

@Tool(name = "createTask", description = "创建一个新任务")
public String createTask(@JsonPropertyDescription("任务名") String name,
    @JsonPropertyDescription("任务描述") String desc, ToolContext ctx) {
    String userId = (String) ctx.getContext().get("jobClawUserId");
    taskManager.create(name, desc, userId);
    return "任务创建成功";
}

求职派在发起对话时,把用户 ID、用户对象和当前消息一起塞进 ToolContext。工具执行时就能直接拿到当前是哪个用户在操作,不需要从全局状态里去猜。

Java

chatClient.prompt(prompt)
    .toolContext(Map.of(
        "jobClawUserId", user.jobClawUserId(),
        "user", user, "msg", msg))
    .call().content();

这个设计在多用户并发场景下特别重要。

每个请求的用户身份通过 ToolContext 显式注入,不会互相污染,也不依赖线程局部变量这类脆弱的机制。

自定义 ReAct 循环

求职派关闭了 Spring AI 的自动工具执行,自己实现了一套 ReAct 循环控制器。

图片.png

为什么要这么做?

生产环境需要在工具调用的每个阶段插入观测点——记录每轮推理消耗了多少 token、统计工具执行花了多长时间、在异常发生时及时中断循环。

Spring AI 的默认自动执行把这些细节全封装掉了,想观测就得自己接管。

求职派的循环控制器在每次工具调用的前后都会触发中间件回调:推理前、推理后、执行前、执行后、完成、异常——6 个生命周期节点,每个都可以挂载自定义的监控逻辑。

同时还支持设置最大迭代轮数,防止大模型在工具之间死循环。

除了工具调用,求职派还集成了 MCP。

岗位推荐服务通过 MCP 端点对外暴露,外部的 AI 客户端可以直接连接使用。用户甚至可以在对话过程中动态注册新的 MCP 服务器,Agent 的工具集能够在运行时扩展。

求职派如何写到简历上?

项目名称:求职派(JobClaw)

项目简介:OpenClaw 风格的多 Agent 求职系统,IM 渠道接入共享 Agent 内核,支持意图识别、岗位检索、岗位推荐等智能求职服务

技术栈:Java 21、Spring Boot 4.0、Spring AI 2.0、LangGraph4J、JPA/Hibernate、React 19 / Next.js 15

核心职责:

  • 基于 Spring AI 的 @Tool 注解和反射机制实现了 Agent 级工具注册,每个业务 Agent 独立携带工具集,覆盖任务管理、岗位搜索、网页抓取等 20+ 工具方法

  • 设计了基于 ToolContext 的用户身份传递方案,在多用户并发场景下通过不可变 Map 将用户 ID 显式注入工具执行上下文,避免了线程局部变量和全局状态的污染风险

  • 关闭 Spring AI 默认的自动工具执行,自研 ReAct 循环控制器,支持最大迭代轮数控制和 6 个生命周期中间件回调节点,实现了 token 消耗统计和工具执行耗时监控

  • 集成 MCP 客户端和服务端双向能力,岗位推荐服务通过 Streamable HTTP 端点对外暴露,支持运行时动态注册外部 MCP 服务器以扩展 Agent 工具集

  • 实现了 Playwright 浏览器自动化工具插件,包含导航、点击、填写、截图等 8 个工具方法,支持 Agent 通过工具调用完成岗位数据的自动化采集

ending

图片.png

【Function Call:大模型说“我需要调这个”,应用程序说“好的,结果给你”。但正是这个简单的约定,让大模型从一个只能聊天的文字生成器,变成了一个能查天气、能建任务、能搜岗位的智能助手。】

Spring AI 用 @Tool 注解和编程式 API 把这套约定封装成了几行代码的事。

求职派在此基础上做了三层扩展:Agent 级工具注册让不同业务有不同的工具包,ToolContext 解决了多用户身份传递,自定义 ReAct 循环让工具调用的每一步都可观测。

工具定义的核心不在代码有多复杂,在于 description 写得有多清楚。

我们下期见。

大模型的知识是训练出来的,训练完成之后就确定了。

你问它“帮我查一下明天北京的天气”,它答不上来——不是它笨,是因为他拿不到实时的天气数据。

Function Call 解决的就是这个问题:让大模型在对话过程中能调用外部工具,拿到训练数据里没有的信息。

这篇内容从工作原理讲起,拆解 Spring AI 的 @Tool 注解和编程式 API,再看求职派在生产环境里怎么用。技术派: https://paicoding.com/jobclaw-function-call

图片.png

我们实现了一套自定义的 ReAct 循环,并通过 ToolContext 给每个工具传递用户身份。读完你会明白,从定义一个工具到它在多 Agent 系统里跑起来,中间到底经历了什么。

01、Function Call 的工作原理

一次工具调用的完整流程分四步。

图片.png

第一步,应用把工具清单随请求一起发给大模型。清单里包含每个工具的名称、功能描述和参数格式,大模型通过这份清单来了解自己有哪些“外挂”可以用。

第二步,大模型判断当前的问题需不需要调用工具。如果需要,它会在响应里返回一条工具调用指令,包含工具名和对应的参数值。

第三步,应用程序根据这条指令找到对应的工具去执行,拿到执行结果。

第四步,把结果回传给大模型,大模型结合工具返回的信息,生成最终的回复交给用户。

注意:大模型自己不执行任何工具。它只负责两件事——决定该调哪个工具,以及生成调用参数。真正干活的始终是应用程序这一侧。

Spring AI 在这个流程之上做了一层封装,把“定义工具、注册工具、执行工具、回传结果”这几步全部自动化了。

图片.png

上图中橙色的部分就是 Spring AI 的工具封装层。

它提供了两种方式把一个 Java 方法变成大模型可以调用的工具:声明式(通过 @Tool 注解)和编程式(通过底层的回调方法)。

先从最简单的声明式开始说起。

02、用 @Tool 注解定义工具

定义一个获取当前时间的工具,只需要在方法上加一个 @Tool 注解就够了。

Java

class DateTimeTools {
    @Tool(description = "Get the current date and time")
    String getCurrentDateTime() {
        return LocalDateTime.now()
            .atZone(LocaleContextHolder.getTimeZone().toZoneId())
            .toString();
    }
}

description 这个属性是写给大模型看的说明书,告诉大模型什么场景下该用这个工具。

它不是代码注释,而是直接影响大模型调用决策的关键信息。描述得不清楚,大模型可能会在该调用的时候不调用,或者在不该调用的时候乱调用。

图片.png

把工具注入到对话中也很简单,调用 ChatClient 时通过 tools() 方法传进去就行了。

Java

chatClient.prompt(msg)
    .tools(new DateTimeTools())
    .call().content();

图片.png

上图对比了两个接口的返回结果。

带工具的接口正确返回了当前时间,不带工具的接口只能给出一个模糊的回答。

差别一目了然。

除了每次对话时手动注入,Spring AI 还支持在创建 ChatClient 时通过 defaultTools() 方法设置默认工具,这样每次对话都会自动带上,不需要重复配置。

如果不用 ChatClient 而是直接用底层的 ChatModel,则需要通过 ToolCallingChatOptions 把工具传入。

三种方式选哪个取决于场景:

  • 工具固定不变就用默认工具

  • 按需切换就用逐次注入

  • 底层调用就走配置项

03、参数和返回值怎么处理

工具方法带参数时,用 @ToolParam 注解给参数加上描述,帮助大模型理解该怎么传值。

Java

@Tool(description = "传入时区,返回对应时区的当前时间")
String getTimeByZoneId(
    @ToolParam(description = "需要查询时间的时区") ZoneId area) {
    return LocalDateTime.now().atZone(area)
        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}

图片.png

用户问“纽约现在几点”,大模型会自动把“纽约”映射到对应的时区参数,然后调用工具拿到结果。

大模型怎么知道参数该传什么格式?

靠 JSON Schema。Spring AI 内置了一个 Schema 生成器,会自动把方法的参数类型转成 JSON Schema 发给大模型。上面这个时区参数生成出来大概是这样的:

JSON

{
  "type": "object",
  "properties": {
    "area": { "type": "string", "description": "需要查询时间的时区" }
  },
  "required": ["area"]
}

除了 @ToolParam,Spring AI 还支持用 Jackson 的 @JsonPropertyDescription 和 @JsonProperty(required = false) 来标注参数描述和是否必传,也支持 Swagger 的 @Schema 注解和 Spring 的 @Nullable 注解。

图片.png

选一种用就行。

返回值方面,大模型只认字符串。Spring AI 定义了一个结果转换器,默认用 Jackson 把返回对象序列化成 JSON 字符串再交给大模型。大多数场景下开箱即用,不需要额外处理。

这里有一个值得注意的实测现象。

同样是获取当前时间的方法, description 写“Get the current date and time”时,大模型偶尔会跳过这个工具;把描述改成“不需要关注用户时区,直接返回当前的时间给用户”之后,调用率明显提升了。

图片.png

工具描述的精确程度,直接决定了大模型的调用行为。

写 description 的时候要站在大模型的角度想:它看到这段描述,能不能准确判断什么时候该调、什么时候不该调?

04、编程式工具定义

@Tool 注解适合工具方法完全由自己实现、可控的场景。但如果工具来自第三方 SDK,或者参数需要动态生成,就得用编程式定义。

图片.png

Spring AI 提供了两个编程式入口。

MethodToolCallback

把一个已有的 Java 方法手动构建成工具。需要自己指定工具定义(名称、描述、参数格式)、方法引用和方法所在的对象实例。

Java

Method method = ReflectionUtils.findMethod(DateTimeTools.class, "getTimeByZoneId", ZoneId.class);
ToolCallback callback = MethodToolCallback.builder()
    .toolDefinition(ToolDefinition.builder()
        .name("getTimeByZoneId").description("传入时区,返回对应时区的当前时间")
        .inputSchema(JsonSchemaGenerator.generateForMethodInput(method)).build())
    .toolMethod(method).toolObject(new DateTimeTools()).build();

比 @Tool 注解写起来繁琐不少,但控制力更强,名称、描述、参数格式全都可以在运行时动态决定。

FunctionToolCallback

如果工具逻辑是通过函数式接口(比如 Function)实现的,可以用函数级的构建器。

输入和输出必须是 POJO 类型(或 void),不支持基本类型和集合类型。

Java

record AreaReq(@ToolParam(description = "时区") ZoneId zoneId) {}
record AreaResp(String time) {}

ToolCallback callback = FunctionToolCallback
    .builder("nowDateByArea", new NowService())
    .description("传入时区,返回对应时区的当前时间")
    .inputType(AreaReq.class).build();

方法工具也有类似的类型限制:不支持 Optional、异步类型(CompletableFuture)、响应式类型(Mono、Flux)和函数式类型作为参数或返回值。

声明式能覆盖绝大多数场景,编程式留给那些需要在运行时动态控制的情况。

二者最终生成的都是同一套工具回调对象,大模型那边看不出任何区别。

05、Spring AI 背后的工具调用流程

工具定义好了,也注入到了对话中。Spring AI 内部到底是怎么完成“拦截调用请求 → 执行工具 → 回传结果”这一整套流程的?

图片.png

完整的流程分五步。

第一步,工具定义随 Prompt 一起发给大模型。大模型拿到的不是 Java 方法本身,而是一份 JSON 格式的工具清单,包含每个工具的名称、描述和参数的 JSON Schema。

第二步,大模型的响应里如果包含了工具调用请求(工具名 + 参数),ChatModel 会拦截这个响应,不直接返回给用户。

第三步,ChatModel 把调用请求转交给内部的工具调用管理器。管理器负责根据工具名找到对应的工具回调对象。

第四步,管理器调用工具的执行方法,把参数传进去,拿到执行结果。结果会被封装成一条工具响应消息,回传给大模型。

第五步,大模型拿到工具返回的信息后,把它作为额外的上下文,生成最终的回复。这条回复通过 ChatModel 返回给调用方。

图片.png

默认情况下,这个过程对开发者完全透明。

Spring AI 自动完成工具选择、参数解析、方法调用和结果回传,不需要手动干预。

但是,Spring AI 也留了一个入口:把 ToolCallingChatOptions 里的 internalToolExecutionEnabled 属性设为 false,框架就不再自动执行工具了,开发者可以自己掌控整个工具调用的生命周期。

什么时候需要手动掌控?

当你想监控每次工具调用的耗时、想控制最大调用轮数、想给工具传递用户身份信息的时候——这些在生产环境里几乎是刚需。

06、求职派的工具调用实战

求职派是一个多 Agent 求职系统,不同的业务 Agent 需要截然不同的工具集。

任务管理 Agent 需要创建定时任务的工具,岗位抓取 Agent 需要操控浏览器的工具,岗位推荐 Agent 需要搜索岗位库的工具。

图片.png

Agent 级工具注册

每个业务 Agent 通过一个 getTools() 方法声明自己需要的工具。工具类里的方法用 @Tool 注解标注,注册时用 Spring AI 的反射扫描工具一次性提取出所有工具方法,转成回调对象数组。

以任务管理 Agent 的工具为例。

注意最后一个参数 ToolContext,它是 Spring AI 提供的上下文传递机制,底层就是一个不可变的 Map。

Java

@Tool(name = "createTask", description = "创建一个新任务")
public String createTask(@JsonPropertyDescription("任务名") String name,
    @JsonPropertyDescription("任务描述") String desc, ToolContext ctx) {
    String userId = (String) ctx.getContext().get("jobClawUserId");
    taskManager.create(name, desc, userId);
    return "任务创建成功";
}

求职派在发起对话时,把用户 ID、用户对象和当前消息一起塞进 ToolContext。工具执行时就能直接拿到当前是哪个用户在操作,不需要从全局状态里去猜。

Java

chatClient.prompt(prompt)
    .toolContext(Map.of(
        "jobClawUserId", user.jobClawUserId(),
        "user", user, "msg", msg))
    .call().content();

这个设计在多用户并发场景下特别重要。

每个请求的用户身份通过 ToolContext 显式注入,不会互相污染,也不依赖线程局部变量这类脆弱的机制。

自定义 ReAct 循环

求职派关闭了 Spring AI 的自动工具执行,自己实现了一套 ReAct 循环控制器。

图片.png

为什么要这么做?

生产环境需要在工具调用的每个阶段插入观测点——记录每轮推理消耗了多少 token、统计工具执行花了多长时间、在异常发生时及时中断循环。

Spring AI 的默认自动执行把这些细节全封装掉了,想观测就得自己接管。

求职派的循环控制器在每次工具调用的前后都会触发中间件回调:推理前、推理后、执行前、执行后、完成、异常——6 个生命周期节点,每个都可以挂载自定义的监控逻辑。

同时还支持设置最大迭代轮数,防止大模型在工具之间死循环。

除了工具调用,求职派还集成了 MCP。

岗位推荐服务通过 MCP 端点对外暴露,外部的 AI 客户端可以直接连接使用。用户甚至可以在对话过程中动态注册新的 MCP 服务器,Agent 的工具集能够在运行时扩展。

求职派如何写到简历上?

项目名称:求职派(JobClaw)

项目简介:OpenClaw 风格的多 Agent 求职系统,IM 渠道接入共享 Agent 内核,支持意图识别、岗位检索、岗位推荐等智能求职服务

技术栈:Java 21、Spring Boot 4.0、Spring AI 2.0、LangGraph4J、JPA/Hibernate、React 19 / Next.js 15

核心职责:

  • 基于 Spring AI 的 @Tool 注解和反射机制实现了 Agent 级工具注册,每个业务 Agent 独立携带工具集,覆盖任务管理、岗位搜索、网页抓取等 20+ 工具方法

  • 设计了基于 ToolContext 的用户身份传递方案,在多用户并发场景下通过不可变 Map 将用户 ID 显式注入工具执行上下文,避免了线程局部变量和全局状态的污染风险

  • 关闭 Spring AI 默认的自动工具执行,自研 ReAct 循环控制器,支持最大迭代轮数控制和 6 个生命周期中间件回调节点,实现了 token 消耗统计和工具执行耗时监控

  • 集成 MCP 客户端和服务端双向能力,岗位推荐服务通过 Streamable HTTP 端点对外暴露,支持运行时动态注册外部 MCP 服务器以扩展 Agent 工具集

  • 实现了 Playwright 浏览器自动化工具插件,包含导航、点击、填写、截图等 8 个工具方法,支持 Agent 通过工具调用完成岗位数据的自动化采集

ending

图片.png

【Function Call:大模型说“我需要调这个”,应用程序说“好的,结果给你”。但正是这个简单的约定,让大模型从一个只能聊天的文字生成器,变成了一个能查天气、能建任务、能搜岗位的智能助手。】

Spring AI 用 @Tool 注解和编程式 API 把这套约定封装成了几行代码的事。

求职派在此基础上做了三层扩展:Agent 级工具注册让不同业务有不同的工具包,ToolContext 解决了多用户身份传递,自定义 ReAct 循环让工具调用的每一步都可观测。

工具定义的核心不在代码有多复杂,在于 description 写得有多清楚。

我们下期见。

大模型的知识是训练出来的,训练完成之后就确定了。

你问它“帮我查一下明天北京的天气”,它答不上来——不是它笨,是因为他拿不到实时的天气数据。

Function Call 解决的就是这个问题:让大模型在对话过程中能调用外部工具,拿到训练数据里没有的信息。

这篇内容从工作原理讲起,拆解 Spring AI 的 @Tool 注解和编程式 API,再看求职派在生产环境里怎么用。技术派: https://paicoding.com/jobclaw-function-call

图片.png

我们实现了一套自定义的 ReAct 循环,并通过 ToolContext 给每个工具传递用户身份。读完你会明白,从定义一个工具到它在多 Agent 系统里跑起来,中间到底经历了什么。

01、Function Call 的工作原理

一次工具调用的完整流程分四步。

图片.png

第一步,应用把工具清单随请求一起发给大模型。清单里包含每个工具的名称、功能描述和参数格式,大模型通过这份清单来了解自己有哪些“外挂”可以用。

第二步,大模型判断当前的问题需不需要调用工具。如果需要,它会在响应里返回一条工具调用指令,包含工具名和对应的参数值。

第三步,应用程序根据这条指令找到对应的工具去执行,拿到执行结果。

第四步,把结果回传给大模型,大模型结合工具返回的信息,生成最终的回复交给用户。

注意:大模型自己不执行任何工具。它只负责两件事——决定该调哪个工具,以及生成调用参数。真正干活的始终是应用程序这一侧。

Spring AI 在这个流程之上做了一层封装,把“定义工具、注册工具、执行工具、回传结果”这几步全部自动化了。

图片.png

上图中橙色的部分就是 Spring AI 的工具封装层。

它提供了两种方式把一个 Java 方法变成大模型可以调用的工具:声明式(通过 @Tool 注解)和编程式(通过底层的回调方法)。

先从最简单的声明式开始说起。

02、用 @Tool 注解定义工具

定义一个获取当前时间的工具,只需要在方法上加一个 @Tool 注解就够了。

Java

class DateTimeTools {
    @Tool(description = "Get the current date and time")
    String getCurrentDateTime() {
        return LocalDateTime.now()
            .atZone(LocaleContextHolder.getTimeZone().toZoneId())
            .toString();
    }
}

description 这个属性是写给大模型看的说明书,告诉大模型什么场景下该用这个工具。

它不是代码注释,而是直接影响大模型调用决策的关键信息。描述得不清楚,大模型可能会在该调用的时候不调用,或者在不该调用的时候乱调用。

图片.png

把工具注入到对话中也很简单,调用 ChatClient 时通过 tools() 方法传进去就行了。

Java

chatClient.prompt(msg)
    .tools(new DateTimeTools())
    .call().content();

图片.png

上图对比了两个接口的返回结果。

带工具的接口正确返回了当前时间,不带工具的接口只能给出一个模糊的回答。

差别一目了然。

除了每次对话时手动注入,Spring AI 还支持在创建 ChatClient 时通过 defaultTools() 方法设置默认工具,这样每次对话都会自动带上,不需要重复配置。

如果不用 ChatClient 而是直接用底层的 ChatModel,则需要通过 ToolCallingChatOptions 把工具传入。

三种方式选哪个取决于场景:

  • 工具固定不变就用默认工具

  • 按需切换就用逐次注入

  • 底层调用就走配置项

03、参数和返回值怎么处理

工具方法带参数时,用 @ToolParam 注解给参数加上描述,帮助大模型理解该怎么传值。

Java

@Tool(description = "传入时区,返回对应时区的当前时间")
String getTimeByZoneId(
    @ToolParam(description = "需要查询时间的时区") ZoneId area) {
    return LocalDateTime.now().atZone(area)
        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}

图片.png

用户问“纽约现在几点”,大模型会自动把“纽约”映射到对应的时区参数,然后调用工具拿到结果。

大模型怎么知道参数该传什么格式?

靠 JSON Schema。Spring AI 内置了一个 Schema 生成器,会自动把方法的参数类型转成 JSON Schema 发给大模型。上面这个时区参数生成出来大概是这样的:

JSON

{
  "type": "object",
  "properties": {
    "area": { "type": "string", "description": "需要查询时间的时区" }
  },
  "required": ["area"]
}

除了 @ToolParam,Spring AI 还支持用 Jackson 的 @JsonPropertyDescription 和 @JsonProperty(required = false) 来标注参数描述和是否必传,也支持 Swagger 的 @Schema 注解和 Spring 的 @Nullable 注解。

图片.png

选一种用就行。

返回值方面,大模型只认字符串。Spring AI 定义了一个结果转换器,默认用 Jackson 把返回对象序列化成 JSON 字符串再交给大模型。大多数场景下开箱即用,不需要额外处理。

这里有一个值得注意的实测现象。

同样是获取当前时间的方法, description 写“Get the current date and time”时,大模型偶尔会跳过这个工具;把描述改成“不需要关注用户时区,直接返回当前的时间给用户”之后,调用率明显提升了。

图片.png

工具描述的精确程度,直接决定了大模型的调用行为。

写 description 的时候要站在大模型的角度想:它看到这段描述,能不能准确判断什么时候该调、什么时候不该调?

04、编程式工具定义

@Tool 注解适合工具方法完全由自己实现、可控的场景。但如果工具来自第三方 SDK,或者参数需要动态生成,就得用编程式定义。

图片.png

Spring AI 提供了两个编程式入口。

MethodToolCallback

把一个已有的 Java 方法手动构建成工具。需要自己指定工具定义(名称、描述、参数格式)、方法引用和方法所在的对象实例。

Java

Method method = ReflectionUtils.findMethod(DateTimeTools.class, "getTimeByZoneId", ZoneId.class);
ToolCallback callback = MethodToolCallback.builder()
    .toolDefinition(ToolDefinition.builder()
        .name("getTimeByZoneId").description("传入时区,返回对应时区的当前时间")
        .inputSchema(JsonSchemaGenerator.generateForMethodInput(method)).build())
    .toolMethod(method).toolObject(new DateTimeTools()).build();

比 @Tool 注解写起来繁琐不少,但控制力更强,名称、描述、参数格式全都可以在运行时动态决定。

FunctionToolCallback

如果工具逻辑是通过函数式接口(比如 Function)实现的,可以用函数级的构建器。

输入和输出必须是 POJO 类型(或 void),不支持基本类型和集合类型。

Java

record AreaReq(@ToolParam(description = "时区") ZoneId zoneId) {}
record AreaResp(String time) {}

ToolCallback callback = FunctionToolCallback
    .builder("nowDateByArea", new NowService())
    .description("传入时区,返回对应时区的当前时间")
    .inputType(AreaReq.class).build();

方法工具也有类似的类型限制:不支持 Optional、异步类型(CompletableFuture)、响应式类型(Mono、Flux)和函数式类型作为参数或返回值。

声明式能覆盖绝大多数场景,编程式留给那些需要在运行时动态控制的情况。

二者最终生成的都是同一套工具回调对象,大模型那边看不出任何区别。

05、Spring AI 背后的工具调用流程

工具定义好了,也注入到了对话中。Spring AI 内部到底是怎么完成“拦截调用请求 → 执行工具 → 回传结果”这一整套流程的?

图片.png

完整的流程分五步。

第一步,工具定义随 Prompt 一起发给大模型。大模型拿到的不是 Java 方法本身,而是一份 JSON 格式的工具清单,包含每个工具的名称、描述和参数的 JSON Schema。

第二步,大模型的响应里如果包含了工具调用请求(工具名 + 参数),ChatModel 会拦截这个响应,不直接返回给用户。

第三步,ChatModel 把调用请求转交给内部的工具调用管理器。管理器负责根据工具名找到对应的工具回调对象。

第四步,管理器调用工具的执行方法,把参数传进去,拿到执行结果。结果会被封装成一条工具响应消息,回传给大模型。

第五步,大模型拿到工具返回的信息后,把它作为额外的上下文,生成最终的回复。这条回复通过 ChatModel 返回给调用方。

图片.png

默认情况下,这个过程对开发者完全透明。

Spring AI 自动完成工具选择、参数解析、方法调用和结果回传,不需要手动干预。

但是,Spring AI 也留了一个入口:把 ToolCallingChatOptions 里的 internalToolExecutionEnabled 属性设为 false,框架就不再自动执行工具了,开发者可以自己掌控整个工具调用的生命周期。

什么时候需要手动掌控?

当你想监控每次工具调用的耗时、想控制最大调用轮数、想给工具传递用户身份信息的时候——这些在生产环境里几乎是刚需。

06、求职派的工具调用实战

求职派是一个多 Agent 求职系统,不同的业务 Agent 需要截然不同的工具集。

任务管理 Agent 需要创建定时任务的工具,岗位抓取 Agent 需要操控浏览器的工具,岗位推荐 Agent 需要搜索岗位库的工具。

图片.png

Agent 级工具注册

每个业务 Agent 通过一个 getTools() 方法声明自己需要的工具。工具类里的方法用 @Tool 注解标注,注册时用 Spring AI 的反射扫描工具一次性提取出所有工具方法,转成回调对象数组。

以任务管理 Agent 的工具为例。

注意最后一个参数 ToolContext,它是 Spring AI 提供的上下文传递机制,底层就是一个不可变的 Map。

Java

@Tool(name = "createTask", description = "创建一个新任务")
public String createTask(@JsonPropertyDescription("任务名") String name,
    @JsonPropertyDescription("任务描述") String desc, ToolContext ctx) {
    String userId = (String) ctx.getContext().get("jobClawUserId");
    taskManager.create(name, desc, userId);
    return "任务创建成功";
}

求职派在发起对话时,把用户 ID、用户对象和当前消息一起塞进 ToolContext。工具执行时就能直接拿到当前是哪个用户在操作,不需要从全局状态里去猜。

Java

chatClient.prompt(prompt)
    .toolContext(Map.of(
        "jobClawUserId", user.jobClawUserId(),
        "user", user, "msg", msg))
    .call().content();

这个设计在多用户并发场景下特别重要。

每个请求的用户身份通过 ToolContext 显式注入,不会互相污染,也不依赖线程局部变量这类脆弱的机制。

自定义 ReAct 循环

求职派关闭了 Spring AI 的自动工具执行,自己实现了一套 ReAct 循环控制器。

图片.png

为什么要这么做?

生产环境需要在工具调用的每个阶段插入观测点——记录每轮推理消耗了多少 token、统计工具执行花了多长时间、在异常发生时及时中断循环。

Spring AI 的默认自动执行把这些细节全封装掉了,想观测就得自己接管。

求职派的循环控制器在每次工具调用的前后都会触发中间件回调:推理前、推理后、执行前、执行后、完成、异常——6 个生命周期节点,每个都可以挂载自定义的监控逻辑。

同时还支持设置最大迭代轮数,防止大模型在工具之间死循环。

除了工具调用,求职派还集成了 MCP。

岗位推荐服务通过 MCP 端点对外暴露,外部的 AI 客户端可以直接连接使用。用户甚至可以在对话过程中动态注册新的 MCP 服务器,Agent 的工具集能够在运行时扩展。

求职派如何写到简历上?

项目名称:求职派(JobClaw)

项目简介:OpenClaw 风格的多 Agent 求职系统,IM 渠道接入共享 Agent 内核,支持意图识别、岗位检索、岗位推荐等智能求职服务

技术栈:Java 21、Spring Boot 4.0、Spring AI 2.0、LangGraph4J、JPA/Hibernate、React 19 / Next.js 15

核心职责:

  • 基于 Spring AI 的 @Tool 注解和反射机制实现了 Agent 级工具注册,每个业务 Agent 独立携带工具集,覆盖任务管理、岗位搜索、网页抓取等 20+ 工具方法

  • 设计了基于 ToolContext 的用户身份传递方案,在多用户并发场景下通过不可变 Map 将用户 ID 显式注入工具执行上下文,避免了线程局部变量和全局状态的污染风险

  • 关闭 Spring AI 默认的自动工具执行,自研 ReAct 循环控制器,支持最大迭代轮数控制和 6 个生命周期中间件回调节点,实现了 token 消耗统计和工具执行耗时监控

  • 集成 MCP 客户端和服务端双向能力,岗位推荐服务通过 Streamable HTTP 端点对外暴露,支持运行时动态注册外部 MCP 服务器以扩展 Agent 工具集

  • 实现了 Playwright 浏览器自动化工具插件,包含导航、点击、填写、截图等 8 个工具方法,支持 Agent 通过工具调用完成岗位数据的自动化采集

ending

图片.png

【Function Call:大模型说“我需要调这个”,应用程序说“好的,结果给你”。但正是这个简单的约定,让大模型从一个只能聊天的文字生成器,变成了一个能查天气、能建任务、能搜岗位的智能助手。】

Spring AI 用 @Tool 注解和编程式 API 把这套约定封装成了几行代码的事。

求职派在此基础上做了三层扩展:Agent 级工具注册让不同业务有不同的工具包,ToolContext 解决了多用户身份传递,自定义 ReAct 循环让工具调用的每一步都可观测。

工具定义的核心不在代码有多复杂,在于 description 写得有多清楚。

我们下期见。