Claudeのループエンジニアリングを試してみる

管理者·

概要

AIに何か頼むとき、最初は「プロンプトを投げて返事をもらう」だけで十分ですよね。
でも、ファイルを読んでから直す、コマンドを実行してその結果を見て次を考える、といった作業をやらせようとすると、急に一発勝負では足りなくなります。
「返事の中にツールを呼びたいという意思表示があったら、こちらが実行して、その結果をまた渡して、続きを考えてもらう」——この往復をプログラム側で回す必要が出てくるんです。

この往復こそが、いわゆる**エージェントループ(agentic loop)**です。
Claude Codeのような道具が裏側でやっているのも、煎じ詰めればこのループの管理に尽きます。ループの止め方を間違えれば無限に回り続けますし、思考量を盛りすぎればトークンを溶かします。逆にここを丁寧に設計できると、長時間ほったらかしでも崩れない自律エージェントが作れます。

この記事では、Anthropicの公式SDKを使ってそのループを自分で組み立てます。
環境はNixOSで再現性よく用意して、最小のループから始めて、停止条件・思考の深さ・トークン予算と、少しずつ実用的な形に育てていきましょう。
モデルは執筆時点で最も能力の高いOpus系(claude-opus-4-8)を前提にします。

ループの正体

まず「何を回しているのか」をはっきりさせておきます。
Claudeのメッセージは状態を持ちません。つまり毎回、会話の履歴を丸ごと送り直すのが基本です。ループの一周は、おおよそ次のような流れになります。

  1. これまでの履歴+使えるツール一覧を送る
  2. 返事の stop_reason を見る
  3. tool_use なら、要求されたツールをこちらで実行する
  4. 実行結果(tool_result)を履歴に積んで、また1へ戻る
  5. end_turn になったら、Claudeはもう道具を使う気がない=ループ終了

ポイントは、ツールを実際に動かすのはこちら側だということ。
Claudeは「get_weatherTokyo で呼びたい」と言ってくるだけで、その関数を走らせて答えを返すのはプログラムの責任です。だからこそ、どこで止めるか・何を許可するかを完全に手元でコントロールできます。

NixOSで実験環境を用意する

環境構築で詰まると本題に入れないので、先に足場を固めましょう。
NixOSらしく、flake.nix で開発シェルを宣言してしまいます。一般的なディストリだと「pip install でグローバルを汚すのが気になる…」となりがちですが、ここではFlakesで閉じた環境を切り出せるのが気持ちいいところですね。

# flake.nix — Claudeループ実験用の開発シェル
{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      devShells.${system}.default = pkgs.mkShell {
        # anthropic SDK 入りの Python をそのまま用意する
        packages = [
          (pkgs.python3.withPackages (ps: [ ps.anthropic ]))
        ];
      };
    };
}

このディレクトリで開発シェルに入ります。

# flake.nix のあるディレクトリで実行
nix develop

python -c "import anthropic" がエラーなく通れば準備完了です。
APIキーは設定ファイルに書かず、環境変数で渡しておきましょう。

# その場のシェルだけに効かせる(履歴に残したくなければ読み込み方を工夫しても良い)
export ANTHROPIC_API_KEY="sk-ant-..."

他ディストリの場合
Ubuntu / Debian系Fedora なら python3 -m venv .venv && source .venv/bin/activate のあと pip install anthropic という流れになります。やっていることは同じですが、NixOSのFlakesだと「この記事を試した環境」をそっくり再現できるのが強みです。

最小のループを書いてみる

いよいよ本体です。
まずは雰囲気をつかむため、get_weather という一つだけのツールを持たせて、ループを手書きしてみましょう。ツールの中身はダミーで構いません。

# loop.py — 最小のエージェントループ
import anthropic

client = anthropic.Anthropic()

# Claude に渡すツールの「説明書」。いつ使うかを具体的に書くのがコツ
tools = [
    {
        "name": "get_weather",
        "description": "指定した都市の現在の天気を返す。"
                       "ユーザーが天気や気温を尋ねたときに呼ぶこと。",
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "都市名"},
            },
            "required": ["city"],
        },
    }
]

