Spring AI 实战——启用工具驱动的生成

57 阅读9分钟

本章内容

  • 用“工具”增强生成能力
  • 声明式定义工具
  • 将方法与函数暴露为工具
  • 提供工具上下文

上次你去看医生是什么时候?当时你有没有想过,医生在评估你的健康状况时掌握的是什么类型的信息?

医生要经过大量训练才能获得行医执照。训练中他们学习医学与生理学的基础。但新的研究不断出现,患者也会呈现各种不常见的状况,医生需要查阅最新研究并与其他医护人员沟通,才能给出最佳治疗方案。即便如此,再多的训练和研究也无法告诉医生你此刻的感受你的生命体征。要了解你的具体情况,医生必须用体温计、血压计、听诊器等工具实时测量。

从这个角度看,和 LLM 的集成也很相似。LLM 通过海量数据训练,能回答很多问题。若要让 LLM 掌握其训练语料之外的信息,我们可以像第 4 章那样使用 RAG。但有些东西 LLM 无法仅靠 RAG 获得:比如当前天气、股价、比赛比分、主题公园项目的实时排队时间等。这时就需要工具(Tools)

本章将展示如何使用工具为 LLM 提供实时信息,并在需要时让 LLM“采取行动”。

为理解如何应用工具,我们先用 OpenAI 的 GPT-4o 做一个很简单的示例,演示 Spring AI 如何支持工具,以及工具如何弥补 LLM 的部分局限。随后把这些做法迁移到 Board Game Buddy 中,让 LLM 在问答时按需获取实时数据。

6.1 开始使用 AI 工具

和 RAG 很像,工具也是让 LLM 回答其训练之外数据的方式。不同的是:

  • RAG 通过文档把信息提供给 LLM;
  • 工具通过应用逻辑把数据提供给 LLM,甚至还能执行动作(如更新数据、调用外部 API)。这等于让 LLM 不只“回答”,还能“做事”。

并非所有 LLM 都支持工具,但 Spring AI 让在支持工具的模型上启用工具调用变得很容易。许多受支持的提供方/模型都支持工具调用,包括:

  • Amazon Bedrock Converse
  • Anthropic(Claude)
  • Azure OpenAI
  • Google Gemini
  • Groq
  • Mistral
  • MiniMax
  • Moonshot
  • NVIDIA(OpenAI-Proxy)
  • Ollama(多种模型)
  • OpenAI
  • 智谱(ZhiPu)

尽管各家 API 对“工具”的实现细节不同,Spring AI 在提交提示词时提供了一致的工具用法接口。下面我们来实现一个基于工具的 Spring AI 应用。

6.1.1 开发一个启用工具的应用

我们来创建一个全新的 Spring AI 应用,回答全球任意城市的当前时间。先用 Spring Initializr(网页或 IDE)创建项目,包含 spring-boot-starter-webSpring AI OpenAI 启动器,就像第 1 章搭建 Board Game Buddy 时那样。项目名随意,以下示例假设为 simple-tools

创建好后,在 src/main/resources/application.yml 中配置 OpenAI API Key:

spring.ai.openai.api-key: ${OPENAI_API_KEY}

然后创建一个很简单的控制器,向 LLM 询问某城市的当前时间:

代码清单 6.1 向 LLM 询问给定城市的当前时间

package com.example.simpletools;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GetTimeController {

  private static final String CURRENT_TIME_TEMPLATE =
      "What is the current time in {city}?";   // #1

  private final ChatClient chatClient;

  public GetTimeController(ChatClient.Builder chatClientBuilder) {
    this.chatClient = chatClientBuilder.build();
  }

  @GetMapping(path="/time", params = "city")
  public String getTime(@RequestParam("city") String city) {
    return chatClient.prompt()
          .user(userSpec -> {
              userSpec
                  .text(CURRENT_TIME_TEMPLATE)
                  .param("city", city);    // #2
            })
          .call()
          .content();
  }
}

如果你完成了前面章节,这段控制器代码应该很熟悉:/time 接收 city 参数,把它填入用户消息模板,然后返回模型响应的文本内容。

启动应用后试试,比如请求美国新墨西哥州的小镇 Jal:

$ http :8080/time?city=Jal+New+Mexico -b
I'm unable to provide real-time information, including the current time, ...

LLM 理解了问题,也给出了一些建议,但无法提供实时信息——这是 LLM 的天然限制。正是工具能解决的问题:我们定义一个工具来返回当前时间,并在提交问题时把这个工具提供给 LLM。为此,新建一个类,编写获取指定时区当前时间的方法,并让 ChatClient 允许 LLM 调用它:

