Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Active Agent gemでRailsに適したAI機能の設計を考察する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルや見出しは内容に即したものにしました。

Rails: Active Agent gemでRailsに適したAI機能の設計を考察する(翻訳)

はじめに

変化の激しい今の世の中では、ありとあらゆる場所にAIが浸透しています。フレームワークやエコシステムも、急速に進化するビジネス上の要求に迅速に対応しなければなりません。今や誰もがアプリに高度な機能を求める時代です。「何らかのフレームワーク」で構築されたプロジェクトにAIドリブンの機能を手軽に追加できるようになれば、その「何らかのフレームワーク」が選ばれる可能性も高まります。ここではその「何らかのフレームワーク」をRuby on Railsであると仮定して、RailsエコシステムにおけるAI機能の利用しやすさを探ってみましょう。

AI革命の火ぶたが切られると、たちまちRubyやRails界隈でもAIエコシステムが立ち上がり、ruby-openaiのような非公式のLLMプロバイダSDKから、RaixRubyLLMなどの専用ライブラリへと進化を遂げました。

alexrudall/ruby-openai - GitHub

OlympiaAI/raix - GitHub

RubyアプリケーションにLLM(Large Language Model: 大規模言語モデル)を提供する方法はそれぞれ異なりますが、私の目には、どれもAI向けの抽象化としてはRailsらしさを感じられませんでした。

私は、Railsの既約と設計原則に従うことで、慣れ親しんだ開発エクスペリエンスをもたらしてくれる、そんなRailsの抽象化が好きです。RaixもRubyLLMも、AIとのチャット機能向けの共通インターフェイスを提供することに特化しています(かつその点は実によくできています)が、それ以上のことは行っていません(階層化アーキテクチャの観点で言えば、これらのライブラリは主にインフラストラクチャ層をカバーしています)。

そういうわけで、友人のJustinからActive Agentの話を聞いて、たちまち夢中になりました。本当に偶然なのですが、ちょうど私の近著である『"Layered Design for Ruby on Rails Applications"』の第2版のアイデアを温めていたところで、Active Agentの背後にある概念は、まさに私が第2版に新しく追加する章として構想していたものにぴったりだったのです。そういうわけで、私はEvil Martiansでいち早くActive Agentのアーリーアダプタとなって積極的に活用することを決意しました。

Active Agentは、RailsネイティブのAIライブラリにふさわしいでしょうか?本記事では実際の利用例を元にこの疑問に答え、RailsにおけるAIの将来像がどのようなものかを探っていくことにします。

免責事項

本記事は、回答よりも問題を多く提示しているものであり(もちろん私たちなりの回答もありますが)、現在のRailsエコシステムと特定のツールの現状を反映するとともに、RailsネイティブのAI抽象化が将来どのようなものになるかという議論の呼び水として捉えていただきたく思います。この議論は、今後コミュニティとともにActive Agent 1.0を育てていくための道を切り開くことを目指しています。

🔗 Active Agentとは

activeagents/activeagent - GitHub

Active Agent gemは、Railsに「エージェント(agent)」という新しい抽象化を導入します(何と!)。ここで言うエージェントは、AIベースのロジックをカプセル化して、開発者が慣れ親しんだRailsのパターンを用いてRailsフレームワークの他の部分と連携させるためのものです。
このパターンには、アクションドリブンなオブジェクト(コントローラやチャネルやメーラーなど)、コールバック(当然!)、そしてAction Viewベースのプロンプト表示が含まれます。コントローラはHTTPリクエストをレスポンスに変換する責務を負い、メーラーはメールの作成や送信を担当しますが、エージェントの主な目的は、あらゆる種類のAI生成をトリガーすることです。

「早くコードを見せて欲しい」と言われそうなので、おしゃべりはこのぐらいにして、Active Agentで実際にAI版「Hello world」、つまり「何か話をしてくれる」エージェントを実装してみることにしましょう。ただし今回は「ジョークを話す」エージェントにします。

以下はJokeAgentクラスのコードです。

class JokerAgent < ApplicationAgent
  after_generation :notify_user

  def dad_joke(topic = "beer")
    prompt(body: "Generate a dad joke for the topic: #{topic}")
  end

  def nerd_joke(topic = "Apple keynote")
    prompt(body: "Generate a nerd joke for the topic: #{topic}")
  end

  private

  def notify_user
    return unless params[:user]

    UserMailer.with(user: params[:user]).joke_generated(response.message.content).deliver_later
  end
