自動投稿パイプラインの一工程を Fable 5 に差し替えた翌朝、これまで JSON を返していた処理が空文字を返していました。エラーは出ません。stop_reason も end_turn です。それでも本文が空でした。原因は単純で、私がその工程で長年使っていた「アシスタント側のプレフィル」が、思考が常時オンのモデルでは無視されていたためです。
Fable 5 は 6 月 9 日に一般提供となり、常時オンのアダプティブ思考を特徴としています。これは「考える前提で動く」ことを意味し、思考をオプトインで足していた頃のコードがいくつかの場所で静かに前提を失います。個人開発で 4 サイトの生成を一人で回している身としては、止まってエラーになるより、止まらず空を返す方がよほど厄介でした。この記事は、私自身がその移行で踏んだ三つの前提崩れと、それぞれの直し方をコードで残すものです。
何が変わったのか — 三つの前提崩れ
思考が常時オンになると、出力の「形」と「会計」が変わります。私が実際に直した順に並べると次の三つでした。
崩れた前提 従来の挙動 思考オンでの挙動
アシスタントのプレフィル 続きから書かせて出力形を固定できた プレフィルは併用できず、形を固定できない
ストリーミングの先頭ブロック 最初の content block = テキスト 最初は thinking ブロック。テキストは後続
max_tokens の意味 ほぼ本文の上限 思考+本文の合計上限。本文前に尽きうる
どれも「例外で気づく」ものではなく、「出力が薄くなる・空になる・たまに途切れる」という形で表れます。だからこそ厄介で、ログを見ても一見正常に見えてしまいます。
前提崩れ1: プレフィルが効かない
JSON を必ず通したいとき、私はアシスタント側に { を置いて続きから書かせるプレフィルを使っていました。思考オンのモデルでは、これは使えません。モデルは出力の前にまず思考ブロックを生成するため、「アシスタントの続き」という地点が成立しないからです。
無理に併用すると、API がリクエストを弾くか、プレフィルが黙って無視されます。私の工程では後者が起きて、空文字が返っていました。
直し方は、プレフィルではなく強制ツール呼び出し で出力の形を縛ることです。tool_choice で特定ツールを必須にし、その入力スキーマを出力スキーマとして使います。思考はそのまま走らせつつ、最終出力だけは構造を保証できます。
import anthropic
client = anthropic.Anthropic()
# 実際のモデルIDは公式リリースノートで確認してください
MODEL = "claude-fable-5"
# 出力させたい構造を「ツールの入力スキーマ」として定義する
EXTRACT_TOOL = {
"name" : "emit_article_meta" ,
"description" : "記事のメタデータを構造化して返す" ,
"input_schema" : {
"type" : "object" ,
"properties" : {
"title" : { "type" : "string" },
"tags" : { "type" : "array" , "items" : { "type" : "string" }},
"is_premium" : { "type" : "boolean" },
},
"required" : [ "title" , "tags" , "is_premium" ],
},
}
def extract_meta (source_text: str ) -> dict :
msg = client.messages.create(
model = MODEL ,
max_tokens = 8000 , # 思考分の余白を含める(後述)
tools = [ EXTRACT_TOOL ],
# このツールを必ず使わせる = プレフィルの代わりに形を縛る
tool_choice = { "type" : "tool" , "name" : "emit_article_meta" },
messages = [{ "role" : "user" , "content" : source_text}],
)
# 思考ブロックは飛ばし、tool_use ブロックだけを拾う
for block in msg.content:
if block.type == "tool_use" :
return block.input # 既にスキーマ準拠の dict
raise RuntimeError ( "tool_use ブロックが見つかりません" )
ポイントは tool_choice を {"type": "tool", "name": ...} にすることです。これでモデルは必ずそのツールを呼び、入力は宣言したスキーマに従います。プレフィルのように「先頭文字を固定する」のではなく「出力の構造そのものを保証する」ので、思考が前に挟まっても結果は崩れません。プレフィルに依存していた既存設計を見直す際は、JSON を通すための多層防御を扱ったClaude API プレフィルで JSON 出力を必ず通す4層防御 も、組み合わせる前提が変わる点で読み直す価値があります。
なお、思考オンのときは temperature をカスタム指定できません(既定の挙動になります)。低温で決定性を上げる運用をしていた場合は、決定性の担保を temperature ではなくツールスキーマと検証ループ 側へ移す必要があります。
前提崩れ2: ストリーミングの先頭はテキストではない
UI に逐次表示する工程では、ストリーミングの最初の content_block をテキストとして扱っていました。思考オンでは、最初に来るのは thinking ブロックです。素直に先頭を本文として描画すると、推論の断片が画面に出るか、型の取り違えで描画が止まります。
正しくは、ブロックの type で経路を分けます。thinking_delta は本文に混ぜず、text_delta だけを読者に見せます。署名付きの signature_delta も流れてきますが、これは表示対象ではありません。
def stream_answer (prompt: str ):
answer_parts = []
current_type = None
with client.messages.stream(
model = MODEL ,
max_tokens = 12000 ,
messages = [{ "role" : "user" , "content" : prompt}],
) as stream:
for event in stream:
if event.type == "content_block_start" :
# ここで初めてブロック種別が分かる(thinking か text か)
current_type = event.content_block.type
elif event.type == "content_block_delta" :
delta = event.delta
if delta.type == "text_delta" :
answer_parts.append(delta.text)
yield delta.text # 読者に見せるのはこれだけ
elif delta.type == "thinking_delta" :
pass # 思考は記録だけ。表示しない
elif delta.type == "signature_delta" :
pass # 署名は内部用
elif event.type == "content_block_stop" :
current_type = None
return "" .join(answer_parts)
期待する出力は、text_delta のみが読者に届き、思考の断片は一切表示されないことです。私はここで一度、思考デルタをそのままバッファに足してしまい、記事プレビューに推論の独り言が混ざる事故を起こしました。type で必ず振り分けることが、唯一の確実な防ぎ方です。
ツールを併用するエージェントでは、もう一段の注意があります。ツール結果を返して会話を続けるとき、直前のアシスタント応答に含まれていた thinking ブロックを、署名ごとそのまま履歴に戻す 必要があります。思考ブロックを捨てて tool_result だけ返すと、推論の連続性が切れたり、エラーになる場合があります。私は履歴を「テキストだけ残す」整形をしていたため、ここでも一度つまずきました。stop_reason の扱いを整理したClaude API の stop_reason ハンドリングガイド と合わせて、履歴の再構成ロジックは見直しておくと安全です。
前提崩れ3: max_tokens は思考と本文の取り合いになる
max_tokens を本文の上限のつもりで小さく設定していると、思考が長引いた回に本文の余白が尽きます。すると stop_reason が max_tokens になり、本文が短い・空のまま返ってきます。アダプティブ思考は問題の難しさに応じて思考量を変えるため、同じプロンプトでも回によって本文が痩せる、という再現性の低い症状になります。
対処は二つです。第一に、max_tokens に思考分の余白を明示的に積むこと。第二に、stop_reason == "max_tokens" を検知して、余白を増やして一度だけ再試行することです。
def answer_with_budget (prompt: str , want_answer_tokens: int = 2000 ) -> str :
# 思考の余白を本文想定の数倍積んでおく(難問ほど思考が伸びる)
budget = want_answer_tokens * 4
for attempt in range ( 2 ):
msg = client.messages.create(
model = MODEL ,
max_tokens = budget,
messages = [{ "role" : "user" , "content" : prompt}],
)
text = "" .join(b.text for b in msg.content if b.type == "text" )
# 思考で使い切って本文前に打ち切られた場合の検知
if msg.stop_reason == "max_tokens" and not text.strip():
budget *= 2 # 余白を増やして一度だけ再試行
continue
return text
raise RuntimeError ( "予算を増やしても本文が得られませんでした" )
usage を見ると、思考トークンも出力課金に含まれます。私が自分のパイプラインで一週間計測したところ、本文の長さは変わらないのに 1 リクエストあたりの出力トークンが体感で増えていました。思考は「無料の前処理」ではなく、コストとして会計する対象です。常時オンの思考をコスト面からどう設計するかは、Claude API 拡張思考の本番コスト分析 に踏み込んだ整理があります。長文を一括生成する工程との兼ね合いは、Fable 5 の 128k 出力で長文を一括生成した記録 も判断材料になります。
なぜプレフィルを捨て、ツールへ寄せたのか
「プレフィルを捨てる」のは惜しく感じました。短く確実で、長年頼ってきた手だからです。それでもツールへ寄せたのは、思考オンのモデルでは出力の前に必ず別ブロックが挟まる 以上、「続きを書かせる」という発想そのものが成立しないと判断したからです。
形を保証したいなら、続きの文字ではなく構造を縛る。これは遠回りに見えて、思考の有無に左右されない分だけ頑健でした。移行で得た一番大きな学びは、テクニックではなく、この「保証する対象を一段上げる」という考え方の方だったように思います。
移行前に確認しておくと安全な三点
思考が常時オンのモデルへ切り替える工程があるなら、デプロイ前に次の三つを実機で確かめておくと、私が踏んだ静かな失敗は避けられます。
確認は次の順で進めることを推奨します。
プレフィルや temperature 指定に依存した工程がないかを洗い出します。あるなら、強制ツール呼び出しと検証ループへ置き換えます。私は短く確実なプレフィルに未練がありましたが、思考オンでは構造を縛る方が結果的に安定しました。
ストリーミング処理が content_block の type で経路を分けているかを確認します。先頭をテキスト前提で読んでいたら必ず直し、thinking_delta を読者に見せていないかを実際の出力で確かめます。
max_tokens に思考分の余白があるか、stop_reason == "max_tokens" を検知して一度だけ予算を増やす再試行が入っているかを点検します。アダプティブ思考は回ごとに伸び縮みするため、余白は本文想定の数倍を起点にするのが私の場合は安全でした。
まず手元の一工程だけを思考オンのモデルに差し替え、出力が空・短い・途切れの三症状を出さないことを確認してから、本番のパイプラインに広げてみてください。同じ移行に取りかかる方の、静かな失敗を一つ減らせたら嬉しいです。