ローカルLLMのPythonコードは動く?4B・8B・12Bをpytest 30回で実測

実験日: 2026-08-24
環境: RTX 5070 Ti 16GB / WSL2 / Ollama 0.32.15 / Python 3.13 / pytest 8.4.1

LLMが返したコードは、見た目が自然でも正しいとは限りません。そこで10個のPython関数をGemma 3 4B、Qwen 3 8B、Gemma 3 12Bへ実装させ、固定したpytestを別processで実行しました。失敗時はtest結果を1回だけLLMへ返し、自己修正できるかも測ります。

コードブロックが自然に見えても、実行してtestを通すまでは採用できません。

初回の合格は4Bが7/10、8Bと12Bが9/10でした。1回の修正後は8Bだけ10/10へ改善し、4Bは7/10、12Bは9/10のままです。「error logを返せば直る」とも「parameterが多ければ必ず勝つ」とも言えない結果になりました。

pytestを通した結果

model 初回合格 1回修正後 修正成功/試行 安全検査reject
Gemma 3 4B Q4 7/10 7/10 0/3 0
Qwen 3 8B Q4 9/10 10/10 1/1 1
Gemma 3 12B Q4 9/10 9/10 0/1 0
ローカルLLM 4B・8B・12Bが生成したPythonコードをpytestで評価した結果
ローカルLLM 4B・8B・12Bが生成したPythonコードをpytestで評価した結果

重要なのはmodel rankingより、評価方法です。

  • promptを固定する
  • testcaseを生成後に都合よく変えない
  • syntaxをparseし、実行前に危険な構造を弾く
  • testをtimeout付きの別processで動かす
  • 初回と修正後を混ぜずに記録する
  • raw code、pytest出力、条件を保存する

この形なら「3問試して全部動いた」というdemoから、再現可能な小規模experimentへ一歩進めます。

なぜコード評価に人間の見た目だけでは足りないか

例えば次の関数は、一見それらしくintervalをmergeします。

def merge_intervals(intervals):
    intervals.sort()
    merged = []
    for interval in intervals:
        if not merged or merged[-1][1] < interval[0]:
            merged.append(interval)
        else:
            merged[-1][1] = max(merged[-1][1], interval[1])
    return merged

しかし仕様が「入力を変更しない」なら不合格です。sort()はlistの順序を変え、mergedに元の内側listを入れてendを書き換えるため、入力dataまで破壊します。

実際に4Bと12Bはこの落とし穴で失敗しました。返り値だけ確認するtestなら合格していたはずです。softwareのcorrectnessは出力値だけでなく、side effect、例外、境界値を含む契約で決まります。

10問は何を測ったか

単なる同型問題にならないよう、10種類を固定しました。

task 主な確認点
palindrome 正規化、空文字、大小文字
two sum 2引数、index、同一elementの再利用禁止
run-length encoding tuple形式、case保持、空入力
interval merge sort、接触区間、入力非破壊
balanced brackets stack、3種類、他文字を無視
matrix rotation 長方形、1行・1列、入力非破壊
common prefix 空list、空文字、case-sensitive
top-k frequency 頻度tieを数値昇順で解決
moving average 浮動小数、invalid windowで例外
grid shortest path BFS、blocked、到達不能、start=goal

各taskは4~6個のassertを持ちます。ただし10問は統計的に小さく、一般的なcoding能力を代表するbenchmarkではありません。今回の目的は、自分の研究用taskへ持ち込める評価pipelineを示すことです。

modelと生成条件を固定する

比較したmodelです。

gemma3:4b-it-q4_K_M
qwen3:8b
gemma3:12b

共通条件です。

temperature 0
seed 42
think false
num_ctx 4096
num_predict 512
repair attempts 1
pytest timeout 10 s

Ollamaの/api/generateへ同じsystem instructionとtask specificationを送りました。temperature 0でもbackendやmodel versionが変われば完全再現を保証できないため、model tag、Ollama version、raw responseも残します。

