ローカルLLM 6モデルに「祝日入りカレンダーGAS」を書かせたら全滅した — 唯一動いた1本も祝日が平成初期だった

前回の記事では、RTX 3090(24GB)でQwen系6モデルの速度と日本語品質を実測した。今回はその続編として、実務コーディング課題を出して、生成物を本物のGoogle Apps Script環境で実行検証してみた。

課題はこれだ。

Googleスプレッドシートに年間カレンダーを生成するGASを書け。土日祝に背景色、祝日は「何の日か」(祝日名)も表示、実行するだけで動くこと。

社内ツールでよくある、地味で実用的なタスク。ポイントは日本の祝日をどう取得するかを指定しなかったこと。ここに各モデルの「知識」と「設計判断」が出る。

結論を先に書くと:

  • 6モデル全滅。一発で正しいカレンダーを作れたモデルはゼロ
  • 実行時エラーなしで完走したのは qwen3-coder:30b ただ1本。ただし生成されたカレンダーの祝日は約30年前の祝日法だった
  • 正解ルート(Googleの祝日カレンダー参照)を選んだモデルはゼロ。全員が祝日をハードコードまたは自力計算して死んだ

実験環境

前回と同じ。RTX 3090(VRAM 24GB)+ Ollama 0.32.6、コンテキスト8192、temperature 0.3、思考モードオフ。プロンプトは全モデル共通で、生成は各1回のみ(リトライなし)。

結果一覧

モデル 生成時間 tok/s 実行 敗因
qwen3:8b 14.7秒 118.6 ✗ 即死 const変数に再代入でエラー。海の日を9月と主張
qwen3:14b 28.1秒 71.2 △ 崩壊 月ブロックの行間隔が足りず全月が重なる。祝日判定が「日にち」のみで全月に誤爆
qwen3.6:27b 81.1秒 38.7 ✗ 即死 sheet.mergeAcross() — Rangeのメソッドをsheetに呼んでTypeError
qwen3.6:35b 52.3秒 107.5 △ 崩壊 日付カウンタを週1回しか進めず、同じ数字が1行に7個並ぶ
qwen3-coder:30b 26.3秒 160.6 ◎ 完走 祝日データが平成初期(成人の日1/15、天皇誕生日12/23)
qwen3:32b 82.9秒 35.2 ✗ ハング 春分・秋分を天文計算で求めに行き、判定条件ミスで無限ループ

各モデルの死に様

qwen3:8b — スタート地点で転ぶ

const startRow = 1;
// ...
startRow += 30;  // TypeError: Assignment to constant variable

constへの再代入で1行も実行されずに死亡。祝日リストも4件だけで、なぜか海の日が9月10日にあった。

qwen3:14b — 全月が1月になる

祝日を holidays[day](日にちだけがキー)で判定するため、「1日」が祝日なら全部の月の1日が祝日になる。さらに月ブロックを6行間隔で配置するが、1つの月は8行必要なので、12ヶ月分が互いに上書きし合う。

qwen3.6:27b — 設計は一番賢く、APIで死ぬ

27bだけが「ユーザーが購読しているカレンダーから日本の祝日を探す」という正解に最も近いアプローチに到達した。第N月曜日の計算関数、春分・秋分の近似式まで自作していて、設計力は明らかに頭一つ抜けている。

だが実行すると:

sheet.mergeAcross(currentRow, 1, 7);  // TypeError: mergeAcrossはRangeのメソッド

存在しないAPI呼び出しで即死。フォールバックの祝日リストも建国記念の日が2/23(正解は2/11)だったので、動いていても間違っていた。

qwen3.6:35b — 自覚のある確信犯

35bの出力には驚いた。自分の祝日データに自分で注釈を付けている

'2-23': '建国記念の日(誤)', // 実際は11日
'1-13': '成人の日(仮)',    // 実際は第2月曜(14日)

「間違っていると分かっているデータ」をそのまま出荷してくるスタイル。しかも日付カウンタを週に1回しか進めないバグで、カレンダーは同じ数字が横一列に並ぶ抽象アートになった。

qwen3:32b — 天文学で無限ループ

一番野心的だったのは前回最下位の32b。春分・秋分の日を太陽黄経の天文計算(ユリウス日→太陽位置)で求めに行った。ロマンはある。

