Gaudiy Tech Blog

Gaudiyの技術、開発組織、カルチャーについてお伝えするブログです

少人数の新規チームでAI駆動開発を1ヶ月半実践してみた

こんにちは。Gaudiyでバックエンドエンジニアをしているryio1010です。

突然ですが、皆さんの開発チームは何人構成でしょうか?

私の所属する新たに新設されたチームは、PdM、デザイナーが0.5人(他PJと兼務)、エンジニアがフロントエンドとバックエンド各1人です。

Gaudiyの他の開発チームはPdMとデザイナーそれぞれ1人、エンジニアが4-5人ほどのチームが多いので、従来よりも少ないチーム構成となっています。

詳細は後述しますが、そんな構成のチームの中で私たちに与えられたミッションは、「AIをフル活用して、他チームと同等のパフォーマンスを出す」というものでした。

従来のやり方にとらわれない自由な開発が許されている環境で、ここ1ヶ月半ほどAI駆動開発に本格的に取り組んできました。

この記事ではこのミッションのもと「AI駆動開発」に取り組んでみて得た学びや、実践と試行錯誤の過程をお届けします。

1. なぜAI駆動開発を始めたのか

チームが発足した当初、Dev代表からこんなミッションをもらいました。

デリバリーする機能の開発が最優先だが、それを担保した上でAIをフル活用して従来のやり方にとらわれない自由な開発の仕方を模索してほしい。 AIをチームでどう活用できるかを考えてほしい。

エンジニア一人当たり4万円/月まで会社負担でAIツールを自由に利用できる福利厚生も整備されてきており、これまでももちろんエンジニアは個人としてAIツールを開発に取り入れていましたが、改めてチームとしてAIを利用した上での開発フローの確立をしたいという意図がありました。

フロントエンド・バックエンドエンジニア各1名という構成で、AIツールの活用を前提としたチーム作りは、ある程度チームとして戦略立ててAIを使っていかなければ個人で使用していた以上の成果を出すことは難しいだろうと考えていました。

そこでまず、チームとして使うAIツールの選定から始めることにしました。

2. チームとして実践したこと

実践内容 詳細
AIツールの統一 • ナレッジの共有や蓄積のしやすさを重視
• その他のツールは各自の責任で自由に利用可能
MCPサーバーの活用 filesystem MCPで別リポジトリを参照
• 社内Go共通実装リポジトリのMCPを活用
• その他MCPの活用
AI Daily Sync 毎朝5〜10分のカジュアルな情報交換
• フロントエンド・バックエンドの異なるアプローチを共有

チームとして取り組むという観点から話し合った結果、ナレッジの共有や蓄積のしやすさ考えてメインで使うツールはなるべく統一したほうがよいだろう、という結論になりました。

CursorやChatGPTなどいくつかのツールを試していたとき、ちょうどClaude Codeがサブスクプランで使えるようになり、世間的に盛り上がりを見せていたタイミングでした。

そこで私たちのチームは Claude (Code/Desktop) をメインとし、設計の壁打ち相手やClaude以外の視点を入れる目的として Gemini を併用するスタイルに落ち着きました。それ以外のAIツールについてはセキュリティーなどは考慮しつつも各自の責任で好きなものを開発に利用することとしました。

また、MCPサーバーも積極的に活用しました。filesystem MCPで別リポジトリを参照できるようにしたり、別チームが開発を進めていた社内のGo共通実装リポジトリの MCPなども活用し、開発を進めていきました。

そしてツール以上に効果的だったのが、毎日実践した「AI Daily Sync」です。これは朝会の前にほんの5〜10分、AIについてカジュアルに情報交換する時間です。

「昨日Gemini CLIを試したら、結構良くて…」 「CLAUDE.mdの管理、どうしてる?」

フロントエンドとバックエンドの開発の取り組みについては後述しますが、両者では使うツールも違えば、AIへのアプローチも細かい部分では異なっていました。だからこそ短い時間でのsync会が、次の取り組みへのヒントになることも多くありました。実際にCLAUDE.mdの管理方法やdocsディレクトリの構成の再検討などを実践する機会となりました。

何より「それいいね!他のrepoでも試してみよう」と、互いの知見を取り入れることでAI活用の知見をチームとして高められた点が特に良かったと感じています。

また毎日話す機会を作ることで、各人が自然とAIについて調査する時間を意識的にとるようにもなっていました。この取り組みは今でも続けています。

Syncの内容をSlackでシェアしてる様子

3. ドキュメント駆動開発を軸にする

AI駆動開発を進めていく中で、どのAIツールを使う上でも共通していることがあると感じていました。

AIのパフォーマンスは、インプットされるコンテキストの質で決まる」

AIに質の高い仕事やアウトプットをしてもらうには、私たちが質の高い情報、すなわち「ドキュメント」をコンテキストとして整備して与えてあげることが不可欠だと感じています。ドキュメントがない場合とのアウトプットの質にかなり差があることはこれまでのAIを活用した開発でも感じていた部分でした。

実践を通して私たちは以下の理由からAIと協働する時にはドキュメント駆動開発を導入した方が良いと考えるようになりました。

3-1. 共通認識としてのドキュメント

開発において最もコストがかかるのは、後から発覚する仕様の認識齟齬による手戻りだと感じています。ドキュメントを起点に開発を進めることで、そのドキュメントをもとに仕様の確認ができるため認識齟齬を減らすことができています。

何を作るべきか、なぜ作るのかが明文化されるため、開発者間はもちろんAIとの間でも認識がズレるリスクを大幅に低減できました。また実装の前にドキュメントのレビューを行うことで、各人が共通認識を持った上で適切なコンテキストのもとでAI開発を行うための土台になっています。

実際のドキュメントの一例

├── docs
│   ├── 01-requirements ★要件定義書など
│   ├── 02-architecture ★ドメイン設計・データ設計など
│   ├── 03-api-reference ★各RPCの仕様書
│   ├── 04-development ★開発ガイドラインなど
│   ├── 05-decisions ★ADR

3-2. 「とりあえず実装」からの脱却

ドキュメントを作成する過程は、要件や設計の曖昧な点を強制的に洗い出すプロセスでもありました。

「とりあえず実装しながら考えよう」という進め方では見落としがちなエッジケースや考慮すべき点を事前に特定できるため、結果的により具体的なプロンプトをAIに与えることができるようになり、AIが生成するコードの質を向上させることができたと感じています。

3-3. チームの生産性向上

AIのために整備を進めたドキュメントはAIだけのものではなく、むしろチームにとっての資産になります。

新しいメンバーが参加した際、コードを隅々まで読まなくてもドキュメントを読めばプロジェクトの全体像や過去の意思決定(なぜこの技術を採用したのか等)をキャッチアップできます。

そして何より「AIに正確に指示を出すため」という明確な目的が、これまで後回しにされがちだったドキュメント作成のモチベーションになっていると感じています。

結果として、ドキュメント駆動開発は「AIのため」ではなく、「チーム全体の開発プロセスを最適化する手法」であるという結論に至り、この開発手法を導入しています。

4. バックエンド開発 with AI

ここからはバックエンド/フロントエンドそれぞれでの取り組みを簡単に紹介します。

バックエンド開発はAIとの協業を成功させるために、まず「AIが迷わず、最高のアウトプットを出せる土台をどう作るか」という点から考え始めました。

4-1. AI駆動開発の3つの指針

AI駆動開発を始める前に、AIと開発するための3つの指針を考えました。

ドキュメント駆動

「3. ドキュメント駆動開発を軸にする」でもご説明した考え方です。AIのパフォーマンスはインプットされるコンテキストの質に大きく左右されます。人間とAIが同じドキュメントを「共通のコンテキスト」として参照することで、双方の認識のズレを防ぎ、精度の高い実装を目指しました。

テストファースト

AIは高速なコード生成は可能ですが、人間が見ればすぐに気づくような単純なミスや、考慮漏れなどのハレーションを起こすことが少なくありません。AIの生成物を盲信せずに必ずテストで品質を担保することを目指しました。

DDDとレイヤードアーキテクチャ

ドメイン駆動設計(DDD)の考え方を取り入れ、責務を明確に分離するレイヤードアーキテクチャを採用しました。これにより、各レイヤーのインターフェースを定義すれば、AIが実装を並列で進められるようになることを目指しました。

4-2. Claude Slash Commandsを利用した標準化

上記の指針を、日々の開発作業に落とし込むためにClaude Slash Commandsをベースとした開発フローを実践しました。AIへの指示が個人のプロンプトスキルに依存するのを防ぎ、「誰がやっても同じ品質」の開発の「型」を作りました。

コマンド 目的 実現される効果
implrpc RPCエンドポイントの実装 ドキュメント存在確認を強制(仕様書がないと実装不可)
• テスト駆動開発(TDD)の徹底
• analyze → 人間実装 → implementの段階的実装
commit-ready コミット前の品質チェック • モック生成・フォーマット・Lint・テストを順次実行
• エラーが完全に解決するまで継続的にAIが修正
• コミットされるコードの品質を一定以上に保証
create-pr PR作成の自動化 • PRテンプレートに従った一貫性のあるPR作成
• 修正内容に適したPRタイトル・説明の自動生成
• セルフレビューコメントの自動追加

implrpcコマンド

RPCのエンドポイントを実装するためのコマンドです。実行時にまず対応するRPC仕様書の存在を確認し、「ドキュメントをもとに必ず開発する」というルールをコマンドで強制する仕組みです。またドメインレイヤーや各レイヤーを繋ぐInterfaceは人間が主導して実装することでAIによるハレーションを減らすようにしています。

# Implement RPC

**このコマンドの目的**: テスト駆動開発)手法に従って新しいRPCエンドポイントを実装します。

## Overview

Implement a new RPC endpoint following strict TDD (Test-Driven Development) methodology.

⚠️ **IMPORTANT NOTICE** ⚠️

Always carefully consider before executing commands from this file. Before implementation, verify:
- The RPC specification is clearly defined
- You understand the impact on the existing codebase
- You understand the Test-Driven Development process

## Arguments

- `rpc_name` (required): The name of the RPC method to implement
- `phase` (optional): The execution phase - "analyze" (default) or "implement"

## Prerequisites (MANDATORY)

**→ For detailed implementation standards, refer to the following sections in `coding-standards.md`:**
- RPC Implementation Standards
- Documentation Standards
- Test-Driven Development (TDD)

### Mandatory Pre-implementation Checklist:

