見出し画像

AIをループに組み込むための、Claude API Tips(開発者向け)

チャットの相手として使うAIと、システムの部品として使うAIは、別物だ。
前者は多少ゆらいでも人間が読んで直せる。後者は、返ってきた値がそのまま次の処理に流れていく。
この記事は、AIを「自分の処理ループの部品」にするために、私がClaude APIで実際に検証したTipsをまとめたものだ。コードはすべて手元で動かして、結果の数字も載せている。



まず、こういう話から始めたい

AIに「このレビューを分析して、JSONだけ返して。説明はいらない」と頼む。よくやる使い方だ。

これを、同じプロンプトで10回投げてみた。結果はこうだった。

10回中、10回とも壊れていた。

壊れ方はどれも同じで、答えの前後にコードブロックの囲み記号(バッククォート3つと json の宣言)が付いてくる。人間が読むぶんには親切だが、プログラムが JSON.parse() に渡した瞬間に例外で落ちる。「説明はいらない」と明示しても、モデルは律儀に囲みを付けてきた。

チャットとして使うなら、これでいい。人間が囲みを外して読めばいい。でも、この出力を次の処理に自動で流したいなら——10回中10回落ちる部品は、使いものにならない。


AIは、ループの中で唯一「形がゆらぐ」部品

以前、「ループエンジニアリング」という記事を書いた。AIに毎回プロンプトを打つのをやめて、AIを繰り返し呼び出すループそのものを設計する側に回る、という話だ。


実際に毎朝回している記事スカウトのループ。AIの返すJSONが次の処理の制御信号になる


これは実際に私がこのnoteの運営で毎朝回しているループを、そのまま図にしたものだ。毎朝決まった時間に起動して、検索ツールが記事を集め、AIが評価し、結果を状態ファイルに保存して、候補を私に通知する。私が寝ていても回る。

ここで気づいてほしいことがある。このループの部品は4つあるが、検索APIも、保存処理も、通知も、出力の形は決まっている。入力を与えれば、決まった型で返ってくる。

ゆらぐのはAIだけだ。

同じ入力でも、返す文章は毎回違う。JSONで頼んでも囲みが付く。つまりAIは、ループの中で唯一、出力の形が保証されていない部品なのだ。そしてループは、いちばん弱い部品のところで止まる。

図の真ん中にあるJSONを見てほしい。candidates は次のツールの入力になり、next_query は明日の検索条件になる。フィールドの一つひとつが、次の処理の制御信号だ。ここが「ただの文章」で返ってきたら、この先の処理はひとつも始められない。

だから、AIをループに組み込むための最初の仕事は、モデル選びでもプロンプトでもなく、出力の形を固定することになる。


① 構造で受け取る——「お願い」ではなく「保証」に変える

いまは、JSONを保証する公式の機能がある

少し前まで、Claude APIで確実にJSONを受け取る定番は「ツール機能を流用するハック」だった。tool_choice で特定のツールを強制的に呼ばせ、その引数としてJSONを受け取る。私自身この方法をずっと使ってきたし、いまでも有効だ。ただ、2025年後半に構造化出力(structured outputs)がネイティブ機能として正式化され、位置づけが変わった。

使い方はシンプルで、リクエストに output_config を足すだけだ。

const body = {
  model: "claude-haiku-4-5",
  max_tokens: 1024,
  output_config: {
    format: {
      type: "json_schema",
      schema: {
        type: "object",
        properties: {
          sentiment: { type: "string", enum: ["positive", "negative", "neutral"] },
          score: { type: "number" },
          keywords: { type: "array", items: { type: "string" } },
        },
        required: ["sentiment", "score", "keywords"],
        additionalProperties: false,
      },
    },
  },
  messages: [{ role: "user", content: `次のレビューを分析して。\nレビュー: ${review}` }],
};

これが「お願い」と決定的に違うのは、仕組みだ。渡したスキーマを文法にコンパイルして、生成のときにスキーマ違反のトークンをそもそも出せないように制限する。プロンプトで「JSONで返してね」と頼むのではなく、壊れた形が物理的に生成できない。

ツールの仕組みとは別物であることも、確かめてある。toolsを一切渡さずに output_config だけを付けても、JSONは普通のtextとして返ってくる。旧ハックのようにツール呼び出しを経由しているわけではない。

冒頭の実験を、この output_config で同じ条件(temperature 1、10回)でやり直すと、こうなった。

10回中、0回。ひとつも壊れなかった。


同条件10回ずつの実測。プロンプト方式は10/10で壊れ、構造化出力は0/10だった


囲みも付かず、返ってきた text をそのまま JSON.parse() に通せる。「お願い」から「保証」へ。ゆらぐ部品が、ゆらがない部品に変わる。これが最初のTipだ。

tool_choice強制はもう不要かというと、そうでもない

ネイティブ機能が出たからといって、ツール強制が無駄になったわけではない。この2つは併用できる。「ツールで自由に調べさせてから、最後は構造化JSONで受け取る」——これが次の話につながる。


② 調べさせてから、型に流す——ループの実装

さっきの図で、AIの箱には小さくこう書いてあった。「途中で本文取得ツールを何度か往復」。

AIをループに組み込むとき、AIに一発で答えさせる必要はない。判断の材料が足りなければ、AIのほうから「このツールで調べたい」と言ってくる。こちらはツールを実行して結果を返し、AIは納得するまで調べてから、最終的な答えを決まった型のJSONで返す。

tools と output_config を同じリクエストに両方入れておくと、この流れが1つのループで書ける。実際に、ダミーの「自分の記事の成績を検索するツール」を持たせて回してみた。

const MAX_TURNS = 8; // 暴走ガード。これは必須
for (let turn = 1; turn <= MAX_TURNS; turn++) {
  const { json } = await messages({
    model, max_tokens: 1024, tools,
    output_config: { format: { type: "json_schema", schema: reportSchema } },
    messages: msgs,
  });

  if (json.stop_reason === "tool_use") {
    // ツールを実行して、結果を user ロールで返す
    msgs.push({ role: "assistant", content: json.content });
    const results = json.content
      .filter((b) => b.type === "tool_use")
      .map((b) => ({
        type: "tool_result",
        tool_use_id: b.id,
        content: JSON.stringify(runTool(b.name, b.input)),
      }));
    msgs.push({ role: "user", content: results });
    continue; // ループ先頭に戻る
  }

  if (json.stop_reason === "end_turn") {
    const report = JSON.parse(json.content.map((b) => b.text ?? "").join(""));
    // ← ここで手に入るのが、型の保証されたJSON。次の処理へ
    break;
  }
}

回してみて、実測でわかったことが2つある。

ひとつ。ツールの呼び出しは、まとめて来る。 1回目のレスポンスで、tool_use のブロックが3つ並列で入ってきた。3つの検索を別々のターンでやるのではなく、1レスポンスにまとめて要求してくる。だから受け取る側は「複数来る前提」で、全部実行してから返す作りにしておく。

ふたつ。ツール結果は user ロールで返す。 ここは間違えやすい。AIの発言(assistant)に対して、ツールの実行結果は「こちらからの入力」なので user ロールで積む。この一手を間違えると、ループが噛み合わない。

そして肝心なのは、このループから抜ける正常な出口はひとつだけだということ。end_turn が返り、型の保証されたJSONを取得できたときだけ、それを次の処理に引き渡す。それ以外は、まだループの途中だ。


エラーハンドリングまで含めた全体フロー。正常な出口はend_turnのひとつだけ


エラーの扱いまで含めると、全体はこの図の形になる。tool_use なら実行結果をAIに返してもう一周。end_turn ならJSONを取得して次の処理へ。それ以外——max_tokens や refusal——はエラーハンドリングに流す。

この「end_turnで得た出力を、次の処理に渡す」の一手こそが、AIをシステムに組み込むということの正体だと思う。tool_useの往復は、その内側の仕組みにすぎない。

一番よく作る形:「webを任意で検索して、必ずJSONで返す」

このパターンの需要がたぶん一番多いので、最小構成を載せておく。web検索はAnthropic公式のサーバーツールを使うと、自前実装ゼロで済む。

const body = {
  model: "claude-haiku-4-5",
  max_tokens: 2048,
  // 検索ツール(サーバー側で実行される。max_usesがコストガード)
  tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 3 }],
  // 最終出力のスキーマ(「必ずJSON」の保証はこちら)
  output_config: { format: { type: "json_schema", schema } },
  // tool_choice は書かない(auto)=「任意で検索」の正体
  messages: [{ role: "user", content: "日銀の現在の政策金利は?最新情報を調べて" }],
};

実際にこれを投げると、1レスポンスの中で検索が実行され、最新の事実(2026年6月の利上げ)を出典URL付きで拾って、そのままparseできるJSONが返ってきた。スキーマに used_search(検索を使ったか)というbooleanを入れておくと、モデルはそこも正しく埋めてくる。

パラメータパターン表

このあたりの設定値は混同しやすいので、役割分担を表にしておく。それぞれ別のレイヤーの機能だから、安心して組み合わせられる。


tools/tool_choice/output_configの役割分担と、tool_choiceの設定値(実測)


サーバーツール特有の注意はひとつだけで、処理が長いと stop_reason に pause_turn(一時停止)が返ることがある。そのときはassistantの内容を履歴に積んで、そのまま再送すれば続きが走る。


