独自の AI エージェントハーネスを構築する方法

@mfpiccolo
英語2 か月前 · 2026年5月28日
339K
1.9K
242
35
4.9K

TL;DR

著者は、従来の AI エージェントフレームワークは柔軟性に欠けると指摘し、認証情報からポリシーエンジンに至るまで、ハーネスのすべてのコンポーネントを独立した交換可能なワーカーとして扱う、モジュール式の「iii」エンジンアプローチを提案しています。

ほとんどのエージェントチームはハーネスを自作しない。既存のものを採用する。LangChain、LangGraph、OpenAI Agents SDK、Anthropic SDK、CrewAI、AutoGen、ループ、ツール、メモリ、オーケストレーションはすべてワンセットで選ばれる。ハーネスはインポートするフレームワークだ。中の一部が合わなければ、フォークするか、無理やり使うか、回避策を講じる。

Mike Piccolo - inline image

私はその形は間違っていると思う。それが、長期間エージェントを運用するチームが結局ハーネスをスクラッチから書き直す理由だ。ハーネスは一つのものではない。周辺のエコシステムがそれらを組み合わせる手段を提供していないため、10 か 12 もの異なるものがバンドルされている。 Pi agent パッケージは正しい方向にあるが、依然として「別のサービスを追加して、他のすべてと統合する」というパラダイムにとどまっている。iii エンジンはすべてのワーカーを同じものとして扱い、統合ロジックを完全に排除する。プロバイダルーター、認証情報ボールト、ポリシーエンジン、承認ゲート、モデルカタログ、セッションストレージ、予算トラッカー、コール後フックのファンアウト、耐久性のあるターンループは、それぞれ独立した関心事である。これらはすべて、キュー、HTTP/API サーバー、ストリーミング、さらにはブラウザワーカーとも相互運用可能だ。これらをひとまとめにして出荷するフレームワークは、本来必要のないトレードオフをあなたに強いている。

iii の根底にある賭けは、それらがひとまとめであるべきではない、というものだ。共有エンジン上に一連のワーカーが存在し、それぞれが交換可能で、独立してバージョン管理され、単一のプリミティブ、つまり他のすべてのワーカーも使用するトリガー(iii.trigger())で接続されるべきである。ハーネスはインストール可能なワーカーのスタックとなり、「独自に構築する」ということはもはや「フレームワークをフォークする」ことを意味しない。「いくつかのワーカーを交換する」ことを意味する。

この記事では、それが実際にどのようなものかを説明する。現在、iii エージェントのターンを駆動する完全なスタック、各レイヤーがなぜ独立したワーカーなのか、そしてどのようにして任意のレイヤーを置き換えることができるのか。

エージェントハーネスが果たすべき 15 のジョブ

プロダクション用のエージェントハーネスをその責務に分解すると、おおよそ次のようなリストになる:

  1. クライアントからのターンリクエストを受け入れ、永続化する
  2. 呼び出されるモデルプロバイダーの認証情報を解決する
  3. 選択されたモデルが実際に何ができるか(ビジョン、ツール、ストリーミング、コンテキストウィンドウ)を調べる
  4. ターンごとのステートマシンを駆動する:プロビジョニング、ストリームアシスタント、ツール実行、ステアリング、ティアダウン
  5. 各関数のリクエスト形式、エラーコード、使用法メモを記述したスキルボディをロードして提供する
  6. システムプロンプト、モード段落、アイデンティティプリアンブル、ワーキングディレクトリ、デフォルトスキル付録を組み立てる
  7. モデルが生成するトークンをクライアントにストリームバックする
  8. すべてのツールコール(単なる関数)を実行前にポリシーに対してチェックする
  9. 人間の判断が必要なツールコールを一時停止し、その回答を正しいターンにルーティングする
  10. ワークスペースまたはエージェントごとの予算に対して LLM 支出を追跡する
  11. ツールコールの前後にフックを実行する(ロギング、編集、カスタム副作用)
  12. セッションをブランチングツリーとして永続化し、フォークと再開を可能にする
  13. コンテキストウィンドウが満杯になったときにセッション履歴を圧縮する
  14. UI が購読するイベントストリームを発行する
  15. どのエージェント構築企業にも欠けているピース。すべてのステップにわたって単一の OpenTelemetry トレースを運び、デバッグを可能にする

