【AI 01】【Spring AI 基础使用】

文章目录

  • 一、前言
  • [二、Spring AI](#二、Spring AI)
  • [三、 简单示例](#三、 简单示例)
  • 四、关键组件
    • [1. ChatModel & ChatClient](#1. ChatModel & ChatClient)
      • [1.1 ChatClient 的参数](#1.1 ChatClient 的参数)
    • [2. ChatMemory & ChatMemoryRepository](#2. ChatMemory & ChatMemoryRepository)
      • [2.1 ChatMemory](#2.1 ChatMemory)
      • [2.2 ChatMemoryRepository](#2.2 ChatMemoryRepository)
    • [3. Advisor](#3. Advisor)
    • [4. Tool 调用](#4. Tool 调用)
      • [4.1. Tool 的注入](#4.1. Tool 的注入)
      • [4.2. ToolCallback](#4.2. ToolCallback)
      • [4.3. ToolCallingAdvisor](#4.3. ToolCallingAdvisor)
      • [4.4 Tool 调用示例](#4.4 Tool 调用示例)
  • 五、参考内容

一、前言

本系列作为 AI 系列内容,内容预计会相当发散,仅做个人笔记。

本系列完整代码 :spring-ai-hwl

本篇完整代码 :spring-ai-hwl/spring-ai-01-base


【LangChain4j 01】【基本使用】 及其系列文章中,我们介绍了 LangChain4j 的基本使用。那么这里我们来介绍下 Spring AI 2.0 的基本使用。

本篇仅介绍 Spring AI 的基本使用,目的是后续使用 Spring AI 为基础的文章做铺垫,所以不会像 LangChain4j 那样详细介绍每一个特性。


本篇代码使用版本如下:

xml 复制代码
    <properties>
        <!-- Spring Boot -->
        <spring-boot.version>4.1.1</spring-boot.version>
        <!-- Spring AI,需单独引入 BOM,Boot BOM 不管理该版本 -->
        <spring-ai.version>2.0.1</spring-ai.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- Spring AI 依赖清单,加载 BOM 后统一管理版本 -->
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
           </dependencies>
    </dependencyManagement>

二、Spring AI

Spring AI 与 LangChain4j 很大的不同是。 LangChain4j 将各个组件拆分的很细,开发者只需要实现对应的接口即可,流程链路的组装交由 LangChain4j 来完成。

本篇涉及内容完整代码 :spring-ai-hwl/spring-ai-01-base

而 Spring AI 则是全权托管给开发者 :Spring AI 提供了 Advisor 接口,通过实现 Advisor 接口来自定义 AI 调用前后的逻辑。

Advisor 我们下面会详细介绍 。


Spring AI 也提供了 ChatClient、ChatModel、ChatMemory 等接口我们可以通过这些接口完成基础的 LLM 调用。

三、 简单示例

我们搭建一个简单的 Spring AI 框架,以实现基础的 LLM 对话功能。关键代码如下:

  1. yaml 配置

    java 复制代码
    spring:
      application:
        name: spring-ai-base
      ai:
        openai:
          # DeepSeek 兼容 OpenAI 协议
          base-url: https://api.deepseek.com
          # 环境变量中获取 API Key,建议在运行时设置
          api-key: ${DEEPSEEK_API_KEY}
          chat:
            model: deepseek-v4-flash
            temperature: 0.7
        model:
          embedding: none
          image: none
          moderation: none
          audio:
            speech: none
            transcription: none
  2. 基本配置

    java 复制代码
    @Configuration
    public class SpringAIBaseConfig {
    
        /**
         * 对话记忆:滑动窗口,保留最近10轮消息(可修改),内存存储
         */
        @Bean
        public ChatMemory chatMemory() {
            return MessageWindowChatMemory.builder()
                    .maxMessages(10)
                    .chatMemoryRepository(new InMemoryChatMemoryRepository())
                    .build();
        }
    
        /**
         * ChatModel 的高级封装
         */
        @Bean
        public ChatClient chatClient(OpenAiChatModel openAiChatModel, ChatMemory chatMemory) {
            return ChatClient.builder(openAiChatModel)
                    .defaultSystem("你是专业的怼人高手")
                    .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                    .build();
        }
    }
  3. 调用实现

    java 复制代码
    @Service
    public class BaseLlmServiceImpl implements BaseLlmService {
    
        @Resource
        private OpenAiChatModel chatModel;
    
        @Resource
        private ChatClient chatClient;
    
        @Override
        public String chat(String userMessage) {
            return chatModel.call(userMessage);
        }
    
        @Override
        public Flux<String> streamChat(String message) {
            return chatModel.stream(message);
        }
    
    
    }

基于上面的代码,我们便可以实现一个简单的基于 Spring AI 的 LLM 调用。

四、关键组件

上面的示例中我们在注入 ChatClient 的时候依赖于两个参数 :ChatModel 和 ChatMemory, 同时还构建了 Advisor 对象。下面我们简单来介绍这些内容

java 复制代码
    @Bean
    public ChatClient chatClient(OpenAiChatModel openAiChatModel, ChatMemory chatMemory) {
        return ChatClient.builder(openAiChatModel)
                .defaultSystem("你是专业的怼人高手")
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }

1. ChatModel & ChatClient

由于各家模型的请求响应标准不尽相同,因此针对不同的LLM提供商,需要构建不同的请求方式,基于上述原因,Spring AI 抽象出了ChatModel 接口,其作用是对外统一成 Prompt 进、ChatResponse 出。


ChatModel 的接口定义如下:

java 复制代码
public interface ChatModel extends Model<Prompt, ChatResponse>, StreamingChatModel {

	default @Nullable String call(String message) {
		Prompt prompt = new Prompt(new UserMessage(message));
		Generation generation = call(prompt).getResult();
		return (generation != null) ? generation.getOutput().getText() : "";
	}

	default @Nullable String call(Message... messages) {
		Prompt prompt = new Prompt(Arrays.asList(messages));
		Generation generation = call(prompt).getResult();
		return (generation != null) ? generation.getOutput().getText() : "";
	}

	@Override
	ChatResponse call(Prompt prompt);

	/**
	 * Gets the chat options for this model.
	 * @return the chat options
	 * @since 2.0.0
	 */
	default ChatOptions getOptions() {
		return ChatOptions.builder().build();
	}

	/**
	 * @deprecated use {@link #getOptions()} instead.
	 */
	@Deprecated(forRemoval = true)
	default ChatOptions getDefaultOptions() {
		return getOptions();
	}

	default Flux<ChatResponse> stream(Prompt prompt) {
		throw new UnsupportedOperationException("streaming is not supported");
	}

}

ChatModel 的能力是完成一次 LLM 调用,并不能支持我们实际业务需要,因此在 ChatModel 的基础上, Spring AI 提供了 ChatClient ,作为 ChatModel 的上层封装,在其基础上提供了业务编排能力,如 Advisor、Tool 等。

ChatClient 接口定义如下 :

java 复制代码
public interface ChatClient {
    ...

    ChatClientRequestSpec prompt();

    ChatClientRequestSpec prompt(String content);

    ChatClientRequestSpec prompt(Prompt prompt);

    Builder mutate();

    ....
}

1.1 ChatClient 的参数

ChatClient 可以通过构造器 ChatClient#builder 方式构建,在构建的时候存在多种默认值设置,这些参数值在实际发起调用的时候也可以进行赋值,但是其赋值后的处理策略并不相同,有的是覆盖 默认值,有的则是合并默认值。

java 复制代码
@Bean
public ChatClient chatClient(OpenAiChatModel openAiChatModel, ChatMemory chatMemory) {
    return ChatClient.builder(openAiChatModel)
            .defaultSystem("你是专业的怼人高手")
            .defaultUser()
            .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
            .defaultOptions(ChatOptions.builder()
                    .temperature(0.7)
                    .model("deepseek-v4-flash"))
            .defaultTools()
            .defaultToolContext()
            .defaultTemplateRenderer()
            .build();
}


-------

// 发起调用时,可以通过诸如 system()、user()、tools() 等方法来对参数进行重新赋值,
// 这里的  system()、user() 会覆盖 default 参数,而 tools 则是跟 default 参数合并,具体见下面【调用赋值策略】
@Override
public String chatWithTools(String userMessage, String conversationId) {
    log.info("[BaseLlmService][chatWithTools, conversationId={}, query={}]", conversationId, userMessage);
    return chatClient.prompt()
            .system("你是助手,需要当前时间或天气时必须调用已提供的工具,再根据工具结果回答。")
            .user(userMessage)
            .tools(currentDateTimeToolCallback, currentWeatherToolCallback)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
            .advisors(new LogAdvisor())
            .call()
            .content();
}

上面各个参数的具体作用以及合并策略如下:

方法名 作用 调用时对应方法 调用赋值策略
defaultSystem 默认系统人设 .system(...) 覆盖
defaultUser 默认用户消息,少用 .user(...) 覆盖
defaultOptions 默认温度、模型名等 .options(...) 覆盖
defaultTools 每次都带上的工具 .tools(...) 合并
defaultToolContext 工具执行时能读到的上下文 Map .toolContext(...) 合并(同 key 覆盖)
defaultAdvisors 默认 Advisor 链 .advisors(...) 合并
defaultTemplateRenderer Prompt 模板引擎,默认 StringTemplate .templateRenderer(...) 覆盖

2. ChatMemory & ChatMemoryRepository

接口 职责
ChatMemory add / get / clear;决定窗口大小、淘汰规则、是否保住 SystemMessage
ChatMemoryRepository 只做消息的写入、按会话取出、删除,不决定裁剪

2.1 ChatMemory

ChatMemory 接口定义如下,其提供的能力就是按照会话 id 对消息进行 CURD 。

Spring AI 默认提供了 MessageWindowChatMemory 的实现,该实现即使通过消息窗口的形式将保存指定长度的消息列表。

java 复制代码
public interface ChatMemory {

    /**
     * The key to retrieve the chat memory conversation id from the context.
     */
    String CONVERSATION_ID = "chat_memory_conversation_id";

    /**
     * Save the specified message in the chat memory for the specified conversation.
     */
    default void add(String conversationId, Message message) {
        Assert.hasText(conversationId, "conversationId cannot be null or empty");
        Assert.notNull(message, "message cannot be null");
        this.add(conversationId, List.of(message));
    }

    /**
     * Save the specified messages in the chat memory for the specified conversation.
     */
    void add(String conversationId, List<Message> messages);

    /**
     * Get the messages in the chat memory for the specified conversation.
     */
    List<Message> get(String conversationId);

    /**
     * Clear the chat memory for the specified conversation.
     */
    void clear(String conversationId);

}

2.2 ChatMemoryRepository

ChatMemoryRepository 用于持久化 Message :ChatMemory 决定了哪些消息要存取删,ChatMemoryRepository决定了从哪存取删。

Spring AI 提供 InMemoryChatMemoryRepository 的默认实现,默认将 Message 存储到内存中,我们可以扩展到存储到 DB 或者 Redis 中。

ChatMemoryRepository 接口定义如下。

java 复制代码
/**
 * A repository for storing and retrieving chat messages.
 *
 * @author Thomas Vitale
 * @since 1.0.0
 */
public interface ChatMemoryRepository {

	List<String> findConversationIds();

	List<Message> findByConversationId(String conversationId);

	/**
	 * Replaces all the existing messages for the given conversation ID with the provided
	 * messages.
	 */
	void saveAll(String conversationId, List<Message> messages);

	void deleteByConversationId(String conversationId);

}

3. Advisor

Spring AI 直接提供了 Advisor 接口来供开发者扩展。Advisor 是 ChatClient 的拦截器链,它挂在 ChatClient 和 ChatModel 之间,在请求发出前改 Prompt、调用后处理响应。

可以简单将 Advisor 理解为 切面的形式, Spring AI 在 LLM 调用前后进行了切面增强。(从 Advisor 的类名在 Spring AOP 的 Advisor 完全一样就可理解)。

需要注意 :直接调 ChatModel 不会走这条链,Advisor 功能是 ChatClient 扩展的功能

Advisor 存在子类 CallAdvisor、StreamAdvisor 分别对应普通调用和流式调用的增强。我们一般使用 BaseAdvisor 来实现。BaseAdvisor 定义如下:

java 复制代码
public interface BaseAdvisor extends CallAdvisor, StreamAdvisor {

	Scheduler DEFAULT_SCHEDULER = Schedulers.boundedElastic();

	@Override
	default ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
		Assert.notNull(chatClientRequest, "chatClientRequest cannot be null");
		Assert.notNull(callAdvisorChain, "callAdvisorChain cannot be null");

		ChatClientRequest processedChatClientRequest = before(chatClientRequest, callAdvisorChain);
		ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(processedChatClientRequest);
		return after(chatClientResponse, callAdvisorChain);
	}

	@Override
	default Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest,
			StreamAdvisorChain streamAdvisorChain) {
		Assert.notNull(chatClientRequest, "chatClientRequest cannot be null");
		Assert.notNull(streamAdvisorChain, "streamAdvisorChain cannot be null");
		Assert.notNull(getScheduler(), "scheduler cannot be null");

		Flux<ChatClientResponse> chatClientResponseFlux = Mono.just(chatClientRequest)
			.publishOn(getScheduler())
			.map(request -> this.before(request, streamAdvisorChain))
			.flatMapMany(streamAdvisorChain::nextStream);

		return chatClientResponseFlux.map(response -> {
			if (AdvisorUtils.onFinishReason().test(response)) {
				response = after(response, streamAdvisorChain);
			}
			return response;
		}).onErrorResume(error -> Flux.error(new IllegalStateException("Stream processing failed", error)));
	}

	@Override
	default String getName() {
		return this.getClass().getSimpleName();
	}

	/**
	 * Logic to be executed before the rest of the advisor chain is called.
	 */
	ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain);

	/**
	 * Logic to be executed after the rest of the advisor chain is called.
	 */
	ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain);

	/**
	 * Scheduler used for processing the advisor logic when streaming.
	 */
	default Scheduler getScheduler() {
		return DEFAULT_SCHEDULER;
	}

}

可以看到 BaseAdvisor 的逻辑并不复杂 :

  • 提供了 BaseAdvisor#before 和 BaseAdvisor#after 方法供子类实现
  • 在 BaseAdvisor#adviseCall 和 BaseAdvisor#adviseStream 方法发起 LLM 调用前后调用扩展方法。

需要注意 Advisor 本身是 Ordered 子类,需要我们实现 Ordered#getOrder 方法以用于多个 Advisor 进行排序调用:值越小越靠外层


Spring AI 内置 Advisor 有很多,如:

Advisor 作用
MessageChatMemoryAdvisor ChatMemory 取历史,作为消息列表塞进 Prompt;调用后再写回
VectorStoreChatMemoryAdvisor 历史走向量检索,以文本追加到 system
QuestionAnswerAdvisor Naive RAG:向量库检索后增强提问
RetrievalAugmentationAdvisor 模块化 RAG
ToolCallingAdvisor 工具调用循环;ChatClient 默认会自动注册
SafeGuardAdvisor 内容安全拦截
ReReadingAdvisor 把用户问题再读一遍,加强推理

4. Tool 调用

Spring AI 提供了 Function Calling 功能,可用于工具调用。

4.1. Tool 的注入

Spring AI 提供了 Tool 调用 功能,有三种方式:

  1. Tool 注解形式 :通过 @Tool 注解的形式声明一个 Tool。

    java 复制代码
    class DateTimeTools {
    
        @Tool(description = "获取用户时区下的当前日期时间")
        String getCurrentDateTime() {
            return LocalDateTime.now().toString();
        }
    
        @Tool(description = "按 ISO-8601 时间设置闹钟")
        void setAlarm(@ToolParam(description = "闹钟时间") String time) {
            // 真正做事
        }
    }
    
    ------------------------------------------
    
    // 调用时在 Client 上指定 Tool,框架扫 @Tool 生成 ToolCallback。
    chatClient.prompt().user("上海").tools(new DemoTools()).call().content();
  2. MethodToolCallback :当想将已有方法封装为 Tool,但是已有方法并没有 @Tool 注解 或者 不想修改源码时,可以通过 MethodToolCallback 方式,如下。

    java 复制代码
        /**
         * 把已有方法包装成 Tool。
         */
        @Bean
        public ToolCallback currentDateTimeToolCallback() {
            Method method = ReflectionUtils.findMethod(DemoTools.class, "getCurrentDateTime");
            return MethodToolCallback.builder()
                    .toolDefinition(ToolDefinitions.builder(method)
                            .description("获取当前日期和时间")
                            .build())
                    .toolMethod(method)
                    .toolObject(new DemoTools())
                    .build();
        }
  3. FunctionToolCallback :没有现成方法对象,或入参是一个类型(JSON Schema 从 inputType 生成)时

    java 复制代码
        /**
         * 把 Function 包装成 Tool。
         */
        @Bean
        public ToolCallback currentWeatherToolCallback() {
            return FunctionToolCallback.builder("getCurrentWeather", (WeatherQuery query) -> new DemoTools().getWeather(query))
                    .description("查询指定城市的当前天气")
                    .inputType(WeatherQuery.class)
                    .build();
        }

4.2. ToolCallback

上面三种方式无论哪一种,对 Spring AI 来说底层都相同 :将 Tool 封装成 ToolCallback 对象,ToolCallback 的定义如下:

java 复制代码
/**
 * Represents a tool whose execution can be triggered by an AI model.
 *
 * @author Thomas Vitale
 * @since 1.0.0
 */
public interface ToolCallback {

	Log logger = LogFactory.getLog(ToolCallback.class);

	/**
	 * Definition used by the AI model to determine when and how to call the tool.
	 */
	ToolDefinition getToolDefinition();

	/**
	 * Metadata providing additional information on how to handle the tool.
	 */
	default ToolMetadata getToolMetadata() {
		return ToolMetadata.builder().build();
	}

	/**
	 * Execute tool with the given input and return the result to send back to the AI
	 * model.
	 */
	String call(String toolInput);

	/**
	 * Execute tool with the given input and context, and return the result to send back
	 * to the AI model.
	 */
	default String call(String toolInput, @Nullable ToolContext toolContext) {
		if (toolContext != null && !toolContext.getContext().isEmpty()) {
			if (logger.isInfoEnabled()) {
				logger.info("By default the tool context is not used,  "
						+ "override the method 'call(String toolInput, ToolContext toolcontext)' to support the use of tool context."
						+ "Review the ToolCallback implementation for " + getToolDefinition().name());
			}
		}
		return call(toolInput);
	}

}

根据定义可以看到 ToolCallback 有如下几个作用 :

  • 通过 ToolDefinition 向 LLM 描述 Tool 的定义,如下:

    • name:这次请求里唯一
    • description:模型靠它决定调不调
    • inputSchema:参数的 JSON Schema
  • 通过 ToolMetadata 决定 Tool 执行策略 :该策略并不会给 LLM 使用,而是 Tool 调用结束后 Spring AI 的判断依据,如 returnDirect 表示 Tool 调用后的返回策略(同轮若调了多个工具,必须全部 returnDirect=true" 才会短路) :

    • false(默认):结果再给模型,让它组织回答
    • true:结果直接返回调用方,少一轮模型调用
  • 通过 call 方法完成 Tool 的调用


4.3. ToolCallingAdvisor

上面 ToolCallback 完成了 Tool 的调用封装,此时还需要将 ToolCallback 加入到 LLM 调用链路中,此时则是通过 ToolCallingAdvisor 完成的。

ToolCallingAdvisor 本身也是一个 Advisor,在有 Tool 时会自动进链,我们也可以通过继承 ToolCallingAdvisor 的方式来进一步扩展 Tool 的加载逻辑。


我们这里简述 ToolCallingAdvisor 的实现方式:

ToolCallingAdvisor 中存在 ToolCallingAdvisor#adviseCall 和 ToolCallingAdvisor# adviseStream 两个方法分别用于普通调用和流式调用两种方式,二者的实现思路基本相同,因此我们这里以 ToolCallingAdvisor#adviseCall 来做解释说明。

ToolCallingAdvisor#adviseCall 注释版如下:

java 复制代码
@Override
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
    Assert.notNull(callAdvisorChain, "callAdvisorChain must not be null");
    Assert.notNull(chatClientRequest, "chatClientRequest must not be null");

    ChatOptions options = chatClientRequest.prompt().getOptions();
    // 判断是否支持 Tool 调用,如果不支持,直接跳到下一个 Advisor 
    if (!(options instanceof ToolCallingChatOptions toolCallingChatOptions)) {
        return callAdvisorChain.nextCall(chatClientRequest);
    }

    // 扩展点:整个工具循环开始前,可以用于修改请求内容
    chatClientRequest = this.doInitializeLoop(chatClientRequest, callAdvisorChain);

    // 本回合完整历史,给 ToolCallingManager 计次;与下面可能裁剪的 instructions 分开
    // fullTurnHistory : 完整的 Tool 调用历史
    // instructions : 裁剪后的 Tool 调用历史
    // 在下面 doGetNextInstructionsForToolCall 方法中会根据 conversationHistoryEnabled 参数判断(默认 true): 
    // - 为 true: Advisor 自己拼完整历史再调模型
    // - 为 false:Advisor 不拼,交给循环里面的 Memory Advisor,避免同一段历史写两遍
    var instructions = chatClientRequest.prompt().getInstructions();

    var fullTurnHistory = instructions;

    ChatClientResponse chatClientResponse = null;
    UsageAccumulator usageAccumulator = new UsageAccumulator();

    boolean isToolCall = false;

    // 【关键点1 : Tool 调用循环】
    do {
        // 本轮发给模型的 Prompt:当前 instructions + 工具定义(在 options 里)
        var processedChatClientRequest = ChatClientRequest.builder()
                .prompt(new Prompt(instructions, toolCallingChatOptions))
                .context(chatClientRequest.context())
                .build();

        // 扩展点:每一轮模型调用前
        processedChatClientRequest = this.doBeforeCall(processedChatClientRequest, callAdvisorChain);

        // doBeforeCall 可能改过 options(例如动态注入工具),执行工具时必须用改完后的那份
        toolCallingChatOptions = (ToolCallingChatOptions) processedChatClientRequest.prompt().getOptions();
        Assert.notNull(toolCallingChatOptions,
                "redundant check that should never fail (doBeforeCall must not change the options type away from ToolCallingChatOptions), but here to help NullAway");

        【关键点2 :Advisor 链路执行】
        // 递归:拷贝不含自己的下游链再 nextCall → 其余 Advisor → ChatModel
        // 这里需要注意的是 :这里的执行会将下游 Advisor 一并执行,比如如果 Advisor 链路是 A -> ToolCallingAdvisor -> B -> C , 这里每次循环都会执行 B、C 
        chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);

        // 扩展点:每一轮模型返回后
        chatClientResponse = this.doAfterCall(chatClientResponse, callAdvisorChain);

        ChatResponse chatResponse = chatClientResponse.chatResponse();
        usageAccumulator.addRoundResponse(chatResponse);
        // 默认:chatResponse != null && chatResponse.hasToolCalls()
        isToolCall = this.toolExecutionEligibilityChecker.isToolCallResponse(chatResponse);

        // 如果需要 Tool 调用,则进入代码块
        if (isToolCall) {
            Assert.notNull(chatResponse, "redundant check that should never fail, but here to help NullAway");

            ToolExecutionResult toolExecutionResult;
            try {
                【关键点3 :Tool 调用顺序】
                // 执行本轮全部 toolCalls(一轮里可以有多个工具)
                // LLM 一次性可能返回多个要调用的 Tool,但是本身并不要求 Tool 的调用顺序,由调用方自行决定
                toolExecutionResult = this.toolCallingManager
                        .executeToolCalls(new Prompt(fullTurnHistory, toolCallingChatOptions), chatResponse);
            }
            catch (ToolCallLimitExceededException ex) {
                // 次数超限:封装成一条 generation 返回调用方,不再回模型
                chatClientResponse = chatClientResponse.mutate()
                        .chatResponse(ChatResponse.builder()
                                .from(chatResponse)
                                .generations(List.of(ex.buildGeneration()))
                                .build())
                        .build();
                break;
            }

            // 保存完整会话历史
            fullTurnHistory = toolExecutionResult.conversationHistory();
            
            //【关键点4 :Tool returnDirect】
            // 如果 tool 要求调用后直接返回则直接返回
            // 默认情况下 Tools 的结果会交由 LLM 整理后再返回最终结果,但是在一些情况下 Tools 的调用结果就是最终答案,就可以通过这样让 Tools 调用结果直接返回。
            if (toolExecutionResult.returnDirect()) {
                // 工具声明 returnDirect:结果直接给调用方,不再喂回模型
                chatClientResponse = chatClientResponse.mutate()
                        .chatResponse(ChatResponse.builder()
                                .from(chatResponse)
                                .generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
                                .build())
                        .build();
                break;
            }

            // 【关键点5 :History 拼接】
            // 把 TOOL 结果写进下一轮 messages,再进入 while
            // 这里会根据 conversationHistoryEnabled 判断是否对消息进行截取
            // - true:默认值。Advisor 自己拼完整历史再调模型
            // - false:Advisor 不拼,交给循环里面的 Memory Advisor,避免同一段历史写两遍。这种情况下,AdvisorChain 下游一定要有 Memory Advisor 来处理历史记录
            // DefaultChatClient 如果发现记忆 Advisor 的 order 在 ToolCallingAdvisor 后面(进了循环),会自动把这个关掉。
            // 自己 new ToolCallingAdvisor 的话,进循环挂记忆就要自己调 disableInternalConversationHistory()。
            instructions = this.doGetNextInstructionsForToolCall(processedChatClientRequest, chatClientResponse,
                    toolExecutionResult);
        }

    }
    while (isToolCall); // 模型不再要工具则结束

    // 把多轮 token 用量累加到最终响应
    chatClientResponse = usageAccumulator.applyAccumulatedUsage(chatClientResponse);
    // 扩展点:整个循环结束
    return this.doFinalizeLoop(chatClientResponse, callAdvisorChain);
}

protected List<Message> doGetNextInstructionsForToolCall(ChatClientRequest chatClientRequest,
        ChatClientResponse chatClientResponse, ToolExecutionResult toolExecutionResult) {
    // 如果 conversationHistoryEnabled = false (默认 true),则下轮循环只往下传 system + 最后一条消息,完整的信息交由下游的 ChatMemory Adv
    if (!this.conversationHistoryEnabled) {
        List<Message> history = toolExecutionResult.conversationHistory();
        if (history.isEmpty()) {
            return history;
        }
        return List.of(chatClientRequest.prompt().getSystemMessage(), history.get(history.size() - 1));
    }

    return toolExecutionResult.conversationHistory();
}

上面的代码注释已经写的很清楚的了,下面对注释中的几个关键点更近一步的说明:

首先明确两个概念 :

  • 一次 Trace 是一次完整的 Agent 执行:从用户发起请求,到框架给出最终回复。这期间模型可能被调用多次(例如中间穿插 Tool),这些都属于同一条 Trace,而不是多次 Trace。
  • 一次 Turn 是 Trace 内部的一轮交互。在有 Tool 的场景里,模型发起一次工具调用并拿到结果,通常记为一轮 Turn。一条 Trace 由一轮或多轮 Turn 组成。

可以简单记为 :一次用户请求 = 一条 Trace;这条 Trace 里每用一次 Tool,就多一轮 Turn。没有 Tool 时,Trace 仍然只有一条,只是 Turn 为 0 或只算一轮模型对话,


  1. 关键点1 : Tool 调用循环

    通过上面的代码我们可以看到 ToolCallingAdvisor#adviseCall 中的 Tool 调用是一个 do...while 循环。通常情况下, LLM 无法在一次回复中将后续需要调用的 Tool 确定好,因此常见情况下是 LLM 发现缺失数据,要求调用 ToolA; 调用方执行 ToolA 将执行结果连同已有对话再喂回给 LLM;LLM 得到数据后如果判断数据仍不足得出接口,可能会再次要求调用 ToolB。如此反复,直到 LLM 不再要Tool,直接给出最终回答。所以 Tool 调用必须做成循环,而不是单次请求。

    而这里的循环的每一圈可以看作一轮 Turn:先打一次模型,若返回里带了 toolCalls,就在本圈消化掉(一轮里可以是 0 个、1 个或多个 Tool,如果一次 LLM 调用了多个 Tool 调用,那么这些 Tool 调用都只算一次 Turn。),再决定要不要进入下一圈。整段 adviseCall 从进入循环到退出,是一条 Trace,里面可以包含一轮或多轮 Turn。

  2. 关键点2 :Advisor 链路执行

    callAdvisorChain.copy(this).nextCall(...) 会复制一条 不含当前 ToolCallingAdvisor 的下游链路,再往下走,最终打到 ChatModel。而上面的【关键点1】我们知道 Tool 的调用是一个 do...while 循环,也就是这里可能会被多次调用,即挂在 ToolCallingAdvisor 后面的 Advisor,每一轮调用都会再执行一次,不是整条 Trace 只跑一次。

    假设 AdvisorChain 是 A -> ToolCallingAdvisor -> B -> C -> ChatModel 的顺序,则:

    • A 在进 adviseCall 之前跑过,不在环里,整条 Trace 只执行一次
    • 如果执行多次 Turn,则每一轮 Turn 都会再跑 B -> C -> ChatModel。
    • 这里的 B、C 就会被执行多次。

    这里需要注意 :

    1. Advisor 继承了 Order 接口,我们可以通其方法控制 Advisor 的执行顺序:Order 值越小,越在外层。
    2. 日志、观测、记忆等 Advisor 如果挂在 ToolCallingAdvisor后面,会按 Turn 次数执行;挂在它前面,只在进循环前跑一次,看不见后面几轮的 TOOL 消息。 因此这里一般可以将日志和观测挂载在 ToolCallingAdvisor 以确保日志和观测的完整性。而对于记忆来说,则存在两种情况:
      1. 记忆挂在 ToolCallingAdvisor 后面(在循环内):每轮 Turn 都会执行。这时不能再让 ToolCallingAdvisor 自己拼完整历史,否则同一段对话写两遍。DefaultChatClient 发现记忆在后面,会自动关掉内部拼历史;自己 new 的话要手动disableInternalConversationHistory
      2. 记忆挂在 ToolCallingAdvisor 前面(在循环前):只在第一轮前补一次历史。环内各 Turn 要靠 Advisor 自己带着 fullTurnHistory / instructions 往下传
    3. ToolCallingAdvisor 放在 Chain 的最后(order 最大、最靠近模型)时,工具环还在转,但环外面的 Advisor 只能看见「进环前那一次请求」和「出环后那一次最终结果」,看不见中间每一轮 Turn。
  3. 关键点3 :Tool 调用顺序

    LLM 一轮调用带会多个 toolCalls,但其只指定要执行的 Tool ,但并不干涉 Tool 的执行方式(执行顺序、串行并行等)。Tool 执行的具体规则由调用方自己决定(顺序和调度在 ToolCallingManager.executeToolCalls 中。注意这些 Tool 仍属于 同一轮 Turn,不是多条 Trace,也不是多轮 do/while)。

    默认情况下,Spring AI 通过 DefaultToolCallingManager 按照 LLM 返回的 Tool 数组的顺序串行的。我们可以通过自定义 ToolCallingManager 的方式来实现 Tool 的并行执行。

    同时有些 Tool 会指定 returnDirect 方式,这里因为某些情况下我们是期望将 Tool 的执行结果直接作为最终结果返回的,则可以用该参数执行

    与 LangChain4j 的 dev.langchain4j.agent.tool.ReturnBehavior 作用相同

  4. 关键点4 :Tool returnDirect

    默认情况下, Tool 的执行结果会写回 message,然后再调用一次 LLM 来整理成最终回答。但是某些情况下 Tool 的输出本身就是最终答案(如 查余额、检索原文等),此时并不需要再喂回给 LLM (再喂回也只是多一轮 Turn),因此我们可以通过对 Tool 指定属性 returnDirect = true,让 Tool 执行结束直接将结果返回给调用方。

    需要注意 :如果是多个 Tool 同时出现在一轮 Turn,那么需要本轮所有的 Tool returnDirect 都为 ture;只要有一个 Tool returnDirect 为 false ,整体结果都会重新喂回给 LLM 。

  5. 关键点5 :History 拼接

    在上面的代码中,有两个变量:

    • fullTurnHistory:完整对话,只给 ToolCallingManager 计次、执行工具。不做裁剪
    • instructions:下一轮真正发给模型和下游 Advisor 的 messages,在doGetNextInstructionsForToolCall 方法中会跟根据情况决定是否裁剪对话记录。

    我们在 关键点2 中提到了, Memory Advisor 在 ToolCallingAdvisor 前后有两种情况,需要不同的处理方式,在 doGetNextInstructionsForToolCall 中会处理该情况,而doGetNextInstructionsForToolCall 中会判断 conversationHistoryEnabled是否启用:

    • 如果启用(true),则 ToolCallingAdvisor 直接携带完整记忆到下游(这个时候要确保下游没有 Memory Advisor,否则会导致重复拼接记忆)。
    • 如果不启用(false),则只返回 system + 最后一条消息 给下游 Advisor,完整记忆交由下游 Memory Advisor 来拼接(这个时候要保证下游存在 Memory Advisor ,否则会导致记忆丢失)。

    而默认情况下 conversationHistoryEnabled= ture,默认的 Advisor 也符合这个顺序( MessageChatMemoryAdvisor 在 ToolCallingAdvisor 外层,也就是 ToolCallingAdvisor 下游没有 Memory Advisor),我们可以通过 ToolCallingAdvisor.builder() 构造时根据实际情况指定 conversationHistoryEnabled 的值。


综上,我们给出一个 调用时序图,如下:

4.4 Tool 调用示例

  1. 定义两个演示 Tool

    java 复制代码
    public class DemoTools {
    
        /**
         * 返回当前日期时间。
         */
        public String getCurrentDateTime() {
            return LocalDateTime.now().toString();
        }
    
        /**
         * 按城市返回示例天气。
         */
        public String getWeather(WeatherQuery query) {
            return query.getCity() + " 当前晴,气温 24°C(示例数据)";
        }
    }
    
    public class WeatherQuery {
    
        /**
         * 城市名。
         */
        private String city;
    
        public String getCity() {
            return city;
        }
    
        public void setCity(String city) {
            this.city = city;
        }
    }
  2. 将 Tool 包装注入容器中

    java 复制代码
    /**
     * 把已有方法包装成 Tool。
     */
    @Bean
    public ToolCallback currentDateTimeToolCallback() {
        Method method = ReflectionUtils.findMethod(DemoTools.class, "getCurrentDateTime");
        return MethodToolCallback.builder()
                .toolDefinition(ToolDefinitions.builder(method)
                        .description("获取当前日期和时间")
                        .build())
                .toolMethod(method)
                .toolObject(new DemoTools())
                .build();
    }
    
    /**
     * 把 Function 包装成 Tool。
     */
    @Bean
    public ToolCallback currentWeatherToolCallback() {
        return FunctionToolCallback.builder("getCurrentWeather", (WeatherQuery query) -> new DemoTools().getWeather(query))
                .description("查询指定城市的当前天气")
                .inputType(WeatherQuery.class)
                .build();
    }
  3. 调用示例

    java 复制代码
    	
        @Resource
        private ChatClient chatClient;
    
        /**
         * MethodToolCallback 示例。
         */
        @Resource(name = "currentDateTimeToolCallback")
        private ToolCallback currentDateTimeToolCallback;
    
        /**
         * FunctionToolCallback 示例。
         */
        @Resource(name = "currentWeatherToolCallback")
        private ToolCallback currentWeatherToolCallback;
        
        @Override
        public String chatWithTools(String userMessage, String conversationId) {
            log.info("[BaseLlmService][chatWithTools, conversationId={}, query={}]", conversationId, userMessage);
            return chatClient.prompt()
                    .system("你是助手,需要当前时间或天气时必须调用已提供的工具,再根据工具结果回答。")
                    .user(userMessage)
                    .tools(currentDateTimeToolCallback, currentWeatherToolCallback)
                    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                    .advisors(new LogAdvisor())
                    .call()
                    .content();
        }

五、参考内容

  1. Spring AI
相关推荐
梨涡泥窝1 小时前
基于SSM的校园二手交易平台的设计与实现
java·tomcat
许彰午2 小时前
44-useRowSet镜像实现
java·低代码·架构
bamboolm2 小时前
springboot+Ollama整合
java·ollama
我不会起名字3223 小时前
一天一道算法题(34):回溯法的经典例题(子集)
java·数据结构·python·算法·golang·深度优先·力扣
Wang's Blog4 小时前
Java框架快速入门: Spring Security+OAuth2之JWT核心概念与实战
java·spring·log4j
Dreams_l4 小时前
死信队列和延迟队列介绍
java·开发语言
Bs_MoneyMagnet4 小时前
基于springboot+vue的生态果园采摘预约系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·毕业设计·计算机毕业设计
麻瓜code4 小时前
【JUC】AQS enq() 自旋入队
java
Wang's Blog5 小时前
Java框架快速入门: Spring Security+OAuth2之核心角色与授权流程
java·spring·github