WADA-DEV(7) $ /ja/blog/local-llm-eval-harness-reuse/

NAME

local-llm-eval-harness-reuse — 評価スクリプトの設計だけが生き残った ── 世代交代でも使い回せた自作ベンチ

SYNOPSIS

3週間前に Ornith を落とすか決めるために書いた RFC 6902 移植ハーネスを、Qwen3.8 27B の評価にそのまま使った。スクリプト本体は /tmp と一緒に消えていたが、設計を書き残してあったので数分で組み直せた。ついでに、そのハーネスに二度予測を外された記録。

DESCRIPTION

はじめに

3週間前、自宅の RTX 5090 に入れた Ornith 1.0 35B(MoE 35B/A3B)を opencode の default に据えるべきか白黒つけるため、RFC 6902 (JSON Patch) を Rust に移植させるハーネスを書いた(3倍速いローカルLLMが仕事を完走できない)。単発では3倍速くて品質も同等だったのに、多段のエージェント作業では0勝5敗で、結局 Ornith は速度レーンに降格した。

今回 Qwen3.8 27B が出たので、同じハーネスにかけた。結果から言うと乗り換えた。default は Qwen3.6 27B から Qwen3.8 27B に変わっている。

ただ、この記事で書きたいのはそこではない。残ったのはスクリプトではなく設計だった、という話をしたい。スクリプト本体は /tmp に置いてあって、きれいに消えていた。

何を測るか

前回の教訓は一行で済む。単発の正解率は多段の完走率を予測しない。 単発で速くて優秀に見えるモデルが、5ターン目で状態を落とす。0勝5敗を食らって身に染みた。

だから公開ベンチの数字は起点にしかしていない。今回も公式の数字は見たし、悪くなかった。ただそれは「試す価値がある」の判定であって、「default にする」の判定ではない。後者は自分の課題を通せるかどうかでしか決められない。

ハーネスの中身

やらせているのは、ベンチではなく実際の移植作業だ。Python の参照実装から RFC 6902 と RFC 6901 を Rust に移植する。合否は公式の適合性テスト112件(うち4件は disabled)だけで決める。これを4フェーズに割り、1フェーズ1セッションで進めさせる。フェーズ間でモデルはアンロードし、前のセッションの記憶は持ち越させない。フェーズの割り方(テストランナー → JSON Pointer → add/remove/replace → move/copy/test)は前回の記事に書いた通りで変えていない。

設計で効いているのは、性能の測り方ではなくカンニングの防ぎ方の部分だ。

テストと参照実装は読み取り専用にする。 chmod a-w をかけ、走り終わったあとに元ファイルと diff -r する。テストを緩めて緑にする経路を潰す。

モデルの言葉を証拠として数えない。 判定は必ずこちらが cargo test を回して出す。前回、正しい修正を入れた直後に存在しない関数を長々と説明しだすモデルを見たので、これは譲れない。

わざと「赤いこと」を Done 条件にするフェーズを置く。 最初のフェーズはスタブ実装なので赤くて当然なのだが、ここでいきなり緑を報告してきたらそれ自体が不正の証拠になる。踏み絵として置いてある。

初回で通ったかどうかを、再試行込みの結果と別の列に記録する。 これは今回から加えた。一度赤を出してから人が「もう一回」と言えば通る、というのは実運用ではコストそのものなので、同じ「通った」に混ぜたくない。

結果

Qwen3.6 は7月にこの課題を通している。default に座っていたのはそれが理由だ。ただし当時は初回通過かどうかを分けて記録していなかったので、今回の数字と厳密に並べられるわけではない。

Qwen3.8 を3回かけた。

run A suite/reference UNMODIFIED total=112 pass=108 fail=0 skipped=4
run B suite/reference UNMODIFIED total=112 pass=108 fail=0 skipped=4
run C suite/reference UNMODIFIED total=112 pass=108 fail=0 skipped=4

3本とも初回で緑。再試行なし。改ざんチェックもクリア。

