3行まとめ
- RakuScan(自分用の日本株スクリーニングツール)は、複数プラグインの分析結果をClaudeが統合する部分を最初
claude -p(Claude Code CLIのsubprocess呼び出し)で実装していた。追加課金なしでClaude Codeサブスクの範囲内に収まる手軽さが狙いだった - だがMacBook常時起動をやめてOracle Cloud無料枠VMに移行しようとした際、「
claude -pはサブスクに紐づくため無人VM運用に向かない」という制約にぶつかり、Anthropic API(anthropicSDK)への置き換えが必要になった - 移行と同時にPrompt Caching(
cache_control: ephemeral)・用途別モデル切り替え(日次はHaiku、対話はSonnet)・API固有のエラーハンドリングを導入し、CLI任せだった部分を明示的にコントロールできるようにした
日本株を毎日自動スクリーニングして分析結果をDiscordに通知する個人ツール「RakuScan」では、複数の投資手法(プラグイン)の結果を統合分析する部分にClaudeを使っている。
最初はClaude Code CLIのclaude -pをPythonからsubprocessで呼ぶだけの実装だった。追加課金もAPIキー管理も不要で、既存のClaude Codeサブスクでそのまま動く手軽さが魅力だった。ところがある制約にぶつかり、claude -pをやめてAnthropic API(anthropic SDK)に置き換えることになった。この記事はその移行の設計記録。
元々の実装 — claude -pをsubprocessで呼ぶ
移行前のsrc/notify/claude_analyzer.pyはこういう作りだった。
"""
Claude Code CLI を使ってスクリーニング結果を統合分析する。
`claude -p` で .claude/skills/scan/SKILL.md の指示に従った統合レポートを生成する。
Anthropic API キー不要・追加課金不要(既存の Claude Code サブスクで動作)。
"""
result = subprocess.run(
["claude", "-p", skill_content, "--output-format", "text", "--tools", ""],
input=slim_json,
capture_output=True,
text=True,
timeout=_TIMEOUT,
)
.claude/skills/scan/SKILL.mdの内容をシステムプロンプト代わりに-pの引数へ直接渡し、スクリーニング結果のJSONを標準入力から流し込む。--tools ""でツール呼び出しを封じ、テキスト生成だけに専念させる構成。Discord bot側の対話コマンド(!research・!why・!compare)も、非同期版のasyncio.create_subprocess_execで同じようにclaudeコマンドをsubprocessで叩いていた。
コメントに書いてある通り、この設計の狙いは「Claude Codeサブスクの範囲内で動かして追加課金を発生させない」ことだった。個人開発のツールとしては合理的な選択。
なぜAPIに切り替えたのか — VM移行という制約
claude -p方式に手を入れることになったきっかけは、Claudeとは関係ないところにあった。RakuScanの日次バッチとDiscord botはMacBook上のlaunchd(cron相当)とtmuxセッションで動いていたが、これは裏を返せばMacBookを常時起動しておく必要があるということ。
これをOracle Cloud Free TierのARM VM(Ampere A1)へ移すことにしたのだが、ここでclaude -pが障害になった。設計ドキュメントにはこう書かれている。
| 問題 | 解決策 |
|---|---|
| MacBookを常時起動する必要がある | Oracle Cloud Free TierのARM VMへ移行 |
claude -pはClaude Codeサブスクで動作するためVM上では使えない |
Anthropic API(anthropic SDK)に切り替え |
| Discord botがtmuxセッション依存で再起動に脆弱 | systemdサービス化 |
| launchd(macOS専用)がVMで使えない | crontabで代替(JST指定) |
「追加課金を避けるためにCLIのサブスクに寄せる」という最初の判断が、「無人のVMで自律稼働させる」という次の要件と衝突した形。ここでAnthropic APIへの切り替えが必要になった。
移行後の実装 — Prompt CachingでSKILL.mdの再送コストを抑える
置き換え後の実装がこちら。
"""
Anthropic API を使ってスクリーニング結果を統合分析する。
`anthropic` SDK で直接 Messages API を呼び出す。
SKILL.md の内容をシステムプロンプトとして渡し、Prompt Caching で
2 回目以降のリクエストコストを削減する。
"""
client = anthropic.Anthropic(api_key=cfg.anthropic_api_key())
response = client.messages.create(
model=cfg.claude_model_daily(),
max_tokens=8192,
system=[
{
"type": "text",
"text": skill_content,
"cache_control": {"type": "ephemeral"},
}
],
messages=[{"role": "user", "content": slim_json}],
)
ポイントはsystemに渡すSKILL.mdの内容にcache_control: {"type": "ephemeral"}を付けたこと。claude -pではCLI側がプロンプトキャッシュの扱いをブラックボックスで管理していたが、API直呼びに変えたことで明示的にキャッシュ制御を書けるようになった。SKILL.mdの内容自体は毎日変わらないので、キャッシュのTTL内に同じシステムプロンプトでリクエストが重なれば読み取り価格で処理される。日次バッチのclaude_analyzer.analyze()呼び出しは1日1回だけなので、この構成が効くのは主にDiscord bot側の対話コマンドを短時間に連続で使った場合や、日次バッチを手動で再実行した場合。効果の大小はさておき、コストを気にせず後から効かせられる構成にしておいたこと自体に意味がある。
スクリーニング結果のJSONは、Claudeに渡す前に不要なフィールドを削って軽量化している。
_PLUGIN_FIELDS = {"plugin", "category", "signal", "score", "confidence", "summary", "target_price"}
def _slim(screener_results: list[dict]) -> list[dict]:
"""details を除いた必要最小限のフィールドだけ残してトークン数を削減する。"""
slimmed = []
for sr in screener_results:
entry = {k: v for k, v in sr.items() if k != "results"}
entry["results"] = [
{k: v for k, v in pr.items() if k in _PLUGIN_FIELDS}
for pr in sr.get("results", [])
]
slimmed.append(entry)
return slimmed
各プラグインが返すdetails(判定根拠の詳細データ)はサイズが大きく、統合分析には不要なので毎回削ぎ落とす。Prompt Cachingでシステムプロンプト側のコストを抑えつつ、ユーザーメッセージ側もペイロードを絞る、という二段構えでトークン消費を管理している。
エラーハンドリングもsubprocess.TimeoutExpiredのような汎用例外から、anthropic.APIConnectionError・anthropic.APITimeoutError・anthropic.APIStatusError・anthropic.APIErrorとAPI固有の例外に置き換わり、失敗理由の切り分けがしやすくなった。
用途別にモデルを使い分ける
移行にあわせてconfig.pyにモデル設定を切り出し、用途ごとに異なるモデルを環境変数で指定できるようにした。
| 用途 | 環境変数 | デフォルトモデル | 理由 |
|---|---|---|---|
日次自動分析(claude_analyzer.py) |
CLAUDE_MODEL_DAILY |
claude-haiku-4-5-20251001 |
構造化JSON → 日本語要約。速度・コスト優先 |
Discord Tier2対話(!research/!why/!compare) |
CLAUDE_MODEL_TIER2 |
claude-sonnet-4-6 |
ユーザー対話の推論品質を優先。レートリミットでコストを抑制 |
def claude_model_daily() -> str:
return get("CLAUDE_MODEL_DAILY", "claude-haiku-4-5-20251001") or "claude-haiku-4-5-20251001"
def claude_model_tier2() -> str:
return get("CLAUDE_MODEL_TIER2", "claude-sonnet-4-6") or "claude-sonnet-4-6"
claude -p時代はCLIが自動選択するモデルに乗るしかなかったが、API直呼びにしたことで「毎日大量に流すバッチはHaiku、たまに使う対話はSonnet」という使い分けが自分でコントロールできるようになった。Discord bot側はasyncio.to_threadで同期SDK呼び出しを非同期化し、イベントループをブロックしない形にしている。
コスト試算
移行設計時点でのコスト試算は以下の通り(あくまで移行前の見積もり)。
| 項目 | 移行前 | 移行後(推定) |
|---|---|---|
| Claude Codeサブスク | 約3,000円/月 | 0円(解約可) |
| Anthropic API(Haiku日次 + Sonnet対話) | 0円 | 約400円/月 |
| Oracle Cloud | 0円 | 0円(Always Free) |
サブスクを解約してAPIの従量課金に切り替えても、MacBookを24時間起動しておくコスト(電気代・機会損失)を考えれば十分に見合う計算だった。プロンプトキャッシュを効かせる設計にしたのも、この試算を実際のコストに近づけるための工夫になっている。
まとめ
-
claude -pはClaude Codeサブスクの範囲で動く手軽さがある一方、サブスクに紐づくためVMでの無人運用に向かないという制約があった - Anthropic API直呼びへの移行で、Prompt Caching・モデルの使い分け・API固有のエラーハンドリングが明示的にコントロールできるようになった
- 「追加課金を避ける」ための最初の設計判断が、「無人稼働させたい」という後からの要件と衝突するのはよくあるパターン。個人開発の自動化をCLIのサブスクにフルで乗せる場合は、将来的にサーバー移行や無人運用をする可能性があるかを最初に考えておくと手戻りが少ない
この記事は Zenn にも同じ内容を投稿しています。