📝 この記事は Zenn(正本) からの転載です。図表・最新版はオリジナルをご覧ください。
私はコードを書かない。プロンプトだけでシステムを作る
最初に立場をはっきりさせておく。私はコードを自分では書かない。設計と要求をプロンプトにして、Claude Code に実装させる。それでもシステムは組める。ただし、ひとつだけサボれないものがある——「観測」だ。この記事は、それを痛い目にあって学んだ話だ。
今回作ったのは、毎日ブログ記事を自動生成して、Telegram に届く承認ボタンを押すだけで、複数媒体へ自動公開される無人パイプラインだった。生成にはローカルLLM(Qwen)を使い、電気代以外のコストをかけずに回すのが狙いだった。環境は macOS(Apple Silicon)で、launchd・Swift 製メニューバーアプリ・macOS の ps といった OS 依存の話が出てくるので、そのつもりで読んでほしい。
設計は気持ちよく決まった。動かした。そして動かした瞬間、まったく無関係なはずの別タスク——動画・画像・音楽の生成——が全部止まった。順番待ちのまま、いつまでも動かない。GPU使用率だけが 50〜65% に張り付いていた。
「AIが回りっぱなしだ。VRAMが壊れたのかもしれない」——これが私の最初の思い込みで、そして完全に間違っていた。真犯人はAIですらなかった。順を追って書く。
作ったもの:無人ブログ工場のアーキテクチャ
まず、何を作ったのかを説明する。
流れはこうだ。
- launchd(定時) — macOS の常駐ジョブ機構で、決まった時刻にトリガーを引く。
-
spool(ジョブ投函) — トリガーは「今日はこれを書け」というジョブファイルをスプールディレクトリ(
~/.local/share/.../spoolのような場所)に投函するだけ。処理そのものはしない。 - 常駐dispatcher — スプールを監視していて、ジョブが来たら拾い上げて実行する。
-
headless
claude -p— dispatcher がclaude -p(プロンプトを渡して非対話で走らせるモード)を起動し、ローカルLLM(Qwen)に記事を執筆させる。この headless セッションは、実体としてはcaffeinate(スリープ抑止)配下の自走プロセスとして動く。 - Telegram承認 — 書き上がった原稿は Telegram に飛んでくる。私は承認ボタンを押すだけ。
- 公開 — 承認されたら複数媒体へ自動で流し込まれる。
ポイントは「人間のやることは承認ボタンだけ」という点だ。生成も、下書きの保存も、公開も、全部無人。コードを書かない私でも、この構成図をプロンプトに書き下して Claude Code に実装させれば、動くものが手に入る。実装言語の内訳はというと、dispatcher と後述の「番人」は Shell/Bash、状態表示のメニューバー常駐アプリは Swift、記事生成は headless の claude -p。参考実装を探すときの当たりを付けやすいよう一応書いておく。
——手に入る、はずだった。
事件:GPUが張り付き、動画生成が永久に順番待ち
問題はブログ工場そのものではなかった。ブログは書けていた。おかしくなったのは「隣の部屋」だ。
私の環境では、GPUを使う生成タスク(動画・画像・音楽)は、1台のGPUを奪い合わないよう直列に調停している。ところが、その調停の待ち行列が一向に前に進まない。動画生成が永久に「順番待ち」から抜けられない。
Activity Monitor 相当のツールでGPUを見ると、使用率が 常時 50〜65% に張り付いている。何かが重い処理を回し続けているように見える。ブログ工場を動かし始めてから、この症状が出るようになった。
私はこう結論づけた——「ブログ用のローカルLLMが回りっぱなしで、GPUを食い続けている。もしかしたらVRAMが壊れて解放されなくなっているのかもしれない」。もっともらしい仮説だった。もっともらしいだけで、間違いだったが。
山場:SIGSTOP二分法でGPU張り付きの犯人を実測特定する
思い込みで対処を始めると、たいてい傷を広げる。だから私は(Claude Codeに指示して)犯人を実測で特定することにした。使ったのは「SIGSTOP二分法」だ。
考え方は単純。kill -STOP <pid> を送るとプロセスは殺されず一時停止する(kill -CONT で再開できる)。だから、容疑者のプロセスを1つずつ一時停止させて、そのたびにGPU使用率がどう動くかを観測すればいい。使用率が落ちたら、そいつが犯人だ。二分探索の要領で容疑者を絞る。
実測はこう進んだ。
- ベースライン:何もいじらない状態で GPU使用率 約43%。
- ブラウザを止める → 変わらず。
- ターミナルを止める → 変わらず。
- 統計表示アプリを止める → 変わらず。ここまで 43〜52% をうろつくだけで、どれもシロ。
- GUIのElectronアプリ(Claude.app などのデスクトップアプリ)を止めた瞬間 → 約2% に急落。
- 念のため
kill -9で完全終了を確認 → 0%。
犯人はブログのAIでも、動画生成でもなかった。Electron製のGUIアプリが、画面を高フレームレートで描き続けていた——それが「アイドルなのにGPU 40〜65%」の主因だった。
証拠もあった。FPSを見ると 121〜174 に達していた。人間の目には静止して見える画面の裏で、GPUは毎秒100回以上も再描画を叩いていたのだ。しかもその負荷の内訳は Render/Tiler、つまり 画面描画 のパイプラインだった。
前言撤回:GPU「使用率」とVRAMは、別物だった
ここで私の思い込みが根っこから覆る。
私は「GPU使用率が高い=AIが計算している=生成できない」と信じていた。だが実態はこうだった。
- GPU「使用率」に見えていた数字は、その大半が Render/Tiler=画面描画 の負荷。GUIアプリの高FPS描画で膨らんでいた。
- 一方、画像や動画の生成が使うのは VRAM(生成用のメモリ)。こちらを見ると、常に 40〜52GB が空き の状態だった。
- Qwenの推論が終わったあと、
ollama psを叩くとモデルは空っぽ。Ollama はアイドルが一定時間続くとモデルを自動アンロードする仕様で(アンロードまでの時間は環境変数OLLAMA_KEEP_ALIVEで決まり、既定は5m)、推論が終われば GPU計算はゼロ、VRAMは約50GB空き。それでも「使用率」だけが 50〜65% に張り付いて見えていた。
つまり「GPUが動いている=生成できない」は誤りだった。GPUは"描画"で忙しかっただけで、"計算"用のメモリはガラ空きだった。私はダッシュボードの一つの数字を、まったく別の資源の枯渇と読み違えていた。
学び:GPU使用率(Render/Tiler=描画)とVRAM(生成用メモリ)は別の資源だ。一つのメーターで両方を判断してはいけない。生成が詰まっているように見えたら、使用率ではなく「VRAMに空きがあるか」「モデルが実際にロードされているか(ollama ps)」を見る。
二次災害:ollama ps が129個ハングして、メモリを圧迫した
真犯人が描画だと分かってスッキリ……とはいかなかった。この一件には、体感を「VRAM故障だ」と誤認させた本当の理由がもう一つあった。
私はメニューバーに自作の常駐アプリ(Swift製)を置いていて、これがGPUの状態を表示するために、状態更新のたびに ollama ps をシェル実行していた。平時は問題ない。
ところが、GPU調停のためにローカルLLMを一時凍結(kill -STOP)している最中に、この ollama ps が走ると事故る。凍結されたプロセスへの問い合わせが返ってこず、ollama ps が 無限ハング するのだ。
そして致命的だったのが、シェル実行ヘルパーに タイムアウトが無かった こと。子プロセスの終了をひたすら待つ実装だったので、ハングした ollama ps を待ち続ける。状態更新は定期的に走るから、ハングした ollama ps とそのラッパーがどんどん積み上がる。実測で 129個 が滞留し、メモリを圧迫していた。「システムが重い、壊れている」という体感の正体は、これだった。
修正は2点。
- シェル実行ヘルパーに 12秒のタイムアウト を入れ、期限が来たら 子プロセスごとkill する。
- そもそも凍結中は
ollama psを 呼ばずにスキップ する。
修正版をビルドし直して設置し、旧プロセスを止めて起動し直したあと、ローカルLLMを凍結した状態で60秒間観測した。ハングした ollama ps の滞留は 129個 → 0個。積み上がりは完全に止まった。
学び:外部コマンドをシェル実行するなら、必ずタイムアウトを持たせ、期限切れでは子プロセスまで含めて確実に殺す。「終了を待つだけ」の実装は、相手がハングした瞬間にプロセスを無限に積み上げる時限爆弾になる。
もう一つの落とし穴:定時ジョブに単一実行ガードが無かった
ブログ工場そのものにも欠陥があった。dispatcher に 単一実行ガード が無かったのだ。
定時トリガーは、前の便が終わったかどうかを確認せず、時間が来たら次の便を dispatch していた。ブログ生成は時間がかかる。結果、ブログ便が3本同時に生存 する事態になった。各便が 5〜11時間 も張り付いていた。
質が悪いのは、これが正のフィードバックを生むことだ。モデルサーバは1台しかない。そこに3本の便が同時にぶら下がると、各便のスループットが 1/N に落ちる。遅くなるから終わらない。終わらないから次便が来ても前便がまだ生きている。積み上がるほど遅くなる——「終わらない」が自己増殖する。
対策はシンプルだった。dispatch の前に既存の便がいないかを確認する。
# ブログ便(caffeinate下のclaudeセッション)が1本でも生きていたら、新規dispatchしない
if pgrep -f 'caffeinate -i claude' > /dev/null; then
echo "既に稼働中の便あり。今回はスキップ。"
exit 0
fi
pgrep -f 'caffeinate -i claude' が1本でもヒットしたら新規 dispatch を見送る。これで便が直列化され、1台のモデルサーバを奪い合わなくなった。ちなみに、すでに3本が絡み合って張り付いていた分は、次節の「番人」を手動で叩いて3本まとめて正規終了させ、GPUとモデルが即座に解放されることも実測で確認した。
学び:定時ジョブ(cron/launchd)は「前回がまだ走っているか」を気にしない。処理が起動間隔より長引きうるなら、単一実行ガード(フロックや pgrep チェック)を必ず入れて直列化する。
番人が「忙しく空回りする便」を健全と誤判定していた
保険として、ハングした便を検知して殺す常駐スクリプト——いわば「番人」——も置いていた。ところがこの番人にも穴があった。
番人が見ていたのは「transcript(対話ログ)が15分間更新されていないか」という指標だけだった。無反応が続けば死んでいるとみなして殺す、という発想だ。理屈は分かる。
だが、前節の「3本同時に張り付いた便」は、無反応ではなかった。数分ごとにわずかに進捗する。トークンをちびちび吐きながら、実際にはほとんど前に進んでいない。番人から見ると「15分以内に更新がある=健全」に映る。結果、番人はこの空回りする便を 約10時間も見逃していた。
対策は、判定軸をもう一本足すことだった。プロセスの実年齢に上限(例:50分)を設け、それを超えた便は進捗の有無にかかわらず問答無用でkillする。idle判定をすり抜ける便も、実年齢では逃げられない。
実装で一つ罠があった。macOS の ps は経過秒数を直接くれる etimes に対応していない。そこで etime(dd-hh:mm:ss 形式の文字列)を取得して、自前で分に変換する処理を書いた。
学び:ハング検知を「無反応時間」だけで組むと、"忙しそうに空回りする"タスクを取りこぼす。無反応時間と実年齢の両輪で見る。そしてプラットフォーム依存(macOSのpsはetimes非対応)は、実際に叩いて確かめてから実装する。
ローカルLLMをheadless claudeで回す最大の罠
ここが、ローカルLLM運用勢にいちばん刺さる話だと思う。
私はブログ生成をローカルの Qwen で回したかった。claude は本来クラウドのAPIを叩く。これをローカルの Ollama に向けたい。素直に思いつくのは「モデル名でローカルを指定する」だ。
ところが——claude --model ollama/... も、設定ファイルの model 指定も、設定オーバーライドも、黙ってクラウドにフォールバック した。エラーも出さない。応答は普通に返る。だから一見、ローカルで動いているように見える。ここが罠だ。
効いたのは、モデル指定ではなく 接続先そのものを環境変数で差し替える ことだった。
ANTHROPIC_BASE_URL=http://127.0.0.1:11434 \
ANTHROPIC_AUTH_TOKEN=ollama-local \
claude --model qwen32b-long
ANTHROPIC_BASE_URL を Ollama のローカルエンドポイントに向ける。これで初めて /v1/messages が HTTP 200、約8.5秒 で正しい応答(思考ブロック+本文)を返した。
そして何より大事なのは検証方法だ。「応答が返る」ことは、ローカルで動いている証拠には ならない。クラウドにフォールバックしても応答は返るのだから。裏を取るには、Ollama側のサーバログで POST /v1/messages が増えているか を必ず確認する。リクエストがローカルサーバに届いていることを、送り先のログで実証する。これしか信用できる証拠はない。
学び:ローカルLLM切り替えは「モデル名」ではなく「接続先URL(ANTHROPIC_BASE_URL)」で行う。そして「応答が返った」で満足せず、サーバ側ログのPOST増加で必ず裏取りする。
付録:ローカルAI環境の構築で踏んだ2つの地雷
本筋ではないが、同じ穴に落ちる人がいそうなので短く残す。
付録1:Node.js 26 では isolated-vm がコンパイルできない
この自走をターミナルレスにするため、ワークフロー基盤の n8n(v2系) を入れようとした。ところが Node.js 26 では native モジュールの isolated-vm がビルドできない。ビルドは serializer_nortti.o のコンパイルで Error 1 を吐いて落ちる。結論、Node.js 22(Homebrew の keg-only パッケージ)を用意して n8n をそこで動かすのが解決策だった。新しすぎるNodeは、native依存を持つツールの地雷になる——ツールの対応バージョンを先に確かめてから入れる。
付録2:空マッチした ls がカレントディレクトリを返す
「該当ファイルを別ディレクトリへ mv する」ループを組ませたとき、対象を ls <glob> で列挙していた。ところが glob が1件もマッチしないと、ls は引数なしで呼ばれたのと同じ扱いになり、カレントディレクトリの一覧 を返す。結果、意図せずディレクトリ直下のファイルを1個ずつ移動し始めた(幸い全量復元でき、データ消失はなかった)。対策は、ls任せにせず実在するファイルだけを配列に集め、存在ガードを通してから処理すること。シェルのglobは「空なら空」を返してくれない、と覚えておく。
まとめ:コードは書かなくていい。だが観測はサボれない
コードを一行も書かなくても、プロンプトだけで無人の自動化基盤は作れる。それは今回で証明できた。だが同時に、痛いほど分かったこともある——システムを作れることと、システムを"観測"できることは別のスキルだ。そして後者はサボれない。
私が今回、身銭(というかGPUの数十時間)を切って学んだのは、次の5つだ。
- GPU使用率とVRAMは別物。使用率(描画)が高くても生成用メモリは空いていることがある。詰まりを疑うなら使用率ではなくVRAMとモデルのロード状態を見る。
-
SIGSTOP二分法。犯人が分からないときは、容疑者を
kill -STOPで1つずつ止めて指標の変化を観測すれば、実測で特定できる。思い込みで対処するより速い。 - シェル実行には必ずタイムアウト。期限切れでは子プロセスごと殺す。「終了を待つだけ」はハング相手にプロセスを無限に積み上げる。
- 定時ジョブには単一実行ガード。前便が生きていないかを確認してから走らせ、直列化する。長引く処理×固定間隔は"終わらない"を自己増殖させる。
-
ローカル切り替えはログで検証。
ANTHROPIC_BASE_URLで接続先を差し替え、「応答が返った」ではなくサーバ側のPOST増加で裏を取る。
真犯人がAIですらなく、ただの画面描画だった——という結末は、少し間抜けに聞こえるかもしれない。でも、これがダッシュボードの数字を鵜呑みにした人間への、ちょうどいい罰だったと思っている。次に「GPUが張り付いた」と誰かが騒いでいたら、私はまず聞く。「それ、使用率の話? VRAMの話?」と。


