🤖

見せてもらおうか、生成AIの性能とやらを(Claude 3.5 Sonnet + Cursor編)

2024/08/04に公開

概要

  • 現在の生成AI技術を用いて、コード生成がどの程度実用的なのかを検証してみました
  • 簡単な問題を生成AIが精度高く解くことができるのは周知の事実なので、少し複雑な問題を題材として検証をしました
    • Claude 3.5 Sonnetを用いたコード生成
    • Cursor + Claude 3.5 Sonnetによるテスト駆動開発(TDD)

なお、TDDで実装したサンプルコードは筆者のGitHubリポジトリに格納してあります。

題材

以下の記事で作成したJavaScriptの関数を、生成AIを使って実装してみたいと思います。
JavaScriptのタグ付きテンプレートリテラルを理解する

以下のコードは、テスティング・フレームワークのJestを使って記述した、パラメーター化テストのサンプルです。複数のテストケースを表形式の文字列で記述すると、テストデータを表すオブジェクトが引数としてテスト関数(アロー関数で定義した部分)に渡されて、テストケースの数だけ関数が繰り返し実行されます。

describe('Story', () => {
    test.each`
        point | asignee  | expected | desc
         ${3} | ${null}  | ${false} | ${'未アサインは開始不可'}
         ${0} | ${'山田'} | ${false} | ${'ポイントを振ってない場合は開始不可'}
         ${0} | ${null}  | ${false} | ${'ポイントもアサインも入ってない場合は開始不可'}
         ${3} | ${'山田'} | ${true}  | ${'ポイントもアサインも入っている場合は開始可'}
    `("開始可能か: $desc", ({point, asignee, expected}) => {
        // Arrange
        const sut = aStory({point, asignee});
        // Act
        const canBeStarted = sut.canBeStarted;
        // Assert
        expect(canBeStarted).toBe(expected);
    });
});

このDSL(ドメイン固有言語)は、JavaScriptの標準仕様にあるタグ付きテンプレートリテラルを用いて実装されています。Jestのeach関数を模して、元記事で筆者が作成したが以下のサンプルコードです。

function each(fragments, ...values) {
    const attrs = fragments[0].split('|').map(s => s.trim());
    const numOfAttrs = attrs.length;
    const array = [];
    let index = 0;
    while (index < values.length) {
        const row = values.slice(index, index + numOfAttrs);
        const obj = attrs.reduce((o, attr, i) => {
            return {...o, [attr]: row[i]};
        }, {});
        array.push(obj);
        index += numOfAttrs;
    }
    return (callback) => {
        array.forEach((e) => callback(e));
    };
}

このコードの実装内容を理解しなくても以降の内容は問題なく読み進めることができますが、
興味がある方は元記事の方をご覧ください。
この記事では、上記のeach関数を生成AIの力を借りて実装してみたいと思います。

Claude 3.5 Sonnet

OpenAI社のGPT-4oの性能を上回ったと言われる、Anthropic社のClaude 3.5 Sonnetを試してみましょう。
※Proプランを使用

なお、これ以降の内容は実際に試した手順を、一切編集することなくノーカットでお届けしています。

関数の説明

まずは元の関数を見せて説明させてみます。

回答は以下のようになりました。

素晴らしい、完璧です。
人間が初見でこのコードを渡されても、ここまでの説明ができる人はほとんどいないでしょう。

関数の実装

次に、チャットを新しく開き直して、今度は実装をさせてみます。
以下のテストコードをパスするような実装案を提示してもらいます。

describe("each関数のテスト", () => {
  test("データ行の数だけコールバックが呼ばれ、引数にはそれぞれのデータが渡される", () => {
    const mockCallback = jest.fn();
    each`
        name | age | isAdmin
        ${"Alice"} | ${25} | ${true}
        ${"Bob"} | ${30} | ${false}
        ${"Charlie"} | ${35} | ${true}
    `(mockCallback);

    expect(mockCallback).toHaveBeenCalledTimes(3);
    expect(mockCallback).toHaveBeenNthCalledWith(1, {
      name: "Alice",
      age: 25,
      isAdmin: true,
    });
    expect(mockCallback).toHaveBeenNthCalledWith(2, {
      name: "Bob",
      age: 30,
      isAdmin: false,
    });
    expect(mockCallback).toHaveBeenNthCalledWith(3, {
      name: "Charlie",
      age: 35,
      isAdmin: true,
    });
  });

  test("入力が空の場合", () => {
    const mockCallback = jest.fn();
    each``(mockCallback);

    expect(mockCallback).not.toHaveBeenCalled();
  });
});

