OpenAI の API を叩く AI Agent を Python で作り、Go の開発を全部 AI にやってもらいたい!

こんにちは、a2 です。

Go のアプリケーションのコードをモリモリ生成してくれる AI Agent を作ることができないかなと思い、試してみてます。タイトルに書いたようなことはまだ全然できてないのですが、今回はその第一歩を書いてみます。

背景

今、開発現場における AI 活用は、GitHub Copilot を使って、エディターで開いているファイルを元に近しいコードを生成したり、関数のコメントから実装を生成してもらう、といった使い方が主流かと思います。 私も、Go の Table Driven Test のコードを書いてもらえるのがめちゃめちゃ助かってます。

しかし、これはあくまでソフトウェアエンジニアリングにおける汎用的な AI 活用方法に過ぎず、個別具体のケースでは最適化の余地が多くあると思っています。

例えば、 GitHub Copilot が生成したコードはコンパイルエラーを含んでいたり、正しく動作しないことが多々あります。 自分で修正することも可能ですが、生成されたコードとその問題点 (error message など) を ChatGPT や GitHub Copilot Chat に打ち込めば、コードをさらに修正した結果が得られます。 コンパイルエラーは、人が何かを判断する必要はありません。機械の側で認識できます。 であれば、コードを生成した後、コンパイルできるかをチェックして、エラーがあれば再度生成し直す、というところまで出来るはずです。 動作の検証だって、テストコードがあれば、コードを生成した後にテストを実行して、エラーがあったらさらに生成し直す、までできるはずです。

つまり、以下は当然のように自動で実行できるはずなのです。

  1. 要件を入力してコードを生成する
  2. コードを検証する(コンパイル、テスト実行、アプリケーション実行)
  3. 生成結果と検証結果を踏まえて再度生成する
  4. 2 と 3 をエラーがなくなるまで繰り返す

AIで置き換えたい実装フロー

とはいえ、 AI モデルの token の制限、実装の手間を考えると、現実はやってみたいと分かりません。 一般のソフトウェアエンジニアがどこまでできるのか、OpenAI の Chat API の function call を活用しながら実現可能性を探っていきます。

function call とは

ChatGPT の API には function call (今は tool call に変えようとしている?) なるものがあります。これは、関数の名前と処理内容をプロンプトと一緒に送ると、関数を呼ぶかどうか、どの関数をどんな引数で呼ぶとよいかを判断して結果を返してくれます。function call により、 Open AI の Chat API は単なるテキスト生成エンジンを超え、プログラムの実行や、より複雑なタスクを解決するためのオーケストレーションツールとして使うことができます。

autonomous agent について

OpenAI API の function call が代表的な実装方法ですが、与えられた課題に対して検索や生成などを自分で判断した上で実行し、タスクの完了を自動で目指す動きをするソフトウェアを、autonomous agent (自律的エージェント) と呼びます。

具体的なソフトウェアとしては AgentGPT が有名です。Web 上で簡単に動かせるようになっている ので、触ってみるとどんな感じかつかみやすいと思います

この定義を踏まえると今回は、自作 autonomous agent をやってみた、に近い記事になります。

やりたいこと

Agent に簡単な TODO アプリを作ってもらうことにします。

究極的には、「TODO アプリをGo で作って」と言ったらレポジトリがまるごと出来上がるような状態を作れないかな、と妄想しています。

もちろん現実には、詳細な機能要件・非機能要件を言語化して指定する必要があったりしますが、今回は自律的に開発を進めるためのおおまかな流れが実現できることをゴールに設定しました。

開発

生成したいコードは Go のコードですが、OpenAI の API に request する agent 自体は Python で書いていきます。なお、langchain は使っていませんが、使い方を学ぶのが面倒だったのと、他の言語で実装することを考えての判断です。

function の列挙

ファイルにコードを書く、 go run を実行する、など、 呼び出し可能な関数と、その説明を用意しておきます

definitions = [
  {
    "type": "function",
    "function": {
      "name": "write_to_file",
      "description": "Overwrite a whole file with given contents. Create the file if not exist. returns the whole file contents as a result of mutation",
      "parameters": {
        "type": "object",
        "properties": {
          "filename": {
            "type": "string",
            "description": "filename to write"
          },
          "content": {
            "type": "string",
            "description": "content to write"
          }
        }
      },
    }
  },
...
  {
    "type": "function",
    "function": {
      "name": "go_run",
      "description": "Execute go run {filename}",
      "parameters": {
        "type": "object",
        "properties": {
          "filename": {
            "type": "string",
            "description": "filename to write"
          },
        },
      },
    },
  },
...
]

