14. エージェントループの構築
第13章では、LLM のツール呼び出しリクエストを実行し、結果を返す方法を学びました。しかし、その作業では、すべてのタスクが単一のツール呼び出しで処理できることを前提としていました。実際には、LLM が最終的な回答に必要なすべての情報を集めるまで、ツール呼び出しを複数のラウンドにわたって継続する必要があることがよくあります。
「フランスの人口を調べて、それを2倍にする」というリクエストを考えてみましょう。このタスクには少なくとも2回のツール呼び出しが必要です。まず人口を調べなければならず、その後でなければ計算を実行できません。2回目の呼び出しは1回目の結果に依存するため、単一のツール呼び出しで処理する方法はありません。
この章では、第13章の単一サイクルをループへと発展させます。LLM がツール呼び出しをリクエストする限り、それらを実行し続けます。LLM が自ら要求を止めるまで繰り返すのです。次に、ループが永遠に実行されるのを防ぐための安全上の制限を追加し、ツールが失敗してもエージェントがクラッシュしないようエラー処理についても取り上げます。
14.1) 単一サイクルからループへ
14.1.1) エージェントループはどのように動作するのか?
導入部で見たように、次のアクションが前のステップの結果に依存するタスクは、単一のツール呼び出しでは処理できないことがよくあります。LLM はツールを呼び出し、結果を確認し、再び判断する必要があります。これがエージェントループの役割であり、3つの段階で動作します。
- Think(考える) — LLM はこれまでの会話を読み、次に何をすべきかを判断します。ツールが必要な場合は、
tool_callsを通じてツール呼び出しをリクエストします。必要でなければ、最終的な回答を返します。 - Act(行動する) —
tool_callsで指定されたツールを実行します。 - Observe(観察する) — ツールの実行結果を確認し、
ToolMessageとして会話に追加します。
エージェントループは、LLM がもうツールをリクエストしなくなるまで、これら3つの段階を繰り返します。このパターンは ReAct(Reason + Act)としても知られており、重要なアイデアは推論と行動を交互に繰り返すことです。
tool_calls が存在する場合は、ツールを実行し、結果を会話に追加して、再び LLM を呼び出します。tool_calls が空の場合は、LLM が最終的な回答を返したということなので、ループは終了します。それでは、これをコードに落とし込んでいきましょう。
14.1.2) Think-Act-Observe ループの実装
前のセクションで説明した Think-Act-Observe ループをコードにしてみましょう。まず、ツールとモデルを準備します。
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langchain.tools import tool
# ツールを定義します
@tool
def get_weather(city: str) -> str:
"""都市の現在の天気を取得します。"""
fake_data = {"Tokyo": "18°C, cloudy", "Cairo": "31°C, sunny"}
return fake_data.get(city, f"No weather data for {city}.")
@tool
def calculate(expression: str) -> str:
"""'3 * 21' のような単純な算術式を評価します。"""
return str(eval(expression)) # 警告: eval() はセキュリティ上のリスクがあります。本番環境では使用しないでください。
# ツールをバインドします
tools = [get_weather, calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}第13章では、ツールを一度実行して停止しました。今回は、LLM がツール呼び出しをリクエストしなくなるまで繰り返します。while True ループの中で LLM を呼び出し、レスポンスに tool_calls が含まれていれば、ツールを実行して再び LLM を呼び出します。tool_calls がなければ、LLM が最終的な回答を返したということなので、ループを抜けます。
def run_agent(user_input: str) -> str:
"""LLM が最終的な回答を返すまで Think-Act-Observe ループを実行します。"""
messages = [
SystemMessage(content="You are a helpful assistant."),
HumanMessage(content=user_input),
]
while True:
# THINK: LLM に次のアクションを判断させます
print("THINK: Asking LLM to decide")
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
# 終了条件: tool_calls がなければ、これが最終的な回答です
if not ai_message.tool_calls:
return ai_message.content
# ACT + OBSERVE: リクエストされたツールを実行し、結果を会話に追加します
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
print(f"ACT: calling '{tool_call['name']}', args={tool_call['args']}")
tool_message = selected_tool.invoke(tool_call)
print(f"OBSERVE: {tool_message.content}")
messages.append(tool_message)これを第13章のコードと比較してみましょう。第13章では、ツールを実行した後、回答を得るために最後にもう一度 LLM を呼び出しました。第14章では、その同じ処理を while True の中に入れ、継続するかどうかを判断するために毎回のイテレーションで tool_calls を確認しています。構成要素は第13章と同じで、それをループで包んだだけです。
実行してみましょう。
answer = run_agent("What's the weather in Tokyo, and is it warm enough for a walk?")
print(f'Final answer: {answer}')出力:
THINK: Asking LLM to decide
ACT: calling 'get_weather', args={'city': 'Tokyo'}
OBSERVE: 18°C, cloudy
THINK: Asking LLM to decide
Final answer: 現在の東京は18°C(約64°F)で曇りです。
この気温は一般的に穏やかで、ほとんどの人にとって散歩に快適です。出力には THINK → ACT → OBSERVE → THINK の流れが示されています。最初のイテレーションでは LLM が get_weather の呼び出しをリクエストし、2回目のイテレーションでは天気の結果を見て最終的な回答を生成しました。tool_calls が空だったため、最終的な回答が返され、ループが終了しました。
では、導入部で挙げた、前のステップの結果を見た後でなければ次の呼び出しができないという、依存関係のあるステップのシナリオをテストしてみましょう。
answer = run_agent("Get the temperature in Cairo, then multiply the number by 3.")
print(f'Final answer: {answer}')出力:
THINK: Asking LLM to decide
ACT: calling 'get_weather', args={'city': 'Cairo'}
OBSERVE: 31°C, sunny
THINK: Asking LLM to decide
ACT: calling 'calculate', args={'expression': '31 * 3'}
OBSERVE: 93
THINK: Asking LLM to decide
Final answer: カイロの現在の気温: 31°C。3倍すると = 93。今回は、ループが3回のイテレーションで実行されました。
- 1回目のイテレーション — LLM が
get_weather("Cairo")をリクエストします。 - 2回目のイテレーション —
"31°C, sunny"という結果を見た後、LLM がcalculate("31 * 3")をリクエストします。気温を見た後でなければ、この式を組み立てることはできませんでした。 - 3回目のイテレーション —
"31°C, sunny"と"93"の両方の結果を見た後、LLM は最終的な回答を返しました。
14.2) 安全上の制限を追加する
上で構築したループには、終了条件が1つしかありません。LLM が tool_calls なしで応答したときにループを抜けるというものです。通常の状況ではこれで十分ですが、LLM がツール呼び出しのリクエストを止めなかった場合はどうなるでしょうか?
たとえば、あるツールが常に曖昧な結果を返す場合、LLM はより良いものを期待してそのツールを呼び出し続けるかもしれません。ループが while True であるため、LLM が止まらなければ、プログラムも止まりません。プログラムが無限に実行され続ける間、API 呼び出しのコストが積み重なっていきます。
最もシンプルな解決策は、ループを実行できる回数に上限を設けることです。while True を for step in range(max_steps) に置き換えれば、LLM がどう動作しようとも、ループは max_steps 回のイテレーションの後に必ず終了することが保証されます。
def run_agent(user_input: str, max_steps: int = 10) -> str:
"""max_steps 回のイテレーション以内で Think-Act-Observe ループを実行します。"""
messages = [
SystemMessage(content="You are a helpful assistant."),
HumanMessage(content=user_input),
]
for step in range(max_steps):
# THINK
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
# 終了条件: tool_calls がなければ、これが最終的な回答です
if not ai_message.tool_calls:
return ai_message.content
# ACT + OBSERVE
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
messages.append(tool_message)
# max_steps に到達: 最終的な回答なしでループが終了しました
return f"[Stopped after reaching max iterations ({max_steps})]"前のコードと比較すると、変わった点は2つです。while True が for step in range(max_steps) になり、ループが max_steps に到達したときの戻り値が追加されました。ループは、次の2つの方法のいずれかで終了するようになりました。LLM が自ら最終的な回答を返す(自然終了)か、max_steps に到達する(安全終了)かです。
安全上の制限が実際に機能することを確認してみましょう。有用な結果を決して返さないツールを作成し、LLM がツール呼び出しのリクエストを止められない状況を強制的に作り出します。
@tool
def unhelpful_search(query: str) -> str:
"""情報を検索します。"""
return "No results found. Try rephrasing your query."
llm_with_bad_tool = llm.bind_tools([unhelpful_search])
tool_map_bad = {unhelpful_search.name: unhelpful_search}
def run_agent_bad(user_input: str, max_steps: int = 5) -> str:
messages = [
SystemMessage(content=(
"You must ALWAYS use the unhelpful_search tool to find information. "
"You are NOT allowed to answer from your own knowledge. "
"If the tool returns no results, you MUST rephrase and search again. "
"Keep searching until you find the answer."
)),
HumanMessage(content=user_input),
]
for step in range(max_steps):
print(f"--- Step {step + 1} ---")
ai_message = llm_with_bad_tool.invoke(messages)
messages.append(ai_message)
if not ai_message.tool_calls:
return ai_message.content
for tool_call in ai_message.tool_calls:
selected_tool = tool_map_bad[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
print(f"ACT: '{tool_call['name']}' → {tool_message.content}")
messages.append(tool_message)
return f"[Stopped after reaching max iterations ({max_steps})]"
answer = run_agent_bad("What is the population of France?")
print(f"\nFinal answer: {answer}")出力:
--- Step 1 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 2 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 3 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 4 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 5 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
Final answer: [Stopped after reaching max iterations (5)]max_steps がなければ、このループは永遠に実行され続けていたでしょう。max_steps=5 のおかげで、5回のイテレーションの後に強制的に終了しました。
max_steps の適切な値は、エージェントの複雑さによって異なります。低すぎると、複雑なタスクが途中で打ち切られてしまいます。高すぎると、誤動作するエージェントが停止される前にコストを積み上げてしまいます。15〜25 が一般的な出発点ですが、実際のワークロードに基づいて調整してください。
14.3) ループ内でのツールエラーの処理
14.1 と 14.2 で構築したループは、ツールが常に正常に実行されることを前提としています。しかし、ツールが例外を発生させた場合はどうなるでしょうか? 現在のコードには例外処理がないため、ツールの実行中に例外が発生すると、エージェント全体がクラッシュしてしまいます。
第12章では、try/except を使ってツール自体の内部で例外をキャッチし、エラーメッセージを文字列として返す方法を学びました。ツールがそのように作られていれば問題ありません。しかし、すべてのツールが内部でエラーを処理するわけではありません。外部のライブラリや API を呼び出すツールは、予期しない例外を発生させることがあります。
これに備えるために、ループのレベルでもエラーを処理しておくとよいでしょう。アプローチはシンプルです。ツールの実行を try/except で包み、例外が発生した場合は、エラーメッセージを ToolMessage に入れて LLM に渡します。LLM はこのエラーメッセージを読み、修正した引数で再試行したり、別のアプローチを選択したりできます。これは自己修正(self-correction)と呼ばれます。
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain.tools import tool
# ツールを定義します
@tool
def calculate(expression: str) -> str:
"""'3 * 21' のような単純な算術式を評価します。"""
return str(eval(expression)) # 警告: eval() はセキュリティ上のリスクがあります。本番環境では使用しないでください。
# ツールをバインドします
tools = [calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}
def run_agent(user_input: str, max_steps: int = 10) -> str:
"""ツールのエラーを LLM に渡し、自己修正を可能にするエージェントループ。"""
messages = [
SystemMessage(content="You are a helpful assistant."),
HumanMessage(content=user_input),
]
for step in range(max_steps):
# THINK
print("THINK: Asking LLM to decide")
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
if not ai_message.tool_calls:
return ai_message.content
# ACT + OBSERVE
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
try:
print(f"ACT: calling '{tool_call['name']}', args={tool_call['args']}")
tool_message = selected_tool.invoke(tool_call)
print(f"OBSERVE: {tool_message.content}")
except Exception as e:
print(f"OBSERVE: Error - {e}")
tool_message = ToolMessage(
content=f"Error: {e}",
tool_call_id=tool_call["id"],
)
messages.append(tool_message)
return f"[Stopped after reaching max iterations ({max_steps})]"14.2 のコードと比較すると、唯一の変更点は try/except です。ツールが例外を発生させた場合、エラーメッセージが ToolMessage に入れられ、会話に追加されます。失敗した呼び出しであっても、一致する tool_call_id を持つ ToolMessage が必要である点に注意してください。LLM は次のイテレーションでこのエラーを見て、次に何をすべきかを判断します。
自己修正が機能することを確認してみましょう。calculate ツールにゼロ除算を要求することで、例外を発生させます。
answer = run_agent("Use the calculator tool to compute 10 / 0")
print(f"Final answer: {answer}")出力:
THINK: Asking LLM to decide
ACT: calling 'calculate', args={'expression': '10 / 0'}
OBSERVE: Error - division by zero
THINK: Asking LLM to decide
Final answer: 電卓ツールを使用したところ、エラーが返されました: "division by zero(ゼロ除算)"。
説明: 10 / 0 は通常の算術では定義されていないため、有限の数を生成できません。
以下のいずれかをご希望でしょうか:
- 片側極限を計算する
- IEEE-754 の浮動小数点結果を表示する
- または別の式を評価する最初のイテレーションでは、LLM が calculate("10 / 0") をリクエストし、ZeroDivisionError が発生しました。try/except が例外をキャッチし、エラーメッセージを LLM に渡しました。2回目のイテレーションでは、LLM がエラーメッセージを見て、ゼロ除算は不可能であることを説明する最終的な回答を返しました。try/except がなければ、プログラムは最初の ZeroDivisionError でクラッシュしていたでしょう。