end

このコードはRails開発者にとって非常に馴染み深く使いやすいので、細かな機能の説明はほとんど不要でしょう。
次はこのコードの使い方です。

result = JokerAgent.nerd_joke("Ruby on Rails").generate_now
puts result.message.content

#=> Why do Ruby on Rails developers always carry a pencil?
#=> Because they want to draw their routes!

user = User.find_by(email: "palkan@evl.ms")
JokerAgent.with(user:).dad_joke("Ruby on Rails").generate_later

# Now, in my Imbox:

# Congrats! A new dad joke has landed:
#
# Why did the developer break up with Ruby on Rails?
# Because they just couldn't handle the "active" relationship!

上のコードでわかるように2つのモードが利用可能です。
生成結果は、即座に(同期的に)得ることも、バックグラウンドジョブに回すことも可能です(ただし、ここでは結果を得るためにコールバックが必要です)。
promptオブジェクトをLLMリクエスト表現として使ってみると、Action Mailerでお馴染みのdeliver_*generate_*に置き換えたものによく似ていることがわかります。後述しますが、この「メーラーらしさ」には欠点もあります。

実際の例に移る前に、上のコードスニペットでは見えないActive Agentのもう1つの機能を明らかにしたいと思います。

上のエージェントコードでは非常に基本的なプロンプトが使われていますが、現実世界では、ジョークに制約を加えて法律から逸脱しないようにするためのガードレールなど、さまざまな指示を十数行ほど加えることになるでしょう。

Active Agentは、こうした指示を改善するためにエージェントクラスのコードを改変する必要はありません。そうしたプロンプトは、以下のように別のテンプレートファイルに記述するだけで済みます。

<!-- app/agents/joker_agent/instructions.text-->
You are an AI joke generator. Your task is to create short, clever jokes that are appropriate and entertaining.

GENERAL GUIDELINES:
- Keep jokes concise and punchy (typically 1-2 sentences)
- Focus on wordplay, puns, unexpected twists, or clever observations
- Ensure jokes are family-friendly and appropriate for all audiences
- ... and so on

このようにセットアップするには少々設定ファイルが必要なので、エージェント向けのベースクラスが用意されています。

class ApplicationAgent < ActiveAgent::Base
  generate_with :gpt

  # システム全体の指示は<agent>/instructions.<format>テンプレートに配置する
  default instructions: {template: :instructions}

  # プロンプトのビューをエージェントの隣に配置する
  prepend_view_path Rails.root.join("app/agents")

  # 単なるショートカット
  delegate :response, to: :generation_provider
end

同様に、アクションごとに異なるプロンプトをテンプレートファイル内に保存できます(joker_agent/dad_joke.text.erbなど)。
Active Agentは内部でAction Viewを使っているので、パーシャルやフォーマットなどの従来のビュー層の機能をすべて利用できます。JbuilderでJSONフォーマットのプロンプトを作成できるところを想像してみてください!

まとめると、エージェントは特定のビジネスドメイン向けにAI生成リクエストを準備・実行する役目を担います(そのため、複数の生成アクションが同じ指示セットや、おそらくコールバックなどの周辺ロジックを共有することも可能です)。

それでは、Active Agentをどのように実地でテストしたかについてお話しいたします。

🔗 バトル1: Twitter風のオンデマンド翻訳機能

Active Agentの発表後、初めて実地に試す機会を得ました。Evil Martians社内の火星人チーム編成プロジェクト(近々redprintでCFP化予定)を手掛けたときに、会話内で投稿への返信を階層表示するという典型的な機能に携わっていました。

私たちのチームにはさまざまな言語を話す人たちがいるので、チームを「温かで心地よい」ムードに保つために、誰でも自分の気持ちを母語で表現し、他の人の考えを自分の母語で理解できるようにしたいと思っていました。そこで翻訳機能が必要となり、オンデマンド翻訳方式を採用することにしました(Twitterの翻訳ボタンのような機能をRailsアプリケーションで実現したような感じで、ユーザーが入力したあらゆるコンテンツをその場で翻訳できます)。

以下が翻訳エージェントのコードです。

