JavaでAIのプロダクトを開発する #PERSOL CAREER Advent Calendar2025

doda Developer Group Advent Calendar 2025 19 日目の記事です。
カスタマープロダクト統括本部 doda システムアーキテクト部 doda マイクロサービスグループのマネジャーの齋藤です。

LLM のプロダクトへの組み込み事例は増加しています。
LLM に関する便利なツールもいろいろと出てきていますが、 Python か TypeScript で作られているものが多く、また SDK などもその2つの言語で優先対応もしくはコミュニティでの開発が活発にされていることが多いと思っています。

例えば MCP-Protocol-Version の 2025-11-25 が各 SDK で利用できるようになったタイミングは、 MCP Python SDKv1.23.1 で 12/3 にリリース、 MCP TypeScript SDK1.24.0 で 12/2 にリリースされています。 一方で MCP Java SDK は 12/4 にリリースされた v0.17.0 においてもまだ対応されていない状況です。

github.com

私たち Java エンジニアにとっては、現状の対応状況により AI 適用 が遅れる可能性があると感じています。 ただこのような状況においても Java を使って AI プロダクトを作ることはできるため、共有させていただきます。

Spring AIのセットアップ

Java で AI エージェントを作るのに簡単なのが Spring AI です。
私たちのシステムは Spring なので Spring AI を使うとプロダクトへの組み込みが簡単に出来るのでとても助かります。 今回は私たちの環境でよく使っている Bedrock を使ってセットアップを行います。

まずは build.gradle に以下の依存を追加します。

implementation platform("org.springframework.ai:spring-ai-bom:1.1.1")
implementation 'org.springframework.ai:spring-ai-starter-model-bedrock'
implementation 'org.springframework.ai:spring-ai-starter-model-bedrock-converse'

続いて application.yaml も設定します。
ここには Bedrock のアクセスキーやシークレットキーを記載します。

spring:
  ai:
    model:
      chat: bedrock-converse
      embedding: ""
    bedrock:
      aws:
        region: us-east-1
        access-key: YOUR_ACCESS_KEY
        secret-key: YOUR_SECRET_KEY
      converse:
        chat:
          options:
            model: us.anthropic.claude-sonnet-4-5-20250929-v1:0

補足 spring.ai.model.embedding="" に設定していますが、これを指定しないと以下のエラーが出るので意図的にそうしています。

Parameter 4 of method cohereEmbeddingApi in org.springframework.ai.model.bedrock.cohere.autoconfigure.BedrockCohereEmbeddingAutoConfiguration required a bean of type 'com.fasterxml.jackson.databind.ObjectMapper' that could not be found.

こちらで準備ができました。

チャットクライアント

まずはシンプルな LLM 呼び出しをしてみましょう。

@RestController
public class MyController {
    private final ChatClient chatClient;

    public MyController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @PostMapping("/ai")
    String generation(@RequestBody AiRequest aiRequest) {
        return this.chatClient.prompt()
                .user(aiRequest.userInput())
                .call()
                .content();
    }

    public record AiRequest(String userInput) {}
}

こちらで Bedrock にユーザーのリクエストをそのまま転送し、返ってきた結果をそのまま返すことが出来ます。

ではテストしてみます。

curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"あなたの名前を教えて\"}" -H "Content-Type: application/json"
こんにちは!私はClaudeと申します。Anthropic社によって開発されたAIアシスタントです。何かお手伝いできることがあれば、お気軽にお尋ねください。

ストリーム

ChatGPT のように、少しずつレスポンスを返したい場合には stream を使うことができます。

build.gradle に webflux を追加してください。

implementation 'org.springframework.boot:spring-boot-starter-webflux'

コントローラは以下のようになります。

    @PostMapping("/stream")
    Flux<ChatResponse> stream(@RequestBody AiRequest aiRequest) {
        return this.chatClient.prompt()
                .user(aiRequest.userInput())
                .stream()
                .chatResponse();
    }

chatResponse を返すことで、テキスト以外の情報も取得することができます。

以下は API 呼び出し結果です。