回答は以下です。

Claudeが提示した実装を、手元の環境にコピペしてテストを実行すると失敗してしまいました。

テスト結果を貼り付けて改善してもらいます。

この実装も正しくなく、テストをパスしないので、少しヒントを出してあげます。

改善案です。

テストが通りました。

ただし、実装がわかりづらいので説明させます。


なるほど、実装内容の理解はできました。
が、可読性が高いとはお世辞にも言えないですね。

Reactなど一部の言語、ライブラリの場合はArtifactsという機能(ベータ版)が立ち上がって、インタラクティブにリファクタリングを進めていくこともできるのですが、JavaScriptではAtrtifacts機能は立ち上がりませんでした。なのでリファクタリングは諦めてここまでとします。

Lessons Learned

  • 単純な実装や、学習モデルに含まれているであろう典型的な実装については高精度でコード生成が可能だが、複雑な実装の場合、一発で正しく実装できない場合がある
  • エラーやヒントを提示して改善させることができる
  • 現時点の技術ではやはりCopilot(副操縦士)に過ぎず、直ちにプログラマーを置き換えるものではない

Cursor + Cloude 3.5 Sonnet

次にAIコードエディタのCursorを使ってみます。CursorはVS Codeからフォークして作られたプロダクトなので、VS Codeと同じ操作感で使用することができます。
アカウントを作成すると14日間はProプランをトライアル利用することができるので、今回はそれで試しました。
利用するLLMは選択可能ですが、引き続きCloude 3.5 Sonnetを利用します。

テスト駆動開発

同じモデルを利用するため、テストコードを示して一発で実装させると、先ほどと同じような結果になってしまいそうです。なので、テスト駆動開発(TDD)の手法に則って、少しずつ実装を進めてみましょう。

TDDで段階的に設計・実装を進めるために、テストケースを一つ追加し、テストデータの属性が1つのケースを設けました。
人間が行うTDDでは、一番シンプルなテストケースから始めて実装とリファクタリングが終わってから次のテストケースを記述します。今回は生成AIを用いるため、テストコードの完成形がコンテキスト情報にあった方が精度が上がるのではないかという推察から、先にテストケース一式を準備しています。これから取り掛かるテストケース以外には skip を付けてテスト実行対象から外しています。

const { each } = require("../src/each");

describe("each関数のテスト", () => {
  test("入力が空の場合", () => {
    const mockCallback = jest.fn();
    each``(mockCallback);

    expect(mockCallback).not.toHaveBeenCalled();
  });

    // ★ 追加したケース
  test.skip("データ行の数だけコールバックが呼ばれ、引数にはそれぞれのデータが渡される(属性1つ)", () => {
    const mockCallback = jest.fn();
    each`
        name
        ${"Alice"}
        ${"Bob"}
        ${"Charlie"}
    `(mockCallback);

    expect(mockCallback).toHaveBeenCalledTimes(3);
    expect(mockCallback).toHaveBeenNthCalledWith(1, {
      name: "Alice",
    });
    expect(mockCallback).toHaveBeenNthCalledWith(2, {
      name: "Bob",
    });
    expect(mockCallback).toHaveBeenNthCalledWith(3, {
      name: "Charlie",
    });
  });

  test.skip("データ行の数だけコールバックが呼ばれ、引数にはそれぞれのデータが渡される", () => {
    const mockCallback = jest.fn();
    each`
        name | age | isAdmin
        ${"Alice"} | ${25} | ${true}
        ${"Bob"} | ${30} | ${false}
        ${"Charlie"} | ${35} | ${true}
    `(mockCallback);

    expect(mockCallback).toHaveBeenCalledTimes(3);
    expect(mockCallback).toHaveBeenNthCalledWith(1, {
      name: "Alice",
      age: 25,
      isAdmin: true,
    });
    expect(mockCallback).toHaveBeenNthCalledWith(2, {
      name: "Bob",
      age: 30,
      isAdmin: false,
    });
    expect(mockCallback).toHaveBeenNthCalledWith(3, {
      name: "Charlie",
      age: 35,
      isAdmin: true,
    });
  });
});