class TranslateAgent < ApplicationAgent
  after_generation :update_record

  def translate(content, locale)
    @locale = locale
    @content = content

    prompt
  end

  private

  def update_record
    return unless params[:record]

    record = params[:record]
    result = response.message.content

    # 翻訳の結果は`<原文ロケール>-><訳文ロケール>: <content>`形式で表示される
    # (このときは階層化出力をサポートしていなかった)
    _, original_locale, locale, content = result.split(/^(\w{2})->(\w{2}):\s*/)

    return unless original_locale.present? && locale.present? && content.present?

    record.translations << Translation.new(locale:, content:) unless locale == original_locale
    record.original_locale = original_locale
    record.save!
  end
end

プロンプトは本質と無関係なので省略します。ここではソフトウェアの設計について議論しているので、上のコードがどのように使われているかに注目することが肝心です。

今回の場合、このコードはコントローラから直接呼び出しました。以下はシンプルなコントローラのコードです。

class TranslationsController < ApplicationController
  def create
    comment = Comment.find(params[:id])
    unless comment.translated_to?(params[:locale])
      TranslateAgent.with(record: comment)
        .translate(comment.body, params[:locale]).generate_now
    end

    render json: comment
  end
end

さて、上のサンプルコードは、アーキテクチャに関する興味深い問いかけを私たちに投げかけています。

エージェントは、データベースの更新などを含むあらゆる操作をカプセル化すべきか、それとも他の抽象化で処理できるように構造化出力だけを生成すべきか?

言い換えると、この#update_recordメソッドは、アーキテクチャ的に見て「コードの匂い」がするでしょうか?「関心の分離」のような設計上の優れた原則は守られているでしょうか?

私の答えは、「たぶんそう」とも「そうでもない」の両方です。

第1に、#update_recordメソッドは、操作全体に間接性と暗黙性を導入しています。このメソッドの呼び出し側は、このメソッドを呼び出すとレコードの状態がエージェントによって更新されるという副作用が生じることを認識しておかなければなりません。しかし、#generate_nowというメソッド名では、そのことが開発者にうまく伝わりません。

第2に、エージェントはターゲットモデルの実装(つまり実際の訳文がどのように保存されるか)についての情報を持ちすぎています。

第2の問題については、以下のようにコードを少し再編成して、モデルレベルの知識はモデルの抽象化層(concern)に置くことで、問題を最小化できます。

# app/agents/translate_agent.rb

 return unless original_locale.present? && locale.present? && content.present?

-record.translations << Translation.new(locale:, content:) unless locale == original_locale
-record.original_locale = original_locale
-record.save!
+record.translated!(original_locale, locale, content)
# app/controllers/translations_controller.rb
 def create
   comment = Comment.find(params[:id])
-  unless comment.translated_to?(params[:locale])
-    TranslateAgent.with(record: comment)
-      .translate(comment.body, params[:locale]).generate_now
-  end
+  comment.generate_translation!(params[:locale])
# app/models/concerns/localizable.rb
+ def generate_translation!(locale)
+   return if translated_into?(locale)
+
+   TranslateAgent.with(record: self).translate(translatable_content.to_s, locale).generate_now
+ end

+ def translated!(from, to, content)
+   self.original_locale = from
+   translations << Translation.new(locale: to, content:) unless original_locale == to
+   save!
+ end

しかしまだ、モデルの更新がエージェントのコールバックでトリガーされています。#translated!呼び出しを#generate_translation!メソッドの中に移動するという設計はどうでしょうか?

第1に、レスポンスの解析はエージェント内にとどめておきたい(し、そうしなければならない)という点があります。モデルは、リクエストで受け取ったデータに対してAIが実際にどうレスポンスを返すかについて、関心を持つべきではありません。
#generate_nowは生のレスポンスだけを返します。

第2に、このサンプルコードを別の観点から考えてみましょう。
LLMは、Webリクエスト内で同期的に呼び出されます。今回のケースではアプリケーションの負荷が小さいので、RubyとFalconを非同期で使ってもまったく問題ありません。

しかし一般に、実行に時間のかかるHTTPリクエストはリクエスト-レスポンスループの外で行われるべきです。この方法を採用する場合に必要なのは、#generate_now#generate_laterに置き換えることと、おそらく、新たに生成された訳文はActionCable.server.broadcast(...)呼び出しをエージェントのコールバックに追加する形でクライアントに通知することだけです。

私たちのアプリケーションでは、元のコンテンツが更新されたときに翻訳を再生成するために、以下のようにバックグラウンド生成モードを使いました。

def regenerate_translations
  return if translations.empty?

  translations.each do
    TranslateAgent.with(record: self)
      .translate(translatable_content.to_s, it.locale)
      .generate_later
  end