1. **Verify RPC Specification**
2. **Study Project Documentation**
3. **Validate Proto Definition**
4. **Reference Existing Implementations**
5. **Check Auto-generated Files**

## Execution Modes

This command operates in two phases:

### Phase: "analyze" (Default)
When executed without phase argument or with `phase=analyze`:
- Analyzes the proto definition
- Provides guidance and templates for human implementation
- Shows examples of interfaces and tests to write

### Phase: "implement"
When executed with `phase=implement`:
- Reads human-written code from Phase 1
- Executes three parallel implementation tasks
- Generates complete implementation based on interfaces

## RPC Implementation Specification

Based on the provided `rpc_name` argument, the command will:

### RPC Details
- **RPC Name**: `{rpc_name}`
- **Functionality**: {Analyze proto definition to determine functionality}
- **Proto Messages**: {Extract Request/Response types from proto definition}

## Implementation Process

### When phase="analyze" (First Execution)

The command will:

1. **Review Documentation** (MANDATORY)
2. **Analyze Proto Definition**
3. **Generate E2E Test Implementation**
5. **Provide Implementation Guidance**
6. **Output Templates and Implementation**
   **E2E Test Implementation** (`test/e2e/{snake_case_rpc_name}_test.go`):

### Human Implementation Phase (Between Commands)

**After reviewing the analysis and AI-generated E2E tests, humans should implement:**

1. **Layer Interfaces**
2. **Domain Layer** (if new domain concepts are needed)

### When phase="implement" (Second Execution)

**After human implementation is complete, the command will:**

1. **First, review all documentation** (CRITICAL)
   - **MUST** re-read docs to ensure implementation follows project standards
   - **MUST** verify patterns match architecture documentation
   - **MUST** check RPC specification in `docs/03-api-reference/{snake_case_rpc_name}.md`
   - **MUST** follow patterns from `docs/architecture/` and `docs/technical/`
   - **MUST** ensure all implementations align with documentation

2. **Then execute parallel tasks following strict TDD methodology:**

#### Parallel Task 1: Interface Layer Implementation (RPC Handler) - TDD Process
**MANDATORY TDD Steps:**
1. **FIRST: Write failing unit tests**
2. **SECOND: Implement minimal code** to make tests pass (green phase)
3. **THIRD: Refactor** while keeping tests green

#### Parallel Task 2: UseCase Layer Implementation - TDD Process
**MANDATORY TDD Steps:**
1. **FIRST: Write failing unit tests**
2. **SECOND: Implement minimal code** to make tests pass (green phase)
3. **THIRD: Refactor** while keeping tests green

#### Parallel Task 3: Infrastructure Layer Implementation - TDD Process
**MANDATORY TDD Steps:**
1. **FIRST: Write failing unit tests**
2. **SECOND: Implement minimal code** to make tests pass (green phase)
3. **THIRD: Refactor** while keeping tests green

commit-readyコマンド

コミット前に必ず実行する、品質チェック用のコマンドです。mock生成、フォーマット、Lint、テストを順番に実行し、問題があればAIが可能な範囲で自動修正まで行います。これにより、コミットされるコードの品質を一定以上に保ちます。

# Commit Ready

**このコマンドの目的**: コードをコミットする前に、フォーマット、リント、テストのチェックを実行してコードベースを準備します。

## Overview

Prepare the codebase for commit by running format, lint, and test checks. This command runs the essential pre-commit checks to ensure code quality and correctness before committing changes.

## CRITICAL REQUIREMENTS

**IMPORTANT**: This command MUST execute ALL of the following commands in order:
1. `make gomock`
2. `make fmt`
3. `make lint`
4. `make test`

**MANDATORY**: The command MUST continue fixing all lint and test errors until they are completely resolved. Do not stop at the first error - continue iterating and fixing issues until all checks pass successfully.

## What this command does

1. **Generate mocks** (`make gomock`) - MUST be run first to regenerate all mock files ensuring they're up to date with current interfaces
2. **Format code** (`make fmt`) - Formats all Go code using goimportz and gofumpt
3. **Lint code** (`make lint`) - Runs golangci-lint to check for code quality issues
4. **Check test coverage** (`make coverage`) - Generates test coverage report and ensures adequate coverage
5. **Verify test implementation** - Checks that all layers have proper tests:
6. **Run tests** (`make test`) - Executes all tests with race detection

## Execution Order and Error Handling

The commands are executed in this specific order:
1. `make gomock` - ALWAYS runs first to ensure mocks are up to date
2. `make fmt` - Formats the code (including newly generated mocks)
3. `make lint` - Checks code quality
   - If lint errors are found, fix them and re-run `make lint`
   - Continue fixing and re-running until all lint errors are resolved
4. `make coverage` - Checks test coverage
5. `make test` - Runs all tests
   - If test failures occur, fix them and re-run `make test`
   - Continue fixing and re-running until all tests pass

**IMPORTANT**: Do NOT stop at the first error. Keep fixing issues and re-running the failed command until it passes, then continue with the next command.

## Prerequisites

- All source code files should be saved
- Docker should be running (for Spanner emulator during tests)
- No ongoing file modifications

## Output

The command will:
- Show formatting results
- Display any lint issues that need to be fixed
- Display test coverage report with percentages per package
- Identify any missing tests or low coverage areas
- Run the full test suite and report results
- Indicate if the code is ready for commit

## Exit behavior

- If any step fails, the command will stop and show the error
- Only when all checks pass is the code considered commit-ready
- Fix any reported issues before attempting to commit

## Common issues and solutions

### Mock generation issues
- **Outdated mocks**: If interfaces have changed but mocks haven't been regenerated, tests will fail
- **Missing mocks**: New interfaces need mock generation before tests can be written
- **Solution**: Always run `make gomock` before committing to ensure all mocks are up to date

### Lint errors
- **godot**: Comments should end with a period
- **gofmt**: File formatting issues (automatically fixed by `make fmt`)
- **goimports**: Import organization issues (automatically fixed by `make fmt`)

### Test failures
- Check test output for specific failure details
- Ensure all mocks are properly configured
- Verify database schema is up to date

### Coverage issues
- **Missing E2E tests**: Check that all RPC endpoints have corresponding tests in `test/e2e/`
- **Low use case coverage**: Ensure all use case methods have unit tests with both success and error scenarios
- **Missing converter tests**: Add tests for all conversion and validation functions
- **Repository coverage**: Verify integration tests cover all repository methods

### Format issues
- Usually auto-fixed by `make fmt`
- Ensure consistent indentation and import grouping

create-prコマンド

開発が完了した際にcommitからPRの作成までを行うコマンドです。PRのテンプレートを参照し、修正内容にあったPRを作成します。

# Create PR

**このコマンドの目的**: CLAUDE.mdのルールに従ってプルリクエストを作成し、レビューコメントを追加します。

## Overview

This Claude Code project command creates a PR following the rules in CLAUDE.md and adds a review comment.

## Functionality

1. Ensures commits follow Semantic Git commits convention using `npx git-cz`
2. Checks current branch changes using git commands
3. Creates PR following CLAUDE.md PR creation rules:
4. After PR creation, adds self-review comment:

## Implementation

This command is executed internally by Claude Code, not as a bash script. When invoked via `/create-pr`, Claude will:

1. Use `npx git-cz` for creating semantic commits if there are uncommitted changes
2. Run `git status`, `git diff`, and `git log` to analyze changes
3. Use `gh pr create` with proper template structure and semantic title
4. Add review comment using `gh pr comment`

## Commit Convention

All commits must follow Semantic Git commits format using `npx git-cz`:

- **feat**: A new feature
- **fix**: A bug fix
- **docs**: Documentation only changes
- **style**: Changes that do not affect the meaning of the code
- **refactor**: A code change that neither fixes a bug nor adds a feature
- **test**: Adding missing tests or correcting existing tests
- **chore**: Changes to the build process or auxiliary tools

PR titles should match the primary commit type and scope.

## Prerequisites

- Changes must be pushed to remote branch beforehand
- Must be executed from a branch other than main
- Ensure commits exist before execution
- GitHub CLI (`gh`) must be configured and authenticated

これらのコマンドを整備したことで、「AIにどう指示すればいいか迷う時間」がほぼゼロになりある程度実装の型を作ることができました。

4-3. コンテキスト管理と役割分担

またAIの能力を最大限に引き出すため、コンテキスト管理や役割分担も工夫しました。

ドキュメントとコンテキストの分離・整備

AIに与える情報は質だけでなく量も重要です。要件定義からDB設計・ADRなど、必要だと思ったドキュメントはまずAIにたたき台を作らせ、人間がレビューする方針で網羅的に整備しました。 またコンテキストファイルは役割を分け、AI向けのCLAUDE.mdはAIの理解度・精度が高い英語で、人間が主に参照するドキュメントは可読性を重視して日本語で管理しています。

AIと人間の役割分担

アプリケーションの核となるドメインロジックや、レイヤー間の繋がりを定義するインターフェース部分は人間が主体となって実装するようにしています。重要な部分の実装をAIに全て任せてしまうとどれだけ適切にコンテキストを与えることができていたとしても、手戻りが大きくなるリスクがあるためです。

5. フロントエンド開発 with AI

(この章はFEのAI駆動開発を推進したエンジニアのhan sanに寄稿いただきました。)

フロントエンド開発は、バックエンドとはまた違ったアプローチでの開発を実施しました。

5-1. 目的ごとのツール群とMCPサーバーの活用

フロントエンドではより細かく用途によるAIツールの使い分け、MCPの活用を行いました。

  • メインツール: Claude (Code/Desktop)
  • 自動化・サブツール: GitHub Copilot Agent
  • 壁打ち・相談役: Gemini Pro

特に開発効率を大きく向上させたのが、MCPサーバーの活用です。

  • context7: OSSのドキュメントなどを参照させ、Mantine UIのようなライブラリの正しい使い方をAIに学習させる。
  • playwright: AI自身に画面を操作・確認させ、E2EテストやUIの動作確認を行わせる。
  • figma: デザインデータからUIプロパティを直接取得させ、デザインと実装の乖離を防ぐ。

5-2. 2つの開発パターン

日々の開発では、タスクの複雑さに応じてAIへの指示の出し方を2つのパターンで使い分けていました。

パターン1: シンプルなタスクは「直接指示」

「テキストのvariantを変えてください」や「実装に合わせてStorybookのケースを増やしてください」などは直接指示でも十分期待通りの結果を出せます。

パターン2: 複雑なタスクは「Opus 4で計画、Sonnet 4で実行」

