👋

AI 時代のコードの書き方, あるいは Copilot に優しくするプロンプターになる方法

2023/06/15に公開

Copilot をオープンベータ直後から長く使っていて、また補助的に ChatGPT も使いながらコードを書いていて、なんとなくコツがわかるようになってきた。

自分は生成モデルのことは表面的な理解しかしてない。雑にバックプロパゲーションの実装の写経したり、Transformer の解説とかは読んだが、にわかの域を出ていない。

https://misreading.chat/2023/04/04/111-formal-algorithms-for-transformers/

あくまで利用者として生成モデルから吸い出したプラクティスになる。

基本的に TypeScript と Rust での経験が元になっているが、他の言語にも適用できる話ではあると思う。自分は TypeScript はかなり得意だが、 Rust はあんまり書けるわけではなく、Rust の学習で ChatGPT を頼ろうとして失敗しているというステージ。

Copilot / ChatGPT とどう付き合うか

まず、前提として ChatGPT も Copilot も、コード生成は内部で同じ Codex というモデルを使ってるので、コードの生成能力は同等と思ってよい。

https://openai.com/blog/openai-codex

で、たぶんコメントなんかの自然言語をプロンプトとして理解する部分がちょっと違う。Copilot はあくまでコード中のコメントとコードの関係しか見てなさそうな気配がする。

基本的に、ChatGPT (GPT-4) は 「いろんなことを丸暗記してるが応用力はイマイチな、だけど基本的には優秀な大学1年生」と思って話しかけてる。自分が型や命名やコメントを書くと、それを理解してコードを書いてくれる相方。型などでヒントを書いてると、自分の脳のサブプロセスとしてコードを書いてくれてる、という感覚がある。

ChatGPT に自分が理解してない疑問点を聞くのはあまりよい結果にならないことが多い。出力結果を検証する時間の方が長くなりがちで、今のエンジニアの価値は、出力された結果を検証できることにあると思う。

実際どれぐらい使ってるかというと、昔はドメイン非依存な場所でたまに使って1割ほど Copilot が生成、という塩梅だったが、今自分が書くコードはおよそ半分ぐらいが Copilot にさせた結果になっている。

Copilot が進化したわけではなく、自分が Copilot に優しくなるように寄っていった。

静的なヒントはあればあるだけよい

https://twitter.com/mizchi/status/1669022984286396416

https://twitter.com/mizchi/status/1669024968099909633

生成モデルは本質的に穴埋めソルバーなので、事前に埋まってる箇所が多ければ多いほどいい。
外壕を埋めると生成コードの品質がわかりやすく上がっていく。そのヒントとしてわかりやすく効くのが型シグネチャ、型アノテーション、型ヒントであり、動的型付けだとこのヒントを書けないがかなりのハンデキャップになってると思う。

https://twitter.com/naoya_ito/status/1669109324923428865

穴埋めソルバーである、ということから逆に考えて、実装から型を推論させることができるが、実装を書いて型を推論するか、型を書いて実装を生成するかなら、労力としては、後者の方がよいと自分は思う。

type Item<T> = {
  item: T,
  flag?: boolean
}
function findFlaggedItem<T>(items: Item<T>[]): Item<T> | undefined {
  // ここまでコードを書くとあとは勝手に提案されるので、採用するだけ
  return items.find((item) => item.flag);
}

これは関数のbodyが少量なので恩恵が少なくみえるが、少なくとも自分は関数のシグネチャを書いただけで、ロジックは一行も考えてない。

型に限らず、コメントもあればあるだけ良い。思った通りの出力がでないときは、一行ずつコメントを書いていく。例えば「配列中のすべての要素が true なら true を返す関数」が、「例外的に要素数が0個のときは false」 を表現したいときはコメントでそう書く。

function allSatisfies(items: Item<unknown>[]): boolean {
  // 0 個の場合は例外的に false を返す
  /* 以下は Copilot の提案 */
  if (items.length === 0) { return false; }
  return items.every((item) => item.flag);
}

このとき、意図せず同じファイルの Item 型を転用して filter 関数を引数として書かなかったのだが、勝手に flag を使っていた。これが正しいか挙動かどうか判断するのは、プログラマの役目。

テストコードも同様のプロンプトとして機能するはずだが、ただ現状の Copilot はテストコードから実装を吸い上げる力が弱いように感じる。たぶんテストのスペック名ぐらいしか読めていない。おそらく、実装とコードが離れていることも理由の一つなので、自分は vitest の in-source testing を使って、実装コードの隣にテストコードを置くようにしている。

// src/index.ts

// the implementation
export function add(...args: number[]) {
  return args.reduce((a, b) => a + b, 0)
}

// in-source test suites
if (import.meta.vitest) {
  const { it, expect } = import.meta.vitest
  it('add', () => {
    expect(add()).toBe(0)
    expect(add(1)).toBe(1)
    expect(add(1, 2, 3)).toBe(6)
  })
}

https://vitest.dev/guide/in-source.html

(これはビルド時に import.meta.vitest=undefined にして DCE でコードを消す前提)

