🔖

クレジットカードがなかったのでClaude Codeを自作した話

に公開

📝はじめに

僕は大学1年生ですが、Claude Codeを使いたかったのにクレジットカードを持っていませんでした。それだけの理由で、ローカルLLMで動く自分用のAIコーディングエージェントを作り始めました。
本記事では作る過程で詰まったこと・気づいたこと・ローカルLLMと格闘して学んだことを書きます。

🚀作ったもの

Loca(ロカ)というCLI型のAIコーディングエージェントです。

  • ローカルLLM(Ollama)で完全無料で動く
  • ファイルの読み書き、シェルコマンドの実行、Web検索、Git操作を自律的に行う
  • uv sync と loca だけで起動できる

👉 GitHub: https://github.com/kanade73/Loca

最終的にはDjangoのタスク管理アプリを一人で作れるくらいには成長しました。

↓完成した簡単なTodoリストアプリ。Claude Codeほどじゃないけど勝手に作ってくれた

🤔なぜローカルLLMなのか

Claude CodeはAPIキーとクレジットカードが必要です。自分はどちらも持っていなかったので、ローカルで動くものを作るしかありませんでした。
使っているのはQwen2.5-Coder:32Bというモデルです。M5 MacBook Pro 32GB上で動かせるギリギリでした。
ただ作り始めてから気づいたことがあって、ローカルLLMはAPIと違ってタダです。「これをやったらどうなるか」を気軽に試せる環境が手元にあるというのは、勉強という意味では逆に恵まれた状況でした。

🛠️詰まったこと①:ローカルLLMにJSONを安定して返させる

最初に一番苦労したのはここです。
LLMにアクション(ファイル操作やコマンド実行)を選ばせるために、こういうJSONを返してもらう設計にしていました。

{
    "thought": "思考プロセス",
    "action": "write_file",
    "args": {"filepath": "main.py", "content": "..."}
}

ChatGPTやClaudeなら雑なプロンプトでもそれなりに返してくれるんですが、Qwen 32Bはちょっとでも指示が曖昧だと「もちろんです!では以下のJSONを...」みたいな文章を混ぜてきたり、シングルクォートで囲んできたり、JSONの中に改行を入れてきたりと、かなり素直じゃなかったです。

試行錯誤した結果、効いたのはこのあたりです。

  • NO conversational text outside the JSON block. と明示する
  • NEVER use single quotes と具体的に禁止する
  • few-shot example(期待するJSONフォーマットの具体例)をプロンプトに直接埋め込む

ローカルLLMはChatGPTやClaudeと違って補完能力が低い分、自分のプロンプトの粗が正直に出る。これが最初はストレスでしたが、プロンプトの書き方が根本的に洗練されていく感じがありました。

🛠️詰まったこと②:最後の指示しかやらない問題

「〇〇して、△△して、最後に××して」みたいなタスクを振ると、最後の「××して」しかやってくれないことが多かったです。
調べると、Transformerのアテンション機構は直近のトークンに強く反応する傾向(Recency Bias)があるらしく、学習データ上でも「最後の要求を満たす=タスク完了」というパターンを学習しているとのこと。
対処としてはタスクを一つずつ渡すというシンプルな方法が一番効きました。エージェントがaction: noneを返したら次のタスクを渡す、という設計にすると安定して動くようになりました。

🛠️詰まったこと③:ツール設計をローカルLLMに合わせる

Claude Codeの仕組みを調べると、ファイル編集は差分パッチ(diffの生成)で行い、ファイル読み込みは関連箇所だけ抽出し、複数のツールを同時に呼び出しています。これは高性能なモデルだからこそ成り立つ設計です。

LocaではローカルLLMの精度に合わせて、あえて「雑だけど確実に動く」方向にツール設計を振り切りました。

ファイル操作を「全文」に振り切る

操作 Claude Code等 Loca
ファイル書き込み 差分パッチを生成 ファイル丸ごと書き直し
ファイル読み込み 関連箇所だけ抽出 ファイル全文を返す
ファイル編集 行番号指定 テキスト完全一致で置換

write_file(全文上書き)を主力にしたのは、ローカルLLMに正確なdiffを生成させるのが現実的ではなかったからです。トークンの無駄遣いだとわかっていても、「ファイルの中身を丸ごと書いて」の方が安定して動きます。

edit_file(部分編集)も実装しましたが、行番号指定ではなくold_textの完全一致で検索→置換する方式にしました。ローカルLLMは行番号を正確に把握するのが苦手なので、「この文字列を探して、これに変えて」の方がミスが少ないです。

1レスポンス = 1アクション

Claude Codeは1回のレスポンスで複数のツールを同時に呼び出せますが、Locaは1回の応答で1つのアクションしか許可しません

Choose ONLY ONE action per response based on the user's request.

さらにシェルコマンドの&&;による連結も禁止しています。複数のことを同時にやらせると、ローカルLLMは2つ目以降の処理を雑にこなすか、無視する傾向がありました。遅くても1つずつ確実にやらせる設計です。

コンテキストの管理:要約を諦めて切り捨てる

会話が長くなるとコンテキストウィンドウが溢れます。本来はLLMに「ここまでの会話を要約して」と頼んで圧縮するべきですが、ローカルLLMの要約精度では重要な情報が失われるリスクがあったので、古いメッセージを単純に切り捨てる方式にしました。

# system_prompt + 直近のやりとりだけ残す
trimmed = [messages[0]] + messages[-(MAX_MESSAGES - 1):]

乱暴ですが、要約の品質が信頼できない以上、「直近の会話だけ見る」方が壊れません。セッションの上限も30回で強制リセットをかけていて、これはコンテキストウィンドウが小さい7B〜32Bモデルへの安全弁です。

