LIFULL Creators Blog

LIFULL Creators Blogとは、株式会社LIFULLの社員が記事を共有するブログです。自分の役立つ経験や知識を広めることで世界をもっとFULLにしていきます。

OpenAI Assistants APIを使わずに無限にスケールする汎用AI(仮)を開発した

KEELチーム の相原です。

前回のエントリ で我々KEELチームはKubernetesベースの内製PaaSであるKEELを開発・運用する傍ら、LLMという新たなパラダイムの台頭にあわせてベクトルデータベースの提供や周辺ソフトウェアを社内向けに開発していることを紹介しました。

www.lifull.blog

あれから数ヶ月が経ち、現在私達はLIFULLのグループ会社全体に向けて汎用AI(仮)を提供しています。

もともと我々KEELチームはPlatform Engineeringの一環として、Kubernetesベースの内製PaaSであるKEELのほかにコードジェネレータによる一貫したPaaS体験を中心に様々なユーティリティをコマンドラインから提供するkeelctl, KEELが提供するプラットフォームのユーザ体験を向上させるブラウザ拡張のkeelextを開発してきました。

Platform Engineeringの責任は無限にスケールさせることです。

プラットフォーム・コマンドライン・ブラウザを手中に収めてソフトウェアエンジニアの生産性向上を盤石なものとした私達が次に目を向けたものが、職種問わずあらゆる業務上の課題を解決できる汎用AIでした。

そもそも社内のLLM活用が思うように進んでいなかった中で、まずは活用の背中を見せること、そして"無限にスケール"を目指す上で避けて通れない汎用AIというテーマにはスケーラビリティや信頼性に専門性を持つ私達が適任だと判断しました。

今回目指す汎用AI

とはいえ汎用AIは壮大なテーマです。

プロダクトの鉄則は「小さく作る」なのでまずはファーストリリースのゴールを設定しましょう。

  • なるべく作らない
  • テキストベースでの対話型インターフェース
  • 職種問わずあらゆる業務上の課題を解決できるスケーラビリティを持つ
  • スケーラビリティとコストのバランスを保つ

私達のプラットフォーム戦略は(3人というコンパクトなチームということもあり)インナーソースに重きを置いていて、無限にスケールする仕組みを用意した後は社内からContributionを集めて加速的に成長していくことを狙っています。

実際にコードジェネレータによる一貫したPaaS体験を中心に様々なユーティリティをコマンドラインから提供するkeelctlでは、あるプラクティスを浸透させたい開発者が自らの手でkeelctlに機能を実装する文化が根付いていて、社内の全体最適に貢献するとともに標準コマンドラインツールとしての地位を確立しています。

汎用AIを目指す上でもあらゆる業務上の課題を解決する機能を私達だけで実装することは現実的ではないため、「なるべく作らない」ことでコストとバランスが取れたスケーラビリティだけを素早く示してインナーソースによって成長していくことを目指しました。

汎用AI(仮) keelai

そうして開発されたものが汎用AI(仮)であるkeelaiです。

(やっていき感を出すために社内プロダクトでもロゴを作るようにしていますが盛り上がるのでお勧めです)

keelaiの基本的なコンセプトを私達はマルチエージェントと呼んでいて、サブタスクを解決するために自律的に動くエージェントを複数組み合わせて協調させることで無限にスケールすることを目指します。

現在では一般的なLLMのユースケースに加えて、例えば以下のようなユースケースにも対応しています。

  • Webから最新のコンテンツを取得して、社内情報と突合しながら新しいコンテンツを生成する
  • 社内のテーブルスキーマに応じたSQLの生成とバリデーション
  • 社内のデザインガイドラインに準拠した画像の生成

とにかく分からないことややりたいことがあれば、それが社内のことでも社外のことでも一見無理そうなことでもとりあえず指示するといい感じにしてくれるというものです。

しかしまだエージェントの実装はあらゆる課題を解決するために十分ではないし、エージェントを人間が実装しないといけない時点で...という気もするので "汎用AI(仮)" です。

そんなkeelaiは複数のコンポーネントから実現されており以下のような構成になっています。

  • agents: サブタスクを解決する複数のエージェントの実装
  • bot: agentsを呼び出しSlack Botとして稼働するテキストベースの対話型インターフェース
  • api: 同様の機能をHTTPで提供するAPI
  • memory: エージェント間で共有する短期記憶で軽量なベクトルデータベースであるRediSearchをバックエンドとする
  • brain: エージェント間で共有する長期記憶でオブジェクトストレージであるAmazon S3をバックエンドとする
  • embedding-retrieval: ChatGPT Retrieval Pluginの信頼性の問題を解決した社内知識を回答するためのソフトウェアでベクトルデータベースであるQdrantをバックエンドとする
  • embedding-gateway: 各Embeddings APIに対する透過的なキャッシュレイヤ
  • summarizers: やり取りが長期化した場合や巨大なドキュメントをもとに回答する場合に要約するモジュールで、用途に応じて複数の要約のオプションが用意されている
  • loaders: embedding-retrievalに文書をインデックスするためのバッチプログラムで、GitHubやSlack, JIRA/Confluenceなど各種データソースごとに実装が存在する
  • manager: loadersの実行管理を行うバッチプログラムで、差分インデックスや並列数の制御を行う
  • evaluation: apiを使いながら典型的なkeelaiのユースケースを実行し、その精度をLLMによって出力したメトリクスから評価するためのバッチプログラム

私達はこれをPlatform Engineeringらしくパッケージとしても配布しており、特定のユースケース用にカスタマイズされた汎用AI(仮)を社内で開発できるようにしています。

初回リリースまでは2週間ほどと大分「なるべく作らない」ことで手を抜けたのでここからはそういった点を紹介していきます。

Agents

エージェントはOpenAIのGPT-4をベースにFunction Callingを使って実装されていて、ブラウザ操作・画像生成・音声処理・社内システムとのインテグレーションなどサブタスクごとにエージェントが分かれています。

私達に自前のLLMを開発する体力はないのでGPT-4を利用することは当然として、エージェントの実装にはFunction Callingも使ってとことん楽をしています。

Function Callingの利用

Function Callingは関数の名前と引数の型をOpenAPI形式で与えるとコンテキストに応じて実行すべき関数とその引数を推論してくれるOpenAIが提供している機能で、エージェントがどの機能を呼び出すかをどうかを自律的に判断できるようになります。

似たようなことを実現するための手法としてPlan-and-Solve Promptingが提案されていますが、Function Callingを利用することで極めて少ない実装量でそれっぽい挙動を再現することができます。

恐らくChatGPT pluginsの中身もFunction CallingでしょうしGPTsのActionsも同様のはずです。

以下は社内システムとのインテグレーションを司る ObservabilityAgent のイメージです。

messages = [{"role": "user", "content": "Pod/keelai-5675dfdf7b-d7c2l で起きているエラーの原因を調べて"}]
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_metrics",
            "description": "Get metrics from Prometheus",
            "parameters": {
                "type": "object",
                "properties": {
                    "pod_name": {"type": "string"},
                    "metric": {
                        "type": "string",
                        "enum": [
                            "container_cpu_cfs_throttled_seconds_total",
                            "container_cpu_usage_seconds_total",
                        ],
                    },
                    "duration": {"type": "string", "enum": ["1h", "6h", "24h"]},
                },
                "required": ["pod_name", "metric", "duration"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_logs",
            "description": "Get logs from Grafana Loki",
            "parameters": {
                "type": "object",
                "properties": {
                    "pod_name": {"type": "string"},
                    "duration": {"type": "string", "enum": ["1h", "6h", "24h"]},
                },
                "required": ["pod_name", "duration"],
            },
        },
    },
]
response = openai.chat.completions.create(
    model="gpt-3.5-turbo-1106",
    messages=messages,
    tools=tools,
    tool_choice="auto",
)

必要に応じて実行すべき関数名と引数が推論されるため、あらかじめ用意しておいた関数を推論された引数で呼び出し、実行結果を返却することで汎用AIっぽい挙動を低コストに実現することができます。

その結果に対して更に推論を挟むことでReAct相当の機能も実現することができ、軽微な実装で更に精度を向上可能です。

マルチエージェントによるトークン消費の抑制

しかしFunction Callingも万能ではありません。

OpenAIの課金はトークンの入出力によって行われますが、tools として与えた関数の候補は入力トークンとして毎回処理されます。 そのため汎用AIを目指す上で多くの関数を実装していくと、単なる「こんにちは」のような問いに対して膨大なトークンが消費されてしまいます。

そのために私達はサブタスクごとに実装されたエージェントに親子関係を持たせて、それを多段で呼び出すことによってトークン消費を抑えるアーキテクチャを採用していてこれをマルチエージェントと呼んでいます。

起点となる親のエージェントには以下のように子のエージェントを呼ぶFunction Callingを定義することで、tools に膨大な関数群を書くことなく毎回のトークン消費を抑えつつ様々な機能の呼び出しに対応しています。

  {
        "type": "function",
        "function": {
            "name": "launch_image_agent",
            "description": "Launch an agent to manipulate images",
            "parameters": {
                "type": "object",
                "properties": {
                    "instruction": {"type": "string"},
                },
                "required": ["instruction"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "launch_observability_agent",
            "description": "Launch an agent to fetch observability signal",
            "parameters": {
                "type": "object",
                "properties": {
                    "instruction": {"type": "string"},
                },
                "required": ["instruction"],
            },
        },
    },

それぞれのエージェントをどう協調させるかどうかもLLMに判断させる ということになります。

これにより画像生成に関係ないタスクの場合は launch_image_agent 分のわずかなトークン消費で抑えることが可能です。

ここにもFunction Callingを利用することでマルチエージェントも「なるべく作らない」で実現することができました。

突き詰めていくと「ある関数が実行された後にしか呼ばれない関数」のようなものが出てくるはずで、内部でコールスタックを持ちながらその依存関係をもとに tools を構築すると更にトークン消費を抑えられるなど、細かいトークン節約のテクニックはまた別のエントリで紹介することにします。

マルチエージェントにおける状態共有とベクトルデータベース

子のエージェントはRPCを通して呼ばれることもあり、負荷の特性に応じて異なるサーバ・異なる言語で実装されることがあります。

そのため、エージェント間の状態の共有には memorybrain という2つの外部記憶を通して行っています。

セッションが終了すると破棄される短期記憶である memory にはベクトルで各エージェントが処理結果を格納し、他のエージェントからは曖昧な表現でその処理結果を取り出せるようにしています。 例えば、画像生成を行うエージェントが「生成した犬の画像」として memory に画像を保存しておき、それをファイルアップロードを行うエージェントが「先ほど生成した犬の画像」として取り出すといった具合です。

素直にエージェント間で状態を共有しようと思うと、子のエージェントの実行結果を親のエージェントの入力トークンとして与えることになりますが、これは当然トークンの消費が激しくなってしまいます。

マルチエージェントにすることでコストとバランスが取れたスケーラビリティを実現するとともに、用途に応じた外部記憶を利用することで機能性を維持することができました。

この memory の実体はRedisの全文検索モジュールであるRediSearchであり、RedisStackというパッケージを利用することで簡単に用意することができるため、ここもまた「なるべく作らない」で実現されています。 この用途では永続性は不要であるためメモリ上に全て載せてパフォーマンスに優れるRedisが適切です。

Bot

LLMを使ったアプリケーションを提供する上でまず最初に選択肢として挙がるものがBotインタフェースでありSlack Botでしょう。

私達も開発初期は当然Slack Botとして実装しましたが、汎用AI(仮)として成長していく中でもWebのインタフェースを用意するつもりはなくSlack Botとして作り続けています。

Slack Botは「なるべく作らない」上で色々と都合がいいです。

  • OpenAIはServer Sent Eventsで結果をストリームで受け取ることができるが、それをキューに溜めながらSlack APIのchat.updateを呼ぶ実装でリアルタイムな返答を再現できる(Rate Limitのために適当にThrottlingする必要はある)
  • 汎用AIを目指すと成果物をファイルとしてアップロードさせたくなるが、Slackはそのファイルの入出力先として十分機能する
  • ダイレクトメッセージでSlack Botに話しかければクローズドに利用することができる上、そのやり取りを他の人に共有することもできるし当然パブリックチャンネルで直接利用することもでき、ChatGPTの Shared Links 相当の機能が実装不要で実現できる
  • ユーザのメタデータは既にSlackが持っているため、所属組織ごとのFunction Callingの制限や言語の切り替えを認証の仕組みなしに実現できる
  • 会話の履歴は当然Slack側に保存されているためこちらで保存する必要がない

このようにSlack Botとして実装することでWebで同じ機能を実現するより格段に手を抜くことができ、汎用AIとして本質的な機能開発に集中することができました。

Slack Botを作るためにはBoltというフレームワークが用意されているためこれを使うだけです。

slack.dev

SlackのSlash Commandとして開発者が容易に拡張できることも好みで、ChatGPTの Custom instructions 相当の機能が社内からのContributionを受けて開発されていたり、GPTsのように作成したプロンプトを配布する仕組みもSlash Commandとして実装されています。

(前述の通り会話の履歴を保存する必要がないため、長期記憶である brain の役割はこういった Custom instructions 相当の機能を実現するためにのみ利用されています)

EmbeddingRetrieval

EmbeddingRetrievalは前回のエントリでも軽く触れた社内向けのChatGPT Retrieval Pluginのforkです。

社内知識を回答するために必要なコンポーネントで、ベクトルデータベースを利用してSemantic Searchすることで関連するドキュメントを取得することができます。

いくつかのパッチは書いたものの、結局ChatGPT Retrieval Pluginが各種データストアに対応するために膨れ上がった依存関係がネックとなりforkという道を選んでしまいました。

やはり私達としては可観測性や信頼性は重要であり、前回のエントリで触れたものを中心にいくつかの改善を施し、利用しないデータストアの実装を削除して利用しています。

その甲斐(?)あって低いエラー発生率や完全な分散トレーシングが得られるようになっており十分な信頼性で運用できています。

LangChainTextSplitterVector storesを使って実現する方法もありましたが、結局LangChainも実装の箇所によって品質にムラがあることには変わりなく依存も同様に巨大となるため、シンプルなChatGPT Retrieval Pluginをforkすることが正解だったと感じています。

開発初期ではChatGPT Retrieval Pluginを使っていたため、「なるべく作らない」ためにChatGPT Retrieval Pluginを利用するということは依然有効だと思います。

私達はベクトルデータベースとして既に用意してあったQdrantを利用していますが、Qdrantも十分にシンプルなものの「なるべく作らない」というコンセプトとしてはAzure Cognitive Searchを利用することが適切でしょう。

Evaluation

汎用AIを開発する上では継続的な精度の監視が必須です。

プロンプトチューニング一つで"あちらを立てればこちらが立たぬ"になりがちで、内部のモデルを変えた時のインパクトも観測する必要があります。

継続的に監視するにあたって毎回Slack Botを手動で呼び出すわけにもいかないためAPIが必要となりますが、エージェントとSlack Botはトークン数削減を狙ったマルチエージェントな実装により疎結合になっているため開発コストは低いはずです。

そしてAPIを実行した結果を何らかの方法で評価するわけですが、この際にはAzure Machine LearningのPrompt Flowが参考になります。

Prompt Flowにはいくつかの評価メトリクスが用意されており、汎用AIの精度評価に関してもこれをそのまま利用できるはずです。

learn.microsoft.com

  • Relevance: 質問に対する回答が与えられたコンテキストとどの程度関連しているか
  • Coherence: 質問と回答に一貫性があるか
  • Fluency: 質問と回答が文章的に自然か

などがLLMの評価メトリクスとして用意されています。

これをそのまま利用してしまうことで「なるべく作らない」で精度監視を実現することができました。

その後

私達の汎用AI(仮) keelaiは多言語対応や契約形態やグループ会社ごとのFunction Callingのアクセス制御を経て、現在は国内外のグループ会社全体で利用されています。

社内知識からの回答やWebブラウジングはもちろんのこと、画像・音声に関する操作や社内システムとのインテグレーション、WebAssemblyでサンドボックス化された安全なCode Interpreter相当の機能も準備中です。

こういった機能の実装は独立したエージェントを開発するだけで開発者誰しもができるようになっていて、Platform Engineeringを専門とする我々KEELチームはここに新たなプラットフォームとしての可能性を見出しています。

私達のプラットフォーム戦略はインナーソースによる成長を積極的に狙っていると先に書きましたが、その進捗はまずまずと言ったところで、ChatGPT Retrieval Pluginの構築を一緒始めた二宮以外にもチーム外のContributorは何人か生まれつつあるのでここからの横展開を頑張ろうといったところです。

今回紹介した通り、この程度であればコアとなる Agents の機能以外を「なるべく作らない」で実現することができます。 プラットフォーマー各位はプラットフォームの次の一手として是非汎用AIをご検討ください。

LIFULLでは今後プラットフォームとの連携を一層強めていき、社内システムとインテグレーションされた汎用AI(仮)による障害対応の自動化や、(うまくGitHub Copilotの隙間を縫いながら)社内の開発ガイドラインをもとにしたコードレビューの自動化をやっていく予定です。

OpenAI Assistants API

と、ここまで書いておいてですが、実は似たようなものはOpenAI Assistants APIを利用することでも実現できます。

https://platform.openai.com/docs/assistants/overviewplatform.openai.com

タイトルにのみ書いてここまで触れてきませんでしたが、OpenAI Assistants APIとは2023年11月6日のOpenAI Dev Dayで発表された機能で、汎用AI(仮)のようなAIアシスタントを開発するためのフレームワークのようなものです。

  • 会話の履歴の保持
  • ファイルサーバの提供(Fine-tuningでも利用されるので厳密にはAssistants APIの持ち物ではありませんが)
  • ファイルサーバと統合されたマネージドベクトルデータベースを利用してSemantic Searchする retrieval の提供
  • Code Interpreterの提供
  • Function Callingとのインテグレーション

が主な機能と言っていいでしょう。

今後利用できる機能はOpenAIによって実装されて増えていく予定らしくこれは強力な選択肢となるはずです。

しかし、会話の履歴の保持やファイルサーバはSlack Botとして実装していればSlackに肩代わりしてもらえますし、retrieval 相当の機能はChatGPT Retrieval PluginとAzure Cognitive Searchで十分事足ります。

クラウド時代の常としてマネージドな部分が増えるほど価格は高くなるわけで、今回はOpenAI Assistants APIを使わず「なるべく作らない」で無限にスケールする汎用AI(仮)を開発した話を紹介しました。

(そもそも私達はDev Day前にここまで作ってしまっていたこともありこのまま突っ走ろうと思います。)

最後に

我々KEELチームはKubernetesベースの内製PaaSを開発・運用する傍ら、汎用AI(仮)の開発に踏み切りプラットフォームの影響力を強めることに成功しました。

KEELチームはこれまでもコマンドラインのソフトウェアやブラウザ拡張を開発してきており、プラットフォームの成功、ひいてはLIFULLの目指す「あらゆるLIFEを、FULLに。」実現に向けてソフトウェアエンジニアとしてPlatform Engineeringの領域からあらゆる手を尽くしていきます。

もし興味を持っていただけた方がいましたら是非こちらからお問い合わせください。

hrmos.co