真剣なエージェントはこれのほとんどを実装している。高価なものはすべてを実装する。安価なものは手を抜き、後でプロダクションに直面したときにその部分を再構築する。フレームワークはそれらをモノリスにバンドルし、それぞれのバージョンを一つだけ出荷する。最後の部分があなたにコストをもたらす。なぜなら、1 年後に、あなたが望むポリシーエンジンはフレームワークが出荷するものではなく、それを交換するにはハーネス全体を交換する必要があることに気づくからだ。

iii ハーネスは、これら 13 のジョブのそれぞれを、 workers.iii.dev レジストリ上の独立したワーカーとして出荷する。それぞれが同じ WebSocket プロトコルを使用する。それぞれが同じエンジンバス上に関数とトリガーを登録する。それぞれは iii worker add で追加可能、交換可能、そして SDK を持つ任意の言語で記述可能である。

ワーカーごとのスタック

以下が iii-hq/workers モノレポからの実際のプロダクションスタックであり、各ワーカーのジョブを一行で示している。バンドル全体は github.com/iii-hq/workers/harness で出荷されている:

Mike Piccolo - inline image

11 のワーカー。1 つのエンジン。それぞれが公開バージョンを持つ。それぞれがスタンドアロンプロセスとして独立して実行可能(開発時は pnpm dev:<worker>、リリースバイナリとして iii worker add <specific-worker>)か、またはそれらを一緒に起動する複合エントリポイントの一部として実行可能である。

これが重要な理由:そのテーブルのすべてのボックスは、誰かがあなたに別のワーカーを渡し、残りはそのまま維持できる場所である。静的モデルカタログが気に入らない? models::list を登録し、ライブ API から読み取るワーカーをプラグインする。ファイルベースの認証情報が気に入らない? auth::get_token を登録し、シークレットマネージャーから読み取るワーカーをプラグインする。異なる分岐をするワークフローに別のターン FSM が必要? turn-orchestrator を置き換える。すべての依存関係は同じバスを介して run::start を呼び出し、turn_state を読み取るため、スタックの残りは変更されない。

ループの実際の実行方法

1 ターンの形状は次のようになり、ワーカーを発火順にたどる。

ブラウザ/CLI/チャットが harness::trigger{session_id, message_id, payload} を含むターンを POST する。ハーネスメタワーカーはペイロードを run::start に転送する。このホップは、OpenTelemetry スパンラッパーがセッション ID とメッセージ ID をバゲッジとしてシードし、スタック内のすべてのワーカーにわたるすべてのネストされた iii.trigger 呼び出しに伝播するために存在する。もう一方の側のトレースツリーは接続されたグラフとなる。

run::startturn-orchestrator に着地する。実行リクエストを永続化し、初期の TurnStateRecord を iii ステートの session/<sid>/turn_state にシードし、すぐに戻る。実際の作業は耐久性のあるステートマシン内で行われ、turn-step FIFO へのパブリッシュによって起動される。

2 つの終端状態は、stoppedfinishSession() によるクリーン終了)と failed(予期しないハンドラースローによりここにルーティングされ、キューを確認応答して再試行を停止し、message_complete{stop_reason:'error'}agent_end を表面化して UI に理由を表示する)である。ティアダウンは、別のキューイングステップではなく、任意のターン終了パスから呼び出されるインラインの finishSession() ポートである。