end

エージェントクラスから永続化ロジックを切り出すには、「カスタムジョブクラスの導入」「生成結果を解析するメソッド」、そしておそらく、(モデルクラスが周辺ロジックで汚染されないようにするため)何らかのService Objectかconcernを導入する必要があるでしょう。

果たして、ここまで複雑にする価値はあるのでしょうか?私は疑わしいと思っています。現在の実装は、私には十分良い妥協点のように見えます。唯一実現して欲しいのは、「生成後のロジックをコールバックではなく、よりよい場所に配置できること」と、「後処理後の結果を返す機能」だけです。

🔗 「テスト可能性」が鍵

メンテナンスしやすいコードが備えている重要な特性のひとつが、テスト可能性(testability)です。Active Agentでは、オブジェクト自身のテストの書きやすさやメンテナンスのしやすさに加えて、そのオブジェクトと相互作用するオブジェクトのテストの書きやすさやメンテナンスのしやすさは、どのぐらい優れているでしょうか?

この問いかけは、たとえば先ほどのTranslateAgentでは次のように言い換えられます。「独自のロジック(LLMレスポンスの解析やレコードの更新)はどのようにテストすればよいのか?」「TranslationsController#createアクションはどのようにテストすればよいのか?」

Active Agentには、すぐ使えるテストツールが何も用意されていません。もちろん、LLM APIへのHTTPリクエストをWebmockやVCRでスタブすればテストはできますが、私にはどうもしっくりきません。

bblimke/webmock - GitHub

vcr/vcr - GitHub

実際のHTTPリクエストは、開発者の書いたコードの実装の詳細ではなく、ライブラリ側の実装の詳細でなければなりません。このままでは、LLMパラメータの微調整から別のAIサービスプロバイダへの切り替えまで、ありとあらゆる作業で常に最新のモックデータが必要になります。潜在的にモックデータを差し替える頻度を考えると、この方法は容認できません。

ありがたいことに、Active AgentはAI生成プロバイダのアダプタ化を、Active Storageでストレージサービスをアダプタ化している方法や、Action Mailerで配信メカニズムをアダプタ化している方法に似た形で行っています。(現時点で)唯一足りないのは、test環境に適したAI生成プロバイダです。
ご安心ください、これは以下のように自分たちで追加できます!

以下は、私たちのテストで使っているfake_llmプロバイダです。

require "active_agent/generation_provider/response"

module ActiveAgent
  module GenerationProvider
    class FakeLLMProvider < Base
      attr_reader :response

      class << self
        def generate_response_content
          raise NotImplementedError, "Must be stubbed via: allow(ActiveAgent::FakeLLM).to receive(:generate_response_content).and_return('...')"
        end

        def generations
          Thread.current[:generations] ||= []
        end
      end

      def initialize(*)
      end

      def generate(prompt)
        @prompt = prompt

        raw_response = prompt_parameters

        message = ActiveAgent::ActionPrompt::Message.new(
          content: self.class.generate_response_content,
          role: "assistant"
        )

        # 実行済みのプロンプトをトラッキングしてコンテンツを検証可能にする
        self.class.generations << prompt

        @response = ActiveAgent::GenerationProvider::Response.new(prompt:, message:, raw_response:)
      end

      private

      def prompt_parameters
        {
          messages: @prompt.messages.map(&:to_h),
          tools: @prompt.actions
        }
      end
    end
  end
end

このフェイクアダプタを、たとえばlib/active_agents/generation_provider/fake_llm_provider.rbファイルに保存して(この保存場所はActive Agentの規約に沿っています)、以下のようにコンフィグファイルで有効にできます。

# config/active_agent.yml
# ...
test:
  gpt:
    service: fake_llm

以下のヘルパーを追加して、機能を強化できます(RSpecの場合)。

module LLMHelpers
  def stub_llm_response(content)
    allow(ActiveAgent::GenerationProvider::FakeLLMProvider).to receive(:generate_response_content).and_return(content)
  end

  def assert_llm_has_been_called(times: 1)
    expect(ActiveAgent::GenerationProvider::FakeLLMProvider).to have_received(:generate_response_content).exactly(times).times
  end

  def assert_llm_has_not_been_called = assert_llm_has_been_called(times: 0)

  def llm_generations = ActiveAgent::GenerationProvider::FakeLLMProvider.generations
end