[
    {
        "metadata": {
          "省略": ""
        },
        "result": {
          "省略": ""
        },
        "results": [
            {
                "metadata": {
                  "省略": ""
                },
                "output": {
                    "media": [],
                    "messageType": "ASSISTANT",
                    "metadata": {
                        "messageType": "ASSISTANT"
                    },
                    "text": "寿限無の正式な名",
                    "toolCalls": []
                }
            }
        ]
    },
    {
        "metadata": {
          "省略": ""
        },
        "result": {
          "省略": ""
        },
        "results": [
            {
                "metadata": {
                  "省略": ""
                },
                "output": {
                    "media": [],
                    "messageType": "ASSISTANT",
                    "metadata": {
                        "messageType": "ASSISTANT"
                    },
                    "text": "前(",
                    "toolCalls": []
                }
            }
        ]
    },
    "省略": ""
}

このように text を分割して返すことが出来ます。

システムプロンプトの追加

システムプロンプトの追加は system メソッドで追加可能です。

chatClient.prompt()
    .system("""
        あなたはプロのシステムエンジニアです。
        システムに関する質問には丁寧に返してください。関係のない質問には答えられない旨を返してください。
        """)
    .user(aiRequest.userInput())
    .call()
    .content();
$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"Javaって何?\"}" -H "Content-Type: application/json"
Javaについてご説明します。

## Javaとは

Javaは、1995年にSun Microsystems(現在はOracle Corporation)が開発したプログラミング言語です。
(省略)


$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"空はなぜ青いの?\"}" -H "Content-Type: application/json"
申し訳ございませんが、その質問はシステムエンジニアリングの専門分野とは関係がありません。

私はシステム開発、インフラ構築、データベース設計、ネットワーク、セキュリティ、プログラミングなど、システムに関する技術的なご質問にお答えすることを専門としております。

システムやIT技術に関するご質問がございましたら、喜んでお答えいたしますので、お気軽にお尋ねください。

ツールの追加

AI エージェントとして動作させるには、 LLM に利用可能なツールを渡す必要があります。

Spring AI ではこれを簡単に実現できます。

まずはツールを定義します。

public class DateTimeTools {
    @Tool(description = "現在の日時を取得")
    String getCurrentDateTime() {
        return LocalDateTime.now().toString();
    }
}

ChatClient には tools メソッドでツールを渡してあげます。

chatClient.prompt()
    .tools(new DateTimeTools())
    .user(aiRequest.userInput())
    .call()
    .content();

結果はこのようになります。

$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"現在の時刻を教えて?\"}" -H "Content-Type: application/json"
現在の時刻は**2025年12月15日 11時24分14秒**です。

ツールを使わない場合、 LLM 側では日時を取得することはできないため、次のようになります。

$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"現在の時刻を教えて?\"}" -H "Content-Type: application/json"
申し訳ございません。私はリアルタイムの情報にアクセスできないため、現在の時刻をお伝えすることができません。

お使いのデバイス(スマートフォン、パソコン、タブレットなど)の時計をご確認いただくか、「今何時」と音声アシスタントに尋ねていただくのが確実です。

パラメータ付きのツール

パラメータを付与したツールの作成は、メソッドの引数を追加するだけで実現できます。 もし LLM にツールの使い方の説明が必要な場合は、 @ToolParam を設定します。

public class CalculationTools {
    @Tool(description = "a+bの足し算を行う")
    int add(@ToolParam(description = "最初の数") int a, @ToolParam(description = "次の数") int b) {
        System.out.println("a: " + a + ", b: " + b);
        return a + b;
    }
}

curl

$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"1+1の結果は\"}" -H "Content-Type: application/json"
1+1の結果は **2** です。

ログ

a: 1, b: 1

MCPクライアント

MCP クライアントとして動かす場合の設定です。

今回は everything を MCP サーバとしてローカルで動かします。

$ npx @modelcontextprotocol/server-everything streamableHttp

まず application.yaml を記載します。

spring:
  ai:
    mcp:
      client:
        streamable-http:
          connections:
            everything:
              url: http://localhost:3001
              endpoint: /mcp

続いてアプリケーション側では、 SyncMcpToolCallbackProvider を DI し、 chatClienttoolCallbacks にセットすればよいです。