provisioning は 3 つのことを行う。実行に分離実行が必要な場合、 iii-sandbox マイクロ VM を起動する。system_default_skills 内のすべての名前空間に対して directory::skills::download を呼び出す(デフォルトは ["iii://iii-directory/index"])。これにより、iii-directory は実行開始時に使用されるスキルボディを事前キャッシュする。さらに、システムプロンプトを 3 つのレイヤーで組み立てる:run_request.mode(plan、ask、または agent)から選ばれたモード段落、モデルに agent_trigger 規則と directory::skills::get のオンデマンド発見パターンを教える iii アイデンティティプリアンブル、そしてエージェントが起動時に持つデフォルトスキルのインデックスを追加したもの。呼び出し元は run::startsystem_prompt を渡すことでプロンプト全体を上書きできる。それ以外の場合はオーケストレーターが構築する。関数スキーマはライブエンジンカタログから取得される。

assistant_streaming は、実行の provider フィールドに一致するプロバイダーワーカーに対して provider::<name>::stream を呼び出す。プロバイダーワーカーは auth::get_token(auth-credentials)を介して認証情報を取得し、モデルの SSE レスポンスを iii チャンネルにストリームし、オーケストレーターはそのチャンネルをドレインして、UI ファンアウト用の agent::eventsmessage_update イベントを発行する。チャンネル作成と読み取りループは provider-stream.ts のプルベースの MessagePump の背後にあり、ストリーミング状態はトランジションに集中し続ける。

アシスタントがツールコールを返すと、FSM は function_execute に入る。すべてのツールコールは、オーケストレーター内の単一のチョークポイントである dispatchWithHook を通過する。consultBefore は 5 秒のタイムアウトで policy::check_permissions を直接呼び出す。ポリシーワーカー(デフォルトスタックではハーネスメタワーカー)は iii-permissions.yaml を読み取り、コールの function_id をルールセットと照合し、次の 3 つの結果のいずれかを返す:

  • allow:ディスパッチが進行する。オーケストレーターはターゲット関数をトリガーし、結果を書き込む。
  • deny:ディスパッチが DenialEnvelope で短絡され、結果は拒否レコードとなる。
  • needs_approval:個々のコールはターンの awaiting_approval リストにパークされる。バッチの残りはディスパッチを続ける。ターンは、1 つ以上のエントリが保留中の場合にのみ function_awaiting_approval に遷移する。

承認解除はリアクティブかつ共有される。オーケストレーターは、スコープ approvals 上に単一の turn::on_approval 状態トリガーを登録する。コンソールが approval::resolve を呼び出すと、approval-gate ワーカーは approvals/<sid>/<cid> = {decision, reason} を iii ステートに書き込む。この書き込みが turn::on_approval を発火させ、影響を受けるセッションを進める。function_awaiting_approval は着地した決定のみを読み取り、到着した各決定をディスパッチし(allow は事前承認ディスパッチに、deny または aborted は合成拒否になる)、awaiting_approval[] が空になると進行する。コールごとの再開関数を登録する必要はない。保留中の承認を回復するための起動時再スキャンも不要。1 つのトリガーですべてのセッションをカバーする。

構造上フェイルクローズ:ポリシーワーカーに到達できないか、5 秒のタイムアウトが発生した場合、consultBeforegate_unavailable エンベロープでコールを拒否する。iii::durable::publish 自体がエラーになった場合、フックファンアウトは publish_failed: true を返し、オーケストレーターはそれを拒否として扱う。

この形状からいくつかのレイテンシ改善が得られる。関数呼び出し後のフックは、トピックに登録された永続サブスクライバーがいない場合、サブスクライバープレゼンスキャッシュを介して publish_collect を短絡し、実行される関数呼び出しあたり約 500ms を削減する。tearing_downfinishSession() にインライン化され、ターンあたり 1 つの永続キュー内ホップを削除する。context-compaction は、オーケストレーターがターン境界で発行する専用の agent::turn_end ストリームを購読するため、コンパクターのウェイクアップはイベントごとではなくターンごとになる。セッション作成ファンアウト状態トリガーはスコープのみでゲートされ、インプロセスで一致するため、以前の書き込みごとの harness::session::is_create_event RPC は不要になった。

バッチ完了後、steering_check は継続、停止、または最大ターン数に達したかを判断する。継続の場合は assistant_streaming にループバックする。停止または最大の場合は finishSession() がインラインで実行される:agent_end を発行し、サンドボックスを解放し、stopped に遷移する。

