TT Lab
はじめる
学ぶ 学習パス コース

AIエージェント — モデルではなくグラフ

ツールは契約だ — 呼ぶ前に測り、受け取ってからまた測る

TT Labで続きを見る

一言でいうと

エージェントの事故の半分は、モデルではなく、ツールを呼び出す側で起きます。引数を測らずに呼び出し、戻ってきたものを測らずに信じるからです。

なぜ必要なのか

ツールを付けたエージェントが最初に壊れる様子は、たいてい次のとおりです。

TypeError: stock_lookup() got an unexpected keyword argument 'sku_id'

モデルが引数名を1つ変えてでっち上げ、それがそのまま関数に渡され、例外がグラフの外に飛び出しました。この例外は、ノードではなく実行全体を終わらせます。状態も、たどってきた道も、なぜそうなったかも、残りません。

その次に壊れる様子は、例外ではないので、さらに悪いものです。ツールが空のリストを返したのに、その上に答えを作ってしまうことです。ユーザーは「在庫3か所」という文を受け取り、ログにはエラーが1つもありません。

2つの事故の原因は同じです。ツールの呼び出しに契約がありません。どの引数をどの型と範囲で受け取るか、何をどんな形で返すかを、コードが1か所に書いておかないと、測るコードがノードごとに散らばるか、そもそもありません。

契約をどこに書くか

契約は、人が読むドキュメントではなく、プログラムが読む値である必要があります。名前1つに、引数スキーマ・返す形・実際の関数を結びつけて、表1つにしておきます。

TOOLS = {
    "stock_lookup": {
        "fn": stock_lookup,
        "args": {"sku": {"type": "str", "required": True},
                 "region": {"type": "str", "required": True, "allowed": ["busan", "jeju", "seoul"]},
                 "limit": {"type": "int", "required": True, "min": 1, "max": 2}},
        "returns": {"sku": {"type": "str"},
                    "rows": {"type": "list", "max_len": 2}},
    },
}

こうしておくと、3つのことが一度についてきます。1つ目に、引数を測るコードが、ツールごとに1つずつ必要ありません。表を読む関数が1つあれば済みます。2つ目に、モデルに渡すツールの説明を、この表から取り出せます。3つ目に、ツールを呼び出す入口が1か所に絞られます。Workflows and agentsが示すツール呼び出しの構造も、結局は、この狭い入口1つをどこに置くかという話です。

呼び出す前に測る

引数の検証で最も重要なのは、呼び出す前という順序です。呼び出してから例外を捕まえるのとは違います。間違った引数で1回呼び出した瞬間に、お金が出ていったり、メールが送信されたり、注文が入ったりします。照会ツールなら取り戻せますが、書き込みツールはそうではありません。

測るのは4つです。足りない引数、型、範囲、許可リスト。これに、契約にない引数を渡すこと(タイプミス・ハルシネーション)まで加えると、5つです。

Pythonで型を測るときに、必ず引っかかる落とし穴が1つあります。boolはintのサブタイプなので、isinstance(True, int)が真です。個数の場所にTrueが入っても、「整数」の検査を通過するという意味です。そのため、整数を測るときは、isinstance(value, bool)を先に除外する必要があります。

検証が何を返すかも、決めておきましょう。真偽値1つでは足りません。何がなぜ間違っているかが、理由の文字列として残って初めて、次に使えます。allowed:regionのように、「どの検査が」「どの引数で」引っかかったかを1行に入れておけば、その文字列1つで、あとで対応を選べます。

引数を選ぶ場所は、もともとモデル

この文章と次のラボは、モデルを呼び出しません。引数を選ぶノードがルールになっているだけで、実際のエージェントは、その場所でモデルに尋ねます。どのツールを呼び出すか、引数を何で埋めるかを、モデルが決めます。残りの構造は、1行も変わりません。

