実験日: 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 |

重要なのは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_sumとtop_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した根拠も評価対象にします。