実行全体を通して、参加するすべてのワーカーは iii.session.idiii.message.idiii.function.id でタグ付けされた OTel スパンを発行する。これらのタグは、エンジンの engine::traces::group_by がトレース UI で「Group by Session」/「Group by Message」/「Group by Function」を表示するために読み取るものである。インストルメンテーションは自動的である:src/runtime/worker.ts はすべての registerFunction をプロキシでラップするため、ワーカーごとのコードがスパンを追加するのを覚えておく必要はない。

独自に構築する

興味深いのは、上記のワーカーのどれも特別ではないということだ。それぞれはエンジンに WebSocket を開き、いくつかの関数とトリガーを登録し、実行するプロセスである。契約はすべてのアプリケーションワーカーが使用するものと同じである。ハーネスは、あなたのビジネスロジックが構築されているのと同じプリミティブの上に構築されている。

つまり、「独自のハーネスを構築する」ことは、「任意のワーカーを書く」ことと同じ操作に分解される。置き換えたいレイヤーを選び、バス上に同じ関数を登録するワーカーを書き、iii worker add で追加すれば、スタックの残りがあなたのワーカーを使い始める。

上のワーカーテーブルには登場しないが、ハーネスの動作に重要な 2 つのレイヤーがある。スキルは、各ワーカーがその関数の機能をどのように宣伝するかである。すべてのワーカーは iii://<worker>/<function> にスキルを公開でき、エージェントはその関数を初めて呼び出す前に directory::skills::get を介してそれを取得する。システムプロンプトはターンごとに、モード段落、iii アイデンティティプリアンブル、および実行時に設定されたデフォルトスキルボディから組み立てられる。両方ともバス駆動である。スキルは iii-directory ワーカーによって提供され、システムプロンプトは turn-orchestrator によって組み立てられる。両方とも置き換え可能である。

5 つの具体例。

モデルカタログをライブ API に置き換える。models::listmodels::getmodels::supports を登録するワーカーを書く。プロバイダーのカタログエンドポイントから N 分ごとに取得し、キャッシュする。公開する。iii worker add your-org/dynamic-models-catalog を実行する。静的な models-catalog ワーカーを停止する。turn-orchestrator は違いに気づかない。iii.trigger('models::list') を呼び出すと、エンジンはその関数 ID を最も最近登録したワーカーにルーティングする。

新しいプロバイダーを追加する。形状は provider-kimiprovider-lmstudio ですでに実証されている。それぞれは provider::<name>::streamprovider::<name>::complete を登録する 1 つのワーカーであり、上流 API からの SSE ストリームを iii チャンネルにドレインし、そのモデル使用量を budget::record を介して llm-budget に書き込む。5 つ目のプロバイダーを追加することは、1 つのフォルダーと 1 つの iii.worker.yaml と 1 つの register.ts を書くことである。レジストリに公開するか、ローカルに保持する。turn-orchestrator は実行の provider フィールドによってプロバイダーを選択する。新しいプロバイダーは、ワーカーが接続した瞬間に利用可能になる。

プライベートアーティファクトストアからスキルを提供する。directory::skills::getdirectory::skills::list を登録するワーカーを書く。バックエンドは内部のドキュメントシステムまたはプライベート S3 バケットとする。デフォルトの iii-directory ワーカーを切断するか名前を変更する。オーケストレーターのブートストラップは名前空間ごとに directory::skills::download を呼び出す。あなたのワーカーが応答する。エージェントの「新しい関数を呼び出す前に関数ごとのスキルを取得する」パターンは、ワイヤー形状が同じであるため変更されずに機能し続ける。

システムプロンプトを完全に上書きする。run::start はオプションの system_prompt フィールドを受け入れる。それを渡すと、オーケストレーターはあなたの文字列をそのまま使用し、モード段落 + アイデンティティプリアンブル + スキル付録の組み立てをスキップする。既存のプロンプトアセットを変更せずにハーネスに尊重させたい場合に便利。スキルのダウンロードはブートストラップで引き続き実行されるため、カスタムプロンプトでもエージェントは directory::skills::get によるオンデマンド発見を維持する。