一機能の実装計画を立てる場合はまず思考能力の高いClaude Opus 4に「フロントエンドのエキスパート」として詳細な実装計画を立て、その計画をClaude Sonnet 4に渡してコーディングを行います。 Opus 4 はセッションあたりのトークン上限が Sonnet 4 より小さいため、コスト効率の観点から「計画=Opus 4」「実装=Sonnet 4」と使い分けています。計画の精度が高ければ、実装品質は Sonnet 4 で十分に担保できます。

5-3. AIと協働するための工夫

並列作業の実現

Git worktreeとClaude CodeなどのAI CLIツールを組み合わせることで、複数の、ターミナルセッションで作業を同時に進められることができます。これによってエンジニア一人で複数のタスクを分担処理でき、作業の効率化を図ることができました。

エンジニアがUIデザインまで担う自由度

UIライブラリで構築された管理画面の実装を行う際、Context7 MCPを活用することで、AIがUIライブラリの仕様を把握でき、一貫性のあるモダンなUIを迅速に作成することができました。

6. 見えてきた課題と今後取り組みたいこと

AI駆動開発を本格的に実践したからこそ、現実的な課題も見えてきました。ここでは、私たちが直面した主な課題と、今後どのような取り組みをしていきたいと考えているかについてお伝えします。

6-1. AIレビューの限界

私たちは当初、コードレビューもAIが行うことで開発がうまく回るのではないかと考えていました。しかし、1ヶ月半試行錯誤した上での正直な感想は、「どれだけpromptを改善しても、レビュアーとしてはまだ物足りない」というものでした。

以下は試したAIコードレビューの一例です。

AIツール 手法 結果
Github Copilot PRのレビュアーに入れることによる自動レビュー typoの修正や部分ごとの実装の修正提案はあるが、PRコード全体でのレビューは難しい。
Devin slackでのコミュニケーションによるレビュー PR全体でのレビューは可能だが、設計思想やドメインを理解した上でのレビューは難しい。
Claude Code Action カスタムプロンプトによるGHAを使用した自動レビュー プロンプトのチューニングも行ったが、Devinとほぼ同じ結果であった。PR全体でのレビューは可能だが、設計思想やドメインを理解した上でのレビューは難しい。

AIはコーディング規約違反や単純なロジックミスといったレビューであれば問題なく対応できます。しかし、私たちがレビューで本当に求めているのはそこだけではありません。

  • この設計は、半年後の機能拡張に耐えられるか?
  • ビジネスのドメインルールを正しくコードに反映できているか?
  • パフォーマンス上の懸念や、よりシンプルな代替案はないか?

こういった背景知識や将来のプロダクト展開までを考慮した「設計の妥当性」に関するレビューは、やはり経験を積んだ人間のエンジニアが行う必要があると感じました。

この経験から、あくまで我々がトライした条件下の中ではありますが、人間によるコードレビューは品質を担保する上で今後も不可欠であると感じています。

6-2. コンテキストを十分に与えられない分野でのコード生成

AIは一般的なWeb開発の知識は豊富ですが、特定のデータベースでの最適化などはそのままでは難しいのではないかと感じました。

特にSpannerのような比較的新しいDBを扱う上で例えば、

  • interleaveで親子関係を設計したテーブルに対する、効率的なJOINクエリ
  • クエリの実行計画を考慮した、パフォーマンスの最適化
  • Spanner特有のトランザクション管理やインデックスのベストプラクティス

といった内容はAIが生成するコードだけでは不十分なケースが多くありました。

6-3. 「AIの実装しやすさ」と「人間のレビューしやすさ」のジレンマ

アーキテクチャの選定においても、課題を感じる部分がありました。例えば今回バックエンドで採用したレイヤードアーキテクチャは、責務が明確に分離されているため、AIに対して「このレイヤーを実装して」と指示を出しやすく、並列でのコード生成も可能という点で「AIフレンドリー」と感じたため採用しました。

しかしその一方でコード量が増加し、人間のレビュー負荷が高まるというデメリットも生じました。例えば一つの簡単な機能追加のために、各レイヤーの複数のファイルにまたがって変更が必要になり、レビュー時に全体像を把握するのが難しくなっていました。

AIの生産性を最大化するアーキテクチャと、人間が保守・レビューしやすいアーキテクチャをどう両立させるかは今後より実践を重ねていきたいと思っています。

6-4. 今後取り組みたいこと

今回AI駆動開発を実践してみて、これからチームとして取り組んでいきたいことがいくつか見えてきました。今後はチームとして特に下記2つを意識してAI駆動開発に取り組んでいきたいと考えています。

1つ目は、開発の専門領域を超えた動きをしていくことです。 フロントエンドとバックエンドそれぞれの領域でAI駆動開発の実践によってAIを利用した開発の型が定まり、AIを活用することで専門でない分野のキャッチアップも容易になってきています。

そのため「フロントエンドだから」、「バックエンドだから」と役割を限定せず、互いの領域をどんどん越境して助け合えるようにしていきたいと考えています。少数チームであってもAIを活用することでお互いの領域の手助けをし合えるチームにしていきたいです。

2つ目は、開発に閉じない動きをしていくことです。 今後はこれまで以上にエンジニアも企画や要件定義の段階からどんどん首を突っ込んでいきたいです。

AIと開発することでこれまでよりも確実に開発スピードは上がってくると思っています。そんな状況の中で、「どう作るか」だけでなく、「なぜ作るのか」、「何を作るのか」から一緒に考えていけるようにしていきたいです。

7. おわりに

それぞれの開発フェーズでAIと人間の役割をまとめてみました。

開発フェーズ AIの役割 人間の役割
設計・計画 アイデアの壁打ち、たたき台作成 要件定義、アーキテクチャの最終決定
実装 定型コードの生成、並列実装、翻訳 複雑なビジネスロジックの実装、設計判断
テスト 単体テストコードの生成、E2Eテストの実行 テストケースの設計、仕様に基づいた検証
レビュー Lint、コーディング規約の自動チェック 設計の妥当性評価、拡張性・保守性のレビュー
ドキュメント テンプレートからの生成、議事録の要約 仕様の明確化、全体像の記述

今回は私たちなりのAI駆動開発という取り組みを紹介させていただきました。この記事が同じようにAIと共に新しい開発の型を模索している皆さんの何かしらのヒントになれば幸いです。

Gaudiyでは私たちと新しいことに積極的に向き合って新しい型を作っていく仲間を積極的に募集しています!この記事を読んで、Gaudiyの開発スタイルに少しでも興味を持ってくださった方、ぜひ一度カジュアルにお話ししませんか?

ご応募、お待ちしています!

site.gaudiy.com

ECチームでの分散トランザクションの課題とOutboxV2の利用について

はじめまして。ファンと共に時代を進める、Web3スタートアップのGaudiyでエンジニアをしている miyamoto です。

Gaudiyでは、ファンコミュニティサービスの「Gaudiy Fanlink」にマイクロサービスアーキテクチャを採用しています。

マイクロサービスアーキテクチャは、サービスの独立性を高め、チームが自律的に開発を進められる強力なパラダイムです。しかし、その分散的な性質から、サービス間のデータ一貫性を保つことには特有の難しさが伴います。

この分散トランザクションにおいて、Gaudiyでは主に Outbox(Transactional Outbox Pattern) を利用していますが、ECチームである課題にぶつかり、その解決策としてGaudiyが独自に開発・改良したOutboxV2という新しい仕組みを導入しました。

今回のブログでは、Outboxの仕組みから、ECチームで生じた問題とその解決策としてのOutboxV2の導入までを詳しくご紹介していきます。マイクロサービスアーキテクチャの一事例として、ご参考になれば嬉しいです。

1. Outboxの仕組み

Outboxは、マイクロサービスアーキテクチャで、データベースの更新とメッセージの送信を確実に両立させるためのデザインです。これにより、サービス間のデータ一貫性を保ちます。

(出典元: microservices.io)

1-1.アトミックな書き込み

サービスは、自身のビジネスデータ(例: Orderテーブル)の更新と、送信したいメッセージの内容を「Outboxテーブル」に書き込む処理を、単一のデータベーストランザクション内で行います。 これにより、データベースの更新と「メッセージを送る」という記録が必ずセットで行われ、どちらか一方が失敗することがなくなります。

1-2.メッセージリレー

メッセージリレーと呼ばれる独立したプロセスが、Outboxテーブルを監視します。Gaudiyでは、テーブルの監視をpollingで実現していました。未送信のメッセージを見つけると、それをメッセージブローカーへ送信し、送信が完了したらテーブルからそのレコードを削除するか、処理済みとしてマークします。

1-3. Outboxテーブル:メッセージの状態管理

// OutboxEventテーブル
CREATE TABLE OutboxEvent (
  OutboxEventId STRING(64) NOT NULL,          -- 一意なイベントID
  Topic STRING(255) NOT NULL,                 -- Pub/Subトピック名
  Data outbox.task.Event NOT NULL,            -- Protocol Buffersのメッセージデータ
  CreatedAt TIMESTAMP NOT NULL OPTIONS (      -- レコード作成時刻
    allow_commit_timestamp = true
  ),
  SentAt TIMESTAMP OPTIONS (                  -- メッセージ送信完了時刻(NULLなら未送信)
    allow_commit_timestamp = true
  ),
  -- FallbackPoller用のシャードID(OutboxEventIdのハッシュ値を8で割った余り)
  -- FallbackPollerについては後述する
  ShardId INT64 NOT NULL AS (MOD(ABS(FARM_FINGERPRINT(OutboxEventId)), 8)) STORED,
) PRIMARY KEY(OutboxEventId);

-- インデックス:未送信メッセージの効率的な検索用
CREATE INDEX OutboxEventBySentAt ON OutboxEvent(OutboxEventId, SentAt);
CREATE INDEX OutboxEventByShardIdSentAtCreatedAt ON OutboxEvent(ShardId, SentAt, CreatedAt);

2. ECサイトの裏側で起きていた「コインが買えていない?」問題

ECチームでは、決済システムや、デジタルグッズ・通常の商品を販売するストア機能の開発を担当しています。 このストアでは、クレジットカードなどの現金決済のほかに、独自の「コイン」でも支払いが可能です。このコインは現金で購入できます。

2-1. どんな問題が起きていたか?

ユーザーがコインを購入する際、複数のマイクロサービスをまたいで行われるため、データの整合性を保つためにOutboxを利用していました。

