Spring AI · Alət çağırışları və əməliyyatların icrası · Dərs 1

Model üçün alətlərin müəyyən edilməsi

Modelin API və verilənlər bazasından məlumat alması, sorğuya uyğun əməliyyatlar yerinə yetirməsi üçün alətlər təqdim et.

Model üçün alətlərin müəyyən edilməsi

Model alət adı və arqumentlər təklif edə bilər. Həmin sorğunun icra olunub-olunmayacağını Java kodu müəyyən edir. Java 21, Spring AI 1.1.8 və əvvəlki prompt və cavab yoxlamalarını saxlayaraq Study Coach-a kiçik alət sərhədi əlavə edəcəyik.

1. Alətin təsvirini icazədən ayırın

@Tool ad və təsvir verir. @ToolParam arqumenti təsvir edir, Spring AI isə giriş sxemini yaradır. Model həmin təsviri və sxemi görür; bunlar çağırış təklif etməyə kömək edir. Onlar icazə qaydası deyil və giriş yoxlamasını əvəz etmir. Java enum-ları dəstəklədiyimiz mövzuları məhdudlaşdırır, metod kodu isə null dəyərləri və sərhədləri yoxlayır.

ToolCallbacks.from(object) annotasiyalı metodları ToolCallback obyektlərinə çevirir. Callback JSON arqumentlərini oxuyur, Java metodunu çağırır və nəticəni mətnə çevirir. ToolContext serverin verdiyi əlavə icra məlumatını daşıyır və modelə göstərilən giriş sxeminə daxil edilmir. Spring AI alətləri

Bu dərslər adi siniflər və testlər əlavə edir. İctimai alət endpoint-i yaratmır və əvvəlki yaddaş/RAG müştərilərinə alət qoşmur. Həmin birləşmələrin ayrıca icazə və kontekst testləri olmalıdır.

2. Etibarlı sorğu konteksti yaradın

src/main/java/academy/study/ToolSession.java faylını yaradın:

Java
package academy.study;

import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.ai.chat.model.ToolContext;

public final class ToolSession {
    public enum Permission { READ_NOTES, READ_PROGRESS, WRITE_PLAN }
    private final String owner;
    private final Set<Permission> permissions;
    private final AtomicInteger remaining = new AtomicInteger(3);

    public ToolSession(String owner, Set<Permission> permissions) {
        if (owner == null || owner.isBlank() || owner.length() > 100) {
            throw new IllegalArgumentException("Invalid owner");
        }
        this.owner = owner;
        this.permissions = Set.copyOf(permissions);
    }

    public Map<String, Object> context() {
        return Map.of("studySession", this);
    }

    public String use(Permission permission) {
        if (!permissions.contains(permission)) {
            throw new IllegalArgumentException("Tool access denied");
        }
        if (remaining.getAndDecrement() <= 0) {
            throw new IllegalStateException("Tool budget exhausted");
        }
        return owner;
    }

    public static ToolSession from(ToolContext context) {
        if (context == null || !(context.getContext().get("studySession") instanceof ToolSession session)) {
            throw new IllegalArgumentException("Trusted tool context required");
        }
        return session;
    }
}

Etibarlı server adapteri sahibin kimliyini və icazələri autentifikasiya edilmiş vəziyyətdən almalıdır. Konstruktor heç kimi autentifikasiya etmir. learner-a kimi test kimlikləri sintetikdir. Bu obyekti yoxlanmamış owner sahəsindən, model arqumentlərindən və ya çağıranın verdiyi icazə siyahısından yaratmayın.

Hər tətbiq sorğusu üçün yeni sessiya yaradın. Sessiya icazəsi olan ən çox üç alət çağırışına imkan verir; sonradan giriş yoxlamasında və ya I/O zamanı uğursuz olan çağırışlar da sayılır. Atomik sayğac lokal icra büdcəsidir, tezlik, xərc və ya icazənin ləğvi mexanizmi deyil. Sonrakı sorğularda cari kimlik və icazələr yenidən yoxlanmalıdır.

3. Kiçik, yalnız oxuyan alət müəyyən edin

src/main/java/academy/study/StudyTipTools.java faylını yaradın:

Java
package academy.study;

import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;

public final class StudyTipTools {
    public enum Topic { INHERITANCE, RESOURCES }
    public record Tip(Topic topic, String text) {}

    @Tool(name = "study_tip", description = "Read a short approved Java study tip for one supported topic")
    public Tip tip(@ToolParam(description = "One supported Java topic") Topic topic, ToolContext context) {
        ToolSession.from(context).use(ToolSession.Permission.READ_NOTES);
        if (topic == null) {
            throw new IllegalArgumentException("Topic required");
        }
        return new Tip(topic, switch (topic) {
            case INHERITANCE -> "A Java class has at most one direct superclass.";
            case RESOURCES -> "Use try-with-resources with AutoCloseable resources.";
        });
    }
}