承認ゲート UI サーフェスを置き換える。デフォルトの approval-gate ワーカーは approval::resolve を登録する。ワイヤースキーマは 1 つの関数呼び出しである:

{argument name="xxx" default="yyy"} このパターンは日本語に翻訳:「{argument name="xxx" default="yyy"}」ですが、実際のソーステキストにはこのパターンは登場しません。そのため、そのまま保持します。注意:原文には {argument ...} 構文はありませんが、もしあれば、name 値と default 値を翻訳する必要があります。ここでは該当なし。

ハンドラーは approvals/<sid>/<cid> = {decision, reason} を iii ステートに永続化する。オーケストレーターの単一の turn::on_approval 状態トリガーがその書き込みを拾い上げ、適切なセッションを起動する。コンソールの代わりに Slack から承認を駆動したい場合は、/approve <id>/deny <id> のスラッシュコマンドをリッスンし、適切なペイロードで approval::resolve を呼び出す Slack ワーカーを書く。オーケストレーターは違いに気づかない。承認ゲートワーカー全体はそのまま変更されない。新しいワーカーを追加しただけで、既存のものを置き換えたわけではない。

別のポリシーエンジン(OPA、Cedar、独自 DSL)が必要な場合は、policy::check_permissions を登録し、{ decision, rule_id?, matched_constraint? } を返すワーカーを書く。デフォルトのポリシーワーカー(ハーネスメタワーカー内にラップされているため、そのハンドラーを無効化するか、簡略化されたメタワーカーを実行する)を切断する。turn-orchestratorconsultBefore は違いに気づかない。同じ 5 秒のタイムアウト、同じフェイルクローズセマンティクス、同じワイヤー形状。

これらの例のポイントは特定の置き換えではない。操作の形状である。iii スタック内のすべてのハーネスレイヤーは、バス上の 1 つまたは 2 つの関数 ID を介して到達可能である。レイヤーを置き換えることは、それらの ID を登録するワーカーを書くことである。システムの残りはそのままである。

ハーネスはスライダーであり、分岐点ではない

古典的なハーネスの議論は、薄いか厚いかという枠組みを設定する。Anthropic の薄いループ対 LangGraph の明示的な DAG。この枠組みは、一方を選んでそれで我慢することを前提としている。

ハーネスが同じバス上のワーカーで構成されている場合、薄いか厚いかは単にインストールするワーカーの数である。薄いハーネスは、turn-orchestratorprovider-anthropicauth-credentials、最小限のハーネスメタワーカーを加えたものだけ。それだけ。承認、予算、ポリシーエンジン、フックファンアウトはなし。何でも実行する。モデルを信頼する。自律研究エージェント、実験的なループ、社内のあらゆるものに有用。

厚いハーネスは、13 のワーカーすべてに加えて、context-compaction、カスタムポリシーワーカー、カスタム承認ゲート、Slack 統合承認サーフェス、ワークスペースごとの上限を強制する予算ワーカーを含む。すべてのツールコールが監査可能で、すべてのモデル支出が財務ダッシュボードに集約される必要がある顧客ワークフローを実行するエージェントに有用。

薄いと厚いの間のアーキテクチャ上の距離は、書き換えではない。設定変更である。同じワイヤプロトコル、同じトレース形状、同じ可観測性。スライダーは、config.yaml からワーカーを追加および削除することで動く。他のすべては変わらない。

これは単一のワーカー内でも適用される。turn-orchestrator はちょうどリファクタリングをリリースし、FSM を 11 状態から 7 状態に削減し、コールごとの turn::approval_resume::<sid>/<cid> メカニズムをなくして、スコープ approvals 上の 1 つのリアクティブな turn::on_approval 状態トリガーに置き換え、tearing_downfinishSession() ポートにインライン化した。スタック内の他のすべてのワーカー(approval-gatesessionllm-budget、プロバイダー、models-catalogauth-credentialshook-fanoutcontext-compaction)は変更されなかった。approval::resolve のワイヤー形状は動かなかった。契約は維持された。これが構成があなたに与える特性である:1 つのワーカーの大規模な内部書き換えは自己完結型の変更であり、隣接するすべてのワーカーはバスレベルの関数 ID を介して通信するからだ。