OpenAI の Chat API は、この json の description を見て関数の内容を把握し、必要に応じて「この関数をこの引数で呼び出せ」という response を返してきます。

用意した関数をいくつか抜粋すると、

  • ファイルの新規作成+書き込み(べき等なファイル書き込み)
  • ファイルへの追記
  • ファイルの読み込み
  • go run
  • go test
  • 任意のコマンド実行

です。"任意のコマンド実行" はさすがに怖いので、実行前に確認を強制しています。

function の実装

呼び出される関数も実装しておきます。

def write_to_file(filename: str, content: str) -> str:
  with open(filename, 'w') as f:
    f.write(content)
  return f"""finished. the contents of the file:
{read_file(filename)}"""  # read_file はファイルの中身を読む helper です。


def go_run(filename):
  if not filename.endswith(".go"):
    return "file is not go file"
  command = f"go run {filename}"
  return _run(command) # _run は コマンドを実行する helper です。

write_to_file は実行結果としてファイルの中身を返していますが、token を消費するので、なくても良いかもしれません。

function の呼び出し OpenAI の Chat API の response には呼び出すべき function と その 引数が含まれるので、対応する関数を以下のように呼び出します。

      getattr(gpt_functions, function_to_call.name)(**arguments)

(普段 Go を触っているので、メタプログラミング的な関数実行にむずがゆさがありますが、とても便利です。)

最初の指示

一番最初に与えるプロンプトは適当に考えました。より良い結果を得たい時、改良の余地が一番大きそうです。

You need to create a todo app in Go.

You need to develop this app in test driven development.

after you generate code, you write this to a file and run it.

if any error or bug is found, you need to fix it.

if you have question to me, you can ask me.

「TODO アプリを Test Driven に開発してね」ということを雑に伝えています。後述しますが、通常はここで簡単に要件を伝えるのが良さそうです。

メインロジックの実装

API 呼び出しのロジック部分は、細部を端折ってますが、以下のようになります。

import os
import json

import openai
import gpt_functions

openai.api_key = os.getenv("OPENAI_API_KEY")

client = openai.OpenAI()

first_prompt = ...

def run_app():
  message = {
    "role": "user",
    "content": first_prompt
  }

  run_conversation([message])

def run_conversation(history):
  response = client.chat.completions.create(
    messages=history,
    model="gpt-3.5-turbo-0125",
    tools=gpt_functions.definitions,
    tool_choice="auto",
  )

  assistant_message = response.choices[0].message
  history.append(assistant_message)

  if assistant_message.tool_calls and len(assistant_message.tool_calls) > 0:
    for tool_call in assistant_message.tool_calls:
      function_to_call = tool_call.function

      try:
        args = json.loads(function_to_call.arguments)
      except:
        print("ERROR: could not parse json")
        return 

      function_response = getattr(gpt_functions, function_to_call.name)(**args)

      next_message = {
        "role": "tool",
        "content": function_response,
        "tool_call_id": tool_call.id,
      }
      history.append(next_message)
    run_conversation(history)
  else: # 通常の Chat 

最初の prompt で「TODO アプリを作ってください」 と指定して、function を列挙した object (gpt_functions.definitions) と共に Chat API に request します。

response に tool_call (function call の新しい呼び名) があれば、対応する function を呼び出し、結果を history に追加しながら再帰的に Chat API に request します。 tool_call は複数帰ってくることもある(例えばファイルへの書き込みをした上で、 go run . を実行する、など)ので、loop を回すようにしています。

なお、実際に書いたコードでは、過程を追うためにログをたくさん仕込んだり、暴走を防ぐために各所で実行を継続してよいかの確認をするようにしています。

下手をすると無限にループして API 利用料がかかってしまうので注意してください

実行結果

関数実行

実際に動かすと、

  1. go mod init todo の実行
  2. go get github.com/stretchr/testify の実行
  3. todo_test.go の生成
  4. go format の実行
  5. go run の実行
  6. (chat) セットアップ終わったけど次はどうする?と聞く(←人(私)が test だけじゃなくてメインのロジックも書いて、と回答)
  7. todo_app.go の生成 (実装はコメントのみで、何もかかれていない状態)
  8. go format の実行
  9. go test の実行
  10. (chat) todo_app.go ができたよ。まだ何か必要だった教えて、と聞く(←人(私)がこのアプリは todo-item の追加と削除ができる必要があります、と要件を追加)
  11. go run の実行
  12. todo_test.go の生成
  13. todo_app.go を生成

といった操作になりました。(chat) という書いている部分は function call ではなく、chat として応答が帰って来たステップです。