しかし、この方法では、データベース(Spanner)に「処理は完了したか?」と確認(polling)するために最大で5秒以上かかってしまうことがありました。5sというのは、Gaudiyでpollingするためにデータベース負荷的に許容できる最低間隔が5sだからです。

このタイムラグのせいで、ユーザーがコインを購入してもすぐには反映されず、「購入に失敗したのかな?」と勘違いして、もう一度コイン購入を試みるという問題が発生していました。

2-2. どのように解決したか?

この問題を解決するために、Gaudiyが独自に開発・改良した「OutboxV2」という新しい仕組みを導入しました。

このOutboxV2によって、コイン購入後のタイムラグを短縮し、ユーザーが「コインが買えていない」と誤解することなく、スムーズに買い物ができるようになりました。

次から、OutboxV2について詳しく説明していきます。

3. OutboxV2とは

(便宜上、この後は元々使われていたOutboxをOutboxV1と呼びます)

OutboxV2で最も重要な技術的要素は、Cloud SpannerのChange Streamsを活用したリアルタイムなデータ変更検知です。従来のpolling方式と比べて、劇的な処理時間短縮を実現しています。

3-1. Change Streamsとは

Cloud SpannerのChange Streamsは、データベース内のデータ変更(INSERT、UPDATE、DELETE)をリアルタイムに近い形で捉え、ストリームとして外部に提供する機能です。

OutboxV2では、OutboxEventテーブルへの新しいレコード挿入を即座に検知し、メッセージ処理を開始します。

ref: https://cloud.google.com/spanner/docs/change-streams?hl=ja

// Change Streamsの設定(SQL)
CREATE CHANGE STREAM OutboxEventStream FOR OutboxEvent 
OPTIONS ( 
  value_capture_type = 'OLD_AND_NEW_VALUES',  // 変更前後の値を取得
  retention_period = '7d',                    // 7日間のデータ保持
  exclude_update = true,                      // UPDATE操作は除外
  exclude_delete = true                       // DELETE操作は除外
);
  • exclude_update = true:OutboxEventのSentAt更新は検知する必要がないため除外
  • exclude_delete = true:OutboxEventレコードは通常削除せず、履歴として保持

Cloud Spannerのchange streamsをOutboxテーブルに設定し、データの変更をリアルタイムに近い形でメッセージリレーがsubscribeし、メッセージブローカーへメッセージをpublishします。こうすることで、OutboxV1では実現できなかった高速な分散トランザクションを実現することができました。

3-2. リアルタイム検知の仕組み

OutboxEventテーブルにレコードが挿入されると、Change Streamsが即座にイベントを発火します。このイベントをChangeStreamsWatcherが受信し、処理を開始します。

(以下のtype ChangeRecordについてはこちらを参照してください)

type ChangeStreamsWatcher struct {
    cs       *changestreams.ChangeStreams  // Change Streamsクライアント
    client   database.SpannerClient        // Spannerクライアント
    kch      channel.Channel               // イベントID送信用チャネル
    done     chan struct{}                 // 終了シグナル
}

type Result struct {
    PartitionToken string          `spanner:"-" json:"partition_token"`
    ChangeRecords  []*ChangeRecord `spanner:"ChangeRecord" json:"change_record"`
}

// Change Streamsのイベントを監視・処理
func (cs *ChangeStreamsWatcher) Watch(ctx context.Context, result *Result) error {
    select {
    case <-cs.done:
        cs.Stop()
        return nil
    default:
        // 各変更レコードを処理
        for _, cr := range result.ChangeRecords {
            if cr.HasDataChangeRecords() {  // データ変更イベントの場合
                for _, dcr := range cr.DataChangeRecords {
                    // 重要:分散ロックによる重複処理防止
                    locked, err := cs.Lock(ctx, dcr.ServerTransactionID)
                    if err != nil {
                        return err
                    }
                    if locked {
                        // 既に他のインスタンスが処理中の場合はスキップ
                        return nil
                    }

                    // 変更されたOutboxEventのIDを取得
                    for _, key := range dcr.Mods.GetValues("OutboxEventId") {
                        if eventID, ok := key.(string); ok {
                            // 処理用チャネルにイベントIDを送信(ここがリアルタイム!)
                            cs.kch.Input(eventID)
                        }
                    }
                }
            }
        }
    }
    return nil
}

3-3. 分散ロック機構による重複処理防止

マイクロサービス環境では、同じOutboxEventが複数のインスタンスで同時に処理される可能性があります。インスタンスごとに未処理のOutboxイベントを処理するsubscribeが実行されているので、同じレコードを処理しようとしてトランザクションの解放待ちにならないようにServerTransactionIDを使った分散ロック機構を実装しています。

SpannerのServerTransactionIDは、Cloud Spannerが生成するデータベースレベルで一意なトランザクション識別子です。トランザクションを一意に識別するため、これをロックキーとして使用することで確実に重複処理を防げます。

<OutboxLockテーブル:分散ロックを管理>

CREATE TABLE OutboxLock (
  TransactionId STRING(28) NOT NULL,          -- SpannerのServerTransactionID
  CreatedAt TIMESTAMP NOT NULL OPTIONS (      -- ロック取得時刻
    allow_commit_timestamp = true
  ),
) PRIMARY KEY(TransactionId),
-- 1日経過したロックレコードは自動削除(メモリリーク防止)
ROW DELETION POLICY (OLDER_THAN(CreatedAt, INTERVAL 1 DAY));
  • TransactionId(ServerTransactionID)をプライマリキーとすることで、同じトランザクションによる重複ロック取得を防止
  • ROW DELETION POLICYにより古いロックレコードが自動削除され、テーブルサイズが無制限に増加することを防ぐ
  • ロック期間は通常数秒〜数分程度だが、障害時の安全マージンとして1日間保持
// 分散ロック機構 - OutboxLockテーブルを使用
func (cs *ChangeStreamsWatcher) Lock(ctx context.Context, serverTxID string) (bool, error) {
    // OutboxLockテーブルにTransactionIDを挿入してロックを取得
    ms := []*spanner.Mutation{
        spanner.Insert("OutboxLock",
            []string{"TransactionId", "CreatedAt"},
            []any{serverTxID, spanner.CommitTimestamp},
        ),
    }
    
    _, err := cs.client.Apply(ctx, ms)
    if err != nil {
        // AlreadyExistsエラー = 既に他のインスタンスがロック取得済み
        if grpcerrors.IsAlreadyExists(err) {
            return true, nil  // ロック済みを示すフラグを返す
        }
        return false, fmt.Errorf("failed to acquire lock: %w", err)
    }
    
    // ロック取得成功
    return false, nil
}

3-4. 全体シーケンス

4. OutboxV2の導入結果と課題への対処

OutboxV1では、OutboxテーブルにレコードがINSERTされてからメッセージブローカーにメッセージをpublishするまでに、最長5000ms程度かかっていました。今回、OutboxV2を導入したことで、レコードがINSERTされてからメッセージがpublishするまでに200ms程度で済むようになり、分散トランザクションの大幅な時間短縮に繋げることができました。

一方で、OutboxV2は強力ですが、万能ではありません。Change Streamsはリアルタイム性に優れる一方で、イベントの伝達が保証されない(At-Most-Once)という特性があります。ネットワークの問題やごく稀なクラウド側の障害で、変更イベントをメッセージブローカーへ送信しそこなう可能性がゼロではありません。これにより、「Outboxテーブルへの書き込み」と「メッセージブローカーへのpublish」の間の整合性に課題が生じます。

そこで我々は、この課題を克服するために、Change Streamsのイベントを取りこぼした場合でも最終的な整合性を担保するリカバリー機構として、OutboxV1で利用していた定期的にOutboxテーブルを監視するポーリング処理(Fallback Poller)も併用しています。このハイブリッドなアプローチにより、リアルタイム性を享受しつつ、メッセージ伝達の最終的な到達保証(At-Least-Once)も実現しています。

5. まとめ

Gaudiyで新しく開発したマイクロサービスアーキテクチャについて紹介させていただきました。

今回は「コインの配布ができていない」という誤解をユーザーに与えないように、リアルタイムに近い処理ができるOutboxV2を採用しました。OutboxV1に比べて分散トランザクションが高速になる一方で、アトミック性を確保できないという課題がありましたが、リカバリー機構を導入することでアトミック性を担保するようにしました。分散トランザクションを扱う上で、どうしても高速に処理したいというシーンはさまざまなプロダクトで発生するかと思うので、ぜひ参考にしてみてください。

GaudiyのECチームではこれから、決済基盤の追加開発や海外展開へ向けてのストア開発などやることが山積みの状態です。特に、巨大なIPを抱えての海外展開はこれからという段階で、ハードな開発がまさに始まろうとしています。

決済、ストア、物流など考えることが山積みでたくさんの困難に挑戦できる機会があるので、興味がある方はぜひお話しましょう!

site.gaudiy.com

site.gaudiy.com

Appendix

Outboxについて microservices.io

以下のメルカリさんの記事がわかりやすいです。 engineering.mercari.com

ファン国家のために"人類の失敗"を代替する。Gaudiyがデータサイエンスと機械学習をやっていく話

はじめまして。ファンと共に時代を進める、Web3スタートアップ Gaudiy の tatsuki (@tatsukiiine)と申します。

Gaudiyは、誰もが好きや夢中で生きられる社会「ファン国家」の創造をビジョンに掲げ、その実現をエンタメ領域から目指しています。

note.com

この「ファン国家」は、単一の巨大なコミュニティというよりは、多様な価値観や熱量をもっている無数の「マイクロコミュニティ」が相互に繋がり合うネットワークのようなものです。それは、情熱、創造性、ビジネス、テクノロジー、そして多くの人々の相互作用が渦巻く、複雑でダイナミックなエコシステムになるはずです。

しかし、このようなマイクロコミュニティが中心となるエコシステムの運営は、よく行われるプロダクト運営の手法では太刀打ちできないと考えています。

そこで本記事では、そんな課題に対する解決手段として、社会シミュレーションの技術と、それを支えるデータサイエンス・機械学習について書いていこうと思います。

意図と結果はよくずれる

まず前提として、社会をある結果に導くべく行動し、想定しない結果をもたらしたといった事象は、歴史上無数に存在します。

たとえば、コブラ効果はかなり擦られている話ではありますが、これは史実ではなく、経済学を説明するためにホルスト・ジーベルトにより導入された寓話、というのが真実に近いそうです。もちろんコブラ効果で括られる実話は数多く存在します。

