Spring AI 2.1のSession MemoryとTool Call Confirmationを実装!会話履歴の保存とAI操作の承認【Spring Boot・Gradle】

Spring AI 2.1では、AIエージェントの実務利用を意識した2つの機能「Session Memory」と「Tool Call Confirmation」が追加されました。前者は会話の履歴をイベントとして保存し、後者はAIが外部処理を呼び出す前に人間の承認を要求します。

本記事では、Spring Boot、Gradle、Java、H2を使って、会話を引き継ぐチャットAPIと「注文をキャンセルして」という依頼を承認待ちにするAPIを実装します。サンプルは検証用で、本番向けの承認管理・認可は含みません。

検証対象:Spring AI 2.1.0-RC2(2026年11月5日公開)、Spring Boot 4.2.0-RC2、Java 21。新機能は2026年10月23日公開のSpring AI 2.1.0-RC1で導入されました。2026年11月7日時点では2.1系は正式版ではなく、安定版はSpring AI 2.0.2です。導入時には必ず最新版と互換性を確認してください。

Spring AI 2.1の新機能は何が便利?

Session Memory:単純なチャット履歴管理からイベントログへ

従来の ChatMemory は会話IDごとにメッセージ一覧を保存する仕組みでした。Spring AI 2.1のSession Memoryでは、メッセージやツールの呼び出し結果を 追記型の SessionEvent として保持します。モデルに渡す履歴はそのログの「有効な範囲(view)」から組み立てます。

特に重要なのが、会話をターン単位で短縮する compaction です。例えば20ターンを超えたら直近10ターンを残すよう設定できます。ツール呼び出しと結果が分断されないようターン境界を守るため、AIエージェントの長い会話に向いています。なお、今回の例では古い会話を要約して残すわけではなく、モデルに渡す対象から外すだけです。ログ自体は書き換えられません。

JDBC実装はPostgreSQL、MySQL、MariaDB、H2をサポートします。テストなら InMemorySessionRepository、データベース保存なら spring-ai-starter-model-session-jdbc を利用します。

Tool Call Confirmation:AIの操作を実行前に止める

AIが「予約する」「注文を取り消す」「メールを送信する」といったツールを実行する際、処理の内容を人間が確認したい場面があります。`@Tool(requiresConfirmation = true)` を設定しておくと、承認が必要な呼び出しの直前にツール実行ループを停止できます。

実行の流れは、①モデルがツール実行を要求、②Spring AIが一時停止、③アプリケーションが承認・拒否を受け取る、④許可されたツールだけを実行、⑤ツール結果をモデルに渡して会話を継続、です。複数ツールが同一応答に含まれる場合、1つでも確認が必要ならその応答のツールはいったんすべて停止します。

Spring Boot・Gradleでプロジェクトを作成する

本記事はGradle(Groovy DSL)を使用します。OpenAI APIキーが必要で、モデル利用にはAPI料金がかかります。Spring Boot 4系のWebMVC starterを使用します。

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.2.0-RC2'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain { languageVersion = JavaLanguageVersion.of(21) }
}

repositories { mavenCentral() }