どんなライブラリを使うか、どんなファイル名でファイルを作るかなどを判断して自動で動いていることがわかります。 生成されるコードの中身がコメントになったりするのは、要件を精緻に伝えれば改善されるかもしれませんが、そこまでやるなら自分で書いたほうが早いともなるので、難しいところです。

最初のプロンプトで TODO アプリの要件を一切指定していなかった(雰囲気で作ってくれると期待していた)ので、最初に生成されたアプリは中身がない空っぽのコードでした。 中身のないアプリを実装してから「できたよ」と言ってきたのは残念でしたが、要件を伝えていなかった側が悪いです。

生成されたコード 少し長いですが、以下が生成された todo_app.go です。

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {

    // Create an empty slice to store todo items
    var todoItems []string

    for {
        // Display the menu options to the user
        fmt.Println("Choose an action:")
        fmt.Println("1. Add todo item")
        fmt.Println("2. Delete todo item")

        // Read user input for the action
        fmt.Print("Enter your choice: ")
        reader := bufio.NewReader(os.Stdin)
        choice, _ := reader.ReadString('\n')

        // Remove newline character from the choice
        choice = choice[:len(choice)-1]

        // Perform the selected action
        switch choice {
        case "1":
            // Add todo item
            fmt.Print("Enter the title of the todo item: ")
            title, _ := reader.ReadString('\n')
            title = title[:len(title)-1]
            todoItems = append(todoItems, title)
        case "2":
            // Delete todo item
            if len(todoItems) == 0 {
                fmt.Println("No todo items to delete")
            } else {
                fmt.Println("Existing todo items:", todoItems)
                fmt.Print("Enter the index of the todo item to delete: ")
                var index int
                fmt.Scanf("%d", &index)
                if index >= 0 && index < len(todoItems) {
                    todoItems = append(todoItems[:index], todoItems[index+1:]...)
                } else {
                    fmt.Println("Invalid index")
                }
            }
        default:
            fmt.Println("Invalid choice. Please try again.")
        }
    }
}

todo item の List を要件で伝え損ねたので、add/delete のみしかできませんが、最低限の例外ケース含めて、うまくできています。

実行してみると、以下のように、動いています。

$ go run todo_app.go 
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: clean the room
Invalid choice. Please try again.
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 1
Enter the title of the todo item: clean the room
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 1  
Enter the title of the todo item: buy food
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 2
Existing todo items: [clean the room buy food]
Enter the index of the todo item to delete: 2
Invalid index
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 2
Existing todo items: [clean the room buy food]
Enter the index of the todo item to delete: 1
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 2
Existing todo items: [clean the room]
Enter the index of the todo item to delete: 

後から add/delete を実装するという要件を伝えた後、 todo_test.go を生成してくれましたが、中身は空っぽで放置されていました。

Test Driven の説明をする際に、"一度テストを失敗させる" ステップを伝えていなかったので、test が成功 = OK と判断した可能性は高いです。

package todo

import (
    "testing"
)

func TestAddTask(t *testing.T) {
    // implement the test
}

func TestCompleteTask(t *testing.T) {
    // implement the test
}

その後

上記のように、最初にダーッと作ったものでもそれなりに動くものが作れたので、「改善すればもっとよくなるのでは??」と期待が高まりました。 しかし、ls cat コマンド相当の function を用意したり、go のコマンドを追加したり、選択肢を増やし、最初の prompt にアプリの要件を追加したりと色々追加しても、最初より良い結果が得られませんでした。

具体的には、 go test を実行してエラーを見つけるところまではいいのですが、その修正として生成されたコードが修正前と差分がなく、無限ループしてしまったり、実装をコメントで // implement here で済ませてしまったり。モデルが扱えるコンテキストの大きさの限界であれば、最初に全て伝えるのではなく、順次実装を進めてもらう形が良いかもしれません。

他にもまだまだ工夫の余地はたくさんあり、

  • より大きいモデルを使ってみる
  • 読み込ませるファイルとコード量を制限・選択する
  • プロンプトを改善する
  • 全体の設計を調整する agent とコードを実装する agent など、複数 agent を用意する

あたりは試してみたいなと思います。

他に気になったこととして、そもそも変更の差分を示す git の commit log が gpt モデルの学習に使われているのか気になりました。もし学習されていれば、コードの状態の遷移を生成する期待ができますが、コードを snapshot として学習していた場合、完成形の状態を生成するのには向いていても、既にあるコードを改修していく方向には活かしにくいかも、、などと考えていました。

先行研究もいくつかあるので、次は論文も漁ってみたいと思います、詳しい方がいらっしゃったら要チェックの論文を教えてください。