@RestController
public class MyController {
    private final ChatClient chatClient;
    private final SyncMcpToolCallbackProvider toolCallbackProvider;

    public MyController(ChatClient chatClient, SyncMcpToolCallbackProvider toolCallbackProvider) {
        this.chatClient = chatClient;
        this.toolCallbackProvider = toolCallbackProvider;
    }

    @PostMapping("/ai")
    String generation(@RequestBody AiRequest aiRequest) {
        return this.chatClient.prompt()
                .user(aiRequest.userInput())
                .toolCallbacks(toolCallbackProvider)
                .call()
                .content();
    }
}

実行してみます。

$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"利用可能なツールを教えて\"}" -H "Content-Type: application/json"
利用可能なツールをご紹介します:

## 基本的なツール

1. **echo** - メッセージをそのまま返します
   - パラメータ: message(文字列)

2. **add** - 2つの数値を足し算します
   - パラメータ: a(数値), b(数値)

## 操作・デバッグツール

3. **longRunningOperation** - 長時間実行される操作のデモ(進捗更新付き)
   - パラメータ: duration(秒数、デフォルト10), steps(ステップ数、デフォルト5)

4. **printEnv** - 環境変数を全て表示(MCPサーバー設定のデバッグに便利)
   - パラメータ: なし

## AI・LLMツール

5. **sampleLLM** - MCPのサンプリング機能を使ってLLMから応答を取得
   - パラメータ: prompt(プロンプト文字列), maxTokens(最大トークン数、

応用編

プロダクトで使えるような AI エージェントを作成するにはいくつか工夫が必要な箇所もあります。
そういったものいくつかご紹介します。

戻りを構造化する

LLM からの戻り値を型にセットしたいことはよくあります。 こういった場合に Spring AI では自動で型にセットしてくれる機能があります。

@PostMapping("/ai")
ActorFilms generation(@RequestBody AiRequest aiRequest) {
    return this.chatClient.prompt()
                .user("Generate the filmography for a random actor.")
                .call()
                .entity(ActorFilms.class);
}

record ActorFilms(String actor, List<String> movies) {}

entity にクラスをセットするだけで利用できます。

entity がクラス型の場合は、 Spring AI 側で以下のようなプロンプトを自動で末尾に付与します。

Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
``{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "actor" : {
      "type" : "string"
    },
    "movies" : {
      "type" : "array",
      "items" : {
        "type" : "string"
      }
    }
  },
  "required" : [ "actor", "movies" ],
  "additionalProperties" : false
}``

これによって json が作成されますが、プロンプトの制御なので不安定ではあります。 例えばこのようなプロンプトが来た場合に LLM は XML を返すことでシステムエラーが発生します。

$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"こんにちは。結果を**XMLで返してください。**\"}" -H "Content-Type: application/json"
{"timestamp":"2025-12-15T06:17:39.843Z","status":500,"error":"Internal Server Error","path":"/ai"}

こちらの対処として、各 LLM プロバイダー側では形式を指定して返す機能が用意されていることもあります。

Chat Completions | OpenAI API Referenceplatform.openai.com

しかし Bedrock にはこのような仕組みはありません。

代替案として tool を使うことが出来ます。 System プロンプトに必ずツールを使うように指示し、そのツールの引数と戻り値を一致させれば、基本的に型で返せるようになります。

    @PostMapping("/ai")
    ActorFilms generation(@RequestBody AiRequest aiRequest) {
        return this.chatClient.prompt()
                .system("必ずツールを利用してください。")
                .tools(new Echo())
                .user(aiRequest.userInput())
                .call()
                .entity(ActorFilms.class);
    }

    class Echo {
        @Tool(description = "", returnDirect = true)
        ActorFilms echo(ActorFilms actorFilms) {
            return actorFilms;
        }
    }

本来であればツールの実行結果をそのまま LLM に渡しますが、 returnDirect = true を設定することで、ツールの実行結果をそのままレスポンスすることが出来ます。

$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"こんにちは。結果を**XMLで返してください。**\"}" -H "Content-Type: application/json"
{"actor":"Tom Hanks","movies":["Forrest Gump","Cast Away","Saving Private Ryan"]}