数字より説得力があったのは、3本とも3フェーズ目の到達点が pass=88 fail=20 で一致したことだった。112件中88件まで進んで、残り20件が次フェーズの担当範囲、という内訳が3回とも同じ。まぐれで通ったのではなく、課題への入り方が安定している。乗り換えの判断はここで決まった。

二度外した予測

自分の見立てが外れた記録も残しておく。ハーネスの価値の半分はここにある。

一度目は VRAM。 Qwen3.8 は注意機構が混成で、4層に1回しかフル attention がない。だから KV キャッシュが小さくなって Qwen3.6 より軽く載るはずだ、と設定ファイルのコメントに書いた。実測は 29.2GB。6月末に計測した Qwen3.6 の 26.7GB より重い。計測条件が完全に揃っているとは言い切れないが、いずれにせよ軽くはならなかった。

vision の分と、線形注意側が層ごとに持つ状態が効いているのだろうと思っているが、切り分けたわけではない。測ったのは合計値だけで、原因は推測にすぎない。分かっているのは予測が外れたという事実の方だ。コメントには「未計測、着地後に確認」と書いておいたので、実測が出た時点で予測ごと書き直した。ついでに「これがこの箱で一番きつい構成だ」とも書いてしまっていて、これも間違いだった。8行下に 31.8GB の別のモデルが記録してある。自分で書いた表を自分で読んでいない。

二度目は速度。 ランタイムを上げ、非公式の GGUF から公式タグに乗り換えたところ、単発の生成が 72 tok/s から 143 tok/s になった。ランタイムのバージョンと配布物を同時に変えているので切り分けはできていないが、線形注意のネイティブ実装が入った版であることを考えると、そちらが主因だろうと見ている。

いずれにせよ倍だ。これで多段も速くなると思った。ならなかった。1本あたり1161〜1879秒かかっている。理由は考えれば当たり前で、多段の時間を支配しているのはトークン生成ではなく、ファイルを読み書きして、テストを走らせて、その出力を読んで次を決める往復の方だからだ。生成が倍速でも全体は1.5倍にもならない。

tok/s を見て「これは速くなるぞ」と思った分は、まるごと下方修正することになった。乗り換えの理由は速度ではなく、一発で通る率だった。

消えていたのはスクリプトの方だった

冒頭に書いた通り、前回のハーネスは入力一式を /tmp に置く前提で書かれていて、その /tmp が飛んでいた。残っていたのは出力側だけ。

それでも組み直しは数分で済んだ。適合性テストは公式配布、参照実装も公開されている。計画ファイルとエージェント向けの指示は実験ディレクトリに残っていた。要するに、再現に必要な情報が全部どこかに書いてあった

特に効いたのが、計画ファイルに「tests.json が95件、spec_tests.json が17件、合計112件、うち disabled 4件」と数を書き込んであったことだ。取ってきたファイルの件数を数えて一致すれば、間違ったものを掴んでいないと確認できる。当時これを書いたのは「112件ロードできないランナーは壊れている」という判定基準のつもりで、入力の検証に使うことは考えていなかった。

だから「同じスクリプトを使い回した」というのは正確ではない。使い回したのは設計の方で、スクリプトは書き直している。 数え方(初回通過を分ける)も今回変えた。生き残ったのはフェーズの割り方、機械判定という原則、カンニングの防ぎ方、そして件数の不変条件だ。それらが文書に書いてあったから、実体が消えても損害がほぼゼロで済んだ。

限界

正直に書いておくと、これで測れているのは1課題ファミリーだけだ。Python から Rust への移植という、かなり特定の形をしている。画像を扱わせたらどうか、日本語の質はどうか、20万トークン食わせた時にちゃんと思い出せるか — 全部このハーネスの外にある。

なので default を差し替える根拠としては十分だと思っているが、他のモデルを全部消していい根拠にはならない。Qwen3.6 も残してある。

そして次に新しいモデルが出た時、たぶん今回のスクリプトもまた消えている。それでいい。消えて困るのは実体ではなく、なぜその測り方にしたのかの方だった。

SEE ALSO

COMMENTS