壁紙アプリで Claude API を使ってカテゴリ分類を始めた頃、最初に困ったのは「JSON は返ってくるのに、たまに気持ち悪い壊れ方をする」現象でした。"categories": ["自然", "風景"] と返してほしい場面で、"categories": "自然, 風景" という文字列が混ざる。スキーマには合っていないのに 200 OK で返ってくる。LLM の癖を知らないまま実装すると、こうした「形だけ正しくない」入力に毎日少しずつ削られて、運用1ヶ月後には監視ダッシュボードが警告だらけになります。
個人開発で運用しているアプリ群では、Claude API を呼ぶ処理のほとんどが、読ませる文章を作るためではなく、後段のコードへ渡す「構造化された値」を取り出すために走っています。人間が読むテキストなら多少の揺れは飲み込めますが、機械が受け取る値の揺れは、そのまま障害の形をしています。その積み重ねの中で固まった壊れにくい設計を、tool_use・JSON Schema・多層検証の3つの軸で書き出します。
構造化レスポンスが「形だけ」では足りない理由
構造化レスポンスを本番に載せると、最初に直面するのは「形は合っているが、意味が壊れている」レスポンスです。たとえば次のような失敗パターンが、運用しているとどんどん見つかります。
スキーマには合っているが、enum 外の値が入っている("category": "その他" のような未定義値)
配列の中に空文字列が混じる(["風景", "", "自然"])
数値フィールドに "5" や "unknown" などの文字列が紛れる
必須フィールドが欠落していて、SDK 側のパースは通るがアプリ側で undefined になる
同じプロンプトでも、ストリーミングで途中切断されると JSON 末尾が壊れたまま完了扱いになる
形式だけを検証する単純な実装は、これらを「成功」として通過させてしまいます。SDK 側の型は静的型でしかなく、ランタイムでフィールドが揃っている保証は別の話です。tool_use を使うと Anthropic 側で JSON 検証が行われるため一段安全になりますが、それでも「意味の正しさ」までは保証してくれません。
私が壁紙アプリの分類処理で導入した方針は単純です。検証を3層に分け、層ごとに失敗時の戦略を変えることでした。後述しますが、これだけで分類処理の手動修正率が月平均で約 4.2% から 0.7% まで下がりました。1日約 1,200 枚を捌くパイプラインで、月に何百件かの手作業が消えるとそれなりにきく数字です。
tool_use・response_format・prompt-only の比較と使い分け
Claude API で構造化レスポンスを得る方法は、大まかに次の3通りに整理できます。それぞれ得意な領域が違います。
方式 厳密さ 実装コスト 失敗時の挙動 得意な場面
prompt-only(プロンプトで JSON を要求) 弱い 最小 壊れた JSON が混じる 単発の小タスク・PoC
tool_use(input_schema で構造を強制) 強い 中 スキーマ違反は API 側でリジェクトされる 本番の構造化抽出全般
extended thinking + tool_use 最も安定 中〜高 推論経路を踏まえてスキーマ通り返す 複雑な分岐・合成タスク
response_format 相当の指定は API リファレンス上は提供されていないため、Anthropic 側で構造を強制したい場合は実質 tool_use を使うことになります。私の運用では tool_use を本番の標準にしていて、prompt-only は使い捨ての検証スクリプトに限定しています。
判断軸は2つだけ意識しています。第一に「失敗をどう受け取りたいか」。tool_use なら API レスポンスが構造として返ってこない場合の処理を一本化できます。第二に「コストとレイテンシの差」。tool_use 自体のオーバーヘッドはほぼ誤差ですが、複雑な extended thinking を併用すると入力トークンが膨らむため、扱うタスクの単価と相談して決めます。壁紙の単純分類なら extended thinking なし、契約書から条項を抽出するなら extended thinking ありで設計しています。
# Before — prompt-only に依存していた頃の素朴な実装
from anthropic import Anthropic
import json
client = Anthropic()
def classify_wallpaper_naive (prompt: str ) -> dict :
resp = client.messages.create(
model = "claude-haiku-4-5" ,
max_tokens = 512 ,
messages = [{ "role" : "user" , "content" : f "次の画像を分類してJSONで返してください。 \n{ prompt } " }],
)
text = resp.content[ 0 ].text
return json.loads(text) # ← ここで時々壊れる
# After — tool_use で「構造を返すしかない」状態に追い込む
TOOL_SCHEMA = {
"name" : "classify_wallpaper" ,
"description" : "壁紙画像のカテゴリ分類を返す" ,
"input_schema" : {
"type" : "object" ,
"properties" : {
"primary_category" : {
"type" : "string" ,
"enum" : [ "nature" , "abstract" , "city" , "minimal" , "people" , "other" ],
},
"tags" : {
"type" : "array" ,
"items" : { "type" : "string" , "minLength" : 1 , "maxLength" : 16 },
"minItems" : 1 ,
"maxItems" : 6 ,
},
"confidence" : {
"type" : "number" ,
"minimum" : 0.0 ,
"maximum" : 1.0 ,
},
"needs_human_review" : { "type" : "boolean" },
},
"required" : [ "primary_category" , "tags" , "confidence" , "needs_human_review" ],
"additionalProperties" : False ,
},
}
def classify_wallpaper_tool (image_block: dict ) -> dict :
resp = client.messages.create(
model = "claude-haiku-4-5" ,
max_tokens = 512 ,
tools = [ TOOL_SCHEMA ],
tool_choice = { "type" : "tool" , "name" : "classify_wallpaper" },
messages = [{
"role" : "user" ,
"content" : [image_block, { "type" : "text" , "text" : "上の壁紙を分類してください" }],
}],
)
for block in resp.content:
if block.type == "tool_use" and block.name == "classify_wallpaper" :
return block.input # ← 構造化済みの dict
raise RuntimeError ( "tool_use block not found" )
注目したいのは tool_choice={"type": "tool", "name": "..."} で強制している点です。これがないと「テキストで答えようとして tool_use を呼ばない」ケースが時々発生します。本番では強制が原則だと考えています。
JSON Schema は「ギリギリ厳しくしない」のが本番運用の勘所
構造化レスポンスの安定性は、JSON Schema の書き方でだいたい決まります。経験上、最初は「最大限厳密」に書きたくなりますが、Claude の出力安定性を考えると、過度に厳しすぎる制約はかえって失敗率を上げます。私が壁紙アプリで採用しているのは「ギリギリ厳しい」設計です。
具体的には次の方針で書いています。
第一に、enum を使う場合は必ず「想定外を受け止める箱」を入れることです。"other" や "unspecified" を enum に含めておくと、モデルが迷ったときに無理に既存カテゴリへ寄せるのではなく、その箱に逃がしてくれます。後処理で "other" だけ手動レビュー対象に回す設計にしておくと、運用が安定します。
第二に、文字列フィールドには minLength と maxLength を必ず入れることです。空文字列の混入と、想定外の長文出力の両方を一気に塞げます。Claude は基本的に短くまとめてくれますが、まれに "primary_category": "自然 (山岳・湖沼を含む広範な景観カテゴリ)" のような長文を返してくることがあるので、maxLength: 32 程度の予防線は効きます。
第三に、additionalProperties: false は本番では必ず入れることです。これがないと、モデルが気を利かせて余計なフィールドを足してくることがあり、ダウンストリームの処理を壊します。
第四に、数値フィールドは minimum と maximum で範囲を明示することです。確率値なら 0〜1、評価スコアなら 1〜5 のように範囲を狭く保つほど、出力の散らばりが減ります。
# Before — 「厳密にしすぎて壊れる」スキーマの例
"input_schema" : {
"type" : "object" ,
"properties" : {
"primary_category" : {
"type" : "string" ,
"enum" : [ "nature" , "abstract" , "city" , "minimal" , "people" ], # other がない
},
"color_palette" : {
"type" : "array" ,
"items" : { "type" : "string" , "pattern" : "^#[0-9A-Fa-f] {6} $" }, # 強すぎる正規表現
"minItems" : 5 ,
"maxItems" : 5 ,
},
},
"required" : [ "primary_category" , "color_palette" ],
}
# After — 「ギリギリ厳しい」スキーマの例
"input_schema" : {
"type" : "object" ,
"properties" : {
"primary_category" : {
"type" : "string" ,
"enum" : [ "nature" , "abstract" , "city" , "minimal" , "people" , "other" ],
},
"color_palette" : {
"type" : "array" ,
"items" : { "type" : "string" , "minLength" : 4 , "maxLength" : 7 }, # #RGB / #RRGGBB
"minItems" : 1 ,
"maxItems" : 5 ,
},
},
"required" : [ "primary_category" , "color_palette" ],
"additionalProperties" : False ,
}
Before のスキーマでは「カラーパレットが必ず5色」「16進数フォーマット完全一致」と縛りすぎていて、稀に画像によっては色数が足りない/フォーマットがわずかにずれるだけで API 側がリジェクトしてリトライを繰り返す事態が起きていました。After のスキーマに緩めてからは、Anthropic 側のリジェクトは月1〜2件程度に収まりました。
ここで「厳密度を緩めると壊れたデータが入るのでは」と思いますが、その分はアプリ側のドメイン検証で受け止めます。これが3層構造の発想です。
3層に分けて検証する — SDK 型 → JSON Schema → ドメイン検証
Anthropic SDK の型は実は信用しすぎないほうがいいです。block.input の型は SDK 側では dict[str, Any] 相当でしか保証されておらず、JSON Schema 検証は API 側で行われたものを信じる形になります。さらに API 側で通っても、アプリ側のドメインルール(たとえば「needs_human_review=true なのに confidence>0.9 はおかしい」など)までは検査されません。
そこで私は次の3層で検証を組み立てています。
層 何を見るか 失敗時の戦略
第1層: SDK 型 block.type == "tool_use" など型ガードのみ 例外を投げて即リトライ
第2層: JSON Schema tool_use の input_schema が API 側で評価済み 失敗なら API レスポンス自体が来ないので例外
第3層: ドメイン検証 ビジネスルール・組み合わせ条件 失敗時はリトライではなく needs_human_review を立てて保存
第3層はモデルにやらせず、アプリ側のコードで厳密に書きます。リトライ前提で考えるとループが増えてコストが膨らむので、ドメイン検証の失敗は「分類タスクとしては完了扱い、ただし人間にレビュー依頼を立てる」という挙動にしています。
from dataclasses import dataclass
@dataclass
class ClassificationResult :
primary_category: str
tags: list[ str ]
confidence: float
needs_human_review: bool
domain_violations: list[ str ]
def validate_domain (raw: dict ) -> ClassificationResult:
violations: list[ str ] = []
# ドメインルール1: 信頼度が高いのにレビュー必要は矛盾
if raw.get( "confidence" , 0 ) > 0.92 and raw.get( "needs_human_review" ):
violations.append( "high_confidence_but_review_requested" )
# ドメインルール2: "other" が選ばれた時点で必ずレビュー対象
if raw.get( "primary_category" ) == "other" and not raw.get( "needs_human_review" ):
raw[ "needs_human_review" ] = True
violations.append( "auto_promoted_other_to_review" )
# ドメインルール3: タグの先頭/末尾の空白を除去し空文字を弾く
cleaned_tags = [t.strip() for t in raw.get( "tags" , []) if t and t.strip()]
if not cleaned_tags:
violations.append( "empty_tags_after_cleaning" )
cleaned_tags = [ "uncategorized" ]
return ClassificationResult(
primary_category = raw.get( "primary_category" , "other" ),
tags = cleaned_tags,
confidence = float (raw.get( "confidence" , 0.0 )),
needs_human_review = bool (raw.get( "needs_human_review" , False )),
domain_violations = violations,
)
domain_violations のリストはログに残します。これが「モデル出力の癖」を観察する一次データになります。たとえば「high_confidence_but_review_requested」が月内に急増しているなら、プロンプトかモデル更新の影響を疑うシグナルとして扱います。Claude のバージョンが変わったときに最初に動かすのが、この violations の前後比較です。
ストリーミング中の partial JSON とどう向き合うか
stream=True で tool_use を扱うとき、tool_use ブロックは content_block_start → input_json_delta の連続 → content_block_stop という流れで流れてきます。途中で UI に「分類結果が少しずつ揃っていく」演出を見せたい場合、partial な JSON 断片をパースしたくなりますが、ここに罠があります。
第一に、ネストされた JSON の途中状態(たとえば {"primary_category": "natu のような切れ目)は標準の json.loads ではパースできません。第二に、content_block_stop が来る前に通信が切れたらどうするか、という設計判断が必要です。
私の運用では次の方針に落ち着いています。
UI 表示用に「整形済みの最終結果」を待つ前提にし、partial 中は「分析中…」のスケルトン表示にとどめる
ただし「進捗のヒント」だけは partial で抽出する。たとえば primary_category のみ最初に決まる順序にプロンプトを誘導しておき、その値が出た瞬間に UI に色合いを反映する
通信切断やタイムアウトで content_block_stop が来なかった場合は、それまでに受け取った partial をログに残してから例外を投げ、リトライキューに入れる
具体的には partial-json のような寛容パーサを併用しつつ、最終的な「アプリで使う構造化結果」だけは content_block_stop 後に SDK 提供の tool_use.input を信用する、というハイブリッドにしています。partial の値を本番ロジックに直接食わせると、後段でデータ不整合の原因になります。
// TypeScript SDK の場合の partial JSON ハンドリング例
import Anthropic from "@anthropic-ai/sdk" ;
import { parse as parsePartial } from "partial-json" ;
const client = new Anthropic ();
async function classifyWithProgressHint ( imageInput : any ) {
let accumulatedJson = "" ;
let preview : { primary_category ?: string } = {};
let finalInput : Record < string , unknown > | null = null ;
const stream = client.messages. stream ({
model: "claude-haiku-4-5" ,
max_tokens: 512 ,
tools: [ TOOL_SCHEMA ],
tool_choice: { type: "tool" , name: "classify_wallpaper" },
messages: [{ role: "user" , content: [imageInput, { type: "text" , text: "分類してください" }] }],
});
for await ( const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "input_json_delta" ) {
accumulatedJson += event.delta.partial_json;
try {
// 寛容パースで進捗だけ得る
preview = parsePartial (accumulatedJson);
if (preview.primary_category) {
notifyUiHint (preview.primary_category);
}
} catch {
// partial の段階での失敗は無視
}
}
if (event.type === "content_block_stop" && event.content_block?.type === "tool_use" ) {
// ← ここで初めて確定値を使う
finalInput = event.content_block.input as Record < string , unknown >;
}
}
if ( ! finalInput) {
throw new Error ( "tool_use did not complete" );
}
return finalInput;
}
UI のフィードバックを早めたい願望と、データの信頼性を担保したい責任、この2つのバランス取りが構造化ストリーミングの本質だと考えています。partial は演出のため、最終確定は SDK 経由、と役割を分けるのが運用上もっとも壊れにくい設計でした。
partial の進捗ヒントは、いつ・いくらで取り出せるのか
寛容パーサを挟むかどうかを決めるとき、私が知りたかったのは2点でした。進捗ヒントが何番目の delta で取り出せるのか と、そのために毎回パースするコストがどれくらいか です。
この2点は API を叩かなくても手元で測れます。input_json_delta がやっているのは、確定した JSON 文字列を細切れにして順に渡すことだけなので、手元の JSON を同じ粒度で刻めばクライアント側の挙動はそのまま再現できます。
次のスクリプトは3方式を比べます。素の json.loads を毎 delta 試す方式、開いたままの括弧と文字列を閉じてから毎回パースする方式(寛容パーサが内部でやっていることに相当します)、そして深さ1の値が閉じた delta でだけパースするゲート付きの方式です。
"""input_json_delta から進捗ヒントを取り出す3方式のコストを手元で測る(API 不要)"""
import json, time
PAYLOAD = {
"primary_category" : "nature" ,
"secondary_categories" : [ "landscape" , "mountain" , "sunrise" , "forest" ,
"lake" , "autumn" , "minimal" , "gradient" ],
"color_tone" : "warm" ,
"confidence" : 0.82 ,
"needs_human_review" : False ,
"palette" : [{ "hex" : "#f2a65a" , "ratio" : 0.34 },
{ "hex" : "#2b3a67" , "ratio" : 0.28 },
{ "hex" : "#e8e1d9" , "ratio" : 0.21 }],
"notes" : "朝焼けの稜線。彩度が高めのため sunset と混同されやすい。前景の樹影が強いので forest との重複タグを許容する。" ,
"locale_hints" : { "ja" : "朝焼けの山" , "en" : "sunrise over the ridge" },
}
RAW = json.dumps( PAYLOAD , ensure_ascii = False )
DELTAS = [ RAW [i:i + 12 ] for i in range ( 0 , len ( RAW ), 12 )] # 12文字ずつに刻む
def scan (buf):
"""buf を1度だけ走査して (深さスタック, 文字列の途中か, 最後のカンマ位置) を返す"""
stack, in_str, esc, last_comma = [], False , False , - 1
for i, ch in enumerate (buf):
if esc:
esc = False
elif in_str and ch == " \\ " :
esc = True
elif ch == '"' :
in_str = not in_str
elif not in_str:
if ch in "{[" :
stack.append( "}" if ch == "{" else "]" )
elif ch in "}]" and stack:
stack.pop()
elif ch == "," and len (stack) == 1 :
last_comma = i
return stack, in_str, last_comma
def repair (buf):
"""開いたままの構造を閉じ、その時点で読める JSON 文字列にする"""
stack, in_str, last_comma = scan(buf)
if in_str: # 値の途中で切れている → 直前の完了地点まで巻き戻す
buf = buf[:last_comma] if last_comma >= 0 else buf[:buf.index( "{" ) + 1 ]
stack, _, _ = scan(buf)
buf = buf.rstrip().rstrip( ",:" ).rstrip()
return buf + "" .join( reversed (stack))
def naive (deltas):
"""毎 delta、素の json.loads を試す"""
buf, tries, ok, first = "" , 0 , 0 , None
for i, d in enumerate (deltas):
buf += d
tries += 1
try :
obj = json.loads(buf)
except ValueError :
continue
ok += 1
if first is None and obj.get( "primary_category" ):
first = i + 1
return tries, ok, first
def repair_always (deltas):
"""毎 delta、修復してからパースする(寛容パーサ相当)"""
buf, tries, ok, first = "" , 0 , 0 , None
for i, d in enumerate (deltas):
buf += d
tries += 1
try :
obj = json.loads(repair(buf))
except ValueError :
continue
ok += 1
if first is None and isinstance (obj, dict ) and obj.get( "primary_category" ):
first = i + 1
return tries, ok, first
def gated (deltas):
"""深さ1の値が閉じた delta でだけ修復+パースする"""
buf, tries, ok, first = "" , 0 , 0 , None
depth, in_str, esc, seen = 0 , False , False , 0
for i, d in enumerate (deltas):
buf += d
trigger = False
while seen < len (buf):
ch = buf[seen]
seen += 1
if esc:
esc = False
elif in_str and ch == " \\ " :
esc = True
elif ch == '"' :
in_str = not in_str
trigger |= ( not in_str and depth == 1 )
elif not in_str:
if ch in "{[" :
depth += 1
elif ch in "}]" :
depth -= 1
trigger |= (depth == 1 )
elif ch == "," and depth == 1 :
trigger = True
if not trigger:
continue
tries += 1
try :
obj = json.loads(repair(buf))
except ValueError :
continue
ok += 1
if first is None and isinstance (obj, dict ) and obj.get( "primary_category" ):
first = i + 1
return tries, ok, first
if __name__ == "__main__" :
print ( f "payload= { len ( RAW ) } chars / deltas= { len ( DELTAS ) } (12 chars each)" )
for name, fn in (( "naive json.loads" , naive),
( "repair every delta" , repair_always),
( "gated repair" , gated)):
start = time.perf_counter()
for _ in range ( 2000 ):
tries, ok, first = fn( DELTAS )
ms = (time.perf_counter() - start) / 2000 * 1000
print ( f " { name :20 } parses= { tries :3 } ok= { ok :3 } "
f "first_hint=delta { first } / { len ( DELTAS ) } { ms :.3f } ms/stream" )
分類結果の JSON(secondary_categories 8件・パレット3色・日英の locale_hints を含む472文字)を12文字ずつ40個の delta に刻み、1ストリームあたりの所要時間を2,000回の平均で取りました。手元の Linux コンテナ・CPython 3.11 での結果です。
方式 パース回数 成功 ヒントが出る delta 1ストリーム
素の json.loads 40 1 40 / 40 0.273 ms
毎 delta 修復してパース 40 35 3 / 40 2.674 ms
ゲート付き修復 15 13 3 / 40 1.023 ms
読み取れたことが2つあります。
ひとつは、素の json.loads が最後の delta まで一度も成功しないという当たり前の事実が、数字で並べると重く響くことです。「途中経過を見せたいから partial をパースする」つもりで書いたコードが、実際には最後まで何も返していません。進捗表示のつもりが完了通知になっていた、という取り違えは、画面のスピナーが回っているぶん気づきにくいところです。
もうひとつは、寛容パースを入れるかどうかより、どこに置くかで差が出ることです。毎 delta 修復する方式とゲート付きの方式とで、ヒントが出るタイミングは同じ delta 3 のまま、所要時間はおよそ2.6分の1 (2.674 ms → 1.023 ms)になりました。パース回数が40回から15回へ減った分が、そのまま効いています。
1ストリームあたり1.6ミリ秒の差は、単発で見れば無視して構わない大きさです。ただこれは delta ごとに同期で走る処理なので、分類を並行で流している最中にイベントループを細かく塞ぐ側のコストになります。私は1日1,200枚前後を数十並列で捌いているため、毎チャンク走る処理の置き場所には慎重になりました。
ヒントの早さを一切犠牲にしないのであれば、ゲートを挟まない理由はありません。私自身、測る前は「寛容パーサを入れるか、外すか」の二択で考えていました。実際の分かれ目は、入れるかどうかではなく、どの瞬間に呼ぶかの方にありました。同じところで迷っている方には、方式を決める前にこの比較スクリプトを手元で走らせてみることをお勧めします。自分のペイロードの形で数字が出てしまえば、迷う余地はほとんど残りません。
失敗ケースを観測し、再試行とエスカレーションを切り分ける
構造化レスポンスの失敗は、起き方によって取るべきアクションが違います。私は失敗を次の4種類に分類し、再試行戦略を変えています。
失敗種別 例 対応
再試行で直る 429 / 5xx / タイムアウト 指数バックオフで最大3回
プロンプトを変えると直る スキーマ違反による API 側リジェクト 同じプロンプトで2回試して失敗ならログだけ残して中止
入力が壊れている 画像が破損・テキストが空 即時失敗扱いでキューに戻さない
ドメイン違反 confidence と review の矛盾など リトライしないで needs_human_review を立てて保存
スキーマ違反は同じプロンプトを連投しても結果が変わらないことが多いです。私の壁紙アプリのログで見ると、スキーマ違反系のリトライで成功するのは初回の約 14% だけでした。3回リトライしても 18% 程度しか上積みされません。これなら 2 回で打ち切ってログを集める方が、コストも時間も無駄になりません。
import time
from anthropic import APIStatusError, APITimeoutError
def classify_with_observability (image_block: dict , max_retries: int = 3 ) -> ClassificationResult:
last_error: Exception | None = None
for attempt in range ( 1 , max_retries + 1 ):
try :
raw = classify_wallpaper_tool(image_block)
result = validate_domain(raw)
log_metric( "classification.success" , { "attempt" : attempt})
return result
except APITimeoutError as e:
last_error = e
log_metric( "classification.timeout" , { "attempt" : attempt})
time.sleep( 2 ** attempt)
except APIStatusError as e:
last_error = e
log_metric( "classification.api_status" , { "status" : e.status_code, "attempt" : attempt})
if e.status_code == 400 :
# スキーマ違反系は2回で打ち切り
if attempt >= 2 :
raise
time.sleep( 2 ** attempt)
raise RuntimeError ( f "classification failed after { max_retries } attempts: { last_error } " )
メトリクスを attempt ごとに細かく刻んでおくと、「リトライで救えている割合」がダッシュボードで一目で見えます。これがないと、リトライがコストだけ食って効いていないケースを見落とします。私は OpenTelemetry に流して Grafana のダッシュボードに乗せていますが、コストをかけずに始めるなら Cloud Run の Log-based Metrics でも十分です。
extended thinking を併用するときの注意点
複雑なスキーマ(ネストが深い・分岐条件が多い)の場合は、extended thinking を併用すると安定性が大きく上がります。私が試した範囲では、5階層を超えるネストや、oneOf を含むスキーマでは extended thinking ありの方が「最初の1回で構造的に成立する確率」が約 22 ポイント高くなりました。
ただし注意点が2つあります。第一に、tool_use と extended thinking を併用する場合、tool_choice="auto" のときに extended thinking 中で tool_use を呼ばずに完了してしまうケースがあります。これを避けるため、私は tool_choice={"type": "tool", "name": "..."} で強制したうえで extended thinking を有効にしています。第二に、レイテンシが約 1.5〜2 倍になります。インタラクティブな UI でユーザーが待つ場面には不向きで、バッチ処理や非同期処理に向いている、と割り切るのが運用しやすいです。
壁紙分類のような単純タスクには extended thinking は使っていません。一方で App Store レビューの返信テンプレートを「カテゴリ判定→トーン選定→文章生成」と多段で行うときは extended thinking ありにしています。needs_human_review=true の発生率が、extended thinking なしで月 8.4%、ありで 2.1% まで下がりました。これは単純な再試行で埋められる差ではなく、推論経路の有無で生まれる差だと感じています。
個人開発スケールで現実的な実装テンプレート
最後に、ここまでの設計を1ファイルにまとめた実装テンプレートを置いておきます。私が壁紙アプリと App Store レビュー返信パイプラインの両方で使っている形に近いものです。
"""structured_call.py — Claude API で構造化レスポンスを安定して取り出す共通レイヤ。"""
from __future__ import annotations
import time
import logging
from dataclasses import dataclass
from typing import Any, Callable, TypeVar
from anthropic import Anthropic, APIStatusError, APITimeoutError
T = TypeVar( "T" )
logger = logging.getLogger( __name__ )
client = Anthropic()
@dataclass
class StructuredResult[T]:
value: T
domain_violations: list[ str ]
attempt_count: int
def call_with_tool (
* ,
model: str ,
tool: dict ,
messages: list[ dict ],
domain_validator: Callable[[ dict ], tuple[T, list[ str ]]],
max_retries: int = 3 ,
extended_thinking: bool = False ,
) -> StructuredResult[T]:
"""tool_use で構造化レスポンスを取り、ドメイン検証を通して返す。"""
create_kwargs: dict[ str , Any] = {
"model" : model,
"max_tokens" : 1024 ,
"tools" : [tool],
"tool_choice" : { "type" : "tool" , "name" : tool[ "name" ]},
"messages" : messages,
}
if extended_thinking:
create_kwargs[ "thinking" ] = { "type" : "enabled" , "budget_tokens" : 4096 }
last_error: Exception | None = None
for attempt in range ( 1 , max_retries + 1 ):
try :
resp = client.messages.create( ** create_kwargs)
for block in resp.content:
if block.type == "tool_use" and block.name == tool[ "name" ]:
value, violations = domain_validator( dict (block.input))
return StructuredResult( value = value, domain_violations = violations, attempt_count = attempt)
raise RuntimeError ( "tool_use block not found" )
except APITimeoutError as e:
last_error = e
logger.warning( "timeout attempt= %s err= %s " , attempt, e)
time.sleep( min ( 2 ** attempt, 8 ))
except APIStatusError as e:
last_error = e
logger.warning( "api_status attempt= %s status= %s " , attempt, e.status_code)
if e.status_code == 400 and attempt >= 2 :
# スキーマ違反系は2回で打ち切り
break
time.sleep( min ( 2 ** attempt, 8 ))
raise RuntimeError ( f "structured call failed: { last_error } " )
この call_with_tool を中心に、画像分類・テキスト要約・レビュー返信テンプレート選定など複数の用途に同じ形で乗せています。共通化することで「ドメイン検証ルールを足すと全機能に効く」「リトライ戦略を1箇所で更新できる」というメリットが出ます。
12年ほど個人開発を続けてきて感じるのは、「LLM 連携は楽しい時期と運用の時期で別物」ということです。最初の数週間は単発で動かして喜んでいられますが、本番に乗せて1ヶ月もすれば、エッジケースの山が見えてきます。tool_use + JSON Schema + ドメイン検証の3層構造は、その山を「逃げない、でも消耗しない」形で整理するための道具立てだと考えています。
同じように構造化レスポンスを本番で運用している方の参考になれば幸いです。お読みいただきありがとうございました。