AI開発におけるガードレール設計
「動いているように見えるけど、コードが少しずつ壊れていく」問題。
AIエージェントにコードを書かせる開発を続けていると、わりと早い段階でこれにぶつかります。具体的には、こんなことが起きます。
- セッションをまたぐと実装方針が変わり、同じ機能なのに書き方がバラバラになる
- リファクタリングを頼んだら、関係ないはずの既存仕様が静かに壊れる
- 型エラーやlintエラーを残したまま「完了しました」と報告してくる
- その場では動くけど、巨大な関数や深いネストが積み上がっていく
これらはモデルの性能が低いから起きるわけではないと思っています。AIは原則として、与えられたコンテキストの範囲で局所最適なコードを生成します。プロジェクト全体の設計意図や、3ヶ月前に決めた命名規則を、毎回正確に思い出してはくれません。
だからAI開発では、「どう作らせるか」と同じくらい「どうやって逸脱を止めるか」を考えないといけない、というのが最近の実感です。
この記事では、AIの逸脱を人間のレビューに頼らず、仕組みで止める方法を整理してみます。
仕様を先に固定して、解釈の余地を減らす
AIの逸脱の多くは、指示が曖昧なことから始まります。曖昧な指示の隙間は、AIが解釈で埋めてくれます。そしてその解釈はセッションごとに変わります。
対策はシンプルで、実装より先に仕様を固定して、それをファイルとしてリポジトリに置いておくことです。チャットで都度説明するのではなく、いつでも参照できる場所に置きます。
例えばユーザー登録機能の仕様を、docs/specs/user-registration.md にこう書きます。
# ユーザー登録
## 要件
- メールアドレスは一意であること
- パスワードは12文字以上であること
- 確認メールを送信すること
## エラーレスポンス
| code | message |
| ---- | ---- |
| EMAIL_EXISTS | このメールアドレスは既に登録されています |
| INVALID_PASSWORD | パスワードが短すぎます |
地味に大事なのが、エラーレスポンスのコードとメッセージまで表で固定しておくことです。ここを曖昧にしておくと、AIはセッションごとに EMAIL_DUPLICATED だの EmailAlreadyExists だの、毎回違う命名を生成してきます。コード(EMAIL_EXISTS)はそのまま実装に使うので英語のまま、メッセージは利用者に見せる文言なので日本語、というふうに分けています。
仕様を固定したうえで、AIへの指示はこうします。
docs/specs/user-registration.md に基づいて実装してください。
APIのレスポンス構造は変更しないでください。
要件をプロンプトに直接書くのではなく、ファイルを参照させているのがポイントです。こうしておくと、仕様が変わったときも仕様書の差分を見るだけで済みます。
あと運用してみて気づいたのは、仕様書に「変えてほしくないこと」を明示的に書いておくことです。AIは書いていないことを「やってもいい」と解釈しがちなので、「APIレスポンス構造は変えない」「既存のエラーコードは消さない」みたいな禁止事項を言葉にしておくと逸脱が少なくなります。
テストを「壊してはいけない契約」として先に置く
AI生成コードでいちばん厄介なのが、見た目は正しく動いているのに、境界条件や既存仕様を静かに壊しているケースです。これをレビューで目視で見抜くのは、正直しんどいです。
そのためテストを書いて、「壊してはいけない契約」を明示的にしておくことが大事です。
テストは後付けにせず、仕様と同時、できれば実装より先に置きます。
例えばパスワードバリデーションのテストを先に固定します。
import { describe, expect, test } from 'vitest'
import { validatePassword } from './password'
describe('validatePassword', () => {
test('12文字未満はエラー', () => {
expect(validatePassword('short')).toBe(false)
})
test('英数字記号を含む12文字以上は許可', () => {
expect(validatePassword('StrongPass123!')).toBe(true)
})
})
後述する CLAUDE.md やルールの中で、「テストファイルは変更しないこと」を明記しておくのが大事です。これを書いておかないと、AIはコードを直す代わりに、失敗しているテストのほうを書き換えて「パスしました」と言ってきます。テストを壊して帳尻を合わせるのは、AIでよく見る事故です。
また、仕様書の各要件にテストを紐づけておくと、「仕様→テスト→実装」が一本につながって、AIがどこを満たせばいいか迷わなくなります。
カバレッジについては、網羅率より「壊れたら困る箇所」を優先したほうが守りとして機能しました。AIにテストを書かせると、網羅率は高いけど本質的には何も保証していないテスト(自明なgetterのテストとか)を量産しがちです。認証・課金・データ整合性みたいな「壊れたら事故になる箇所」を人間が責任を持って固定して、そこは変更させない、という運用のほうが実態に合っています。
ルールをドキュメント化して、毎回読ませる
AIエージェントは、プロジェクト固有のルールを覚え続けてくれません。「この前も言ったよね」が通じない相手だと思っておいたほうがいいです。だったらルールは口頭で都度説明するのではなく、AIが毎セッション自動で読み込むファイルにしておきます。
Claude Code なら CLAUDE.md、Cursor なら rules が、その「毎回読み込まれる場所」にあたります。例えばバックエンドのルールを CLAUDE.md にこう書きます。
# バックエンドのルール
- リポジトリパターンを使う
- コントローラからDBを直接触らない
- リクエストのバリデーションは zod を使う
- 例外を投げる代わりに Result 型を使う
- 関数は50行まで
ルールは「判定できる形」で書くのが重要です。「きれいなコードを書く」みたいな抽象的なルールは、AIにとっても人間にとっても基準が曖昧で、ほぼ機能しません。「コントローラからDBを直接触らない」「関数は50行まで」みたいに、満たしているかを後で機械的にチェックできる粒度にしておくと、後述のCIとも連動させやすいです。
あと、CLAUDE.md は太らせすぎないほうがいいです。ルールを増やすほど安心しがちですが、ファイルが長くなるとAIは後半のルールを無視し始めます。100個のルールを等しく守らせるのは難しいので、本当に守らせたい数個に絞ったほうが結果的に守られます。
タスクの種類ごとにルールが変わるなら、skills のようなディレクトリに分割して、必要なものだけ読ませる構成が便利です。
.skills/
backend-api.md
frontend-component.md
testing.md
例えば testing.md には、テストの書き方だけを切り出して書いておきます。
# テストのルール
- vitest を使う
- 外部APIはモックする
- 成功ケースと失敗ケースの両方をカバーする
- スナップショットテストは避ける
こうしておくと、テストを書かせるときだけこれを参照させればよくて、CLAUDE.md 本体を太らせずに済みます。AIが毎回違うテストスタイルを生成する問題も抑えられました。
CIを「越えられない品質ゲート」にする
ここまでの仕様・テスト・ルールは、言ってしまえばAIへの「お願い」です。お願いなので、AIは守らないこともあります。型エラーを残したまま「完了」と言ってくるのが、その典型です。
なので最後に、お願いを聞いたかどうかを機械的に検査して、満たしていなければマージさせない仕組みが要ります。それがCIです。ローカルの確認や人間のレビューはすり抜けますが、CIだけは全PRに対して同じ基準を確実に強制できます。
- lint: eslint などで書式・型エラーを検査する
- unit test: 壊してはいけない契約を全PRで検証する
- complexity check: 巨大関数や深いネストの増殖を止める
complexity check はAIの長期的な弱点をカバーしてくれます。AIは短期的に動くコードを書くのは得意ですが、長く保守できる形に単純化するのは苦手で、放っておくと条件分岐と関数の長さがじわじわ増えていきます。これを人間のレビューで毎回指摘するのはしんどいので、機械にやらせるのが向いています。eslint ならこんなふうに閾値を設定します。
export default [
{
rules: {
complexity: ['error', 10],
'max-lines-per-function': ['error', 50],
'max-depth': ['error', 4]
}
}
]
ここで 'max-lines-per-function': 50 が、さっき CLAUDE.md に書いた「関数は50行まで」と同じ数字になっているのがポイントです。ルールに書いた基準を、CIで強制する。お願い(CLAUDE.md)と強制(CI)を同じ数字で揃えておくと、AIがCLAUDE.mdを無視しても最後にCIが止めてくれます。
レビューの自動化で、細かい指摘を任せる
CIで弾けるのは、ルール化できる定型的な問題です。一方で「N+1問題になってそう」「この命名、周りと揃ってないな」みたいな、文脈依存だけどレビューで毎回指摘するような問題も、AI生成コードでは大量に出てきます。
ここは CodeRabbit のようなAIレビューツールに任せます。未使用コード、N+1、null安全性、命名の不整合あたりを、PRごとに自動でコメントしてくれます。
ポイントは、自動レビューを「ブロッカー」ではなく「指摘係」として使うことです。通る/通らないが明確なものはCIのゲートに任せて、自動レビューは「人間が見る前のフィルタ」くらいに位置づける。こうすると人間のレビュアーは細かい指摘から解放されて、設計みたいな本質的なところに集中できます。
AIに任せる範囲を意識する
ここまで仕組みの話をしてきましたが、最後に意識しておきたいことを書きます。何をAIに任せて、何は人間がちゃんと見るか、という話です。
AIは設計の壁打ち相手として使うこともできますし、ざっくりした方向性を出してもらうのには役立ちます。ただ、後から変えるコストが高い部分は人間がきちんと判断したほうが安全です。
特に注意が必要なのは、DB設計・認証設計・セキュリティ周りです。これらは一度決めると影響範囲が広く、AIが出してきた案をそのまま採用してトラブルになる可能性もあります。AIの提案を参考にしつつも、最終的な判断は人間が責任を持つ、というスタンスが無難だと思っています。
逆に、CRUD実装・テスト追加・リファクタリング補助・型定義生成・migration作成あたりは、枠さえ決まっていれば安心して任せられます。
そして人間が下した設計判断は、記録に残しておきます。AIは過去の意思決定の経緯を覚えていないので、ADR(Architecture Decision Record)みたいな形で「なぜこう決めたか」を残しておくと、後からAIにも人間にも参照させられます。
# ADR-0001: バリデーションに zod を使う
## 背景
全エンドポイントで一貫したサーバーサイドバリデーションが必要だった。
## 決定
リクエストのバリデーションはすべて zod を使う。
## 結果
バリデーション層が統一され、ロジックが一箇所にまとまる。
「なぜzodを使うと決めたのか」の背景・決定・結果を残しておくと、AIに同じような実装をさせるときの判断材料になります。さっきの CLAUDE.md のルール(リクエストのバリデーションは zod を使う)が何に基づくのかを、ADRが補ってくれる関係です。
まとめ
AI開発の品質は、モデルの性能よりも、逸脱を止める仕組みで決まる、というのが今の実感です。大事なのは、人間の注意力に頼らず機械的に止めること。
やってきたことを振り返ると、仕様を固定して解釈の余地を減らし、テストで壊してはいけないものを固定し、ルールをドキュメント化して毎回読ませ、CIでそのルールを機械的に強制する、という流れでした。
AIは制約なしに使うとコードベースがどんどん荒れていきますが、ガードレールをちゃんと設計すれば、品質が一定担保できます。特にこれからAI開発を始めるチームほど、コードを書き始める前にこの仕組みを整えておくのがおすすめです。
Discussion