これが、フレームワークモデルがあなたに提供できない部分である。フレームワークはあなたのためにスライダー上の位置を選び、そこに固定する。ワーカーモデルはスライダーをあなたの手に委ねる。

これが実際に意味すること

もしあなたがフレームワーク上でエージェントを運用していて、ほとんどのチームがスケール時に直面するのと同じ境界問題を感じているなら、おそらく答えは「独自のフレームワークでハーネスを書き直す」ではない。ポリシーエンジンが必要な方法で拡張できない。承認 UI がフレームワークのチャットサーフェスに配線されている。認証情報ストアがシークレットマネージャーと通信できない。予算トラッカーはトレースが見えないサイドカーデータベースにある。答えは、ハーネスがそもそも分解されている基盤に切り替えることである。

この議論を体感する最も速い方法は、github.com/iii-hq/workers をクローンし、pnpm installpnpm build を実行し、複合エントリポイントを実行することだ。14 ワーカーの完全なハーネスが iii エンジンを指して表示される。ブートリストからエントリを削除することで、任意のワーカーを無効にできる。同じ関数 ID を登録する代替ワーカーを書くことで、任意のワーカーを交換できる。フックトピックにサブスクライバーを追加することで、任意のワーカーを拡張できる。hook-fanout::publish_collect は、すべての iii フックが基づく汎用的なものだ。

ドキュメントは iii.dev/docs にある。エンジンは github.com/iii-hq/iii にある。ワーカーレジストリは workers.iii.dev にある。ハーネスバンドルは github.com/iii-hq/workers/harness にある。

賭け

ハーネスはインストールするものではない。ハーネスは、エージェントが耐久性、安全性、観測可能性をもって実行するためにシステムが実行しなければならない一連のジョブである。フレームワーク時代は、それらのジョブをバンドルした。なぜなら、その下にそれらを構成する方法を提供するものが何もなかったからだ。

iii の賭けは、1 つのプリミティブ、つまり WebSocket を介してエンジンに接続し、関数とトリガーを登録するワーカーが、それらのジョブのそれぞれを個別に吸収するのに十分に小さく、その結果得られるスタックがどのフレームワークよりも有用である、というものである。すべてのレイヤーが独立して置き換え可能だからだ。

あなたは iii ハーネスを採用するのではない。必要なワーカーをインストールし、必要なものを書き、あなたのシステムに正確に適合したハーネスを得る。すべてのレイヤーで同じプロトコル。すべての呼び出しで同じトレース。レジストリから取得する部分にも、自分で公開する部分にも、同じ iii worker add を使用する。

基盤が正しい形状であるとき、「独自のエージェントハーネスを構築する」とはこういうことだ。ワーカーを選ぶ。欠けているものを書く。構成する。ハーネスは構成そのものである。

現代世界が必要とする完璧なエージェントハーネスを一緒に作りましょう:discord.gg/iiidev

iii はオープンソースです。始めるには iii.dev/docs をご覧ください。ハーネスワーカーは github.com/iii-hq/workers に、エンジンは github.com/iii-hq/iii にあります。

— Mike Piccolo, Founder & CEO @iiidevs

ワンクリック保存

YouMindでバイラル記事をAI深読み

ソースを保存し、的を絞った質問をし、主張を要約して、バイラル記事を再利用できるノートに変えます。すべてを1つのAIワークスペースで行えます。

YouMindを探索
クリエイターのために

あなたの Markdown をきれいな 𝕏 記事に

自分の長文を投稿するとき、画像・表・コードブロックを 𝕏 向けに整形するのは手間がかかります。YouMind は Markdown 全体を、そのまま投稿できるきれいな 𝕏 記事に変換します。

Markdown → 𝕏 を試す

解読すべきパターンをもっと

最近のバイラル記事

バイラル記事をもっと見る