dependencies {
    implementation platform('org.springframework.ai:spring-ai-bom:2.1.0-RC2')
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springframework.ai:spring-ai-starter-model-openai'
    implementation 'org.springframework.ai:spring-ai-starter-model-session-jdbc'
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') { useJUnitPlatform() }

spring-ai-bom でSpring AI関連ライブラリのバージョンを揃えます。Spring Boot側の依存バージョンは io.spring.dependency-management とSpring Bootプラグインで管理しています。RC版ですが成果物はMaven Centralから取得可能です。

application.yml

spring:
  application:
    name: spring-ai-21-example
  datasource:
    url: jdbc:h2:file:./data/spring-ai-session;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:
    driver-class-name: org.h2.Driver
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4.1-mini
    session:
      repository:
        jdbc:
          initialize-schema: always

H2はファイルDBとして設定しているため、アプリを再起動しても会話イベントを残せます。`spring.ai.session.repository.jdbc.initialize-schema=always` は検証環境向けです。本番ではFlywayなどでスキーマを管理してください。

APIキーは application.yml へ直書きせず環境変数に設定します。

$env:OPENAI_API_KEY = '自分のAPIキー'
gradle bootRun

Session Memoryで会話履歴を保存する

Spring AIの SessionAdvisors を ChatClient に設定します。これにより、会話履歴を読み書きする SessionMemoryAdvisor と、ターン単位の処理やcompactionを担当する SessionTurnAdvisor をまとめて導入できます。

AiConfig.java

package com.example.springai;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.session.SessionService;
import org.springframework.ai.session.advisor.SessionAdvisors;
import org.springframework.ai.session.compaction.TurnCountTrigger;
import org.springframework.ai.session.compaction.WindowCompactionStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfig {
    @Bean
    ChatClient chatClient(ChatClient.Builder builder, SessionService sessionService, DemoTools demoTools) {
        return builder
                .defaultSystem("あなたは注文管理アシスタントです。注文取消依頼にはcancelOrderツールを使ってください。")
                .defaultAdvisors(SessionAdvisors.builder(sessionService)
                        .compaction(new TurnCountTrigger(20),
                                WindowCompactionStrategy.builder().lastTurns(10).build())
                        .build())
                .defaultTools(demoTools)
                .build();
    }
}

重要なのは、API呼び出しごとに SessionAdvisors.session(sessionId) を渡すことです。セッションIDの指定を省略して全ユーザーの履歴を共有する仕組みではありません。 同じIDなら前回の会話を参照し、異なるIDなら別の会話として管理します。

設定した TurnCountTrigger(20) と lastTurns(10) は、20ターンを超えた場合に直近10ターンをviewに残す例です。要約機能を利用する場合は別途summarizerを設定します。

Tool Call Confirmationで「注文キャンセル」を承認制にする

本番の注文テーブルは変更せず、サンプルでは Set に注文番号を記録する疑似処理にします。処理の意味が分かりやすいよう、実行されるメソッドに requiresConfirmation = true を指定します。

DemoTools.java

package com.example.springai;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

@Component
public class DemoTools {
    private final Set<String> cancelledOrders = ConcurrentHashMap.newKeySet();

    @Tool(description = "指定された注文を取り消す(デモの疑似処理)", requiresConfirmation = true)
    public String cancelOrder(@ToolParam(description = "注文番号。例: ORD-001") String orderId) {
        cancelledOrders.add(orderId);
        return "注文 " + orderId + " を取り消しました(デモ)";
    }
}

cancelOrder が呼ばれても、承認前にこのメソッド本体は実行されません。なお、Tool Call Confirmationは業務上の権限チェックを代替しません。注文の所有者・状態・キャンセル可否などはツール内部でも検証する必要があります。

REST API:承認待ちの判定と実行再開

ChatResponse のツール実行承認待ちは、`ToolCallDecisions.awaitingDecision(response)` で判定できます。`content()` だけを読むと停止状態を見落とすので、chatResponse() を使う点が重要です。

次のControllerでは /api/ai/chat でチャットを開始し、承認が必要なら PENDING_APPROVAL を返します。ユーザーは /api/ai/approval へ許可または拒否を送ります。承認後は ToolCallingManager.executeToolCalls(...) で処理し、結果を同じセッションのモデルへ送り返します。

ここで使う ConcurrentHashMap はデモ専用です。 再起動・複数インスタンス・承認の競合には対応しません。承認待ちレスポンスをクライアントに持たせて書き戻させる実装は避けてください。

AiController.java

package com.example.springai;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.IntStream;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallDecision;
import org.springframework.ai.model.tool.ToolCallDecisions;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.model.tool.ToolExecutionResult;
import org.springframework.ai.session.advisor.SessionAdvisors;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/ai")
public class AiController {
    private final ChatClient chatClient;
    private final ToolCallingManager toolCallingManager;
    private final DemoTools tools;

    // デモ専用: 承認待ち ChatResponse は再起動で消える。本番は安全な永続ストアへ。
    private final Map<String, ChatResponse> pending = new ConcurrentHashMap<>();

    public AiController(ChatClient chatClient, ToolCallingManager toolCallingManager, DemoTools tools) {
        this.chatClient = chatClient;
        this.toolCallingManager = toolCallingManager;
        this.tools = tools;
    }

    public record ChatRequest(String sessionId, String message) { }
    public record ApprovalRequest(String sessionId, boolean allow) { }
    public record PendingCall(String key, String name, String arguments, boolean requiresApproval) { }

    @PostMapping("/chat")
    public Map<String, Object> chat(@RequestBody ChatRequest request) {
        if (pending.containsKey(request.sessionId())) {
            throw new IllegalStateException("未処理の承認待ちがあります。先に承認または拒否してください。");
        }
        ChatResponse response = chatClient.prompt()
                .user(request.message())
                .advisors(SessionAdvisors.session(request.sessionId()))
                .call()
                .chatResponse();
        return toApiResponse(request.sessionId(), response);
    }

    @PostMapping("/approval")
    public Map<String, Object> approval(@RequestBody ApprovalRequest request) {
        ChatResponse paused = pending.remove(request.sessionId());
        if (paused == null) {
            throw new IllegalStateException("承認待ちが存在しないか、すでに処理済みです。");
        }

        // 同一応答に含まれる全ツール呼び出しを一括で許可/拒否する最小デモ。
        List<String> keys = ToolCallDecisions.toolCallKeys(paused.getResult().getOutput());
        ToolCallDecisions decisions = new ToolCallDecisions(keys.stream()
                .map(key -> request.allow()
                        ? ToolCallDecision.allow(key)
                        : ToolCallDecision.deny(key, "ユーザーが実行を拒否しました"))
                .toList());

        ToolCallingChatOptions options = ToolCallingChatOptions.builder()
                .toolCallbacks(ToolCallbacks.from(tools))
                .build();
        ToolExecutionResult result = toolCallingManager.executeToolCalls(
                new Prompt(List.of(), options), paused, decisions);

        List<Message> history = result.conversationHistory();
        ChatResponse continued = chatClient.prompt()
                .messages(history.get(history.size() - 1))
                .advisors(SessionAdvisors.session(request.sessionId()))
                .call()
                .chatResponse();
        return toApiResponse(request.sessionId(), continued);
    }

    private Map<String, Object> toApiResponse(String sessionId, ChatResponse response) {
        if (response == null) {
            throw new IllegalStateException("モデルから応答がありませんでした");
        }
        List<String> awaiting = ToolCallDecisions.awaitingDecision(response);
        if (!awaiting.isEmpty()) {
            pending.put(sessionId, response);
            AssistantMessage message = response.getResult().getOutput();
            List<String> keys = ToolCallDecisions.toolCallKeys(message);
            List<PendingCall> calls = IntStream.range(0, keys.size())
                    .mapToObj(i -> new PendingCall(
                            keys.get(i),
                            message.getToolCalls().get(i).name(),
                            message.getToolCalls().get(i).arguments(),
                            awaiting.contains(keys.get(i))))
                    .toList();
            return Map.of("status", "PENDING_APPROVAL", "toolCalls", calls);
        }
        String answer = response.getResult().getOutput().getText();
        return Map.of("status", "COMPLETED", "answer", answer == null ? "" : answer);
    }
}

ToolCallDecisions.toolCallKeys(...) が返すキーを用いて、モデルが要求したすべてのツール呼び出しについて判定を指定します。今回のデモは一括許可・一括拒否ですが、実務ではツールごとに個別の判断を受ける設計も可能です。

result.conversationHistory() の最後はツール実行結果のメッセージです。Session Memoryには承認待ちのモデル応答が既に保存されているため、再開時はその最後のツール結果だけを同じ sessionId で送信します。

APIの動作確認

まず会話履歴の保存を確認します。PowerShellから次のリクエストを送ってください。

Invoke-RestMethod -Method Post -Uri http://localhost:8080/api/ai/chat `
  -ContentType 'application/json; charset=utf-8' `
  -Body '{"sessionId":"demo-1","message":"私の名前は田中です。覚えてください。"}'

Invoke-RestMethod -Method Post -Uri http://localhost:8080/api/ai/chat `
  -ContentType 'application/json; charset=utf-8' `
  -Body '{"sessionId":"demo-1","message":"私の名前は?"}'

同じ demo-1 を使用した2回目の質問では、通常は以前の内容を踏まえた回答になります。モデルの回答文面は一定ではありません。異なるセッションIDで実行すれば会話履歴が分離されることも確認できます。

次に、注文キャンセルを指示します。

Invoke-RestMethod -Method Post -Uri http://localhost:8080/api/ai/chat `
  -ContentType 'application/json; charset=utf-8' `
  -Body '{"sessionId":"order-1","message":"注文 ORD-001 をキャンセルして"}'

モデルが cancelOrder の実行を要求すると、レスポンスの status は PENDING_APPROVAL になり、`toolCalls` にツール名と引数が含まれます。モデルがツール呼び出しを選択するかは確率的なので、必ずこのレスポンスになるとは限りません。

承認する場合は次を実行します。

Invoke-RestMethod -Method Post -Uri http://localhost:8080/api/ai/approval `
  -ContentType 'application/json; charset=utf-8' `
  -Body '{"sessionId":"order-1","allow":true}'

拒否したい場合は、新しく承認待ちを作ってから "allow":false を渡します。拒否時はツールの実処理を呼ばず、拒否メッセージがモデルに渡されます。

実務導入で注意したい4つのポイント

  1. セッションの認可:クライアントから渡されたセッションIDを無条件に信用せず、ログインユーザーやテナントとの所有関係を検証する。
  2. 承認待ちの永続化:承認待ちの ChatResponse と実行状態を安全なストアで管理し、再起動や分散環境に対応する。既存の承認待ちを放置して次の質問を受け付けると、ツール結果が欠けてモデルエラーになる可能性がある。
  3. 重複実行防止:同じ承認を繰り返し送っても二重キャンセルや二重決済が起きないよう、承認状態の排他制御とツール処理の冪等化を実装する。
  4. 承認内容の表示:ツール名だけではなく、対象ID・変更内容・金額などの引数を人間に表示する。Tool Call Confirmationはそれ自体で認証やドメイン権限を保証するものではない。

また、Session MemoryのJDBC対応は2026年11月7日時点でPostgreSQL、MySQL、MariaDB、H2が中心です。従来の MessageChatMemoryAdvisor や ChatMemory はSpring AI 2.1から非推奨となり、3.0.0で削除予定です。ただし、移行先の対応ストアには差があります。既存プロジェクトでは依存関係とデータ移行計画を確認しましょう。

まとめ

Spring AI 2.1のSession Memoryを使うと、単純な会話履歴に加えて、ツール実行のやり取りをイベントログとして保存できます。Tool Call Confirmationを組み合わせれば、AIが勝手に重要な業務操作を実行するのをアプリケーション側で一時停止し、人間の判断を挟めます。

Spring BootでAIエージェントを作るなら、まずは「会話をどこに保存するか」と「何を実行前に承認させるか」を明確にすると設計しやすくなります。

MCP連携から始めたい方は、関連記事「Spring BootでMCPサーバーを作る:Spring AI MCP Starter入門」も参考にしてください。

参考資料(公式)

是非フォローしてください

最新の情報をお伝えします

類似投稿