理系学生日記

おまえはいつまで学生気分なのか

LangChain Meetup 5でJavaのソースコード解析を試み敗北した経験について発表しました

発表しました。 Langchain Meetup はめちゃくちゃ良かったです。オフライン勉強会だからこそ聞けることがある。すごくモチベーションが上がった。また参加したい。

テーマ設定

発表することを決めてからテーマを考えるといういわゆる発表駆動型になったので、まずはテーマ設定が課題でした。 チームでは様々な形でLangchainを使っているものの、僕自身はなかなかコーディングに時間を割けていない。 そんな中でも、まぁ生成AI駆動で開発が進められると良いよねというのと、ソースコードの自動生成周りには興味があったので、 その辺りを取り上げることにしました。

コード生成の課題

コード生成には既存のプロダクトを使ってもいくつか課題があると思っていますが、この発表で取り上げたのは、いわゆるライブラリやフレームワークが提供するAPIを使いこなせてないよね、というものです。生成AIにそれらのAPI知識を入れない限り、生成AIは自分が学習したライブラリ・フレームワークの力しか引き出せない1。 学習していないなら外部から知識を与える他ないのでRAGかなということになるんだけど、じゃぁそれどういう形で与えれば良いのさ、ということになる。 この辺りをまずはやってみようという話でした。

アプローチ

前提

前提として、各種エージェント実装においてこの辺りの把握をどうしているのかというと、大きく2ステップに分けられる認識です。

  1. tree-sitter等のパーサを使って、クラスやメソッドのSignatureを抽出し、ソースツリーの全体感を掴む
  2. その上で、必要なファイルを実際に読み込み、APIの中身を理解する

もちろんこれだけにとどまらず、たとえばClineはripgrepを使って正規表現で色々抽出したり、potpie.aiはクラスやメソッド定義をグラフDBに叩き込んで後で利用できるようにするなど、多層的なアプローチをとっている。

今回のアプローチ

コード生成をするときに足りないのは、わりと意味情報なんじゃないかと思ったんです。 生成AIとして「こういうことをするコードを生成したい」と思った時、そのユースケースを満たすフレームワークやコンポーネントのメソッドはどれなのかを考える上では、どういうユースケースを満たすメソッドが存在するのかという情報が必要です。このアプローチにおいては、(tree-sitterで抽出できるような)Method signatureを基点とするとうまくいきそうになく、むしろ「このユースケースはこのメソッド」という、意味情報 -> Method Signatureのマッピングがあるべきなのではないかと思ったわけで。

なので、まずはそのマッピングを作ろうというのが今回取り上げた内容でした。この辺かなり言葉足らずでしたが。

結果

業務ロジック

まずはLangchainを使って、ISUCON 12 QualifyのJavaのソースコードを解析して見ました。一部の結果を示します。 解析自体は、生成AIにmethod bodyを渡し、その実装が何をしているのかを生成AIに解読させるという方法でした。 プロンプトチューニングを全くしなかったため、要点は捉えられていないものの、まぁ解析できているかなという感じです。

Application:
  retrievePlayer:
    body: |-
      {
              try {
                  PreparedStatement ps = tenantDb.prepareStatement("SELECT * FROM player WHERE id = ?");
                  ps.setQueryTimeout(SQLITE_BUSY_TIMEOUT);
                  ps.setString(1, id);
                  ResultSet rs = ps.executeQuery();
                  if (!rs.isBeforeFirst()) {
                      return null;
                  }
                  return new PlayerRow(
                          rs.getLong("tenant_id"),
                          rs.getString("id"),
                          rs.getString("display_name"),
                          rs.getBoolean("is_disqualified"),
                          new Date(rs.getLong("created_at")),
                          new Date(rs.getLong("updated_at")));
              } catch (SQLException e) {
                  throw new RetrievePlayerException(String.format("error Select Player: id=%s, ", id), e);
              }
          }
    description: |-
      `retrievePlayer`メソッドは、指定されたプレイヤーIDに基づいてデータベースからプレイヤー情報を取得します。具体的には、以下の処理を行います:

      1. プレイヤーIDを使用して、`player`テーブルから該当するレコードを選択するSQLクエリを準備します。
      2. クエリのタイムアウトを設定します。
      3. クエリを実行し、結果セットを取得します。
      4. 結果セットが空であれば、`null`を返します。
      5. 結果が存在する場合、プレイヤーの情報(テナントID、ID、表示名、失格フラグ、作成日時、更新日時)を用いて`PlayerRow`オブジェクトを生成し、返します。
      6. SQL例外が発生した場合は、`RetrievePlayerException`をスローします。
  authorizePlayer:
    body: |-
      {
              try {
                  PlayerRow player = this.retrievePlayer(tenantDb, id);
                  if (player == null) {
                      throw new AuthorizePlayerException(HttpStatus.UNAUTHORIZED, String.format("player not found: id=%s", id));
                  }

                  if (player.getIsDisqualified()) {
                      throw new AuthorizePlayerException(HttpStatus.FORBIDDEN, String.format("player is disqualified: id=%s", id));
                  }
              } catch (RetrievePlayerException e) {
                  throw new AuthorizePlayerException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e);
              }
          }
    description: |-
      `authorizePlayer`メソッドは、指定されたIDに基づいてプレイヤーを取得し、そのプレイヤーが存在しない場合や失格状態である場合に適切な例外をスローします。具体的には、プレイヤーが見つからない場合は401 Unauthorizedエラーを、失格の場合は403 Forbiddenエラーを発生させます。また、プレイヤーの取得中にエラーが発生した場合は、500 Internal Server Errorをスローします。

