HuggingFace が「Is it agentic enough? Benchmarking open models on your own tooling」という記事を出した(2026/6/18)。これが意味するのは、モデルが自分のツール環境でどれだけ動くかを自分で測れ、という話だ。
ただ、自分が現場でハマったのはそこじゃない。ベンチマークはモデルの賢さを測る。でもエージェントが本番で落ちるバグの大半は、モデルじゃなくて自分のループ側にある。引数を取り違える、ツールのエラーをモデルに戻し忘れる、止まらない。これはベンチを何回回しても出てこない。
Q. なぜエージェントのテストは書きにくいのか
毎回モデルを叩くからだ。遅いし、課金されるし、同じ入力でも出力が揺れる。非決定的なものを CI に乗せると、たまに落ちるテストになって誰も信じなくなる。
解決は単純で、LLM クライアントを細い抽象にして、テストのときだけ台本(スクリプト)に差し替える。
ループ側を抽象に依存させる
エージェント本体が依存するのは「messages を渡したら次の一手が返る」という1メソッドだけにする。
# agent.py
from dataclasses import dataclass
from typing import Callable, Protocol
@dataclass
class ToolCall:
name: str
args: dict
@dataclass
class Step: # モデルの1ターン。tool_call が無ければ最終回答
text: str | None = None
tool_call: ToolCall | None = None
class LLM(Protocol):
def respond(self, messages: list[dict]) -> Step: ...
class AgentError(RuntimeError):
pass
def run_agent(llm, user_input, tools: dict[str, Callable[..., str]], max_steps=6):
messages = [{"role": "user", "content": user_input}]
for _ in range(max_steps):
step = llm.respond(messages)
if step.tool_call is None:
return step.text or ""
call = step.tool_call
if call.name not in tools:
raise AgentError(f"unknown tool: {call.name}")
try:
result = tools[call.name](**call.args)
except Exception as e: # ツール失敗もモデルに戻して回復させる
result = f"ERROR: {e}"
messages.append({"role": "tool", "name": call.name, "content": result})
raise AgentError(f"max_steps={max_steps} を超えました")
本番では respond の中で実プロバイダを呼び、返ってきた tool_call を Step に詰め替えるアダプタを書く。テストはここを丸ごと差し替える。
台本フェイクを差し込む
決められた Step を順に返すだけ。渡された messages も記録しておく。
# fake_llm.py
class ScriptedLLM:
def __init__(self, script):
self._script = list(script)
self.seen = [] # 各ターンで渡された messages を保存
def respond(self, messages):
self.seen.append([dict(m) for m in messages]) # コピーして残す
return self._script.pop(0)
dict(m) でコピーするのが地味に大事だ。messages はループ中に書き換わるミュータブルなリストなので、参照のまま持つと全ターンが最終状態に化けて、検査が成立しない。最初これで30分溶かした。
自分のループのバグを名指しで撃つ
# test_agent.py(抜粋)
def make_tools(log):
return {
"get_weather": lambda city: log.append(("get_weather", city)) or f"{city} is sunny, 22C",
"boom": lambda: (_ for _ in ()).throw(ValueError("backend down")),
}
class TestAgentLoop(unittest.TestCase):
def test_calls_tool_then_answers(self):
log = []
llm = ScriptedLLM([
Step(tool_call=ToolCall("get_weather", {"city": "Tokyo"})),
Step(text="東京は晴れ、22度です。"),
])
out = run_agent(llm, "東京の天気は?", make_tools(log))
self.assertEqual(out, "東京は晴れ、22度です。")
self.assertEqual(log, [("get_weather", "Tokyo")]) # 正しい引数で1回だけ
def test_tool_result_is_fed_back(self): # 戻し忘れの検出
llm = ScriptedLLM([Step(tool_call=ToolCall("get_weather", {"city":"Osaka"})), Step(text="done")])
run_agent(llm, "?", make_tools([]))
self.assertEqual(llm.seen[1][-1]["role"], "tool")
self.assertIn("Osaka is sunny", llm.seen[1][-1]["content"])
def test_tool_exception_becomes_error_message(self): # 例外→回復
llm = ScriptedLLM([Step(tool_call=ToolCall("boom", {})), Step(text="復旧")])
run_agent(llm, "?", make_tools([]))
self.assertIn("ERROR: backend down", llm.seen[1][-1]["content"])
def test_infinite_loop_is_bounded(self): # 止まらないモデルでも打ち切る
llm = ScriptedLLM([Step(tool_call=ToolCall("get_weather", {"city":"X"}))] * 99)
with self.assertRaises(AgentError):
run_agent(llm, "?", make_tools([]), max_steps=4)
self.assertEqual(len(llm.seen), 4)
手元で走らせた結果がこれ。
$ python -m unittest test_agent -v
test_calls_tool_then_answers ... ok
test_infinite_loop_is_bounded ... ok
test_tool_exception_becomes_error_message ... ok
test_tool_result_is_fed_back ... ok
test_unknown_tool_raises ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK
5本で 0.2ms、ネットワーク呼び出し 0回、課金 $0.00。標準ライブラリの unittest だけで動くので追加インストールも要らない。
Q. これで何が嬉しいのか
ここで撃っているバグを並べると、性格がはっきりする。引数を取り違えていないか。ツール結果が次ターンの messages に戻っているか。ツールが例外を投げてもモデルに渡って回復できるか。未知のツール名で死ぬか。止まらないモデルでも max_steps で打ち切れるか。
どれもモデルの賢さとは無関係だ。配線(オーケストレーション)の正しさを問うている。そして配線のバグは、自分のコードにしか無い。ベンチマークは相手のモデルを評価する道具で、自分のループのこの手のミスは絶対に映さない。役割が違う。
実運用ではこの台本テストを土台に、回帰用のシナリオを足していけばいい。「このツールが空配列を返したとき」「2回目の呼び出しで別の引数になったとき」を Step の列として書き起こすだけで、再現テストになる。バグ報告が来たら、その会話列を台本に落として赤いテストを1本作り、緑にする。モデルを1回も叩かずに、だ。
ベンチで賢さを測るのは大事だが、出荷を守るのは決定論テストのほうだと思っている。賢いモデルに替えても、戻し忘れのループは直らない。最初に作るべきは評価スクリプトより、この台本1本だった。