はじめに
Oracle Autonomous AI DatabaseのSelect AI Agentで、Supervisor型のマルチエージェントをかなり小さく試してみました。
Supervisor Agentは、ユーザーの依頼を見て、登録されているWorker Agentの中から誰に仕事を振るかを決める司令塔役です。Workerから結果が返ってきたら、別のWorkerも呼ぶのか、そのまま回答するのかもSupervisorが判断します。
今回はあくまで「触ってみた」なので、複雑な業務処理は作りません。
なお、「Autonomous AI Databaseだけで」といいつつ、生成AIサービスも使います。AgentがDBで動いても、LLMまでDBの中で動くわけではないからね。しょうがないね。
Agent、Task、Tool、Teamの定義、PL/SQL Toolの実行、Teamのオーケストレーション、履歴確認はAutonomous AI Database側で行います。LLMの推論部分は、AI Profileに設定したOCI Generative AIへお願いします。
今回作るのは次の3役です。
- 在庫確認を担当するWorker
- 補充申請案の作成を担当するWorker
- 依頼内容に応じてWorkerを選ぶSupervisor
依頼を変えたら別々のWorkerとToolが呼ばれるかだけを見てみます。
今回確認すること
確認したいのは、ほぼこれだけです。
Supervisor Agentへ異なる依頼を渡すと、依頼内容に合ったWorkerとToolが選択されるか。
業務テーブルの検索も更新もしません。2つのToolは「自分が呼ばれました」と分かる固定文字列を返すだけです。潔いほどの最小構成です。
「SAMPLE-001の在庫を確認して」
→ STOCK_WORKER
→ CHECK_STOCK_TOOL
「SAMPLE-001の補充申請案を作って」
→ REQUEST_WORKER
→ PREPARE_REPLENISHMENT_TOOL
もちろん、この固定文字列はToolの振り分けを見るためだけのものです。本物の在庫や申請を扱っているわけでも、外部APIを呼んでいるわけでもありません。
検証環境
今回実際に使った環境は次のとおりです。
- Oracle Autonomous AI Transaction Processing Serverless
- Tokyoリージョン
- Database Version 26ai
- 2 ECPU、20GB Storage
- Compute Auto Scalingは無効
- OCI Generative AIはOsakaリージョン
- Modelは
meta.llama-3.3-70b-instruct
Developer構成も試しましたが、検証用コンパートメントのDeveloper ADB Quotaを使い切っていたため、通常ADBの最小構成に切り替えました。時間課金なので、短時間だけ使って終わったら止める作戦です。
構成
Select AI Agentでは、Agent、Task、Tool、Teamを組み合わせて処理を作ります。
MIN_SUPERVISOR_TEAM
├── ORCHESTRATOR_AGENT(Supervisor)
├── STOCK_WORKER
│ └── STOCK_TASK
│ └── CHECK_STOCK_TOOL
└── REQUEST_WORKER
└── REQUEST_TASK
└── PREPARE_REPLENISHMENT_TOOL
- Agent:使用するAI Profileと役割を定義する
- Task:Agentへ渡す指示と、利用可能なToolを定義する
- Tool:PL/SQL Functionなど、Agentが実際に呼び出せる処理を定義する
- Team:SupervisorとWorker Agent・Taskの組を関連付ける
Supervisor型Teamの処理は逐次実行です。Workerが一斉に「俺に任せろ」と走り出すわけではありません。今回確認した機能では並列実行ではありません。
AI Providerへ接続する
本来ならADBのResource PrincipalとIAM Policyを使いたいところですが、今回は検証環境のIAM Policyを変更できませんでした。そこで、既存のOCI API署名鍵をADB Credentialへ登録し、そのCredentialをAI Profileから参照しました。
短時間の動作確認を優先した構成です。実運用なら、秘密鍵をDBへ保存しなくてよいResource Principalなど、運用要件に合った認証方式を検討してください。
Credentialの作成例です。値は自分のOCI環境のものへ置き換えます。秘密鍵をSQL履歴へ残したくない場合は、Database Actionsのバインド変数などを使って入力するのがよさそうです。
BEGIN
DBMS_CLOUD.CREATE_CREDENTIAL(
credential_name => 'OCI_USER_CRED',
user_ocid => '<OCI_USER_OCID>',
tenancy_ocid => '<OCI_TENANCY_OCID>',
private_key => '<OCI_API_SIGNING_PRIVATE_KEY>',
fingerprint => '<OCI_API_KEY_FINGERPRINT>'
);
END;
/
続いてAI Profileを作成します。
BEGIN
DBMS_CLOUD_AI.CREATE_PROFILE(
profile_name => 'QI_SUPERVISOR_PROFILE',
attributes => '{
"provider": "oci",
"credential_name": "OCI_USER_CRED",
"region": "ap-osaka-1",
"model": "meta.llama-3.3-70b-instruct",
"temperature": 0,
"max_tokens": 200
}'
);
END;
/
まずはAgentを作る前に、ADBからOCI Generative AIへ到達できるかだけ確認しました。
SELECT DBMS_CLOUD_AI.GENERATE(
prompt => 'Reply with exactly OK',
profile_name => 'QI_SUPERVISOR_PROFILE',
action => 'chat'
) AS response
FROM dual;
実行結果はOKでした。これで、少なくともAgent以前のLLM接続は切り分けられました。
検証用Functionを作る
どちらのToolが呼ばれたのか一目で分かるように、検証用のPL/SQL Functionを2つ作ります。
CREATE OR REPLACE FUNCTION demo_check_stock(
p_item_code IN VARCHAR2
) RETURN VARCHAR2
IS
BEGIN
RETURN 'CHECK_STOCK_TOOLが呼ばれました。商品コード=' || p_item_code ||
'(検証用固定レスポンス)';
END;
/
CREATE OR REPLACE FUNCTION demo_prepare_replenishment(
p_item_code IN VARCHAR2
) RETURN VARCHAR2
IS
BEGIN
RETURN 'PREPARE_REPLENISHMENT_TOOLが呼ばれました。商品コード=' || p_item_code ||
'(検証用固定レスポンス。DB書込みなし)';
END;
/
補充申請側も、実際のINSERTはしません。「申請案を作るToolが呼ばれた」という体で文字列を返すだけです。今回はルーティングだけ見られればよしとします。
Toolを作る
先ほどのFunctionをSelect AI AgentのToolとして登録します。
BEGIN
DBMS_CLOUD_AI_AGENT.CREATE_TOOL(
tool_name => 'CHECK_STOCK_TOOL',
attributes => '{
"instruction": "指定された商品コードの在庫状況を確認するときに使用します。",
"function": "DEMO_CHECK_STOCK"
}'
);
DBMS_CLOUD_AI_AGENT.CREATE_TOOL(
tool_name => 'PREPARE_REPLENISHMENT_TOOL',
attributes => '{
"instruction": "指定された商品コードの補充申請案を作るときに使用します。実際の申請登録は行いません。",
"function": "DEMO_PREPARE_REPLENISHMENT"
}'
);
END;
/
Teamを組む前に、Tool単体でも実行しておきました。
SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(
tool_name => 'CHECK_STOCK_TOOL',
input => '{"P_ITEM_CODE":"SAMPLE-001"}'
) AS result
FROM dual;
実際の結果です。
{
"status": "success",
"result": "'CHECK_STOCK_TOOLが呼ばれました。商品コード=SAMPLE-001(検証用固定レスポンス)'"
}
PREPARE_REPLENISHMENT_TOOLも同様に単体実行し、次の結果になりました。
{
"status": "success",
"result": "'PREPARE_REPLENISHMENT_TOOLが呼ばれました。商品コード=SAMPLE-001(検証用固定レスポンス。DB書込みなし)'"
}
ここまでで、FunctionとToolそのものは動いています。あとはSupervisorがどちらを選ぶかです。
Worker Agentを作る
BEGIN
DBMS_CLOUD_AI_AGENT.CREATE_AGENT(
agent_name => 'STOCK_WORKER',
attributes => '{
"profile_name": "QI_SUPERVISOR_PROFILE",
"role": "在庫確認担当です。在庫確認の依頼だけを処理します。",
"enable_human_tool": false
}'
);
DBMS_CLOUD_AI_AGENT.CREATE_AGENT(
agent_name => 'REQUEST_WORKER',
attributes => '{
"profile_name": "QI_SUPERVISOR_PROFILE",
"role": "補充申請案の作成担当です。補充申請案の依頼だけを処理します。",
"enable_human_tool": false
}'
);
END;
/
Taskを作る
Workerが使えるToolはTask側で分けます。在庫担当には在庫確認Toolだけ、補充担当には補充申請案Toolだけを渡します。
BEGIN
DBMS_CLOUD_AI_AGENT.CREATE_TASK(
task_name => 'STOCK_TASK',
attributes => '{
"instruction": "ユーザーの依頼: {query}。商品コードを取り出し、CHECK_STOCK_TOOLを必ず1回使用して結果を返してください。",
"tools": ["CHECK_STOCK_TOOL"]
}'
);
DBMS_CLOUD_AI_AGENT.CREATE_TASK(
task_name => 'REQUEST_TASK',
attributes => '{
"instruction": "ユーザーの依頼: {query}。商品コードを取り出し、PREPARE_REPLENISHMENT_TOOLを必ず1回使用して結果を返してください。",
"tools": ["PREPARE_REPLENISHMENT_TOOL"]
}'
);
END;
/
Supervisor AgentとTeamを作る
supervisorをtrueにしたAgentを作り、Teamのsupervisor_agentへ指定します。
BEGIN
DBMS_CLOUD_AI_AGENT.CREATE_AGENT(
agent_name => 'ORCHESTRATOR_AGENT',
attributes => '{
"profile_name": "QI_SUPERVISOR_PROFILE",
"role": "在庫確認はSTOCK_WORKER、補充申請案はREQUEST_WORKERへ委譲し、不要なWorkerは呼ばないでください。",
"supervisor": true,
"enable_human_tool": false
}'
);
DBMS_CLOUD_AI_AGENT.CREATE_TEAM(
team_name => 'MIN_SUPERVISOR_TEAM',
attributes => '{
"process": "sequential",
"supervisor_agent": "ORCHESTRATOR_AGENT",
"agents": [
{"name": "STOCK_WORKER", "task": "STOCK_TASK"},
{"name": "REQUEST_WORKER", "task": "REQUEST_TASK"}
]
}',
description => 'Qiita向けSupervisorルーティング最小検証'
);
END;
/
実行してみる
Database ActionsなどのWeb SQL ClientからRUN_TEAMする場合は、conversation_idを渡します。渡さずに実行したところ、ORA-20053: Conversation_id is not set in the sessionになりました。素直に渡しましょう。
まずは在庫確認です。今回は実行ごとに新しいConversationを作っています。
SELECT DBMS_CLOUD_AI_AGENT.RUN_TEAM(
team_name => 'MIN_SUPERVISOR_TEAM',
user_prompt => 'SAMPLE-001の在庫を確認して',
params => '{"conversation_id":"' ||
DBMS_CLOUD_AI.CREATE_CONVERSATION() || '"}'
) AS response
FROM dual;
続いて補充申請案をお願いします。
SELECT DBMS_CLOUD_AI_AGENT.RUN_TEAM(
team_name => 'MIN_SUPERVISOR_TEAM',
user_prompt => 'SAMPLE-001の補充申請案を作って',
params => '{"conversation_id":"' ||
DBMS_CLOUD_AI.CREATE_CONVERSATION() || '"}'
) AS response
FROM dual;
補充申請案側のTeam応答は、次のようになりました。
SAMPLE-001の補充申請案を作成しました。
LLMの回答文は変わる可能性があるので、今回の合否は回答文ではなく履歴に記録されたWorkerとToolで判断します。
実行履歴を確認する
まずUSER_AI_AGENT_TEAM_HISTORYを確認します。
SELECT
team_exec_id,
team_name,
state,
start_date,
end_date
FROM user_ai_agent_team_history
WHERE team_name = 'MIN_SUPERVISOR_TEAM'
ORDER BY start_date DESC
FETCH FIRST 2 ROWS ONLY;
2回ともSUCCEEDEDでした。
TEAM_EXEC_ID STATE START_DATE END_DATE
------------------------------------ ---------- ----------- -----------
5A594565-2114-C138-E063-9C14000A97E8 SUCCEEDED 14:03:48 14:03:52
5A593A5F-37B1-0A81-E063-9C14000A558B SUCCEEDED 14:00:43 14:00:50
続いてUSER_AI_AGENT_TOOL_HISTORYで、それぞれのTeam実行IDを確認しました。
SELECT
team_exec_id,
agent_name,
task_name,
tool_name,
input,
tool_output
FROM user_ai_agent_tool_history
WHERE team_exec_id IN (
'5A594565-2114-C138-E063-9C14000A97E8',
'5A593A5F-37B1-0A81-E063-9C14000A558B'
)
ORDER BY start_date;
実際の履歴は次の2行でした。
AGENT_NAME TASK_NAME TOOL_NAME INPUT
-------------- -------------- ------------------------------- --------------------------------
STOCK_WORKER STOCK_TASK CHECK_STOCK_TOOL {"P_ITEM_CODE":"SAMPLE-001"}
REQUEST_WORKER REQUEST_TASK PREPARE_REPLENISHMENT_TOOL {"P_ITEM_CODE":"SAMPLE-001"}
在庫確認のTeam実行にはCHECK_STOCK_TOOLの1行だけ、補充申請案のTeam実行にはPREPARE_REPLENISHMENT_TOOLの1行だけが記録されました。不要なWorkerやToolは履歴に出ていません。
これで、今回やりたかった「依頼に応じてSupervisorがWorkerとToolを選び分ける」を確認できました。ずいぶん低いゴールですが、「触ってみた」なので大丈夫です。
なお、今回の環境では履歴のTOOL_OUTPUT列はNULLでした。Toolの戻り値そのものは先ほどのRUN_TOOL単体実行で確認し、SupervisorのルーティングはAGENT_NAME、TASK_NAME、TOOL_NAME、INPUTで確認しています。履歴に列があるから必ず値が入る、と決めつけないほうがよさそうです。
実務では何がうれしいのか
今回は固定文字列を返しただけですが、実務ではたとえば次のような使い方が考えられます。
- Workerごとに、公開するToolや担当範囲を限定する
- 参照するAgentと、データを更新するAgentを分ける
- 業務領域ごとに、別のDatabase FunctionやREST APIを割り当てる
- 依頼内容に応じて、必要なWorkerだけを呼ぶ
- WorkerごとのRoleとTaskを小さく保つ
- LLMへ渡すコンテキストを必要な範囲に絞る
- Toolの候補を減らし、期待どおり動いたか評価しやすくする
- あるWorkerのプロンプトやToolを変更したとき、ほかへの影響を抑える
- 失敗したAgent、Task、Toolを履歴から切り分ける
- Team実行IDから、一連の処理を追いかける
- Toolへ渡された入力や、選択されたToolをあとから確認する
- 書込みや外部通知を行うWorkerだけに、人間確認を追加する
- 機密性の異なるデータソースをWorker単位で分ける
- 部門ごとにToolの管理を分ける構成へ発展させる
固定文字列を返すだけの今回の例からはずいぶん話が広がりましたが、いろいろ応用できそうです。
とはいえ、何でもWorkerに分ければよいわけでもありません。Workerを増やせば、構成もプロンプトもテスト対象もLLMの呼出回数も増えます。処理順がいつも同じなら、普通の逐次処理やPL/SQLで十分かもしれません。依頼内容や途中結果によって、呼ぶWorkerや処理の終わり方が変わる場面で使うのがよさそうです。
あと、履歴ビューがあるので「監査にも使えそう」と言いたくなりますが、これだけでコンプライアンス要件を満たす監査ログになるわけではありません。まずは実行経路の追跡、デバッグ、Toolの利用状況確認に便利、くらいに捉えるのがよさそうです。
まとめ
Oracle Autonomous AI DatabaseのSupervisor Agentパターンを、2つのWorkerと2つの検証用Toolで、かなり小さく試してみました。
- ADB 26aiからOCI Generative AIを呼び出せた
- 2つのToolを単体実行し、それぞれ別の固定文字列が返った
- Supervisorへ在庫確認を渡すと
STOCK_WORKERとCHECK_STOCK_TOOLが選ばれた - Supervisorへ補充申請案を渡すと
REQUEST_WORKERとPREPARE_REPLENISHMENT_TOOLが選ばれた - 2回のTeam実行はどちらも
SUCCEEDEDだった - 選ばれたAgent、Task、Tool、入力を履歴ビューから確認できた
実処理を作り込まなくても、SupervisorがWorkerを選び分ける流れは体験できました。
「Supervisor型マルチエージェントって結局どんな感じ?」を知る最初の一歩としては、これくらいでも十分ではないでしょうか。