深入解析 Spring AI 系列:解析函式呼叫

努力的小雨發表於2025-01-16

我們之前討論並實踐過透過常規的函式呼叫來實現 AI Agent 的設計和實現。但是,有一個關鍵點我之前並沒有詳細講解。今天我們就來討論一下,如何讓大模型只決定是否呼叫某個函式,但是Spring AI 不會在內部處理函式呼叫,而是將其代理到客戶端。然後,客戶端負責處理函式呼叫,將其分派到相應的函式並返回結果。

好的,我們開始。

函式呼叫

核心程式碼

函式呼叫是開發AI Agent的關鍵組成部分,它使得AI能夠與外部系統、資料庫或其他服務進行互動,從而提升了其功能性和靈活性。所以開發必須要適用於支援函式呼叫的聊天模型,在Spring AI中處理函式呼叫也僅僅是一行程式碼,核心程式碼如下,我們看下:

if (!isProxyToolCalls(prompt, this.defaultOptions)
        && isToolCall(response, Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(),
                OpenAiApi.ChatCompletionFinishReason.STOP.name()))) {
    var toolCallConversation = handleToolCalls(prompt, response);
    return this.internalCall(new Prompt(toolCallConversation, prompt.getOptions()), response);
}

假設我們已經開發並整合了一個天氣查詢函式,當我們向大模型提出類似“長春天氣咋樣”這樣的請求時,大模型會自動識別並選擇呼叫相應的函式。在這個過程中,handleToolCalls 方法透過反射機制來動態地呼叫正確的天氣查詢方法,接著該方法會遞迴呼叫 internalCall 方法,繼續處理後續的邏輯。需要注意的是,關於反射機制和遞迴呼叫的具體實現細節,在前文中已經有所說明,因此此處不再贅述。

判斷是否是函式

protected boolean isToolCall(Generation generation, Set<String> toolCallFinishReasons) {
    var finishReason = (generation.getMetadata().getFinishReason() != null)
            ? generation.getMetadata().getFinishReason() : "";
    return generation.getOutput().hasToolCalls() && toolCallFinishReasons.stream()
        .map(s -> s.toLowerCase())
        .toList()
        .contains(finishReason.toLowerCase());
}

isToolCall的核心邏輯就是要判斷大模型返回的資訊是否正確,OpenAI的API文件如下:

image

重寫判斷

如果你的大模型返回的格式不一樣,那麼重寫方法即可,比如minimax就重寫了,我們看下:

protected boolean isToolCall(Generation generation, Set<String> toolCallFinishReasons) {
    if (!super.isToolCall(generation, toolCallFinishReasons)) {
        return false;
    }
    return generation.getOutput()
        .getToolCalls()
        .stream()
        .anyMatch(toolCall -> org.springframework.ai.minimax.api.MiniMaxApiConstants.TOOL_CALL_FUNCTION_TYPE
            .equals(toolCall.type()));
}

他在原有的基礎上又再次判斷了一下toolCall.type是否為function,因為minimax不僅支援function型別的type,還支援web_search,看下官方文件,如圖所示:

image

不要細究為什麼他會有這個型別,只需要明白你可以根據不同大模型介面重寫isToolCall方法判斷即可!

函式自動呼叫開關

前面提到之所以會預設呼叫函式並再次進行大模型呼叫以進行潤色並返回參考結果,關鍵原因在於 isProxyToolCalls 引數預設設定為 false。這個引數充當了一個控制開關,用來決定是由使用者自行處理相關邏輯,還是由 Spring AI 自動進行處理並進行潤色。

具體而言,使用者可以透過設定該開關來選擇是手動管理流程,還是讓系統自動完成這一過程。以下是該控制開關的核心程式碼示例:

OpenAiChatOptions openAiChatOptions = OpenAiChatOptions.builder().withProxyToolCalls(true).build();

此時一旦你開啟此開關,你就需要自己進行處理本次結果了。大模型將僅返回撥用的引數以及其思考過程的輸出,具體內容如下所示:

image

沒返回引數等資訊,是因為我把其他資訊丟棄了,你可以這樣寫:

 ChatResponse content = this.chatClient
                .prompt(systemPrompt)
                .user(userInput)
                .options(openAiChatOptions)
                .advisors(messageChatMemoryAdvisor,myLoggerAdvisor)
                .functions("CurrentWeather","TravelPlanning","toDoListFunctionWithContext","myWorkFlowServiceCall")
                .call();
                // .content();這裡只返回string,也就是思考結果

好的。一旦你獲得了 ChatResponse 類的例項後,你就可以根據需要自由地操作該物件,並呼叫其中的各種函式了。你不必從頭編寫所有的程式碼,實際上,你可以參考 OpenAI 提供的測試樣例,這樣會大大簡化你的開發過程。以下是一個參考示例:

FunctionCallback functionDefinition = new FunctionCallingHelper.FunctionDefinition("getWeatherInLocation",
        "Get the weather in location", """
                {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "The city and state e.g. San Francisco, CA"
                        },
                        "unit": {
                            "type": "string",
                            "enum": ["C", "F"]
                        }
                    },
                    "required": ["location", "unit"]
                }
                """);

@Autowired
private OpenAiChatModel chatModel;

private FunctionCallingHelper functionCallingHelper = new FunctionCallingHelper();

