
Strands Agents の GoalLoop プラグインが追加されていたので触ってみた
はじめに
こんにちは、スーパーマーケットが大好きなコンサル部の神野(じんの)です。
最近、Strands Agents に GoalLoop というプラグインが用意されていることを知りました。ClaudeやCodexなどでも/goalコマンドがありますよね。Strandsでもあるのか・・・!と興味が湧きました。
この GoalLoop は、エージェントの応答をゴール(目標条件)に照らして検証し、不合格ならフィードバックを注入して自動で再実行してくれるプラグインです。合格するか、試行回数・時間の上限に達するまでループしてくれます。
今回は実際に手を動かして、自然言語ゴール・プログラマティック検証器・上限到達時の挙動の3パターンを試してみたいと思います!
前提
今回の検証環境は下記の通りです。
| 項目 | バージョン・設定 |
|---|---|
| Python | 3.12.12 |
| strands-agents | 1.45.0 |
| モデル | Claude Haiku 4.5(Amazon Bedrock) |
| リージョン | us-east-1(クロスリージョン推論) |
パッケージ管理には uv を使用します。プロジェクトを作成して strands-agents をインストールしておきます。
uv init --bare --name goal-strands
uv add strands-agents
また、Amazon Bedrock で Claude Haiku 4.5 が利用できる状態になっている前提です。AWS認証情報の設定も済ませておいてください。
GoalLoop の仕組み
実装に入る前に、GoalLoop の動作フローを整理しておきます。
エージェントが応答を生成すると、その応答がゴールに照らして検証されます。合格ならそこで終了、不合格ならフィードバックが会話に注入されて再実行され、合格するか max_attempts / timeout の上限に達するまでこのループが繰り返されます。
ゴールの指定方法は2種類あります。
- 自然言語の文字列で指定するとLLMのジャッジ(判定エージェント)が合否を判断
- Python関数で指定するとLLMを使わないプログラマティックな検証
文字数チェックやスキーマ適合、テスト実行結果の確認など、機械的に判定できるものは関数ベースにするのがコスト面でも有利です。
次のセクションから実際に試していきます!
どんなユースケースで使えるか
イメージとしては、Claude Code の /goal コマンドのような仕組みです。/goal は達成条件を設定すると、別の評価用モデルがターンごとに合否を判定し、条件を満たすまでエージェントが作業を続けてくれる機能ですが、GoalLoop はまさにこの「ゴール設定 + 別モデルによる合否判定 + 自動ループ」の構造を、自分で作る Strands エージェントに数行で組み込めるようにしたものと捉えるとわかりやすいと思います。
具体的には下記のようなユースケースが考えられます。
- 出力フォーマットや品質の担保
- 文字数制限、JSONスキーマ適合、必須項目の有無など、後続処理に渡す前に形式を保証したいケース
- コード生成の品質担保
- 生成したコードに対して pytest や lint を検証器として実行し、通るまで修正させるケース
- 回答ポリシーの遵守
- 出典を2つ以上引用する、特定のトーンで回答する、といった自然言語で表現される品質基準を満たしたいケース
これまではこうした要件をシステムプロンプト・スキルの指示で頑張るか、検証とリトライのループを自前実装していました。GoalLoop なら検証ロジックだけ書けば、リトライ回数の管理やフィードバックの注入はプラグイン側が面倒を見てくれます。
やってみた① 自然言語ゴール
まずは一番シンプルな、自然言語でゴールを指定するパターンです。「3文以内・小学生にもわかる言葉・専門用語なし」という条件で、虹ができる理由を説明してもらいます。
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.vended_plugins.goal import GoalLoop
model = BedrockModel(
model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
region_name="us-east-1",
)
concise = GoalLoop(
goal="3文以内で、小学生にもわかる言葉で説明すること。専門用語は使わない。",
max_attempts=3,
)
agent = Agent(model=model, plugins=[concise])
result = agent("虹はどうしてできるの?")
goal_result = concise.last_result(agent)
print("\n--- GoalResult ---")
print(f"passed: {goal_result.passed}")
print(f"stop_reason: {goal_result.stop_reason}")
print(f"attempts: {len(goal_result.attempts)}")
for attempt in goal_result.attempts:
print(f" #{attempt.attempt}: feedback={attempt.feedback}")
GoalLoop を生成して Agent の plugins に渡すだけで、リトライループが有効になります。goal に自然言語の文字列を渡すと、応答のたびにジャッジ用のエージェントが内部で呼び出されて合否を判定してくれます。実行後は last_result() で検証結果の詳細(合否・停止理由・各試行のフィードバック)を取得できます。
実行してみます!
uv run python nl_goal.py
--- GoalResult ---
passed: True
stop_reason: satisfied
attempts: 2
#1: feedback=ゴールの3つの要件すべてが満たされていません。(1)回答は3文を大幅に超えており、複数のセクション、箇条書き、詳細説明を含んでいます。(2)「屈折」「反射」「波長」「グラデーション」など多数の専門用語が使用されており、小学生には理解困難です。要件を満たすには、3文以内で、「光が雨の水に当たってはね返ったり曲がったりして、色が分かれて見える」のような非常にシンプルな説明に変更してください。
#2: feedback=None
おお、ちゃんとリトライされていますね!!
1回目の応答は見出しや箇条書きを使った長い解説で、ジャッジに「3文を大幅に超えている」「専門用語が多数使用されている」と具体的に指摘されて不合格になりました。そのフィードバックが会話に注入されて再実行された結果、2回目は下記のようなシンプルな回答が返ってきました。
太陽の光が雨の水滴に当たるとき、光がはね返ったり曲がったりします。
このとき、光に含まれている色がそれぞれ違う角度ではね返るので、
赤・黄・青などの色が分かれて見えるのです。
だから雨の日に太陽を背にして見ると、虹が見えるんですよ。
3文以内で専門用語もなく、小学生にもわかる説明になっていますね!ジャッジのフィードバックがどこがダメで、どう直せばいいかまで踏み込んでくれるので、2回目で合格できたのだと思います。
やってみた② プログラマティック検証器
次は、LLMを使わずにPython関数で検証するパターンです。回答が100文字以内かどうかをチェックする検証器を実装します。
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.vended_plugins.goal import GoalLoop
model = BedrockModel(
model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
region_name="us-east-1",
)
def char_count_validator(response, agent):
text = "".join(
block["text"] for block in response["content"] if "text" in block
)
chars = len(text)
if chars <= 100:
return True
return {
"passed": False,
"feedback": f"回答が長すぎます({chars}文字)。100文字以内に収めてください。",
}
plugin = GoalLoop(
goal=char_count_validator,
max_attempts=5,
timeout=60.0,
)
agent = Agent(model=model, plugins=[plugin])
result = agent("生成AIエージェントとは何か説明してください。")
goal_result = plugin.last_result(agent)
print("\n--- GoalResult ---")
print(f"passed: {goal_result.passed}")
print(f"stop_reason: {goal_result.stop_reason}")
print(f"attempts: {len(goal_result.attempts)}")
for attempt in goal_result.attempts:
print(f" #{attempt.attempt}: feedback={attempt.feedback}")
検証関数は response と agent の2引数を受け取り、合格なら True を、不合格なら passed と feedback を持つ辞書を返します。feedback の文言がそのまま次の試行のヒントとしてモデルに渡されるため、「何文字オーバーしているか」のような具体的な情報を入れておくと収束が早くなります。timeout も指定しており、今回は全体で60秒の時間予算を設定しています。なお、検証関数は async def でも書けるため、pytest の実行のような時間のかかる検証もそのまま組み込めます。
uv run python validator_goal.py
--- GoalResult ---
passed: True
stop_reason: satisfied
attempts: 3
#1: feedback=回答が長すぎます(576文字)。100文字以内に収めてください。
#2: feedback=回答が長すぎます(131文字)。100文字以内に収めてください。
#3: feedback=None
576文字 → 131文字 → 合格、と回答がどんどん短くなっていく様子が確認できましたね!最終的な回答は下記の通りで、きっちり100文字以内に収まっています。
生成AIエージェントとは
自律的に複数のツールを組み合わせ、タスクを自動実行するAIシステムです。
ユーザーの指示に対し、自ら判断・計画して目標達成まで動作します。
LLMジャッジと違って検証自体のLLM推論コストがゼロなので、文字数のような機械的な条件であれば、迷わずこちらを選ぶのが良さそうです。
やってみた③ 上限に達した場合の挙動
最後に、合格できないまま max_attempts に達するとどうなるかを確認しておきます。動作確認用に、絶対に合格しない検証器を用意しました。
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.vended_plugins.goal import GoalLoop
model = BedrockModel(
model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
region_name="us-east-1",
)
def impossible_validator(response, agent):
return {
"passed": False,
"feedback": "この検証は絶対に合格しません(動作確認用)。",
}
plugin = GoalLoop(goal=impossible_validator, max_attempts=2)
agent = Agent(model=model, plugins=[plugin])
result = agent("こんにちは!")
goal_result = plugin.last_result(agent)
print("\n--- GoalResult ---")
print(f"passed: {goal_result.passed}")
print(f"stop_reason: {goal_result.stop_reason}")
print(f"attempts: {len(goal_result.attempts)}")
--- GoalResult ---
passed: False
stop_reason: max_attempts
attempts: 2
例外が投げられるのではなく、passed=False かつ stop_reason='max_attempts' という結果が返ってくる形ですね。アプリケーション側では stop_reason を見て、不合格時のフォールバック処理(人間へのエスカレーションや別モデルでの再実行など)に分岐させる設計ができそうです。
気になったこと① リトライでコンテキストは膨らまないの?
ここで気になったのが、リトライのたびにフィードバックと再回答が会話履歴に積まれていくと、コンテキストがすごいことになりそうという点です。実際にソースを読むと、リトライは失敗した応答へのフィードバックをユーザーメッセージとして注入して再実行する仕組みなので、デフォルト(preserve_context=True)では失敗した試行がすべて履歴に残ります。
どれくらい膨らむのか、preserve_context の True / False で会話履歴を比較してみました。検証内容はやってみた②と同じ100文字以内チェックで、どちらも3試行で合格しています。
=== preserve_context=True ===
attempts: 3
最終的な会話履歴のメッセージ数: 6
会話履歴の合計文字数: 1393
=== preserve_context=False ===
attempts: 3
最終的な会話履歴のメッセージ数: 3
会話履歴の合計文字数: 402
preserve_context=True では、最初のプロンプト → 失敗した応答 → フィードバック → 失敗した応答 → フィードバック → 合格した応答、と6メッセージすべてが残っています。一方 False にすると、最初のモデル呼び出し直前のスナップショットに毎回巻き戻してからリトライするため、3メッセージに収まりました。
ここで意識したいのが、False でも失敗時のフィードバックはちゃんと渡されている点です。実際の履歴を見てみると、巻き戻されるのは失敗した応答の本文だけで、直前の試行へのフィードバックはリトライ用のユーザーメッセージとして毎回注入されています。
[0] user: 生成AIエージェントとは何か説明してください。
[1] user: Your previous attempt did not satisfy the goal. ...(直前の試行へのフィードバック)
[2] assistant: (合格した回答)
つまり True は「過去の全試行 + 全フィードバック」を見ながら改善していくのに対し、False は「元のプロンプト + 直前のフィードバック1件」だけを持って毎回作り直す、という違いになります。
さらに、Strands の ConversationManager と組み合わせられるかも試してみました。SlidingWindowConversationManager は、会話履歴が指定したメッセージ数を超えたら古いものから削除していく仕組みです。これを window_size=4 で設定して、preserve_context=True のまま実行してみます。
agent = Agent(
model=model,
plugins=[plugin],
conversation_manager=SlidingWindowConversationManager(window_size=4),
)
最終的な会話履歴のメッセージ数: 4
[0] user: Your previous attempt did not satisfy the goal. ...(1回目へのフィードバック)
[1] assistant: (2回目の失敗した回答)
[2] user: Your previous attempt did not satisfy the goal. ...(2回目へのフィードバック)
[3] assistant: (合格した回答)
本来なら6メッセージ残るところ、古い2件(最初のプロンプトと1回目の失敗した回答)が窓からはみ出して削除され、4メッセージに保たれています。GoalLoop のリトライを邪魔することなく共存できていますね!
前の試行を踏まえて改善してほしい場合は True のまま、試行回数が多くなりがちなケースや履歴を汚したくないケースは False や ConversationManager との併用、という使い分けができそうです。
気になったこと② ストリーミングはできるの?
もう1つ気になったのがストリーミングです。リトライを挟む都合上、stream_async() と併用できるのか試してみました。
async def main():
async for event in agent.stream_async("生成AIエージェントとは何か説明してください。"):
if "data" in event:
print(event["data"], end="", flush=True)
結果、問題なくストリーミングできました!特徴としてリトライ分もすべて同じストリームで返却されます。今回は3試行で合格したのですが、ざっくり下記のようなイメージで、3試行分のテキストが1つのストリームとして途切れず連続して届きました。
# 生成AIエージェントとは
## 基本的な定義
生成AIエージェントは、生成AI(LLM等)を中核に...
(↑ 1回目の長い回答が最後まで流れてくる)
# 生成AIエージェントとは
生成AI(LLM)を搭載し、ユーザーの指示に対して...
(↑ 区切りなくそのまま2回目の回答が流れてくる)
# 生成AIエージェントとは
生成AIを搭載し、ユーザーの指示に対して自律的に...
(↑ さらに続けて3回目の合格した回答が流れてくる)
エンドユーザー向けのUIでこれをそのまま垂れ流すと、不合格だった途中の回答まで全部見えてしまいます。最終結果だけ表示したいケースもあるかと思います。
試行の区切りをどう検出するか
ではどう実装するのが良いのか、ストリームに試行の区切りを検出できるマーカーがないか、流れてくるイベントの種類を調べてみました。
init_event_loop ← 試行1の開始
(data イベントが流れる)
message(assistant) ← 試行1の応答が確定
init_event_loop ← 試行2の開始
(data イベントが流れる)
message(assistant) ← 試行2の応答が確定
init_event_loop ← 試行3の開始
(data イベントが流れる)
message(assistant) ← 試行3の応答が確定
result ← 全体の完了
リトライのたびに init_event_loop イベントが流れてきているので、これを区切りとしてバッファをリセットすれば、合格した最終試行の回答だけを表示する実装ができそうです。
async def main():
buffer = ""
attempt = 0
async for event in agent.stream_async("生成AIエージェントとは何か説明してください。"):
if "init_event_loop" in event:
if buffer:
print(f"(試行 {attempt} は不合格のため破棄。リトライします)")
attempt += 1
buffer = ""
if "data" in event:
buffer += event["data"]
print(f"\n=== 合格した試行 {attempt} の回答だけを表示 ===")
print(buffer)
init_event_loop が来たらバッファを空にして、data イベントのテキストを積んでいくだけのシンプルな実装です。実行してみます。
(試行 1 は不合格のため破棄。リトライします)
(試行 2 は不合格のため破棄。リトライします)
=== 合格した試行 3 の回答だけを表示 ===
# 生成AIエージェントとは
自律的に判断・行動するAIシステム。ユーザーの指示を受けて、自ら計画を立てて複数のツールを組み合わせ、主体的に目標を達成します。
不合格だった試行を捨てて、合格した回答だけを表示できました!最終回答だけを確実に見せたい場合は、このようにサーバー側・アプリ側でバッファリングしておき、合格が確定してから表示するのが安全です。
不合格でもエージェントの過程も見せたい場合は垂れ流しつつ、init_event_loopが来たら再試行していることがわかるようなUIにするのも良いかもしれませんね。
設定パラメータまとめ
GoalLoop の主な設定パラメータは下記の通りです。
| パラメータ | デフォルト | 説明 |
|---|---|---|
| goal | 必須 | 自然言語の文字列、または検証関数 |
| max_attempts | 無制限 | 試行回数の上限 |
| timeout | 無制限 | 秒単位の全体の時間予算 |
| judge | None | 自然言語ゴールの判定に使うモデルの設定(JudgeConfig) |
| preserve_context | True | リトライ時に会話履歴を保持するか |
| resume_prompt_template | 組み込み | リトライメッセージを生成する関数 |
judge を指定すると、合否判定に使うモデルをメインのエージェントとは別に指定できます。
from strands.vended_plugins.goal import GoalLoop, JudgeConfig
plugin = GoalLoop(
goal="出典を2つ以上引用すること。",
max_attempts=3,
judge=JudgeConfig(
model=BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0"),
),
)
判定は失敗のたびに発生するので、このように Nova Lite や Haiku のような軽量モデルを割り当てるとコストを抑えられます。また preserve_context を False にすると、失敗した応答の履歴を引き継がず、初回呼び出し前の状態に戻したうえで直前のフィードバックだけを渡して再試行する、ステートレスなリトライになります。
おわりに
これまではエージェントの実装を自前で作り込んだり、プロンプトに指示を足したりして対応していた部分が、まるっとカバーされた感じで、プラグインを差し込むだけで使えるのは嬉しいですね!
長時間動かすタスクや一定の品質を担保したい際に使ってみたいです。
本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!
補足:カスタム判定器の自作もできる
今回は触れませんでしたが、自然言語ゴールの判定に使われる内部のシステムプロンプトやプロンプト生成関数は公開されていて、判定器そのものを自作することもできます。公式ドキュメントに例が載っているので引用します。
from strands.vended_plugins.goal import (
GoalLoop,
build_judge_prompt,
JUDGE_SYSTEM_PROMPT,
JudgeOutcome,
)
async def custom_judge(response, agent):
from strands import Agent as JudgeAgent
from strands.models.bedrock import BedrockModel
judge = JudgeAgent(
model=BedrockModel(model_id="us.amazon.nova-lite-v1:0"),
callback_handler=None,
system_prompt=JUDGE_SYSTEM_PROMPT,
structured_output_model=JudgeOutcome,
)
prompt = build_judge_prompt("Be concise.", agent.messages)
result = await judge.invoke_async(prompt)
outcome = result.structured_output
return {"passed": outcome.passed, "feedback": outcome.feedback}
plugin = GoalLoop(goal=custom_judge, max_attempts=3)
組み込みのジャッジと同じ部品(JUDGE_SYSTEM_PROMPT / build_judge_prompt)を流用しつつ、判定エージェントの構成を丸ごと差し替えられる形です。判定プロンプトを日本語にカスタマイズしたい場合などは、この方法が使えそうです。





