Omniludeのai-serviceはなぜAssistant / Thread / Run構造を採用したのか
はじめに
今回のポストでは、Omniludeのai-serviceの実装を共有してみようと思います。誰でも一度くらいは聞いたことがある有名な言葉があります。 「車輪を作り直すな」です。もちろん、私もこの言葉には共感しています。私はai-serviceのAI実行方式を設計するとき、以前のOpenAIのAssistants API(Beta)を使ったことがあり、その体験は悪くありませんでした。ちょうどそれ以上にひらめく案もなかったので、初期構造はかなり模倣に近い形で持ってきました。
OpenAIのAssistants APIの構造はかなり単純でした。assistant / thread / message / run / run stepという骨格があり、Assistantはプリセット、Threadは会話コンテナ、Runは実際の実行、と理解できる構造でした。OpenAI公式ドキュメントのdeep diveやFAQを見ても、この概念の軸ははっきりしています。
ただし、Omniludeはこの構造をそのまま複製したわけではありません。私たちはOpenAIだけを前提にしたサービスを作っていたわけではなく、ローカルモデル、OpenAI互換サーバー、Anthropic、Ollama、LM Studioのような複数の実行経路を、ひとつのプラットフォームの中で扱う必要があったからです。
そのため、Omniludeのai-serviceはOpenAI Assistantsから概念を借りつつも、実装自体はより分離された方向へ進みました。この文章では、その構造をできるだけわかりやすく説明してみます。
まず、何を保存しているのか
Omniludeのai-serviceには、AIに関するオブジェクトがいくつもあります。名前だけ見ると複雑に見えますが、役割を分けて見ると思ったより単純です。
AiProvider: どのプロバイダを使うかを保存します。OpenAI、Anthropic、Ollama、LM Studioのような軸です。AiApiKey: そのプロバイダに接続する認証キーを保存します。AiModel: 実際のモデル情報です。モデル名、reasoning対応可否、料金、コンテキストサイズなどが入ります。AiAssistant: モデルをどんな目的で、どんな指示で使うかを定義した実行プリセットです。AiThread、AiMessage、AiRun、AiRunStep: 会話と実行履歴です。AiAgent: 複数のassistantを組み合わせてワークフローとして実行する上位レイヤーです。
一文で縮めるとこうなります。
ProviderとModelは材料で、Assistantはレシピで、ThreadとRunは実際の注文と調理記録です。
図で見ると、関係はもう少しわかりやすくなります。
OpenAIの過去のAssistants APIと似ている部分もまさにここです。Assistant、Thread、Message、Run、Run Stepという骨格自体はよく似ています。ただしOmniludeは、provider / model / apiKeyをAssistantの外に出して、別の資産として管理します。
なぜわざわざこう分けたのか
理由は単純です。OmniludeではAssistantを、単なるプロンプトの塊として扱いたくなかったからです。
たとえばassistantをひとつ作るとします。そこで本当に保存したいのは、単なるinstructionsだけではありません。
- どのプロバイダを使うのか
- どのモデルを使うのか
- reasoningに対応しているのか
- response formatはtextなのかjsonなのか
- temperatureやtop-pをどう置くのか
- 後からモデルを変えても、どのモデルを元の基準点として見るのか
実際のAiAssistantエンティティを見ると、その考え方がそのまま入っています。modelIdとprimaryModelIdが分かれていて、apiKeyIdとprimaryApiKeyIdも分かれています。これは単に元の基準モデルを記録しておくためだけではありません。もっと重要なのはフォールバックです。特定のproviderがパニック状態になったり、あるキーが塞がれたり、現在つながっているモデルが不安定になったりしたとき、別のモデルとキーに切り替えてサービスを継続する必要があります。つまり、今つながっているモデルとキーと、本来メインとして扱うモデルとキーを分けておくことで、障害時でもより堅牢に運用できるようにしているわけです。
この構造のおかげでassistantは、単なるプロンプトテンプレートではなく、運用可能な実行プリセットになります。
ここが、OpenAI Assistantsと比べたときにOmniludeの実装が変わる重要な地点です。OpenAIのAssistantは、比較的model + instructions + toolsをひとまとまりで見る印象がありました。一方でOmniludeは、provider、model、api keyを先に独立した資産として置き、その上にassistantという組み合わせレイヤーを設計しています。
AssistantはDBエンティティで終わらない
ここで、もうひとつ重要な段階があります。保存されたAiAssistantが、そのまま実行されるわけではありません。
実際の実行直前に、AiAssistantServiceが次の情報を一度に組み合わせます。
- assistant自身のpreset値
- つながっているmodel情報
- つながっているprovider情報
- 暗号化して保存されたapi key
そして、この結果をRunnableAiAssistantという実行オブジェクトに落とします。
この名前はかなり重要です。Omniludeでは、DBに保存されたassistantと、実際に動かすassistantを分けて見ています。
- DB側の
AiAssistant: 設定を保存するエンティティ - 実行側の
RunnableAiAssistant: モデル呼び出しにすぐ使えるオブジェクト
こう分けると利点は明確です。
第一に、実行レイヤーはもはやJPAエンティティを気にしなくてよくなります。
第二に、providerごとの差異はRunnableAiModel実装の中に隠せます。
第三に、reasoningオプション、response format、samplingパラメータを実行時に一か所で解釈できます。
つまり、assistantをデータとして保存することと、assistantを実際に実行することを分離したのです。
実際の呼び出しはどう流れるのか
assistantを直接実行する、いちばん単純な流れはだいたい次のようになります。
この流れで中心になるのがLlmGatewayです。Omniludeでは、いまやほとんどの直接実行がこのゲートウェイを通ります。
しかも、このゲートウェイが担っている役割は単純ではありません。
- providerごとのRate Limit適用
- 実行開始と終了の記録
AiRun、AiRunStepの生成と状態管理- リクエストとレスポンスpayloadの記録
- トークンとコストの収集
つまり、単にassistant.chat()を呼んでいるわけではなく、実行追跡つきのプラットフォーム呼び出しとして包んでいるのです。
この違いはかなり大きくなります。AI機能を少し深く使い始めるだけでも、後になると「どんなプロンプトを使ったか」より、「どのassistantがどのモデルでいくら使って、どんな結果を出したか」のほうが重要になるからです。
なぜThread、Message、Runを残したのか
ここで、多くの人が一度はこう思うはずです。
assistantを直接実行できるのに、なぜわざわざThread、Message、Runまで分けて持つのか。
私はこの部分が、Omniludeの構造の中でもかなり重要だと思っています。
Assistantはあくまでプリセットです。しかし実際のユーザー体験は、プリセットひとつで終わりません。ユーザーは会話を続け、メッセージを積み上げ、ある時点の入力に対して実行を要求し、その結果をまたメッセージとして受け取ります。
Omniludeでは、その流れを次のように分けています。
AiThread: 会話部屋AiMessage: ユーザーとassistantがやり取りするターンAiRun: 特定のメッセージを基準にした実行要求AiRunStep: その実行内部の詳細トレース
これは、OpenAI Assistantsが見せていた構造を、実際のプロダクトランタイムにかなり現実的な形で持ち込んだ例だと思います。
たとえばクライアントは、まずthreadを作り、その中にuser messageを積み、その次に/ai-runsを呼びます。するとこの時点からは、単なるchat completion呼び出しではなく、会話文脈を持った実行単位になります。
なぜこれが重要なのでしょうか。
第一に、実行履歴を会話と結びつけて見られます。 第二に、同じthreadの中でどの時点から再開するかを管理できます。 第三に、タイトル生成、お気に入り、リセットのような付加機能をthreadレベルで付けられます。
AIサービスをプロダクトにしていくほど、単発の呼び出しより、こうしたインターフェースのほうが長く残ります。
ただしRunを押しても、いつもAssistantひとつだけが回るわけではない
ここでもOmniludeはOpenAI Assistantsと少し違います。
OpenAI Assistantsの印象は比較的明確でした。Assistantがあり、Threadがあり、Runがそのassistantをthread上で実行する構造です。
しかしOmniludeの/ai-runsは、今ではworkflowの入口に近いものです。実際にAiRunControllerはその場でモデルを呼ばず、DTEキューに作業を入れます。そしてChatAgentTaskHandlerがその作業を受け取り、threadのメッセージを読み、AiAgentのworkflowを実行します。
つまり、現在のOmniludeにおけるRunは、「assistantをひとつ呼ぶ」という意味だけにとどまりません。場合によっては、複数のassistantが入ったworkflowを実行する起点になります。
こう見ると、構造はよりはっきりします。
- Assistant: ひとつのLLMプリセット
- Agent: assistantを組み合わせたワークフロー
- Run: そのワークフローを現実に開始する実行単位
この違いによって、Omniludeのai-serviceは単なるassistant保管庫より、一段プラットフォームに近いものになります。
すべての経路が完全に同じではない
現実的な話もひとつ残しておきたいです。構造がきれいに整理されているとしても、現在のすべての入口がまったく同じ実行経路を通るわけではありません。
たとえばbackoffice playgroundやdirect inference側はLlmGatewayを通るので、追跡とコスト記録が比較的よく付いています。一方、内部システムチャットAPIである/system/ai/chatは、もう少し直接的な経路を使います。この経路ではassistantをロードしたあと、rate limiterを適用してそのままchatを呼ぶので、gatewayベースの追跡構造とは少し性格が違います。
私はこういう部分も、むしろ自然だと思っています。実際のプラットフォームは最初から完璧にひとつのパターンで整理されるより、使い道が増えるにつれて少しずつ共通構造へ収束していくことのほうが多いからです。
重要なのは、いまでも中心軸はすでに決まっているという点です。
- 管理オブジェクトは分離されていて
- 実行オブジェクトは
RunnableAiAssistantへ統一されつつあり - 会話と実行記録は
Thread / Message / Run / RunStepとして残り - 上位オーケストレーションは
AiAgentが担っています
私はこの構造がかなり気に入っています
改めて見直すと、Omniludeのai-serviceは単に「モデルをうまく呼ぶサービス」ではありません。
このサービスは、次の四つを同時にやろうとしています。
- 複数のproviderとmodelをひとつのプラットフォームで扱うこと
- assistantを再利用可能な実行プリセットにすること
- 会話と実行をthread/message/run構造として残すこと
- 将来的にはassistantを組み合わせてagent workflowへ拡張すること
私はこの方向はかなり現実的だと思っています。AI機能をプロダクトに入れ始めると、結局必要になるのはモデル呼び出し関数のいくつかではなく、実行可能なインターフェースだからです。
そしてそのインターフェースは、かつてOpenAI Assistantsが見せた概念構造に意外とよく似ています。ただ、Omniludeはそこから一歩進んで、providerとmodelをより明確に分け、trackingとworkflowをさらに強く埋め込む方向に発展させたと言えます。
まとめ
Omniludeのai-serviceにおけるAiAssistantは、単なるプロンプト保存庫ではありません。どのproviderとどのmodelをどう呼ぶか、そしてその結果をどんな形式で受け取り、どのランタイムの上で記録するかまでをまとめた実行プリセットに近いものです。
そしてAiThread、AiMessage、AiRun、AiRunStepは、そのプリセットを実際のプロダクトフローの中で動かすためのランタイムインターフェースです。ここにAiAgentが乗ることで、Omniludeのai-serviceは、assistantをいくつか管理する段階を越えて、workflowを運営するプラットフォームへと大きくなっています。
次回は、この話からもう一歩進んで、assistantを組み合わせてagentを作る過程を持ってきたいと思います。