はじめに
普段のコーディングは、クラウドの高性能モデルに頼りきりです。便利なのですが、ふと不安になりました。ある高性能モデルを使っていたら、公開からわずか数日でアクセスできなくなったことがあったのです。すぐ復旧はしたものの、「一斉に使えなくなったらどうするか」という問いが頭に残りました。同時に全部落ちる確率は低い。それでも、備えがあれば慌てずに済みます。
そこで、手元で動く「保険」を用意することにしました。最終的にはクラウド上の仮想マシンで大きめのモデルを回すのがコスパは良さそうですが、まずはファーストステップとして、自分のノートPCにローカルLLMを入れ、Claude Codeのような「差分を出して適用してコミットする」端末型コーディングエージェントに繋ぐところまでを試します。
環境は Apple Silicon Mac(arm64)、macOS 15系(Darwin 24.5.0)、メモリは16GB。別プロジェクトのDocker開発環境が常時動いているので、モデルに割けるメモリは実質もっと少ない、という前提でのスタートです。使うモデルは qwen2.5-coder:7b、ハーネスは aider にしました。結論から言うと、いちばんの壁はモデルの賢さではなく、Pythonのバージョンでした。
先にまとめ
先に結論を3つ。
- 7Bは「分解して渡せば」実用になるが、「設計ごと丸投げすると」転ぶ。 手順を噛み砕いて指示すればテストまで綺麗に自己修正する一方、仕様だけ投げると途中で止まったり、実行して初めて分かるバグを埋め込んだりします。
- 最初の関門はモデルではなく Python 3.13。 aiderが起動すらしませんでした。原因は標準ライブラリ
audioopの削除です。 - 最終的に動いた最短手順は下記。 これだけで「ローカル7B+aider」の土台は立ちます。
# 1. コーダーモデルを取得(約4.7GB)
ollama pull qwen2.5-coder:7b
# 2. aider を導入(Python 3.13 対策で audioop-lts を同梱)
uv tool install --force --with audioop-lts aider-chat
# 3. 接続先を明示(後でクラウドへ切り替える時はここを差し替える)
export OLLAMA_API_BASE=http://127.0.0.1:11434
# 4. 使い捨てリポジトリで起動
mkdir ~/aider-sandbox && cd ~/aider-sandbox && git init
aider --model ollama/qwen2.5-coder:7b急ぎでなければ、どうしてこの手順に落ち着いたのか、実際にたどった道を読んでください。回り道の方が学びがありました。
最初の関門:aiderが起動しない
モデルの取得はあっさり終わりました。試しに単発でバグ修正を投げてみます。
ollama run qwen2.5-coder:7b "Fix the bug in this Python function and explain the fix briefly:
def average(nums):
return sum(nums) / len(nums) + 1"余計な + 1 を的確に指摘し、修正版を返してきます。7Bとしては上々。期待が持てました。
問題は次です。aiderを uv tool install aider-chat で入れて起動した瞬間、盛大に転びました。
ModuleNotFoundError: No module named 'audioop'
...
File ".../pydub/utils.py", line 16, in <module>
import pyaudioop as audioop
ModuleNotFoundError: No module named 'pyaudioop'トレースバックを追うと、aider → voice.py → pydub → utils.py の順に辿って落ちています。最初は「音声機能なんて使わないのに、なぜ?」と戸惑いました。ここで手を止めて調べたところ、犯人はモデルでもaiderでもなく、Python本体でした。
Python 3.13 で標準ライブラリの audioop が削除されています(PEP 594、いわゆる「dead batteries」の一掃)。aiderは音声入力のために pydub を読み込み、その pydub がモジュール読み込みの時点で audioop を import します。つまり音声機能を一切使わなくても、aiderの起動時トップレベルのimportで連鎖的に落ちる。uvが既定でPython 3.13を掴んだために踏んだ罠でした。
推奨される置き換えは audioop-lts で、これがPython 3.13以降向けにドロップインな audioop モジュールを提供します。aiderの隔離環境にこれを同梱して入れ直すと、あっさり解決しました。
uv tool install --force --with audioop-lts aider-chat
# → audioop-lts==0.2.2 が追加され、起動できるように公式ドキュメントの手順どおりに入れて、ドキュメントに載っていない理由で落ちる。ローカル環境構築の「あるある」ですが、原因の切り分けに一番時間を使ったのはここでした。もし3.13に留まる必要がなければ、uv tool install --force --python 3.12 aider-chat と3.12に固定して丸ごと避ける手もあります。
分解して渡すと、7Bは綺麗に直す
起動できたので、まずは素直なタスクから。使い捨てのgitリポジトリで、aiderにFizzBuzzを作らせます。
> create fizzbuzz.py with a fizzbuzz from 1 to 30生成された差分をプレビューし、yesで適用すると、自動でgitコミットまで走ります。「Claude Codeの形」がローカル7Bで再現できた瞬間で、ここは気持ちよく通りました。
次に、docstringとテストの追加を頼みます。すると関数化はできたものの、生成された test_fizzbuzz.py が機能的に壊れていました。
def test_fizzbuzz():
expected_output = [
1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz",
11, "Fizz", 13, 14, "FizzBuzz", 16, 17, "Fizz", 19, "Buzz",
"Fizz", 22, 23, "Fizz", "Buzz", 26, "Fizz", 28, 29, "FizzBuzz"
]
actual_output = []
with open('fizzbuzz.py', 'r') as file:
exec(file.read())
for i in range(1, 31):
print(i)
actual_output.append(input())
assert expected_output == actual_output面白いのは、expected_output の中身(15と30で "FizzBuzz" になる等)は完全に正しいという点です。FizzBuzzの正解列は知っている。にもかかわらず、「printする関数の出力をどうテストするか」という設計で転んでいる。exec した後に input() を30回呼ぶという、実行すればハングするコードです。知識はあるのに、テスタビリティの工学的判断が抜ける——これが小型モデルの手触りかと、この時点では受け止めました。
そこで、設計を全部こちらで噛み砕いて渡してみます。
> refactor fizzbuzz() in fizzbuzz.py to return a list of results instead
of printing, then rewrite test_fizzbuzz.py to assert on that returned
list with pytestすると今度は一発でした。print をやめて result に貯めて return する関数へ変換し、__main__ ガードも print(fizzbuzz()) に追従。テスト側は壊れた exec + input() を丸ごと捨て、from fizzbuzz import fizzbuzz して戻り値を直接assertする、まっとうなpytestに書き直しました。
import pytest
from fizzbuzz import fizzbuzz
def test_fizzbuzz():
expected_output = [
1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz",
11, "Fizz", 13, 14, "FizzBuzz", 16, 17, "Fizz", 19, "Buzz",
"Fizz", 22, 23, "Fizz", "Buzz", 26, "Fizz", 28, 29, "FizzBuzz"
]
assert fizzbuzz() == expected_outputuv run --with pytest pytest -q
# → 1 passed in 0.00s緑。生成 → 自己修正 → テスト通過の完全ループが閉じました(ちなみに import pytest は未使用で、7Bらしい余分な一行です)。ここで見えたのは、「7Bは、設計を渡しさえすれば実装は綺麗にこなす」という像です。天井は「壁」ではなく、「自分で計画させると転ぶ」という形をしているのではないか。そう仮説を立てて、次に検証することにしました。
仕様だけ丸投げすると、途中で止まる
検証のため、今度はあえて分解しません。データ構造・ID採番・argparseの設計・エラー処理を全部モデル任せにして、仕様だけ投げます。ついでに、コンテキスト長を広げた派生モデル(num_ctx 16384)を作って、多ファイルタスクで効くかも同時に見ます。
FROM qwen2.5-coder:7b
PARAMETER num_ctx 16384> build a command-line todo app in todo.py that stores tasks in a JSON
file. support adding, listing, marking done, and deleting by id. keep
all file read/write logic in a separate module storage.py. handle the
case where the JSON file doesn't exist yet, and print a friendly
message if the user marks or deletes an id that doesn't exist.ここで最初のつまずきです。2ファイルを頼んだのに、モデルは storage.py だけを書いて、「once you have this module, we can move on to creating todo.py」と言って止まりました。対話モデルの癖で「まず片方を出して同意を求める」フローに流れ、タスクを最後まで走らせる持久力が切れた形です。仕方なく todo.py を催促すると、続きを書き始めました。
さらに、todo.py 生成後のflake8自動修正ループが不安定でした。storage.load_tasks() と未importの名前で呼んで F821 undefined name 'storage' → load_tasks() に変えるも今度は F821 undefined name 'load_tasks' → import文に追加、と3周かけて解決。その途中、モデルの出力に実在しない User: / A: の往復が幻覚として混入しました。編集対象と会話文脈を取り違えかけた、危うい瞬間です。幸い最終的なファイルは無傷で、add_parser の定義行も生きており、py_compile も通りました。壊れはしなかったものの、自律修正ループ中にこういう幻覚が出るのだ、という記録は残りました。
実行して初めて出た本命バグ
生成物を眺めて、私は「storage.py にID採番が無いから、list した瞬間に KeyError: 'id' で落ちる」と予想しました。ところが実際に動かすと、外れました。
❯ python todo.py add "buy milk"
Task added: buy milk
❯ python todo.py list
1: buy milk - Pending # ← KeyError は出ない理由は、採番の責務が呼び出し側の todo.py に置かれていたからです。add の時点で {'id': len(load_tasks()) + 1, ...} とidを付けて渡している。storage.py 単体だけ見て結論を急いだ私の読みが不正確でした。ただしこの len()+1 方式は、削除でidが飛んだ後に採番が衝突しうる潜在バグを抱えています(今回の短い操作では未発現)。
本命は別のところに潜んでいました。操作を続けます。
❯ python todo.py done 1
Task 1 marked as done.
❯ python todo.py delete 1
Task 1 not found. # ← id=1 は在るのに「見つからない」
❯ python todo.py list
2: write blog - Pending # ← なのに 1 は消えている矛盾しています。delete 1 が「not found」と言ったのに、listから1は消えている。存在しないidでも試すと、同じ挙動でした。
❯ python todo.py delete 99
Task 99 not found.実在idでも架空idでも、毎回同じ「not found」。原因を追うと、storage.py の delete_task が戻り値を持たない(常に None を返す)ことに行き着きました。同じファイルの mark_task_done は未存在idに False を返す設計なのに、delete_task だけがフィルタして黙って終わる——関数間で契約が非対称なのです。一方 todo.py 側は if delete_task(...) の真偽で成否メッセージを分岐している。None は常に偽なので、削除が成功していても必ず else 枝に落ち、「not found」という嘘のメッセージを毎回印字していた、というわけです。
削除自体は動く。でもユーザーには毎回「見つからない」と嘘をつく。クラッシュしないぶん、かえって気づきにくい。実行して、しかも実在idと架空idの両方で叩いて初めて見える種類のバグでした。7Bに設計を委ねた時の壊れ方として、これ以上ないくらい典型的です。
ハマりどころまとめ
今回踏んだものを、症状・原因・対処で一覧にします。
| 症状 | 原因 | 対処 |
|---|---|---|
aiderが起動せず audioop で落ちる | Python 3.13が標準のaudioopを削除(PEP 594)。pydub経由で連鎖 | --with audioop-lts を同梱、または3.12に固定 |
| 多ファイル生成が途中で停止 | 7Bが「片方出して同意を求める」対話フローに流れる | 続きを明示的に催促する |
| 最初のpytestが機能不全 | 出力の捕まえ方をexec+input()で誤設計 | 「戻り値をassert」と設計を分解して渡す |
deleteが実在idでも「not found」 | delete_taskが常にNoneを返し、呼び出し側の真偽分岐が全部偽に | 戻り値契約を揃える(成功時True等) |
| 削除後にid採番が衝突しうる | len()+1方式(今回は未発現) | 最大id+1、またはUUID採番に変更 |
| lint修正中に偽の会話が混入 | 自律ループ中の幻覚 | 差分は必ず目視、狭いlintセレクタを過信しない |
Repo-map: using 4096 tokens | Ollamaの既定コンテキストが小さい | Modelfileでnum_ctxを拡張 |
zsh: no matches found: *.json / MallocStackLogging... | zshのglob仕様 / macOSの定型出力 | いずれも無害。エラーと切り分ける |
最後の行は蛇足に見えますが、切り分けでは大事でした。エラー風の無害な出力に気を取られると、本当の原因を見失います。
やってみての所感
一連を通して、7Bの輪郭がはっきりしました。向いているのは、手順を噛み砕いて渡せる作業です。FizzBuzzのように「こう直せ」と設計を渡せば、実装もテストも綺麗にこなし、自己修正でpytestまで緑にできる。定型的なリファクタや、単一ファイルの明確な修正なら、ローカル7Bは十分に実用でした。
向いていないのは、設計判断ごと委ねる仕事です。多ファイルを最後まで走らせる持久力、似た関数間で契約を揃える一貫性、Noneを返さないといった戻り値の作法、そして自律修正ループの安定性。このあたりが抜けます。しかも抜け方が「クラッシュ」ではなく「動くように見えて嘘をつく」形なので、実行して初めて分かる。保険として使うなら、レビューは人間が握る前提が必須だと感じました。
メモリの面では、16GBに常時稼働のDockerが乗った状態でも7Bは動きましたが、num_ctx を上げるとメモリ圧が効いてきます。実リポジトリを本格的に読ませるなら、このクラスの機体では16k前後が現実的な上限でした。
次は、もっとメモリの潤沢な機体で、より大きなMoE系のコーダーモデル(30Bクラス)に、今回とまったく同じtodoタスクを丸投げしてみるつもりです。今回7Bが転んだ「自律設計の天井」を、上位モデルは超えるのか。そして、その先にある、接続先をローカルからクラウドの仮想マシンへ切り替える構成へ。今日のaider起動時に出た OLLAMA_API_BASE は、まさにその切り替えの入口になります。
まとめ
ローカル7B+aiderで「差分を出して適用してコミットする」環境は、Apple Silicon Mac (16GB)でも確かに立ちました。ただし今日いちばん学んだのは、モデルの賢さの手前に、Python 3.13の audioop 削除のような環境の罠が待っている、ということでした。そしてモデルの実力は、丸投げした時の「嘘のnot found」に、いちばん正直に現れました。分解すれば直り、任せると壊す。保険は手に入りましたが、その保険は、こちらが設計図を握っている時だけ効くようです。