RSpec.configure do |config|
  config.include LLMHelpers

  config.after { llm_generations.clear }
end

次に、エージェントとエージェントに依存するテストを以下のように記述します。

describe TranslateAgent do
  describe "#translate" do
    before do
      stub_llm_response("ru->en: Blood type on the sleeve")
    end

    specify do
      agent = described_class.translate("Группа крови на рукаве", "en")

      result = agent.generate_now

      expect(result.message.content).to eq("ru->en: Blood type on the sleeve")

      prompt = llm_generations.last

      # 正しい指示が使われたことを検証する
      expect(prompt.instructions).to include("You are an experienced translator knowing many languages")
    end
  end
end

これが、エージェントフレームワークで必要となる基本的なテスト機能です。近いうちにActive Agentにも同様の機能が導入されるそうなので、どうぞご期待ください!

🔗 バトル2: Redprints CFPのAIレビュアー

evilmartians/redprints-cfp - GitHub

第2の例は、私たちがRailsとInertia.jsで構築したRedprints CFPアプリケーションを拡張して、プロポーザルをAIでレビューする機能を搭載したときのものです。このAIエージェントは、カンファレンスで発表する内容のプロポーザルを評価して採点し、建設的なフィードバックを添えて、カンファレンスの主催者が十分な情報に基づいて意思決定を下すことを支援します。

私たちが実験したReviewAgentは、サンフランシスコで開催されるSF Ruby Conferenceのニーズに合わせてカスタマイズされました。ここでは、「新規性(novelty)」「テーマとの関連性(relevance)」「プロポーザル自体の質(quality)」という3つの採点基準を設ける形で、プロポーザルごとの初期スコアを設定しました。そのため、AIエージェントに3つの採点基準をどのような形で評価すべきかを指示する必要が生じました。

🔗 発表内容の新規性を検索するためのツールを統合する

CFPを評価するときの基準の1つは新規性でした。私たちは、新しさのある発表やトピックへの配点は高くしたいと考えました。LLMは、寄せられた発表やトピックのうち、どれが古びていて、どれがそうでないかを果たして判定できるでしょうか?
おそらく可能(maybe)ですが、だからといってLLMが「このCFPはおそらく新しいだろう」という程度のあやふやな判定を下すようでは、容認できる水準に達しません。
そこで、AIエージェントに過去のRuby関連の発表内容データベースを検索する機能を提供することで、レビュー対象の中から新規性スコアに基づいて決定できるようにしてみました。

まず、近年の厳選されたカンファレンスで発表されたRubyやRails関連の発表内容を検索するためのインデックスを作成しました。
ここでは、RubyEventsの「データベース」(YAMLファイル)を取得して、Trieve.ai用のデータセットに変換しました。

続いて、ReviewAgentで利用するための#search_talksツールを定義しました。このあたりでソースコードをお見せいたしましょう。

class ReviewAgent < ApplicationAgent
  def review(proposal)
    @proposal = proposal

    prompt(output_schema: "review_schema")
  end

  def search_talks
    query = params[:query]
    results = TrieveClient.search(query)

    prompt(instructions: "") do |format|
      format.html { render partial: "search_talks_results", locals: {results:} }
    end
  end
end

ツールは、他のアクションと同様、単なるメソッドです。ツールに渡した引数は、#paramsハッシュ経由でアクセスできます。結果はテンプレートでレンダリングできます。

LLMプロバイダがツールにアクセスするためには、別のテンプレートファイル(例: search_talks.json)でスキーマを定義する必要があります(Jbuilderも使えます!)。

json.type :function
json.function do
  json.name :search_talks
  json.description "このアクションは、クエリパラメータを受け取って、過去数年のRuby Eventsデータベースから取得した発表タイトルとアブストラクトを返す。"
  json.parameters do
    json.type :object
    json.properties do
      json.query do
        json.type :string
        json.description "検索クエリ(用語を1〜2個指定する)"
      end
    end
  end
end

しかし、これは私の意見ですが、この部分はRailsの美しさと規約の遵守から逸れてしまいます。マシンに渡すコードは手書きするのではなく、何らかの形でAIに推論させるべきです。

私の過去記事でRubyとAIについて扱ったときは、インライン形式のRBS(Ruby Type Signature)を用いてツールを定義するという、(私にとっては)エレガントな方式を提案しました。

