はじめに
最近、AIエージェントを使ったWebアプリ開発の進め方をいろいろ試しています。
この記事は、製品開発の正式なプロセスを変えた話ではありません。個人的にいくつか実験してみた結果、「今のところ、この形にすると壊れにくそうだ」と感じていることをまとめたものです。
最初は、AIエージェントを使うと「コードを書くのが速くなる」という話だと思っていました。
実際、実装フェーズはかなり速くなります。短時間でかなりの量のコードがPRとして出てきます。
ただ、しばらく試してみると、問題は「AIがコードを書けるか」ではなくなってきました。
むしろ重要なのは、AIが書いたコードをどう受け入れるかです。
- 何を実装してよいか
- 何を実装してはいけないか
- どのコンポーネントが何に責任を持つか
- どういう状態になったら完了とみなすか
- 誰がレビューし、誰が受け入れを判断するか
このあたりを決めないまま実装だけ速くすると、コードベースは速く壊れます。
この記事では、この進め方を仮に 受け入れ駆動開発 と呼びます。
TDDのように「テストからコードを書く」というよりは、AIエージェントに任せるために、受け入れケース・責務境界・仕様の検証方法を先に定義しておく開発スタイルです。
従来の開発プロセスをそのままAIに渡すと壊れる
最初は、従来の開発プロセスにAIエージェントを足せばよいと思っていました。
チケットを切り、AIに実装させ、人間がレビューし、問題があれば直す、という流れです。
しかし、いくつか試すうちに、この前提はそのままでは壊れることがわかってきました。
全コードレビューは破綻しやすい
最初は、AIが書いたコードは人間が全部読むべきだと思っていました。実際、最初は全部読んでいました。
しかし、AIエージェントがかなりの速度でPRを作るようになると、人間がすべてのコードを同じ密度で読むのは現実的ではありません。
そこで、レビューの考え方を変えました。
コードレビューそのものは、ガイドラインに基づいて独立したReviewerエージェントに任せます。人間は、時々サンプルされたコードを読みます。
そして、問題があれば個別のコードを直すというより、ガイドラインを直します。
コードを全部読む
↓
個別に指摘する
ではなく
サンプルを見る
↓
問題のパターンを見つける
↓
ガイドラインを直す
↓
次からReviewerエージェントに検出させる
これはコードレビューというより、品質管理に近いです。
全数検査ではなく、工程を監査する感覚です。
ユーザーストーリー単位のタスクは並列化に向かなかった
人間の開発では、ユーザーストーリー単位でタスクを切るのは自然です。
たとえば「ユーザーが注文できるようにする」というタスクを作るとします。
このタスクは、画面、画面向けAPI、注文サービス、在庫サービス、決済サービスなど、複数のコンポーネントにまたがります。
人間なら、全体の責務境界を意識しながら進められます。しかし、AIエージェントにこれを任せると、1つのエージェントが複数コンポーネントを触り始めます。
その結果、責務境界が崩れます。
ユーザーストーリー単位のタスク
↓
複数コンポーネントにまたがる
↓
1つのエージェントがいろいろ触る
↓
便利な場所に便利な処理が置かれる
↓
責務境界が崩れる
AIエージェントは、責務分割があまり得意ではありません。放っておくと、動く場所に処理を置きます。
そこで、タスクはコンポーネントに閉じるようにしました。
「注文機能を作る」ではなく、「Order APIコンポーネントの受け入れケースを満たす」のように切るイメージです。
記録を残しすぎると、リポジトリがエコーチェンバーになる
最初は、なるべくすべての記録をリポジトリに残そうとしていました。
作業記録、判断の履歴、handover、エージェントへの指示、過去の計画などを残しておくと、AIがそれを読んでうまく引き継げるのではないかと考えていました。
しかし、これはあまりうまくいきませんでした。
リポジトリの中でエコーチェンバーのような現象が起きます。だんだん独自の語彙が生まれ、エージェントとの会話が意味不明になっていきます。
また、過去の記録に引きずられて、悪い実装の癖が治らないこともありました。
人間なら「これは昔のメモだから今は違う」と判断できます。しかし、AIは頻出する情報を正しそうな文脈として扱ってしまいます。
そのため、リポジトリには本当に必要なものだけ残すようにしました。
SSoT、Single Source of Truthを意識させることが重要です。重複した説明、古い方針、なんとなく残したメモは、AIにとってノイズになります。
Dev / Review / QA の3エージェント構成にする
受け入れ駆動開発では、AIエージェントの役割を分けます。
今試している流れは、ざっくり以下のようなものです。
要件定義
↓
基本設計
↓
インターフェース設計
↓
スキーマベースの机上デバッグ
↓
コンポーネント単位の受け入れケース作成
↓
Devエージェントによる実装
↓
Reviewerエージェントによるレビュー
↓
QAエージェントによる受け入れレビュー
↓
E2Eテスト
ポイントは、Dev / Reviewer / QA を同じエージェントにやらせないことです。
| 役割 | 主な責務 |
|---|---|
| Dev | 実装と受け入れテストの作成 |
| Reviewer | ガイドラインに基づくコードレビュー |
| QA | 受け入れケースと受け入れテストの妥当性レビュー |
Devエージェントは、実装と自動テストを作ります。
Reviewerエージェントは、PRが作成されたらガイドラインに基づいてレビューします。レビューコメントを受けてDevエージェントが修正し、フィードバックループが回ります。
QAエージェントは、受け入れレビューをします。ここで見るのは、「テストが通るか」だけではありません。
- 受け入れケースは妥当か
- 受け入れテストはケースをちゃんと表現しているか
- テストケースが不足していないか
- 受け入れと関係ない余計なコードがないか
- Devが基準をhackしていないか
といったことを見ます。
最初は、QAエージェントに手動テストをさせていました。しかし、これは品質が安定しませんでした。時間もかかります。
そこで、受け入れテスト自体はDevが自動テストとして作成し、QAはその妥当性をレビューする方式に変えました。
Dev:
実装する
受け入れテストを書く
Reviewer:
ガイドラインに基づいてコードを見る
QA:
その受け入れテストで本当に受け入れケースを確認できるかを見る
この分担にすると、Devが自分で作った基準を自分で満たして終わる、という状態を避けやすくなります。
AIエージェントは、与えられた基準を満たすことには強いです。一方で、その基準自体が妥当かどうかを自分で判断させると、都合のよいテストや都合のよい実装になりがちです。
そのため、Dev / Reviewer / QA を分けて、独立した視点を持たせることが重要でした。
コンポーネント単位で独立して開発できるようにする
AIエージェントに並列で開発させるには、タスクをコンポーネント単位に閉じる必要があります。
人間向けには、ユーザーストーリー単位のタスクは自然です。しかし、AIエージェントにとっては範囲が広すぎます。
複数コンポーネントをまたぐタスクを渡すと、AIは便利な場所に処理を置きます。画面向けAPIがドメインロジックを持ったり、中継コンポーネントがスキーマ変換責務を持ったりします。
そのため、実装タスクは「業務機能」ではなく「コンポーネントの受け入れケース」として切るようにしました。
acceptance-scope.md は受け入れケースの優先順位付きリスト
各コンポーネントには acceptance-scope.md を置きます。
これは詳細な仕様書ではありません。
acceptance-scope.md は、コンポーネント単位の受け入れテストケースを、優先度順に並べたシンプルなリストです。
1ケースは基本的に1行で書きます。
詳細な手順、request / response、期待されるDB状態、外部サービスへのrequestなどは、受け入れテストコードやJSON Schema、机上デバッグ用のpayloadに置きます。acceptance-scope.md に全部を書こうとはしません。
このファイルで管理するのは主に以下です。
- どの受け入れケースがあるか
- 優先度は何か
- どこまで実装済みか
- どのPRで実装されたか
例えば、注文APIなら次のようになります。
# Order API acceptance scope
## P0: 注文作成の最小成立
| ID | 受け入れケース | Status | PR |
|---|---|---|---|
| ORD-AC-001 | 注文作成リクエストを受け付け、注文を保存できる | done | #123 |
| ORD-AC-002 | 在庫サービスに在庫確保リクエストを送る | done | #123 |
| ORD-AC-003 | 決済サービスにオーソリリクエストを送る | done | #124 |
| ORD-AC-004 | レスポンスがJSON Schemaに準拠する | done | #124 |
## P1: 代表的な失敗系
| ID | 受け入れケース | Status | PR |
|---|---|---|---|
| ORD-AC-101 | 在庫不足なら注文を保存せず409を返す | in_progress | #128 |
| ORD-AC-102 | 決済失敗なら注文を確定しない | not_started | |
| ORD-AC-103 | 不正なrequest bodyなら400を返す | not_started | |
P0 / P1 / P2 は、技術的な難易度ではなく、業務的に意味のある単位でまとめます。
例えば注文APIなら、P0は「注文作成が最小限成立する」、P1は「代表的な失敗系を扱える」、P2は「ログや監査などの運用要件を満たす」というように切ります。
ここで大事なのは、P0の完了で業務的に意味のある最小単位が成立することです。
acceptance-scope.md は、PR分割にも使います。
PRを作るときは、このファイルのどの受け入れケースをcloseするかを先に決めます。
例えば、Order APIのP0を一気に全部実装すると大きすぎる場合は、次のように分けます。
#123:
- ORD-AC-001
- ORD-AC-002
#124:
- ORD-AC-003
- ORD-AC-004
このとき、1つのPRが大きくなりすぎないように、だいたい3000行以下に収まるように実装スコープを調整しています。
3000行という数字は厳密なルールではありません。人間とエージェントがレビュー可能なサイズに保つための目安です。
受け入れテストはコンポーネント単位の小さなE2Eとして書く
受け入れテストは、単体テストとは別に作ります。
単体テストは、関数やクラスのような小さな単位を検証します。もちろんこれは必要です。
しかし、AIエージェント開発では、単体テストだけでは足りません。個々の関数は正しくても、コンポーネント全体として責務がズレていることがあるからです。
そこで、コンポーネント単位の受け入れテストを作ります。
これは「小さなE2E」のようなものです。
通常のE2E:
ブラウザやユーザー操作からシステム全体を見る
コンポーネント単位の受け入れテスト:
対象コンポーネントだけを起動し、
依存先をモックして、
外からAPIを叩いて確認する
注文アプリのOrder APIを例にします。
Order APIは、注文作成の入口となるコンポーネントです。注文リクエストを受け取り、在庫サービスや決済サービスを呼び出し、注文を保存します。
受け入れテストでは、Order APIだけを本物として起動します。
- Order APIは実際に起動する
- DBはIn Memory DBを使う
- 在庫サービスはモックサーバにする
- 決済サービスもモックサーバにする
- HTTP API経由でOrder APIを叩く
- レスポンスを確認する
- DB状態を確認する
- モックサーバに届いたrequestを確認する
- request / response がJSON Schemaに準拠しているか確認する
イメージとしては以下です。
test
↓ HTTP request
Order API -----> Inventory Mock Server
|
+---------> Payment Mock Server
|
+---------> In Memory DB
たとえば、正常系の受け入れテストでは以下を確認します。
Given:
- Inventory Mock Server は在庫確保成功を返す
- Payment Mock Server は決済オーソリ成功を返す
- In Memory DB は空
When:
- POST /orders に注文作成requestを送る
Then:
- response.status は 201
- response.body は order-create-response.schema.yaml に準拠する
- In Memory DB に注文が保存されている
- Inventory Mock Server に送られたrequestが inventory-reserve-request.schema.yaml に準拠する
- Payment Mock Server に送られたrequestが payment-authorize-request.schema.yaml に準拠する
ここで大事なのは、外部サービスに送ったrequestも検証することです。
Order APIのレスポンスだけを見ると、表面上はうまく動いているように見えるかもしれません。しかし、在庫サービスや決済サービスに送るrequestが仕様からズレていれば、コンポーネント間の契約は壊れています。
例えば、仕様では決済サービスに customerId を渡すことになっているのに、AIが勝手に userId に変換していたとします。
{
"userId": "cus_001",
"amount": 2000
}
これを payment-authorize-request.schema.yaml で検証すれば、仕様とのズレを検出できます。
実装:
Payment Service に userId を送る
仕様:
Payment Service には customerId を送る
受け入れテスト:
mock server が受け取ったrequestをJSON Schemaで検証
↓
schema validation で失敗
これで、実装が仕様からズレたことを検知できます。
受け入れテストは「動くか」ではなく「責務通りか」を見る
E2Eテストは、最終的にユーザー操作として成立しているかを見るには向いています。
しかし、その実現方法が妥当かどうかまでは見てくれません。
受け入れテストでは、「動くか」だけではなく、「このコンポーネントが責務通りに振る舞っているか」を見ます。
そのために、受け入れケースには「やること」だけでなく「やらないこと」も入れます。
Order API がやること:
- 注文リクエストを受け取る
- 在庫サービスに在庫確保を依頼する
- 決済サービスにオーソリを依頼する
- 注文を保存する
Order API がやらないこと:
- 在庫数を自分で計算しない
- 決済可否を自分で判断しない
- customerId を userId に変換しない
- 外部サービスのレスポンス形式を都合よく補正しない
AIは「やるべきこと」は比較的うまく実装します。
一方で、「やってはいけないこと」を守らせるのは難しいです。だからこそ、受け入れケースと受け入れテストに、out-of-scopeを明示することが重要になります。
Markdown + OpenAPI / JSON Schema で仕様バグや実装とのズレを防ぐ
AIエージェントを使うようになると、仕様の書き方も重要になります。
Markdownで書かれたSpecは読みやすいです。意味論や意図も書けます。
しかし、Markdownだけでは実装との整合性が担保されません。
たとえば、次のようなSpecを書いたとします。
注文作成APIは customerId を受け取る。
画面向けAPIは customerId を変更せず、注文サービスに渡す。
人間には読みやすいです。
しかし、AIが実装すると、内部的には userId の方が自然だと判断して、勝手に変換処理を入れることがあります。
Markdownだけだと、こうしたズレを自動検出できません。
一方で、JSON Schemaだけでも不十分です。
JSON Schemaは、JSONの形を検証できます。しかし、「そのフィールドが業務上どういう意味を持つか」までは表現しきれません。
つまり、どちらか一方では足りません。
Markdown:
読みやすい
意味論を書ける
しかし実装とのdriftを検出しづらい
JSON Schema:
機械検証できる
CIに組み込める
しかし意味論は落ちる
だから:
Markdownで意味を書く
JSON Schemaで境界を固定する
replayと受け入れテストで検証する
この組み合わせが重要でした。
AIが出す仕様にはバリがある
AIは仕様もかなりそれっぽく書いてくれます。
ただし、実際に何度か試してみると、AIが出す仕様には必ずボロがあります。
例えば、以下のようなものです。
- なんとなく必要そう、という理由で付いたフィールド
- 同じデータが親と子の両方に存在する
- 同じ概念なのに別名で出てくる
- nullable / optional の理由が曖昧
- 画面向けAPIとリソースサーバでスキーマが微妙に違う
- 中継するだけのはずのコンポーネントが、いつの間にか変換責務を持っている
ここでいう「画面向けAPI」は、いわゆるBFF、Backend for Frontendのようなコンポーネントです。画面から呼びやすいAPIを提供し、必要に応じて裏側のサービスを呼び出す中継役です。
この中継役のスキーマと、裏側の注文サービスのスキーマが少しだけ違うとします。
人間なら「そもそもこのスキーマが変では?」と気づくかもしれません。
しかし、AIエージェントはそのズレを「満たすべき仕様」として受け取ります。
その結果、画面向けAPI側で型変換を始めたり、フィールド名の変換をしたり、不要なfallback処理を入れたりします。
微妙にズレた仕様
↓
AIが頑張って整合させる
↓
中継コンポーネントに変換・補完・rename処理が生える
↓
責務が歪む
これは3Dプリンタのバリ取りに近い作業だと思っています。
AIは形を出してくれます。しかし、そのまま使うには接合面が荒い。
そこで、OpenAPIやJSON Schemaを使って、実装前に机上デバッグをします。
机上デバッグはAPI仕様を紙の上で実行する
机上デバッグでは、API仕様を実装前に「紙の上で実行」します。
流れはだいたい以下です。
1. API仕様をOpenAPI形式で書く
2. OpenAPIだけでは表現しづらい意味論をMarkdownで補足する
3. スキーマを schema/ 以下に独立したJSON Schema形式のYAMLとして置く
4. 代表的な業務シナリオをYAMLで定義する
5. シナリオごとに満たすべき機能要件・非機能要件を書く
6. そのシーケンスをシミュレーションする
7. 各ステップのrequest / responseをJSONとして記録する
8. 結果記録用のYAMLで、シナリオのステップとrequest / responseを紐づける
9. 最後にJSON Schemaに即してrequest / responseをバリデーションする
10. シミュレーション結果をCIに組み込み、schema変更時の回帰テストにする
例えば、注文作成APIを考えます。
ディレクトリ構成は以下のようなイメージです。
docs/
order-api/
interfaces/
openapi.yaml
semantics.md
acceptance-scope.md
verification/
scenarios/
create-order.yaml
results/
create-order-result.yaml
payloads/
create-order/
01-create-order-request.json
02-create-order-response.json
03-inventory-request.json
04-payment-request.json
schema/
order-create-request.yaml
order-create-response.yaml
inventory-reserve-request.yaml
payment-authorize-request.yaml
OpenAPIには、エンドポイントとrequest / responseの形を書きます。
paths:
/orders:
post:
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
$ref: "../../../schema/order-create-request.yaml"
responses:
"201":
description: Order created
content:
application/json:
schema:
$ref: "../../../schema/order-create-response.yaml"
スキーマは schema/ 以下に独立したJSON Schema形式のYAMLとして置きます。
$id: "order-create-request"
type: object
additionalProperties: false
required:
- customerId
- items
properties:
customerId:
type: string
items:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required:
- productId
- quantity
properties:
productId:
type: string
quantity:
type: integer
minimum: 1
ただし、これだけでは customerId が何を意味するのかは十分に表現できません。
そこで、意味論をMarkdownで補足します。
# Order API Semantics
## customerId
customerId は注文主体となる顧客IDを表す。
- 認証済みユーザーIDとは限らない
- Order APIは customerId を userId に変換してはならない
- 在庫サービスや決済サービスに渡す場合も、意味を変えてはならない
## Order APIの責務
Order APIは、注文作成の入口となるAPIを提供する。
Order APIは以下を行う。
- 注文リクエストを受け取る
- 在庫サービスに在庫確保を依頼する
- 決済サービスにオーソリを依頼する
- 注文を保存する
Order APIは以下を行わない。
- 在庫数を自分で計算する
- 決済可否を自分で判断する
- customerId を別の意味のIDに変換する
次に、代表的な業務シナリオをYAMLで書きます。
id: create-order-happy-path
title: 注文を作成する
requirements:
functional:
- ユーザーが商品と数量を指定して注文を作成できる
- Order APIは在庫サービスに在庫確保を依頼する
- Order APIは決済サービスにオーソリを依頼する
- レスポンスにorderIdを含める
nonFunctional:
- 各APIのrequest/responseはJSON Schemaに準拠する
- Order APIはcustomerIdの意味を変更しない
steps:
- id: create-order-request
component: order-api
operation: POST /orders
request: ../payloads/create-order/01-create-order-request.json
response: ../payloads/create-order/02-create-order-response.json
- id: reserve-inventory
component: inventory-service
operation: POST /internal/inventory/reservations
request: ../payloads/create-order/03-inventory-request.json
- id: authorize-payment
component: payment-service
operation: POST /internal/payments/authorizations
request: ../payloads/create-order/04-payment-request.json
最後に、各ステップのrequest / responseをJSONで記録し、結果記録用のYAMLでschemaと紐づけます。
scenario: create-order-happy-path
results:
- step: create-order-request
request:
file: ../payloads/create-order/01-create-order-request.json
schema: ../../../schema/order-create-request.yaml
valid: true
response:
file: ../payloads/create-order/02-create-order-response.json
schema: ../../../schema/order-create-response.yaml
valid: true
- step: reserve-inventory
request:
file: ../payloads/create-order/03-inventory-request.json
schema: ../../../schema/inventory-reserve-request.yaml
valid: true
- step: authorize-payment
request:
file: ../payloads/create-order/04-payment-request.json
schema: ../../../schema/payment-authorize-request.yaml
valid: true
CIでは、これらのpayloadをJSON Schemaに対して検証します。
schema変更
↓
代表シナリオのrequest / responseを再検証
↓
過去のシミュレーション結果が壊れていないか確認
これは、実装コードに対するテストではなく、仕様レベルの回帰テストです。
JSON Schemaだけでは意味論の破壊を防げない
この仕組みが必要なのは、JSON Schemaのvalidationだけでは、意味論の破壊を防げないからです。
例えば、AIが次のような「改善」を提案したとします。
required:
- userId
- products
properties:
userId:
type: string
products:
type: array
items:
type: object
required:
- id
- amount
properties:
id:
type: string
amount:
type: integer
一見すると、これもスキーマとしては成立しています。
customerId を userId に、items を products に、quantity を amount に変えただけです。AIからすると、自然な命名に寄せたつもりなのかもしれません。
しかし、業務シナリオ上は壊れています。
-
customerIdとuserIdは同じ意味ではない -
quantityとamountは同じ意味とは限らない - 中継コンポーネントが変換責務を持ち始める
- 同じ概念の名前がコンポーネント間でズレる
問題は変換処理がないことではなく、そもそもスキーマがズレていることです。
人間なら「このスキーマおかしくない?」と上流に戻せるかもしれません。しかし、AIは与えられた仕様を満たすために、局所的な補正コードを書いてしまいます。
だから、代表シナリオを使って、実際にデータを流してみる必要があります。
人間はコードを書くより、違和感をフィードバックする役割に寄っていく
AIエージェントに任せられることは増えています。
明確な受け入れケースがあり、仕様が機械可読で、自動テストできるものは、かなり任せやすくなりました。
例えば、以下のようなものです。
- コンポーネント単位の実装
- 受け入れテストの作成
- 既存ガイドラインに基づくレビュー
- PRコメントへの対応
- JSON Schemaに基づくrequest / response検証
一方で、人間がまだ見た方がよいものもあります。
- 責務分割
- 抽象化の止めどころ
- UIや設計の「なんか微妙」
- 過去に同じ失敗をしていないか
- ガイドライン自体が間違っていないか
- Elephant in the room
AIレビューで怖いのは、細かい指摘が間違っていることより、Elephant in the roomを素通りすることです。
例えば、以下のようなものです。
- 受け入れテストしかなく、単体テストがない
- UI実装なのにCSSが一切書かれていない
- 中継コンポーネントがいつの間にか変換責務を持っている
- 見た瞬間に「なんか微妙」な設計になっている
レビューコメントの一つ一つはもっともらしいです。しかし、そもそも大きな問題を見落としていることがあります。
AIは短距離の整合性には強いです。
この関数は型に合っているか。
このテストは通るか。
このPRコメントに対応できるか。
このJSONはschemaに合っているか。
一方で、長距離の整合性にはまだ弱いと感じています。
前回も同じ責務分割で失敗していないか。
この抽象は3ヶ月後に保守できるか。
この命名は別コンポーネントと意味がズレていないか。
この長距離の違和感を人間が補う必要があります。
人間のレビューは、コードの正誤判定から工程改善へ移っていきます。
問題のあるコードを1つ直すのではなく、同じ問題を次から検出できるようにガイドラインを直す。個別修正者ではなく、品質管理のフィードバック源になる。
これが、今のところ一番しっくりきている役割分担です。
まとめ
AIエージェントを使うと、実装速度はかなり上がります。
しかし、実装が速くなると、ボトルネックは「書くこと」ではなく「受け入れること」に移ります。
そのためには、AIに曖昧なタスクを渡すのではなく、次の3つを先に整える必要がありました。
- Dev / Reviewer / QA を分ける
- コンポーネント単位で独立して開発できるようにする
- Markdownだけでなく、OpenAPIやJSON Schemaで仕様を機械可読にする
特に重要だったのは、仕様を「読めるもの」として書くだけでなく、「検証できるもの」にすることです。
Markdownは意味論を書くには向いています。
一方で、実装とのズレを検出するには、JSON Schemaやシナリオreplay、コンポーネント単位の受け入れテストが必要でした。
また、人間の役割も変わります。
AIが書いたコードをすべて読むのではなく、サンプルを見て、違和感を拾い、ガイドラインや受け入れケースにフィードバックする。コードを直接直すより、同じ問題が次から検出されるように工程を直す。
受け入れ駆動開発は、AIエージェントに実装を任せるための開発プロセスというより、AIが高速に生成するコードを安全に受け入れるための品質管理の仕組みだと感じています。