@SuppressWarnings("unchecked")
private static Map<String, String> getFunctionArguments(String functionArguments) {
    try {
        return new ObjectMapper().readValue(functionArguments, Map.class);
    }
    catch (JsonProcessingException e) {
        throw new RuntimeException(e);
    }
}

// Function which will be called by the AI model.
private String getWeatherInLocation(String location, String unit) {

    double temperature = 0;

    if (location.contains("Paris")) {
        temperature = 15;
    }
    else if (location.contains("Tokyo")) {
        temperature = 10;
    }
    else if (location.contains("San Francisco")) {
        temperature = 30;
    }

    return String.format("The weather in %s is %s%s", location, temperature, unit);
}

void functionCall() throws JsonMappingException, JsonProcessingException {

    List<Message> messages = List
        .of(new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"));

    var promptOptions = OpenAiChatOptions.builder().functionCallbacks(List.of(this.functionDefinition)).build();

    var prompt = new Prompt(messages, promptOptions);

    boolean isToolCall = false;

    ChatResponse chatResponse = null;

    do {

        chatResponse = this.chatModel.call(prompt);

        isToolCall = this.functionCallingHelper.isToolCall(chatResponse,
                Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(),
                        OpenAiApi.ChatCompletionFinishReason.STOP.name()));

        if (isToolCall) {

            Optional<Generation> toolCallGeneration = chatResponse.getResults()
                .stream()
                .filter(g -> !CollectionUtils.isEmpty(g.getOutput().getToolCalls()))
                .findFirst();

            AssistantMessage assistantMessage = toolCallGeneration.get().getOutput();

            List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();

            for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) {

                var functionName = toolCall.name();

                String functionArguments = toolCall.arguments();

                @SuppressWarnings("unchecked")
                Map<String, String> argumentsMap = new ObjectMapper().readValue(functionArguments, Map.class);

                String functionResponse = getWeatherInLocation(argumentsMap.get("location").toString(),
                        argumentsMap.get("unit").toString());

                toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), functionName,
                        ModelOptionsUtils.toJsonString(functionResponse)));
            }

            ToolResponseMessage toolMessageResponse = new ToolResponseMessage(toolResponses, Map.of());

            List<Message> toolCallConversation = this.functionCallingHelper
                .buildToolCallConversation(prompt.getInstructions(), assistantMessage, toolMessageResponse);

            prompt = new Prompt(toolCallConversation, prompt.getOptions());
        }
    }
    while (isToolCall);

    logger.info("Response: {}", chatResponse);

    assertThat(chatResponse.getResult().getOutput().getText()).contains("30", "10", "15");
}

這段程式碼採用了 while 迴圈來實現預設情況下呼叫大模型進行潤色的邏輯。你可以選擇去掉這一部分邏輯,改為直接呼叫你自己定義的函式,這樣就可以繞過大模型的潤色過程,直接將結果返回給客戶端。透過這種方式,你能夠輕鬆實現類似市面上大多數智慧體平臺所提供的功能:即在不同場景下,可以選擇是否使用固定格式的回答,或是直接採用大模型的回答。

聊天記錄維護

這裡有幾個需要特別注意的關鍵點。首先,你必須將每次呼叫後的結果主動封裝並更新到歷史聊天記錄中。如果不這樣做,一旦資訊順序或格式出現混亂,系統會直接報錯。因此,確保按正確的順序進行操作是至關重要的。正常的操作流程應遵循如下順序:

image

你可以看到測試樣例中是有這一步操作的,在這一行程式碼buildToolCallConversation,程式碼追到後面就是這樣的核心邏輯,程式碼如下:

protected List<Message> buildToolCallConversation(List<Message> previousMessages, AssistantMessage assistantMessage,
        ToolResponseMessage toolResponseMessage) {
    List<Message> messages = new ArrayList<>(previousMessages);
    messages.add(assistantMessage);
    messages.add(toolResponseMessage);
    return messages;
}

總結

透過今天的討論,我們首先了解了如何實現函式呼叫的基礎機制,透過核心程式碼示例展示瞭如何在Spring AI中進行函式的動態呼叫。在此過程中,關鍵的isToolCall方法和函式自動呼叫開關的使用,確保了我們可以根據具體需求調整函式呼叫的方式,甚至完全由客戶端來接管函式執行。此外,透過維護聊天記錄並精心管理工具呼叫的順序,我們能確保AI的行為更為可控和穩定。

總的來說,今天的分享為大家提供了一種新的思路,使得在開發AI Agent時,我們不僅僅依賴大模型的內建能力,還可以透過客戶端控制函式的呼叫和返回結果,從而打造更加靈活和高效的智慧系統。這種方式無疑為開發者提供了更多定製化的選擇,提升了開發過程的自由度和效率。


我是努力的小雨,一個正經的 Java 東北服務端開發,整天琢磨著 AI 技術這塊兒的奧秘。特愛跟人交流技術,喜歡把自己的心得和大家分享。還當上了騰訊雲創作之星,阿里雲專家博主,華為云云享專家,掘金優秀作者。各種徵文、開源比賽的牌子也拿了。

💡 想把我在技術路上走過的彎路和經驗全都分享出來,給你們的學習和成長帶來點啟發,幫一把。

🌟 歡迎關注努力的小雨,咱一塊兒進步!🌟

相關文章