AIコードのベビーシッティングをやめる方法
LLMエージェントは機能開発を安価にするが、アーキテクチャの腐敗をもたらす。アーキテクチャ上の決定を実装から分離し、ビルドシステムのチェックでルールを強制することで、開発者は生成されたコードのレビュー負担を減らし、システム設計に集中できる。
LLMエージェントは機能開発をほぼ安価にした。変更を依頼すると、エージェントがコードを書き、テストを追加し、実行する。一瞬、未来は「バイブコーディング」のように見える。
しかし、その後請求書が届く。エージェントは最も近い便利なパスからインポートし、フロントエンドコードにデータベースへの直接アクセスを与えた。その選択は機能の実装を容易にしたが、短期的な便利さがアーキテクチャを書き換えた。
明らかな次の一手はルールを書き留めることだ。AGENTS.mdファイル、仕様書、計画文書はすべて、エージェントがコードを書く前にアーキテクチャを読み込もうとする。これらは役立つが、散文は依然としてリマインダーであり、制約ではない。エージェントはそれを忘れたり、都合の良い部分だけに従ったり、ルールを満たさずに成功を主張したりする。
レビューが最後の砦となり、すべての差分でそのような近道をチェックすることを意味した。それは役立ったが、開発を退屈な監視業務に変えた。
解決策は、アーキテクチャ上の決定を実装の詳細から分離し、実装がそれらの決定を尊重することを強制するチェックを追加することだった。それが整うと、レビューは生成された配管から、モジュール境界、公開API、依存関係といった長期的なアーキテクチャ上の決定へと移行した。
ルールは居場所を必要とする
より深い問題は記憶だ。人間は社会的文脈を前方に運ぶ。エージェントはその文脈を毎回リロードするか強制する必要がある。同じ書かれたルールを10回破っても、11回目のリマインダーを新しい情報として扱う。毎朝リセットされる非常に速いインターンを雇うようなものだ。
ドキュメントは有用なコンテキストだが、プロジェクトが破壊できないルールにとっては貧しい住処だ。コンテキストは希少だ。指示は互いに希釈する。ルールが毎回守られなければならないなら、コードベースが強制できる場所に住むべきだ。
コスト曲線が反転した
ソフトウェアチームはすでにチェックを使用している。重要なルールは散文を離れ、型、テスト、リンター、ビルドチェック、またはランタイム検証になる。チームは通常、違反が一般的であるか、強制が重要である場合にのみルールを昇格させた。それでも、標準的なリンタープリセットと設定が安価なチェックを手にした。
モジュールレイアウト、インポート境界、公開インターフェースの概念を理解するカスタムチェッカーは、初期の多大なエンジニアリング労力を要し、見返りは疑わしかった。標準的なリンタープリセットと品質の最後の砦としてのコードレビューが、しばしばより良いトレードオフだった。
コーディングエージェントは両方向で経済性を変えた。
- 人間がレビューできるよりも速くドリフトを生成する。
- 特注のチェッカーを午後でプロトタイプできるほど安価にする。
ドリフトの発生源が今やそれを封じ込めるのに役立つ。
小さな例を挙げる。エージェントは単純な引数を儀式で包むのが好きだ。
代わりに:load(id: string)
次のような小さな申請フォームが得られる:load(input: { id: string })
指示ファイルでエージェントに「それをするな」と言うのはしばらくは機能する。エージェントに公開インターフェースでそのパターンを拒否するチェックを書かせるのに数分かかった。それ以来、チェックがリマインダーを処理し、エージェントが修正するまでビルドは失敗する。
このパターンは実際に重要なルールに容易に拡張できる。フロントエンドがデータベーススキーマをインポートしてはならない?それはインポート境界チェックだ。検証ロジックをUIに置きたくない?それはソースチェックだ。ルールがチェックになれば、失敗は決定的であり、エラーメッセージはルールを名指しし、エージェントは私が差分を見る前に自分の違反を修復する。
外部に契約、内部に生成コード
私のプロジェクトでは、ローカルルールはビルドシステムのチェックになる。それらはパッケージソースの外にあり、UIプリミティブ、UIコンポーネント、ドメインパッケージなど、それらが管理するコードごとにグループ化される。
この例では、ドメインパッケージを見てみよう。これは主にプロジェクトのビジネスロジックを運ぶプレーンなTypeScriptコードだ。ロジックは狭い公開インターフェースを持つ小さなモジュールに分割されている。各モジュールは強制された構造に従う。
some-module/ ソースファイルはレビュー責任で分割される。
contract/— 権威ファイル
* interface.ts — 公開APIのみ、実装なし
* create.ts — モジュールコンストラクタを公開、必要な依存関係をすべて宣言
* cases.ts — テストとして動作例を定義、create.tsを通じてモジュールを構築、偽の依存関係で実際のモジュールに対してパス
impl/— エージェントが書いた実装コード
アーキテクチャ上の決定は権威ファイルに存在する。manifest.json、README.md、contract/**はモジュールの意図、公開インターフェース、依存関係、動作例を定義する。
実装の詳細はimpl/**内に留まる。それらは契約を満たし、インポート境界内に留まらなければならない。
権威ファイルへの変更はプロジェクトの形状を変える可能性があり、impl/**の再生成を必要とする。impl/**内の変更はモジュールローカルに留まるべきである。その境界を超えるとチェックが失敗する。
チェックはビルドシステム内、モジュールの外側に存在し、それらの役割を強制する。
仕様システムチェック
いくつかのチェック:
interface.ts: 公開APIのみ、実装なしcreate.ts: モジュールコンストラクタを公開、必要な依存関係をすべて宣言cases.ts: 動作例をテストとして定義、create.tsを通じてモジュールを構築、偽の依存関係で実際のモジュールに対してパスimpl/**:create.tsとinterface.tsのみインポート可能、Dateやcryptoなどの環境能力を使用不可
目標はimpl/**を閉じた箱にすることだ。契約ファイルが十分に指定されていれば、生成されたコードは依然として間違っている可能性があるが、混乱は実装ディレクトリ内に留まる。
これにより、レビューを持続的な決定が存在する権威ファイルに集中させることが可能になる。
依存関係
依存関係は設計上の決定である。生成された実装は、ネットワーク呼び出し、ストレージへのアクセス、クロックの読み取り、ID生成の許可を自分自身に与えるべきではない。
私のプロジェクトでは、impl/**内のコードは直接Date.now()、fetch、localStorage、crypto.randomUUID()を呼び出せない。モジュールが時間、ネットワーク、ストレージ、IDを必要とする場合、その設計上の決定はcontract/create.tsでコンストラクタ引数として依存関係を宣言することで記録される。
これによりレビューは正しい質問に移る。エージェントがlocalStorageを正しく使ったかどうかではなく、このモジュールに永続性が必要かどうかを問う。
同じパターンがモジュール境界にも機能する。モジュールが別のモジュールを必要とする場合、その依存関係も明示的でなければならない。生成されたファイルがその境界を越えると、失敗はルールを指すべきである。
例えば、エージェントがorder/impl/price.tsをカタログ実装に到達させるかもしれない:
// order/impl/price.ts
import type { OrderDeps } from "../contract/create";
import type { PriceQuote, PriceRequest } from "../contract/interface";
import { catalogStore } from "../../catalog/impl/store";
export function priceOrder(_deps: OrderDeps, request: PriceRequest): PriceQuote {
const item = catalogStore.lookup(request.sku);
return { amount: item.price * request.quantity };
}チェックは差分がレビューに届く前にターミナルで失敗する:
$ repo check
error import-boundary
order/impl/price.ts imports ../../catalog/impl/store
impl/** may only import:
../contract/create
../contract/interface修正方法はカタログ依存関係を契約の一部にし、モジュール外部から渡すことだ:
// order/contract/create.ts
import type { CatalogStore } from "../../catalog/contract/interface";
import type { OrderService } from "./interface";
export type OrderDeps = {
catalog: CatalogStore;
};
export declare function createOrder(deps: OrderDeps): OrderService;その後、impl/**はカタログ実装を直接インポートする代わりにdeps.catalogを使用する。
動作
型とメソッドシグネチャは形状を定義するが、動作は解釈の余地を残す。契約ケースは実行可能な例でそのギャップを埋める。実際には、これらのチェックは通常の単体テストである。
contract/cases.tsが書かれる時点で、公開インターフェースは既にinterface.tsに存在する。時間、ストレージ、IDなどの環境能力を含むすべての依存関係はcontract/create.tsで宣言されなければならない。つまり、ケースはimpl/**が存在する前に書くことができる。それらは最初に失敗し、その後生成を導く。
モックも簡素化される。テストは実装内に隠れているクロック、ストレージ、ネットワーク呼び出しをパッチしない。それらの能力をコンストラクタを通じて渡す。これは本番コードと同じ方法だ。
実装が変更されても、ケースは契約の意味を固定する。意味が変更されなければならない場合、ケースが先に変更される。
レビュー
レビューは人間の注意に値する決定に近づく。構造により、公開形状、依存関係、動作例がすべて私がレビューを期待するファイルに存在するため、影響の大きい変更を隠すことが難しくなる。
これにより、私が問う質問が変わる。このモジュールには単一の目的があるか?依存関係は価値があるか?公開形状は経年変化に耐えるか?例は重要な動作をカバーしているか?
impl/**内部では、エージェントはアダプターの迷路を構築したり、すべての変数に二重の名前を付けたりできる。契約と境界が保持されていれば、気にする必要はない。
限界
エージェントの助けがあっても、エンコードするには難しすぎたり高価すぎたりするルールが依然としてある。複雑で脆く、例外だらけになるチェックは、節約するよりも多くのメンテナンス作業を生み出す可能性がある。強制する価値のあるルールは、実行が安価で、理解しやすく、毎回適用できるほど安定している。
チェックは注意深い設計と有用なエラーメッセージを必要とする。エラーがあいまいだと、エージェントはチェックを回避する。それは強制をモグラ叩きに変える可能性がある。良いチェックには、明確なルール、正確なメッセージ、そして人間のレビューに値するケースのための逃げ道が必要である。
結論
コード生成はソフトウェアを書くことを安価にした。生成されたコードのレビューがボトルネックになりつつある。エンジニアの注意が希少リソースならば、それはシステムを形作る決定に費やされるべきである。
強制層は繰り返しの仕事への賭けである。初期コストは、エージェントが同じ境界に十分な頻度で戻り、レビュー注意を節約することでそれを返済する場合にのみ意味を持つ。
このアプローチは、エージェント生成コードのレビューの退屈さの多くを取り除いた。それは私の努力をシステムのエンジニアリング、すなわち契約の形成、境界の選択、どの部分が互いに依存することを許されるかの決定に戻す。仕事はベビーシッターのように感じるよりも、ステアリングのように感じる。