780Mでローカル27Bコーディングエージェントを動かす: 止まった理由と、動き出した理由

背景

DeepSeek Harness(dsh)は、Claude Codeと同じ系統のオープンソースのコーディングエージェントCLI/Webアプリです。ここでは有料APIの代わりに、ローカルのOllamaモデルに繋いでいます。モデルはQwen3.8-27B-UD-Q3_K_XL、Unslothのdynamic量子化版で13.15GB、Radeon 780Mの共有VRAMパーティション上で完全に動いていて(ollama ps100% GPUと確認済み)、生成はおよそ4.2トークン/秒、プロンプトの取り込みは40〜60トークン/秒です。課題は、Phaser 3の単一ファイルのタワーディフェンスゲームを組み立てさせて、これだけ遅いローカルモデルが無人のエージェントとしてどこまでやれるかを見ることでした。

止まった

最初の試行は、1つのプロンプトにフルの仕様(タワー配置、敵のウェーブ、戦闘、勝敗の状態、必須のセルフ検証パス)を詰め込み、普通の1ターンとして送るというものでした。コードは1行も書かれませんでした。

Output token limit reached
Input 8.7K tok · Output 24.1K tok

Thinkブロックだけで24,100トークン、ツール呼び出しはゼロ、応答の上限で打ち切られています。この上限自体は、どこかに別途設定された値ではありませんでした。dsh-llm-pi-aiのソースを読むと、モデルのmaxTokensフィールドは任意で、未設定ならプロンプトを差し引いたあとのコンテキストウィンドウの残り分にフォールバックします。32,768(設定したnum_ctx)から入力の8,700を引くと24,068になり、報告された24.1Kとほぼ一致します。思考の途中でトークンの枠を使い切り、思考を終えるという判断を一度もしないまま終わりました。

手作業でタスクを分割すると直る

1つではなく7つに分けた別々のプロンプトで、それぞれ今の一手だけを説明する内容にしました(ボイラープレート、パスとHUD、タワー配置、敵、戦闘、ウェーブ、最後にgrepベースの検証パス)。1ステップあたりおよそ20〜60分、4.2トークン/秒換算です。どのステップも、途中で本物の自己修正を挟みながら、きれいで正しいコードを出しました。壊れた編集のあとに残っていたspliceの残骸を自分で見つけて直したこと、メソッド名の衝突についての疑問を正しく検討して片づけたこと、ステップを完了と宣言する前にシェルツール経由でnode --checkの構文チェックを自分から走らせたことなどです。

その途中で、パーミッション周りの本物のバグにも行き当たりました。このハーネスはサンドボックスの権限を広げる際の厳格なルールを持っていて、セッションは現在のモードより厳密に広いモードにしか昇格できません。workspace-writeのセッションを開始した状態で、モデルが拒否されるたびに習慣的にworkspace-writeを要求し直すと、今のアンビエント権限と要求している権限がそのつど一致してしまい、このルールに引っかかります。すでに持っているのと同じレベルへの要求は、決して「より広い」とはみなされないからです。この修正では、モデル自身のふるまいに合わせる形をとりました。モデルがworkspace-writeを要求するのは、実際に書き込みが失敗したあとの後追いの反応としてだけで、先回りして要求することはありません。最初からworkspace-writeでセッションを始めれば、拒否そのものが一度も起きないので、この昇格ロジックが働く場面自体がなくなります。以降のステップはすべて、パーミッションの確認なしで進みました。

同じプロンプトの中で先に計画を立てさせても効かない

次に浮かぶ疑問は、この停止がスコープの問題なのか、それとも元のプロンプトが段階的に進めるよう指示していなかっただけなのか、というものです。同じフルの仕様を1つのプロンプトに入れたまま、今度は「短い番号付きの計画を書き、最初の(ごく小さな)一手だけを実行して、そこで止まる」という明示的な指示を加えました。