CursorのChatウィンドウで指示を始めます。

良さそうです。「Apply」をクリックすると対象ファイル(each.js)への反映が提案されるので受け入れます。

テストもパスしたので、次のテストケースに進みます。skipを外しました。

  test("データ行の数だけコールバックが呼ばれ、引数にはそれぞれのデータが渡される(属性1つ)", () => {
    const mockCallback = jest.fn();
    each`
        name
        ${"Alice"}
        ${"Bob"}
        ${"Charlie"}
    `(mockCallback);

    expect(mockCallback).toHaveBeenCalledTimes(3);
    expect(mockCallback).toHaveBeenNthCalledWith(1, {
      name: "Alice",
    });
    expect(mockCallback).toHaveBeenNthCalledWith(2, {
      name: "Bob",
    });
    expect(mockCallback).toHaveBeenNthCalledWith(3, {
      name: "Charlie",
    });
  });
});

次の指示を出します。

提案された実装を適用し、テストを実行するとすんなりパスしました。
実装内容を解説してもらいましょう。


最後のテストケースに進みます。

  test("データ行の数だけコールバックが呼ばれ、引数にはそれぞれのデータが渡される", () => {
    const mockCallback = jest.fn();
    each`
        name | age | isAdmin
        ${"Alice"} | ${25} | ${true}
        ${"Bob"} | ${30} | ${false}
        ${"Charlie"} | ${35} | ${true}
    `(mockCallback);

    expect(mockCallback).toHaveBeenCalledTimes(3);
    expect(mockCallback).toHaveBeenNthCalledWith(1, {
      name: "Alice",
      age: 25,
      isAdmin: true,
    });
    expect(mockCallback).toHaveBeenNthCalledWith(2, {
      name: "Bob",
      age: 30,
      isAdmin: false,
    });
    expect(mockCallback).toHaveBeenNthCalledWith(3, {
      name: "Charlie",
      age: 35,
      isAdmin: true,
    });
  });

指示を出します。効果があるかはわかりませんが、ちょくちょくお礼を言ったり褒めたりしてあげます。


無事テストが通りました!

最後にリファクタリングをしてみましょう。


Extract Methodなどのリファクタリングテクニックを適用してくれました。
今回の関数であれば元の実装で十分かなという印象ですが、リファクタリング後のコードもちゃんとテストをパスしました。

考察・まとめ

比較

生成AIが実装したソースコードを比較してみましょう。
まずはClaudeのチャットで実装したコードです。

function each(strings, ...values) {
  return (callback) => {
    if (strings.length <= 1) return; // 空の入力の場合は何もしない

    // すべての文字列を結合して完全なテンプレートを再構築
    let fullTemplate = strings[0];
    for (let i = 1; i < strings.length; i++) {
      fullTemplate += `\${${i-1}}` + strings[i];
    }

    const lines = fullTemplate.trim().split('\n');
    const headers = lines[0].split('|').map(h => h.trim());

    for (let i = 1; i < lines.length; i++) {
      const line = lines[i].trim();
      if (line === '') continue; // 空行をスキップ

      const rowData = {};
      const cells = line.split('|').map(cell => cell.trim());

      headers.forEach((header, index) => {
        const placeholder = `\${${(i-1)*headers.length + index}}`;
        const value = cells[index].includes(placeholder) ? values[(i-1)*headers.length + index] : cells[index];
        rowData[header] = value;
      });

      callback(rowData);
    }
  };
}

次に、Cursorを使ってTDDで実装したコードです(最後のリファクタリング適用前)。

function each(strings, ...values) {
  return (callback) => {
    // 入力が空の場合は何もしない
    if (strings.length <= 1 && values.length === 0) {
      return;
    }

    // ヘッダー行を取得し、トリムして分割
    const headers = strings[0].trim().split(/\s*\|\s*/);

    // データ行を処理
    for (let i = 0; i < values.length; i += headers.length) {
      const data = {};
      for (let j = 0; j < headers.length; j++) {
        data[headers[j]] = values[i + j];
      }
      callback(data);
    }
  };
}