インドを統治していた英国のインド総督府は、デリーにおける多くの毒ヘビ特にコブラの害を脅威と看做し[3]、コブラの死骸を役所に持ち込めば報酬を与えることにした。

最初のうちは報酬目当てに多くの蛇が捕獲されたので巧くいくと思われていたが、蛇の死骸を多く持ち込めば収入が多くなるのなら蛇を捕獲するよりは蛇を飼って増やせば良いと目先の利く連中がコブラの飼育を始めてしまうことになった。

蛇を減らす目的の筈が反って蛇を増やす原因になったことを重く見て、この施策は取り止めになった。

この結果報酬目当てに繁殖していたコブラが野に放たれ、コブラの数は施策が行われる以前よりも増加してしまった。一見正しそうな問題解決策は、状況をさらに悪化させた[2][4]

ja.wikipedia.org

また少しスケールが小さくなりますが、ストライサンド効果も類似の例として挙げられます。 ja.wikipedia.org

僕はこの手のうんちくを収集するのが好きなのですが、どの例をとっても想定通りいかなかった介入者の哀愁が垣間見えておもしろいです。

複雑なエコシステム

このような意図と結果の乖離には様々な要因が絡んでいますが、ここでは2つに着目したいと思います。

ひとつめは、社会が単なる部品の寄せ集めではなく、無数の要素が相互に影響し合い、非線形かつ予測不能な振る舞いを見せる複雑系であるということ。

こうした複雑系において特に注目されるのが、完全な秩序と無秩序なカオスとの狭間、いわゆる「カオスの縁」と呼ばれる状態です。この領域では、系は硬直化することなく、かといって崩壊するほどの不安定さもなく、自己組織化や新たなパターンの創発、そして環境への適応が最も活発に行われるとされています。

このような状態は、創造性や革新性が生まれやすい、社会のあるべき姿であるといえますが、絶妙なパラメータの調整が必要であり、狙って実現することは困難を極めます。

www.kodansha.co.jp

ふたつめに、そのシステムを構成し、ときに介入しようとする我々人間自身も、単純ではない「複雑な合理性」の中で動いているということ。

この点は、ノーベル経済学賞を受賞したハーバート・サイモンが提唱した「限定合理性」の概念によって深く掘り下げられています。人間は完全な情報や無限の計算能力を持つわけではなく、自身の認知能力の限界の中で意思決定を行っており、必ずしも常に「最適な」選択をするわけではない。むしろ、利用可能な情報の中から満足のいく結果をもたらしそうな選択肢を探索し、決定に至るという、より現実的なプロセスを辿ります。

ja.wikipedia.org

さらに、我々の意思決定は、経験則(ヒューリスティクス)に頼る一方で、それはいわゆる「認知バイアス」を生む原因ともなり得ます。たとえば、自分の信念を支持する情報を優先的に集めてしまう確証バイアスや、最初に提示された情報に過度に影響されるアンカリング効果などが知られています。

このように、人間の合理性は、認知的な制約や心理的な傾向によって、多層的に「複雑」なものとなっているのです。

高速なエコシステム

またIPコンテンツのマイクロコミュニティにおいては、システムが変化するその時間的速度についても言及すべきことがあります。それは、IPコンテンツのライフサイクルが早まりつつあるという言説です。

『コンテンツビジネスのデザイン』 https://www.unijapan.org/producer/pdf/producer_304.pdf

以前までは、製作委員会方式やメディアミックス戦略への言及が多かったようですが、近年ではさらに、グローバルプラットフォームによる流行の高速化や推薦アルゴリズムへの適合によるショート化が要因として加わり、そのスピードはますます早くなっています。(動画コンテンツを主題としていますが面白い議論があります)

www.cogitatiopress.com

ここで言いたいことは、「スピードを遅めよう」ということではありません。

クリエイターがしなければならない重要な意思決定が、コンテンツのライフスパンが短縮されたことにより、短いタームで押し寄せてくる状況にある、ということです。

意思決定のためのAgent Based Modeling

この意思決定において、人類は、その質を科学で進化させてきました。基本となるのは、小学校でやった「対照実験」です。

アサガオの鉢を用意して、葉の一部をアルミニウムはくで覆って1日暗所で寝かせる。その後日光に十分当てた後に脱色してヨウ素液につけると、はくで覆っていた場所以外が青紫色になるというアレです。

この「比較して確かめる」というシンプルな知恵は、近代、かのR・A・フィッシャーによって統計的な厳密さを与えられ、RCT(ランダム化比較試験)、つまり現代でよく言うところのA/Bテストへと発展しました。

bookplus.nikkei.com

重要な意思決定で致命的なミスを犯さないためには、A/Bテストが有効です。

名だたるTech企業の成功は、A/Bテストを徹底的に活用し、データに基づいてサービスを改善し続けたことに依る部分も大きいです。ソフトウェアプロダクトの世界では、ある新機能を試したいと思ったら、ユーザーの半分を「アルミはくで覆い」、残り半分に新機能を提供して、どちらが良いかを比較検証することができます。

www.uber.com

しかしながら、IPコンテンツはそれとは異なります。なぜなら、一つ一つの施策は本番であり、短いライフスパンの中で意思決定が連続するからです。

またファンコミュニティは、個々のファンが様々な相互に繋がり合う世界です。そのため、半分のユーザにだけイベントを実施したり、グッズを販売することができず、実験をすることが難しいという特徴があります。

そんなIPコンテンツにも、もしかしたら実験環境が用意できるかもしれません。それは、LLMの進歩によって現実味を帯びてきた「Agent Based Modeling(ABM)」による社会シミュレーションです。

もし、IPエコシステムを構成するファンやクリエイターの振る舞いをリアルに再現できるAgentを作り出し、彼らが相互作用する仮想世界を構築できたなら、 そこは、現実では不可能な「実験」を心ゆくまで行える、理想的なテストベッドになるはずです。

www.nature.com

この「社会シミュレーション」という手段は、Gaudiy AI Teamが以前から追いかけているテーマでもあります。

実は、Generative Agentsの取り組みは、自分が元々やりたいと思っていたことにかなり近いんです。それは、社会のシミュレーションを実現すること。金融のキャリアのなかで「社会科学系の実験のしづらさ」を感じていて、社会の実現するパスがひとつしかないために検証ができないのはバグだと思っていました。 (中略) この問題は、Generative Agentsを使った社会のシミュレーションを実現できれば解決に近づきます。そのために、人間の感情までも再現できるようにしていきたいと考えています。

note.gaudiy.com

この記事を書いている最中にも、いくつかの大学とAmazonの共同研究チームからある論文が出ました。

ユーザペルソナからLLM Agentの母集団を生成し、サンプリングしてControl/Treatmentに割り振り、Amazon.comのサイドバーにデザイン差分を作る。現実でも同じように当ててみて、現実と仮想における反応の傾向を見たら概ね一致していたという内容です。(ただしアウトカムに至るまでのアクション回数などは大きく傾向が違うそうで、これも考察のしがいがあります)

あくまで複雑系ではなく、個人とプロダクトのインタラクションにおける集団としての傾向を一致させられるということですが、部分的にはすでに実現できる可能性が高いといえます。

arxiv.org

人類の偉大な発明やイノベーションは、常にそれ以前の人類の能力の限界を代替してきました。馬車が自動車に、算盤がコンピュータに。

僕たちは、社会シミュレーションによって、IPコンテンツにおける「実験できない」という制約、そしてそこから生まれる「あり得たかもしれない」失敗を代替したいと考えています。

社会シミュレーションとデータサイエンス

僕はGaudiyの「ファン国家」の創造というビジョンのために、Gaudiyにデータサイエンスチームを立ち上げることにしました。

前章までで述べたように、IPコンテンツとファンが織りなす複雑でダイナミックなエコシステムにおいて、より良い意思決定を行うためには、社会シミュレーションが有望なアプローチとなります。

特にファン国家においては、単に経済圏を機能させるだけでなく、個々のファン活動がコミュニティ全体のIPコンテンツ認知や消費へ与える正負の影響――経済学でいうところの「外部性」を捉え、それが当人のインセンティブとして適切に評価される仕組みも重要になります。

この点で、ファンひとりひとりの貢献を計測・可視化しうる社会シミュレーションは、ファン国家の基盤技術とも言えると考えています。

そして、この信頼性の高い社会シミュレーションを実現するためには、データサイエンスや機械学習の力が不可欠となります。

なぜなら、シミュレーションを実現するという目標は、突き詰めれば、従来のデータサイエンスおよび機械学習が長年取り組んできた核心的な技術課題と深く重なり合っているからです。具体的には、以下の3つの技術要素が不可欠だと考えています。

image: Flaticon.com

第一に、「解釈」の技術。シミュレーションの根幹をなすAgentが現実の人間の複雑な振る舞いを、どれだけ忠実に再現できるかが肝です。

そのためには、人間の行動を深く「解釈」する能力が求められます。効果検証や因果推論の技術をベースに、心理学・社会学などを取り込んだMixed Methodsのような形が望ましいと考えています。

正直なところ、因果推論 x Mixed Methodsの全体像はまだ描けていない(業界でのコンセンサスもまだ薄い)ですが、いわゆる「定性リサーチ」の部分については、LLMを使ったプロダクトが台頭してきており、これから熱い領域になると思っています。

wondering.com

第二に、その解釈に基づいて未来を「予測」する技術。個体としてのAgentの行動(e.g. あるファンが次にどんなコンテンツに興味を持つか)から、それらが相互作用した結果として創発するマクロな現象(e.g. 特定のIPに関する話題がコミュニティでどれだけ拡散するか、市場全体のトレンドがどう変化するか)まで、様々なレベルでの予測が求められます。

個体レベルについては、短期的なコンバージョンの推定から始め、サロゲートインデックスなどを経由して長期指標の予測へと移っていくことになると思われます。

developers.cyberagent.co.jp

コミュニティレベルになると、相互作用を加味した予測が必要となるため、集団としての振る舞いから始めるのが良いと思います。グラフニューラルネットワークなどが先駆けとなっていると思われるので、そのキャッチアップに励んでいます。

そして第三に、予測に基づいてコミュニティを健全な成長へ導くための「最適化」の技術。これは画一的なトップダウン制御を意味せず、前述の「カオスの縁」のようなダイナミクスにより、全体のポテンシャルを最大化することを目指していきます。

これは冒頭のGaudiy CEOのnoteでも触れられていますが、人の多様性(参加人数)とコラボレーションの深度を両立した社会に求められる、適応型の行政システムと考えていただくと良いかと思います。

