0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMエージェントのツール呼び出しを、モデルを叩かずに決定論的にテストする

0
Posted at

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本だった。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?