どうでしょうか?
前者は駆け出しエンジニアが頑張って書いたコードという印象です。
後者の方が良いコードであるのは一目瞭然でしょう。
同じLLMモデル(Claude 3.5 Sonnet)を用いているのに、こんなに結果が変わるのは興味深いです。

参考として元記事で私が書いたコードを再掲します。

function each(fragments, ...values) {
    const attrs = fragments[0].split('|').map(s => s.trim());
    const numOfAttrs = attrs.length;
    const array = [];
    let index = 0;
    while (index < values.length) {
        const row = values.slice(index, index + numOfAttrs);
        const obj = attrs.reduce((o, attr, i) => {
            return {...o, [attr]: row[i]};
        }, {});
        array.push(obj);
        index += numOfAttrs;
    }
    return (callback) => {
        array.forEach((e) => callback(e));
    };
}

やっている処理の内容は大差ないのですが、少しトリッキーというかテクニックを多用しているので、初見だとわかりづらいかもしれません。

追加検証

以下の仮定についても検証してみました。

人間だけで行うTDDでは、一番シンプルなテストケースから始めて実装とリファクタリングが終わってから次のテストケースを記述します。今回は生成AIを用いるため、テストコードの完成形がコンテキスト情報にあった方が精度が上がるのではないかという推察から、先にテストケース一式を準備しています。これから取り掛かるテストケース以外には skip を付けてテスト実行対象から外しています。

テストケースの完成形(テストケース一式)を先に示すのでなく、一個ずつ順番に提示してテスト駆動開発を行ってみました。
結果としては、すんなりとは行かず、途中でテストが失敗することもあり、試行錯誤しながら実装を進めました。ただし修正は全てAIにやらせて私自身がコードを書くことなく完成まで持っていくことができました。

面白かったは、原因特定と修正のために、デバッグログ出力の埋め込みと結果提示をAIが求めてきたことです。これはもう人間とペアプロをやってる感覚です。

最終的な実装(以下)が、また別の形となったことも興味深いです。

function each(strings, ...values) {
  return (callback) => {
    // 入力が空の場合は何もしない
    if (strings.length === 1 && strings[0].trim() === "") {
      return;
    }

    // ヘッダー(属性名)を取得し、区切り文字を除去
    const headers = strings[0]
      .trim()
      .split("|")
      .map((h) => h.trim())
      .filter((h) => h !== "");

    // データ行を処理
    const rowCount = values.length / headers.length;
    for (let i = 0; i < rowCount; i++) {
      const rowData = {};
      headers.forEach((header, index) => {
        rowData[header] = values[i * headers.length + index];
      });
      callback(rowData);
    }
  };
}

まとめ

コード生成AIは、仕組みとしては大規模言語モデル(LMM)を活用したものなので、ChatGPTなどと変わりません。学習済みのモデルを用いて、これまでの入力やコンテキスト情報をもとに、確率的に尤もらしい文章やコードを完成させようとします。

このことは、生成AIの回答精度を高めるためのプロンプトエンジニアリングのテクニックがコード生成AIにおいても適用できる可能性を示唆します。
プロンプトエンジニアリングの基本テクニックの一つとして、難しい問題は分割して段階的に考えさせるというものがあります。本記事の後半でCursorを使って行ったテスト駆動開発の手法は、まさにそれにあたります。結果として生成されるコードが実際によりすぐれていることも確認できました。

また、生成AIのモデルの性能は大事ですが、それだけでなくエージェントが実現する開発者体験の良さというもの非常に重要で、開発生産性の向上のキーとなるでしょう。
筆者は業務ではGitHub CopilotのBusinessプランを利用していますが、執筆時点においてはCursorと比べるとはっきり言って雲泥の差です。

ただし生成AIや周辺技術の進化のスピードには目を見張るものがありますので、これらのツールが切磋琢磨して発展していくことを期待しますし、我々開発者もしっかりアンテナを張って遅れを取らないようにしたいものです。

Discussion