AI Agent で生産性向上: Grab 流のアプローチと実装例
1. 概要
大企業の技術チームのような Grab は、単なる「チャットボットで質問に答える」から「AI Agent が自動的に行動する」へと移行して、技術的な生産性を向上させています: コード review を自動化し、pull request を自動要約し、incident を自動 triage します。この記事では、AI Agent の本質が単純な LLM 呼び出しとどう違うのか、一般的な Planner – Tool – Executor アーキテクチャ、そして Java 21 + Spring AI を使って同様の agent を自分で構築する方法を説明します。この記事を読んだ後、実際のツール(ファイル読み取り、内部 API 呼び出し)を呼び出す小さな agent を自分で書くことができるようになり、agent をいつ本番環境に投入すべきかを知ることができます。
2. AI Agent と通常の LLM 呼び出しの違い
単純な LLM 呼び出しは prompt を受け取ってテキストを返すだけです — 状態がなく、アクションもありません。一方、AI Agent には 3 つの特徴があります:
- 計画を自動立案(planning): 大きなリクエストを小さなステップに分割します。
- ツール呼び出し(tool calling / function calling): agent は API を呼び出したり、ファイルを読んだり、クエリを実行したりできます — テキスト生成だけではありません。
- 観察・行動ループ(observe – act loop): agent はツールの戻り値を確認し、次のステップを決定し、タスクが完了するまで繰り返します。
実際の大規模な engineering チームの例: agent が pull request の diff を読み込み、「チームの coding convention を取得する」ツールを呼び出し、「static analysis を実行する」ツールを呼び出し、その後、複数の review コメントに統合します — すべてが各ステップを待つ必要なく自動化されます。
注意: タスクが単なる「テキストの要約」や「言語翻訳」である場合、純粋な LLM 呼び出しで十分です。すべての OpenAI API 呼び出しに「agent」というラベルを付けないでください — 不要な複雑化により、システムのデバッグが難しくなります。
3. 基本的な agent アーキテクチャ: Planner – Tool – Executor
最も一般的なモデルは 3 つの部分で構成されています:
User request
│
▼
[Planner] -- 実行する必要があるステップのリストを生成、または次のツールを選択
│
▼
[Tool Executor] -- 実ツール呼び出し: API、DB、ファイルシステム
│
▼
[Observation] -- ツールの結果を Planner に返す
│
└── Planner が「完了」と報告するまで繰り返す
Planner は通常、LLM にツールの「メニュー」を提供されています(名前、説明、パラメータスキーマ)。LLM はコードを実行しません — どのツールをどのパラメータで呼び出すかを決定するだけです; あなたのコードが実際の実行者です。
3.1. 必要な依存関係
Spring AI はこのパターンに対してかなり優れた抽象化を提供します:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
4. 実践例: Code Review Agent
Order service の diff を受け取り、convention をチェックし、修正提案を出す agent を作りたいとします。まず、agent が呼び出せるツールを宣言します — ここでは、内部ファイルから coding convention を読み込むツールです。
@Component
public class ConventionTool {
@Tool(description = "チームの現在の coding convention を言語ごとに取得")
public String getConvention(String language) {
return switch (language.toLowerCase()) {
case "java" -> "DTO には record を使用; @Autowired フィールドは使用しない; SLF4J で logging を実施.";
default -> "この言語に対する特別な convention はまだありません.";
};
}
}
@Toolは Spring AI のアノテーションで、LLM が呼び出せるメソッドをマークします。descriptionは LLM が「このツールを呼び出すべきか」を決定するために読むテキストです — 明確に書けば、LLM が正しく選択します。
次に service のオーケストレーション — Planner + Executor の役割を果たすもの:
@Service
public class CodeReviewAgent {
private final ChatClient chatClient;
public CodeReviewAgent(ChatClient.Builder builder, ConventionTool conventionTool) {
this.chatClient = builder
.defaultTools(conventionTool)
.build();
}
public String review(String diff) {
return chatClient.prompt()
.system("あなたは reviewer です。コメントする前に必要に応じてツールを使って convention を取得してください.")
.user("次の diff を review してください:\n" + diff)
.call()
.content();
}
}
defaultTools(conventionTool)はすべてのリクエストに対してツールを登録します。review(diff)を呼び出すと、Spring AI は prompt 付きでツールメニューをモデルに送信します。モデルがgetConvention("java")を呼び出すことを決定した場合、フレームワークは自動的に対応する Java メソッドを実行し、結果を取得して、モデルに返して最終的な回答を生成します — この全体のループは 1 つのcall()コマンド内で発生します。
試してみます:
@RestController
@RequestMapping("/api/review")
public class ReviewController {
private final CodeReviewAgent agent;
public ReviewController(CodeReviewAgent agent) {
this.agent = agent;
}
@PostMapping
public String review(@RequestBody String diff) {
return agent.review(diff);
}
}
典型的な出力:
チームの convention では DTO には record を使い、logging には SLF4J を使う必要があります。
この diff では、OrderDto クラスはゲッター/セッターを持つ通常のクラスです —
record に変更する必要があります。42 行目は logger の代わりに System.out.println を使っています。
convention に従って修正してください。
重要なポイント: 「通常のクラスが見られた場合は record を提案する」というロジックを書く必要はありません — モデルがツールから取得した convention に基づいて自動的に推論します。これが agent が「生産性を向上」させる理由です: review はもはや人が convention を覚えているかどうかに依存しません。
5. 複数のツール呼び出し: PR Summary Agent
実際には大規模なチームは通常、agent が連続して複数のツール呼び出しを必要とします — 例えば: 変更されたファイルのリストを取得し、テストカバレッジを取得し、その後 PR を reviewer 用に要約します。
@Component
public class PullRequestTools {
private final GitService gitService;
private final CoverageService coverageService;
public PullRequestTools(GitService gitService, CoverageService coverageService) {
this.gitService = gitService;
this.coverageService = coverageService;
}
@Tool(description = "PR id で変更されたファイルのリストを取得")
public List<String> listChangedFiles(long prId) {
return gitService.changedFiles(prId);
}
@Tool(description = "ファイルのテストカバレッジの割合を取得")
public double getCoverage(String filePath) {
return coverageService.coverageOf(filePath);
}
}
- 2 つのツールは独立しており、各ツールは 1 つの小さな仕事をするため、個別にテストが容易です。
- モデルは呼び出しの順序を自動的に決定します: 通常は最初に
listChangedFilesを呼び出し、その後、返された各ファイルに対してgetCoverageを呼び出します — これがまさにセクション 3 で説明した「観察・行動」ループです。
@Service
public class PullRequestSummaryAgent {
private final ChatClient chatClient;
public PullRequestSummaryAgent(ChatClient.Builder builder, PullRequestTools tools) {
this.chatClient = builder.defaultTools(tools).build();
}
public String summarize(long prId) {
return chatClient.prompt()
.system("PR を要約してください: 変更ファイル数、70% 以下のカバレッジを持つファイルに注意が必要。")
.user("PR id " + prId + " を要約してください")
.call()
.content();
}
}
このタイプの agent は生産性の問題を正確に解決します: reviewer は各ファイルを開いてカバレッジを確認する必要がありません — agent が既に 1 つの要約に統合しています。
6. Agent のテスト
agent を実運用に投入する際の最も難しい部分は、LLM を呼び出すことではなく、ツールが正しく呼び出されることを保証することです(入力が変わるとき)。したがって、ツール用のユニットテスト(確定的)と agent 用の統合テスト(ChatClient をモック可能)を分けるべきです。
class ConventionToolTest {
private final ConventionTool tool = new ConventionTool();
@Test
void shouldReturnJavaConvention() {
String result = tool.getConvention("Java");
assertThat(result)
.contains("record")
.contains("SLF4J");
}
@Test
void shouldReturnDefaultMessageForUnknownLanguage() {
String result = tool.getConvention("cobol");
assertThat(result).isEqualTo("この言語に対する特別な convention はまだありません.");
}
}
- ツールのテストは純粋なメソッドをテストするのと同じ方法で行います。LLM をモックする必要はありません — これはあなたが 100% 制御できる部分です。
- LLM を実際に呼び出す agent 部分については、「契約」レベルでテストを書く必要があります: レスポンスに必須のキーワード(例: 「record」、「coverage」)が含まれていることをアサートし、LLM の出力は呼び出し間で完全に安定していないため、正確な文字列をアサートしないようにしてください。
@Test
void reviewShouldMentionConventionKeyword() {
String diff = "class OrderDto { private String id; }";
String result = codeReviewAgent.review(diff);
assertThat(result.toLowerCase()).containsAnyOf("record", "convention");
}
注意: LLM を呼び出すテストはコストがかかり、遅いです — 別のテストスイート(例えば
@Tag("llm-integration"))に分けて、パイプラインで個別に実行し、すべてのコミットで実行しないようにしましょう。
7. Agent 導入時の一般的なエラー
- ツール説明が曖昧: ツールの説明が不明確な場合、モデルが間違ったツールを選択したり、必要なときにツール呼び出しをしなくなります。初めてツールを見る人のために Javadoc を書くように説明を書きましょう。
- ループ回数の制限がない: agent はロジックが終了点が不明確な場合、ツール呼び出しのループに陥る可能性があります。オーケストレーション層で常に
maxIterationsまたはタイムアウトを設定してください。 - 機密性の高いデータを書く権限を agent に与える: agent に「本番環境にデプロイ」や「データ削除」ツールの権限を持たせないでください。agent は幅広い読み取り権限を持つべきですが、書き込み権限は限定的であるべきです。
- ツール呼び出しプロセスのログを記録しない: agent が間違った答えを出した場合、どのツールを呼び出したのか、どのパラメータで呼び出したのかを知る必要があります。このログがないとエラーの追跡がほぼ不可能になります。
- 単純なルールで解決できるタスクに agent を使う: ロジックが「X の場合は Y」のように固定されている場合、通常のコードを書く方が速く、安く、テストしやすいです。
8. AI Agent を使うべきではない場合
Agent は複数の不確定なステップがあり、多数のソースから情報を統合する必要があり、小さなエラーを許容できるタスク(review の提案、要約)に適しています。Agent は以下には適していません:
- 完全な正確さが必要なタスク(金銭計算、金融取引処理)。
- 固定されたプロセスがあり変更が少ないタスク — rule engine または workflow engine(Camunda、Temporal)は依然として安定していて監査しやすいです。
- レイテンシが低い必要があるシステム。なぜなら、各ツール呼び出しループは LLM への追加ラウンドトリップを費やすからです。
9. 結論
AI Agent は正しい場所に適用された場合、生産性に明らかな価値をもたらします: 繰り返される engineering タスク、複数のステップ、分散情報を統合する必要があるもの — コード review、PR 要約、エラー triage。「すべてを知る」agent を書く代わりに、小さく、明確で、独立したテストを持つ数個のツールから始め、Planner(LLM)が協調する方法を決定させるべきです。最も重要なのは権限を分けることです: agent に多く読む許可を与え、少なく書く許可を与え、常に最終段階で人の review を保ちます。これが大きな影響のあるアクションの場合特に重要です。