③ ループが事故る、いくつかのポイント

構造化出力でJSONが保証されると、つい安心してしまう。でもループを止めるのは、たいてい別のところだ。実際に事故らせて確かめたものを挙げておく。

スキーマ保証があっても、max_tokens には勝てない

max_tokens を小さく設定して、わざと出力を途中で打ち切らせてみた。返ってきたのはこれだ。

{"summary":"日本の税制は、国税と地方税からなる複層構造で、所得税、法人税、消費税が主要な税目です。個人と企業の

文字列の途中でぶつっと切れている。stop_reason は end_turn ではなく max_tokens。当然 JSON.parse() は失敗する。構造化出力はスキーマ違反を防ぐが、「最後まで書き切ること」は保証しない。

だから、parseする前に必ず stop_reason を確認する。max_tokens なら、それは壊れたJSONではなく「途中で紙が尽きた」状態として扱い、上限を上げて投げ直す。

使えないスキーマは、その場で400が返る

構造化出力のスキーマには制限がある。たとえば数値に minimum / maximum(範囲指定)を付けると、実行前に弾かれる。実際のエラーメッセージはこうだった。

output_config.format.schema: For 'number' type,
properties maximum, minimum are not supported

ほかにも、再帰的なスキーマ、文字数の下限・上限、additionalProperties: false 以外の指定などが非対応だ。「JSON Schemaで書けること」と「構造化出力で使えること」は同じではない。動かす前にドキュメントで確認しておくと、無駄な往復が減る。

途中のターンにも、それらしいtextが混ざることがある

ツール併用のループを何度か回していると、stop_reason が tool_use のレスポンスに、スキーマ準拠のtextブロックが同居していることがあった。見た目は完全に最終回答のJSONだ。でもモデルはまだツールを要求している——つまりそれは途中経過にすぎない。

textブロックが取れたからといって最終回答と決めつけてparseすると、調べ終わる前の暫定の答えを次の処理に流してしまう。最終回答として扱っていいのは、stop_reason が end_turn のときのtextだけ。この規律は、実際に回すと想像より早く効いてくる。

stop_reason は end_turn と tool_use だけではない

ループを書くとき、tool_use か end_turn かの二択で考えてしまいがちだが、実際にはほかの値も返ってくる。安全上の判断でモデルが応答を断る refusal、長時間かかるサーバー側処理の途中経過を示す pause_turn などだ。

だから設計としては、「end_turn 以外は、まだ終わっていない。値を見て理由を確かめる」という構えにしておく。知らない値を「たぶん大丈夫だろう」と流すと、そこでループが崩れる。そして、どんなループにも必ず周回上限(MAX_TURNS)を付ける。上限のないループは、事故ったときに課金だけが回り続ける。

(余談)構造化出力は、キャッシュとの相性に癖がある

ここは続編向けの話なので軽くだけ。プロンプトキャッシュと output_config を一緒に使うと、キャッシュの効き方に癖がある。実測すると、構造化出力を付けたリクエストは、付けないリクエストとは別のキャッシュ行として扱われるようだった。つまり両者を同じパイプラインで混ぜると、キャッシュの書き込みが二重になる。コストを詰めるフェーズで効いてくる話なので、別の記事で詳しく書きたい。


これは、机上の話ではない

冒頭の図に戻る。あのループは実在していて、この記事を書いている今朝も動いていた。

コメントすべき記事を探すのも、マガジンに追加する記事を品質で判定するのも、AIの出力を構造化して受け取り、次の処理に流すループでできている。私が寝ている間に検索と評価が終わっていて、起きたら候補が届いている。

チャット相手としてのAIなら、多少ゆらいでも困らない。でも、無人で回るループの部品にした瞬間、「たまに壊れるJSON」は「たまに止まるシステム」になる。だから、お願いではなく保証で受け取る。出口をひとつに絞る。事故るポイントを先に潰しておく。地味だが、この足場があって初めて、AIは安心して任せられる部品になる。


おわりに

AIを呼ぶこと自体は、もう驚くほど簡単になった。難しいのはその先——AIから受け取った出力を、どう自分の処理に組み込むかの設計のほうだ。

ループの中で唯一ゆらぐ部品の、形を固定する。それが「いいプロンプトを書く」から「AIを部品として組み込む」へ進む、最初の一歩だと思う。


この記事のコードはすべて手元でClaude API(claude-haiku-4-5)に対して実行し、結果の数字を確認したもの。かかった費用は数円程度だった。

参考にした一次情報:

Anthropic公式ドキュメント「Structured outputs」

Anthropic公式ブログ「Structured outputs on the Claude Developer Platform」

Anthropic公式ドキュメント「How the agent loop works」

過去の関連記事:


いいなと思ったら応援しよう!