Alət iki sabit qeyddən birini qaytarır. Mövzunu model seçir; sahibi və icazələri isə seçmir. Modeldən düzgün davranmağı istəyən təsvir faydalı təlimatdır, amma metod çatışmayan konteksti, icazəni və dəstəklənməyən girişi yenə rədd edir.

4. Ən çox bir təklif edilmiş çağırışı icra edin

src/main/java/academy/study/ToolStudyClient.java faylını yaradın:

Java
package academy.study;

import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallback;

public final class ToolStudyClient {
    private final ChatClient client;
    private final Map<String, ToolCallback> allowed;

    public ToolStudyClient(ChatModel model, ToolCallback... callbacks) {
        if (callbacks.length < 1 || callbacks.length > 4) {
            throw new IllegalArgumentException("Register one to four tools");
        }
        this.client = ChatClient.builder(model).build();
        this.allowed = Arrays.stream(callbacks).collect(Collectors.toUnmodifiableMap(
                callback -> callback.getToolDefinition().name(), Function.identity()));
    }

    public Reply answer(String question, StudyPromptFactory.Language language, ToolSession session) {
        var options = ToolCallingChatOptions.builder().internalToolExecutionEnabled(false).build();
        var response = client.prompt(StudyPromptFactory.create(question, language))
                .options(options).toolCallbacks(allowed.values().toArray(ToolCallback[]::new))
                .toolContext(session.context()).call().chatResponse();
        if (response == null || response.getResults().size() != 1) {
            throw new IllegalStateException("Expected one model response");
        }
        var output = response.getResult().getOutput();
        if (!output.hasToolCalls()) {
            if (AnswerChecks.check(output.getText()) != AnswerChecks.Result.PASS) {
                throw new IllegalStateException("Invalid model answer");
            }
            return new Reply(Kind.MODEL_TEXT, output.getText());
        }
        if (output.getToolCalls().size() != 1) {
            throw new IllegalArgumentException("Exactly one tool call is allowed");
        }
        var call = output.getToolCalls().getFirst();
        ToolCallback callback = allowed.get(call.name());
        if (callback == null || !"function".equals(call.type()) || call.arguments() == null
                || call.arguments().isBlank() || call.arguments().length() > 1_000) {
            throw new IllegalArgumentException("Invalid tool request");
        }
        String result = callback.call(call.arguments(), new ToolContext(session.context()));
        if (AnswerChecks.check(result) != AnswerChecks.Result.PASS) {
            throw new IllegalStateException("Invalid tool result length");
        }
        return new Reply(Kind.TOOL_RESULT, result);
    }

    public enum Kind { MODEL_TEXT, TOOL_RESULT }
    public record Reply(Kind kind, String text) {}
}

Spring AI avtomatik alət icrasını dəstəkləyir. Burada isə internalToolExecutionEnabled(false) modelin təklifini əvvəlcə tətbiqin yoxlamasına imkan verir. Ən çox 1,000 UTF-16 kod vahidindən ibarət JSON arqumentləri olan bir qeydiyyatlı funksiyanı qəbul edib bir dəfə çağırırıq. Model/alət dövrü yoxdur. Alət nəticəsi yenidən yazılmaq üçün modelə göndərilmədən TOOL_RESULT kimi qaytarılır. Adi model cavabı MODEL_TEXT kimi işarələnir və alətin işə düşdüyünü sübut etmir.

Qeydiyyat server qərarıdır: etibarlı adapter daxilində new ToolStudyClient(model, ToolCallbacks.from(new StudyTipTools())) istifadə edin. Çağıran tərəfə ixtiyari callback qeydiyyatı açmayın. Sxemi modelə təklif edilsə belə, callback icazəni yoxlayır. Nəticə ölçüsü icradan sonra yoxlanır; bu yoxlama artıq baş vermiş əməliyyatı geri almır.

5. Sxemi və yönləndirmə sərhədini test edin

src/test/java/academy/study/ToolDefinitionTest.java faylını yaradın:

Java
package academy.study;

import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.execution.ToolExecutionException;