# このアクションは、クエリパラメータを受け取って、過去数年のRuby Eventsデータベースから取得した
# 発表タイトルとアブストラクトを返す。
# @rbs (query: String) -> ActiveAgent::Prompt
tool def search_talks(query:)
  results = TrieveClient.search(query)
  prompt(instructions: "") do |format|
    format.html { render partial: "search_talks_results", locals: {results:} }
  end
end

この方法なら、JSONスキーマを型から生成できるので、JSONスキーマを手書きする必要がなくなるだけでなく、「このメソッドがツールであること」と「引数がメソッドのパラメータに移動すること」も明確に示されることを前提にできます。さらに、RBSを利用していれば、コードがスキーマと一致していることをランタイムテスト機能で確認できるようになります。

利用できる選択肢はRBSに限定されないので、YARDやカスタムDSLなど何でも構いません(ruby_llm-schemaでも実験してみたところ、Active Agentとうまく連携できました)。
ただし明示的なスキーマ生成はRails wayから外れるので、行うべきではありません。

🔗 新規性の出力の構造化を改善する

上述のスニペットで、プロンプトにoutput_schema: "review_schema"というパラメータを渡していたことにお気づきでしょうか。指定したJSONスキーマで最終的な回答を生成するためのLLMへの指示は、このようにして行います。こうすることで、テキスト解析部分を省略して、以下のように構造化された出力を得られるようになります。

result = ReviewAgent.review(proposal).generate_now

JSON.parse(result.message.content)

#=> {
#     "scores": {"novelty":4,"relevance":4,"quality":5},
#     "feedback": "This one is a good fit for the conference",
#     "notes": "I've searched for the topic and couldn't find a lot of talks like this one"
#   }

かなりいい感じになってきましたが、私にはまだ物足りない点があります。

第1に、テンプレートを用いてスキーマを手動定義するときにも同じ問題があります。

第2に、JSON.parseをわざわざ呼び出す必要はあるのでしょうか?output_schemaパラメータを指定した時点で、出力はJSON文字列でなければならないことは既にAIエージェントに指定済みです。

以下のような使い心地を実現できるでしょうか?

class ReviewAgent < ApplicationAgent
  Scores = Data.define(
    :novelty,   #: Integer
    :relevance, #: Integer
    :quality    #: Integer
  )
  ReviewResult = Data.define(
    :scores,   #: Scores
    :feedback, #: String
    :notes,    #: String
  )

  def review(proposal)
    @proposal = proposal
    prompt(output_object: ReviewResult)
  end
end

result = ReviewAgent.review(proposal)
result.message.data #=> #<data ReviewResult scores="" ...>

はい、私はここでもRBSを推していますが、マシンの定型文を減らせるという重要なポイントはご理解いただけたと思います。

🔗 プロポーザルのサンプルをフューショットRAGで生成する

このAIエージェントへの指示には、良いプロポーザルとよくないプロポーザルのサンプルを表示するようにしています(なお、speakerline.ioというサイトで実際のプロポーザル例を多数参照できますが、ほとんどのプロポーザルはよくできています)。

# review_agent/instructions.text.erb

...

#### 合格したプロポーザルの例

<%= render partial: "proposal_example_accepted" %>

#### リジェクトしたプロポーザルの例

<%= render partial: "proposal_example_rejected" %>

ご覧のように、ここではAIへの指示内容をパーシャル構文で組み立てています。これは、指示内容の分量が多い場合には特に便利です。

しかし、ここで別の疑問が持ち上がります。
ビューベースの静的なプロンプトを、データベースに保存される動的なプロンプトに移行するにはどうすればよいでしょうか?
既存のさまざまなプロポーザルをピックアップして、それぞれの有用性をサンプルとして測定するにはどうすればよいでしょうか?毎回コードを変更することなく、プロンプトを繰り返し処理するにはどうすればよいでしょうか?

ここでいったん話を止めて、現在のActive Agentにまだ存在していない機能と、私たちがこれまで回避方法をどのように模索してきたかについて、駆け足で説明しておきたいと思います。

🔗 今後のバトル: Rails AIで今後何が必要か

私たちがActive Agentを使ってきた経験から、Active AgentはAIと対話を行うためのRails風の規約を提供しているものの、AIアプリケーションの分野においては、さらに高度な抽象化が求められていることもわかってきました。より正確には、絶え間ない進化を繰り返している現代のAIにおいては、そうした変化に追従できるよう、柔軟で拡張しやすいフレームワークが求められています。

ここでは、RailsアプリケーションでAIドリブンの機能を構築するうえで、今後どんな追加作業が必要になるかについて、いくつかの分野の将来像について書いておきたいと思います。

🔗 AIクレジットの利用状況トラッキング

AIアプリケーションでは、利用状況の監視と制限が必要です。ユーザーにはAIクレジットを付与する必要があり、テナントにはAI予算の管理機能が必要です。AIエンジンは、「利用状況のトラッキング」と「クレジットが枯渇したらAIを使えないようにする」ためのフックを提供しなければなりません。
以下は、そうした機能をActive Agentで実装した場合の例です。

class ApplicationAgent < ActiveAgent::Base
  before_generation :ensure_has_credits
  before_generation :track_start_time
  after_generation :track_usage

  private

  def identity = (params[:account] || Current.account)&.ai_identity

  def ensure_has_credits
    return unless identity

    raise "No credits available" unless identity.has_credits?
  end

  def track_start_time = @start_time = Time.current

  def track_usage
    return unless response

    if identity
      identity.generations.create!(
        purpose: [agent_name.underscore, action_name].join("/"),
        time_spent: (Time.current - @start_time).to_i,
        tokens_used: response.tokens_used,
        model: generation_provider.config["model"],
        provider: generation_provider.config["service"]
      )
    end
  end
end

エージェントコールバックは、そうした目的に最適です。account.ai_identityは、指定のアカウント(テナント)の「利用状況」「クレジット」などのAI関連情報を含むカスタムモデルです。generationsという関連付けには、主に監査用の詳細な利用状況情報が含まれています。

これはフレームワークには(まだ)取り入れられていませんが、今後一般化されてプラグインとして提供される可能性があります。

🔗 動的なcredentialとプライベートLLM

ユーザーが独自のAPIを利用できる機能や、外部に公開しない自社専用のプライベートLLMをデプロイできる機能も必要です。私たちの場合は、同じaccount.ai_identityモデルでこれを実現しています。

class ApplicationAgent < ActiveAgent::Base
  generate_with :default

  # ...

  private

  # デフォルトのgeneration_providerの振る舞いを上書きする
  def generation_provider
    @generation_provider ||= identity&.provider_for(self.class._generation_provider_name) || super
  end
end

繰り返しますが、Active Agentでは、ベースクラスでメソッドを1つオーバーライドするだけで実現できます。
なお、設定済みの生成AIプロバイダには、"openai"や"grok"のようなサービス固有の名前ではなく、"default"や"mini"のような意味のある名前を与えていることにご注意ください。

🔗 動的なプロンプト

プロンプトをハードコードする方法は、小規模なアプリケーションやごく基本的なユースケースであれば十分かもしれませんが、使っているうちに欲が出てくるのが世の常です。

AIドリブンのロジックを使えば使うほど、ロジックは高度になり、絶え間なく磨き続けなければならなくなります。やがて、ユーザーにプロンプトの一部の入力を許すことになるかもしれません(プロンプト全体でないことを願いたいものです)。

どの機能についても、何らかの形でプロンプトをどこかから動的に読み込む機能がいずれ必要になってきます。

ありがたいことに、最近のRails AIエコシステムは、prompt_engineというプロンプトライブラリエンジンのおかげで充実してきました。

aviflombaum/prompt_engine - GitHub

prompt_engineをActive Agentと統合する方法は、以下のような感じになるはずです。

class ApplicationAgent < ActiveAgent::Base
  default body: proc {
    slug = ["agent", agent_name.underscore, action_name].join("/")
    PromptEngine.find_by(slug:)&.render_in(self)
  }

  # ...
end

(上のコードはあくまで例ですが、たぶん実際に動くと思います)

1つのライブラリやフレームワークで何もかも実装しようとするのは得策ではありません。既存のツールを統合する方法は、すべての関係者にとってメリットがあります。

🔗 プロンプトの保護と評価

プロンプトインジェクションという言葉を聞いたことがありますか?
自分が与えたプロンプトが、ハルシネーションを引き起こさずに期待通り動くと確信できますか?

セキュリティはRailsで常に最優先事項でした。AIを扱うフレームワークは、開発者がAI機能を安全に構築できるよう支援する必要があります。ユーザー入力を検証し、プロンプトにガードレールを設置し、出力結果も評価しなければなりません。

つまり技術的に言えば、入出力プロセッサ、すなわちミドルウェアが必要です。最初のうちはコールバックで代用してもよいのですが、AI生成用のパイプラインを整備する方が効果的です。

