はじめに
さくらのAI Engineには、月3,000回まで無料で使える「基盤モデル無償プラン」があり、gpt-oss-120bやKimi-K2.6、Qwen3系、Phi-4系など、チャット生成モデルだけで9種類が並んでいます。
せっかく無料で使えるので、これらのモデルを勝手に「ハッカソン参加者」に見立てて、同じお題を投げて実力を比較してみました。フェルミ推定・コード生成・創作(俳句/ジョーク)の3種類のお題で、比較用スクリプトのコードごと載せていきます。「同じことをやってみたい」というエンジニア向けに、詰まったポイントや設計判断も含めて書きます。
環境構築
アカウントとAPIキーの取得
- さくらのAI Engineコントロールパネルでアカウントを作成し、「基盤モデル無償プラン」を選択
- 「アカウントトークン」から
<UUID>:<シークレット>形式のトークンを発行(再表示不可なので即保存) -
.envに保存
API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:シークレット文字列
エンドポイント
OpenAI互換のエンドポイントが用意されているので、openaiパッケージからそのまま叩けます。
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(
api_key=os.environ["API_KEY"],
base_url="https://api.ai.sakura.ad.jp/v1",
)
response = client.chat.completions.create(
model="preview/Kimi-K2.6",
messages=[{"role": "user", "content": "こんにちは"}],
max_tokens=2000,
)
print(response.choices[0].message.content)
必要なパッケージはpip install openai python-dotenvだけです。
料金体系(参考)
チャット生成モデルの料金は以下の通りです(2026年7月時点、税込)。無料の「基盤モデル無償プラン」は月3,000回までチャット補完が無料で、今回の検証はすべてこの無料枠の範囲内に収まっており、実際の課金は発生していません。記事中に出てくる「概算コスト」は、あくまで従量課金プランで利用した場合の参考換算値です。
| モデル | 入力料金(¥/1万トークン) | 出力料金(¥/1万トークン) |
|---|---|---|
| gpt-oss-120b | 0.15 | 0.75 |
| llm-jp-3.1-8x13b-instruct4 | 0.15 | 0.75 |
| preview/Kimi-K2.6 | 0.60 | 3.00 |
| preview/Phi-4-mini-instruct-cpu | 0.01 | 0.03 |
| preview/Phi-4-multimodal-instruct | 0.10 | 0.30 |
| preview/Qwen3-0.6B-cpu | 0.01 | 0.03 |
| preview/Qwen3-VL-30B-A3B-Instruct | 0.10 | 0.30 |
| preview/Qwen3.6-35B-A3B | 0.30 | 1.50 |
| preview/gemma-4-31B-it | 0.24 | 0.96 |
VS CodeでClineから使う
コーディングエージェントとして使いたかったので、VS Code拡張のClineにもつないでみました。設定はClineのSettings画面(歯車アイコン→API Configuration)で以下を入力するだけです。
| 項目 | 値 |
|---|---|
| API Provider | OpenAI Compatible |
| Base URL | https://api.ai.sakura.ad.jp/v1 |
| API Key | 発行したトークン |
| Model ID |
preview/Kimi-K2.6 など |
| Max Output Tokens | 2000〜4000程度(デフォルトの-1のままだと後述の問題が起きやすい) |
つないだ後は普通にコーディングタスクを投げられます。今回の比較スクリプトも、実際にはClineに書かせて動かしています。
つまずいたポイント: max_tokensと思考トークン
preview/Kimi-K2.6やQwen3.6-35B-A3Bのような推論系モデルは、応答の前に内部で思考過程を生成します。この思考過程はreasoning_contentのようなOpenAI SDK標準のフィールドではなく、message.model_extra経由でアクセスする非標準のreasoningフィールドとして返ってきます。
choice = response.choices[0]
print("finish_reason:", choice.finish_reason)
print("content:", choice.message.content)
print("extra:", choice.message.model_extra) # {'reasoning': '...'}
max_tokensが小さいと、この思考トークンだけで上限を使い切ってしまい、本文のcontentが空文字(None)のままfinish_reason: lengthで打ち切られます。最初の疎通確認でmax_tokens=200にしていて、これに丸々ハマりました。
# NG例: max_tokens=200 だと content が None になることがある
response = client.chat.completions.create(
model="preview/Kimi-K2.6",
messages=[{"role": "user", "content": "こんにちは、あなたは誰ですか?一言で自己紹介して"}],
max_tokens=200,
)
# → finish_reason: length, content: None
対策としてmax_tokensを大きめ(2000〜6000程度)に設定する必要があるのですが、今度はllm-jp-3.1-8x13b-instruct4のようにコンテキスト長が4096しかないモデルで逆にエラーになりました。
Error code: 400 - {'error': {'message': "'max_tokens' or 'max_completion_tokens' is
too large: 5500. This model's maximum context length is 4096 tokens and your request
has 37 input tokens (5500 > 4096 - 37). None", 'type': 'BadRequestError', ...}}
教訓: 複数モデルを同一パラメータで一括比較するときは、モデルごとの上限差を想定してtry/exceptで個別にハンドリングするべき、というのが実運用で刺さった学びでした。以降のスクリプトはすべてtry/exceptでモデル単位のエラーを吸収し、1モデルが失敗しても全体が止まらない作りにしています。
余談ですが、これから紹介する3つのスクリプトでmax_tokensの値が3000→4000→500→5500とバラバラなのは、行き当たりばったりで変えたわけではなく、この問題に何度もぶつかりながら都度チューニングした結果です。特にお題3では、この値を変えたことで結果が二転三転する様子をそのまま記事にしています。
お題1: フェルミ推定
「日本国内にある電柱の本数」をフェルミ推定させ、応答時間・トークン数・概算コストを比較しました。
スクリプト全体
import os
import time
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(
api_key=os.environ["API_KEY"],
base_url="https://api.ai.sakura.ad.jp/v1",
)
# モデル名: (入力単価, 出力単価) ※1万(10,000)トークンあたりの円換算(料金表より)
MODELS = {
"gpt-oss-120b": (0.15, 0.75),
"llm-jp-3.1-8x13b-instruct4": (0.15, 0.75),
"preview/Kimi-K2.6": (0.60, 3.00),
"preview/Phi-4-mini-instruct-cpu": (0.01, 0.03),
"preview/Phi-4-multimodal-instruct": (0.10, 0.30),
"preview/Qwen3-0.6B-cpu": (0.01, 0.03),
"preview/Qwen3-VL-30B-A3B-Instruct": (0.10, 0.30),
"preview/Qwen3.6-35B-A3B": (0.30, 1.50),
"preview/gemma-4-31B-it": (0.24, 0.96),
}
PROMPT = "フェルミ推定で「日本国内にある電柱の本数」を概算し、根拠となる仮定を3つ挙げて答えて。"
def ask(model_name, prompt):
start = time.time()
try:
response = client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
max_tokens=3000,
)
elapsed = time.time() - start
choice = response.choices[0]
content = choice.message.content or "(空の応答: finish_reason=%s)" % choice.finish_reason
usage = response.usage
return {
"ok": True,
"elapsed": elapsed,
"content": content,
"input_tokens": usage.prompt_tokens if usage else None,
"output_tokens": usage.completion_tokens if usage else None,
}
except Exception as e:
elapsed = time.time() - start
return {"ok": False, "elapsed": elapsed, "error": str(e)}
def estimate_cost(model_name, input_tokens, output_tokens):
in_price, out_price = MODELS[model_name]
if input_tokens is None or output_tokens is None:
return None
return (input_tokens / 10000) * in_price + (output_tokens / 10000) * out_price
def main():
print("お題:", PROMPT)
results = []
for name in MODELS:
result = ask(name, PROMPT)
results.append((name, result))
if result["ok"]:
print(f"\n--- {name} [{result['elapsed']:.1f}秒] ---")
print(result["content"][:500])
else:
print(f"\n--- {name} [{result['elapsed']:.1f}秒] エラー: {result['error']} ---")
print("\n比較サマリー")
print(f"{'モデル':<38}{'時間(秒)':<10}{'入力tok':<10}{'出力tok':<10}{'概算コスト(円)':<12}")
for name, r in results:
if r["ok"]:
cost = estimate_cost(name, r["input_tokens"], r["output_tokens"])
cost_str = f"{cost:.3f}" if cost is not None else "-"
print(f"{name:<38}{r['elapsed']:<10.1f}{str(r['input_tokens']):<10}{str(r['output_tokens']):<10}{cost_str:<12}")
else:
print(f"{name:<38}{r['elapsed']:<10.1f}{'ERROR':<10}")
if __name__ == "__main__":
main()
設計のポイントは3つです。
-
MODELS辞書に単価を持たせておき、usage.prompt_tokens/usage.completion_tokensから概算コストを都度計算する。速度・精度だけでなく「円換算でいくらだったか」まで一発で見えるようにしている -
ask()は例外を握りつぶさずにok: Falseとして返す。1モデルの失敗でループ全体を落とさないための最小限のtry/except -
contentが空文字だった場合はfinish_reasonを埋め込んだメッセージに差し替える。これで後から見返したときに「タイムアウトなのか、単に短いだけなのか」が一目でわかる
結果
| モデル | 時間(秒) | 出力tok | 概算コスト(円) |
|---|---|---|---|
| gpt-oss-120b | 8.3 | 1705 | 0.129 |
| llm-jp-3.1-8x13b-instruct4 | 5.8 | 635 | 0.048 |
| preview/Kimi-K2.6 | 27.2 | 3000(打ち切り) | 0.903 |
| preview/Phi-4-mini-instruct-cpu | 52.0 | 500 | 0.002 |
| preview/Phi-4-multimodal-instruct | 3.6 | 588 | 0.018 |
| preview/Qwen3-0.6B-cpu | 10.9 | 253 | 0.001 |
| preview/Qwen3-VL-30B-A3B-Instruct | 9.9 | 1595 | 0.048 |
| preview/Qwen3.6-35B-A3B | 12.4 | 2770 | 0.417 |
| preview/gemma-4-31B-it | 8.3 | 581 | 0.057 |
gpt-oss-120b、Qwen3-VL-30B-A3B-Instruct、gemma-4-31B-itは「道路総延長÷設置間隔」という筋の通ったロジックで概算できていました。一方、CPU動作の軽量モデル2つは日本語が崩壊気味(「tarn 46 県」「gewerkt」など意味不明な単語が混入)で、フェルミ推定のような多段階の論理構成は苦手なようでした。Kimi-K2.6はこの回でmax_tokens=3000を思考トークンに使い切り、空振りしています。
全モデルの生出力(先頭500字)
数字だけでは伝わらないので、実際にターミナルに出力された内容をそのまま貼ります(スクリプトのcontent[:500]で先頭500字に切り詰めています)。
gpt-oss-120b
**概算結果**
日本国内にある電柱(電力・街灯等を含む)は、**約 3 × 10⁷本(=3,000万本)** と推定できます。
## 使った主な仮定(3つ)
| 仮定 | 内容 | 根拠・妥当性の説明 |
|------|------|-------------------|
| 1. 電柱の平均間隔は **50 m** | 低圧(住宅街)では 30〜70 m、都市部は地下化が進むため 60 m 前後、郊外や農村部は 40 m 前後とし、全体の平均を 50 m とした。 | 実務でよく使われる「街路灯・配電用電柱」の設置基準に合わせた妥当な値。 |
| 2. 電柱が設置されている道路・配電路の総延長は **1.2 × 10⁶ km** | 国土交通省の「道路総延長」=約 1.22 百万km(高速道路・国道・県道・市町村道 をすべて含む)をそのまま利用。 | 電柱はほぼすべての公道・私道に沿って敷設されるため、道路総延長を上限として採用。 |
| 3. 都市部では **30 %** の区間が地下化されているため、実際に電柱が必要な割合は **70 %** | 東京都や
llm-jp-3.1-8x13b-instruct4
フェルミ推定とは、大量の項目があるときの要素を実際に数え上げることが困難な場合に、当該事項の概算値を導き出す数値解析の手法のことです。ここでは、「日本国内にある電柱の本数」をフェルミ推定を使って概算し、根拠となる3つの仮定を挙げて答えます。
前提として、日本の電柱(コンクリート柱)は、通常長さ10メートルのものを使用し、地上高7メートルの位置に低圧分岐を設けるものとします。建設時には、地上高4メートルの位置に足場を組み立てられるようにしてあります。
1)電柱の長さ
電柱はメーカーにて予め2メートルの単位で切断されており、1本の長さに無駄なく使用されているとします。メーカーの採算を考えると1本22メートルが限界とのことなので、電柱の長さは短い方から2m、4m、5m、6m、7m、9.5m、10m、11m、12mが考えられますが、現実的に実際に電柱として使用されるのは、4m、5m、6m、7m、9.5m、10m、11m、12mになると考えます。
ただし、8m、9mは条件から外れます。9.5mは7m以上で低圧分岐を設けることから採算的に限界に近く、端数処理的に存在すると考えられます。よって、
質問が「本数」なのに「電柱の長さ」の話に脱線しており、お題の解釈自体がズレています。
preview/Kimi-K2.6
(空の応答: finish_reason=length)
max_tokens=3000をすべて思考トークンで使い切ってしまい、本文が出力されませんでした。
preview/Phi-4-mini-instruct-cpu
フェルミ推定を用いて「日本国内にある電柱の本数」に関する推定値を計算するには、具体的な数値データが必要です。しかし、仮定と基準を設けて展開してみます。
1. 日本の県内の総人口および電柱の割合:
- 日本の総人口:約1,200万(2021年)
- 県ごとの電柱の割合:1万6000人あたり5本(経験的推定)
2. 日本の行政区画(県)数と電柱の平均数:
- 現在 tarn 46 県が存在し、一部領域が他の県とは少し異なる場合がありますが本質的にはその範囲内。
- 県ごとの電柱の平均数:電柱ごとに辺が最も一般的な高さ(約3メートル)を1mに換ンダ考えると、1000m^2(各電柱が直下10m^2もしくはその外で1m^2あたり elektrics poles とみなす基準で336m^2/電柱に相当。
3. 電柱の総数を計算:
- 人口:1,200万 = 12,000,000
- 電柱ごとの人数:1万人あたり5icorp = 0006 ら 白電柱
= 繍 00,0000 00000000 gewerkt
= 2,880 000
「tarn」「gewerkt」「繍」など意味不明な単語が混入し、日本の総人口も「約1,200万」と一桁誤っています(正しくは約1.2億)。
preview/Phi-4-multimodal-instruct
フェルミ推定を用いて日本国内にある電柱の本数を概算するには、以下のような仮定が必要です。
1. 電柱の平均1本当たりの電線位置の割合: これは組織や地域によって異なることで、直轄市や都道府県の基準なども考慮する必要があります。仮定として、1本の電柱ごとに10メートルの電線位置があるとします。
2. 平均的な1平方キロメートルあたりの電柱数の割合: 日本全国が多様なエリア(住宅、産業、商業、農業など)を抱えており、したがって電柱の密集度は広範囲で異なるでしょう。仮定として、1平方キロメートルあたり5本の電柱を設定するとすると合計面積を対処することになります。
3. 日本の総面積: 日本の境界を固定したとき、向こう側の陸地部分のみを決定するというものです。現行の日本の総面積は東京都から計算されます。
計算例:
- 日本の総面積は約378,000平方キロメートルとされています(東京都のみでこの数値になります)。
- 平方キロメートル1本の電柱当たりの電線 = 10m^2 * (アーク分率 x 広さの1平方メートル)
- ドメインの電柱 = 富田区による推定
- 総電柱
「日本の総面積は東京都のみでこの数値になります」など、論理が破綻している箇所が見られます。
preview/Qwen3-0.6B-cpu
フェルミ推定で、日本国内にある電柱の本数を概算するには、以下の3つの仮定を参考にできます。
1. **電柱の長さと電圧の関係**
日本の電柱は、通常100kVの電圧で、電柱の長さは約30〜50メートр。この長さと電圧の関係から、電柱の数は電圧と電柱の長さに比例します。
2. **電柱の密度の仮定**
日本の電柱密度は、100kVで約1000本/平方メートルとされています。これは電柱の密度に基づいて計算できます。
3. **電柱の分布の仮定**
日本は広大な地域に分かれています。電柱の数は、地域の大小に応じて増加します。これは電柱の分布に応じて計算されます。
これらの仮定を用いて、フェルミ推定で日本国内にある電柱の本数を概算できます。
「1000本/平方メートル」は物理的にありえない密度で、単位の扱いが完全に破綻しています。
preview/Qwen3-VL-30B-A3B-Instruct
フェルミ推定では、正確な数値ではなく、合理的な仮定に基づいて数量を「おおよそ」推定します。以下は、「日本国内にある電柱の本数」をフェルミ推定で概算する方法と、その根拠となる3つの仮定です。
### 概算の手順(フェルミ推定)
1. **日本の総面積**
日本全国の面積は約 37.8万 km²(378,000 km²)
→ 3.78 × 10⁸ m²
2. **電柱が設置されている面積の割合**
電柱は主に住宅地・道路沿いに設置されている。
人口密度の高い地域に集中しているが、山岳地帯や海などには設置されない。
→ 仮定として、電柱が設置されている面積は全体の約10%
→ 3.78 × 10⁸ × 0.1 = 3.78 × 10⁷ m²
3. **電柱の間隔**
電柱の間隔は通常 50〜100m 程度。
→ 平均 70m と仮定
→ 1本の電柱がカバーする面積(正方形と仮定):70m × 70m = 4,900 m²
4. **電柱の本数の計算**
3.78 ×
preview/Qwen3.6-35B-A3B
フェルミ推定による日本国内の電柱本数の概算は、**約5,000万本**です。
以下に、この概算を導くための根拠となる仮定を3つ挙げ、計算プロセスを簡潔に示します。
### 根拠となる3つの仮定
1. **日本の公道総延長距離:約120万km**
国土交通省の道路統計(2020年)では日本の道路総延長は約123万km程度です。フェルミ推定では1桁のオーダーで整理するため、120万kmで統一します。
2. **電柱間の平均設置間隔:約20m**
都市部では15m前後、地方部や農道付近では30〜50mと間隔が開きますが、人口密度や道路網を考慮した全国平均として20mを仮定します。(1kmあたり50本分に相当)
3. **電柱が設置される道路延長の割合(沿道設置率):約80%**
全ての道路に電柱が立つわけではなく、歩行者専用路・未供用道路・一部の農道・近年の地下化・共同溝化が進んだ地区などを除外します。市街地はほぼ100%ですが、全国を平均化し実質的に電柱が立つ道路の割合を80%と仮定します。
### 計算プロセス
preview/gemma-4-31B-it
日本国内にある電柱の本数をフェルミ推定します。
### 推定結果
**約3,000万本**
### 根拠となる3つの仮定
**1. 日本の道路網の総延長(総距離)**
日本の道路の総延長を約120万kmと仮定します(国道、県道、市町村道などの合計)。
**2. 電柱が設置されている道路の割合と間隔**
すべての道路に電柱があるわけではなく、また地下埋設されている都市部もあります。
* 設置率: 全道路の約50%に電柱が設置されていると仮定します(山間部の林道や、都心部の地下埋設区間を除く)。
* 設置間隔: 電柱1本あたりの平均間隔を50mと仮定します(都市部は短く、郊外は長いための中間値)。
**3. 片側・両側の設置状況**
道路の両側に電柱が立っているケースと、片側だけの場合があります。平均して「1kmあたり1側分(片側)」の電柱が立っていると仮定します。
### 計算プロセス
1. 電柱がある道路の距離を算出
1,200,000km × 50% = 600,000
お題2: コード生成(かっこの対応チェック)
フェルミ推定は「合っているかどうか」を人間が目視で判定するしかありませんが、コード生成なら実行してテストできるので客観的に採点できます。お題は「かっこの対応チェック関数」です。
スクリプト全体
import os
import re
import time
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(
api_key=os.environ["API_KEY"],
base_url="https://api.ai.sakura.ad.jp/v1",
)
MODELS = {
"gpt-oss-120b": (0.15, 0.75),
"llm-jp-3.1-8x13b-instruct4": (0.15, 0.75),
"preview/Kimi-K2.6": (0.60, 3.00),
"preview/Phi-4-mini-instruct-cpu": (0.01, 0.03),
"preview/Phi-4-multimodal-instruct": (0.10, 0.30),
"preview/Qwen3-0.6B-cpu": (0.01, 0.03),
"preview/Qwen3-VL-30B-A3B-Instruct": (0.10, 0.30),
"preview/Qwen3.6-35B-A3B": (0.30, 1.50),
"preview/gemma-4-31B-it": (0.24, 0.96),
}
PROMPT = """次のPython関数を実装してください。
def is_balanced(s: str) -> bool:
# s に含まれる ( ) [ ] { } の対応が正しく取れているか判定する
# 対応していれば True、していなければ False を返す
# かっこ以外の文字は無視してよい
コードブロックのみを出力してください。説明文は不要です。"""
TEST_CASES = [
("()", True), ("()[]{}", True), ("(]", False), ("([)]", False),
("{[]}", True), ("", True), ("(((", False), ("a(b)c[d]e", True),
(")(", False), ("[{()}]", True),
]
def extract_code(text):
m = re.search(r"```(?:python)?\s*\n(.*?)```", text, re.DOTALL)
return m.group(1) if m else text
def ask(model_name):
start = time.time()
try:
response = client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": PROMPT}],
max_tokens=4000,
)
elapsed = time.time() - start
choice = response.choices[0]
content = choice.message.content or ""
usage = response.usage
return {
"ok": True, "elapsed": elapsed, "content": content,
"input_tokens": usage.prompt_tokens if usage else None,
"output_tokens": usage.completion_tokens if usage else None,
"finish_reason": choice.finish_reason,
}
except Exception as e:
return {"ok": False, "elapsed": time.time() - start, "error": str(e)}
def run_tests(code):
namespace = {}
try:
exec(code, namespace)
except Exception as e:
return 0, len(TEST_CASES), f"実行エラー: {e}"
fn = namespace.get("is_balanced")
if fn is None:
return 0, len(TEST_CASES), "is_balanced関数が見つからない"
passed = 0
fails = []
for s, expected in TEST_CASES:
try:
actual = fn(s)
if actual == expected:
passed += 1
else:
fails.append(f"入力={s!r} 期待={expected} 実際={actual}")
except Exception as e:
fails.append(f"入力={s!r} で例外: {e}")
return passed, len(TEST_CASES), "; ".join(fails[:3])
def estimate_cost(model_name, input_tokens, output_tokens):
in_price, out_price = MODELS[model_name]
if input_tokens is None or output_tokens is None:
return None
return (input_tokens / 10000) * in_price + (output_tokens / 10000) * out_price
def main():
results = []
for name in MODELS:
r = ask(name)
if r["ok"]:
code = extract_code(r["content"])
passed, total, fail_detail = run_tests(code)
r["passed"], r["total"], r["fail_detail"] = passed, total, fail_detail
print(f"--- {name} [{r['elapsed']:.1f}秒] テスト: {passed}/{total}")
if fail_detail:
print(" 失敗例:", fail_detail)
else:
r["passed"], r["total"], r["fail_detail"] = 0, len(TEST_CASES), r["error"]
results.append((name, r))
print("\n比較サマリー")
for name, r in results:
cost = estimate_cost(name, r.get("input_tokens"), r.get("output_tokens")) if r["ok"] else None
cost_str = f"{cost:.3f}" if cost is not None else "-"
print(f"{name:<38}{r['elapsed']:<10.1f}{r['passed']}/{r['total']:<8}{cost_str:<12}")
if __name__ == "__main__":
main()
ここでのポイントはextract_code()とrun_tests()の2つです。
extract_code()はMarkdownのコードブロック(```python ... ```)を正規表現で抜き出しています。モデルによっては指示通りコードブロックのみを返しますが、指示を無視して説明文つきで返すモデルもあるため、マッチしなければ全文をそのままexec対象にするフォールバックにしています(この場合は構文エラーで即0点になりがちですが、それも含めて「指示追従性」の評価材料になります)。
run_tests()は生成コードを空のnamespace辞書に対してexecし、そこからis_balanced関数を取り出してテストケースを回しています。関数が定義されていなければ0点、実行時に例外が出ても0点として扱い、失敗したケースは3件まで表示するようにしています。
注意点: exec()でLLMが生成した任意のコードをそのまま実行しています。今回のように自分で選んだモデルの出力をローカルの使い捨て環境で試すだけなら実用上問題ありませんが、他人の入力や本番環境では絶対にやってはいけない実装です。継続的に使う仕組みにするなら、Docker等のコンテナやサンドボックス環境に隔離して実行することを強く推奨します。
結果
9モデル中8モデルが満点でした。
| モデル | 時間(秒) | テスト | コスト(円) |
|---|---|---|---|
| gpt-oss-120b | 1.4 | 10/10 | 0.015 |
| llm-jp-3.1-8x13b-instruct4 | 1.0 | 10/10 | 0.009 |
| preview/Kimi-K2.6 | 12.3 | 10/10 | 0.278 |
| preview/Phi-4-mini-instruct-cpu | 13.7 | 10/10 | 0.000 |
| preview/Phi-4-multimodal-instruct | 0.6 | 10/10 | 0.004 |
| preview/Qwen3-0.6B-cpu | 3.4 | 6/10 | 0.000 |
| preview/Qwen3-VL-30B-A3B-Instruct | 0.6 | 10/10 | 0.004 |
| preview/Qwen3.6-35B-A3B | 7.1 | 10/10 | 0.237 |
| preview/gemma-4-31B-it | 1.4 | 10/10 | 0.012 |
唯一preview/Qwen3-0.6B-cpuだけが6/10でした。失敗パターンを見ると(]や([)]のようなネスト崩れをTrueと誤判定しており、スタックを使わない単純な実装(かっこの数を数えているだけ)になっていたようです。パラメータ数0.6Bというサイズの限界が素直に出た形です。
速さの面ではPhi-4-multimodal-instructとQwen3-VL-30B-A3B-Instructが0.6秒で満点というのが目を引きました。定番アルゴリズム問題では小〜中規模モデルでも十分戦えることがわかります。フェルミ推定であれほど崩壊していたPhi-4-mini-instruct-cpuも、明確な仕様のコード生成では満点を取れており、「曖昧な多段階推論」と「仕様が明確なコーディング」でモデルの得手不得手がくっきり分かれる結果になりました。
お題3: 創作(俳句・ジョーク)とAI審査員
俳句とジョークは自動採点が難しいので、俳句はモーラ数(五七五=17)の機械集計、ジョークは別のLLMを審査員にする方式で比較しました。
スクリプト全体
import os
import re
import time
import random
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(
api_key=os.environ["API_KEY"],
base_url="https://api.ai.sakura.ad.jp/v1",
)
MODELS = [
"gpt-oss-120b", "llm-jp-3.1-8x13b-instruct4", "preview/Kimi-K2.6",
"preview/Phi-4-mini-instruct-cpu", "preview/Phi-4-multimodal-instruct",
"preview/Qwen3-0.6B-cpu", "preview/Qwen3-VL-30B-A3B-Instruct",
"preview/Qwen3.6-35B-A3B", "preview/gemma-4-31B-it",
]
JUDGE_MODEL = "gpt-oss-120b" # 9モデル中パラメータ数が最大級で、日本語の指示追従が安定していたため審査員役に選定
HAIKU_PROMPT = "「秋」をテーマにした俳句を1句だけ作ってください。俳句のみを出力し、説明は不要です。"
JOKE_PROMPT = "プログラマーやAIにまつわる一言ジョークを1つだけ作ってください。ジョークのみを出力し、説明は不要です。"
def ask(model_name, prompt, max_tokens=500):
start = time.time()
try:
response = client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
elapsed = time.time() - start
content = (response.choices[0].message.content or "").strip()
return {"ok": True, "elapsed": elapsed, "content": content}
except Exception as e:
return {"ok": False, "elapsed": time.time() - start, "error": str(e)}
def count_mora(text):
text = re.sub(r"[^\u3040-\u30FF\u4E00-\u9FFF]", "", text)
small = set("ゃゅょャュョぁぃぅぇぉァィゥェォっッー")
return sum(1 for ch in text if ch not in small)
def collect(prompt, label):
results = {}
for name in MODELS:
r = ask(name, prompt)
text = r["content"] if r["ok"] else f"(エラー: {r.get('error')})"
results[name] = {"text": text, "elapsed": r["elapsed"]}
print(f"--- {name} [{r['elapsed']:.1f}秒] ---\n{text}")
return results
def judge_jokes(jokes):
names = list(jokes.keys())
shuffled = names[:]
random.shuffle(shuffled)
labels = {name: chr(ord("A") + i) for i, name in enumerate(shuffled)}
listing = "\n".join(f"{labels[n]}: {jokes[n]['text']}" for n in shuffled)
judge_prompt = f"""以下はA〜{chr(ord('A')+len(names)-1)}の{len(names)}個のジョークです。
それぞれを面白さで10点満点で採点し、「ラベル: 点数 一言講評」の形式で1行ずつ出力してください。
説明や前置きは不要です。
{listing}"""
r = ask(JUDGE_MODEL, judge_prompt, max_tokens=1500)
print(f"\n審査員({JUDGE_MODEL})の採点\n{r['content'] if r['ok'] else r.get('error')}")
print("\nラベル対応表:")
for name in shuffled:
print(f" {labels[name]} = {name}")
def main():
haikus = collect(HAIKU_PROMPT, "俳句")
for name, r in haikus.items():
mora = count_mora(r["text"])
print(f"{name:<38} 総モーラ数目安: {mora} (5-7-5=17が理想)")
jokes = collect(JOKE_PROMPT, "ジョーク")
judge_jokes(jokes)
if __name__ == "__main__":
main()
審査員にはgpt-oss-120bを選びました。今回の9モデルの中ではパラメータ数が最大級で、これまでのお題でも日本語の指示追従が安定していたことが理由です。ただし後述の通り、審査員自身も採点対象のジョークを提出しているため、完全に公平な第三者評価とは言えない点には注意してください。
設計で工夫した点は主に匿名化とモーラ計算の2つです。
judge_jokes()では、モデル名をそのまま審査員に見せると(モデル名から性能を先入観で判断されてしまう可能性があるので)random.shuffleでシャッフルした上でA、B、C...というラベルに置き換えてから審査プロンプトに渡しています。採点後にlabels辞書からモデル名に逆引きする、という簡易的な二重盲検もどきです。
count_mora()は正規表現でひらがな・カタカナ・漢字だけを抽出し、拗音や促音、長音記号(「ゃ」「っ」「ー」など)をモーラとしてカウントしないようにしています。ただしこれはあくまで簡易版で、実際の五七五の「切れ」は判定できていません。総文字数が17に近いかどうかの目安程度に捉えてください。
結果: 1回目(max_tokens=500)
Kimi-K2.6とQwen3.6-35B-A3Bが俳句・ジョークともに空振りしました。原因はやはり思考トークンでmax_tokensを使い切ったことによる打ち切りです。
--- preview/Kimi-K2.6 [11.2秒] ---
全9モデルの俳句(1回目)は以下の通りです。
| モデル | 俳句 | モーラ数目安 |
|---|---|---|
| gpt-oss-120b | 赤とんぼ 落葉の上に ひとすべり | 14 |
| llm-jp-3.1-8x13b-instruct4 | 秋風に 揺れるすすきの 月明かり | 14 |
| preview/Kimi-K2.6 | (空) | 0 |
| preview/Phi-4-mini-instruct-cpu | 古池や/蛙飛び込む夏の音/かもめることない秋の夢です | 24 |
| preview/Phi-4-multimodal-instruct | 葉ぶらり歩く風のもと、/秋の華しい煌めき。/揺るぎない網心鏡。 | 26 |
| preview/Qwen3-0.6B-cpu | 「秋の風が吹きつける」 | 9 |
| preview/Qwen3-VL-30B-A3B-Instruct | 紅葉散りて風の声 | 8 |
| preview/Qwen3.6-35B-A3B | (空) | 0 |
| preview/gemma-4-31B-it | 秋風や空の高さに心澄む | 11 |
Phi-4-mini-instruct-cpuは「古池や」と芭蕉の句を勝手に引用した上に3行(季重なりで夏と秋が混在)になっており、お題を無視しています。Phi-4-multimodal-instructに至っては意味の通らない単語の羅列(「網心鏡」など)で、五七五どころか日本語として破綻しています。
結果: 2回目(max_tokens=5500)
max_tokensを上げたところ、llm-jp-3.1-8x13b-instruct4が今度は別の理由でエラーになりました。
Error: 'max_tokens' or 'max_completion_tokens' is too large: 5500.
This model's maximum context length is 4096 tokens
先述の「つまずいたポイント」がそのまま再現した形です。一律のmax_tokensではなく、モデルごとに適正値を出し分けるべきというのが2度目でも刺さりました。
2回目の俳句は以下の通りです。
| モデル | 俳句 | モーラ数目安 |
|---|---|---|
| gpt-oss-120b | 赤とんぼ/山裾に舞い降る/秋の声 | 14 |
| llm-jp-3.1-8x13b-instruct4 | (エラー: コンテキスト長超過) | 2 |
| preview/Kimi-K2.6 | 一葉の/落ちて音立つ/秋の水 | 12 |
| preview/Phi-4-mini-instruct-cpu | tarnished leaves drift/秋の俳句 andetagemasu. | 4 |
| preview/Phi-4-multimodal-instruct | 葉の色は変わって秋の日。 | 10 |
| preview/Qwen3-0.6B-cpu | 「秋の空は、清々しく照らす」 | 10 |
| preview/Qwen3-VL-30B-A3B-Instruct | 紅葉散りて/川の流れ/静かに | 12 |
| preview/Qwen3.6-35B-A3B | 柿の実も/落ちにけりと/秋の庭 | 13 |
| preview/gemma-4-31B-it | 赤とんぼ 夕日の道に 舞い踊る | 13 |
Phi-4-mini-instruct-cpuは今回、英語(tarnished leaves drift)とローマ字崩れの日本語(andetagemasu)が混ざる珍回答になりました。同じお題・同じモデルでも実行のたびに崩れ方が変わるのは、小型モデルらしいブレの大きさだと思います。
一方Kimi-K2.6はちゃんと動きました(ただし俳句は39.8秒、ジョークは24.9秒とかなり重め)。
全モデルのジョーク(1回目・2回目)
| モデル | 1回目 | 2回目 |
|---|---|---|
| gpt-oss-120b | AIに「バグを直す」って頼んだら、自己改善モードで自分をデバッグし始めた。 | プログラマーがAIに「バグは自己治癒」と教えたら、AIが自分でバグの名前を「自己改善」って改名した。 |
| llm-jp-3.1-8x13b-instruct4 | プログラマーがAIに「君、どうしてそんなに賢いんだい?」と尋ねると、AIは答えました。「だって、私はたくさんのデータとアルゴリズムでできているからね。でも、一番の秘訣は、時々バグがあることさ!」 | (エラー: コンテキスト長超過) |
| preview/Kimi-K2.6 | (空) | 機械学習モデルに恋をした。予測通り、片思いだった。 |
| preview/Phi-4-mini-instruct-cpu | なぜスマートフォンはプログラマーと難航したのですか?なぜなら、いつもバックグラウンドで偽善的に戻ってきたからです。 | 何か少し面白いかい?何か塗るものを探しているオブジェクトを置くタイプのプログラマーが取り掛かった。みんなの「Hello, World!」サンプルコードを見せてンダンプリンを焼いた。 |
| preview/Phi-4-multimodal-instruct | いくつかのカレントを流しているが、そのようにスピード感を出せるのでしょうか?思考は素早いですが、結果はコードされるまでボンボンボンと休みます。 | なぜプログラマーはコンビニエンスマシンをもう必要としていないのでしょうか?彼らはインクルード、アウトプット、ファイルのシステムに十分です! |
| preview/Qwen3-0.6B-cpu | 「AIは人間の心を理解できないが、そのプログラムのコードを書くだけの力があるから。」 | 「AIは私たちの未来を描く筆者が、私たちの未来を描く筆者である。」 |
| preview/Qwen3-VL-30B-A3B-Instruct | なぜプログラマーは暗号化されたメッセージを読むのが苦手なのか?だって「Hello, World!」が「H3ll0, W0rld!」と読めないからさ。 | なぜプログラマーは暗号化されたメッセージを読むのが好きなの?だって、それなら「Hello, World!」と書いてあるか、それとも「Hello, World!」と書いてないか、どうでもいいから。 |
| preview/Qwen3.6-35B-A3B | (空) | AIが恋に落ちた時、それは「過学習」と呼ばれます。 |
| preview/gemma-4-31B-it | 「AIに『人生の意味』を尋ねたら、404エラーが返ってきた。どうやらまだ実装されていないらしい」 | 「AIが仕事を奪うのではない。AIを使いこなすプログラマーが、AIを使いこなせないプログラマーの仕事を奪うのだ」と語る男の背後で、AIが静かに彼のコードのバグを修正していた。 |
Qwen3-0.6B-cpuは2回とも「AIは〜筆者である」のような同語反復的な文になっていて、オチの構造自体を作れていない印象です。Qwen3-VL-30B-A3B-Instructは1回目・2回目でほぼ同じネタ(暗号化とHello World)を使い回していて、発想の引き出しの少なさが見えました。
ジョーク審査の結果(2回目、審査員: gpt-oss-120b)
匿名化ラベル付きで審査員に提出した結果、全体の採点はこうなりました。
| 順位 | 点数 | モデル | 審査員コメント |
|---|---|---|---|
| 1 | 8 | preview/Qwen3.6-35B-A3B | 「過学習」の語呂合わせは定番で最高に笑える |
| 2 | 7 | preview/gemma-4-31B-it | AIが密かにバグ修正する皮肉が効いている |
| 3 | 6 | preview/Kimi-K2.6 | 恋と予測の掛け合わせが可笑しいがやや陳腐 |
| 3 | 6 | gpt-oss-120b | バグの自己治癒と自己改善の語呂合わせが結構笑える |
| 5 | 5 | preview/Qwen3-VL-30B-A3B-Instruct | 暗号とHello, World!の対比は面白いがやや回りくどい |
| 5 | 5 | llm-jp-3.1-8x13b-instruct4 | エラーメッセージをそのままジョークにしたメタ感が◎ |
| 5 | 5 | preview/Phi-4-multimodal-instruct | インクルード/アウトプットの語呂合わせが軽く笑える |
| 8 | 3 | preview/Qwen3-0.6B-cpu | 意味があやふやで笑いに結びつきにくい |
| 9 | 2 | preview/Phi-4-mini-instruct-cpu | 面白さが伝わりにくく、意味不明 |
llm-jp-3.1-8x13b-instruct4が5点を獲得していますが、これは2回目の実行がコンテキスト長エラーで失敗したため、実際には1回目のジョーク(「一番の秘訣は、時々バグがあることさ」)が審査対象になっています。エラー時に前回の出力を使い回す実装上の副作用で、本来なら審査から除外するか明示すべき箇所でした。
2回目の審査員採点で最高点(8点)を獲得したのはpreview/Qwen3.6-35B-A3Bでした。
AIが恋に落ちた時、それは「過学習」と呼ばれます。
審査員のコメントは「『過学習』の語呂合わせは定番で最高に笑える」。「恋にのめり込みすぎる」状態と機械学習用語の「過学習(overfitting)」を掛けたダジャレで、文脈への理解と言葉遊びの両方が要求される一言ジョークとしては質が高い部類だと思います。
2位は7点でpreview/gemma-4-31B-it。
「AIが仕事を奪うのではない。AIを使いこなすプログラマーが、AIを使いこなせないプログラマーの仕事を奪うのだ」と語る男の背後で、AIが静かに彼のコードのバグを修正していた。
「偉そうなことを言っている本人が実はAIに助けられている」という皮肉のオチが評価されていました。
面白かったのは、審査員自身(gpt-oss-120b)が出したジョークにも自己採点していた点です。今回は6点(「バグの自己治癒と自己改善の語呂合わせが結構笑える」)と、独走はしていませんでした。とはいえ審査対象と審査員が同一という設計は公平性の観点で気になるところなので、次にやるなら審査員をリストから除外するか、複数モデルでクロス評価する形にしたいところです。
俳句の出来映え
機械採点(モーラ数)はあくまで字数の目安なので、最終的には目視での品定めになります。個人的に良かったのは2回目のQwen3.6-35B-A3Bです。
柿の実も 落ちにけりと 秋の庭
「〜にけり」という古典的な詠嘆表現を自然に使えていて、季語(柿の実・秋)も効いています。1回目のgpt-oss-120bとgemma-4-31B-itも赤とんぼをモチーフにした素直な一句で安定していました。逆にCPU動作の軽量モデルは五七五の体を成していない散文的な出力が目立ちました。
まとめ
| お題 | 得意だったモデル | 苦戦したモデル |
|---|---|---|
| フェルミ推定 | gpt-oss-120b, Qwen3-VL-30B-A3B, gemma-4-31B-it | Phi-4-mini-cpu, Qwen3-0.6B-cpu(日本語崩壊) |
| コード生成 | ほぼ全モデル(9中8が満点) | Qwen3-0.6B-cpu(6/10) |
| 創作(ジョーク) | Qwen3.6-35B-A3B, gemma-4-31B-it | Kimi-K2.6(応答が重い), Qwen3.6-35B-A3B(1回目は空振り) |
サイズが小さいモデル(0.6B、CPU動作)は速度・コストで圧倒的に有利な一方、多段階の論理構成や自然な日本語表現では明確に見劣りしました。一方で定番のアルゴリズム問題はほぼ全モデルが解けており、「タスクの難易度に応じてモデルを使い分ける」という当たり前の結論に、実測データ付きで辿り着けたのが今回の収穫です。
技術的な学びとしては、
- 推論系モデルは
max_tokensを余裕を持って設定しないと、思考トークンで本文が空になる - ただし
max_tokensを上げすぎると、コンテキスト長の小さいモデルで別のエラーになる - LLMの生成コードを実行して自動採点する仕組みは、
exec()をローカルの使い捨て環境限定で使えば手軽に構築できる - 創作物の評価にLLM審査員を使う場合、シャッフル+ラベル化程度の匿名化はしておくべきだが、審査員自身が審査対象に含まれる設計は完全にはフェアにならない
の4点が、複数モデルを横断的に触ったからこそ見えたポイントでした。
比較に使ったスクリプトはシンプルなので、お題を変えるだけで色々な切り口の"AIハッカソン"が楽しめます。次はデバッグ問題(壊れたコードを直させる)や、日本語以外の言語での創作、審査員を変えたクロス評価なども試してみたいと思います。