夜間に走らせている定期処理のログを翌朝に見返したら、ある1回だけ、いつものエラーとも成果物とも違う結末が記録されていました。例外は飛んでいません。HTTP は 200 です。それでも本文には、依頼した内容そのものではなく「その要求にはお応えできません」という丁寧な断りが返っていました。パイプラインはそれを「応答が返ってきた=成功」と判断し、断り文をそのまま成果物として書き出していたのです。
エラーでもなく、望んだ完了でもない。この「安全上の理由による応答拒否」は、無人運用のパイプラインが最も取りこぼしやすい第三の結末です。人が横にいれば一目で気づくものが、成否を二分岐でしか見ていない自動処理では、静かにすり抜けていきます。
2026年7月に Claude Fable 5 が全世界で提供を再開し、その際に最上位モデルの悪用を抑えるための新しいサイバーセキュリティ分類器が追加されました。高性能なモデルを個人が日常的に使う時代になったということは、正当な作業がまれに安全側の判定に触れる場面も、これまでより現実的になったということです。だからこそ、拒否という結末を「例外」ではなく「起こりうる正常な分岐の一つ」として設計に織り込んでおく価値があります。
結末は二分岐では足りない
多くのパイプラインは、1回の呼び出しの結末を「成功」か「失敗」の二択で扱います。ところが実際には、少なくとも四つの状態を区別する必要があります。
| 結末 | 典型的なサイン | 取るべき対応 |
|---|---|---|
| 正常完了 | HTTP 200 / stop_reason が end_turn / 期待した構造の本文 | 成果物として採用 |
| インフラ起因の失敗 | HTTP 429・529・5xx / タイムアウト | 指数バックオフで再試行 |
| 安全上の拒否 | HTTP 200 だが本文が依頼と無関係の断り / stop_reason が refusal | 再試行せず、人間のレビューキューへ退避 |
| 劣化・空応答 | HTTP 200 だが本文が空・途中で切れている | 完了条件アサーションで赤くする |
この四分割のうち、インフラ失敗と劣化応答については、Claude API の stop_reason を読み解く — 応答の途切れを「終了」と誤認しないための設計 や 「成功」と記録されたのに成果がゼロだった — Cowork スケジュールタスクの無音失敗を終了前アサーションで止める で扱いました。ここで一段深く踏み込みたいのは、三つ目の「安全上の拒否」です。
拒否とインフラエラーを混同しない
拒否がやっかいなのは、HTTP レイヤーでは成功に見えることです。ステータスコードだけを見ている限り、拒否は「正常な200」に紛れます。判別には、ステータスと stop_reason、そして本文の三つを合わせて見る必要があります。
from dataclasses import dataclass
from enum import Enum
class Outcome(Enum):
OK = "ok" # 採用してよい正常完了
INFRA_ERROR = "infra_error" # 再試行してよい一時的失敗
DECLINED = "declined" # 安全上の理由で断られた
DEGRADED = "degraded" # 空・途中切れ
@dataclass
class RunResult:
http_status: int | None
stop_reason: str | None
text: str
def classify(result: RunResult) -> Outcome:
# 1. まずインフラ起因かどうか(ネットワーク層で失敗)
if result.http_status is None or result.http_status >= 429:
return Outcome.INFRA_ERROR
# 2. モデルが明示的に拒否を返した場合
if result.stop_reason == "refusal":
return Outcome.DECLINED
# 3. 200 だが本文が空・極端に短い=劣化
if not result.text or len(result.text.strip()) < 40:
return Outcome.DEGRADED
# 4. それ以外は正常完了
return Outcome.OKここで大切なのは、refusal を最優先で拾い、インフラエラーと決して同じ袋に入れないことです。両者を混ぜると、本来は人の判断が要る拒否を、機械が延々と再試行してしまいます。再試行は一時的な不調に効く手当てであって、拒否には効きません。同じ依頼を同じ条件で投げ直しても、返ってくるのは同じ断りです。
stop_reason が refusal として明示されない断り方(本文中で丁寧に辞退するケース)も現実には起こります。無人運用で確実に拾いたい場合は、stop_reason に加えて、成果物が満たすべき構造——たとえば「見出しを2つ以上含む」「指定した JSON キーが揃っている」——を完了条件として検証し、それを満たさない200を DEGRADED として一度止めるのが安全側の設計です。断りかどうかの意味解釈まで機械で断定しようとせず、「期待した形をしていない」という観測可能な事実で止める、という割り切りです。
やってはいけない: 断られた依頼を自動で言い換えて押し通す
ここが本稿で最も伝えたい一点です。拒否を受け取ったとき、プロンプトを機械的に言い換えて再送し、通るまで繰り返す——という手当てを組み込みたくなる場面があります。これは避けてください。
理由は二つあります。ひとつは、それが正当な理由のある拒否だった場合、自動化で回避しようとすること自体が、安全設計の意図に反するからです。断られるべきものは、断られたままにしておくのが筋です。もうひとつは、仮に正当な作業への誤判定だったとしても、言い換えの当否を判断できるのは、その作業の文脈を知っている人間だけだからです。無人のループに「通す」役目を負わせると、正当性の確認という一番大事な工程が抜け落ちます。
正当な作業がまれに判定に触れることはあります。その場合の正しい対処は、機械に言い換えさせることではなく、人が作業の目的と背景を具体的に添えて、あらためて依頼し直すことです。判断を人に戻す。無人パイプラインの役目は、押し通すことではなく、止めて渡すことです。
拒否はレビューキューへ退避させる
では止めた後にどう渡すか。拒否を受け取ったら、その回の入力と出力を丸ごと、後から人が見返せる場所へ退避させます。成果物として採用せず、成功としても数えず、しかし失われもしない——この「保留」の置き場所がレビューキューです。
import json
import hashlib
from datetime import datetime, timezone
from pathlib import Path
REVIEW_DIR = Path("review_queue")
def enqueue_for_review(task_id: str, prompt: str, result: RunResult) -> Path:
REVIEW_DIR.mkdir(exist_ok=True)
# 同一入力の拒否を1件にまとめる冪等キー
key = hashlib.sha256(f"{task_id}\n{prompt}".encode()).hexdigest()[:16]
path = REVIEW_DIR / f"{key}.json"
record = {
"task_id": task_id,
"queued_at": datetime.now(timezone.utc).isoformat(),
"outcome": "declined",
"prompt": prompt,
"response_text": result.text,
"stop_reason": result.stop_reason,
}
path.write_text(json.dumps(record, ensure_ascii=False, indent=2))
return path
def handle(task_id: str, prompt: str, result: RunResult) -> str:
outcome = classify(result)
if outcome is Outcome.DECLINED:
enqueue_for_review(task_id, prompt, result)
return "held" # 成功でも失敗でもなく「保留」
if outcome is Outcome.INFRA_ERROR:
raise TransientError(result.http_status) # 再試行に回す
if outcome is Outcome.DEGRADED:
raise DefinitionOfDoneError(task_id) # 完了条件で赤く
return "ok"
class TransientError(Exception): ...
class DefinitionOfDoneError(Exception): ...冪等キーで同じ入力の拒否を1件にまとめておくと、毎晩同じ依頼で断られてもキューが同じ内容で膨れません。週に一度キューをまとめて見返せば、断られた依頼にどんな傾向があるかも見えてきます。特定のトピックばかりが触れているなら、その領域は無人ではなく手元で扱う、という運用判断につなげられます。
この設計のもう一つの利点は、「保留」という第三の返り値を持つことで、パイプライン全体のログが正直になることです。拒否を成功に数えなければ成功率は実態を映し、拒否を失敗に数えなければ再試行やアラートを無駄に焚かずに済みます。長時間の無人運用に上限や退避先を用意しておく発想は、pause_turn の継続ループに上限を入れる — 無人で長時間サーバーツールを安全に回す とも地続きです。
まとめと次の一歩
安全上の拒否は、無人パイプラインにとって障害ではなく、想定しておくべき正常な分岐のひとつです。二分岐の成否判定に「保留」という三つ目を足し、拒否はインフラエラーと分けて判別し、自動で言い換えて押し通さず、レビューキューへ渡して人の判断に戻す——この四点を仕込んでおくだけで、静かにすり抜けていた結末が、翌朝きちんと目に入るようになります。
まずは手元の一番大切な定期処理を一つだけ選び、classify 相当の判別を1回の呼び出しに挟んでみてください。断りを一度でも「保留」として拾えたなら、その仕組みは他のタスクへ広げる価値があります。私自身、Dolice として複数サイトを個人開発で無人運用するなかで、この保留の置き場所を用意してから、夜間の処理を安心して任せられるようになりました。お読みいただきありがとうございました。