代码清单 6.2 用 Java 方法定义一个工具

package com.example.simpletools;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.ZoneId;

@Component
public class TimeTools {

  private static final Logger LOGGER =
      LoggerFactory.getLogger(TimeTools.class);

  @Tool(name = "getCurrentTime",
        description = "Get the current time in the specified time zone.")   // #1
  public String getCurrentTime(String timeZone) {
    LOGGER.info("Getting the current time in {}", timeZone);
    var now = LocalDateTime.now(ZoneId.of(timeZone));
    return now.toString();
  }
}

getCurrentTime()LocalDateTime.now(ZoneId.of(...)) 取指定时区当前时间并返回字符串。关键在于方法上的 @Tool 注解:这会把该方法暴露为可被 LLM 调用的工具,并通过 namedescription 向 LLM 描述用途。一个类里可以有多个 @Tool 方法,但名称必须唯一(若未指定 name,默认用方法名)。

接下来,把该工具注入控制器,并在构建 ChatClient 时通过 defaultTools() 提供给它:

代码清单 6.3 发送提示词时启用工具

package com.example.simpletools;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GetTimeController {

  private static final String CURRENT_TIME_TEMPLATE =
      "What is the current time in {city}?";

  private final ChatClient chatClient;

  public GetTimeController(ChatClient.Builder chatClientBuilder,
                           TimeTools timeTools) {
    this.chatClient = chatClientBuilder
        .defaultTools(timeTools)
        .build();
  }

  @GetMapping(path="/time", params = "city")
  public String getTime(@RequestParam("city") String city) {
    return chatClient.prompt()
          .user(userSpec -> {
              userSpec
                  .text(CURRENT_TIME_TEMPLATE)
                  .param("city", city);
            })
          .call()
          .content();
  }
}

再次请求:

$ http :8080/time?city=Jal+New+Mexico -b
The current time in Jal, New Mexico is 9:19 PM on February 19, 2025.

这次模型给出了精确的当前时间。成功!

6.1.2 更深入地理解(Digging deeper)

也许你会好奇:Spring AI 是否把你定义的工具暴露成了一个供 OpenAI 调用的接口?看起来好像 LLM 能直接调用你的工具,但事实并非如此。

当你提出一个涉及“工具”的问题时,你的应用与模型之间会进行一段多轮对话。图 6.1 展示了这段对话中的每一步。

image.png

图 6.1 工具调用是应用与 LLM 之间的多段式对话

对话从用户消息(初始问题)开始,就像发送任何提示词一样。下面是发送给 OpenAI 的 GPT-4o 模型的请求 JSON 示例:

{
  "messages": [
    {
      "content": "What is the current time in Jal New Mexico?",
      "role": "user"
    }
  ],
  "model": "gpt-4o-mini",
  "stream": false,
  "temperature": 0.7,
  "tools": [
    {
      "type": "function",
      "function": {
        "description": "Get the current time in the specified time zone.",
        "name": "getCurrentTime",
        "parameters": {
          "$schema": "https://json-schema.org/draft/2020-12/schema",
          "additionalProperties": false,
          "type": "object",
          "properties": {
            "timeZone": {
              "type": "string"
            }
          },
          "required": [
            "timeZone"
          ]
        }
      }
    }
  ]
}

这个请求的结构适用于 OpenAI 的 API;若你使用其他提供商,结构可能不同。但与“工具”交互的步骤在不同模型之间基本一致。

在这第一次请求中,只有一条携带问题的用户消息;同时,tools 属性列出了可供 LLM 使用的工具数组。此处仅有一个工具 getCurrentTimedescription 描述工具能力,parameters 用 JSON Schema 说明输入参数格式。

模型对该请求的响应大致如下:

{
  "id": "chatcmpl-B2sJlOCkIK1PDnJolKLdri3TtVAzo",
  "object": "chat.completion",
  "created": 1740025153,
  "model": "gpt-4o-mini-2024-07-18",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_n64P6pcemoLpheNqMaXRNve8",
            "type": "function",
            "function": {
              "name": "getCurrentTime",
              "arguments": "{"timeZone":"America/Denver"}"
            }
          }
        ],
        "refusal": null
      },
      "logprobs": null,
      "finish_reason": "tool_calls"
    }
  ],
  "usage": {
    "prompt_tokens": 59,
    "completion_tokens": 19,
    "total_tokens": 78,
    "prompt_tokens_details": {
      "cached_tokens": 0,
      "audio_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 0,
      "audio_tokens": 0,
      "accepted_prediction_tokens": 0,
      "rejected_prediction_tokens": 0
    }
  },
  "service_tier": "default",
  "system_fingerprint": "fp_13eed4fce1"
}

