AIコーディング比較は読むより測る——ローカルLLM3種をpytestで採点した

目次
  1. 何をどう測るか
  2. 評価ハーネスの作り
  3. 実測結果——合格率と生成時間
  4. 落ちたコードをそのまま見る
  5. deepseek-coder:1.3b の暴走——1問に587秒
  6. この測り方の使いどころと限界

AIコーディングツールの比較記事を何本読んでも、どれを使うかを決められなかった。私が読んだ範囲では、並んでいるのは料金表と、SWE-benchのようなベンダー公表ベンチマークの転載と、使ってみた感想で、知りたいこと——自分が書くようなコードを任せたらどれくらい通るのか——には誰も答えていない。

答えが載っていないのは当然で、それは私の手元でしか測れない。そこで同一タスク・同一プロンプト・pytest自動採点の小さな評価ハーネスを書き、無料で動くローカルLLM 3モデルを実測した。以下、測り方と、合格率・生成時間・落ちたコードの生データをそのまま置く。

何をどう測るか

測定対象は ollama で動くローカルLLMに絞った。qwen2.5-coder の 1.5b と 7b、それに別ファミリの対照として deepseek-coder:1.3b の3つ。Claude Code・GitHub Copilot・Cursor のようなエージェント型ツールは、同一条件・自動採点の土俵に乗せるのに課金が必要になるため、今回の実測対象には入れていない(比較表の常連はむしろそちらだが、この記事は「測れたものだけを書く」方針にした)。

「無料」の線引きもローカル推論に限った。クラウド各社の無料枠でも同じことはできるが、枠の上限や提供条件が変わると同一条件での再測定が崩れるので、手元で完結する構成を選んだ。

実行環境は macOS 26.6.1(arm64)・Python 3.14.6・ollama 0.31.1。マシンは次のとおり。

$ sh -c 'sysctl -n machdep.cpu.brand_string; sysctl -n hw.memsize'
Apple M4
25769803776
# exit=0 (9 ms)

Apple M4、hw.memsize は 25769803776、つまりメモリ24GBだ。速度の実測値はすべてこのマシンでの値で、環境が違えば変わる。

モデルサイズは ollama list 表示で 7b が 4.7 GB、1.5b が 986 MB。deepseek-coder:1.3b は ollama pull deepseek-coder:1.3b で取得した(776 MB)。いずれも無料で、この規模ならユニファイドメモリ24GBに余裕で載る。

評価ハーネスの作り

ハーネスの構成は2つだけ。タスク集 tasks/ と、実行役 bench.py

各タスクはディレクトリ1つで、モデルへの指示 prompt.txt と採点用の test_solution.py を置く。6問用意した。

  • slugify — 文字列正規化。連続記号の畳み込み・前後トリム・空文字という仕様の縁をテストで固定
  • rle — ランレングス符号化と復号の往復
  • parse-log — ログ1行を dict に分解(不正行は None)
  • next-business-day — 週末と祝日リストを跳ぶ日付計算
  • fix-chunks — off-by-one のバグ入り関数を提示して修正させる(修正タスク型)
  • top-n-words — 頻出語 top-N(同率は辞書順)

6問の粒度は、私が普段チャット型のAIに投げる最小単位——貼ればそのまま使える純粋関数1個——に合わせた。生成タスクだけだと「白紙から書く力」しか測れないので、既存コードの修正(fix-chunks)を1問混ぜてある。テストは意図的に仕様の縁を突くように書いた。プロンプトの例示だけなぞって縁で落ちる、というのが小型モデルに予想した失敗の形で、それを検出できるテストでなければ採点の意味がないからだ。

たとえば slugify のプロンプトには「ASCII英数字以外が連続する部分はまとめてハイフン1個に」「小文字にする」「先頭・末尾のハイフンは除去」と仕様を明記し、テスト5本のうち3本をその縁に当てた。抜粋するとこうだ。

def test_numbers_and_symbols():
    assert slugify("Python 3.12 (beta)") == "python-3-12-beta"


