請求画面を開いて、指が止まりました。
前月と同じ本数の夜間バッチしか回していないのに、金額が 1.7 倍になっている。個人開発で複数サイトの定期処理を無人で走らせている以上、金額の跳ねは「どこかで設計が壊れた」という信号です。
原因は数分で見つかりました。ある補助的なタスクのモデル名が、半年前のリファクタで共通定数を参照するようになっていた。その共通定数は「対話用の既定モデル」として、後日もっと上位のモデルに書き換えられていました。誰も嘘をついていません。ただ、そのタスクが「安いモデルで十分」であるという判断が、コードのどこにも記録されていなかっただけです。
2026 年 7 月に Enterprise 向けへ入ったモデル単位のエンタイトルメントと支出アラートは、まさにこの穴を組織の規模で塞ぐものです。個人〜小規模の運用でも、同じ発想は自前で持てます。私自身が Dolice Labs の 4 サイトで使っている実装を、そのまま置いていきます。
モデル名は設定ではなく、契約として書く
model="claude-opus-4-8" という文字列は、コードから見れば設定値です。運用から見れば、そのタスクが 1 回あたりいくらまで使ってよいかという契約です。
契約を設定ファイルの散らばった位置に置くと、リファクタのたびに静かに書き換わります。私はここで、二つを分離することにしました。
タスクが宣言するもの は「必要な能力の下限」と「許容するコストの上限」。解決レイヤが決めるもの は「その条件を満たす具体的なモデル名」。
この分離があると、共通定数を書き換えても、宣言した上限を超えるモデルは選ばれません。逆に、どうしても上位モデルが必要になったタスクは、マニフェストを編集する必要があり、その差分はレビューに乗ります。
マニフェスト — 何を宣言するか
宣言は最小限にします。増やすと誰も更新しなくなります。
フィールド 意味 例
ceilingそのタスクが使ってよい最上位のモデル sonnet-5
floorこれ未満に落ちるなら実行しない下限 haiku-4-5
max_output_tokens1 回の呼び出しの出力上限 4096
daily_usdそのタスク単体の日次上限(概算) 0.80
on_ceiling_miss上限内に収まらないときの挙動 degrade / fail
floor を持たせるのは、静かな格下げが最も怖い故障だからです。エラーは気づけますが、能力の落ちたモデルが「それらしい出力」を返し続ける状態は、数週間気づかないことがあります。この点は 弱いモデルへ静かに落ちる方がエラーより怖い — Claude API のモデル降格に「下限」を設ける設計 で詳しく扱いました。
実際のマニフェストはこうなります。
# config/model_entitlements.yaml
_ladder : # 安い順。解決レイヤはこの順序だけを信じる
- haiku-4-5
- sonnet-5
- opus-4-8
_aliases :
haiku-4-5 : claude-haiku-4-5-20251001
sonnet-5 : claude-sonnet-5
opus-4-8 : claude-opus-4-8
tasks :
news_ticker_summarize :
ceiling : haiku-4-5
floor : haiku-4-5
max_output_tokens : 1024
daily_usd : 0.15
on_ceiling_miss : fail
article_draft :
ceiling : sonnet-5
floor : sonnet-5
max_output_tokens : 8192
daily_usd : 2.50
on_ceiling_miss : degrade
weekly_seo_synthesis :
ceiling : opus-4-8
floor : sonnet-5
max_output_tokens : 4096
daily_usd : 1.20
on_ceiling_miss : degrade
_ladder を明示的に持つ理由は、モデル名の文字列から価格順を推測させないためです。推測は必ずどこかで裏切られます。新モデルが出たら、この 3 行を人間が編集する。それが唯一の入り口になります。
解決レイヤ — 既定拒否で書く
ポリシーの実装で最も大事なのは、知らないタスク名は必ず落とす ことです。呼び出し側が typo をした瞬間に「マニフェストにないから制限なし」で通してしまうと、ガードは存在しないのと同じです。
# entitlements.py
from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass
import yaml
from anthropic import Anthropic
_CFG = yaml.safe_load( open ( "config/model_entitlements.yaml" , encoding = "utf-8" ))
_LADDER : list[ str ] = _CFG [ "_ladder" ]
_ALIASES : dict[ str , str ] = _CFG [ "_aliases" ]
_TASKS : dict[ str , dict ] = _CFG [ "tasks" ]
# 1M トークンあたりの単価(入力, 出力)。請求と突き合わせるための概算値
_PRICE_USD = {
"haiku-4-5" : ( 1.00 , 5.00 ),
"sonnet-5" : ( 2.00 , 10.00 ), # 2026-08-31 までの導入価格
"opus-4-8" : ( 5.00 , 25.00 ),
}
class EntitlementError ( RuntimeError ):
"""マニフェスト違反。握りつぶさず、必ず呼び出し元まで上げる。"""
@dataclass ( frozen = True )
class Decision :
task: str
tier: str # ladder 上の名前
model: str # 実際の API モデル文字列
max_output_tokens: int
degraded: bool
def _rank (tier: str ) -> int :
try :
return _LADDER .index(tier)
except ValueError as exc:
raise EntitlementError( f "未知のモデル階梯: { tier } " ) from exc
def resolve (task: str , want: str | None = None ) -> Decision:
"""タスク名と希望モデルから、実際に使ってよいモデルを決める。
want を渡さなければ ceiling をそのまま使う。want が ceiling を超えるときは
on_ceiling_miss に従い、degrade なら ceiling まで引き下げ、fail なら例外。
"""
spec = _TASKS .get(task)
if spec is None :
# 既定拒否。マニフェストに無いタスクは 1 トークンも使わせない
raise EntitlementError( f "未登録のタスク: { task } " )
ceiling, floor = spec[ "ceiling" ], spec[ "floor" ]
if _rank(floor) > _rank(ceiling):
raise EntitlementError( f " { task } : floor が ceiling を上回っています" )
target = want or ceiling
degraded = False
if _rank(target) > _rank(ceiling):
if spec.get( "on_ceiling_miss" , "fail" ) == "fail" :
raise EntitlementError(
f " { task } : { target } は上限 { ceiling } を超えています"
)
target, degraded = ceiling, True
if _rank(target) < _rank(floor):
# 下限割れは degrade では救わない。能力不足は静かに通してはいけない
raise EntitlementError( f " { task } : { target } は下限 { floor } を下回ります" )
return Decision(
task = task,
tier = target,
model = _ALIASES [target],
max_output_tokens = int (spec[ "max_output_tokens" ]),
degraded = degraded,
)
on_ceiling_miss が degrade のときだけ引き下げ、floor を割るときは常に例外にする。この非対称性が設計の核心です。コストの逸脱は静かに直してよいが、能力の逸脱は静かに直してはいけない。
呼び出しラッパ — 決定を必ず記録する
ポリシーは、守られたかどうかが後から確認できて初めて意味を持ちます。呼び出しのたびに 1 行の JSON を追記します。
_client = Anthropic( api_key = os.environ[ "ANTHROPIC_API_KEY" ])
_LEDGER = os.environ.get( "ENTITLEMENT_LEDGER" , "logs/entitlement.jsonl" )
def _estimate_usd (tier: str , usage) -> float :
pin, pout = _PRICE_USD [tier]
return (usage.input_tokens * pin + usage.output_tokens * pout) / 1_000_000
def call (task: str , messages: list[ dict ], want: str | None = None , ** kw):
d = resolve(task, want)
started = time.monotonic()
resp = _client.messages.create(
model = d.model,
max_tokens = d.max_output_tokens,
messages = messages,
** kw,
)
record = {
"ts" : time.time(),
"task" : d.task,
"declared_ceiling" : _TASKS [d.task][ "ceiling" ],
"resolved_tier" : d.tier,
# 応答が名乗ったモデル。ここが resolved と食い違ったら監査で拾う
"billed_model" : resp.model,
"degraded" : d.degraded,
"input_tokens" : resp.usage.input_tokens,
"output_tokens" : resp.usage.output_tokens,
"est_usd" : round (_estimate_usd(d.tier, resp.usage), 6 ),
"elapsed_ms" : round ((time.monotonic() - started) * 1000 ),
}
os.makedirs(os.path.dirname( _LEDGER ), exist_ok = True )
with open ( _LEDGER , "a" , encoding = "utf-8" ) as fp:
fp.write(json.dumps(record, ensure_ascii = False ) + " \n " )
return resp
billed_model を必ず残すのが要点です。リクエストで送ったモデル名と、応答が名乗るモデル名は、フォールバックや自動格下げが働けば一致しません。宣言・解決・課金の三点を突き合わせて初めて、ポリシーが効いていたと言えます。
台帳の粒度については Claude API のコスト計算が請求と合わないとき で触れた通り、キャッシュ読み書きのトークンを別立てにするとさらに請求と合わせやすくなります。
差分監査 — 翌朝に逸脱を拾う
夜間バッチの逸脱は、その夜に止めるより翌朝に検出するほうが現実的です。無人実行を止める判断は誤検知のコストが高いためです。日次の総額そのものに上限を設ける仕組みは 予算サーキットブレーカー設計 が担い、本稿のマニフェストはその内訳に責任を持ちます。
# audit_entitlements.py
import collections, datetime, json, sys
from entitlements import _TASKS , _ALIASES
WINDOW_H = 24
now = datetime.datetime.now().timestamp()
rows = [
json.loads(l)
for l in open (sys.argv[ 1 ], encoding = "utf-8" )
if l.strip()
]
rows = [r for r in rows if now - r[ "ts" ] < WINDOW_H * 3600 ]
spend = collections.defaultdict( float )
violations: list[ str ] = []
for r in rows:
spend[r[ "task" ]] += r[ "est_usd" ]
expected = _ALIASES [r[ "resolved_tier" ]]
if not r[ "billed_model" ].startswith(expected.split( "-2025" )[ 0 ]):
violations.append(
f " { r[ 'task' ] } : 解決 { expected } / 課金 { r[ 'billed_model' ] } "
)
for task, usd in sorted (spend.items(), key =lambda kv: - kv[ 1 ]):
cap = _TASKS [task][ "daily_usd" ]
pct = usd / cap * 100
flag = "OVER" if usd > cap else ( "WARN" if pct >= 80 else "ok" )
print ( f " { flag :4 } { task :28 } $ { usd :6.3f } / $ { cap :5.2f } ( { pct :5.1f } %)" )
degraded = sum ( 1 for r in rows if r[ "degraded" ])
if degraded:
print ( f " \n 引き下げ発生: { degraded } 件 / { len (rows) } 件" )
for v in violations:
print ( f "VIOLATION { v } " )
sys.exit( 1 if violations or any (spend[t] > _TASKS [t][ "daily_usd" ] for t in spend) else 0 )
私の環境で実際に出た出力です。
ok article_draft $ 1.842 / $ 2.50 ( 73.7%)
WARN weekly_seo_synthesis $ 1.014 / $ 1.20 ( 84.5%)
ok news_ticker_summarize $ 0.062 / $ 0.15 ( 41.3%)
引き下げ発生: 3 件 / 214 件
WARN が二晩続いたら、上限を上げるかタスクを分割するかを人間が決める。閾値の 80% は、私の 4 サイト運用で「翌週には超える」ことが経験的に多かった水準です。ここは各自の分散に合わせて調整するのが妥当です。
導入手順
既存のモデル名を棚卸しする。 grep -rn "claude-" --include="*.py" . で洗い出し、どのタスクがどのモデルを踏んでいるかを表にする。ここで既に驚くはずです。
各タスクの ceiling を「現状のまま」で書き起こす。 最初から締めない。可視化が先です。
ラッパ経由の呼び出しに置き換える。 直接 messages.create を呼ぶ箇所をゼロにする。CI で grep -rn "messages.create" --include="*.py" . | grep -v entitlements.py が空になることを確認します。
7 日間、監査だけ走らせる。 この期間に daily_usd の実測値が取れます。
実測の 1.3 倍を上限として daily_usd を確定する。 私は最初 1.1 倍にして、月末の記事量が増える週に無用な OVER を量産しました。
ceiling を一段ずつ下げて品質を見る。 ここで初めてコストが下がり始めます。
公式ドキュメントに書かれていない運用の勘所
degrade の判断は「出力の検証可能性」で決める。 出力にスキーマ検証や後段のゲートがあるタスクは、引き下げても壊れたときに気づけます。人間が読むだけの文章を生成するタスクは、on_ceiling_miss: fail にしてしまうほうが安全です。私は記事の下書きを degrade に、記事の最終ゲート判定を fail にしています。
マニフェストのバージョンを応答ログに残す。 上限を変更した日をまたいでコストを比較すると、原因の切り分けができなくなります。私はファイルの SHA-1 の先頭 8 文字を record に足しています。
floor を持たないタスクを作らない。 個人開発では「どんなモデルでもいい」と思えるタスクが生まれがちですが、それはたいてい出力を誰も検証していないタスクです。そういうタスクは、そもそも回す価値があるかを疑ったほうがよいと私は考えています。
上限は「1 回あたり」ではなく「1 日あたり」で持つ。 1 回あたりの上限は max_tokens で既に効いています。日次で持つと、リトライ嵐が金額として見えるようになります。実際、私が最初に OVER を踏んだのはモデルの選択ミスではなく、失敗したツール呼び出しの再試行が 40 回積み上がったケースでした。
まとめ
モデル名を定数ではなく契約として扱うと、コストの逸脱はレビュー可能な差分になります。
次の一歩として、手元のリポジトリで grep -rn "claude-" --include="*.py" . を走らせてみてください。宣言なく上位モデルを踏んでいるタスクが 1 つでも見つかったら、この設計を入れる価値があります。
長期の無人運用は、賢いモデルよりも、破られない小さな約束の積み重ねで安定していく。そう感じております。