def run_tool(name, tool_input):
    # 本来はここで実際のAPIを叩く。今回は固定値を返すだけ
    if name == "get_weather":
        return f"{tool_input['city']}は晴れ、22℃です。"
    return f"未知のツール: {name}"

messages = [{"role": "user", "content": "東京の天気を教えて。それから一句詠んで。"}]

while True:
    response = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=16000,
        tools=tools,
        messages=messages,
    )

    # Claude がもう道具を使わないなら、ここで抜ける
    if response.stop_reason == "end_turn":
        break

    # 返事(tool_use を含む)をそのまま履歴へ積む
    messages.append({"role": "assistant", "content": response.content})

    # 要求されたツールを実行し、結果をまとめて返す
    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            result = run_tool(block.name, block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,  # 必ず元の tool_use と紐付ける
                "content": result,
            })
    messages.append({"role": "user", "content": tool_results})

# 最後のテキストを取り出す
final = next(b.text for b in response.content if b.type == "text")
print(final)

nix develop のシェルの中で動かします。

python loop.py

天気を一度問い合わせたあと、その結果を踏まえて俳句まで返ってくれば成功です。
肝になっているのは三点だけ。stop_reason で抜けるassistantの返事を丸ごと積み直すtool_use_id で結果を対応づける。この型さえ守れば、ツールが何種類に増えても構造は変わりません。

ループを賢く止める

最小ループには落とし穴があります。end_turn だけを見ていると、状況によっては延々と回り続けたり、想定外の停止理由でクラッシュしたりします。
実運用に近づけるなら、反復回数の上限停止理由の網羅を入れておきましょう。

MAX_TURNS = 25  # 暴走の最後の砦

for turn in range(MAX_TURNS):
    response = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=16000,
        tools=tools,
        messages=messages,
    )

    if response.stop_reason == "end_turn":
        break

    # サーバー側ツールが内部の上限に達したときの一時停止。送り直せば再開する
    if response.stop_reason == "pause_turn":
        messages.append({"role": "assistant", "content": response.content})
        continue

    # 出力上限に当たった=途中で切れている。max_tokens を見直すサイン
    if response.stop_reason == "max_tokens":
        raise RuntimeError("出力が途中で切れました。max_tokens を増やしてください。")

    # 安全上の理由で拒否されたケース。無限リトライしない
    if response.stop_reason == "refusal":
        print("リクエストは拒否されました。")
        break

    # ここに来たら tool_use。実行して結果を返す
    messages.append({"role": "assistant", "content": response.content})
    # (ツール実行は前節と同じなので省略)
else:
    print(f"{MAX_TURNS} ターンで打ち切りました。")

for ... else を使うと、「ループが自然に終わらず上限で抜けた」ケースをきれいに拾えます。
ここで大事なのは、上限はあくまで保険だということ。普段はClaudeが end_turn で気持ちよく終わるのが理想で、しょっちゅう上限に当たるなら、ツール設計かプロンプトを見直すべきサインです。

思考の深さをダイヤルで回す

ループの一周ごとに「どれだけ考えてもらうか」も設計対象です。
Opus系では、Claude自身が必要に応じて考える量を決めるアダプティブ思考を有効にできます。さらに effort(努力量)で全体のトークン消費と粘り強さの匙加減を調整します。

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    thinking={"type": "adaptive"},          # 考える量はClaudeに任せる
    output_config={"effort": "high"},        # low / medium / high / xhigh / max
    tools=tools,
    messages=messages,
)

effort のざっくりした使い分けはこんな感じです。

  • low — 軽い分類や定型処理、レイテンシ優先のとき
  • medium — コストと質のバランスを取りたい大半の用途
  • high — 知的な負荷が高い作業の基準点。まずここから試すのがおすすめ
  • xhigh — コーディングや本格的なエージェント作業で粘らせたいとき
  • max — とにかく正確さ最優先で、コストは二の次のとき