wrl.co.jp

こちらも個体レベルから始め、情報推薦や強化学習の知見を活かしていきます。コミュニティレベルでは、近年最適化自体をモデル学習の対象とする、Learn-to-Optimizeなども来ていそうです。

academic.oup.com

これらの「解釈」「予測」「最適化」という技術的要求に応えるためには、多様な専門性を持つデータサイエンティストと機械学習エンジニアが集い、協働することが不可欠です。要求される技術スタックは広範であり、その多くは日進月歩で進化しています。

正直に言って、たとえば人間の感情や文化といった本質的に捉えにくい要素のモデル化のように、未だ確立された方法論が存在しない領域も多く含まれていると認識しています。我々自身も、常に最新の知見をキャッチアップし、未知の領域を自ら切り拓いていく必要があると思っています。

データサイエンスチームがいまからやること

ここまでの話を読んで、どんな夢想家だ。と思われてしまっているハズなので、最後に直近の話もさせてください。

僕自身は、どちらかというとリアリストです。ファン国家というGaudiyのビジョンを成し遂げるためにも、足元のアウトカムについて重めに考えています。やるべきことは大きく2つあります。

ひとつめは、Gaudiyのプロダクトと組織へのデータサイエンスのインストールです。

Gaudiyのプロダクトは、新機能開発によるシード期から、既存機能の磨き上げとUXの追求というグロース期へと移行すべき段階にきています。効果検証・レコメンデーション・時系列解析など、グロース期に必要なものを、まずはプロダクトと組織にインストールするところから始めたいです。

ふたつめは、既にGaudiyでGenerative AIを中心に活動してきたGaudiy AI Teamとのコラボレーションです。

ABMでの連携は今まで論じてきた通りではありますが、データサイエンスチームが持つ統計解析、情報推薦、予測モデリング、最適化といった強みと、AIチームが持つGenerative AIの技術を組み合わせることで、単独では実現できない大きな価値を生み出せると考えています。

note.com

かくいう僕自身も、PdMとしてAIプロダクトを鋭意開発中ですが、Gen AIの技術だけでプロダクトインすることの難しさを感じています。

特にビッグデータのハンドリングにおいては、従来のML・DSの技術を積極的に活用した方がいいです。例えば、RAGの検索精度がAIプロダクトそのものの性能に大きく影響することは開発者であれば骨身にしみるところですが、検索結果をRe-Rankingする部分に、精度とパフォーマンスの観点で従来のLearning-to-Rankは今再注目を浴びています。

www.elastic.co

情報の解釈に関しても、構造/非構造化データに関わらず、データ量が多い場合には教師なし学習のアプローチを使った圧縮が有効であると思います。

aclanthology.org

長くなってしまいましたが、これが僕たちが描く構想とその現在地になります。

人類の未来を、データサイエンスと機械学習、そしてAIの力で明るくしたい。この挑戦に、少しでもワクワクしてくれたなら、ぜひカジュアルに一度お話しさせてください。

Gaudiyには、正社員でも副業でも、どんな形からでもコミットできる企業文化があります。

site.gaudiy.com

special.gaudiy.com

自動化するLLMシステムの品質管理: LLM-as-a-judge の作り方

こんにちは。ファンと共に時代を進める、Web3スタートアップ Gaudiy の seya (@sekikazu01)と申します。

LLM-as-a-Judge、名前の通り何かしらの評価にLLMを使う手法です。 LLMは非構造化データでもうまいこと解釈してくれる優れものなため、通常のプログラミングでは判断することが難しいことでも、それらしき判断やスコアをつけてくれます。

本記事ではそんなLLM-as-a-Judgeを、LLMを使ったシステムの振る舞いの改善の確認 and デグレ検知目的で作っていく際の、私なりの流れやTipsなどを紹介していきます。

なぜLLMでJudgeしたいのか?

はじめに「なぜLLMでの評価がしたいのか」、その目的を整理したいと思います。

まず大上段の目的として、LLMシステムが改善しているか、または劣化(デグレ)してないかを検知したいというのがあります。

LLMを使った機能は様々な変化によって改善・デグレが起きます。

  • プロンプトを変えたり
  • インプットの仕方を変えたり
  • LLM APIリクエストのパラメータを変えたり
  • モデルを変えたり
  • データが変わったり

実に様々な要素によって変化し、作っていく時には色んな組み合わせを試したいのですが、そんな折に都度人間が目検査で改善したか、もしくは劣化したかを確認するのは中々に手間です。 なので、さながらプログラミングしている時の単体テストを作るような感覚で、自動で検査して判断できる仕組みがほしくなります。

これが大枠として達成したい目的です。

※ LLM-as-a-Judgeはプロダクトの中の一機能として使われることもあると思いますが、本記事では上記のような内部品質改善のみをスコープとして考えます。

次に、手段の話として、評価を自動で実行していくにあたってLLMは常に最良のツールではないのですが、評価観点によっては通常のプログラミングでは判定できない or 難しいため、そこにLLM-as-a-Judgeを導入したくなります。

LLMをプロダクトに組み込む時にその振る舞いを評価する時の基準は、通常のシステム開発でのテストと比較すると大分ファジーなものになりがちです。

例えば、弊社では特定の人物の模倣をするAIを開発するにあたって、評価項目としては

  • 共感する or 励ます or 話を聞く姿勢を示す
  • タメ口で返答すること・敬語ではないこと

などのちょっと純粋なプログラミングでは判定し難い項目があったりします。このような観点を自動で評価したい場合にはLLMの出番になります。

ただ、先ほど「LLMは常に最良のツールではない」と申し上げた通り、通常のプログラミングで判定できる評価観点であれば、基本そちらを採用した方がいいです。なぜならLLM-as-a-Judgeは遅い&お金がかかるからです。💸(最近のGPT-4o miniやGemini Flashはそうでもなくなってきてますが)

例えばユーザのクエリと同じ言語、英語なら英語、日本語なら日本語で応答しているかを確認したかったとします。LLMでも判定はできるでしょうが、それとは別に言語を判定するライブラリがあったりします。

github.com

なのでこのような評価観点に関しては、次のような関数でスコアを出しています。

from fast_langdetect import detect
from langsmith import EvaluationResult
from langsmith.schemas import Example, Run

def answer_with_language_same_as_query(run: Run, example: Example) -> EvaluationResult:
    """Check if the agent reply is in query language."""
    query = example.inputs["query"].replace("\n", "")
    query_language = detect(query)

    agent_reply = get_output(run, "output").replace("\n", "")
    agent_reply_language = detect(agent_reply)

    score = query_language.get("lang") == agent_reply_language.get("lang")
    return EvaluationResult(key="ANSWER_WITH_SAME_LANGUAGE_AS_QUERY", score=score)

また、LLMもプログラミングも評価するのに適切でない評価観点も色々あります。分かりやすいのが”会話の面白さ”のような人の主観に基づくものや、医療や法律相談などの専門的な実地経験が必要なものなどです。

こういった観点を評価させると、それらしき理由とスコアはつけてくれると思うのですが、人間のリアルな評価との一致度はそこまで上がらない可能性が相対的に高いです。

これから「どうLLM-as-a-Judgeをどうやって作っていくか」の解説をしますが、これを作るのにも「サクッと終わる」程度ではない時間がかかります。 なので実際に作るにあたっては、まず「これの評価は本当にLLMが最適なのか?」を問うことが重要です。

それでは、前段としての目的感について擦り合わせたところで次に作り方を見ていきましょう!

LLM-as-a-Judgeの作り方: アノテーションより始めよ

それでは実際にLLM-as-a-Judgeを作ることになった場合、まずは元となる評価基準を考える…ことをしたくなりますが、そうではなく地道にデータを作ってアノテーションをすることから始めるのが良いと考えています。

なぜなら、どちらにしろ後々の評価に対してデータセットは必要、且つ評価基準は実際に評価してみないと見えてこない、というのが往々にしてあるからです。 もちろんそもそも満たしたい機能要件や仮説があるはずなので、ある程度ラフな言語化はしておいた方がいいですが、ここではかっちり決め過ぎないで実際に評価をしてみるのが重要だと考えています。

ざっくりLLM-as-a-Judgeを含めた全体像を描くと、次のようなものが最終的には出来上がります。(さすがに評価の評価の評価は不毛過ぎるのでしません)

LLMシステムの評価全体図

という訳で、まずは既に作った評価したい対象のLLMシステムを回す用のデータセットを作って、実際に回して、その結果に対してアノテーションをしています。

弊社ではLangSmithを評価・実験管理のプラットフォームとして活用しているので、こちらに最終的にデータを入れて、SDKの評価関数を使って行うようにしています。こちらは評価でスコアをつけている場合、時系列での変化が見られて便利です。

LangSmith評価UI

ちなみにこのLangSmithでの評価実行を行うためのライブラリを、弊社がOSSとして出していたりするので良ければご活用ください。

github.com

アノテーションをしていくうちに評価観点が自分の中で言語化されていくと思いますし、また、この時の評価は後々評価の評価に使えたりします。

ドメインエキスパートにアノテーションを依頼する

というとりあえず評価をしてみよう!という話をしたところで、勢いを削ぐようですが、本来的にはより精度高く評価できる人、作りたいプロダクトのドメインエキスパートにお願いするのが重要です。(本当の最初のPoCとかは速度重視で開発チームの誰かがやるで良いと思いますが)

私個人の失敗談として、最近作っている社内向けエージェントの回答の評価のアノテーションをまず自分でしました。しかし、私の評価は大分ゆるく、その後そのエージェントが前提とすべきドメイン知識を持った社内の人(ドメインエキスパート)と擦り合わせたところ、私がOKと判定したものの内多くの項目が誤りと判定されました。

ついさっき貼ったLangSmithの参考スクショをしっかり見て「スコア下がってるやん!」とツッコまれた方もいらっしゃるかもしれませんが、これはより厳し目の評価に修正したからです。

LangSmith評価UI

私のアノテーションを前提に作ったLLM-as-a-Judgeの場合、不当にスコアが高くなり過ぎてあまり問題を検出できずに役立たずとなっていた、もしくは不正確な安心感を与えてむしろ害になっていたかもしれません。

この評価基準やそこから出るスコアリングは、どんなLLMプロダクトに育てていくかの指針となるものです。なので存外重要な意思決定だと私は考えています。

「あくまで参考程度」ぐらいのものしか作らないのであれば、そこまで工数削減や安心感を持った修正を加えていける体験には繋がらないので、しっかり高品質なアノテーションができる人材を探し、協力してもらって改善していくのが重要です。

評価基準を早期に作り込み過ぎない

冒頭の目的感について話していたところで”単体テストのような感覚”と書いていたのが大事なのですが、今回のLLM-as-a-Judgeでしている評価というのは、あくまで「規定した中でのLLMシステムが期待通り・以上に動いているか」を確認するためのものです。

つまり、仮に評価自体の精度がめちゃくちゃ高く、それに沿ってLLMシステムのスコアも高くできたとして、実際に使われるような文脈のデータが評価時には用意できておらず、ユーザにはウケが悪かったり、プロダクトの成長には何も寄与しないことだってあります。

そういった中でそもそもその機能が必要なくなったり、または別の評価観点が重要で、今まで社内でコツコツ作ったデータが無価値と帰すような気づきを得られることもあります。なので早期に評価基準を「これだ!」と決めてこの自動評価の仕組みに投資し過ぎるのは無駄な時間の使い方になりかねませんし、そもそも実際のユーザに使ってもらわない限り評価データセット自体の品質も高まりません。

なのでここはブレないだろう・守らなきゃダメだろうという品質基準から始めて、ユーザテストや実際に世に出してみてからの使われ具合などから徐々に評価項目・基準をチューニングしていくのが良いのだろうと思います。

昔別の記事で書いたことを引っ張ってくるのですが、狩野モデルで定義されているような区分を登っていく形で考えるとイメージしやすいです。

  • 当たり前品質(いわゆるガードレールで守ような品質)から始め
  • 一元的品質
  • 次に魅力的品質

と登っていくイメージです。

狩野モデル

引用: 狩野モデルから探る品質のあり方とは

zenn.dev

また、こういった実際の使われ方などを見て漸進的に改善していくプロセスとして、EvalGenというフレームワークが提唱されています。本章で解説しているような自動評価の作成を更に自動化させるような内容なのですが、こういったパイプラインを組んでいくのも長期的に響いてくるのではないかと思います。

arxiv.org

個人的には評価がLLMプロダクトを作っていく上で最重要だとは考えているのですが、あまり原理主義的にならずに状況に応じて投資具合を見極めていくことが大事なのだと思います。

評価の評価をする

最後に評価プロンプトの評価用データセットを作って精度確認をしていくので、その流れをご紹介します。

評価プロンプトを作る

まずはそもそも評価する対象の評価プロンプトが必要です。作りましょう。 具体的なチューニングはちゃんと評価データセットを作ってから行うので、最初は目でチェックした時に変すぎなければ良いです。

一例を載せますと、簡素な例ですが次のように書いています。

ポイントとしては

  • 理由を返させている - 後から評価の評価をする時に判断しやすい
  • temperatureは0 - 違う実験間で評価が変わられると何が原因でスコアが上下したのか追いづらくなる

また、評価プロンプトにおいても当然Few-shotsやCoTなどのテクニックは有効ですが、評価データセットと同じ例をFew-shotsに含めないようには注意しましょう。スコアが高くなりやすくなって問題が見えづらくなります。

class EvalResult(BaseModel):  # noqa: D101
    score: str = Field(description="score (0 or 1)")
    score_reason_jp: str = Field(description="reason for the score in japanese")
    score_reason_en: str = Field(description="reason for the score in english")
    