フレームワーク

Nablarchのようなフレームワークについても解析してみた結果が以下。

いや、全然ダメなんですよね。業務ロジックの解析結果でも傾向はありましたが、フレームワークは顕著で、結局のところは別クラスへの処理の委譲やコンポーネント呼び出しが多くなると、それら別クラス等の情報がないと意味が抽出できないわけです。

HandlerQueueManager:
  addHandler:
    body: |-
      {
              if (handler instanceof Handler) {
                  return addHandler((Handler) handler);
              }
              if (!allowsMethodLevelDelegation()) {
                  throw new IllegalArgumentException(
                      "a handler must implement Handler interface."
                  );
              }
              return addHandler(methodBinder.bind(handler));
          }
    description: |-
      `addHandler`メソッドは、引数として渡された`handler`が`Handler`インターフェースのインスタンスである場合、その`handler`を`Handler`として追加する処理を行います。もし`handler`が`Handler`のインスタンスでない場合、かつメソッドレベルのデリゲーションが許可されていない場合には、`IllegalArgumentException`をスローします。そうでない場合は、`methodBinder`を使用して`handler`をバインドし、その結果を追加します。
  allowsMethodLevelDelegation:
    body: |-
      {
              return (methodBinder != null);
          }
    description: |-
      対象メソッド `allowsMethodLevelDelegation` は、`methodBinder` が `null` でない場合に `true` を返し、`null` の場合には `false` を返します。これは、メソッドレベルのデリゲーションが許可されているかどうかを判定するためのメソッドです。      

学び

この手の意味解析は、少なくとも1パスの解析ではうまくいかない。当たり前と言えば当たり前ですが。 特に、コンポーネント分割がうまく行われていたり、ドメイン駆動的設計がなされているなどした場合、この傾向はより顕著になると思います。

この問題に立ち向かうためには、個々のクラスの責務が何かを解析するパス、そのクラスの持つメソッドの責務が何かを解析するパス、全体的なアプリケーション実装を理解させるパスというような複数パスでの解析が必要になりそうに思います。あるいは、コールグラフを作って、その上で意味解析を行うというのも一つの手かもしれません。

元々向き合った課題はコード生成でしたが、その観点で考えると、アプリケーションのコード生成を(できるだけ人間の介在なく行うためには)最初にコンポーネント分割を行わせ、その上で個々のコンポーネントを実装させないと厳しいだろうという感想です。当たり前のことを言っていますが、要するに最初にコードを生成し、その上でリファクタしながらコンポーネント分割を行うアプローチは(少なくとも今のところは)難しいということです。

LangChain

LangChainに触れると、資料にも書いたように、美しいフレームワークだと思います。その美しさが根ざしているのは、生成AIアプリケーションに必要なコンポーネントは抽象化するとこれだけの種類があるよね、というところをまとめ上げているところが大きい。こうなるとコンポーネントの組み合わせを抽象レベルで考えていけるので、とっつきにくかった生成AIが手を出しやすくなります。 たくさん実装が存在するしね。

今回メインで実装したのは、Document Loaderから呼び出されるParserだったのですが、裾野の広いLangChainコミュニティにおいては、個々の言語のParser実装もlangchain-community側に実装されています。

決して扱いやすい実装というわけではないのですが、各言語パーサを記述する上でのベースラインにはなるので、ご興味がある方は読み解いてもらえると良いかなと。こちらのParserもtree-sitterベースで解析を行なっています。


  1. この辺なんかナレッジあるんかな。