首先注意:此响应并未直接给出答案。LLM 需要帮助,于是返回一条来自“assistant”的消息,请求调用 getCurrentTime 工具。虽然 LLM 不能直接得知 Jal(新墨西哥州)当前时间,但它知道 getCurrentTime 可以做到,且需要一个时区参数。它也知道 Jal 处于 America/Denver 时区,于是请求以该参数调用工具。

注意 finish_reasontool_calls。通常该字段为 stop,表示模型已完成回答;而 tool_calls 则表示对话尚未结束,需要应用代表它调用工具。

好消息是:虽然 LLM 请求应用调用工具,但你无需手写这段调用逻辑。Spring AI 会在幕后完成:

  • 调用 getCurrentTime
  • 把工具结果装入新请求;
  • 将该新请求再次发送给模型进行生成。

这个“第二次”请求大致如下:

{
  "messages": [
    {
      "content": "What is the current time in Jal New Mexico?",
      "role": "user"
    },
    {
      "role": "assistant",
      "tool_calls": [
        {
          "id": "call_n64P6pcemoLpheNqMaXRNve8",
          "type": "function",
          "function": {
            "name": "getCurrentTime",
            "arguments": "{"timeZone":"America/Denver"}"
          }
        }
      ]
    },
    {
      "content": ""2025-02-19T21:19:13.963458"",
      "role": "tool",
      "name": "getCurrentTime",
      "tool_call_id": "call_n64P6pcemoLpheNqMaXRNve8"
    }
  ],
  "model": "gpt-4o-mini",
  "stream": false,
  "temperature": 0.7,
  "tools": [
    {
      "type": "function",
      "function": {
        "description": "Get the current time in the specified time zone.",
        "name": "getCurrentTime",
        "parameters": {
          "$schema": "https://json-schema.org/draft/2020-12/schema",
          "additionalProperties": false,
          "type": "object",
          "properties": {
            "timeZone": {
              "type": "string"
            }
          },
          "required": [
            "timeZone"
          ]
        }
      }
    }
  ]
}

你会看到,它与初始请求类似,仍包含用户问题和工具定义。但现在还多了两条消息:

  • 一条来自 assistant,表示工具调用的意图;
  • 一条 role: "tool" 的消息,携带工具的返回时间

这三条消息构成了当前对话历史,其中最新的工具消息为 LLM 提供了生成答案所需的关键信息。

接下来,模型基于这个上下文返回最终答案:

{
  "id": "chatcmpl-B2sJmwWTsSANYtQfdhitIc1P8L2Rv",
  "object": "chat.completion",
  "created": 1740025154,
  "model": "gpt-4o-mini-2024-07-18",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "The current time in Jal, New Mexico is 9:19 PM
                    on February 19, 2025.",
        "refusal": null
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 103,
    "completion_tokens": 25,
    "total_tokens": 128,
    "prompt_tokens_details": {
      "cached_tokens": 0,
      "audio_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 0,
      "audio_tokens": 0,
      "accepted_prediction_tokens": 0,
      "rejected_prediction_tokens": 0
    }
  },
  "service_tier": "default",
  "system_fingerprint": "fp_13eed4fce1"
}

就是这样!最终响应中的 assistant 消息给出了答案。此时 finish_reasonstop,表示对话已经完成。

以上就是在 Spring AI 中使用工具的核心要点。接下来,我们会把这些做法应用到 Board Game Buddy,并探索在 Spring AI 中使用工具的更多方式。

6.2 实现工具(Implementing tools)

在 Board Game Buddy 应用里,除了回答各种游戏规则相关的问题,我们还希望 API 允许用户反馈某个游戏的复杂度。复杂度用 1–5 的等级来表示,大致分级如下:

1—简单
2—较易
3—中等
4—较难
5—困难

按设想,每个游戏的复杂度评分由 Board Game Buddy 的用户提供,但最终存储在数据库中。因此,这正适合作为工具提供给 LLM 使用的数据。

有了“游戏复杂度”这个设定,接下来我们把它融入 Board Game Buddy 应用,并最终支持回答“某游戏有多难”之类的问题。但在定义供生成式 AI 使用的工具之前,你需要先构建从数据库读取复杂度信息的基础代码。