結果はやはり失敗でしたが、今度は違う形をとりました。1時間以上、13,000トークン超、途切れない1つのThinkブロックのまま、ツール呼び出しはゼロ。モデルは、まだ描くと決めてもいないパスのために、lineStyle()の正確な色の値をあれこれ迷い続けていました。仕様全体という野心的な機能リストを先に見せてしまうこと自体が引き金になっているようで、スコープを絞る指示があるかどうかとは関係なく起きているように見えます。ここでもう一つ、無関係な別のバグが見つかりました。dsh-llm-pi-aistreamIdleTimeoutMsのデフォルトを300,000(5分)にしていて、これはホスティングされたAPIの応答速度に合わせたしきい値で、この4.2トークン/秒のローカルモデルよりかなり速い前提を置いています。これが生成の途中で発火すると、応答全体が最初からやり直しになり、モデルがそれまでに組み立てていたものは失われます。settings.yamlで1,800,000に上げました。

プランモードは計画を立てるだけで、実行にはうつせない

このハーネスにはネイティブの/planモードがあり、Claude Codeの同種の機能と同じ発想です。その中で、モデルはしっかりしたものを出してきました。ステップごとの具体的な受け入れ条件つきの番号付き増分リスト、あとのコードについての明示的な先読み(「定数はファイルの先頭にまとめておく、そうすればあとで飛び道具のコードを足すときに設定変更が要らなくなる」)、そして自分で課した長さの上限を、こちらから言わなくても守っていました。増分1についても実際のコードを書くよう求めると、ファイルの書き込みを設計上禁止しているモードとその指示との折り合いをつけようとして、そのまま止まってしまいました。プランモードは計画を立てる場で、実行はそもそも設計に入っていません。その両方を同じ呼吸の中でやらせようとすること自体が、モデルにきれいには解決できない矛盾になっています。

効いた方法: モデル自身のgoalツール

dshcreate_goalというツールをモデルに直接公開していて(dsh-tool-goalのソースを読んで見つけました)、モデル自身が長期にわたる目標だと判断したものに対して、自分の意思で呼び出せます。一度作成すると、ハーネスは設定した上限まで、ラウンドをまたいだ自動継続を進めます。プランモードと違い、通常の読み書き権限はそのあいだずっと保たれます。

同じフルの仕様、同じ「ラウンド1はごく小さく留める」という枠組みを、今度はcreate_goalに向けて、ラウンド数の上限10、通常モードで試しました。create_goalはほぼ即座に呼ばれ、続けて対象ファイルがまだ存在しないことを確認するGlobが入り、そのあと正しく最小限の最初の書き込み、暗い背景だけのまっさらなPhaserシーンが、およそ10分ほどで書かれました。ターンの終わりには、ラウンド1で何を作ったか、ラウンド2で次に何を足すかという自己生成の要約がついていて、こちらから何もメッセージを送らないまま、ラウンド2が自分から始まりました。

結果

同じ停止を解消しようとした4回の試みは、4つとも違う結果になりました。手作業でのタスク分割はうまくいきましたが、毎回人間が分割作業をする必要があります。プロンプト内での「計画してから実行」指示は、指示のあるなしにかかわらずモデルが目指す全体像そのものを見てしまうせいで、はっきり失敗しました。ネイティブのプランモードは、そのどちらよりも良い計画を出したうえで、モード自身が作った壁にぶつかりました。create_goalは、タスクを分割する必要があるとモデル自身に判断させ、きちんと分割させ、人間が手でチャンクを作らなくてもラウンドを重ねながら動き続けさせました。単一ターンの中での推論の質は、このあいだ一貫して変わっていません。同じ重みのまま、誰にも頼まれていない色の値を延々と練り続けるという傾向も同じです。そのつど変わったのは、ターンの境目での判断、つまりモデルが推論をやめて行動にうつすタイミングでした。4.2トークン/秒で動くこのハードウェアの上では、その一つの判断が、機能が出荷されるか、それとも一度も書かれなかった機能についての長い独り言に終わるかを分けています。