みなさんごきげんよう。最近プログラミング界隈ではAIコーディングが話題ですが、今回はAIフレンドリーな設計を考えてみて、それを実践したところAIとフレンドリーになれた、というお話です。
AIが思うようにコードを出力しない
CopilotやChatGPTにコードを書かせていると、同じ説明を何度しても意図した修正が入らないことがあります。テストが落ちている箇所を指摘しても、関係ない部分を直したり、別のバグを増やしたりする。私はこれを「LLMの限界」だと思っていました。
しかし設計を変えたところ、ほぼ修正指示なしで実装が通るようになりました。そう、原因はプロンプトでも、ましてやAGENTS.mdでもなく、コードの書き方でした。
LLMが得意なコーディングってなんだろう?
世間にはいろんなAIエージェントがありますが、その中身はどれもLLMです。LLMは、雑に言えば「あるトークンの次に来そうなトークンを予測する関数」です。
だからこそ、「ちがう」と指示しても出力コードがなかなか変わらないのではないか。変えないのではなく、変えにくいのではないか。LLMが重視する学習元に、その修正パターンがあまり存在しないからです。
……では逆に、学習元に似たような実装がたくさん存在する形に寄せたらどうなるでしょうか。LLMもコードを出力しやすくなるのではないでしょうか。そんな特徴を持つコーディングとは何だろう、と考え始めました。
そんなことを考えていたところ、QiitaでTunaScriptを見かけました。言語構文にSQLやJSXを埋め込める、面白い取り組みです。ScalaにもXML式という似た機能がありますが、Scala 3では「文字列補完」の仕組みに整理され、XMLに限らず独自DSLを構築できるようになっています。
DSLは文字列を解析してASTを構築し、interpreterでASTを評価する形で実装します。ここで重要なのはDSLそのものではなく、「処理をオブジェクトの振る舞いとして書くのをやめ、データを評価する形に変える」という発想でした。
例:注文システム
身近な例で考えてみます。ネットスーパーの注文票を実装してみます。注文票(order)には、商品(item)と、その商品の割引率(discountRule)、税率(taxRules)があります。それらを合計した値が注文の金額です。
例えば、次のように書けます。
case class Order(items, discountRules, taxRules):
def calcTotal(): Int
とても自然な定義です。しかしAIにとっては、これは追跡が難しい構造です。
val total = order.calcTotal()
この字面だけでは、このメソッドの振る舞いによって order の中身が変わらないことが保証されません。
そこで次のように書き換えてみました。
case class Order(items, discountRules, taxRules)
object CalcTotal:
def apply(order): Int
メソッドをクラス(評価器)に分離します。呼び出しは次のようになります。
val total = CalcTotal(order)
CalcTotal は静的で、渡す order も不変です。AIから見ると、これは「オブジェクトの状態変化」ではなく「データの変換」です。結果として、実装も自然と参照透過に近い形になります。
さらに、レシートを印字する PrintReceipt 機能を追加するとします。この実装は CalcTotal とよく似た構造になります。そのため、複雑な背景を説明しなくても、LLMは CalcTotal を参考にして PrintReceipt の実装を問題なく出力してくれました。
まとめ
食わず嫌いしていたAIコーディングですが、その特徴を観察してみると、ドメインをデータとして記述するDDD的な構造と、statelessで型クラス的に振る舞いを追加するFPのスタイルがよく噛み合うと分かりました。実際、Codex Appで実装を書かせてみても、以前とは違い、詰まることなくコードを出力してくれるようになりました。
DDDやFPを採用しようとして始めたわけではなかったのですが、AIと壊れない形で協調してコードを書こうとした結果、自然とそういう設計に近づいていった、という感覚です。
AIにコードを書かせたいなら、オブジェクトの振る舞いではなく「評価可能なデータ変換」としてドメインを表現すると、生成精度が安定します。