6.2.1 编写工具的基础设施

游戏数据(例如复杂度)会保存在关系型数据库中。在 Spring 里处理关系数据,Spring Data 是最省心的做法。更具体地,可以使用 Spring Data JDBC,在构建文件中加入依赖:

implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'

你还需要准备一个数据库并在构建中加入数据库驱动。几乎任何关系型数据库都可以;这里先用 H2 内存数据库

runtimeOnly 'com.h2database:h2'

这个依赖不仅会触发 Spring Boot 自动配置创建 Spring Data JDBC 所需的 DataSource,还会直接创建 H2 数据库。你只需要在 src/main/resources 下定义 schema.sql 来建表:

create table Game (
    id identity,
    title varchar(255) not null,
    slug varchar(255) not null,
    complexity float not null
);

Game 表当前比较简单,专注于存放复杂度所需的核心数据,字段包括:

  • id——数据库主键;
  • title——游戏标题;
  • slug——标题的规范化形式(全小写、下划线分隔),与第 4 章中为文档块关联游戏所用的 slug 一致;
  • complexity——游戏复杂度,1 到 5 的实数值。

按设想,这张表会由应用中其他组件在用户提交复杂度评价时写入。但为了演示,我们先用初始化 SQL 插入几条测试数据。在 src/main/resources 目录下创建 data.sql

insert into Game (title, slug, complexity)
          values ('Burger Battle', 'burger_battle', 1.33);
insert into Game (title, slug, complexity)
          values ('Azul', 'azul', 1.76);
insert into Game (title, slug, complexity)
          values ('Carcassonne', 'carcassonne', 1.89);
insert into Game (title, slug, complexity)
          values ('Catan', 'catan', 2.29);
insert into Game (title, slug, complexity)
          values ('Scythe', 'scythe', 3.44);
insert into Game (title, slug, complexity)
          values ('Puerto Rico', 'puerto_rico', 3.27);
insert into Game (title, slug, complexity)
          values ('7 Wonders', '7_wonders', 2.32);

接下来编写真正供工具使用的 Java 代码,首先需要一个承载游戏数据的 Java 类型。如下这个 Game 记录类型就可以:

package com.example.boardgamebuddy.gamedata;

import org.springframework.data.annotation.Id;

public record Game(
    @Id Long id,
    String slug,
    String title,
    float complexity) {

  public GameComplexity complexityEnum() {
    int rounded = Math.round(complexity);
    return GameComplexity.values()[rounded];
  }

}

除了与 Game 表一一对应的属性外,Game 记录还提供了 complexityEnum() 方法,用来把数值型复杂度转换为 GameComplexity 枚举值。GameComplexity 枚举如下:

package com.example.boardgamebuddy.gamedata;

public enum GameComplexity {

  UNKNOWN(0),
  EASY(1),
  MODERATELY_EASY(2),
  MODERATE(3),
  MODERATELY_DIFFICULT(4),
  DIFFICULT(5);

  private final int value;

  GameComplexity(int value) {
    this.value = value;
  }

  public int getValue() {
    return value;
  }

}

然后需要一个仓库接口来从数据库读取游戏数据。下面这个 GameRepository 指定了通过 slug 查找游戏数据的 findBySlug() 方法:

package com.example.boardgamebuddy.gamedata;

import org.springframework.data.repository.CrudRepository;

import java.util.Optional;

public interface GameRepository extends CrudRepository<Game, Long> {

  Optional<Game> findBySlug(String slug);

}

GameRepository 还继承了 Spring Data 的 CrudRepository,从而自动获得多种读写方法。更重要的是,继承 CrudRepository 会触发 Spring Data 在运行时为仓库接口自动生成实现,你无需自己编写实现类。到这里,读取游戏复杂度数据所需的底层设施已经齐备,接下来就可以创建工具,让 AskController 能回答游戏复杂度相关的问题了。

6.2.2 定义工具(Defining the tool)

在清单 6.2 里,你在一个组件类中用 @Tool 注解了 getCurrentTime() 方法来定义工具。下面的清单展示了如何用同样的技巧为“游戏复杂度”创建一个新的 GameTools 组件。

清单 6.4 声明一个“游戏复杂度”工具

@Bean
package com.example.boardgamebuddy;

import com.example.boardgamebuddy.gamedata.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.util.Optional;

@Component
public class GameTools {

  private final GameRepository gameRepository;

  public GameTools(GameRepository gameRepository) {
    this.gameRepository = gameRepository;
  }