だが秋分の判定条件が「赤経0度付近」になっていた。秋分点の赤経は180度なので、条件は永遠に成立せず無限ループ。GASの6分タイムアウトで強制終了される運命のコードだ。ちなみに計算した振替休日を返り値に含め忘れるバグもあり、天文学が全部無駄になっていた。

唯一の生還者を実際に動かす

qwen3-coder:30bのコードだけは、構文・API・レイアウト計算のすべてが正しかった。生成速度も26秒(160 tok/s)で最速。文句なしの優勝……に見えた。

実際にApps Scriptで実行してみた(検証はChromeの自動操作で行い、コード投入→OAuth承認→実行→シート確認まで自動化した)。

実行の様子

結果がこれだ。

qwen3-coder:30bが生成したカレンダー

一見まともに見える。だがよく見ると成人の日が1月15日にある。ハッピーマンデー制度(2000年)以前の祝日法だ。天皇誕生日は2月に存在せず、平成の12月23日に置かれている。つまり、エラーなく動く唯一のコードは、約30年前の日本の祝日を出力する

これは前回記事で見た「billion→億」の誤変換と同根の問題だと思う。学習データには新旧の祝日法が混在していて、モデルはどれが現行法か区別できない。日本ローカルの制度知識は、ローカルLLMの一番の弱点だ。

正解はこう書く

祝日をハードコードした時点で負けだ。GASにはCalendarAppがあり、Googleが管理する日本の祝日カレンダーを直接参照できる。振替休日も国民の休日も、Google側の計算をそのまま使える。

const HOLIDAY_CALENDAR_ID = 'ja.japanese#[email protected]';

function fetchHolidays(year) {
  const cal = CalendarApp.getCalendarById(HOLIDAY_CALENDAR_ID);
  const events = cal.getEvents(new Date(year, 0, 1), new Date(year + 1, 0, 1));
  const map = {};
  const tz = Session.getScriptTimeZone();
  for (const ev of events) {
    const title = ev.getTitle();
    if (!HOLIDAY_NAME_PATTERN.test(title)) continue; // 後述
    map[Utilities.formatDate(ev.getStartTime(), tz, 'yyyy-MM-dd')] = title;
  }
  return map;
}

この方式で書いた参照実装を実行した結果がこちら。祝日18件(法定祝日+振替休日)が祝日名付きで正しく入る。

参照実装のカレンダー

ただし実際にやってみて、ハマりどころが2つあった。

  1. このカレンダーは「行事」も含む。節分、雛祭り、銀行休業日まで返ってくる(2026年は27件)。イベントのdescriptionは空で判別できないため、法定祝日名の正規表現ホワイトリストでフィルタする必要がある
  2. 祝日のみを返す ja.japanese.official#holiday@... というIDも存在するが、GASからは使えない。未購読だとgetCalendarByIdがnullを返し、subscribeToCalendarは "Action not allowed" で弾かれる

この2つはWeb上にもほぼ情報がなく、モデルが知らないのも無理はない。ただ、だからこそ「実行して確かめる」工程が省けない。

まとめ — ローカルLLMコーディングの現在地

  • 「それらしいコード」と「動くコード」の間には壁があり、「動くコード」と「正しいコード」の間にはもう1枚壁がある。6モデル中、1枚目の壁を越えたのは1本、2枚目を越えたのはゼロ
  • コーディング用途ならqwen3-coder:30bが頭一つ抜けている。速度3倍で唯一完走。ただし出力の事実性は別問題
  • 日本ローカル知識(祝日法、制度、慣習)はローカルLLMの鬼門。この領域はコードレビューではなく実行結果の検証で潰すしかない
  • LLMの生成コードは必ず実行して検証する。今回それを自動化した(Puppeteer + CDPでApps Scriptを操作)が、この仕組み自体も面白いテーマなので、また別の記事で書く

道具として見れば、160 tok/sで26秒でこの水準のコードが電気代だけで出てくるのは十分実用的だ。ただし「祝日」のような一見簡単で実は深いドメインでは、人間側が正解ルートを知っているかどうかで結果が決まる。ローカルLLMは、あなたの知らないことは、だいたい間違える。