system instructionの要点です。

Return only one Python code block.
Define exactly the requested function.
Use only Python built-ins; no imports, I/O, network, subprocess,
dynamic execution, global state, or top-level executable statements.

「安全なcodeを」と曖昧に頼むのではなく、実行環境で許す範囲を列挙しました。

生成codeをいきなり実行しない

LLMのcodeはuntrusted inputとして扱いました。最初にMarkdown fenceを除き、Pythonのast.parseでsyntax treeへ変換します。

今回の簡易gateは次を拒否します。

import / import from
class定義
global / nonlocal
指定外のfunction
top-levelの実行文
eval, exec, open, compile, __import__, input
globals, locals, vars, getattr, setattr, delattr
dunder attribute access

top-levelには、指定された関数定義と定数docstringだけを許しました。

tree = ast.parse(code)

for node in ast.walk(tree):
    if isinstance(node, (ast.Import, ast.ImportFrom, ast.ClassDef)):
        raise ValueError(type(node).__name__)

これは完全なsandboxではありません。Pythonの静的AST検査だけで、CPU消費、memory消費、巧妙なobject traversalを完全には防げません。今回は自分のoffline PC、networkを使わない小関数、10秒timeoutという限定条件です。他人のcodeをmulti-tenant serverで動かす防壁にはなりません。

pytestは別process・別directoryで動かす

安全検査を通ったcodeとtestだけをtask別directoryへ置きます。

generated_code/
  qwen3_8b/
    shortest_path_grid/
      candidate.py
      candidate_first.py
      candidate_repaired.py
      test_candidate.py

実行commandです。

python -m pytest -q --disable-warnings --maxfail=1

Pythonからはsubprocess.run(..., timeout=10)で起動します。pytestの標準的な起動方法に沿いつつ、1 failureで止めることで修正feedbackを短くしました。

processを分ける理由は、main evaluatorへmodule cacheや例外状態を持ち込まないためです。ただしOS-level isolationではないため、信頼できない第三者codeならcontainer、VM、resource limit、network namespace等が別途必要です。

初回結果:4B 7問、8Bと12Bは9問

task別の初回結果です。

task 4B 8B 12B
palindrome pass pass pass
two sum fail pass pass
RLE pass pass pass
interval merge fail pass fail
brackets pass pass pass
matrix rotation pass pass pass
common prefix pass pass pass
top-k fail pass pass
moving average pass pass pass
grid shortest path pass reject pass

8Bのgridだけはalgorithmの誤りではなく、from collections import dequeを含んだため実行前にrejectしました。標準libraryでも、今回は「built-insのみ」という実験契約違反です。

4Bは仕様を「実装し直して」しまった

4Bのtwo_sumtop_k_frequentは、要求された2引数関数ではなく1引数関数を生成しました。

TypeError: two_sum() takes 1 positional argument but 2 were given
TypeError: top_k_frequent() takes 1 positional argument but 2 were given

自然言語仕様には関数signatureを明記していました。それでもmodelは独自interfaceを作ったため、function名だけでなく引数をtestから実際に呼ぶ必要があります。

これは研究codeで特に危険です。modelが「改善」のつもりでAPIを変え、呼び出し側と噛み合わなくても、code単体は整って見えるからです。

4Bと12Bはinput mutationを見落とした

merge_intervalsでは返り値のassertは通過し、その次で落ちました。

x = [[1,3],[2,6],[8,10],[10,12],[15,18]]
before = [row[:] for row in x]

assert merge_intervals(x) == [[1,6],[8,12],[15,18]]
assert x == before

pytestの差分は先頭が[1,3]から[1,6]へ変わったことを示しました。仕様の「do not mutate the input」は飾りではなく、test可能なpostconditionです。

修正例は、sort前とmerge時に新しいlistを作ることです。

ordered = sorted([row[:] for row in intervals])