むしろ、選ぶ側がモデルであるほど、この構造がもっと必要になります。ルールは間違えても同じ方法で間違えますが、モデルは毎回違う間違え方をします。引数名を似たものに変え、数値を文字列で渡し、契約にない地域名をでっち上げます。そのため、契約表からモデルに渡すツールの説明を取り出し、モデルが返した引数を同じ契約表でもう一度測る、2つの方向が1か所でかみ合うようにしておくことが、この設計の価値です。

受け取ったあとでも測る

ツールが返したものは、他人が作った値です。自分たちの関数の戻り値のように扱ってはいけません。実際によく出会うものが4つあります。

この4つを測る関数も、契約表を読んで作ります。結果の検証をノードの中に手で書くと、ツールが増えるたびに、抜けます。

失敗は例外ではなく値

ツールを呼び出す入口は、例外を外に出さない関数にします。

{"ok": False, "value": None, "error": "args:allowed:region"}

返す形がいつも同じなら、ノードは分岐する材料を得ます。そして、errorに書かれた理由が状態に積み上がれば、次の試行で何を直すべきかが、コードで決まります。理由がargs:allowed:regionなら地域をデフォルト値に変え、result:too_bigなら、ツールを旧バージョンから新バージョンに変えます。逆に、例外として投げてしまうと、残るのは「何かが間違った」だけなので、直せる失敗と直せない失敗を分けられません。

直せない理由も、はっきりあります。空の結果がそうです。同じ質問をもう一度しても、空の結果です。このときリトライするのは、予算を燃やすだけで、答えをでっち上げるのは、さらに悪いです。直せる理由のリストをコードに書いておき、それ以外はすぐに断念します。試行の上限は、その上にもう1枚重ねる安全装置です。

同じことを2回尋ねない

ループを回るエージェントは、同じツールを同じ引数で呼び出し続けます。状態に結果が残っていないか、残っていても次のノードが見つけられないからです。呼び出す入口が1か所なら、その入口の前にメモ化を置けば済みます。

キーは이름 + 정규화한 인자(プレースホルダーは名前と、正規化した引数です)です。引数を書いた順序が違っても、同じキーが出る必要があるので、json.dumps(args, sort_keys=True)のようにソートして作ります。そして、成功だけを覚えます。失敗まで覚えると、直してもう一度呼び出す道が塞がれます。

現場での姿

1つ目: モデルが引数名をでっち上げる。契約にない引数が来るのは、事故ではなく日常です。防ぎ、理由を残し、もう一度尋ねます。

2つ目: 照会は成功したが、中身がない。okだけを見て通り過ぎると、その上に文が作られます。空の結果を失敗に分類する1行が、これを防ぎます。

3つ目: 旧バージョンのAPIが引数を無視する。上限を守らない応答が、そのままモデルのコンテキストに載ってトークンを食います。結果の検証は、コスト管理でもあります。

4つ目: 同じ照会が1回の実行で5、6回回る。請求書にまず現れます。状態に結果が残っていないか、残っていても、次のノードがそれを見つけられないからです。

5つ目: 例外1つが実行全体を終わらせる。ツール関数の内側で起きたKeyErrorがノードの外へ、グラフの外へ出ると、その1件がまるごと消えます。途中まで積み上げた状態も一緒に消えて、もう一度動かすときは、最初からやり直す必要があります。

実務で本当に大切なこと

次のラボですること

/root/work/agtool/tools.pyを、1ステップずつ育てます。ツールの契約表を作り、引数を呼び出す前に測る関数と、結果を受け取ったあとで測る関数を作り、例外を外に出さない呼び出しの入口を立てます。続いて、同じ引数を2回呼び出さないようにメモ化を付け、失敗を経路として扱うグラフを立てたあと、理由を見て引数やツールを直してもう一度試すグラフまで作ります。引数を選ぶ場所はルールで代用しますが、実際のエージェントは、その場所でモデルに尋ねます。残りの構造は同じです。採点ツールは、書かれたモジュールを実際に読み込んで、毎回異なる値で検証関数を叩いてみて、ツールの本体が何回動いたかまで数えます。