def test_all_symbols():
    assert slugify("!!!") == ""

bench.py は ollama の /api/generate を temperature 0・seed 0 で叩き、返答から最初の ```python ブロックを抽出して保存し、pytest を回して合否を集計する。中核はこれだけだ。

def generate(model: str, prompt: str, timeout: int = 600):
    payload = {
        "model": model,
        "prompt": prompt,
        "stream": False,
        "options": {"temperature": 0, "seed": 0},
    }
    t0 = time.monotonic()
    resp = requests.post(OLLAMA_URL, json=payload, timeout=timeout)
    resp.raise_for_status()
    data = resp.json()
    wall_s = time.monotonic() - t0
    eval_count = data.get("eval_count") or 0
    eval_duration_ns = data.get("eval_duration") or 0
    tok_s = eval_count / (eval_duration_ns / 1e9) if eval_duration_ns else 0.0
    return data["response"], wall_s, tok_s

判定は pass@1 に固定した。温度0で1回生成し、pytest が全部緑なら pass、それ以外(テスト失敗・コードブロック抽出失敗・実行時エラー)はすべて fail。本番の測定前にモデルごとに1回ウォームアップ呼び出しを入れて、モデルロード時間が最初のタスクの生成時間に混ざらないようにしてある。

temperature 0・seed 0 に固定したのは、モデル間の比較を同じ条件で行うためで、実運用の体感(サンプリングあり・リトライあり)を再現するためではない。なお、同じ条件で再実行したときに毎回同じ出力が返ることまでは確かめていない。

実測結果——合格率と生成時間

結果は bench.py が出す表をそのまま貼る。gen_s は生成の壁時計秒、tok/s は ollama の応答メタデータから計算した生成速度、pytest 列は各タスクの内訳だ。fail でも「何本中何本落ちたか」まで残るので、あとから落ち方を追える。

まず qwen2.5-coder:1.5b。

$ python bench.py qwen2.5-coder:1.5b
## qwen2.5-coder:1.5b

| task | status | gen_s | tok/s | pytest |
|---|---|---|---|---|
| fix-chunks | pass | 1.9 | 74.3 | 4 passed in 0.70s |
| next-business-day | pass | 2.1 | 73.3 | 4 passed in 0.74s |
| parse-log | fail | 2.4 | 73.6 | 2 failed, 2 passed in 0.65s |
| rle | pass | 2.3 | 75.0 | 4 passed in 0.64s |
| slugify | fail | 1.8 | 74.0 | 4 failed, 1 passed in 0.72s |
| top-n-words | pass | 1.8 | 74.2 | 4 passed in 0.68s |

passed 4/6, total generation 12.3s
...
# exit=0 (19680 ms)

次に qwen2.5-coder:7b。

$ python bench.py qwen2.5-coder:7b
## qwen2.5-coder:7b

| task | status | gen_s | tok/s | pytest |
|---|---|---|---|---|
| fix-chunks | pass | 3.9 | 18.9 | 4 passed in 1.05s |
| next-business-day | pass | 4.5 | 19.5 | 4 passed in 0.83s |
| parse-log | pass | 9.0 | 19.7 | 4 passed in 0.70s |
| rle | fail | 7.7 | 17.3 | 3 failed, 1 passed in 0.81s |
| slugify | pass | 4.0 | 20.1 | 5 passed in 0.74s |
| top-n-words | pass | 5.5 | 19.6 | 4 passed in 0.92s |

passed 5/6, total generation 34.6s
...
# exit=0 (47223 ms)
3.9sfix-chunks4.5snext-business-day9sparse-log7.7srle4sslugify5.5stop-n-words
qwen2.5-coder:7b の生成時間(タスク別)

最後に deepseek-coder:1.3b。

$ python bench.py deepseek-coder:1.3b
## deepseek-coder:1.3b

| task | status | gen_s | tok/s | pytest |
|---|---|---|---|---|
| fix-chunks | fail | 6.7 | 87.6 | 1 error in 0.86s |
| next-business-day | fail | 7.8 | 87.0 | 4 failed in 0.72s |
| parse-log | fail | 587.2 | 69.8 | 1 error in 0.96s |
| rle | pass | 9.5 | 84.7 | 4 passed in 0.90s |
| slugify | extract-error | 0.9 | 0.0 | - |
| top-n-words | fail | 7.4 | 82.7 | 3 failed, 1 passed in 0.94s |

passed 1/6, total generation 619.5s
...
# exit=0 (634189 ms)

数字を並べ直すと、合格率は 7b が 5/6、1.5b が 4/6、deepseek-coder:1.3b が 1/6。生成速度はこのマシンで 1.5b が 74 tok/s 前後、7b が 19 tok/s 前後で、体感の差ははっきりある。6問合計の生成時間は 1.5b が 12.3秒、7b が 34.6秒。deepseek-coder は 619.5秒かかったが、その内訳は後述するようにほぼ1問の暴走だ。

この速度差は使い方を分ける数字だと思う。1.5b は6問すべて 2.4秒以内に返ってきて「打てば響く」感覚で回せる一方、7b は最長で 9.0秒(parse-log)かかる。チャットで待つ分には気にならないが、連打する使い方だと待ちを感じる長さだ。合格率と速度のどちらを取るかは、結局このあとの「何が落ちたか」を見ないと決められない。

見出しの数字だけなら「7bが最強」で終わりだが、落ちた中身を見るとそう単純ではなかった。7b は 1.5b が通した rle に落ちている。

落ちたコードをそのまま見る

1.5b の slugify から。生成されたコードはこれだ。

$ sed -n 1,40p solutions/qwen2.5-coder_1.5b/slugify/solution.py
import re

def slugify(s: str) -> str:
    # ASCII英数字以外の文字をハイフンに置き換える
    s = re.sub(r'[^A-Za-z0-9]', '-', s)
    
    # 結果の先頭・末尾にハイフンが残る場合は取り除く
    if s.startswith('-'):
        s = s[1:]
    if s.endswith('-'):
        s = s[:-1]
    
    return s
# exit=0 (5 ms)

仕様のうち「小文字にする」を実装しておらず、「連続する記号はまとめてハイフン1個」も1文字ずつの置換になっている。さらにトリムも startswith で1個ずつしか剥がさない。結果、5テスト中4つが落ちた。

$ python -m pytest -q --no-header -p no:cacheprovider test_solution.py
...
    def test_basic():
>       assert slugify("Hello, World!") == "hello-world"
E       AssertionError: assert 'Hello--World' == 'hello-world'
...
4 failed, 1 passed in 1.10s
# exit=1 (1337 ms)

プロンプトに明記した仕様の縁——畳み込み・小文字化・全記号入力——でまとめて落ちる。この「仕様を全部は拾わない」感じが、この6問で見えた 1.5b の落ち方だった(parse-log も同型で、4テスト中2つ落ちている)。

7b の rle はまったく違う落ち方をした。

$ sed -n 1,40p solutions/qwen2.5-coder_7b/rle/solution.py
def rle_encode(s: str) -> list[tuple[str, int]]:
    if not s:
        return []
    encoded = [(s[0], sum(1 for _ in g)) for _, g in groupby(s)]
    return encoded

from itertools import repeat

def rle_decode(pairs: list[tuple[str, int]]) -> str:
    decoded = ''.join(chain.from_iterable(repeat(k, v) for k, v in pairs))
    return decoded
# exit=0 (3 ms)

groupbychain を使っているのに、import したのは repeat だけ。しかも import 文が関数定義の途中に挟まっている。実行すると当然 NameError になる。

$ python -m pytest -q --no-header -p no:cacheprovider test_solution.py
...
FAILED test_solution.py::test_roundtrip - NameError: name 'groupby' is not de...
FAILED test_solution.py::test_encode - NameError: name 'groupby' is not defined
FAILED test_solution.py::test_decode - NameError: name 'chain' is not defined
3 failed, 1 passed in 0.63s
# exit=1 (801 ms)

ちなみに s[0] を使っている点も怪しい(ロジックとしては groupby のキー側を使うべき箇所だ)が、そこに到達する前に import で死んでいる。人間がレビューすればすぐ直る「惜しい」失敗だが、pass@1 の自動採点では不合格になる。逆に言えば、この採点方式は「そのまま貼って動くか」を測っていて、惜しさに部分点を出さない。補完用途なら import 忘れは人間が直せばいいので、ここでの不合格は実用上の不合格とイコールではない——ここは測り方の性格として押さえておく必要がある。

deepseek-coder:1.3b の暴走——1問に587秒

deepseek-coder:1.3b は対照として置いたのだが、合格率 1/6 より先に生成時間が問題だった。parse-log の1問に 587.2秒かかっている。何が起きたかは生成物を見ると分かる。

$ sh -c 'wc -l -c raw_response.txt; sed -n 1,12p raw_response.txt; echo ...; tail -n 4 raw_response.txt'
     557  111187 raw_response.txt
以下の Python 関数は、指定形式(`YYYY-MM-DD HH:MM:SS [LEVEL] module: message`)を満たすログ行を解析し返却時刻,等級,モジュール名称,メッセージの情報。
...
# exit=0 (16 ms)

応答は557行・111187バイト。冒頭から日本語と中国語が混ざった壊れた説明文で始まり、ログの後半を見ると同じ行をほぼそのまま繰り返し続けて途切れていた。bench.pynum_predict(生成トークン数の上限)を指定しておらず既定値のままなので、ハーネス側にこの暴走を早めに打ち切る仕組みがなかった。これは測られたモデルの問題であると同時に、ハーネスの設計不備でもある。小さい・古いモデルを混ぜるなら生成上限は必須だった。

slugify では逆に、0.9秒でこれだけ出力して止まった。

$ cat raw_response.txt
以下の Python 関数 ```python_codeblock``` 実装後ろに説明文(書かない必要ありま
# exit=0 (4 ms)

文の途中で切れていて、コードブロックが1つも無いので抽出段階で fail(extract-error)になる。なぜ0.9秒で生成が終わったのかは確かめていない。いずれにせよ、今回の6問と日本語プロンプトの組み合わせでは、deepseek-coder:1.3b は「遅い×落ちる」の二重苦で、対照としての役目——「qwenが特別なのか、1b級なら何でも書けるのか」の切り分け——だけを果たして退場した。

この測り方の使いどころと限界

6問・pass@1・温度0という設計は、モデルの序列を一般化するには狭すぎる。7b が rle に落ちたのを見て分かるとおり、1問の合否は import 忘れ1つで反転するので、4/6 と 5/6 の差を実力差と言い切ることはできない。この規模の測定で答えられるのは「自分の用途の門前払い判定」——つまり、自分が日常的に投げる形のタスクを最低ラインとして、それを通せないモデルを候補から外すことだ。私の場合、1.5b でも小物の純粋関数は 4/6 通る一方、仕様の縁を落とすので生成物のテストは省けない、という運用感覚が数字付きで手に入った。

ほかにも測っていないことがある。6問はすべて私が書いた日本語プロンプトなので、英語で指示した場合の成績は分からない。プロンプトの書き方を変えたときの合否の揺れも測っていない。

この構成が向かないケースもはっきりしている。大きなコードベースを読ませる補完・エージェント用途の比較はこの土俵では測れないし、実測対象を無料のローカルLLMに限ったので、比較記事の主役であるエージェント型ツールの優劣にはこの記事は何も言えない。逆に、測り直しは簡単にできる。tasks/ にディレクトリを1つ足して prompt.txttest_solution.py を書けば、python bench.py qwen2.5-coder:7b のように叩けば自分のタスクでの合格率が同じ形式の表になって出る。候補のモデルが増えたら引数を足すだけでいい。比較記事を読み比べる時間で、手元の答えが1つ増える。

関連書籍