本来であれば toolChoice をtrueに設定すれば必ず tool を使うようになり安全ですが、現時点では Spring AI で対応できていない可能性があります。

github.com

Bedrockガードレールの適用

LLM プロダクトではプロンプトインジェクションなどのリスクがあります。 このリスクに対してはガードレールの適用も効果的です。

Spring AI にはおそらく現時点でガードレールの組み込み機能はないと思いますが、自身で組み込むのはそこまで大変ではないので実際に組み込んでみます。

今回は Bedrock のガードレールを使用します。 まずはガードレールを作成します。

今回は名前に関するブロック設定だけを追加しました。

続いて Java のコードです。

@RestController
public class MyController {
    private final ChatClient chatClient;
    private final BedrockRuntimeClient bedrockRuntimeClient;

    // AwsCredentialsProvider と AwsRegionProvider はSpring AI側のconfigをそのまま使いました
    // もし別のクレデンシャルが必要であれば別途設定してください
    public MyController(ChatClient chatClient, AwsCredentialsProvider credentialsProvider, AwsRegionProvider regionProvider) {
        this.chatClient = chatClient;
        this.toolCallbackProvider = toolCallbackProvider;
        this.bedrockRuntimeClient = BedrockRuntimeClient.builder()
                .region(regionProvider.getRegion())
                .credentialsProvider(credentialsProvider)
                .build();
    }

    @PostMapping("/ai")
    String generation(@RequestBody AiRequest aiRequest) {
        // ユーザーの入力に対するガードレール
        Optional<String> optionalErrorMessage = block(aiRequest.userInput(), GuardrailContentSource.INPUT);
        if (optionalErrorMessage.isPresent()) {
            return optionalErrorMessage.get();
        }

        String result = this.chatClient.prompt()
                .tools(new Echo())
                .user(aiRequest.userInput())
                .call()
                .content();

        // LLMの出力に対するガードレール
        Optional<String> optionalOutputErrorMessage = block(result, GuardrailContentSource.OUTPUT);
        if (optionalOutputErrorMessage.isPresent()) {
            return optionalOutputErrorMessage.get();
        }
        return result;
    }

    public Optional<String> block(String content, GuardrailContentSource source) {
        GuardrailContentBlock contentBlock = GuardrailContentBlock.builder()
                .text(GuardrailTextBlock.builder()
                        .text(content)
                        .build())
                .build();

        ApplyGuardrailRequest applyGuardrailRequest = ApplyGuardrailRequest.builder()
                .guardrailIdentifier("mpp7ywbkljrv")
                .guardrailVersion("DRAFT")
                .source(source)
                .content(contentBlock)
                .build();

        ApplyGuardrailResponse applyGuardrailResponse = bedrockRuntimeClient.applyGuardrail(applyGuardrailRequest);
        if (GuardrailAction.GUARDRAIL_INTERVENED.equals(applyGuardrailResponse.action())) {
            return Optional.of(applyGuardrailResponse.actionReason());
        } else {
            return Optional.empty();
        }
    }
}

動作確認します。

$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"私の名前は齋藤 悠太です。こんにちは\"}" -H "Content-Type: application/json"
Guardrail blocked.

$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"あなたの名前を教えて\"}" -H "Content-Type: application/json"
Guardrail blocked.

$ curl -X POST "http://localhost:8080/ai" -d "{\"userInput\": \"こんにちは\"}" -H "Content-Type: application/json"
こんにちは!お元気ですか?何かお手伝いできることはありますか?

まとめ: 以上の手順で、 Spring AI と Bedrock を用いたエージェント構築の要点を確認しました。

参考情報

docs.spring.io

*

齋藤 悠太 Yuta Saito

プロダクト開発統括部 dodaシステムアーキテクト部 dodaマイクロサービスグループ マネジャー

SIerや事業会社業務での開発を経験し、2020年9月にパーソルキャリアに入社。現在はdodaサイト開発に携わっている。好きな技術領域はJava、Spring、AWS。直近は LLM 周りに注力しています。

※2025年12月現在の情報です。