面白いのは、エージェントループでは高めの effort がかえって安く済む場合があること。最初にしっかり計画させると、結果的にツール往復の回数が減って、トータルのターン数が縮むからです。短いループで何度も浅く考えさせるより、一周あたりを賢くしたほうが綺麗に収束する、というのはよくある話ですね。

なお、画面に思考の様子を出したいなら thinking={"type": "adaptive", "display": "summarized"} のように要約表示を明示します。既定では思考は走っていてもテキストは空なので、「出力前にやけに長い沈黙がある」と感じたらこれを思い出してください。

トークン予算で暴走を防ぐ

反復回数の上限は「最後の砦」でしたが、もう少し賢い止め方もあります。
Opus 4.7以降では**タスク予算(Task Budgets)**というベータ機能が使えて、「このループ全体でだいたいこれだけのトークンを使っていいよ」とClaudeに伝えられます。Claudeは残量のカウントダウンを見ながら、優先順位をつけて自分で店じまいに向かってくれます。

response = client.beta.messages.create(
    betas=["task-budgets-2026-03-13"],
    model="claude-opus-4-8",
    max_tokens=16000,
    thinking={"type": "adaptive"},
    output_config={
        "effort": "high",
        "task_budget": {"type": "tokens", "total": 128000},  # 最低 20,000
    },
    tools=tools,
    messages=messages,
)

max_tokens が「一回の返事に対する強制的な上限(モデルは知らされない)」なのに対して、task_budget は「ループ全体に対する、モデルが意識する目安」です。役割が違うので、両方を併用するのが素直です。
長時間の自律タスクほど、こうした「予算感覚」を持たせておくと、途中で力尽きるにしても綺麗に着地してくれます。

外側のループ——定期実行という発想

ここまでは「一つのタスクをやり切るための内側のループ」でした。
もう一段視点を上げると、そのタスク自体を定期的に回す外側のループも設計対象になります。たとえば毎晩ログを点検させる、定期的に状態を監視させる、といった使い方です。

NixOSならこの外側のループはお手の物で、systemd.timers として宣言的に書けます。

# configuration.nix の一部
systemd.timers.claude-nightly = {
  wantedBy = [ "timers.target" ];
  timerConfig.OnCalendar = "*-*-* 03:00:00";  # 毎日3時に起動
};

systemd.services.claude-nightly = {
  serviceConfig.ExecStart = "${pkgs.python3}/bin/python /etc/claude/loop.py";
};

設定を書き換えたら反映します。

# 設定ファイルを書き換えて再構築
sudo nixos-rebuild switch

内側のループ(モデルとツールの往復)と、外側のループ(タスクの定期実行)。
この二層を分けて考えられるようになると、「Claudeに何をどう任せるか」の設計がぐっと見通しよくなります。

まとめ

エージェントループは、突き詰めれば「stop_reason を見て、ツールを実行して、履歴を積み直す」というシンプルな往復でした。
ただ、そのシンプルな型の周りに、反復回数の上限・停止理由の網羅・思考量のダイヤル・トークン予算といった「タガ」を一つずつ嵌めていくことで、ほったらかしでも崩れないループに育っていきます。今回NixOSのFlakesで環境を切り出したのは、こうした実験をいつでも同じ条件でやり直せるようにするためで、ループを少しずつ調整しながら育てる作業との相性はかなり良かったです。

向き不向きでいうと、一問一答で済む処理にここまでの仕組みは過剰です。ループを組むのは「複数ステップで、事前に手順を全部書ききれない作業」のとき。逆にそういうタスクこそ、ループ設計の良し悪しがそのまま結果に出ます。
まずは最小ループを動かして、effort を一段ずつ変えながら挙動の差を眺めてみてください。「考えさせる量を上げたら、かえってターン数が減った」という瞬間に出会えたら、ループエンジニアリングの面白さが掴めてきたサインです。

シェア: