はじめに

2026年8月、CloudflareがCloudflare OSをオープンソース化しました。

社内で数千人が使っているAIエージェント環境をまるごと公開する、というやつです。ドキュメントを書き、アプリを作り、業務を自動化する。それを「自社のCloudflareアカウントにデプロイできますよ」という売り方をしています。

blog.cloudflare.com Cloudflare OS: an open platform for agents, apps, and work

でもリポジトリのREADMEを読んでいたら、こう書いてあったんですよ。

To quickly run Cloudflare OS locally, install pnpm, then do: pnpm run-local

デプロイしなくていいの?ローカルで動くの?

動くなら課金はゼロです。しかもモデルをOllamaに差し替えれば、推論まで含めて完全にローカルで完結する。Workersにも課金せず、APIキーも使わず、レート制限にも当たらない。

というわけで試しました。

結論から言うと、動きました。ただし「三目並べを作って」の一言から遊べるものが出てくるまでに、5ラウンド・約16,000トークン・1時間半かかりました。 私が適当なタイミングで見守ってたり指示したりもありますが。

そしてその5回の失敗が、全部それぞれ違う理由で面白かったので記事にします。Cloudflare OSというプロダクトの設計思想が、失敗するたびに1枚ずつ剥がれて見えてくる感じでした。

「ローカルLLMでエージェント環境を動かすと、実際どこで詰まるのか」を知りたい人向けの、実測つき奮闘記です。

検証環境

先に言っておきます。

RTX 5060 (VRAM 8GB) / RAM 32GB。ゲーミング用途で組んだWindows機です(AI用に用意したものではありません)。

潤沢な環境ではありません。辛い。ゲーム目的なので全然十分なのですがね…。 ただ、AIを動かすのに潤沢でないからこそ「どこで詰まるか」を具体的に書けます。

  • OS: Windows 11
  • Node.js 24.13.0 / pnpm 11.15.1
  • Cloudflare OS: 2026年8月リリースの早期アクセス版(v2)
  • モデル: Ollama + qwen3:8b → 途中から qwen3:30b-a3b

Cloudflare OSとは何か

詳しい設計の話は別記事にするので、ここでは2つだけ。

Gadget は、ユーザーごとにプライベートなインスタンスが立つ小さなアプリです。スライドを作るときも、ダッシュボードを作るときも、ゲームを作るときも、全部この形式。SaaSを共有で使うのではなく、自分専用のコピーがサンドボックスの中に生成される。だから中のコードを好きに書き換えても、他人に影響しません。

Gatekeeper は、外部サービスごとの仲介役です。GitHubやGoogleに繋ぐとき、Gatekeeperがアクセス範囲を絞り、全操作をログに残し、副作用のある操作には人間の承認を挟みます。

このGatekeeperの承認の仕方が面白くて、エージェントを止めません。承認待ちの操作は「実行したことにして」シミュレート結果を返し、エージェントは先に進む。人間は後からまとめて承認・却下する。コーヒーを取りに行っている間に止まっていない、というのが売りです。 コーヒーを取りに行ってるという表現とてもアメリカを感じる。結構好き。

この「後で承認」が今日の伏線になります。

github.com GitHub - cloudflare/cloudflare-os: Agent workspace built on Cloudflare Workers for creating documents, building apps, and running agents with your company’s context and systems.

Windowsでは pnpm run-local が動かない

READMEは pnpm run-local の一行で済むと言っています。

済みません。

Error: spawnSync pnpm ENOENT
    at Object.spawnSync (node:internal/child_process:1120:20)
    at run (file:///.../scripts/run-local.mjs:118:3)

いつものやつです。原因はスクリプトがpnpmを直接spawnしていること。

execFileSync("pnpm", ["install"], { stdio: "inherit", cwd: ROOT });

Windowsで実際に存在するのは pnpm.cmd なので、pnpm という名前のバイナリを探しに行くと見つからずENOENTになります。Node.jsの execFileSync はシェルを経由しないため、.cmd の解決をしてくれません。もはやWindows端末使っていると何かにあたっても慣れてくるし、今の時代はAIがなんとかしてくれる。いい時代ですよ、本当に。

該当箇所は3ファイルありました。

  • run-dev-server.js
  • packages/gatekeeper-context/build-app.mjs
  • packages/gatekeeper-scheduler/build-app.mjs

直し方は一行です。

  execFileSync("pnpm", ["exec", "wrangler", "dev", ...args],
-     { stdio: "inherit", cwd: ROOT });
+     { stdio: "inherit", cwd: ROOT, shell: process.platform === "win32" });

パッチを当てたくない場合は、同じ手順を手で踏めば普通に通ります。

pnpm install
pnpm --filter @gadgets/typed-storage build
pnpm --filter @gadgets/workshop-frontend exec vite build
node run-dev-server.js --serve-frontend-assets

これで http://localhost:8787 が立ちます。wranglerとworkerdの上で全部ローカルに動くので、Cloudflareアカウントとは一切繋がりません。ログインもしていない状態で動きます。

ちなみに起動ログを眺めていたら、こんな行が出ていました。

Wrangler detected this dev session is running in an AI agent.
The Local Explorer API is available at http://127.0.0.1:8787/cdn-cgi/local/explorer/api

wranglerが「これAIエージェントから実行されてるな」を検知して、KVやD1やDurable ObjectをHTTPで覗けるAPIを勝手に生やしてきます。エージェント向けの導線が標準で用意されている時代になったんだなあ、という気持ちになりました。

あとローカル版はユーザー名とパスワードだけの簡易認証で、開発時のデフォルトが ADMINS=["admin"] です。つまり admin という名前でアカウントを作ると管理者になります。作りました。 adminでゴリ押せるのはローカルのいいところ。公開する場合は当然変えようね。

OllamaのモデルをCloudflare OSに繋ぐ

Cloudflare OSが対応しているプロバイダは5種類です(packages/workshop-backend/src/ai-models.ts)。

プロバイダ備考
anthropic / openai / google各社APIキー
cloudflareWorkers AI(BYOK)
ollamaAPI URLを自由に指定できる

最後の ollama が実質「任意のOpenAI互換エンドポイントを挿せる口」になっていて、ここにローカルのOllamaを繋ぎます。

winget install Ollama.Ollama
ollama pull qwen3:8b

qwen3:8bはQ4で約5.2GB。VRAM 8GBに丸ごと載るサイズです。 あと別に普通にGLMでもDeepSeekとか好きなのでいいでしょう。 私が個人的にずーっとqwen好きなのと慣れてるので、qwenです。 健気に頑張ってくれるのが可愛い。

UIから登録します。セットアップ画面の「Add new model…」→ プルダウンの一番下にある「Other Ollama…」を選ぶと、フォームが出ます。 まあPlaywrightに任せてましたが。

  • Model ID: qwen3:8b
  • API URL: http://localhost:11434
  • API Token: 空でOK

これだけです。登録すると右上のコスト表示が $0 のままになります。 当たり前ですが、安心します。溶けるトークンや5時間制限に怯えなくて済むのは本当にメンタルと財布に良い。

なぜqwen3なのか、という話を先にしておく

ここで「え、いま qwen3?」と思った方がいると思うので、先に答えておきます。

選んだ理由は3つです。課金がゼロであること。ローカル完結なので何を食わせても外に出ないこと。そして応答速度。 あとは、APIキーを挿して終わりの公式対応プロバイダと違って、「枠の外のモデルを繋ぐときのセットアップはこんな感じ」という実例を残したかったのもあります。

もうひとつ、2026年だと無視できないのがレート制限に当たらないことです。 金額は予算を積めば解決しますが、5時間制限は金で殴っても即座には解けません。しかも上限の設計は自分でコントロールできない変数です。ローカルにはそれがない。遅いけど、遅いのは自分の都合。

で、もっと即物的な理由もあります。そもそも大きいモデルが載らない

ここ、スペック表の数字だけ見ていると間違えるポイントです。

モデルはマシンを独占しない

「RAM 32GBだから20GB台のモデルもいけるでしょ」と思うじゃないですか。実際に測ってみました。モデルを1つも読み込んでいない状態で、こうです。

RAM合計: 31.9 GB / 空き: 16.3 GB / 使用中: 15.6 GB

内訳は、Edge WebView2が2.48GB、Chromeが2.01GB、VS Codeが1.22GB、Claudeが0.8GB。そしてMemory Compressionが4.99GB。Windowsが既にページを圧縮して凌いでいる状態です。

つまり実作業中に使えるモデル枠は、総量ではなく約16GBでした。

VRAMも同じことが起きています。こちらのほうが影響が大きい。

memory.total: 8151 MiB / memory.used: 1138 MiB / memory.free: 6759 MiB

これもモデル未ロードの状態です。GPUに触っているプロセスを数えたら24個ありました。Chrome、Edge WebView2が5プロセス、Discord、VS Code、エクスプローラー、スタートメニュー、スクリーンショットツール……。デスクトップを普通に使っているだけで1GB強が常時押さえられています。

実効VRAMは公称の8割強。8GBのカードなら約6.6GBです。

(この1.1GBは「Windowsでデスクトップを使いながら」の税金です。ヘッドレスのLinux機ならほぼゼロなので、AI専用に振り切るならWindowsを使わない選択そのものが最適化になります。この記事はゲーミングPCを流用する話なので、払う側にいます。)

ベンチマークの多くは「全部閉じた状態」で測られています。 でも実際の作業は、Slackを開いていたり、Chromeでタブを開いていたり、Spotifyで音楽を流しながら行われる。それは贅沢な状態ではなく普通の状態です。 カタログ燃費と実燃費の関係と同じで、知りたいのは実燃費のほう。

ちなみに「ローカルは無料」も正確ではない

一番ありがちな指摘が「GPU代は無料じゃないだろ」なので先に精密化しておくと、ローカルLLMがタダなのは限界費用がゼロという意味です。総コストがゼロではない。

このマシンはゲーム用として既に存在していて、その余剰資源を使っているから電気代だけで済んでいます。逆に「ローカルLLMのために専用機を買う」なら経済性は反転します。十数万円のマシンはAPIトークンをかなりの量買えるので、買う前に比較したほうがいいです。

なお良いゲーミングPCでもLLMだと厳しい理由

同じマシンがゲームでは余裕なのにLLMでは足りない、というのは矛盾ではなくて、測っている軸が違うだけです。

ゲームのVRAMは解像度に応じたテクスチャやバッファの置き場で、8GBあれば1440pまでは足ります。ボトルネックは主に演算性能のほう。対してLLMはモデル全体をVRAMに置くので容量が全てで、演算はメモリ帯域待ちで遊んでいることが多い。

なので実用ルールとしては、LLM用途ではGPUの世代や型番ではなく、VRAMのGB数だけ見ればいいです。5060の8GBと4060の8GBは、載るか載らないかではほぼ同じ振る舞いをします。

qwenちゃん、三目並べに挑む

準備ができたので、READMEが勧めてくるお題をそのまま投げます。

Make a tic tac toe game.

Cloudflare OSはワークスペースを自動で作り、チャット欄に思考過程を段落で流し始めました。Ollamaが reasoning フィールドを別建てで返すので、何を考えているかが全部見えます。これが後で効いてきます。

qwen3:8bの初戦はこうでした。

1ターン目、Gadgetの箱だけ作ってターンが終わる。 2ターン目、サーバー側のコードにこう書く。

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";

Denoです。

ここはCloudflare Workers(workerd)なので、当然「No such module」で死にます。そこまでは、まあ、ある。問題はその後で、エラーを見たqwenちゃんは自分で直さずに一般論の解説を始めました。「URLに :// が抜けている可能性があります」「Denoの環境が正しく設定されているか確認してください」。

いや君が書いたんだよ。

3ターン目に「ここはworkerdです。ツールを使って修正を適用してください、説明ではなく」と明示的に指示しても、また丁寧な解説が返ってきました。Filesタブは「No files yet」のまま。

いい子なんですけどね。

8Bでは荷が重いと判断して、qwen3:30b-a3bに変更します。MoEで実働3Bなので、19GBあるわりに速いはずという読みでした。

読みは当たって、CPU 68% / GPU 32%の混合実行で約28 tok/s。CPUに7割載っている状態でこの速度が出るのがMoEの強みです。同じ30Bでもdenseだったら数tok/sまで落ちていたはずで、体感が完全に別物になります。

……で、ここから本当の犯人が出てきます。

デフォルト4096トークンが静かに全部壊していた

30b-a3bに変えてしばらくすると、チャットにこんなエラーが出るようになりました。

Error: Stream ended without finish_reason
Error: internal error; reference = sppsc1llga6bg4g...
Error: Connection error.

モデルを変えた直後なので、最初はモデルかメモリを疑いました。でも症状がもっと妙で、だんだん頭が悪くなっていくんですよ。

  • 自分がさっき作ったGadgetのbinding名を忘れて、別の名前で新しいGadgetを作り直す
  • writeFile のパラメータ名を間違える(filenamepath と書く)
  • ターンが途中で終わる

Ollamaのサーバーログを見に行ったら、決定的な一行がありました。

slot context shift, n_keep = 4, n_left = 4091, n_discard = 2045

Ollamaのデフォルトコンテキスト長は、このクラスのGPUだと4096トークンです。

正確に言うと、現行のOllamaはデフォルトをVRAM量で決めます。23GiB以上で32K、47GiB以上で256K、それ未満は4096。つまりVRAMが貧しいと、コンテキストのデフォルトまで貧しいところから始まる。実効6.6GBの我が家は、当然いちばん下の段です。

そしてCloudflare OSのシステムプロンプトは、ツール定義まで含めると4千トークン級あります。つまり会話が始まった瞬間からコンテキストが溢れていて、古いトークンを捨てながら生成していた。

さらに悪いのが n_keep = 4 です。これは「先頭から4トークンだけは守る」という意味で、実質何も守っていません。システムプロンプトは丸ごと切り捨て対象でした。 色々と検証のためにharness外したり、Ollamaも極力「素の状態」にしてたの忘れてたよね。普段の環境はカスタマイズ済みなので、本来は踏まない罠です。でも初見の人が踏むのはデフォルトのほうなので、結果的にこれで正しい検証だった、ということにしています。

症状の説明が全部つきます。binding名を忘れたのは、自分で作ったGadgetの情報が捨てられたから。writeFile のパラメータ名を間違えたのは、ツール定義がコンテキストから消えていたから。

これ、会話用途なら妥当な戦略なんですよ。古い雑談ほどどうでもいいので、古い順に捨てるのは理にかなっている。でもエージェント用途では構造的に逆向きです。一番大事な情報(システムプロンプト、ツール定義、フレームワークの規約)が、常に一番古いところにいる。守るべきものから順に殺していく。

そして一番厄介なのが、エラーも警告も出ないことです。UIから見えるのは「なんか挙動が変」だけ。モデルのせいだと思ってモデルを変えたり、量子化を疑ったりして時間を溶かすやつです。

ちなみにフロンティア系のAPIだとこれは起きません。コンテキストを超えたら普通にエラーで蹴られるので、嫌でも気付きます。黙って捨てて動き続けるのはローカルならではの罠。 エラーで怒られるのって、ありがたいことだったんですね……。

対処は環境変数です。

setx OLLAMA_CONTEXT_LENGTH 16384

公式ドキュメントはエージェントやコーディング用途に64K以上を推奨していますが、うちのVRAMでは16Kが現実的な妥協点です。 設定してOllamaを再起動すると、症状は消えました。

ただし副作用があります。qwen3:8bのメモリ使用量が5.2GB → 7.8GBに増えました。コンテキスト長はKVキャッシュのサイズに直結するので、素直にメモリを食います。

そしてここで、さっきの実効VRAMの話と繋がります。7.8GBは公称8GBには収まるように見えて、実効6.6GBには収まらない。案の定、CPU 27% / GPU 73%の混合実行に落ちました。

スペック見積もりはVRAM容量だけでは足りない、というのはこういうことでした。

三目並べが動くまでに5ラウンドかかった

コンテキストを直したので、いよいよ本番です。結果を先に置いておきます。

Rこちらが渡した情報の解像度結果
1「三目並べ作って」binding名をブラウザのグローバル変数として参照 → ReferenceError
2「このエラーが出てる」fetchでHTTPを叩く方式に変更 → CSPで全遮断
3「CSPで塞がれてる」CORSの問題と誤診して無限ループ → 手動停止
4gadget RPCスタブの存在を明示クラス名を独自命名 → メソッドが見つからない
5完全なスケルトンを渡す完走

順に見ていきます。

ラウンド1。client.js(ブラウザ側)で、サーバー側のbinding名 TIC_TAC_TOE をグローバル変数として参照して落ちました。サーバーとクライアントの境界を混同しています。

ラウンド2。エラーを伝えると、今度はfetchでHTTPエンドポイントを叩く方式に書き換えました。まっとうなWeb開発の発想です。

でもコンソールにこう出ます。

Connecting to 'http://localhost:8787/getGameState' violates the following
Content Security Policy directive: "connect-src 'none'"

Gadgetが動くiframeには connect-src 'none' が効いていて、fetchもXHRもWebSocketも一切通りません。設定の問題ではなく、原理的に通らない。

ここで初めて「ああ、そういう強制になってるか」と分かりました。Gadgetは外の世界と直接喋れないんです。喋れるのはサーバー側だけで、しかもbinding(=Gatekeeper)経由だけ。サンドボックスの設計が、規約外のコードを物理的に動かなくすることで強制されている。 どこまで生の状態で強制してるのかなあってのも知りたかったのでラッキー。

ラウンド3。ここが今回の山場でした。

CSP違反を見たqwenちゃんは、これをCORSの問題だと誤診しました。そしてサーバー側に Access-Control-Allow-Origin を足し始めます。当然直りません。直らないのでまた足します。直りません。

CSPとCORSの混同は人間の初心者もやる古典的な間違いなので、そこは責められない。問題は止まらないことでした。数分おきにCORSヘッダーの位置を変えては再試行し、延々と回り続ける。 健気な子ですよ、本当に。

10分ほど眺めてから、Stopボタンを押しました。

ラウンド4。仕様を明示します。「client.jsには gadget というグローバル変数が注入されていて、await gadget.method() でサーバーのメソッドが呼べる。fetchは使うな。index.htmlも要らない」。

方式は直りました。が、今度はサーバー側のクラス名を TIC_TAC_TOE と命名して、「メソッドが見つからない」で落ちます。

これは私のミスでした。Cloudflare OSではクラス名が Gadget 固定なんですが、その時点で私は既にシステムプロンプトを読んでいて、その制約が自分の中で空気みたいに当たり前になっていた。だから伝え忘れた。普通に人間サイドの凡ミスともいえる。

ラウンド5。もう全部書いて渡しました。

import { DurableObject } from "cloudflare:workers";

export class Gadget extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.board = Array(9).fill("");
    this.currentPlayer = "X";
  }
  getGameState() { /* ... */ }
  makeMove(index) { /* ... */ }
}

これを渡したら、正確に写経して完走しました。

Appペインに3×3の盤面が出て、セルをクリックすると gadget.makeMove() がRPCで飛んで、Durable Object側の状態が更新されて、再描画されて印が付く。ちゃんと遊べます。

完成時のqwenちゃんの報告がこれです。

The Tic Tac Toe game is now fully functional! ✅ No more “internal error” or module import issues ✅ Game state updates correctly via RPC ✅ Board renders properly in the app pane with full gameplay You can play the game immediately—click any cell to place X/O. Well done! 🎮

Well done! じゃないんよ。

とはいえ嬉しそうで何よりでした。終わったね、頑張ったね、ありがとうね。

なぜqwenちゃんは止まれなかったのか

引っかかっていたのはラウンド3です。

CORSヘッダーを何度書き換えても直らないなら、どこかで「これ違うかも」と言ってほしい。 人間なら3回目くらいで「すいません、これ分かりません」と言うところです。 いや3回目になってから言うのはリアルの人間でもそこそこの殺意なんですけどね。 なぜ言わないのか。

気になったのでCloudflare OSのコードを読みに行きました。 普段だとこのあたりはharness側で人間に確認させるようにしてるんですけど、今回は素のままの検証なので。

エージェントのターンを止める判定は packages/workshop-backend/src/agent.ts にあります。

shouldStopAfterTurn: () =>
    abortSignal.aborted ||
    ++turnCount >= 30 ||
    connectionRequested ||
    awaitingActionDecision ||

通常のチャットで効く停止条件は4つです(ほかにコールバック起動時専用の自動終了が1つありますが、今回は関係ないので省きます)。

  1. 人間がStopを押した
  2. ターン数が30に達した
  3. 外部リソースの接続承認を待っている
  4. 副作用のある操作の承認を待っている

「失敗が繰り返されている」という概念が、一つも入っていません。

入っているのは「人間が既に介入した」か「許可カードの返事待ち」だけ。唯一の歯止めが30ターン上限ですが、これはループを止めているのではなく、燃やせる上限を決めているだけです。

じゃあエージェント側から「詰まりました」と言う手段はあるのか。探すと2つありました。

requestConnection は「GitHubに繋ぎたいので認証してください」というリソース接続専用のもので、汎用の相談窓口ではありません。giveUp というお手上げ宣言用のツールもあるんですが、コードを読むと登録される条件がこうなっています。

// When the agent was started to handle callbacks, add the giveUp tool so it can bail out.
if (callbackInitiated) {
  tools.giveUp = defineTool({ /* ... */ });
}

callbackInitiated、つまりスケジュール実行やコールバック起点で動いているときだけです。通常のチャットではgiveUpすら生えていません。

つまりCloudflare OS上でコーディングエージェントを務めるモデルの選択肢は、誰が座っても「やり続ける」か「勝手にターンを終える」の二択です。qwenちゃんは、呼び鈴が設置されていない部屋に閉じ込められていたわけです。

そう考えると、ちょっと申し訳なくなってきます。

そしてここで、冒頭の伏線が回収されます。Gatekeeperの売り文句は「承認を待たずに進める、人間は後からまとめて承認すればいい、コーヒーを取りに行っていい」でした。副作用は非同期化されているのに、失敗は同期的な人間の監視に依存したままなんですよ。コーヒーを取りに行っている間、誰がStopを押すんでしょうか。

部品自体は揃っているんです。エラーはキャプチャされていて(UIに「Send 2 captured errors to chat」というチップが出ます)、停止フックもあり、ターン履歴も永続化されている。shouldStopAfterTurn に「直近N回のエラーが同種なら停止して人間に投げる」を足すだけの話です。難易度ではなく、優先順位の問題として今そこにない。

ちなみにこれ、プロンプトで解決しようとしても無理だと思います。「2回失敗したら聞いて」と指示すると、失敗回数を数える主体がループの中にいる本人になるので。真っ白な盤面に✅を3つ並べて「完全に動作します」と宣言する自己評価は、信号として壊れているんですよね。数える主体はループの外にいる必要がある。これはハーネスの仕事です。

なぜ常識が全部裏目に出るのか

5ラウンドの失敗を並べてみて気付いたことがあります。

失敗の中身が、全部「余計なことをした」なんですよ

  • RPCスタブが用意されているのに、fetchでHTTPを叩こうとした
  • client.jsが全部組み立てる規約なのに、index.htmlを作った
  • 通信自体が遮断されているのに、CORSヘッダーを足した
  • クラス名は Gadget 固定なのに、独自に命名した

正解は毎回、書いたものより小さかった。一度も「機能が足りなくて」失敗していません。

なぜかというと、一般的なWeb開発の常識は何かを持っている前提で組み立てられているからです。fetchできる。localStorageがある。好きなクラス名を選べる。HTTPルーティングを自分で書く。CORSで解決できる。

Cloudflare OSは、それを全部持っていません。というか自分たちでやる、というスタンス。 だから一般的な常識前提で書くと全部落ちる。

同じ思想はプロダクト全体を貫いていて、クライアントにストレージを置かない、Gadgetからネットワークに出られない、サブエージェントに渡すツールは2つだけ、Blueprintで共有されるのはコードの「形」だけで認証情報は含まない。

持たせない、渡さない、迂回させない

Unix哲学の「最小限で、余計なことをさせない」と同じ形ですね。

そう考えると、この環境で問われているのは「コードを書く能力」ではなく、常識を捨てて初見の規約に従う能力でした。この2つは別の軸にあります。

なので、Cloudflare OS上で枠の外のモデルに本気で開発させるなら、この規約一式は最初からデフォで持たせてあげるのが良いと思います。ラウンド5で渡したスケルトンをラウンド1で渡していれば、たぶん1ラウンドで終わっていた。規約の内面化を待つより、規約を荷物に入れてから送り出すほうが早い。 そしてこの「必要最低限だけ持たせる」って、結局Cloudflare自身の設計思想と同じところに帰結するんですよね。向きが逆なだけで。

qwenちゃんの名誉のために書いておくと、三目並べのロジック自体は最初から一発でまともでした。勝敗判定も、盤面の状態管理も、DOM操作も問題ない。壁は実装力ではなく、規約の内面化のほうだった。 そもそも写経が出来るのは当たり前なので、ラウンド5自体は威張れた話ではないです。ただラウンド4の敗因はこちらの伝え漏れだったので、最初から規約が完全に渡ってさえいれば、スケルトンなしでも自力で書けていたはず。写経しかできない子ではありません。

まとめ

  • Cloudflare OSは wrangler + workerd完全にローカルで動く。課金ゼロで試せるが、Windowsでは pnpm run-local がENOENTで落ちるので、spawnに shell: process.platform === "win32" を足すか手動で同じ手順を踏む
  • モデルはOllama経由で差し替え可能。ollama プロバイダは実質「任意のOpenAI互換エンドポイント」の口
  • Ollamaのデフォルトコンテキスト長4096(VRAM 23GiB未満のGPUの場合)が最大の罠。システムプロンプトが黙って捨てられ、エラーも警告も出ないまま「モデルが急に馬鹿になる」。OLLAMA_CONTEXT_LENGTH を必ず確認する
  • スペック見積もりは総容量では足りない。デスクトップ常駐分が先に取られて実効VRAMは公称の8割強。MoEなら30B級でもCPU主体で28 tok/s出るので、VRAM増設よりRAM増設のほうが費用対効果が高い
  • Gadget開発は独自規約の塊で、一般的なWeb開発の常識を持ち込むと全部落ちる。5ラウンドの失敗は全部「余計なことをした」で、正解は毎回もっと小さかった。本気で開発させるなら、スケルトンと禁止事項を最初から持たせてあげる

実用の話はここまで。ここから先は感想戦です。

おわりに

正直、今日の詰まりの半分は「ローカルLLMだから」です。コンテキストの罠もCORSの誤診も、フロンティアAPIなら多分出会わずに済んでいます。 ただ、呼び鈴のない部屋のほうはモデルを賢くしても直りません。あれは部屋の問題なので。

じゃあ賢いモデルなら安心かというと、そうでもなくて。賢さで減るのはループの入口に入る確率で、出口が増えるわけではないんですよね。CORSみたいな古典的な誤診はしないにしても、代わりにもっともらしい仮説を次々立てて、別の検証や修正を健気に頑張ってくれちゃう。回り続ける構造は同じで、一手一手がもっともらしいぶん、人間が気付くのはむしろ遅れます。 最近「エージェントはループだ」という話をよく見ますが、出口まで設計しないループは、従量課金では純粋に金食い虫です。

そしてここが面白いんですが、弱いモデルだったからこの環境の欠陥が見つかったとも言えます。一発で成功していたら、エージェントに「詰まったので聞く」手段が無いことに永遠に気付かなかった。エラーメッセージが connect-src 'none' としか言わず「gadgetスタブを使え」とは教えてくれないことにも、気付かなかったはずです。

「弱いモデルで通る設計は、強いモデルでも通る。逆は成り立たない」という非対称があります。フロンティアモデルの賢さは、設計の粗を隠してしまう。これは一種の測定誤差です。

最後にもうひとつ。今回できた三目並べには、まだバグが残っています。盤面の状態をDurable Objectのメモリにしか持っていないので、サーバーが再起動すると消えます。Cloudflare OSのDesign Tipsには、はっきりこう書いてあるんですけどね。

ALWAYS store server state in Durable Object storage, not just in memory.

で、これって今日ずっと戦っていたコンテキスト溢れと同じバグなんですよ。レイヤーが違うだけで。メモリはキャッシュで、永続ストレージが真実。消えないファイルに書き出しながら作業していたのも、結局これでした。

散々転んでおいてなんですが、Cloudflare OS自体の感想は「もうこいつでいいんじゃないか」です。脳直と言われようと、ドキュメントもアプリも自動化も、面倒くさいことの大半がこの中で完結してしまう。APIキーを挿して普通に使うぶんには、本当にこれで済む気がします。

とはいえ、扱いこなせるかは別の話。正直、エンジニア要素がないとキツい気もしています。セキュリティやガバナンスには文句のつけようがないんですが、「安全である」と「作れる」は別の軸なんですよね。このへんは設計の別記事に譲ります。

あと当然ながら、これは私みたいにローカルLLMだの特定のモデルだのに固執しない人の話。固執する側は、今日みたいに枠の外で戦うことになります。

ローカルで良かったな、と思ったのは最後です。あのCORS無限ループ、従量課金だったら3ラウンド目で心が折れていました。減ったのは電気代だけだったので、ニコニコ眺めていられた。

財布とトークンの忍耐力が無料になるのは、ローカルLLMの地味に大きな効能だと思います。