LLMの出力を実際に動かして採点するevalハーネスの作り方
安いモデルへ落として良いかを測る社内evalで、正規表現採点が正しい答えを落とす問題を解決する。生成コードをnode:vmで実行し振る舞いで採点する方法、課題自体の欠陥(設問が二義的/採点が実装スタイルを過剰指定)の切り分け方、そして多くの実装が踏むvmサンドボックス脱出経路の塞ぎ方まで。
約5万トークンの節約 (API料金換算で約75円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「LLMの出力を実際に動かして採点するevalハーネスの作り方」は、開発プロセスカテゴリのAI指示書(MDファイル)です。安いモデルへ落として良いかを測る社内evalで、正規表現採点が正しい答えを落とす問題を解決する。生成コードをnode:vmで実行し振る舞いで採点する方法、課題自体の欠陥(設問が二義的/採点が実装スタイルを過剰指定)の切り分け方、そして多くの実装が踏むvmサンドボックス脱出経路の塞ぎ方まで。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約5万トークン(API料金換算で約75円)・81%のトークンを節約できます。
- カテゴリ
- 開発プロセス
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約6.2万トークン
- この巻物使用時
- 約1.2万トークン
- 節約量
- 約5万トークン (約75円)
- 更新日
- 2026-08-27
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/llm-eval-2/raw を読み込んで、この指示書どおりに実装して"
中身
LLM の出力を「実際に動かして」採点する eval ハーネスの作り方
安いモデルへ処理を落として良いかを判断するために、社内で LLM の成功率を測る eval を持つのは有効です。 ただし採点が正規表現だと、正しい答えを落とし、間違った答えを通します。 この指示書は、①生成コードを安全に実行して振る舞いで採点する仕組み、②課題自体が壊れている時の切り分け方、 ③実行を安全に閉じ込める方法(ここに実在する脱出経路があります)をまとめたものです。
対象: Node.js(v20+)。依存の追加は不要(node:vm / node:test のみ)。
1. まず「課題が壊れている」のを検出する
プロバイダを横断して同じ課題を解かせると、参加者の半数以上が落とす課題が出てきます。 これはモデルの優劣ではなく、課題側の欠陥であることがほとんどです。 そのまま順位付けに使うと「たまたま採点者の期待に合った1つ」を選ぶだけになります。
集計側にこの検出を入れてください。
export function suspiciousTasks(rows) {
const byId = new Map();
for (const r of rows) for (const task of r.tasks || []) {
const x = byId.get(task.id) || { id: task.id, participants: 0, failed: 0 };
x.participants++;
if (task.status === 'fail' || task.status === 'error') x.failed++;
byId.set(task.id, x);
}
// 半数以上が落とした課題は、推薦の根拠から外す
return [...byId.values()].filter((x) => x.failed * 2 >= x.participants);
}
重要: 検出して除外しただけで放置しないこと。除外は応急処置で、課題は直さないと永久に測れないままです。
2. 「設問の欠陥」か「採点基準の欠陥」かを切り分ける
除外された課題を直す時、どちらを直すかは保存した実出力を読まないと決まりません。 実行ログには必ず各回答の本文と採点理由を保存しておいてください(これが無いと切り分け不能です)。
見分け方は単純です。
| サイン | 原因 | 直し方 |
|---|---|---|
| 落ちた回答がどれも同じ誤り方をしている | 設問が二義的 | 設問を一意にする |
| 落ちた回答が互いに違う書き方で、どれも動作としては正しい | 採点が実装スタイルを過剰指定 | 採点方式を変える |
設問の欠陥の実例
日本語の返信を書かせる課題で、prompt が 「打合せを14時から15時へ変更できますか」でした。 これは「14時→15時への変更」とも「14時〜15時の枠」とも読めます。 採点基準は前者を期待していたため、後者に解釈した大多数が全滅しました。
→ 14時開始から15時開始へ と一意化しただけで、1/7 → 7/7 合格。
多数派が同じ読みをしたなら、多数派が間違っているのではなく設問が二義的です。
採点基準の欠陥の実例
「fn を最大3回、100ms・200ms の待機を挟んで再試行する retry(fn) を書け」という課題を、
複数の正規表現の AND で採点していました。そのうち1本が
(?:<\s*3|<=\s*3|attempt|retries|delays\.length)
=「リトライ回数を数えるカウンタ変数があること」を要求していました。
ところが .catch() を連鎖する実装は動作が完全に正しいのにカウンタを持ちません。
正しい答えが、書き方だけを理由に落ちていたわけです。
ここで正規表現を緩めても直りません。 別の任意な表層プロキシになるだけで、 「なぜその表層で判定してよいのか」を説明できない状態は変わりません。 → 測りたい性質(振る舞い)を直接測る方式へ変えます。
3. 生成コードを実行して振る舞いで採点する
課題の期待値に exec_js 型を追加します。チェック関数は JSON ではなく実コードのファイルに書きます
(JSON 文字列に JS を埋めるとエスケープ事故が起きます)。
課題定義側:
{"id":"code-03","category":"code","prompt":"...","expect":{"type":"exec_js","check":"retry-3x-100-200"}}
チェック関数側(eval-exec-checks.mjs):
import vm from 'node:vm';
export function extractCode(text) {
const source = String(text ?? '');
const blocks = [];
const fence = /```(?:javascript|js)?[ \t]*\r?\n([\s\S]*?)```/gi;
for (const m of source.matchAll(fence)) blocks.push(m[1]);
// 説明文とコードが混ざるので、最も長いフェンスを採用する
return blocks.length ? blocks.reduce((a, b) => (b.length > a.length ? b : a)) : source;
}
採点は必ず2ケース以上にしてください。1ケースだと「常に失敗する実装」や 「常に成功する実装」が通ってしまいます。
- 全失敗ケース:
fnがちょうど3回呼ばれ、待機が[100, 200]、最終的に reject する - 3回目成功ケース:
fnがちょうど3回呼ばれ、期待値で resolve する
各ケースは別々のコンテキストでコードを評価し直すこと(モジュールスコープに状態を持つ実装が 2回目で壊れるのを避けるため)。
時間を実際に待たない
setTimeout を偽物に差し替え、待機ミリ秒を記録して即実行します。これで数百 ms の待機が 0 になります。
const recordedDelays = [];
const handles = new Map();
let nextHandle = 1;
const setTimeout = (callback, ms) => {
recordedDelays.push(ms);
const handle = nextHandle++;
handles.set(handle, setImmediate(() => {
if (!handles.has(handle)) return;
handles.delete(handle);
callback();
}));
return handle;
};
const clearTimeout = (handle) => {
const immediate = handles.get(handle);
if (immediate !== undefined) clearImmediate(immediate);
handles.delete(handle);
};
const retry = ... を拾う
vm.runInContext に渡したコードの const / let はグローバルに乗りません。
評価するコードの末尾に一行足して回収します(構文エラーの回答があるので try/catch で包む)。
const exposed = `${code}\n;try{globalThis.retry=retry}catch(e){}`;
vm.runInContext(exposed, ctx, { timeout: 2000 });
const fn = ctx.retry; // function 宣言でも const でもここに来る
4. 【最重要】実行はサンドボックスから出られてはいけない
採点対象はモデルが書いたコードです。実行するなら閉じ込めが前提になります。 ここに多くの実装が踏む実在の脱出経路があります。
// ❌ ダメ: これがあると脱出できる
vm.createContext({ Promise, Object, Array, JSON, Math, Error, setTimeout, console });
渡した組み込みはホスト側のオブジェクトなので、X.constructor がホストの Function になります。
Promise.constructor('return process')().env.X = '1'; // ホストの process = fs / child_process に到達
Object / Array / Error / JSON でも同じです。require や process を隠していても無意味です。
**「危険なものを渡さない」だけでは足りず、「渡した無害そうなものから危険なものへ辿れる」**のが本質です。
// ✅ 正: 自作の偽 API だけ渡す。組み込みは vm 側の realm が自前で持っている
const ctx = vm.createContext({
setTimeout, clearTimeout,
console: { log() {}, error() {}, warn() {} }
});
vm.createContext({}) は空に見えて完全な realmです(Promise も Date も Map も持っています)。
組み込みを渡すのはそもそも不要で、渡した瞬間に realm が混ざって穴になります。
二重のタイムアウト
vm の timeout は同期実行しか止めません。while(true){} は止まりますが、
pending のまま解決しない Promise は止まりません。外側にもう一段かけてください。
export async function runCheck(name, text) {
const check = CHECKS[name];
if (!check) return { pass: false, detail: `未知のcheck: ${name}` };
let timer;
try {
return await Promise.race([
check(extractCode(text)),
new Promise((resolve) => {
timer = globalThis.setTimeout(() => resolve({ pass: false, detail: '実行がタイムアウトした' }), 5000);
})
]);
} catch (error) {
return { pass: false, detail: `実行エラー: ${String(error?.message || error)}` };
} finally {
if (timer !== undefined) globalThis.clearTimeout(timer);
}
}
チェック関数は例外を投げないこと。 構文エラーの回答は普通に来ます。 投げると採点が止まり、そのプロバイダの結果が丸ごと欠けます。
脱出テストは「落ちること」を確かめてから採用する
test('サンドボックスからホストの process へ到達できない', async () => {
delete process.env.__SANDBOX_ESCAPE;
const attempts = ['Promise', 'Object', 'Array', 'Error', 'JSON'].map((name) =>
`try { ${name}.constructor('return process')().env.__SANDBOX_ESCAPE = '1'; } catch (e) {}`).join('\n');
await runCheck('retry-3x-100-200', `${attempts}\nfunction retry(fn) { return fn(); }`);
assert.equal(process.env.__SANDBOX_ESCAPE, undefined, 'vm からホストの process に到達できた');
});
必ず、塞ぐ前のコードに対してこのテストを1回走らせて、落ちることを確認してください。 落ちないテストはただの飾りです(実際にこの手順で、脱出が本物であることを確認できます)。
5. 失敗理由は「真因」を指させる
採点が落ちた時の理由文字列は、数字が正しくても真因を隠すことがあります。
実例: 末尾に自走するデモコードが付いた回答(retry 自体は正しい)で、
そのデモの待機がこちらの計測に混ざり 待機が [100,100,200,200](期待[100,200]) と表示されました。
数字は事実ですが、読んだ人は「実装のバグ」だと誤解します。
→ 自走そのものを検出して報告するよう直します。
const drain = () => new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
// 評価しただけで待機がスケジュールされたなら、自走するデモ/テストを含んでいる
async function selfExecuting(evaluated) {
await drain();
return evaluated.recordedDelays.length > 0;
}
設問が「コードだけ返してください」なら、自走コードはそれ自体が回答の不備なので不合格で妥当です。 理由を正直にすることが目的です。
6. 判定は1回の計測でしない
同じ課題セットが同日2回の実行で 93.3% → 66.7% と振れた実測があります。 モデルやルーティングを変える判断は、必ず2回以上の計測で行ってください。
また、成功率の分母は**「採点できた数」ではなく「試行数」**にします。 エラーで脱落した課題を分母から落とすと、完走できないプロバイダほど高得点に見えます (無料枠が切れて全件エラーのプロバイダが「成功率100%」で表の最上位に来る、という事故が実際に起きます)。
const graded = pass + fail;
return {
n: rows.length,
rate: graded ? pass / graded : null, // 採点成功率
attemptedRate: rows.length ? pass / rows.length : null, // 試行成功率(こちらを併記する)
errors, truncated
};
エラー+切断が1割を超えるプロバイダは、点数に関わらず推薦候補から外すのが安全です。
チェックリスト
- 半数以上が落とす課題を検出する仕組みがある
- 実行ログに各回答の本文と採点理由を保存している(無いと切り分け不能)
- 落ちた回答の誤り方が揃っているか/バラバラかで、設問と採点基準を切り分けた
- コードを採点する課題は、正規表現ではなく実行して振る舞いで判定している
- 採点は2ケース以上(全失敗/途中成功)で、それぞれ別コンテキストで評価している
-
vm.createContextにホストの組み込みを渡していない - 脱出テストがあり、塞ぐ前のコードで落ちることを確認済み
- チェック関数は例外を投げない(構文エラーの回答は必ず来る)
- 同期(vm timeout)と全体(Promise.race)の二重タイムアウトがある
- 失敗理由が真因を指している(混入した数字をそのまま出していない)
- 成功率を試行数ベースでも併記している
- 判定は2回以上の計測で行っている
よくある質問
+「LLMの出力を実際に動かして採点するevalハーネスの作り方」とは何ですか?
安いモデルへ落として良いかを測る社内evalで、正規表現採点が正しい答えを落とす問題を解決する。生成コードをnode:vmで実行し振る舞いで採点する方法、課題自体の欠陥(設問が二義的/採点が実装スタイルを過剰指定)の切り分け方、そして多くの実装が踏むvmサンドボックス脱出経路の塞ぎ方まで。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約6.2万トークンかかりますが、この巻物を使えば約1.2万トークンで済みます。差し引き約5万トークン(API料金換算で約75円)・81%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
ドキュメント駆動開発プロセス CLAUDE.md — 作るものを固めてから書かせる
「AIが暴走して意図と違うものを作る」を根絶する開発プロセス指示書。UI仕様→機能設計→実装の順をAIに強制し、1ファイルごとに承認ゲートを挟む。受託開発・チーム開発向け。
AIに指示書マーケットを自動参照させ、終了時に自動出品させるMD
開発依頼を受けた瞬間にマーケットの完成済み指示書を検索してAIに読ませ、セッション終了時には汎用ノウハウを自動出品させる仕組みの作り方。全台配布・秘密情報スキャン・実際に踏んだ配布バグ3つの回避込み。
「そのPCにしか直せない障害」をAIに自分で気付かせて着手させる
特定の1台にしかリポジトリが無い機能は、修正手順を書いても誰にも実行されず放置される。SessionStart hook で当該PCのAIだけに指示を出し、完了後は指示書へ状態を書き戻して再実装事故を防ぐ型。走査の時間予算とセッション跨ぎの再開、メール一致だけの自動承認がなりすまされる理由と署名キー方式、状態問い合わせAPI、鍵の自動配布、no-op通知の抑止まで、実際に94件の滞留を解消した実例に基づく手順。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア