テクノロジー

Omniludeのai-serviceでAgentはこう動きます

AAnonymous
12分で読めます

はじめに

この記事は、以前書いた Omniludeのai-serviceはなぜAssistant / Thread / Run構造を選んだのか の続きです。前回は assistantthreadrun がそれぞれ何をするのかを説明しました。今回は、その上で agent が実際にどのように動くのかをお話ししたいと思います。

この構造を説明するとき、最初に一つ誤解を解いておきたいです。Omnilude の AiAgent は巨大な魔法の箱ではありません。assistant をまったく別の何かに変える層でもありません。むしろ逆に近いです。AiAgent は複数の assistant をノード単位で束ね、その実行順序を workflow として保存する構造です。

もっと簡単に言うと、こうなります。

AiAssistant が個別の LLM プリセットなら、AiAgent はそれらのプリセットをつなぐ作業フローです。

この記事では、その流れがコードの中でどう実装されているのかを、できるだけ難しくならないように説明してみます。

Agent は別の AI ではなく workflow です

最初に見るべきなのは AiAgent エンティティです。ここには名前、説明、タイプも入っていますが、本当の中心は workflow フィールドです。この値は別テーブルではなく JSONB として保存されます。

つまり Omnilude における agent は、「一つのモデルをもっと賢く呼ぶオブジェクト」というより、ノードとエッジで表現された実行グラフに近いです。

例えば、ある agent は次のような形になります。

  • 開始ノードで入力を受け取ります。
  • generate-text ノードで一つの assistant を使ってテキストを生成します。
  • router ノードで次の経路を決めます。
  • 最後の finish ノードで結果を整理します。

ここまで見ると、assistant と agent の違いがかなりはっきりしてきます。

  • assistant は一回の推論のためのプリセットです。
  • agent はその推論たちをどんな順序と条件でつなぐかを表す workflow です。

Agent は別の AI ではなく workflow です(テキスト参照)

AiAssistant
  - 単一の LLM 呼び出し用プリセット
  - どの model/provider/instructions を使うかを定義

AiAgent
  - 複数のノードをつないだ workflow
  - 各ノードは assistant を使うことも、tool のように振る舞うこともできる
  - 核心は「一回の呼び出し」ではなく「実行順序と接続」にある

リクエストはすぐには実行されません

Omnilude での AiAgent 実行は、ほとんどの場合、同期関数呼び出しだけで終わりません。ここで重要な役割を担うのが DTE(Distributed Task Executor) です。

流れはおおよそ次のとおりです。

  1. クライアントやバックオフィスが agent 実行を要求します。
  2. サーバーはそれをすぐ処理せず、DTE job に変換します。
  3. キューに入った job を WorkflowTaskHandler が受け取ります。
  4. その後 SingleWorkflowExecutor が実際の workflow 実行を担当します。
  5. 内部では BasicWorkflowEngine がノードを一つずつ進めます。

この構造が重要なのは単純です。agent 実行は思ったより長くなり、ストリーミングが付き、中間状態を残す必要があり、場合によってはバックグラウンドでも動かさなければならないからです。これを全部通常の request-response に押し込むより、実行そのものを job として扱うほうがずっと安定します。

リクエストはすぐには実行されません(テキスト参照)

Controller
  -> AiAgentExecutor
  -> DistributedTaskQueue
  -> WorkflowTaskHandler
  -> SingleWorkflowExecutor
  -> BasicWorkflowEngine

重要ポイント
  - 実行を job として扱う
  - ストリーミングやバックグラウンド処理がしやすくなる
  - 長時間の実行も一つの構造で管理できる

開始点は start node 一つです

workflow があるからといって、エンジンが最初から最後まで単純にリストを順番にたどるわけではありません。実際の BasicWorkflowEngine は、まず startNode=true で、かつタイプ名が -input で終わるノードを探します。そしてそのノードに初期入力を入れて実行を始めます。

私はこの部分がかなり気に入っています。agent を「コマンド一行」として見るのではなく、入口が明確な実行グラフとして扱っているからです。

例えば text-input ノードは、ユーザーが入れた文字列を準備し、その値を次のノードへ渡します。その次に generate-text ノードがその値を受け取って LLM 呼び出しを起こします。そして output は edge に沿って次のノードへ伝播します。

つまり agent は巨大な関数一つではなく、小さな実行単位がつながった流れです。

ノードを実際に実行するのは NodeExecutor です

ここで本当に重要な層が NodeExecutor です。各ノードはタイプ文字列を持っていて、NodeExecutorFactory がそのタイプに合う実行器を探します。このとき NodeExecutorRegistry@NodeType アノテーションをスキャンし、どの実行器がどのタイプを処理するかを自動で登録しておきます。

この構造のおかげで、新しいノードを追加するときにエンジン全体を作り直す必要がありません。ノードを一つ作り、タイプを宣言し、provider を付ければランタイムに組み込まれます。

今回の記事で理解しておくとよい代表的なノードは四つあります。

  • TextInputNode: 開始入力を準備します。
  • GenerateTextNode: assistant を使って実際のテキストを生成します。
  • RouterNode: LLM で次の経路を判断します。
  • FinishNode: 最終結果を集めて実行を終えます。

この四つだけでも、Omnilude の agent 構造がどう動くのかをかなりつかめます。

Agent の中でも実際の推論は assistant が行います

ここで前回の記事と今回の記事がもう一度つながります。

AiAgent があるからといって、agent 自体が直接モデルを呼ぶわけではありません。実際の LLM 呼び出しはノード内部で起こり、そのとき再び AiAssistantService が登場します。

例えば GenerateTextNode は設定値から aiAssistantId を読みます。そしてその id で assistant を取得し、AiModelAiProviderAiApiKey まで組み合わせて RunnableAiAssistant を作ります。そのあとで初めて実際の chat model 呼び出しが起こります。

つまり、この構造は次のように読むのが自然です。

  • assistant は推論のための部品です。
  • agent はその部品をどの順序で動かすかを決める workflow です。

こう理解すると、assistantagent を競合する概念として見る必要がないことも自然に見えてきます。両者は代替関係ではなく、層の関係です。

Agent の中でも実際の推論は assistant が行います(テキスト参照)

AiAgent
  - workflow を持つ上位の実行単位

Node
  - 必要なときに assistant を選んで呼び出す

RunnableAiAssistant
  - assistant + model + provider + key が組み合わさった実行オブジェクト

/ai-runs 経路では agent が会話文脈の上に乗ります

もう一つ面白いのが /ai-runs 経路です。これは保存済み agent をそのまま直接実行する API ではありません。まず thread の message を読み、その文脈の上で特定の AiAgent workflow を実行します。

現在の実装では、ChatAgentTaskHandlerAiAgentType.USING_WEB_SEARCH_TOOL のようなタイプの agent を選び、SingleWorkflowExecutor に渡します。つまりこの時点で run は単なる LLM 呼び出しではなく、会話文脈の上で agent workflow を開始する命令になります。

この点こそ、Omnilude の ai-service が単なるモデルラッパーよりもプラットフォームらしく見える理由の一つです。同じ推論構造でも、それが thread/message/run インターフェースの上に載ると、製品としての意味が変わるからです。

multiagent は今回は意図的に外します

コードを読んでいると multiagent パッケージも見えてきます。ただ、これは今回説明している AiAgent workflow とは少し性格が違います。複数の agent をオーケストレーションし、動的ルーティングやリトライ、human review のようなもっと大きな問題を扱う別レイヤーに近いです。

何より、現時点では実際の AiAgent runtime とまったく同じ層として説明するには、まだ進行中の部分があります。だから今回はあえて入れませんでした。この記事の範囲はあくまで assistant を組み合わせて一つの agent workflow を実行する構造 です。

このように範囲を絞るほうが、読者にとっても理解しやすく、説明もぼやけません。

私はこの構造がかなり現実的だと思っています

改めて見ると、Omnilude の ai-service における agent は、仰々しい名前の別の AI ではありません。assistant を包む別の wrapper でもありません。むしろもっと現実的です。

  • 実行フローは AiAgent.workflow に保存し
  • 実際の実行は DTE job として渡し
  • エンジンは開始ノードから edge に沿って進み
  • 各ノードは必要なときに assistant を呼んで LLM を使います

この構造が良いのは、拡張性を大げさに語っていないからです。assistant、thread、run、agent がそれぞれ違う役割を持ち、どこまでが各自の責任なのかが比較的はっきりしています。

私は、AI 機能を製品の中に入れれば入れるほど、このような構造が重要になると思っています。モデルを一度うまく呼ぶことよりも、どの単位で保存し、どのインターフェースで実行し、どの流れでつなぐかのほうが長く残るからです。

まとめ

前回の記事で assistant / thread / run が Omnilude の実行プラットフォームを形づくる基本概念だったなら、今回の agent はその上に置かれた構成レイヤーです。AiAgent は別の AI ではなく workflow を保存するエンティティであり、その実行は DTE -> WorkflowExecutor -> NodeExecutor の構造を通って流れます。

そして実際の推論は今でも assistant が担当します。結局、Omnilude の ai-service は assistant と agent を対立させるのではなく、assistant を部品として agent を組み立てる方法で大きくなっています。

次はこの構造をもう一歩実践側に寄せて、実際の workflow JSON とノード構成をどのように設計しているのかを、例を中心に説明してみます。