  private static final Logger LOGGER = 
      LoggerFactory.getLogger(GameTools.class);

  @Tool(name = "getGameComplexity",
      description = "Returns a game's complexity/difficulty " +
          "given the game's title/name.")
  public GameComplexityResponse getGameComplexity(
            @ToolParam(description="The title of the game")
            String gameTitle) {
    var gameSlug = gameTitle
        .toLowerCase()
        .replace(" ", "_");

    LOGGER.info("Getting complexity for {} ({})",
        gameTitle, gameSlug);

    var gameOpt = gameRepository.findBySlug(gameSlug);

    var game = gameOpt.orElseGet(() -> {
      LOGGER.warn("Game not found: {}", gameSlug);
      return new Game(
          null,
          gameSlug,
          gameTitle,
          GameComplexity.UNKNOWN.getValue());
    });

    return new GameComplexityResponse(
        game.title(), game.complexityEnum());
  }

}

这里,getGameComplexity() 方法与之前的 getCurrentTime() 类似,用 @Tool 进行标注。description 属性给出说明,便于 LLM 判断何时可用该工具来回答问题。方法内部通过注入的 GameRepository 查询游戏数据;若未找到,创建一个占位的 Game 实例以表示复杂度未知。

该工具方法以 String 接收输入(游戏标题)。注意参数 gameTitle 用了 @ToolParam 注解,提供参数说明,帮助 LLM 理解该参数的用途,从而在请求调用工具时正确传参。

查询到复杂度后,工具返回 GameComplexityResponse(携带游戏标题和 GameComplexity 枚举):

package com.example.boardgamebuddy.gamedata;

public record GameComplexityResponse(
    String title, GameComplexity complexity) {
}

剩下的工作,就是把这个工具纳入发送给 LLM 的提示中。下面就来完成它。

6.2.3 让工具投入使用(Putting the tool to work)

ChatClient 提供工具有两种方式:

  1. 在构建 ChatClient 时统一提供:将 GameTools 注入到 AiConfigchatClient() 方法里,并在创建 ChatClient 时传给 defaultTools()
@Bean
ChatClient chatClient(ChatClient.Builder chatClientBuilder,
                      VectorStore vectorStore,
                      GameTools gameTools) {
  return chatClientBuilder
      .defaultAdvisors(
          QuestionAnswerAdvisor.builder(vectorStore)
              .searchRequest(SearchRequest.builder().build()).build(),
          MessageChatMemoryAdvisor.builder(
              MessageWindowChatMemory.builder().build()).build())
      .defaultTools(gameTools)
      .build();
}

这样做能保证:来自该 ChatClient 的所有请求,都会自动携带 GameTools 中所有被 @Tool 注解的方法。

  1. 在构建单次提示时按需提供:把 GameTools 注入到 AskController(或服务类)中,在 askQuestion() 里通过 tools() 传入:
@Service
public class SpringAiBoardGameService implements BoardGameService {

  private final ChatClient chatClient;
  private final GameTools gameTools;

  public SpringAiBoardGameService(ChatClient chatClient,
      GameTools gameTools) {
    this.chatClient = chatClient;
    this.gameTools = gameTools;
  }

  // ...

  @Override
  public Answer askQuestion(Question question, String conversationId) {

    // ...

    return chatClient.prompt()
        .user(question.question())
        .tools(gameTools)
        // ...
        .call()
        .entity(Answer.class);
  }
}

无论选择在构建时全局注册还是在提示时按需注册,现在都可以启动应用进行体验了。针对样例数据,尝试询问几个游戏的复杂度,可能会得到如下结果:

$ http :8080/ask question="What is the complexity?" \
                 gameTitle="Puerto Rico" -b
{
    "answer": "The complexity of the game is moderate.",
    "game": "Puerto Rico"
}

$ http :8080/ask question="How complex is the game?" \
                 gameTitle="Burger Battle" -b
{
    "answer": "The complexity of the game is easy.",
    "game": "Burger Battle"
}

$ http :8080/ask question="What is the difficulty?" gameTitle="Azul" -b
{
    "answer": "The complexity of the game is moderately easy.",
    "game": "Azul"
}

另外,由于在工具里加了 LOGGER.info(),你可以通过日志确认工具确实被调用了,而不是 LLM“编的”。

到这里,Board Game Buddy 既能回答游戏规则问题,也能回答“这个游戏有多难”。在结束本章前,我们再看一种在提示上下文中提供工具的方式。