1回のerror feedbackで直ったのは1件だけ

初回不合格には、元仕様とpytestのstdout/stderrを返しました。追加の自由な会話はせず、修正codeだけを要求します。

The previous implementation failed validation.
Return a complete corrected implementation.

Original specification: ...
Validation feedback: ...

Qwen 3 8Bはdeque importを削除し、built-in listをqueueとして使うBFSへ直して合格しました。

一方、4Bの3件と12Bの1件は、同じwrong signatureやinput mutationを再生成して不合格でした。error logを与えただけでは、modelがfailureの根本原因を理解して修正するとは限りません。

productionなら、repairを無制限に繰り返すのではなく上限を設け、人間reviewへ送るべきです。今回の1回制限は、model間でretry budgetを揃える意味もあります。

「安全検査で落ちた」と「testで落ちた」を分ける

8Bの初回はcodeを実行していません。これをpytest failureと同一視すると、algorithm accuracyとpolicy complianceが混ざります。

結果JSONでは別のfieldに保存しました。

{
  "first_safe": false,
  "first_safety_message": "Blocked AST node: ImportFrom",
  "first_test": {
    "passed": false,
    "stderr": "Blocked AST node: ImportFrom"
  },
  "final_passed": true
}

研究評価では少なくともsyntax failure、policy rejection、test assertion failure、timeout、infrastructure errorを分離すると、改善すべき場所が分かります。

この実験から言えないこと

10問、各model 1 seedなので、8Bが一般に12Bよりcoding能力が高いとは言えません。model familyも違います。HumanEvalやMBPPの公式scoreとも比較できません。

testcaseも完全ではありません。合格は「この公開testを通った」であり、全入力に正しい証明ではありません。LLMがtest内容をpromptで見てrepairしているため、未知testへのgeneralizationも別に測る必要があります。

より強い評価にするなら次を追加します。

  • hidden testをrepair promptから隠す
  • property-based testingで入力を生成する
  • task数とseed数を増やしconfidence intervalを出す
  • mutation testingで弱いtestを検出する
  • runtimeとmemory使用量も採点する
  • human-written baselineと比較する

再現手順

repository内のscriptはtask、test、prompt、AST gate、repair、保存まで含みます。

cd /mnt/c/Users/user/Documents/Codex/2026-08-24/ko
/opt/ai-lab/venv/bin/python -u work/ai_lab/experiment_llm_codegen_pytest.py
/opt/ai-lab/venv/bin/python work/ai_lab/plot_llm_codegen_pytest.py

主なartifactです。

work/ai_lab/experiment_llm_codegen_pytest.py
work/ai_lab/results/llm_codegen_pytest.json
work/ai_lab/generated_code/
work/ai_lab/plot_llm_codegen_pytest.py
work/figures/llm-codegen-pytest.png

JSONには30 generationすべてについて、元response、抽出code、安全検査結果、pytest stdout/stderr、wall time、修正responseを保存しました。成功数だけでなく失敗例を残すと、後でtestやpromptを改善できます。

研究室で使うなら

LLM生成codeを研究へ組み込む最小workflowは次です。

仕様を書く
  ↓
LLMが候補を生成
  ↓
静的検査
  ↓
隔離環境で自動test
  ↓ fail
有限回だけ修正 → 再test
  ↓ pass
人間がalgorithm・data leakage・計算量をreview
  ↓
version管理して実験へ採用

pytestが通っても、研究仮説、dataset split、統計手法、licenseが正しいとは限りません。LLMは実装候補を速く出す道具であり、実験設計の責任者ではありません。

今回もっとも大きな差はparameter数ではなく、「見た目で採用せずtestへ接続したこと」です。4Bの返り値が正しそうなmutationも、8Bの禁止importも、機械的なgateがあれば公開前に発見できます。

文書検索へ接続する場合は、回答だけでなくretrievalした根拠も評価対象にします。

関連記事