🔗 エージェント型ワークフローについて

私は、いわゆるエージェント型ワークフロー(agentic workflow: タスクをAIでオーケストレーションするため、必然的に非決定的になります)を検討するつもりはありませんが、機能によってはエージェント型ワークフローが有効かもしれません。
エージェント型ワークフローをモデル化するとすれば、必要に応じて他のエージェントに作業を委譲する、いわゆるエントリポイントオーケストレーター(entrypoint orchestrator)エージェントになるでしょう。

委譲の場合、以下のようなRails風のシンタックスシュガーでAIエージェントにツールとして接続できる機能があるとよいかもしれません。

class ReviewAgent < ApplicationAgent
  # 呼び出したい`check_grammar`ツールを追加する
  has_agent :grammar_critic, through: :check_grammar
end

ワークフローには一時停止機能や人間の介入が必要になるかもしれませんが、これはAIエージェントの安定性が高まって記憶容量が増えるまでは、おそらく不可能でしょう。

🔗 LLMの記憶容量とコンテキストの永続化

AIエージェントを使っていると、過去の会話を保存して再現したり、会話を超えてコンテキストを維持する機能が必要になることがよくあります。会話の記憶方法は、静的(コンテキストに完全に含まれる)、動的(LLMからツールでリクエスト可能)、短期記憶、長期記憶、圧縮タイプ、ロスレスタイプがあります。これらすべてに対応するには、他と統合可能な専用のライブラリが必要となります。

RubyでAIチャット機能を使いたいときは、RubyLLMを検討してみましょう。RubyLLMでは(Active Recordベースの)メッセージ永続化機能がすぐ使えるので、こうした用途に最適です。

🔗 コンテキストエンジニアリングとベクトル化

Active Agentは生成に特化しているため、コンテキストエンジニアリングRAG方面の次世代用語)は専門外です。生成AIプロバイダに加えて、ベクトライザ(vectorizer)、エクストラクタ(extractor)、チャンカー(chunker)も抽象化して使えるようになるとよいでしょう。

抽象化がそんなにたくさん必要な理由とは何でしょうか?どの抽象化も、ドキュメントを検索可能なコンテキスト断片に変換する処理の各段階を反映しています。

  1. (あらゆる種類の)ドキュメントからテキスト表現を抽出する(通常の場合)
  2. テキスト表現をチャンクに分割する(ドキュメントが大きい場合など)
  3. 埋め込みを生成して、後でチャンクを検索可能にする

もちろん、これらの抽象化のすべてがあらゆるアプリケーションで必要とは限りませんが、用途によってはさらに抽象化が必要になる可能性もあります。

たとえば、知識ベースを扱う場合は、巨大なドキュメントをチャンクに分割する前に要約しておいたり、ドキュメントの命題を特殊なチャンクとして抽出しておく必要が生じる可能性もあります。

🔗 私たちはどの方向に進むべきか

この方面には未解決の問題がたくさんあり、Active Agentを含むRails向けのAIライブラリのほとんどは、その問題に答えられるほど成熟していません。しかし、それらのライブラリは、日常のニーズを理解して、それらを拡張可能な形で(他のユーザーがプラグインとして問題を解決できるように)設計できる必要があります。

Active Agentは、RailsらしいAI機能の基盤として有望です。しかし、その真価を試されるのは、production環境のAIアプリケーションで求められる高度なパターンをサポート可能になるかどうかです。
このフレームワークが成功するかどうかは、AI機能がプロトタイプからproductionに移行するときに必要となる「利用状況のトラッキング」「高度なinstrumentation」「複雑なワークフロー」といった複雑な要求を満たせる拡張ポイントを提供できるかどうかに最終的にかかっています。

Rails開発者としての私たちは、どうすれば愛しいRailsフレームワークでAIがうまくはまるかどうかをやっと理解し始めたばかりです。しかし、Active AgentのようなライブラリがRailsの規約への準拠をリードしている様子を見るにつけ、私たちが愛しているあのRailsアプリケーションと同じくらい自然な形で、AI搭載アプリケーションを構築できると期待しています。

関連記事

Rails: Evil Martiansが使って選び抜いた夢のgem -- 2024年度版(翻訳)

ChatGPTのしくみとAI理論の根源に迫る:(1/16)実は語を1個ずつ後ろに追加しているだけ(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。