6.3 将函数用作工具(Enables functions as tools)

Spring AI 在早期里程碑版本中把“tools(工具)”称为“functions(函数)”,后来为了与多数 AI API 的术语保持一致,改用了“工具”。尽管名称改变了,但用“函数”来理解工具依然很贴切:就像 Java 的 Function 接口一样,工具通常接收输入→执行业务→返回结果;当没有输入或输出时,也可以分别映射到 SupplierConsumer

除了用@Tool 注解的 Bean 方法来声明工具,Spring AI 还支持把工具实现为 Function / Supplier / Consumer。下面演示如何把 GameTools 及其 getGameComplexity() 重写为实现 Java Function 的形式。

清单 6.5 以 Java Function 方式定义工具

package com.example.boardgamebuddy.gamedata;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Description;
import org.springframework.stereotype.Component;

import java.util.Optional;
import java.util.function.Function;

@Component
@Description("Fetches the complexity of a game.")
public class GameTools
    implements Function<GameComplexityRequest, GameComplexityResponse> {

  public static final Logger LOGGER =
      LoggerFactory.getLogger(GameTools.class);

  private final GameRepository gameRepository;

  public GameTools(GameRepository gameRepository) {
    this.gameRepository = gameRepository;
  }

  @Override
  public GameComplexityResponse apply(
      GameComplexityRequest gameDataRequest) {
    String gameSlug = gameDataRequest.title()
        .toLowerCase()
        .replace(" ", "_");

    LOGGER.info("Getting complexity for {} ({})",
        gameDataRequest.title(), gameSlug);

    Optional<Game> gameOpt = gameRepository.findBySlug(gameSlug);

    Game game = gameOpt.orElseGet(() -> {
      LOGGER.warn("Game not found: {}", gameSlug);
      return new Game(
          null,
          gameSlug,
          gameDataRequest.title(),
          GameComplexity.UNKNOWN.getValue());
    });

    return new GameComplexityResponse(
        game.title(), game.complexityEnum());
  }

}

这个版本的大体逻辑与清单 6.4 相同,但 GameTools 现在实现了 Function,核心逻辑写在 apply()Function 唯一必须实现的方法)中。类上用 @Description 说明工具用途,并用 @Component 让 Spring 创建 Bean。

与先前的 getGameComplexity() 一样,apply() 返回 GameComplexityResponse。不同的是:Function 形式的工具不能直接接收 String 或其他 Java 原生类型,因此需要用一个自定义类型来包装请求参数——这里是 GameComplexityRequest

package com.example.boardgamebuddy.gamedata;

import org.springframework.context.annotation.Description;

@Description("Request data about a game, given the game title.")
public record GameComplexityRequest(String title) {
}

这里对 GameComplexityRequest 使用了 @Description,与清单 6.4 中 @ToolParam.description 的作用相同:为模型提供参数语义说明。

剩下要做的就是把该工具注册到 ChatClient。但与传入 defaultTools() / tools()实例不同,Function 形式的工具要通过Bean 名称注册到 defaultToolNames()toolNames()。由于 GameTools@Component 标注,默认 Bean 名称是类名首字母小写:gameTools。因此可以在创建 ChatClient 时这样注册:

@Bean
ChatClient chatClient(ChatClient.Builder chatClientBuilder,
                      VectorStore vectorStore) {
  return chatClientBuilder
      .defaultAdvisors(
          QuestionAnswerAdvisor.builder(vectorStore)
              .searchRequest(SearchRequest.builder().build()).build(),
          MessageChatMemoryAdvisor.builder(
              MessageWindowChatMemory.builder().build()).build())
      .defaultToolNames("gameTools")
      .build();
}

如果希望在构建单次提示时再指定工具,可以用 toolNames()

@Override
public Answer askQuestion(Question question, String conversationId) {
  return chatClient.prompt()

      ...

      .toolNames("gameTools")
      .call()
      .entity(Answer.class);
}

无论采用哪种方式,现在都可以启动应用,使用这个基于 Function 的工具来询问游戏复杂度。

小结

  • 通过“工具”,LLM 可以基于实时提供的数据来回答问题。
  • Spring AI 会在发送提示时附带工具的描述与参数模式
  • LLM 不会“直接调用”工具;它会请求应用去调用,然后应用将工具结果作为后续提示的上下文再发回给 LLM。
  • Spring AI 为不同模型与 API 提供了一致的工具调用编程范式
  • 工具既可以是@Tool 注解的方法,也可以是 Function / Supplier / Consumer 的实现。