これで多少の精度があがる。というか、copilot はテストから実装を生成するのは苦手だが、実装からテストケースを生成する精度はかなり高い。ややトートロジー感はあるが...

Rust も同様の in-source testing がある。

pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}

また、命名やモジュールの抽象のメタファでデザパタも大事で、なにか書いてもコードが補完されなくなってるときは、自分がモデリングに失敗していることが多い。その時はコードレビューを受けてる気持ちになって、モジュール構成の変更や、命名、コメントを書き直している。

非エンジニアとの協調のために出力形式を限定する

プログラミングは画像生成AIと比べてある種のファジーさが許されない。

だからAIの精度があがっていくとエンジニアが置き換えられるとか、置き換えられないとかの論争があると思ってるのだが、それはともかく、型と同じく外堀を埋めてメタデータを大量に付与すると精度は上がる。

ところで、自分は最近 Headless UI のアクセシビリティ情報を元にCSSを生成する検証をしていた。

// Popover.tsx
import * as Popover from '@radix-ui/react-popover';
import {Content, Trigger, Arrow} from "./App.css";

export const Popover = () => (
  <Popover.Root>
    <Popover.Trigger className={Trigger}>More info</Popover.Trigger>
    <Popover.Portal>
      <Popover.Content className={Content}>
        Some more info…
        <Popover.Arrow className={Arrow} />
      </Popover.Content>
    </Popover.Portal>
  </Popover.Root>
);

これに対して vanilla-extract のコードを出力しろ、そのサンプルはこうだ、というのを例示する。

import { style } from '@vanilla-extract/css';


export const Trigger = style({
  
});

export const Content = style({
  
});

export const Arrow = style({
  
});

これに対して、ChatGPT が生成するコードの例

import { style } from '@vanilla-extract/css';

export const Trigger = style({
  backgroundColor: '#ddd',
  padding: '10px',
  border: 'none',
  borderRadius: '4px',
  cursor: 'pointer',
  ':hover': {
    backgroundColor: '#ccc',
  }
});

export const Content = style({
  backgroundColor: '#fff',
  border: '1px solid #ddd',
  borderRadius: '4px',
  padding: '20px',
  boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
});

export const Arrow = style({
  color: '#ddd',
});

実際に実行してみた結果。

https://zenn.dev/mizchi/scraps/c212ed98ea97df

これのいいところはいくつかある

  • アクセシビリティ情報のためのコンポーネント名が、一種のプロンプトとして機能している
  • CSS なのである程度のファジーさが許容されている
  • ロジックに関与させるとプログラマが事前に書いたロジックが破壊される恐れがあるが、関与をCSSに限定しているので、その恐れがない
    • 悪意あるインジェクションを受け辛い。最近はありそうでなかったライブラリをレジストリに登録して、それを使わせることで攻撃するパターンがあると聞く

この例から考えると、ある種のAIノーコードツールの実装パターンは、次のように考えることができる。

  • プログラマがコードのテンプレートを登録
  • プログラマがそのテンプレートを書き換える構造の指定と、テンプレートを書き換えるためのプロンプトテンプレートを指定して、Few Shot としてのいくつか出力例を登録
  • 非プログラマは登録されたプロンプトからスタートして、対話的にプレビューしながら修正を重ねる
  • 出来上がった出力に対して、プログラマが事前に登録した静的な型チェックとテストコードを実行し受け入れテストをする

ここでも静的解析とテストが大事になる。

AI の性能向上、例えば一度に食えるトークン量の増加でやれることは増えていくと思うが、基本的にはその時代のAIの表現力に即した抽象度にあわせて、コードの生成パターンを絞り込むプロンプトエンジニアリングとも言える。

ここから数年はこのパターンのAIプロダクトやAIベンチャーが増えると予想している。プロンプトだけで価値になるわけではなく、何かのプロダクトがあり、その利用例としてもっとも精通した理解者としての、プロンプトエンジニアリング。

ChatGPT はドメイン知識の変換と参照に使う

ChatGPT の活用方法として、自分が自明に理解していることを、組み合わせてる方向に使うべきだと思う。

例えばこれは ChatGPT に Svelte テンプレートを React に変換させた例。

<div>
  <h1>hello</h1>
</div>

ChatGPT による変換

import React from 'react';

const MyComponent = () => {
  return (
    <div>
      <h1>hello</h1>
    </div>
  );
}

export default MyComponent;

これは簡単な例だが、自分は Svelte も React も読めるので、変換したコードが妥当であることがわかる。

もうちょっと複雑な例で、次のは Copilot で書いたコードで、 TypeScript で変数をリネームする時の RenameLocation を取り出してそれを適用するコード。

import { RenameLocation, UserPreferences } from "typescript";

// 書き換え対象が {x} の shorthand assignment の場合、 {x: ?} に書き換えないといけない
export type RenameLocationWithShorthand = RenameLocation & {
  isShorthand?: boolean;
};

export type RenameInfo = {
  original: string;
  to: string;
  locations: readonly RenameLocationWithShorthand[];
};

export type RewiredRenameItem = {
  original: string;
  to: string;
  location: RenameLocationWithShorthand;
};

