エージェントとは、 ツール利用を ループに入れたときに得られるものです。1 回のツール呼び出しは 1 つの問いに 答えます。エージェントはツールを呼び、結果を読み、次にどうするかを決め、 タスクが本当に片づくまで続けます — 検索し、上位のヒットを読み、価格を調べ、 そして答えを書く。仕組みは単純で、毎回まったく同じ 4 拍子です。モデルが ツールを要求し、あなたが実行し、結果を戻し、もう一度モデルを呼ぶ。難しいのは ループそのものではありません。永遠に回り続けたり、たった 1 回の実行で $40 をこっそり請求したりするのを止める、ガードレールのほうです。
この記事では、OpenAI Python SDK を使って https://api.brievio.com/v1 に対する実際に動くエージェント ループを組み立て、それを本番投入できるようにする 4 つの制御で包みます。 厳格な反復回数の上限、検証付きのツールディスパッチ、 正直なトークン数から算出する実行ごとのコスト予算、そして モデルが想定外の動きをしたケースの健全なハンドリングです。どのスニペットも そのまま動きます。claude-sonnet-4-6 を gemini-2.5-pro に差し替えれば、同じコードが別のモデルを駆動します。
ループ、そしてなぜ天井が必要なのか
これがエンジンの全体です。すでにご存じのツール利用ループに、すべてを 変える 1 つの追加を加えたものです。while True ではなく for step in range(MAX_ITERS) です。
# 反復回数に厳格な上限を設けたエージェントループ。モデル -> tool_calls -> 実行 ->
# 結果を戻す -> 繰り返す。モデルが散文で答えるか、上限に達するまで続く。
# この上限こそが「エージェント」と「暴走する請求書」を分ける一線。
from openai import OpenAI
import json
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
MAX_ITERS = 8 # ほとんどのタスクは 2〜4 ラウンドで終わる。8 は十分な余裕。
def run_agent(question: str, model: str = "claude-sonnet-4-6") -> str:
messages = [
{"role": "system", "content": "You are a helpful research agent. "
"Use the tools when you need live data. Answer directly when you "
"already know enough."},
{"role": "user", "content": question},
]
for step in range(MAX_ITERS):
resp = client.chat.completions.create(
model=model,
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
msg = resp.choices[0].message
# ツールの要求がない -> これが最終回答。完了。
if not msg.tool_calls:
return msg.content
# アシスタントのターンを返ってきたまま厳密に追加する。次のメッセージが
# 参照すべき tool_call の id を保持しているため。
messages.append(msg)
# 要求された呼び出しをすべて実行し、id ごとに tool メッセージを 1 つ追加する。
for call in msg.tool_calls:
result = dispatch(call) # 次のスニペットを参照
messages.append({
"role": "tool",
"tool_call_id": call.id, # 呼び出しの id と必ず一致させること
"content": json.dumps(result),
})
# ループ: モデルはツールの出力を見て続きを進める。
# 解決しないまま上限に到達。黙って永遠にループせず、明示的に失敗させる。
raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")この上限つきの for こそ、エージェントで最も重要な 1 行です。 有能なモデルなら、適切にスコープされたタスクは 2〜4 ラウンドで終わります。 ですがモデルは混乱します。同じ検索を 2 回呼んだり、行き止まりを追いかけたり、 あるいは典型的な失敗として、ツールを呼んで気に入らない結果を得て、ほぼ 同じ引数でそれを呼び直すのを永遠に繰り返したりします。while True は それを、上限のない請求とハングしたリクエストに変えてしまいます。上限は 「永遠に回り続ける」を「8 回試したあと明確なエラーで失敗する」に変換します。 後者なら捕捉し、ログに残し、復旧できます。数はタスクに合わせて選びましょう。 一発の検索なら 2、多段階の調査エージェントならおそらく 10。意図して設定し、 上限なしのままにしないことです。
平然と紛れているもう 1 つのガードレールにも注目してください。 モデルがツールを呼ばないケースを扱うことです。 msg.tool_calls が空のとき、それはモデルが答えるのに十分だと 判断したということ — エラーではなく、あなたの出口です。毎ターン必ずツール 呼び出しが発生すると決めつけたループは、クラッシュするか、決して終わりません。 毎回、両方の結果に分岐させましょう。
ツールディスパッチ: モデルは提案し、あなたのコードが処理する
モデルがあなたのシステムに触れることは決してありません。関数名と引数の JSON 文字列を吐き出して止まるだけ。それを尊重するかどうかは あなたのコードが決めます。その境界こそがエージェントのセキュリティの すべてであり、だからこそバリデーションはディスパッチ関数に置かれます — 飾りとしてではなく、すべての引数が信頼できないモデル出力だからです。見知らぬ他人が記入したフォームのフィールドとまったく同じです。
# バリデーション付きのツールディスパッチ。モデルは呼び出しを「提案」し、
# あなたのコードが「処理」する。すべての引数は信頼できないモデル出力 — パースし、
# 登録済みの名前か確認し、型を検証してから実行する。
def get_weather(city: str, unit: str = "celsius") -> dict:
if not isinstance(city, str) or not city.strip():
raise ValueError("city must be a non-empty string")
if unit not in ("celsius", "fahrenheit"):
raise ValueError(f"unsupported unit: {unit!r}")
return {"city": city, "temp": 18, "unit": unit, "sky": "clear"}
# ホワイトリスト: モデルが呼べるのは明示的に登録したものだけ。
TOOL_IMPLS = {"get_weather": get_weather}
TOOLS = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string",
"enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
},
},
}]
def dispatch(call) -> dict:
name = call.function.name
fn = TOOL_IMPLS.get(name)
if fn is None:
# モデルがツールを幻覚した。ループを落とさず、エラーをツール結果として
# 戻し、モデルが自分で修正できるようにする。
return {"error": f"unknown tool: {name}"}
try:
args = json.loads(call.function.arguments) # 常に JSON 文字列
except json.JSONDecodeError:
return {"error": "arguments were not valid JSON"}
try:
return fn(**args)
except (TypeError, ValueError) as e:
# 不正な引数(型違い、必須フィールド欠落、範囲外)。メッセージを戻すと
# モデルはたいてい修正した呼び出しで再試行する。
return {"error": str(e)}ここでは 3 つの失敗モードを扱い、そのいずれもがループをクラッシュさせる のではなく、エラーをモデルに戻します。幻覚したツール名 — モデルが発明したものの、あなたが登録していないもの — はホワイトリストが 捕まえます。引数文字列内の不正な JSON — 本物のフラッグ シップではまれですが、それでも防御的にパースします。そして不正な 引数値 — 型違い、必須フィールドの欠落、モデルがでっち上げた enum。どのケースでも、ツール結果として {"error": "..."} を返すほうが例外を投げるよりも優れています。 モデルは次のターンでそのメッセージを読み、たいてい自分の呼び出しを直すからです。 自分のミスから復旧できるエージェントは、最初の不正な引数で死ぬものより はるかに堅牢です。
ホワイトリストは厳しく保ちましょう。TOOL_IMPLS.get(name) は、 モデルが — 本物であれそうでなかれ — 明示的に登録した関数しか呼び出せない ことを意味します。その 1 つの辞書があなたの被害半径です。データを削除したり、 カードに課金したり、メールを送ったりするツールなら、ループに自律的に発火 させるのではなく、明示的な確認の後ろにゲートしましょう。
予算ガード: ループは増え続けるコンテキストを再送する
反復回数の上限は、モデルを呼ぶ回数を制限します。各呼び出しの コストは制限しません — そしてループでは、コストはラウンド ごとに上がります。理由は構造的です。各ターンはそこまでの会話全体を、加えて それに追加されたすべてのツール結果を再送します。1 ターン目は入力 800 トークンかもしれません。6 ターン目、5 つのツール出力が積み上がったあとは、 6,000 になり得ます。安い 8 ラウンドが、こっそりと安くない 1 実行に積み上がる のです。対策は、各呼び出しが返す実トークン数から計算する、2 つ目の独立した 支出の天井です。
# 実行ごとのコスト/トークン予算ガード。ループの各ターンは増え続けるコンテキスト
# (履歴 + ツール出力)を再送するため、ラウンドごとにコストが上がる。各呼び出しの
# あとに正直な usage オブジェクトを読み、料金を計算し、予算を超えたら停止する —
# 反復回数の上限とは独立に。
from decimal import Decimal
# 公開されている Brievio のレート。100 万トークンあたり USD(公式定価より約 15% 安い)。
RATES = {
"claude-sonnet-4-6": {"in": Decimal("2.55"), "out": Decimal("12.75")},
"claude-haiku-4-5": {"in": Decimal("0.85"), "out": Decimal("4.25")},
}
def call_cost(model: str, usage) -> Decimal:
r = RATES[model]
m = Decimal("1000000")
return usage.prompt_tokens * r["in"] / m + usage.completion_tokens * r["out"] / m
RUN_BUDGET = Decimal("0.10") # 1 回のエージェント実行で 10 セント、厳格な上限。
def run_agent_budgeted(question: str, model: str = "claude-sonnet-4-6") -> str:
messages = [{"role": "user", "content": question}]
spent = Decimal("0")
for step in range(MAX_ITERS):
resp = client.chat.completions.create(
model=model, messages=messages, tools=TOOLS, tool_choice="auto",
)
spent += call_cost(model, resp.usage) # 毎ターン、実トークンを集計
if spent > RUN_BUDGET:
raise RuntimeError(f"run exceeded ${RUN_BUDGET} (spent ${spent:.4f})")
msg = resp.choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for call in msg.tool_calls:
messages.append({"role": "tool", "tool_call_id": call.id,
"content": json.dumps(dispatch(call))})
raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")肝心なのは、Brievio の resp.usage が本物のモデルが実際に処理した 正直な入力・出力トークン数を保持していることです — だから走行合計は推測では なく本物のお金です。毎ターン usage を読み、 RUN_BUDGET で停止すれば、本来なら高価な 8 ラウンドを焼き尽くす 混乱したエージェントも、何回反復しようと、10 セントを超えた瞬間に打ち切られます。 2 つの天井で、2 つの異なる失敗モードを押さえます。反復回数の上限は無限ループを止め、予算は高価なループを止めます。両方が必要です。ループは 短くて高価にも、長くて安価にもなり得て、どちらか一方だけでは他方から守って くれないからです。
計算のために知っておく価値があること。失敗した 4xx/5xx の呼び出しは Brievio では課金されません。ですから不安定なツールや一時的な 上流エラーに対する再試行が実行予算を消耗させることはありません — 実際に結果を 返した呼び出しのぶんだけコストを集計します。これにより支出曲線は、吸収した エラーではなく、実行された仕事に追従します。呼び出しごと・ユーザーごとに支出を 制限する完全なパターンは API 支出に上限を設けるガイドにあります。
ループが伸びてもトークン請求を抑える
コストを制限するのと、コストを減らすのは別の話です。各ターンが増え続ける プレフィックスを再送するため、同じコンテキストの代金を何度も何度も払うことに なります — これこそまさにプロンプトキャッシュが作られた形です。リクエストの 静的な部分(システムプロンプト、ツール定義)をキャッシュ可能と印付ければ、 2 ターン目以降は変わらなかったものすべてに対して入力レートのほんの一部を 払うだけになります。同じ数千トークンのツールカタログとシステムプロンプトを 毎ラウンド再送するループでは、それが請求に効く最大のレバーです。
実践的な習慣もいくつか役立ちます。実行を通してシステムプロンプトとツール定義を安定させましょう — ループ途中で追加したツールやシステムプロンプト内の タイムスタンプはキャッシュを無効化し、入力コストをこっそり倍にします。そして、 ツールが大量のデータ(ウェブページ全体、1,000 行のクエリ)を返し得るなら、 messages に追加する前に結果を要約するか切り詰めましょう。モデルが その全部を必要とすることはまれで、追加したバイトはすべて以降の毎ターン再送 されます。エージェントループがコンテキストの肥大化にとりわけ敏感なのは、まさに コンテキストが 1 回ではなく N 回再送されるからです。
会話メモリ: 実行と実行のあいだに何を持ち越すか
ここまではすべて 1 回の実行内のメモリです — messages リストはエージェントの作業メモリであり、そこに追加することがモデルにすでに 調べたことを覚えさせる方法です。複数のリクエストにまたがってユーザーと話す マルチターンのエージェントでは、そのリストを前へ持ち越します。セッションごとに messages を永続化し(Redis、データベースのカラム、どこでも)、 次のリクエストでそれを再読み込みし、新しいユーザーのターンを追加します。 ループは同一で、変わるのは開始状態だけです。
管理すべきは、際限のない増大です。長く生きるセッションは履歴を蓄積し、やがて 毎呼び出しが高価になり、最終的にはコンテキストウィンドウを超過します。よくある 戦略は 2 つ。直近 N ターンのローリングウィンドウを保ち、最も古いものを捨てるか、 古い履歴を定期的にコンパクトな要約にまとめ、生のターンと置き換えるか。どちらも いくらかの忠実度と引き換えに、有界で予測可能なコンテキストサイズを得ます。 どちらを選んでも、上記の実行ごとの予算ガードは引き続き効きます — 計画より 大きく育ってしまったセッションを捕まえる、最後の砦です。
エスカレーションするエージェントを 1 つのキーで
これを Brievio の後ろで作ることの有用な性質。エージェントは他を何も変えずに、 タスクの途中で使うモデルを切り替えられます。安いラウンドは小さいモデルで回し、 タスクが難しいときだけフラッグシップにエスカレートする — 簡単なツール ディスパッチは Haiku 4.5(入力 $0.85 / 出力 $4.25)に通し、 推論の重い最終回答は Sonnet か別のファミリーに切り替える。1 つの キーがすべてのモデルをカバーする、しかも単一の base_url の後ろで、ですから、そのエスカレーションはループ内の model 文字列を変える 1 行の変更です — 2 つ目の SDK も、2 つ目の認証方式も、2 つ目の 課金関係もいりません。ツールフィールドを含む完全なリクエスト/レスポンスの 契約は Chat Completions ドキュメントに、正確な id 付きの ライブなモデル一覧は モデルページにあります。
もちろん、これが意味を持つのは反対側のモデルが本物である場合だけです。 エージェントループは格下げされた身代わりに容赦がありません。ツール引数を 取り違えたりツールを無視したりするモデルは、自分のミスを追いかけて反復回数と 支出を焼き尽くすからです。Brievio は本物のファーストパーティのモデルを提供し、 ネイティブのツール呼び出しを尊重し、正直なトークン数を報告します — それこそが、 ループも予算の計算も実際に機能させるものです。
まとめ: 4 つのガードレール、それから出荷
ループそのものは十数行です。それを本番投入できるものにするのは、周りを 囲む境界です。
- 反復回数に上限を。
while Trueより上限つきのforを。天井で永遠に回らず、明示的に失敗させる。 - ツールなしのケースを扱う。 空の
tool_callsはエラーではなく出口。毎ターンそこで分岐する。 - すべてのディスパッチを検証する。 ツール名をホワイト リスト化し、引数をパースし、型を確認する — そしてエラーはクラッシュ させずモデルに戻す。
- 実行に予算をつける。 毎ターン
usageを読み、 公開レートで料金を計算し、厳格な支出の天井で停止する。増え続ける コンテキストに気を配り、静的なプレフィックスをキャッシュして再送コストを 抑える。
この 4 つを正しくやれば、実際の多段階の仕事をこなし、自分のミスから復旧し、 最悪ケースのコストを請求書で発見するのではなく自分で選んだエージェントが手に入ります。単一呼び出しの仕組みがまず必要なら ツール利用ガイドから始め、それをループと上記の 4 つのガードレールで包みましょう。