def judge_correctness_with_llm(query: str, agent_reply: str, ground_truth_agent_reply: str) -> EvalResult:  # noqa: D103
    llm_as_judge = ChatOpenAI(model="gpt-4o-mini", temperature=0).with_structured_output(EvalResult, method="json_mode")
    llm_judge_result: EvalResult = llm_as_judge.invoke(
        f"""Your task is to give a score(0 or 1) for the below Agent reply based on whether the given agent reply has same information as ground truth agent reply.

Query:
{query}
Ground truth Agent reply:
{ground_truth_agent_reply}
Agent reply:
{agent_reply}

# Criteria
- If the information in the agent reply has nuance that's opposite to ground truth agent reply, give score 0, else give score 1.
- If the agent reply is missing some information from the ground truth agent reply, but the rest of the information is correct, give score 1.
- not all the information have to match exactly.

# Output format
You must respond in json format with the following keys:
- score_reason_jp: reason for the score in japanese
- score_reason_en: reason for the score in english
- score: score (0 or 1)"""

    return llm_judge_result

ちなみに「どうスコアをつけるか」にも、上記のような0 or 1で出すのかもう少しグラデーションのあるスコアをつけるかなどの、判断の分かれ目ポイントがあります。

個人的な好みとしては0 or 1の方が分かりやすい・判断がつけやすいので好みなのですが、やはりこれも「評価観点による」ところです。

例えば私の直近の例だと「エージェントの回答がどれくらいGround Truthの回答の内容に含まれるべきことを言っているか」を判定したい場面がありました。0 or 1の評価だと厳し過ぎるかゆるすぎるのどちらかしか選べず、この評価観点においては不適当だったので、”含まれるべき項目”のリストを生成し、その内何%が含まれているかを判定するグラデーションのある方法に変えました。

このようにどんな風に評価していくか、スコアをつけるかは評価観点によって最適な選択が変わってきます。

また、スコアをつける以外にもペアワイズ(二つの回答を用意してどちらがいいかを選ぶ)であったり、複数のモデルでスコアをつけて平均を取る手法など色々バリエーションもあり、それらについては昔別の記事にまとめたのでご参照ください。

zenn.dev

評価の評価データセットを作る

次に評価用データセットの作り方なのですが、これには先ほどしてきたアノテーションが役に立ちます。

私の最近のワークフローとしては、まずはLangSmithで評価対象のシステムを回し、人間の評価と食い違うものをピックアップして別のデータセットにまとめます。

LangSmithデータセットに追加のUI

このようなデータたちは偽陰性/偽陽性のチェックに最適です。 LLM-as-a-Judgeのチューニングですが、基本的に人間の判断(アノテーション)にアライメントすることをゴールとして行います。

この時アライメントしていく際に参考になるのが人間の判断とズレているもので、「実際は間違っているのに正しいと判定されているもの - 偽陽性」と「実際は正しいのに間違っていると判定されているもの - 偽陰性」を取り除いていくことが主にチューニング時に意識することになります。 ただ、もちろん既に判断が合致しているものもデグレの確認目的のデータとしては有用です。

また、統計学の考え方として次のような指標があるため、カバレッジを見ていく際にはこのような指標を参考に見ていくといいでしょう。

  • 適合率(Prescision): 陽性と判断されたものの内、実際に陽性だった割合
  • 再現率(Recall): 実際に陽性だったものの内、陽性と判断された割合
  • F1スコア: 適合率と再現率の調和平均

note.com

偽陽性/偽陰性解説

ちなみにこれまでの私の感覚だと基本的には偽陽性、つまり「よろしくないのにハイスコア」としてしまわないかを検出する方を重点的にしたくなりがちになります。 いいのにダメと判定されるのは、チェックする時間の無駄が増えたりはするのですが、件数がそこまで多くなければそんなに問題にはなりません。

一方、偽陽性のせいで問題を見過ごしてしまうのは品質低下への影響度が大きくなるので、こちらを確認するためのデータセットを拡充する方が優先度高くなっていきます。

このような観点を考えつつデータを溜めていきます。特に正解はないのですが大体数十件貯まったら初期の確認としては十分なのではないかと思います。

合成データを使った評価データセットの拡充

合成データ、つまりLLMにテスト用のデータセットを拡充してもらうのも安心感を増やす意味では有効です。

何よりこれまでのやり方では最初に作ったデータセットの範囲にインプットの幅が閉じてしまいます。なので似た観点でありつつも、違う内容のインプットでテストの幅を担保することが有用です。

ただ、一方で大事なのは簡単過ぎる例を増やし過ぎないことです。 偽陽性をテストするために、理想とされる答えと真逆のことをテストデータとして用意したとて、「そら間違いと判定するやろ!!」みたいなデータになってしまうとあまりいいテストになりません。むしろそういうデータが増え過ぎて「正解率は90%を超えてるから安心だな」と偽りの安心感を抱かせてしまうかもしれません。

もちろん「さすがにこれくらいは…」と越してほしい難易度を段階的に試すのは有効だと思うので、そういう時はタグなどで難易度をつけて個別に実行できるようにするといいと思います。

参考までに最近合成データを作った例で言うと、次のように微妙に間違ったデータを作るようにGPT-4o に頼んだりしていました。パッと見では分からないがよく読むと間違えてるいい感じの例を作ってくれたりします。

def generate_slightly_wrong_output(reference_answer: str) -> str:
    """Generate a slightly wrong output."""

    llm = ChatOpenAI(model="gpt-4o", temperature=0)
    output = llm.invoke(
        f"""Your task is to generate a wrong answer text based on the reference answer.
this output is going to be used to test evaluator's ability to judge the correctness of the information.

reference answer:
{reference_answer}

background information of the agent reply:
{document}
"""
    )
    return str(output.content)

評価の評価プログラムを書く

めっちゃシンプルにリファレンスと合っているかどうかだけ確認しています。

from langsmith import EvaluationResult
from langsmith.schemas import Example, Run

def check_actual_score_matches_reference_score(run: Run, example: Example) -> EvaluationResult:
    """Evaluate if the output score of the run matches the reference score."""
    actual_score = run.outputs.get("output") if run.outputs else None
    reference_score = example.outputs.get("output") if example.outputs else None

    if actual_score is None or reference_score is None:
        raise ValueError("actual_score or reference_score is None")

    score = int(actual_score) == int(reference_score)

    return EvaluationResult(key="matches_reference", score=score)

チューニング!

ここまで準備できたら後はスコアが高まるまでLLMシステムをいじりまくるのみです。

私の感覚的には、最近のLLMは安くて賢いので、このチューニング自体はそんなに時間がかかる印象はなく、どちらかというと「いかに評価基準をうまいこと言語化するか」の方が肝で時間がかかる気がしています。なのでプロンプトチューニングというよりは基準チューニングみたいな感覚になるかもしれません。

おわりに

以上、LLM-as-a-Judge の作り方をご紹介してきました。

ちょっと手間はかかるのですが、無事デグレを検知してくれたりすると「おお…賢い…」となんか気持ちよくなります。

先日も同僚がAgentic Workflowが含まれたシステムを改善していく際に、様々なモデルの組み合わせや、アーキテクチャーの変更(Agentic Workflow抜いたりとか)でスコアがどう変化するかを試して参考にしてくれていました。正直まだ具体的に見ていくと「ちょっと厳し過ぎるんじゃないかなぁ」とか「こういう評価観点もあるんじゃね?」とかは無限に湧いてくるのですが、一定参考となる指標ができ、安心感を持ってシステムの変更ができるようになるのは、主観でなんとなく判断していた時と比較すると大きな価値だなと感じました。

LLMによる評価を信頼できる形で作るには、多少骨は折れるものの価値はしっかりあると思います、ぜひ活用していきましょう!

最後にリンク貼っておきます。

site.gaudiy.com

site.gaudiy.com

参考文献

eugeneyan.com zenn.dev www.brainpad.co.jp arxiv.org www.sh-reya.com zenn.dev

gql.tada に graphql-code-generator から移行した話

こんにちは。ファンと共に時代を進める、Web3スタートアップのGaudiyでエンジニアをしているkodai(@r34b26)です。

Gaudiyでは、以前からフロントエンド(Next.js)とGateway(Node.js)の通信においてGraphQLを使用しています。

techblog.gaudiy.com

その際に、GraphQLスキーマからコードを自動生成するツールとしてGraphQL-Codegenを活用してきましたが、開発者体験やユーザー体験においていくつかの課題を抱えていたため、今回、gql.tadaに移行しました。

この記事では、課題背景から実際の移行プロセスを紹介してみるので、gql.tadaが気になっている人やGraphQLの運用に課題感のある人の参考になれば嬉しいです。

1. GaudiyとGraphQL

最初に、Gaudiyの技術スタックを簡単に紹介します。

Gaudiyのバックエンドでは、各コミュニティで必要な機能をマイクロサービスとして構成しており、その機能群を、GatewayであるGraphQLサーバーを通すことでフロントエンドの複雑性を回避しています。

導入時の課題背景やGaudiyのプロダクト戦略におけるGraphQLの役割は、以下の記事が詳しいのでここでは割愛します。

techblog.gaudiy.com

2. GraphQL-Codegenにまつわる課題

これまではGraphQL-Codegenを用いて、GraphQLのスキーマファイルからでコード生成し、Query, Fragmentのスキーマ定義を記述してきました。

the-guild.dev

GraphQL-Codegenは、Apolloの公式ドキュメントにも記述されていたり、プラグインのエコシステムも充実していることから、GraphQLを使う上ではもはや最低限整備しなければいけないツールとして一般的になっているのではないでしょうか。

www.apollographql.com

ただ、このツールには、いくつかの課題があります。

まず開発者体験としては、

  • schema記述時に補完やバリデーションが効かず、 npx graphql-codegen を都度実行して検証しなければならない
  • schema記述後に、それを用いて、queryやmutationを記述する前に npx graphql-codegen を実行しなければいけない

などが課題です。もちろん watch mode を利用すれば軽減しますが、体感できるほどのラグがあることと、schema量が大量になってくると生成はされてもVSCode(などのエディタ)が読み込まれるまでに時間がかかることがありました。

またユーザー体験としても、

  • 生成されたdocument nodeがclient bundleに含まれてしまい、エンドユーザーまで配信されてしまうことで、サイトパフォーマンスに悪影響を与えてしまう
  • アプリケーションの成長(schema定義数増加)と比例して線形的に増えるしかない構造にある

といった課題があります。

/**
* Map of all GraphQL operations in the project.
*
* This map has several performance disadvantages:
* 1. It is not tree-shakeable, so it will include all operations in the project.
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel or swc plugin for production.
*/

これらは、生成結果のドキュメントファイルにも明確に記述されています。babelとswcのプラグインが用意されていますが、特にswcプラグインはNext.jsとのversion管理の問題などがあり、ブロッカーが依然として多い状態にあります。

github.com

実際、Gaudiyのwebアプリケーションでは、60kb近くにも膨れていました。

これらの課題を解決するために、今回導入したのが、gql.tadaです。

3. gql.tadaの導入

gql-tada.0no.co

In short, `gql.tada`,

- parses your GraphQL documents in the TypeScript type system
- uses your introspected schema and scalar configuration to derive a schema
- maps your GraphQL queries and fragments with the schema to result and variables types
- creates fragment masks and enforces unwrapping fragments gradually
 `gql.tada`を簡潔に述べると、

 - GraphQLドキュメントをTypeScriptの型systemでパースする
 - イントロスペクトスキーマとスカラーからスキーマを導出する
 - スキーマと共に GrahphQLクエリとフラグメントを戻り値と変数の型にマッピングする
 - フラグメントマスクを生成し、段階的に解消する

ということで、今までdocument nodeを生成してschemaを解釈していた部分まですべて、型systemの中で解決することができます。

つまりは即座にコード反映し、推論とエラー表現によって操作性が高く、clientバンドルにも影響しない環境を実現してくれます。

仕組みとしては TypeScript LSP プラグインとして、GraphQLSPが動いており、これと組み合わせることで TypeScript コンパイラが型チェックを実行できるようになっています。

これが何をしているかというと schema で指定している GraphQL Schema の変更を監視しており、これがスキーマのイントロスペクションとして tadaOutputLocation で指定されたファイルに保存しています。

その後 gql.tada に渡されアンビエント宣言をすることで typescript コンパイラが型チェックを実行できるようになっています。

またスポンサーには、GraphQL-Codegenの作者である The Guild も名を連ねており、urqlの作者がコアメンテナーであることも、将来性の観点から導入を後押しする一材料となりました。

デモを見れば、その快適性は一目瞭然かと思うので、ぜひこちらの動画を見つつ、exampleを動かしてみてください。

gql-tada.0no.co

4. 移行プロセスと注意点

ここからは、実際の移行プロセスについてご紹介します。

現在、client presetからのcodemodは検討されてはいるものの、まだ提供されていない状態にあります。

github.com

ただ、基本的には GraphQL-Codegen の成果物と対応するメソッドは用意されているので、それを書き換えるだけで問題なく動作することと、メリットの大きさからこれを待たずに移行することを決めました。

手順は以下の通りです。ドキュメント通りにセットアップした後、

1:graphql 関数と型のimport先を変更する

// before
import { graphql } from '@/generated/graphql';
import type { ResultOf, VariablesOf } from '@graphql-typed-document-node/core';
    
// after
import { graphql } from '@/tada';
import type { ResultOf, VariablesOf } from 'gql.tada';

2:Fragment, Query, Mutation 定義時のimport元と命名を変更し、使用する子Fragmentをarrayに追加する

// before
import { getFragmentData, graphql, type FragmentType } from '@/generated/graphql';
    
const ParentFragment = graphql(`
 fragment ParentFragment {
  ...ChildFragment
 }
`)
    
type Props = {
 data: FragmentType<typeof ParentFragment>;
}
    
const Component = ({ data }) => {
 const fragment = getFragmentData(ParentFragment, data);
 ...
}
    
// after
import { graphql } from '@/tada';
import { readFragment, type FragmentOf } from 'gql.tada';
import { ChildFragment } from './ChildComponent';
    
const ParentFragment = graphql(`
 fragment ParentFragment {
  ...ChildFragment
 }
`, [ChildFragment])
    
type Props = {
 data: FragmentOf<typeof ParentFragment>;
}
    
const Component = ({ data }) => {
 const fragment = readFragment(ParentFragment, data);
 ...
}

3:テストデータ作成時のimport元と命名、引数の順番を変更する

// before
import { makeFragmentData } from '@/generated/graphql';
       
const data = makeFragmentData({
 __typename: 'Test',
 id: '1',
 ...
 },
 TestFragment
)
    
// after
import { maskFragments } from 'gql.tada';
       
const data = maskFragments([TestFragment], {
 // __typename: 'Test',
 id: '1',
 ...
 }
)

これでできます。我々の場合は jscodeshift を用いて、自分たちのケースに沿って8割程の移行をしてくれるscriptを用意した上で、機能区分ごとに移行しました。

また移行に際しての注意点として、いくつかTipsをご紹介します。

  • scalar or enum の取り出し
 type Status = ReturnType<typeof graphql.scalar<'Status'>>;
  • maskFragments のpropertyは完全一致させる
export const ParentFragment = graphql(
 /* GraphQL */ `
 fragment ParentFragment on Test {
  id
  title
  items {
   id
   ...ChildFragment
  }
 }
 `,
[ChildFragment]
);

このFragmentをmockする場合、以下のように同じFragment構造で定義します。

maskFragments([ParentFragment], {
 id: 'id1',
 title: 'タイトル',
 items: [{
  id: 'id1',
  ...maskFragments([ChildFragment], {
  id: 'id1',
  title: 'タイトル',
  // ...
  }),
 }],
})

一致していない時に出る型エラーがわかりにくく、かつ codemod作りきるのが難しかったため、この修正に一番時間がかかりました。

  • Fragmentを複数同じ階層に渡す際には、arrayに追加する
fragment CFragment on CSection {
 id
 ...AFragment 
 ...BFragment
}

上述のような Fragment定義のときには、

// NO
maskFragments([CFragment], {
 id,
 ...maskFragments([AFragment], {
  id,
  // 省略
 }),
...maskFragments([BFragment], {
  id,
  // 省略
 }),
}),
    
// OK
maskFragments([CFragment], {
 id,
 ...maskFragments([AFragment, BFragment], {
  id,
  // 省略
 }),
}),

このようにする必要があります。

些細ではありますが、ドキュメントに例として明示されていないので、嵌る人もいたポイントです。

5. まとめ

GraphQL-Codegenからgql.tadaに移行した結果、

  • schema記述時の補完と即時の型反映という開発体験の向上
  • 100KB近いバンドルサイズ削減から得られるユーザー体験の向上

という、まさに一石二鳥の効果を得ることができました。

コードベースが多ければ、turboを設定するなどでコンパイル速度とメモリの問題も抑えられますし、大きなダウンサイドは今のところ感じていません。

gql-tada.0no.co

我々のユースケースでは、一点だけ型の不足を感じましたが、逆に言うとそれだけでした。

github.com

TypeScript自体のperformanceが、このツールが向かう方向性の明確なリスクであることは間違いないですが、Codegen以外の選択肢の一つとして検討してもよいのでは? と個人的には思います。

もしGaudiyのGraphQL周りやwebフロントエンドについて興味ある人いたら、ラフにお話しさせてください!

site.gaudiy.com

一緒に開発する仲間も募集してます

site.gaudiy.com