個人開発で Dolice Labs の4サイトぶんの記事生成を毎晩まとめて回しているのですが、ある朝だけ「検索したはずの最新情報が本文に入っていない」記事が混じっていました。エラーログには何も出ていません。レスポンスを保存しておいたものを開くと、stop_reason が pause_turn で返っていて、私の生成ループがそこで満足して止まっていたのです。web_search が一度の往復で終わらず「区切り」を返してきたのに、ループ側がそれを完了だと読んでしまっていました。
pause_turn は普段あまり目にしません。短いプロンプトでは出ないからです。けれど web_search・web_fetch・コード実行のような長時間サーバーツールを挟むと、ある日突然返ってきます。しかも例外ではなく正常応答として返るので、握りつぶしている限り気づけません。無人運用ではここが静かな取りこぼしの温床になります。
pause_turn は「エラー」でも「完了」でもない、第三の状態
stop_reason を「応答が終わった理由」だと一括りにすると、pause_turn で必ず足をすくわれます。値の性格を「終わった系」と「まだ続く系」に分けて捉えるのが出発点です。
| stop_reason | 状態 | 必要な後続処理 |
| end_turn | 正常終了 | なし。出力を確定してよい |
| max_tokens | 途中で打ち切り | 継続するか、不完全と記録する判断が要る |
| tool_use | 継続要求(クライアント側) | tool_result を積んで再リクエスト |
| pause_turn | 継続要求(サーバー側) | 応答をそのまま積んで再リクエスト |
| refusal | 安全上の拒否 | 再試行せず設計で受け止める |
tool_use と pause_turn は似ていますが、積み直すものが違います。tool_use は自分でツールを実行して tool_result という新しいユーザーメッセージを足す必要があります。一方 pause_turn は、サーバー側ツール(検索や実行)の途中経過がすでに assistant の応答に入っているので、追加入力なしで応答ブロックをそのまま積み直すだけで続きが走ります。基本的な分岐そのものはstop_reason を取りこぼさないための実装パターンで整理していますので、まだ max_tokens すら見ていないという段階の方はそちらから入るのが早いです。本稿はその先、「無人で長時間ツールを回すときに pause_turn をどう設計するか」に絞ります。
まず再現する — どのツールで pause_turn が出るか
闇雲に対策する前に、自分のコードで pause_turn を一度出しておくと判断が速くなります。サーバーサイドツールを有効にして、複数回の検索が要りそうな問いを投げます。
import anthropic
client = anthropic.Anthropic(api_key="YOUR_API_KEY")
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
tools=[{"type": "web_search_20260318", "name": "web_search"}],
messages=[{
"role": "user",
"content": "2026年6月のClaude Developer Platformの更新を3件、出典つきで要約して",
}],
)
print(resp.stop_reason) # pause_turn になることがある
print([b.type for b in resp.content])
# 例: ['text', 'server_tool_use', 'web_search_tool_result', 'text']
ここで覚えておきたいのは、pause_turn のときも content には途中までの text・server_tool_use・web_search_tool_result といったブロックがすでに入っているという点です。つまり「空の応答」ではありません。だからこそ握りつぶすと、中途半端な本文がそのまま採用されてしまいます。私が最初にやらかしたのも、まさにこの「途中の text を完成品だと思い込む」失敗でした。
4種類の stop_reason を1つのループで分岐する
無人運用では、pause_turn だけを特別扱いするより、継続を要求する3値(tool_use・pause_turn・max_tokens)を1つのループで一貫して扱う方が壊れにくくなります。骨格はこうです。
def run_turn(client, model, messages, tools, *, max_tokens=4096):
while True:
resp = client.messages.create(
model=model, max_tokens=max_tokens, tools=tools, messages=messages,
)
reason = resp.stop_reason
if reason == "pause_turn":
# サーバー側ツールの途中。応答をそのまま積んで継続する
messages.append({"role": "assistant", "content": resp.content})
continue
if reason == "tool_use":
# クライアント側ツール。実行結果を tool_result として積む
messages.append({"role": "assistant", "content": resp.content})
tool_results = execute_client_tools(resp.content) # 自前実装
messages.append({"role": "user", "content": tool_results})
continue
if reason == "max_tokens":
# 続きを足すか、不完全として記録するか。ここはユースケース依存
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": "直前の出力の続きを書いて"})
continue
# end_turn / refusal はループを抜ける
return resp
pause_turn と tool_use で messages.append の形が違うことが要です。pause_turn は assistant 応答を積むだけ、tool_use はそのうえで user ロールの tool_result を足す。ここを取り違えると、片方は「ツール結果が二重になる」、もう片方は「ツールが永遠に呼ばれ続ける」状態になります。
継続時に壊しやすいのは「積み直し方」
pause_turn の継続で一番事故が多いのは、content を加工してから積み直してしまうことです。たとえば「text ブロックだけ抜き出して保存しよう」と先回りすると、server_tool_use と web_search_tool_result のペアが崩れ、次のリクエストで 400 が返ります。継続中は resp.content を一切いじらず丸ごと積むのが鉄則です。整形や抽出は、最終応答(end_turn)を得てからまとめてやります。
拡張思考を併用している場合はもう一段の注意が要ります。thinking ブロックには signature が付いており、これも改変せずに積み直す必要があります。署名を落としたり中身を編集したりすると、思考の連続性が壊れて継続が拒否されます。「pause_turn の最中は応答を読むだけ・足すだけ・触らない」と決めておくと、この種の不具合はまとめて防げます。サーバーツールそのものの設計はweb_fetch でライブ情報を扱うときの構成に寄せてあります。
無人運用には上限と時間予算を必ず入れる
ここからが、対面で使うコードと無人バッチで分かれる部分です。while True: は人が見ているなら許容できますが、夜間バッチに置くと、継続が想定以上に伸びたときに気づけません。サーバーツールは1回の「ターン」の中で何度も検索・実行を繰り返せるため、複雑な問いでは pause_turn が何度も連続することがあります。上限を入れずに回すと、止まらないターンが課金を静かに積み上げます。本番運用では、この暴走を時間予算で先回りして回避しておくのが効きます。
私は継続回数と経過時間の二段ガードを必ず入れています。
import time
def run_turn_guarded(client, model, messages, tools, *,
max_tokens=4096, max_continuations=12, wall_budget_s=180):
start = time.monotonic()
continuations = 0
while True:
resp = client.messages.create(
model=model, max_tokens=max_tokens, tools=tools, messages=messages,
)
reason = resp.stop_reason
if reason in ("pause_turn", "tool_use", "max_tokens"):
continuations += 1
elapsed = time.monotonic() - start
if continuations > max_continuations or elapsed > wall_budget_s:
# 暴走の疑い。打ち切って「不完全」として上位に返す
return {"status": "aborted", "reason": reason,
"continuations": continuations, "elapsed": elapsed,
"partial": resp}
messages.append({"role": "assistant", "content": resp.content})
if reason == "tool_use":
messages.append({"role": "user",
"content": execute_client_tools(resp.content)})
elif reason == "max_tokens":
messages.append({"role": "user", "content": "直前の続きを書いて"})
continue
return {"status": "ok", "reason": reason, "result": resp}
数字はワークロード次第です。私の記事生成では、検索を2〜3回挟む程度なら継続は多くて5〜6回に収まるので、max_continuations=12 は「明らかにおかしい」を捕まえる安全弁として置いています。打ち切ったときに例外を投げず status: aborted で返しているのは、無人運用では「1本が不完全でも残りは続けたい」からです。1本の暴走で夜間バッチ全体を巻き込まないことを優先しています。
セグメントを跨ぐ usage をどう数えるか
pause_turn で継続すると messages.create が複数回走るので、コストはセグメントの合算になります。最後のレスポンスの usage だけ見ると、途中の検索や中間生成ぶんを丸ごと見落とします。検索を挟むターンは、挟まないターンの2〜3倍のトークンを使うこともあり、ここを取りこぼすと差が積み上がります。無人運用ではここを取り違えると、見積もりと請求が静かにずれていきます。
ループの各反復で usage を足し込んでおくことを強く推奨します。
def accumulate_usage(total, usage):
total["input_tokens"] += usage.input_tokens
total["output_tokens"] += usage.output_tokens
# サーバーツールの実行回数も別枠で課金されるため記録する
su = getattr(usage, "server_tool_use", None)
if su and getattr(su, "web_search_requests", None):
total["web_search_requests"] += su.web_search_requests
return total
そして、打ち切ったかどうかに関わらず、1ターンの結末を必ず1行ログに残します。pause_turn を end_turn と取り違える事故は、観測してさえいれば翌朝すぐ気づけます。私のログには最低限「最終 stop_reason」「継続回数」「合算 input/output」「検索回数」を入れています。継続回数がいつもより跳ねている記事を朝に拾えるだけで、品質の崩れを早く止められます。サーバーが詰まって 529 が混ざる時間帯の切り分けは529 過負荷に耐える本番アプリの設計と合わせて考えると整理しやすいです。
ストリーミングでは pause_turn が最後に届く
UI に「考え中」を出しながらストリーミングしている場合、stop_reason はストリームの最後の message_delta で届きます。pause_turn も例外ではありません。ここで表示側の状態管理を「終わり = end_turn のみ」にしておくと、pause_turn を継続せずに画面が固まったように見える事故が起きます。
with client.messages.stream(model=model, max_tokens=4096,
tools=tools, messages=messages) as stream:
for event in stream:
if event.type == "text":
render(event.text) # 画面には流す
final = stream.get_final_message()
# 「終わった表示」は end_turn のときだけにする
if final.stop_reason == "pause_turn":
messages.append({"role": "assistant", "content": final.content})
# 再度ストリームを開いて継続(上のガードと同じ上限を共有する)
ストリーミングでも非ストリーミングでも、継続判定のロジックは1か所に集約しておくのがおすすめです。表示と継続を別々に書くと、片方だけ pause_turn を取りこぼす、という非対称が必ず生まれます。イベント順序の細部はストリーミングが途中で切れる原因の切り分けに寄せてあります。
今日入れるなら、まず「pause_turn を数える」一行から
pause_turn の継続ループ・ガード・usage 合算を一度に入れようとすると腰が重くなります。最初の一歩としておすすめなのは、いまのコードに「最終 stop_reason をログに残す」一行を足すことです。これだけで、無人バッチのどのターンが pause_turn で静かに切れているかが、翌朝の確認で見えるようになります。実態が見えてから、継続ループと上限を順に足していけば十分間に合います。私自身、ガードを足したのは「数える」を入れて2日後でした。まず可視化、それから設計、の順がいちばん遠回りに見えて近道だと感じています。