はじめに
Vibe codingで動くアプリはすぐできます。問題はそのあとで、「これを実ユーザに出せる品質にするにはどうすればいいのか」に、まだ定番の答えがありません。
この記事は、その答えを探して私が実際に試してきたことの記録です。うまくいったこと、失敗したこと、そして失敗から立てた仮説を書きます。大事な断りとして、この記事の後半は未検証の仮説です。検証できている部分とできていない部分を明示しながら書くので、そのつもりで読んでください。
想定しているのは、個人開発〜小規模チームで作るサービスの話です。企業と契約して提供するB2Bサービスなら、QAに相応の体制と予算を組むのが筋で、この記事の割り切りを持ち込む場面ではありません。
失敗1: 何も決めずにVibeしたら、保守できないものができた【検証済み】
最初は本当に何も決めずに、プロンプトだけで作らせていました。動くものは出てきます。ただ、中を見ると厳しい状態でした。
- 責務が分割されておらず、UIとビジネスロジックとDB操作が同じ関数に同居している
- 同じディレクトリにファイルが30個くらい積み上がっている
- 似たようなコードがあちこちに散在していて、直すときにどれが本物か分からない
- テストは書かれている。でも読んでも何がテストされているのか分からない
最後のやつが地味に一番怖かったです。テストが通っていることが品質の根拠にならない。「エージェントが書いたコードを、エージェントが書いたテストが通している」だけで、その輪の中に私の意図が入っていない。
ここでの学びは2つです。
- アーキテクチャは最初に人間が決める。 ディレクトリ構成、レイヤー分割、依存の向き。ここをエージェント任せにすると、後から直すコストが膨らみ続けます。
- ガイドラインを書いて、機械にゲートさせる。 コーディングガイドラインを用意してAIにレビューさせる。加えてlizardのような複雑度計測ツールでCIにゲートを入れ、閾値を超えたら定期的にリファクタリングする。
「エージェントの出力品質はプロンプトの丁寧さではなく、事前に敷いた構造と機械的なゲートで決まる」というのが、この段階の結論でした。これは今も運用していて、効いています。
失敗2: 仕様と実装が、いつの間にか両方とも「正解」じゃなくなった【検証済み】
構造の問題が落ち着くと、次に効いてきたのがドリフトでした。
最初のうちは、仕様と実装のギャップは小さいんです。でも開発を続けると仕様変更が重なります。仕様書を直し、実装を直し、また仕様が変わり…… を繰り返すうちに、仕様書と実装のどちらも中途半端に古くなって、正解がどこにあるのか誰にも分からなくなりました。仕様書を信じて直すと実装側の暗黙の挙動が壊れ、実装を信じると本来の意図から離れていく。
ここで試して、うまくいったのがこれです。
実装したエージェントとは別のエージェントにQAをさせる。QAの際は、仕様から抽出した受入条件を満たしているかをチェックさせる。
ポイントは2つあって、一つは生成と検証を別のエージェントに分けたこと。実装した本人(同じコンテキスト)に確認させると、同じ思い込みで同じ見落としをします。もう一つは、チェックの基準をコードではなく仕様由来の受入条件に固定したこと。「実装がどうなっているか」ではなく「何を満たすべきか」を判定基準にする。
これは実際にかなり機能しました。振り返ると、ここで得た教訓が後の仮説の土台になっています。別々の経路で作られた成果物を突き合わせて、不一致を見つけることに価値がある、ということです。
失敗3: 形式手法に夢を見た【検証済みの失敗】
受入条件QAで日々のドリフトは抑えられるようになりましたが、もっと根本的に解決できないかと考えました。そこで形式手法です。
発想はこうでした。最初から自然言語の仕様を形式化してしまえば、バグやドリフトのないソフトウェアが作れるんじゃないか。仕様をLeanで書き、そこから実装を導けば、意図とのズレは原理的に起きないはずだ、と。
結果は期待外れでした。エージェントに自然言語の仕様を渡して形式化させると、期待する形式化は生成されませんでした。「何か」は証明されるんです。型は通るし、定理も証明される。でもそれは私が保証したかったものとはだいぶ違う何かでした。モデルは現実のシステムと座標系が合っておらず、モデルと実装の乖離が大きすぎて、ドリフトを検知することもモデルをレビューすることも実質不可能でした。
失敗を整理すると、こういうことだと思っています。
- 自然言語の仕様だけを入力にすると、エージェントは現実のコードと接点のない、抽象的にきれいなモデルを空中に構築する
- モデルと実装の距離が遠いと、突き合わせたときの差分が全部ノイズになり、失敗2で機能した「不一致を見る」アプローチが成立しない
- 「形式化されている」ことと「自分の意図が形式化されている」ことは別物で、後者を確認する手段がなければ証明に意味がない
「最初から形式化すればズレない」は、少なくとも私のやり方では幻想でした。
いま試している仮説: 順番を逆にする【未検証】
ここからが仮説パートです。まだ確かめられていません。
3つの失敗を並べると、方向が見えてきます。失敗1から、構造とゲートは人間が先に敷けばエージェントは安定すること。失敗2から、別経路の成果物の突き合わせは機能すること。失敗3から、接地のない形式モデルは機能しないこと。
そこで、形式化の順番を逆にしてみたらどうか、と考えています。
- 最初に決めるのはアーキテクチャとガイドラインだけ(失敗1の学び)
- Vibe codingで動くものを速く作る。一定動くものは出てくる
- できた実装からインターフェースだけを抽出する。エンティティ名、フィールドと型、APIの入出力。実装本体のロジックは含めない
- その骨格と「保証したいこと」(仕様由来の主張)を渡して、Leanで小さな抽象モデルを作らせる
- モデルと実装を突き合わせて、意図の違いや境界の不整合を検知する(失敗2の学びの発展形)
失敗3との違いは、モデルに実装由来の語彙という接地を与えることです。骨格があれば、モデル生成は自由作文ではなく穴埋め問題になり、現実のシステムと座標系の合ったモデルが出てくるのではないか。一方で、「何が許されて何が許されないか」という意味の部分は実装からではなく仕様由来の主張から書かせる。実装から丸ごと写すと、実装のバグまできれいに写ってしまうからです。語彙は実装から、意味は仕様から。これが仮説の核です。
検知したいのは、たとえばこういう類のズレです。BFF(フロントの都合に合わせたAPI層)と、実データを持つResource Serverの間で、フィールドが噛み合っていないケース。
// Resource Server側のendpointは publishedAt を必須で要求する
type CreatePostRequest = {
title: string;
body: string;
publishedAt: Date; // 必須
};
// しかしBFFがフロントから受け取る入力に publishedAt は存在しない
type BffCreatePostInput = {
title: string;
body: string;
};
人間なら「この値、どこから来るんですか?」と設計に疑問を持つ場面です。でもAIは既存の設計に合わせようとするので、黙ってfallbackで埋めます。
// vibe産実装: 型を通すために、それらしい値で埋めてしまう
await resourceServer.createPost({
title: input.title,
body: input.body,
publishedAt: new Date(), // ← 下書き保存でも「公開日時 = 今」が入る
});
型は通るし、テストも通るし、画面も動きます。でも「下書きなのに公開日時が入っている」データが静かに溜まっていく。エラーとして現れないので、実装のテストではまず見つかりません。数ヶ月後に「公開日時順の並びがおかしい」みたいな症状として遠くで発火します。
抽象モデル側では、この層をまたぐ変換そのものを書くことになります。Leanで書くとこうなります(Leanを知らなくても雰囲気で読めるはずです)。
-- 骨格から写した語彙: BFFがフロントから受け取る入力
structure BffCreatePostInput where
title : String
body : String
-- 骨格から写した語彙: Resource Serverが要求するリクエスト
structure CreatePostRequest where
title : String
body : String
publishedAt : Date -- 必須
-- BFF入力 → Resource Server呼び出し への写像を書こうとすると…
def toRequest (input : BffCreatePostInput) : CreatePostRequest :=
{ title := input.title
, body := input.body
, publishedAt := ?missing } -- ← ここが埋まらない
error: don't know how to synthesize ?missing : Date
TypeScriptの実装では new Date() と書けば黙って通ってしまった場所が、モデルではコンパイルが止まる穴として現れます。Leanは「Date型の値をどこから持ってくるのか」を、根拠を書くまで先に進ませてくれません。エージェントが実装でやった「それらしい値で埋める」という選択が、モデル上では省略できない明示的な決断になる。
そして穴を前にして初めて、「そもそも下書きの作成に公開日時は必要なのか?」という仕様側の問いが立ちます。正しい直し方はfallback値の工夫ではなく、おそらくドメインの修正です。
-- 公開日時は「公開済み」状態にだけ存在する、と直す
inductive PostState where
| draft
| published (publishedAt : Date)
こう直すと、publishedAt を要求していたResource Server側の契約の方が間違っていた、という結論に到達します。実装のテストは「今の動きが変わっていないか」を見るのは得意ですが、「この値はどこから来るべきなのか」という問いは立ててくれません。この問いを型の穴として機械的に出せるようになるのが、抽象モデルへの期待値です。
次に確かめること
仮説なので、検証計画を書いておきます。
既知の仕様バグ(上のような「上流に出所のないフィールド」を含む境界)を持った骨格を意図的に渡して、生成されたモデルがそのバグをそのまま再生産するか、それとも指摘するかを見る。再生産するなら、骨格が前提の押し付けになっていて、レビュー工程を別に足す必要があるということ。指摘できるなら、穴埋め化しても検出力は死なないということ。どちらに転んでも学びがあるので、結果はまた記事にします。
まとめ
検証できていること: アーキテクチャとガイドラインは人間が先に決め、複雑度ゲートで機械的に守ると、Vibe産コードの構造は保てる。別エージェントに仕様由来の受入条件でQAさせるアプローチは機能する。
検証できた失敗: 自然言語の仕様だけから形式モデルを作らせても、期待する形式化は得られない。
未検証の仮説: 実装から抽出した骨格で接地させ、意味は仕様から与えれば、実用になる抽象モデルが作れるのではないか。
Vibe codingの速さを商用品質につなげる道は、まだ誰も確立していないと思います。だからこそ、うまくいった話だけでなく、失敗した話と検証前の仮説を、ラベルを付けて共有する意味があると考えています。同じあたりで試行錯誤している方がいたら、ぜひ結果を教えてください。