JSONパーサーの4段フォールバック

①で書いた「JSONを安定して返させる」の裏側として、パーサー側にも保険をかけています。ローカルLLMの出力は本当に何が来るかわからないので、4段階で必死に拾いにいきます。

1. 正規表現で ```json ... ``` ブロックを抽出
2. JSONDecoder.raw_decode で途中からパース
3. 最初の { から最後の } まで力技で切り出し
4. 最終手段: ast.literal_eval(シングルクォートも許容)

さらに、これでも全部失敗した場合は「あなたの前の応答はJSONとしてパースできませんでした」とLLMに知らせて自動リトライします。クラウドモデルでは必要ない防御処理ですが、ローカルでは日常的に発動します。

✨工夫したこと:合議制アーキテクチャ(/proモード)

単体のLLMだと自分の出力に謎の自信を持っていて、間違いを指摘しても「おっしゃる通りですね(でも同じコードを書く)」みたいなことが起きやすいです。
そこで実装したのが/proモードです。

Editor AI:コードを生成する
Reviewer AI:そのコードを批判する

この2つを交互に動かして、ReviewerがApproveを出すまで修正を繰り返す仕組みです。
ポイントはEditorとReviewerのコンテキストを分離すること。Reviewerには「別のエージェントが書いたコード」として渡すことで、自分が書いたという先入観なしに批判させられます。
同じQwen 32B一つで動かしているんですが、役割を分けるだけで単体より明らかに品質が上がりました。

✨工夫したこと:記憶システム(/rememberコマンド)

僕はClaude CodeにコンプレックスがあるのでLocaにもLoca.mdというマークダウンファイルにルールを書き込むという設計にしました。それだけだと面白くないので、プロンプトからルールを見る、ルールを追加する、忘れさせるなどをそれぞれコマンドで実装しました。

> /remember パッケージ管理は全てuvを使うこと
 記憶しました: パッケージ管理は全てuvを使うこと

マークダウンファイルを自分で編集するでも良いし、ここで追加/削除するということもできます。

✨工夫したこと:/undoコマンド

ローカルLLMはクラウドモデルに比べてミスが多いです。ファイルを壊されることも普通にあります。この現実に寄り添うために/undoコマンドを実装しました。

write_fileedit_fileを実行する直前に元のファイル内容をメモリ上のスタックに保存しておき、/undoで即座に巻き戻せるようにしています。新規作成されたファイルの場合は、undo時にファイルごと削除します。

「壊されても怖くない」という安心感があるだけで、ローカルLLMに思い切ってタスクを任せやすくなりました。

✨工夫したこと:自動lintチェック

ローカルLLMはファイル間のimportエラーや構文ミスを自分では気づけないことが多いです。write_fileedit_fileでPythonファイルを書き込むたびに、ruffpy_compileを自動で実行するようにしました。

エラーが検出された場合はその結果をそのままAIに返して自動修正を促します。人間が気づく前にLocaが自分でエラーを拾って直しにいくので、import周りの凡ミスが大幅に減りました。

✨工夫したこと:プラグインシステム(loca_toolsフォルダ)

loca_toolsというフォルダにPythonファイルを置くだけで、Locaがそれを自分のツールとして認識して使えるようになる仕組みを実装しました。

loca_tools/
└── my_tool.py   ← ここに置くだけで自動的に使えるようになる
# loca_tools/my_tool.py
TOOL_NAME        = "my_tool"
TOOL_DESCRIPTION = "このツールが何をするか(AIに伝える説明)"
ARGS_FORMAT      = '{"key": "value"}'

def run(args: dict) -> str:
    # 処理を書く
    return "結果の文字列"

Locaが起動時にこのフォルダをスキャンして、定義されたツールをシステムプロンプトに動的に追加します。コードを書いてフォルダに入れるだけでLocaの能力が拡張されるので、記憶システムとは別の次元で「育てる」体験ができます。

💡ローカルLLMをやって気づいたこと

M5 MacBook Pro 32GBで動かしていますが、Qwen 32B Q4でスワップが3GBくらい発生してギリギリです。ローカルLLMは「普通の人がPCを買って動かせる限界値」がはっきり見えるので、その制約の中でどこまでできるかを突き詰める感じが面白いです。
APIを使えば「賢さコスパ」は圧倒的にAPIが勝ちます。でもローカルLLMをやることで、LLMがどういうミスをしやすいか・なぜそうなるのかが肌感覚でわかるようになって、プロンプトの書き方やエージェント設計への理解が深まりました。作ったものが実用的かどうかはさておき、あえて性能が低いLLMを使うということならではの学びがありました。あとLocaはモデルを差し替えればなんでも使えるので、ローカルLLMでより性能が高くて軽いものが出ればLocaもそれに連動して便利になるのが良いところだと思います。

🚀今後やりたいこと

  • /diffコマンド:セッション中にLocaが行った全変更を色付きで表示(人間のレビュー時間を短縮)
  • 小さいモデル(7B・14B)での動作の安定化
  • 既存のクラウドのサービスと連携して使えるようにする。(設計書ファイルが更新されたことを起点にLocaが動作してコーディングをするようにし、クラウドのサービスでのトークン消費を少なくする)

🏁おわりに

まだ完成とは言えないしClaude Codeには到底及ばないのですが、制約の中でDjangoアプリを一人で作れるくらいには育ちました。
クレジットカードを持っていないという制約が、結果的にLLMの仕組みを深く理解するきっかけになりました。
フィードバックや改善案があればコメントやGitHubのIssueでもらえると嬉しいです。やっているうちに愛着が湧いてきたのでもう少し作りたいと思います。

👉 GitHub: https://github.com/kanade73/Loca

Discussion