// ...

function applyRewiredRenames(
  code: string,
  renames: RewiredRenameItem[],
): [renamed: string, changedStart: number, changedEnd: number] {
  let current = code;
  let offset = 0;
  let changedStart = 0;
  let changedEnd = 0;
    /* ここから全部 AIが生成 */
  for (const rename of renames) {
    const loc = rename.location;
    const toName = rename.location.isShorthand
      ? `${rename.original}: ${rename.to}` // ここだけ自分で修正した
      : rename.to;
    const start = loc.textSpan.start;
    const end = loc.textSpan.start + loc.textSpan.length;
    if (changedStart === 0 || changedStart > start) {
      changedStart = start;
    }
    if (changedEnd === 0 || changedEnd < end) {
      changedEnd = end;
    }
    current = current.slice(0, start + offset) + toName +
      current.slice(end + offset);
    offset += toName.length - (end - start);
  }
  /* ここまでAIが書いた */
  return [current, changedStart, changedEnd];
}

元々コンパイラが返してくる情報がリネーム用にはちょっと足りないので型を拡張子、複数の変更をバッチで適用したかったので RenameInfo, RewiredRenameItem という変形を行い(そこはこのコードにはない)、それを元にソート済みの RewiredRenameItem[] を文字列に与えて適用する。

自分の感覚だと、 Copilot は文字列操作に強い。トークナイザーとかパーサーとか書かせるとスラスラ書く。たぶんドメイン非依存で、GitHub 上にサンプルがたくさんあるからだと思う。

たぶんこれは過去に誰かが書いたな、と想像する力が大事で、過去の人類が書いたコード、それを学習した ChatGPT の認識モデルを類推できると、精度が高い領域のホットスポットが踏みやすい。

逆に、ドメイン固有のものは補完精度が厳しく, TypeScirpt コンパイラのAPIを使うコードは、過去に書いた人間が少ないからか、まともなコードが出てこない。

そのドメイン不足を補うためのハックとして、自分は VSCode の OpenFiles がプロンプトに使われてそうなのを利用して、 typescript.d.ts の型定義ファイルをピン止めしてコードを書いてる。

https://twitter.com/mizchi/status/1658045044811792394

今後のコード生成の進化

今はまだツール同士が連携が足りないと感じている。例えば、コード生成と静的解析や自動テストを組み合わせて、その実行結果で段階的にコードを修正するのは、今のテクノロジーでも実装可能だが、わかりやすいプロダクトとして落ちてきてはいない。

(現状でも LangChain でそれができるのは知ってるが、正直筋がいい技術に見えないので、様子見している。自分でモデルをコード生成のモジュールとして組み合わせて、普通の制御構文で書いたほうが絶対にいい)

また、推論が強すぎる言語は Copilot にとってのヒントが少ないので補完しづらく、しかしコンパイラフロントエンドの型検査結果の表現が豊かなので、それを吸い上げると精度が上がるのでは? という話を Twitter でした。

https://twitter.com/mizchi/status/1669027186416963584

https://twitter.com/cm_ayf/status/1669030796932579328

https://twitter.com/cm_ayf/status/1669031461742346242

https://twitter.com/cm_ayf/status/1669033469215920128

Haskell だけではなく、おそらく Rust もその傾向がある。ワンショットではあんまりいいコードが出ない。たぶん、「妥当なコード」の範囲が、自分が普段使っている TypeScript と比べてずっと狭い。

だから、 Copilot は補完結果を自分でコンパイラに渡して、その検査結果を吸い上げてほしいし、今後はそうなっていくだろうと思う。

https://twitter.com/mizchi/status/1668862488102998021

たぶん、テストコードをうまく読んでくれないのも、テスト実行結果のアサーションのログを Copilot が直接読めば解決する問題だと思っている。(ところで、Copilot は同時に開いている Terminal のバッファを見ているという噂があったような気がするが、思い出せない)

AI に向いてる言語と、コンパイラと対話するAIの未来

これは個人の意見だが、今の AIコード生成に向いてる言語は TypeScript / Scala / C# あたりの抽象度だと思う。

型システムが強力になって非人間的な数理モデルが強くなって、また推論しすぎて型シグネチャのヒントが減り、 C や Rust みたいにシステムプログラミングに近づくと、非人間的なコンピュータのデータ抽象が強くなる。推論が強い言語にとっては悲しいことに、明示的に型を書くほど精度が上がる。

逆に言えば、Compiler の Language Service が AI と対話するための、AIに優しいエラー出力を持つようになれば、その言語を学習したサンプル数が足りなくても高速に修正イテレーションを回して高品質なコードを生成できるだろう。なんならエラーのための Embedding Vector なんかを内部で持つようになっててもおかしくない。

また、今のCopilotの出力の癖で、括弧の対応をとるのが苦手で、haskell や python のオフサイドルールのほうが書きやすいかもしれない?という気持ちがある。

今が最適解だという感覚はなく、これから既存言語がAI向きに進化したり、AI特化言語が生まれてくるだろう、という予想をしている。

Discussion