import static academy.study.StudyPromptFactory.Language.ENGLISH;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class ToolDefinitionTest {
    private ToolSession session() {
        return new ToolSession("learner-a", Set.of(ToolSession.Permission.READ_NOTES));
    }

    @Test
    void schemaExposesTopicButNotTrustedContext() {
        var tool = ToolCallbacks.from(new StudyTipTools())[0];
        String schema = tool.getToolDefinition().inputSchema();
        assertEquals("study_tip", tool.getToolDefinition().name());
        assertTrue(schema.contains("INHERITANCE"));
        assertFalse(schema.contains("studySession"));
        assertFalse(schema.contains("context"));
    }

    @Test
    void callbackConvertsJsonToAnAllowedEnumAndSerializesResult() {
        var tool = ToolCallbacks.from(new StudyTipTools())[0];
        String result = tool.call("{\"topic\":\"INHERITANCE\"}", new ToolContext(session().context()));
        assertTrue(result.contains("direct superclass"));
        assertThrows(ToolExecutionException.class,
                () -> tool.call("{\"topic\":\"UNKNOWN\"}", new ToolContext(session().context())));
    }

    @Test
    void missingPermissionAndMissingTopicFailClosed() {
        var tools = new StudyTipTools();
        var denied = new ToolSession("learner-a", Set.of());
        assertThrows(IllegalArgumentException.class,
                () -> tools.tip(StudyTipTools.Topic.RESOURCES, new ToolContext(denied.context())));
        assertThrows(IllegalArgumentException.class,
                () -> tools.tip(null, new ToolContext(session().context())));
        assertThrows(IllegalArgumentException.class,
                () -> tools.tip(StudyTipTools.Topic.RESOURCES, null));
    }

    @Test
    void requestBudgetStopsTheFourthInvocation() {
        var tools = new StudyTipTools();
        var context = new ToolContext(session().context());
        for (int i = 0; i < 3; i++) tools.tip(StudyTipTools.Topic.RESOURCES, context);
        assertThrows(IllegalStateException.class, () -> tools.tip(StudyTipTools.Topic.RESOURCES, context));
    }

    @Test
    void clientExecutesOneAllowedCallbackWithoutASecondModelCall() {
        ChatModel model = mock(ChatModel.class);
        var output = AssistantMessage.builder().content("").toolCalls(List.of(
                new AssistantMessage.ToolCall("call-1", "function", "study_tip", "{\"topic\":\"RESOURCES\"}"))).build();
        when(model.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(output))));
        var client = new ToolStudyClient(model, ToolCallbacks.from(new StudyTipTools()));
        var reply = client.answer("Give a resource tip", ENGLISH, session());
        assertEquals(ToolStudyClient.Kind.TOOL_RESULT, reply.kind());
        assertTrue(reply.text().contains("AutoCloseable"));
        verify(model).call(any(Prompt.class));
    }

    @Test
    void clientRejectsAnUnregisteredTool() {
        ChatModel model = mock(ChatModel.class);
        var output = AssistantMessage.builder().content("").toolCalls(List.of(
                new AssistantMessage.ToolCall("call-1", "function", "delete_everything", "{}"))).build();
        when(model.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(output))));
        var client = new ToolStudyClient(model, ToolCallbacks.from(new StudyTipTools()));
        assertThrows(IllegalArgumentException.class, () -> client.answer("Do something", ENGLISH, session()));
    }
    @Test
    void rejectsMultipleCallsAndOversizedArgumentsBeforeExecution() {
        ChatModel model = mock(ChatModel.class);
        var normal = new AssistantMessage.ToolCall("call-1", "function", "study_tip", "{\"topic\":\"RESOURCES\"}");
        var oversized = new AssistantMessage.ToolCall("call-2", "function", "study_tip", "x".repeat(1_001));
        var multiple = AssistantMessage.builder().content("").toolCalls(List.of(normal, normal)).build();
        var large = AssistantMessage.builder().content("").toolCalls(List.of(oversized)).build();
        when(model.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(multiple))),
                new ChatResponse(List.of(new Generation(large))));
        var client = new ToolStudyClient(model, ToolCallbacks.from(new StudyTipTools()));
        assertThrows(IllegalArgumentException.class, () -> client.answer("First", ENGLISH, session()));
        assertThrows(IllegalArgumentException.class, () -> client.answer("Second", ENGLISH, session()));
    }

}

Testlər real annotasiya callback-lərini sintetik girişlər və mock model ilə işlədir. Sxemin məzmununu, JSON çevrilməsini, icazələri, büdcəni və yönləndirmə sərhədlərini yoxlayır. Sabitlənmiş callback-də metod xətaları ToolExecutionException daxilinə alınır; bəzi kontekst və arqument yoxlamaları daha əvvəl uğursuz ola bilər. Bütün xətaların eyni tipdə olacağını güman etməyin. Metod callback-inin implementasiyası

Bizim yönləndirici xətanı ötürür və onun detallarını modelə geri göndərmir. HTTP adapteri onları stack trace olmadan sabit xəta cavablarına çevirməlidir. Avtomatik təkrar cəhd əlavə olunmur. Tək model çağırışı üçün də provayder timeout-u və token ayarları bu sinifdən kənarda qurulmalıdır.

6. İcra müqaviləsini yoxlayın

İcra edin:

sh
env -u RUN_LIVE_EVALUATION mvn clean verify

Əvvəlki modullar varsa, altmış səkkiz oflayn test metodu keçməli, bir canlı evaluator isə ötürülməlidir. Provayder çağırılmır.

Tapşırıq: mock modeldən bir cavabda iki etibarlı alət istəməsini təmin edin, sonra qeydiyyatda olmayan alət adı verin. Alət təsvirində “icazəlidir” yazılmasının nəticəni niyə dəyişmədiyini izah edin.

Cavab yoxlaması: hər iki sorğu callback icrasından əvvəl rədd edilir. Yalnız server qeydiyyatı, yoxlanmış arqumentlər və etibarlı icazə yoxlaması nəyin işləyə biləcəyini müəyyən edir. Model əməliyyat təklif edə bilər; ona giriş icazəsi verə bilməz.