0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コンピュータとオセロ対戦58 ~LLMでもっと強くなる方法を考えよう~

0
Posted at

前回の記事

今回の目標

前回,LoRAを用いてLLMでオセロAIを作る試みをしました.
しかし,全く学習が進まずに失敗しました.
今回はその原因を突き止めて,この方針には無理があるのか,それともまだ改善の余地があるのかについて考えてみます.

ここから本題

実際の出力を確かめてみよう

まずは,前回のオセロAIが実際にどのような出力をしていたのかを確かめてみましょう.
出力を要約すると,正解は一つも出せず,不正解と無効手が半分ずつでした.
しかし実際の出力は確かめていません.
形式が違うために無効手だったのか,あるいは形式は正しいが選んだ手が間違いだったのか.
それを確かめるためにも実際の回答をファイル出力して見てみます.
最初の50件だけ出してみます.

test_inference.py
test_inference.py
"""学習データの先頭 N 件を使って LLM の出力を確認するテストスクリプト."""
import json
import re
import urllib.request
from datetime import datetime

import pandas as pd
from tqdm import tqdm

DATA_PATH   = "data/train.jsonl"
OLLAMA_URL  = "http://localhost:11434/api/chat"
MODEL_NAME  = "qwen3.5-osero"
N_SAMPLES   = 50


def call_ollama(user_content: str) -> str:
    """Ollama に問い合わせてレスポンステキストを返す."""
    body = json.dumps({
        "model": MODEL_NAME,
        "stream": False,
        "messages": [{"role": "user", "content": user_content}],
    }).encode("utf-8")
    req = urllib.request.Request(
        OLLAMA_URL,
        data=body,
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        data = json.loads(resp.read().decode("utf-8"))
    return data["message"]["content"]


def extract_move(text: str) -> str | None:
    """</think> 以降から最初の座標(a-h + 1-8)を抽出する."""
    think_end = text.find("</think>")
    area = text[think_end + 8:].strip() if think_end >= 0 else text.strip()
    m = re.search(r"[a-h][1-8]", area)
    return m.group() if m else None


def main():
    correct = 0
    wrong   = 0
    invalid = 0
    error   = 0
    rows    = []

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    out_path  = f"outputs/test_inference_{timestamp}.csv"

    with open(DATA_PATH, encoding="utf-8") as f:
        lines = [f.readline() for _ in range(N_SAMPLES)]

    for i, line in enumerate(tqdm(lines, desc="推論中")):
        if not line.strip():
            continue

        sample = json.loads(line)
        convs  = sample["conversations"]
        user_content      = convs[0]["content"]
        assistant_content = convs[1]["content"]
        expected_move     = extract_move(assistant_content)

        try:
            response = call_ollama(user_content)
            llm_move = extract_move(response)
        except Exception as e:
            print(f"[{i+1:4d}] エラー: {e}")
            error += 1
            rows.append({
                "入力": user_content, "期待する出力": assistant_content,
                "実際の出力": str(e), "期待する出目": expected_move, "実際の出目": None,
            })
            continue

        if llm_move is None:
            invalid += 1
        elif llm_move == expected_move:
            correct += 1
        else:
            wrong += 1

        rows.append({
            "入力": user_content, "期待する出力": assistant_content,
            "実際の出力": response, "期待する出目": expected_move, "実際の出目": llm_move,
        })

    pd.DataFrame(rows).to_csv(out_path, index=False, encoding="utf-8")

    total = correct + wrong + invalid + error
    print("\n=== 結果 ===")
    print(f"総数  : {total}")
    print(f"正解  : {correct} ({correct/total*100:.1f}%)")
    print(f"不正解: {wrong}   ({wrong/total*100:.1f}%)")
    print(f"無効手: {invalid} ({invalid/total*100:.1f}%)")
    print(f"エラー: {error}   ({error/total*100:.1f}%)")
    print(f"CSV   : {out_path}")


if __name__ == "__main__":
    main()

まず標準出力がこちら.

=== 結果 ===
総数  : 50
正解  : 0 (0.0%)
不正解: 29   (58.0%)
無効手: 21 (42.0%)
エラー: 0   (0.0%)
CSV   : outputs/test_inference_20260426_100440.csv

1000件で試した時よりも不正解の数が多く,無効手が少なくなっています.
では実際の出力を見てみましょう.
最初の十個を見るとこんな感じ.

0                         , e7, f7, d8
1                                  NaN
2                             , d7, f7
3                                  NaN
4                                  NaN
5                                 , e8
6                                  NaN
7                                  NaN
8                             , g7, c8
9                         , h6, e7, g7

期待する出力は,まず思考過程が<think>タグに囲まれて出力し,その閉じタグの後に改行して選択した手が表示される,というものでした.
しかし実際には,置ける手をカンマつなぎで羅列するような出力.<think>タグすら出力されていません.
期待していた出力形式には程遠いですね.

原因として考えられるのはどれでしょうか.

  • データ不足
  • 学習不足
  • 与えたデータが複雑すぎる
  • そもそもモデル性能的にこのくらいしか出せない

Claudeに聞いてみたところ,以下の考察が挙げられました.

  • ModelfileTEMPLATE指定がない.これがないと,学習した出力パターンを再現できない
  • Qwen3系にあるネイティブのシンキング機能と衝突している
  • 量子化による劣化

逆に,僕の考えた原因は当てはまらないはず,とのことでした.
与えたデータは構造的であり,50kあればデータ数として十分すぎるとのこと.
また,学習中にlossが順調に下がっている以上学習不足もないはず.

しかし,TEMPLATE指定とは初耳です.というか,Modelfileの内容は君が提示してきたんやろがい! という気持ち.それが間違っていたのだとしたら憤慨ですよ.
という事でまずは一番簡単そうなTEMPLATE指定からやってみることに.

…と思いきや,ここでClaudeが原因を発見.
Qwen3.5の場合,モデル実行時にenable_thinking=trueを明示的に渡さないと,もう思考は終わったという前提で回答を作ってしまうとのこと.つまりLLMモデル的には,もう</think>まで出力した体でその続きを作ってしまっていたのです.
そりゃ正解が出ないはずだ.

Modelfileを修正

Modelfileを変更して,量子化モデルを作り直します.
何をするかというと,出力が必ず<think>から始まるように変更します.

FROM ./outputs/qwen3.5-osero-q4_k_m.gguf

TEMPLATE """{{ range .Messages }}{{ if eq .Role "user" }}<|im_start|>user
{{ .Content }}<|im_end|>
{{ end }}{{ end }}<|im_start|>assistant
<think>
"""

PARAMETER stop "<|im_end|>"
PARAMETER stop "<|im_start|>"

ちなみにこのファイル内容としては,盤面を渡すメッセージ(ユーザーメッセージ)があることと,出力を<think>から始めなければならないことを指定しています.
その後,LLMの出力は会話の終了タグで終わるように設定しています.

さて,これでモデルを作り直せたので,このモデルでの出力を確認してみましょう.
まずは上記pythonファイルを実行したときの標準出力.

=== 結果 ===
総数  : 50
正解  : 7 (14.0%)
不正解: 43   (86.0%)
無効手: 0 (0.0%)
エラー: 0   (0.0%)
CSV   : outputs/test_inference_20260426_103543.csv

若干よくなっていますね.正解こそ少ないですが,無効手は0になっています.
では実際の出力も見てみましょう.

0                                                                                               候補手を評価する:\n  d7: 3手先で黒の手が平均9.3手 ← 最小\n  f4: 3手先で黒の手が平均9.7手\n  c6: 3手先で黒の手が平均9.9手\n  f3: 3手先で黒の手が平均10.1手\n  e3: 3手先で黒の手が平均10.1手\n  g5: 3手先で黒の手が平均10.2手\n  g4: 3手先で黒の手が平均10.2手\n→ d7 を選択(3手先で相手の手を最も制限できる)\n</think>\n\nd7
1                                                                                                                                        候補手を評価する:\n  b8: 3手先で白が平均9.3石 ← 最大\n  c4: 3手先で白が平均9.1石\n  c6: 3手先で白が平均8.6石\n  c2: 3手先で白が平均8.5石\n  c3: 3手先で白が平均8.4石\n  c5: 3手先で白が平均8.4石\n→ b8 を選択(3手先で最も石数が多くなる)\n</think>\n\nb8
2                                                                                                   候補手を評価する:\n  c5: 3手先で黒の手が平均9.0手 ← 最小\n  e3: 3手先で黒の手が平均9.0手\n  c3: 3手先で黒の手が平均9.2手\n  d2: 3手先で黒の手が平均9.4手\n  f2: 3手先で黒の手が平均9.5手\n  c2: 3手先で黒の手が平均9.6手\n  c4: 3手先で黒の手が平均9.9手\n→ c5 を選択(3手先で相手の手を最も制限できる)\n</think>\n\nc5
3                                                                                        候補手を評価する:\n  g6: 3手先で白が平均15.4石 ← 最大\n  g4: 3手先で白が平均15.3石\n  g5: 3手先で白が平均14.5石\n  g3: 3手先で白が平均14.3石\n  e3: 3手先で白が平均14.2石\n  g8: 3手先で白が平均14.1石\n  g7: 3手先で白が平均14.0石\n  g2: 3手先で白が平均14.0石\n→ g6 を選択(3手先で最も石数が多くなる)\n</think>\n\ng6
4    候補手を評価する:\n  c6: 3手先で白が平均15.8石 ← 最大\n  b5: 3手先で白が平均15.8石\n  g6: 3手先で白が平均15.0石\n  e2: 3手先で白が平均14.9石\n  d7: 3手先で白が平均14.8石\n  e7: 3手先で白が平均14.7石\n  g7: 3手先で白が平均14.7石\n  f2: 3手先で白が平均14.7石\n  f8: 3手先で白が平均14.6石\n  g2: 3手先で白が平均14.6石\n  b4: 3手先で白が平均14.6石\n  c5: 3手先で白が平均14.4石\n→ c6 を選択(3手先で最も石数が多くなる)\n</think>\n\nc6
5                         候補手を評価する:\n  c6: 3手先で白が平均16.1石 ← 最大\n  b4: 3手先で白が平均15.4石\n  c8: 3手先で白が平均15.2石\n  d8: 3手先で白が平均15.2石\n  b3: 3手先で白が平均15.2石\n  f2: 3手先で白が平均15.1石\n  e7: 3手先で白が平均15.1石\n  b5: 3手先で白が平均15.1石\n  e2: 3手先で白が平均15.1石\n  c5: 3手先で白が平均15.0石\n  g2: 3手先で白が平均14.8石\n→ c6 を選択(3手先で最も石数が多くなる)\n</think>\n\nc6
6                                                                                        候補手を評価する:\n  e7: 3手先で白が平均18.4石 ← 最大\n  h8: 3手先で白が平均17.9石\n  g2: 3手先で白が平均17.4石\n  b5: 3手先で白が平均17.3石\n  e2: 3手先で白が平均17.3石\n  b3: 3手先で白が平均17.3石\n  f2: 3手先で白が平均17.1石\n  b4: 3手先で白が平均16.9石\n→ e7 を選択(3手先で最も石数が多くなる)\n</think>\n\ne7
7                                                                                                                                            候補手を評価する:\n  e7: 3手先で白が平均20.3石 ← 最大\n  h8: 3手先で白が平均20.3石 ← 最大\n  e2: 3手先で白が平均18.9石\n  f2: 3手先で白が平均18.8石\n  g2: 3手先で白が平均18.5石\n同スコアの手: e7, h8\n→ e7 をランダムに選択\n</think>\n\ne7
8                                                                                                             候補手を評価する:\n  e7: 3手先で白が平均21.1石 ← 最大\n  e3: 3手先で白が平均20.3石\n  e2: 3手先で白が平均20.2石\n  b6: 3手先で白が平均20.1石\n  f2: 3手先で白が平均20.0石\n  g6: 3手先で白が平均19.6石\n  c5: 3手先で白が平均19.6石\n→ e7 を選択(3手先で最も石数が多くなる)\n</think>\n\ne7
9                                                                                                             候補手を評価する:\n  h4: 3手先で白が平均23.6石 ← 最大\n  f2: 3手先で白が平均22.4石\n  h3: 3手先で白が平均22.3石\n  g2: 3手先で白が平均22.3石\n  e3: 3手先で白が平均21.9石\n  g6: 3手先で白が平均21.8石\n  c5: 3手先で白が平均21.8石\n→ h4 を選択(3手先で最も石数が多くなる)\n</think>\n\nh4

なんだかよく分かりませんが,予想できていることが分かります.
これはいけるのでは?

最初に標準出力を見た時は「正解少ないなあ…」と思いましたが,よく考えたら正解が多かったとしてもそれは既存のnhandやnmostなどを単に模倣しているだけにすぎません.
むしろ,それらの思考方法をコピーしつつ,状況に応じて思考を使い分ければ,学習もとよりも強くなれるのではないか?

ということで,学習もととなった思考方法との対戦も行ってみましょう.
ソースは,前回作成したEvaluator.javaを使用します.

ちなみに,正規表現のスタックオーバーフローが出たためソースを若干変更しています.

Osero.java
    /**
     * Ollama 経由で学習済み LLM に手を問い合わせる.
     * 最大3回リトライし,有効な手が得られなければランダムにフォールバックする.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void llm(long[] board, boolean turn) {
        String prompt  = boardToText(board, turn);
        String escaped = escapeForJson(prompt);
        String reqBody = "{\"model\":\"" + LLM_MODEL + "\",\"stream\":false,"
                       + "\"messages\":[{\"role\":\"user\",\"content\":\"" + escaped + "\"}]}";

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest req = HttpRequest.newBuilder()
            .uri(URI.create(OLLAMA_URL + "/api/chat"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(reqBody))
            .build();

        for (int attempt = 0; attempt < 3; attempt++) {
            try {
                HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
                // 出力された手を抽出
                String body = resp.body();
                int thinkEnd = body.indexOf("</think>");
                String moveArea = thinkEnd >= 0 ? body.substring(thinkEnd + 8) : body;
                Matcher mv = Pattern.compile("[a-h][1-8]").matcher(moveArea);
                while (mv.find()) {
                    int col = "abcdefgh".indexOf(mv.group().charAt(0));
                    int row  = mv.group().charAt(1) - '1';
                    if (canPlace(row, col, board, turn)) {
                        place(row, col, board, turn);
                        return;
                    }
                }
            } catch (Exception e) {
                System.err.println("Ollama リクエストエラー: " + e.getMessage());
            }
        }
        // フォールバック: ランダムに打つ
        System.err.println("LLM から有効な手を取得できなかったため,ランダムに打ちます");
        random(board, turn);
    }

既存の思考方法との対戦結果がこちら.
各思考方法に対して,「LLMが黒」「LLMが白」をそれぞれ20戦ずつ行い,その結果を出力しています.

相手         |     黒勝     黒敗     黒分 |     白勝     白敗     白分
---------------------------------------------------------
N_HAND     |      8     12      0 |      8     12      0
N_LEAST    |      6     14      0 |      3     17      0
N_MOST     |      3     17      0 |      7     12      1
RANDOM     |     10     10      0 |     12      8      0

ランダム相手には互角に戦っていますが,他の思考方法に対しては負け越しています.
全然だめですね.

次の方針を考えよう

今の盤面と置ける位置を与えて,「この位置に置いたらスコアがこうなる」を予測するやり方ではあまり強くなれないようです.
というかそもそもLLMそれ単体では数値を扱うことが苦手なので,スコアを出力してもらうというアプローチ自体に無理があったような気もします.
という事で,次の方針を考えてみました.

次の方針とは,もう少しエージェントチックにやってみるという手です.
どういうことかというと…

  • オセロの定石などをあらかじめシステムプロンプトで与えておく
  • 今の盤面でどの位置に置くとどうなるのかを返すツールを渡して,それを使えるようにする

そうです.もうLoRAとかやりません.
モデル性能でぶん殴る方式で考えてみます.
だからもうqwen3.5:0.8bとかいうケチ臭いモデルを使用する必要もなくなります.

Modelfileについては長いので格納しますね.
興味ある人は読んでください.

Modelfile
FROM gemma4:e4b

SYSTEM """あなたはオセロ(リバーシ)の AI プレイヤーです.
simulate_move ツールを活用しながら,盤面を分析して最善の手を選んでください.

# 盤面の記法
- B = 自分の石,W = 相手の石,. = 空きマス
- 座標は列(a〜h)+行(1〜8),例: c4,f5

# ポジションの重要度

## 角(最重要)
a1, h1, a8, h8 — 一度取れば絶対にひっくり返せない永続的な安定石.取れる機会があれば最優先.

## 避けるべきマス
- X位置(b2, g2, b7, g7): 角の斜め隣.自分が置くと相手に角を取られやすい
- C位置(a2, b1, h2, g1, a7, b8, h7, g8): 角の縦横隣.角を渡すリスクが高い

## 辺(端行・端列)
安定石の基点になりうる.ただし C位置を相手に渡さない形であること.

# 局面別の方針
- 序盤(〜20手目): 石数より相手の手数を減らすことを優先
- 中盤(20〜40手目): 辺・角への布石,安定石の増加を意識
- 終盤(40手目〜): 石数最大化,安定石の確保

# simulate_move ツールの使い方

simulate_move(row, col) を呼ぶと以下が返される:
- board: 着手後の盤面テキスト
- my_stones: 自分の石数
- opponent_stones: 相手の石数
- flipped: ひっくり返した相手石の座標リスト
- opponent_valid_moves: 相手が次に置ける位置の一覧(少ないほど良い)
- stable_gained: この手で増えた自分の安定石の数(大きいほど良い)

## 結果の評価基準(優先度順)
1. 角が取れる手 → 他と比較不要,即決定
2. opponent_valid_moves に角(a1/h1/a8/h8)が含まれない手を優先
3. stable_gained が大きい手を優先
4. opponent_valid_moves の数が少ない手を優先
5. 序盤・中盤: 石数差より 1〜4 を重視,終盤: my_stones が多い手

# 思考手順

1. 置ける位置から候補手を絞る
   - 角があれば simulate_move で確認して即採用
   - X位置・C位置は最終手段以外では選ばない
2. 有望な候補を 2〜5 手 simulate_move でシミュレートする
3. 上記評価基準で比較して最善手を決める

# 回答形式
最終的な手を "d3" のような座標1つだけで返すこと.他の文章は不要."""

ソースの変更

あと,ソースも変更していきましょう.
まずはツールの返り値となる構造体を定義します.

Osero.java
    /**
     * simulate_move ツールの戻り値を格納するデータクラス.
     */
    protected static class SimulateResult {
        /** 着手後の盤面テキスト */
        String boardText;
        /** 着手側の石数 */
        int myStones;
        /** 相手の石数 */
        int opponentStones;
        /** ひっくり返した石の座標リスト("d4" 形式) */
        List<String> flipped;
        /** 着手後に相手が置ける位置一覧 */
        List<String> opponentValidMoves;
        /** この着手で増えた自分の安定石の数 */
        int stableGained;
    }

次に,安定石の個数をカウントするメソッドを作ります.
安定石とは「このあとどれだけゲームが進んでも絶対にひっくり返らない」石のことです.これが多ければ多いほど強いはず,ということを考えています.

安定石かどうかの判断方法について説明します.
ある位置に置かれた石について,縦横斜め方向に走査して,その全てにおいて

  • いずれかの端まで自分の石で埋まっている
  • その行・列・斜め全てに石が埋まっている(全てが自分の石ではなく,相手の石が交ざっていてもいい)

のいずれかを満たす場合に,その石は安定石になります.
理屈は分かりますよね.「いずれかの端まで自分の石で埋まっている」状況なら相手の石に挟まれることはないのでもうひっくり返らない.「すべてに石が埋まっている」状況なら自分も相手も石を置けないのでもうひっくり返らない.このいずれかを全ての行・列・斜めについて満たせれば,その石はもう安定石です.

OseroBase.java
    /**
     * 指定プレイヤーの安定石(ひっくり返せない石)の数を返す.
     * 4 軸(横・縦・斜め2本)それぞれで「いずれかの方向へ自分の石が端まで連続する」
     * または「ライン全体に空きマスがない」を満たせば,その軸を安全とみなす.
     * 4 軸すべて安全な石を安定石として数える.
     * @param board ビットボード(board[0]=黒, board[1]=白)
     * @param turn  カウント対象の手番(false=黒, true=白)
     * @return 安定石の数
     */
    protected int countStableStones(long[] board, boolean turn) {
        int my = turn ? 1 : 0;
        int[][] axes = {{0, 1}, {1, 0}, {1, 1}, {1, -1}};
        int count = 0;

        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                if ((board[my] & (1L << ((r << ROW_SHIFT) + c))) == 0) continue;

                boolean stable = true;
                for (int[] ax : axes) {
                    int dr = ax[0], dc = ax[1];

                    // − 方向へ自分の石が端まで連続するか
                    int tr = r - dr, tc = c - dc;
                    while (tr >= 0 && tr < SIZE && tc >= 0 && tc < SIZE
                            && (board[my] & (1L << ((tr << ROW_SHIFT) + tc))) != 0) {
                        tr -= dr; tc -= dc;
                    }
                    boolean negEdge = tr < 0 || tr >= SIZE || tc < 0 || tc >= SIZE;

                    // + 方向へ自分の石が端まで連続するか
                    tr = r + dr; tc = c + dc;
                    while (tr >= 0 && tr < SIZE && tc >= 0 && tc < SIZE
                            && (board[my] & (1L << ((tr << ROW_SHIFT) + tc))) != 0) {
                        tr += dr; tc += dc;
                    }
                    boolean posEdge = tr < 0 || tr >= SIZE || tc < 0 || tc >= SIZE;

                    if (negEdge || posEdge) continue;

                    // どちらも端未到達: ライン全体に空きマスがないか確認
                    // まず − 方向の端まで移動
                    tr = r; tc = c;
                    while (tr - dr >= 0 && tr - dr < SIZE && tc - dc >= 0 && tc - dc < SIZE) {
                        tr -= dr; tc -= dc;
                    }
                    // 端から + 方向へ空きマスを探す
                    boolean lineFilled = true;
                    while (tr >= 0 && tr < SIZE && tc >= 0 && tc < SIZE) {
                        long b = 1L << ((tr << ROW_SHIFT) + tc);
                        if ((board[0] & b) == 0 && (board[1] & b) == 0) {
                            lineFilled = false; break;
                        }
                        tr += dr; tc += dc;
                    }
                    if (!lineFilled) { stable = false; break; }
                }
                if (stable) count++;
            }
        }
        return count;
    }

では次は,LLMが呼び出すツールを実装します.
現在の盤面とターン,あと「どの位置に置くか」を引数として,先ほど定義した構造体を返り値とします.
LLMはこの返り値情報をもとにして,「ここに置くと強い」とか「ここに置くと弱い」とか判断することになります.

なお,LLMが無効手を指定してきたときの対策として例外処理を入れています.

Osero.java
    /**
     * 指定位置に石を置いた場合の結果をシミュレートする(実際の盤面は変更しない).
     * 指定位置が合法手でない場合は IllegalArgumentException をスローする.
     * @param board ビットボード(board[0]=黒, board[1]=白)
     * @param turn  手番(false=黒, true=白)
     * @param row   行インデックス(0〜7)
     * @param col   列インデックス(0〜7)
     * @return シミュレーション結果
     * @throws IllegalArgumentException 指定位置に石を置けない場合
     */
    protected SimulateResult simulateMove(long[] board, boolean turn, int row, int col) {
        if (!canPlace(row, col, board, turn))
            throw new IllegalArgumentException("置けない位置が指定されました: " + coordToStr(row, col));

        int my  = turn ? 1 : 0;
        int opp = turn ? 0 : 1;
        int before = countStableStones(board, turn);

        long[] boardCopy = {board[0], board[1]};
        place(row, col, boardCopy, turn);

        SimulateResult res = new SimulateResult();
        res.myStones       = countStones(boardCopy, turn);
        res.opponentStones = countStones(boardCopy, !turn);
        res.boardText      = boardToText(boardCopy, !turn);
        res.stableGained   = countStableStones(boardCopy, turn) - before;

        // 着手前に相手の石だったもので,着手後に自分の石になったもの = ひっくり返した石
        long flippedBits = board[opp] & boardCopy[my];
        res.flipped = new ArrayList<>();
        for (int r = 0; r < SIZE; r++)
            for (int c = 0; c < SIZE; c++)
                if ((flippedBits & (1L << ((r << ROW_SHIFT) + c))) != 0)
                    res.flipped.add(coordToStr(r, c));

        // 着手後に相手が置ける位置
        res.opponentValidMoves = new ArrayList<>();
        for (int r = 0; r < SIZE; r++)
            for (int c = 0; c < SIZE; c++)
                if (canPlace(r, c, boardCopy, !turn))
                    res.opponentValidMoves.add(coordToStr(r, c));

        return res;
    }

ではようやく本丸,llmメソッドの修正です.
llmメソッドはollamaのAPIにアクセスし,思考結果を受け取ります.
また,レスポンスの指示に従って上記ツールを実行し,その結果を渡します.

ただその中で,いくつか例外的なことが起こりうるので,それへの対処について簡単に説明.

  • ツール呼び出しの際,無効手を指定してくる: 上のメソッドで説明したように,無効手であることを返す
  • 最終的な回答はd3のように置く位置だけを回答するようになっているが,それを守らずに自然言語などで回答してくる: 最初から質問を投げ直す
  • LLMが無効手を返してくる: 「その手は無効なので考え直してください」的なメッセージを送り,会話続行
  • LLMが考えすぎて会話がずっと続く: 一定回数で切り,再チャレンジ
  • 失敗しすぎて再チャレンジ回数がかさむ: 一定回数で諦めて,ランダムに置く

また,せっかくなので,思考過程をログ出力するようにしています.
いつか使うかもしれないのでね.

また,このメソッドで呼び出している補助メソッドはあとで載せます.

Osero.java
    /**
     * Ollama 経由でツール利用 LLM に手を問い合わせる.
     * 最大 3 回の思考セッションを試み,各セッションで LLM が simulate_move ツールを
     * 呼び出しながら思考して最終的な着手を返す.全セッション失敗時はランダムにフォールバックする.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void llm(long[] board, boolean turn) {
        final String toolsJson = "[{\"type\":\"function\",\"function\":{"
                + "\"name\":\"simulate_move\","
                + "\"description\":\"指定位置に石を置いた場合の結果をシミュレートする\","
                + "\"parameters\":{\"type\":\"object\",\"properties\":{"
                + "\"row\":{\"type\":\"integer\",\"description\":\"行インデックス (0〜7, 上から)\"},"
                + "\"col\":{\"type\":\"integer\",\"description\":\"列インデックス (0〜7, 左から)\"}"
                + "},\"required\":[\"row\",\"col\"]}}}]";
        final String initialMsg = "{\"role\":\"user\",\"content\":\""
                + escapeForJson(boardToText(board, turn)) + "\"}";
        HttpClient client = HttpClient.newHttpClient();

        // 最大三回チャレンジする
        for (int attempt = 0; attempt < 3; attempt++) {
            // セッションごとにメッセージ履歴をリセット
            List<String> messages = new ArrayList<>();
            messages.add(initialMsg);

            // 一回のセッションで最大十回会話する
            for (int loop = 0; loop < 10; loop++) {
                try {
                    // リクエスト body 組み立て
                    StringBuilder reqBody = new StringBuilder();
                    reqBody.append("{\"model\":\"").append(LLM_MODEL)
                           .append("\",\"stream\":false,\"tools\":").append(toolsJson)
                           .append(",\"messages\":[");
                    for (int i = 0; i < messages.size(); i++) {
                        if (i > 0) reqBody.append(",");
                        reqBody.append(messages.get(i));
                    }
                    reqBody.append("]}");

                    // リクエスト送信
                    HttpRequest req = HttpRequest.newBuilder()
                            .uri(URI.create(OLLAMA_URL + "/api/chat"))
                            .header("Content-Type", "application/json")
                            .POST(HttpRequest.BodyPublishers.ofString(reqBody.toString()))
                            .build();
                    HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());

                    if (resp.statusCode() != 200) {
                        System.err.println("Ollama HTTP エラー: " + resp.statusCode());
                        break;
                    }

                    String body = resp.body();
                    if (logWriter != null) {
                        logWriter.println("{\"request\":" + reqBody + ",\"response\":" + body + "}");
                    }

                    // レスポンスの message オブジェクトを抽出(履歴追加用)
                    String assistantMsg = null;
                    int msgKeyIdx = body.indexOf("\"message\":");
                    if (msgKeyIdx >= 0) {
                        int braceIdx = body.indexOf("{", msgKeyIdx + 10);
                        if (braceIdx >= 0) assistantMsg = extractJsonObject(body, braceIdx);
                    }
                    if (assistantMsg == null) {
                        System.err.println("Ollama レスポンスに message フィールドがありません");
                        break;
                    }

                    // ツール呼び出しの指定があれば
                    if (body.indexOf("\"tool_calls\"") >= 0) {
                        // ── ツール呼び出し ──────────────────────────────
                        if (assistantMsg != null) messages.add(assistantMsg);

                        int searchFrom = body.indexOf("\"tool_calls\"");
                        while (searchFrom >= 0) { // 複数のツールが指定されている可能性があるのでループする
                            // ツール引数作成
                            int argIdx = body.indexOf("\"arguments\"", searchFrom);
                            if (argIdx < 0) break;
                            int argObjStart = body.indexOf("{", argIdx + 11);
                            if (argObjStart < 0) break;
                            String argObj = extractJsonObject(body, argObjStart);

                            int rowKeyIdx = argObj.indexOf("\"row\":");
                            int colKeyIdx = argObj.indexOf("\"col\":");
                            if (rowKeyIdx < 0 || colKeyIdx < 0) { searchFrom = argIdx + 1; continue; }

                            int row = Integer.parseInt(String.valueOf(argObj.charAt(rowKeyIdx + 6)));
                            int col = Integer.parseInt(String.valueOf(argObj.charAt(colKeyIdx + 6)));

                            // ツール呼び出し実行
                            String toolContent;
                            try {
                                SimulateResult res = simulateMove(board, turn, row, col);
                                toolContent = buildSimulateResultJson(res);
                            } catch (IllegalArgumentException e) {
                                toolContent = "{\"error\":\"" + escapeForJson(e.getMessage()) + "\"}";
                            }
                            messages.add("{\"role\":\"tool\",\"content\":\""
                                    + escapeForJson(toolContent) + "\"}");
                            searchFrom = argIdx + 1;
                        }
                    // ツール呼び出しの指定がない
                    } else {
                        // ── 最終回答 ──────────────────────────────────
                        String content = assistantMsg != null
                                ? extractStringField(assistantMsg, "\"content\"") : "";
                        int thinkEnd = content.indexOf("</think>");
                        String moveArea = thinkEnd >= 0 ? content.substring(thinkEnd + 8) : content;

                        String trimmed = moveArea.trim();
                        if (trimmed.matches("[a-h][1-8]")) {
                            int col = "abcdefgh".indexOf(trimmed.charAt(0));
                            int row = "12345678".indexOf(trimmed.charAt(1));
                            if (canPlace(row, col, board, turn)) {
                                place(row, col, board, turn);
                                return;
                            }
                        }
                        break; // フォーマット不正または無効手 → このセッション失敗,次の attempt へ
                    }

                } catch (Exception e) {
                    System.err.println("Ollama リクエストエラー: " + e.getMessage());
                    break;
                }
            }
        }

        // フォールバック: ランダムに打つ
        System.err.println("LLM から有効な手を取得できなかったため,ランダムに打ちます");
        random(board, turn);
    }

上記llmで呼び出している補助メソッドです.

Osero.java
    /**
     * 指定位置を開始点とする JSON オブジェクト文字列を抽出する.
     * 文字列リテラル内の括弧を読み飛ばし,対応する '}' を正確に判定する.
     * @param s     検索対象の文字列
     * @param start '{' の位置
     * @return '{' から対応する '}' までの部分文字列
     */
    private String extractJsonObject(String s, int start) {
        int depth = 0;
        boolean inStr = false;
        for (int i = start; i < s.length(); i++) {
            char c = s.charAt(i);
            if (inStr) {
                if      (c == '\\') i++;
                else if (c == '"')  inStr = false;
            } else {
                if      (c == '"') inStr = true;
                else if (c == '{') depth++;
                else if (c == '}' && --depth == 0) return s.substring(start, i + 1);
            }
        }
        return s.substring(start);
    }

    /**
     * 文字列中の指定キーに対応する JSON 文字列フィールドの値を返す.
     * エスケープシーケンス(\n, \r, \t, \\, \")をデコードして返す.
     * @param s   検索対象の文字列(エスケープ済み)
     * @param key 検索キー(例: "\"content\"")
     * @return フィールドの値(デコード済み),キーが存在しない場合は空文字列
     */
    private String extractStringField(String s, String key) {
        int keyIdx = s.indexOf(key);
        if (keyIdx < 0) return "";
        int colonIdx = s.indexOf(":", keyIdx + key.length());
        if (colonIdx < 0) return "";
        int quoteIdx = s.indexOf("\"", colonIdx + 1);
        if (quoteIdx < 0) return "";
        StringBuilder sb = new StringBuilder();
        for (int i = quoteIdx + 1; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == '\\' && i + 1 < s.length()) {
                char esc = s.charAt(++i);
                if      (esc == 'n') sb.append('\n');
                else if (esc == 'r') sb.append('\r');
                else if (esc == 't') sb.append('\t');
                else                 sb.append(esc);
            } else if (c == '"') {
                break;
            } else {
                sb.append(c);
            }
        }
        return sb.toString();
    }

    /**
     * SimulateResult を LLM に返す JSON 文字列に変換する.
     * @param res シミュレーション結果
     * @return JSON 文字列
     */
    private String buildSimulateResultJson(SimulateResult res) {
        StringBuilder sb = new StringBuilder();
        sb.append("{\"board\":\"").append(escapeForJson(res.boardText)).append("\"");
        sb.append(",\"my_stones\":").append(res.myStones);
        sb.append(",\"opponent_stones\":").append(res.opponentStones);
        sb.append(",\"stable_gained\":").append(res.stableGained);
        sb.append(",\"flipped\":[");
        for (int i = 0; i < res.flipped.size(); i++) {
            if (i > 0) sb.append(",");
            sb.append("\"").append(res.flipped.get(i)).append("\"");
        }
        sb.append("],\"opponent_valid_moves\":[");
        for (int i = 0; i < res.opponentValidMoves.size(); i++) {
            if (i > 0) sb.append(",");
            sb.append("\"").append(res.opponentValidMoves.get(i)).append("\"");
        }
        sb.append("]}");
        return sb.toString();
    }

また,Evaluator.javaも変更します.
対戦結果を標準出力ではなくCSVファイルに出すようにします.

Evaluator.java
Evaluator.java
import java.io.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.function.BiConsumer;

/**
 * 学習済み LLM を既存 AI アルゴリズムと対戦させ,勝率を評価するクラス.
 *
 * 使い方:
 *   javac *.java
 *   java Evaluator
 */
public class Evaluator extends Osero {

    private static final int GAMES_PER_MATCHUP = 20;
    private static final int READ_GOAL         = 3;

    /** 対戦相手として使用する AI メソッドの一覧 */
    private static final PlayMethod[] OPPONENTS = {
        PlayMethod.N_HAND, PlayMethod.N_LEAST, PlayMethod.N_MOST, PlayMethod.RANDOM
    };

    /**
     * 1ゲームを実行し,勝者を返す.
     * @param black 黒番の思考方法
     * @param white 白番の思考方法
     * @return 1=黒勝ち, -1=白勝ち, 0=引き分け
     */
    public int runGame(PlayMethod black, PlayMethod white) {
        this.initBoard();
        BiConsumer<long[], Boolean> blackPlayer = resolvePlayMethod(black);
        BiConsumer<long[], Boolean> whitePlayer = resolvePlayMethod(white);
        boolean turn          = false;
        boolean prevCouldPlay = true;

        while (true) {
            if (!canPlaceAny(this.board, turn)) {
                if (!prevCouldPlay) break; // 両者置けない → 終了
                prevCouldPlay = false;
                turn = !turn;
                continue;
            }
            prevCouldPlay = true;
            (turn ? whitePlayer : blackPlayer).accept(this.board, turn);
            turn = !turn;
        }

        int blackStones = countStones(this.board, false);
        int whiteStones = countStones(this.board, true);
        if      (blackStones > whiteStones) return  1;
        else if (whiteStones > blackStones) return -1;
        else                                return  0;
    }

    /**
     * 秒数を "Xm Ys" 形式にフォーマットする.
     * @param seconds 秒数
     * @return フォーマットされた時間文字列
     */
    private static String formatTime(long seconds) {
        return String.format("%dm%02ds", seconds / 60, seconds % 60);
    }

    /**
     * 進捗行を標準出力に上書き表示する.
     * @param opp     現在の対戦相手
     * @param bDone   現在の対戦相手での黒番完了ゲーム数
     * @param wDone   現在の対戦相手での白番完了ゲーム数
     * @param done    全体完了ゲーム数
     * @param total   全体ゲーム数
     * @param startMs 開始時刻(ミリ秒)
     */
    private static void printProgress(PlayMethod opp, int bDone, int wDone,
                                      int done, int total, long startMs) {
        long elapsed   = (System.currentTimeMillis() - startMs) / 1000;
        long remaining = done > 0 ? elapsed * (total - done) / done : 0;
        System.out.printf("\r[%s] 黒 %d/%d 白 %d/%d | 全体 %d/%d (%d%%) | 経過 %s 残り約 %s   ",
                opp, bDone, GAMES_PER_MATCHUP, wDone, GAMES_PER_MATCHUP,
                done, total, done * 100 / total,
                formatTime(elapsed), formatTime(remaining));
    }

    /**
     * 評価のエントリーポイント.
     * LLM を各 AI と GAMES_PER_MATCHUP ゲームずつ対戦させ,結果を CSV に保存する.
     * @param args 未使用
     * @throws IOException CSV ファイルの書き込みに失敗した場合
     */
    public static void main(String[] args) throws IOException {
        Evaluator ev = new Evaluator();
        ev.setReadGoal(READ_GOAL, READ_GOAL);

        LocalDateTime now = LocalDateTime.now();
        String filename = "../csv/eval_" + LLM_MODEL + "_"
                + now.format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".csv";
        String date = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        int totalGames = OPPONENTS.length * GAMES_PER_MATCHUP * 2;
        int doneGames  = 0;
        long startMs   = System.currentTimeMillis();

        try (PrintWriter csv = new PrintWriter(new FileWriter(filename))) {
            csv.println("model,date,games_per_matchup,opponent,b_win,b_lose,b_draw,w_win,w_lose,w_draw");

            for (PlayMethod opp : OPPONENTS) {
                int bWin = 0, bLose = 0, bDraw = 0;
                int wWin = 0, wLose = 0, wDraw = 0;

                for (int i = 0; i < GAMES_PER_MATCHUP; i++) {
                    // LLM が黒
                    int r1 = ev.runGame(PlayMethod.LLM, opp);
                    if      (r1 ==  1) bWin++;
                    else if (r1 == -1) bLose++;
                    else               bDraw++;
                    doneGames++;
                    printProgress(opp, i + 1, i, doneGames, totalGames, startMs);

                    // LLM が白
                    int r2 = ev.runGame(opp, PlayMethod.LLM);
                    if      (r2 == -1) wWin++;
                    else if (r2 ==  1) wLose++;
                    else               wDraw++;
                    doneGames++;
                    printProgress(opp, i + 1, i + 1, doneGames, totalGames, startMs);
                }

                System.out.printf("\r[%s] 完了 黒 %d勝%d敗%d分 白 %d勝%d敗%d分%n",
                        opp, bWin, bLose, bDraw, wWin, wLose, wDraw);

                csv.printf("%s,%s,%d,%s,%d,%d,%d,%d,%d,%d%n",
                        LLM_MODEL, date, GAMES_PER_MATCHUP, opp,
                        bWin, bLose, bDraw, wWin, wLose, wDraw);
            }
        }

        System.out.println("結果を保存しました: " + filename);
    }
}

戦わせてみよう

それでは完成しましたので,実際に各思考方法と対戦させてみたいと思います.
なお,LLMの思考に時間がかかるので,各思考方法との対戦回数は10回ずつとしています.

…と,思ったのですが,最終出力が「最終的な座標指定は d8 を採用する。\n(これは、プログラム上の実行ではなく、最善手としての提案である。)」のようになっていて,全く指定した通りではありません.
また,ツールの引数では行と列を数値で指定するのに,最終出力はd3のような形式なのもLLMを混乱させていました.

それぞれの問題を以下のように解消します.

問題1: 最終出力が想定通りではない.それによってバリデーションに引っ掛かる
対策:

  • システムプロンプトで最終出力をより強調する
  • 最終出力のバリデートは厳密に行わず,「最終出力のうち最後に出てきた座標を採用する」に変更.「最後」にしている理由は,最終的に決定した手を回答の一番最後で言っているパターンが多いから

問題2: ツールの引数では行と列を数値で指定するのに,最終出力はd3のような形式になっていて,不整合
対策: ツールの引数もd3のような形式にする

システムプロンプトはこうなりました.

あなたはオセロ(リバーシ)の AI プレイヤーです.
simulate_move ツールを活用しながら,盤面を分析して最善の手を選んでください.

# 座標の記法(重要)
列は a〜h(左から),行は 1〜8(上から).例: 左から4列目・上から3行目 = d3
- B = 自分の石,W = 相手の石,. = 空きマス
- simulate_move の引数も最終回答も,すべてこの形式(例: d3)を使う

# ポジションの重要度

## 角(最重要)
a1, h1, a8, h8 — 一度取れば絶対にひっくり返せない永続的な安定石.取れる機会があれば最優先.

## 避けるべきマス
- X位置(b2, g2, b7, g7): 角の斜め隣.自分が置くと相手に角を取られやすい
- C位置(a2, b1, h2, g1, a7, b8, h7, g8): 角の縦横隣.角を渡すリスクが高い

## 辺(端行・端列)
安定石の基点になりうる.ただし C位置を相手に渡さない形であること.

# 局面別の方針
- 序盤(〜20手目): 石数より相手の手数を減らすことを優先
- 中盤(20〜40手目): 辺・角への布石,安定石の増加を意識
- 終盤(40手目〜): 石数最大化,安定石の確保

# simulate_move ツールの使い方

simulate_move(coord) を呼ぶ(coord は "d3" 形式)と以下が返される:
- board: 着手後の盤面テキスト
- my_stones: 自分の石数
- opponent_stones: 相手の石数
- flipped: ひっくり返した相手石の座標リスト
- opponent_valid_moves: 相手が次に置ける位置の一覧(少ないほど良い)
- stable_gained: この手で増えた自分の安定石の数(大きいほど良い)

## 結果の評価基準(優先度順)
1. 角が取れる手 → 他と比較不要,即決定
2. opponent_valid_moves に角(a1/h1/a8/h8)が含まれない手を優先
3. stable_gained が大きい手を優先
4. opponent_valid_moves の数が少ない手を優先
5. 序盤・中盤: 石数差より 1〜4 を重視,終盤: my_stones が多い手

# 思考手順

1. 置ける位置から候補手を絞る
   - 角があれば simulate_move で確認して即採用
   - X位置・C位置は最終手段以外では選ばない
2. 有望な候補を 2〜5 手 simulate_move でシミュレートする
3. 上記評価基準で比較して最善手を決める

# 回答形式(厳守)
thinking が終わったら,選んだ座標を "d3" のような形式で1つだけ出力すること.
説明文・理由・その他の文章は一切不要.座標のみ.

llmメソッドはこうなりました.

Osero.java
    /**
     * Ollama 経由でツール利用 LLM に手を問い合わせる.
     * 最大 3 回の思考セッションを試み,各セッションで LLM が simulate_move ツールを
     * 呼び出しながら思考して最終的な着手を返す.全セッション失敗時はランダムにフォールバックする.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void llm(long[] board, boolean turn) {
        final String toolsJson = "[{\"type\":\"function\",\"function\":{"
                + "\"name\":\"simulate_move\","
                + "\"description\":\"指定座標に石を置いた場合の結果をシミュレートする\","
                + "\"parameters\":{\"type\":\"object\",\"properties\":{"
                + "\"coord\":{\"type\":\"string\",\"description\":\"座標(列 a〜h + 行 1〜8,例: d3)\"}"
                + "},\"required\":[\"coord\"]}}}]";
        final String initialMsg = "{\"role\":\"user\",\"content\":\""
                + escapeForJson(boardToText(board, turn)) + "\"}";
        HttpClient client = HttpClient.newHttpClient();

        // 最大三回チャレンジする
        for (int attempt = 0; attempt < 3; attempt++) {
            // セッションごとにメッセージ履歴をリセット
            List<String> messages = new ArrayList<>();
            messages.add(initialMsg);

            // 一回のセッションで最大十回会話する
            for (int loop = 0; loop < 10; loop++) {
                try {
                    // リクエスト body 組み立て
                    StringBuilder reqBody = new StringBuilder();
                    reqBody.append("{\"model\":\"").append(LLM_MODEL)
                           .append("\",\"stream\":false,\"tools\":").append(toolsJson)
                           .append(",\"messages\":[");
                    for (int i = 0; i < messages.size(); i++) {
                        if (i > 0) reqBody.append(",");
                        reqBody.append(messages.get(i));
                    }
                    reqBody.append("]}");

                    // リクエスト送信
                    HttpRequest req = HttpRequest.newBuilder()
                            .uri(URI.create(OLLAMA_URL + "/api/chat"))
                            .header("Content-Type", "application/json")
                            .POST(HttpRequest.BodyPublishers.ofString(reqBody.toString()))
                            .build();
                    HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());

                    if (resp.statusCode() != 200) {
                        System.err.println("Ollama HTTP エラー: " + resp.statusCode());
                        break;
                    }

                    String body = resp.body();
                    if (logWriter != null) {
                        logWriter.println("{\"request\":" + reqBody + ",\"response\":" + body + "}");
                    }

                    // レスポンスの message オブジェクトを抽出(履歴追加用)
                    String assistantMsg = null;
                    int msgKeyIdx = body.indexOf("\"message\":");
                    if (msgKeyIdx >= 0) {
                        int braceIdx = body.indexOf("{", msgKeyIdx + 10);
                        if (braceIdx >= 0) assistantMsg = extractJsonObject(body, braceIdx);
                    }
                    if (assistantMsg == null) {
                        System.err.println("Ollama レスポンスに message フィールドがありません");
                        break;
                    }

                    // ツール呼び出しの指定があれば
                    if (body.indexOf("\"tool_calls\"") >= 0) {
                        // ── ツール呼び出し ──────────────────────────────
                        if (assistantMsg != null) messages.add(assistantMsg);

                        int searchFrom = body.indexOf("\"tool_calls\"");
                        while (searchFrom >= 0) { // 複数のツールが指定されている可能性があるのでループする
                            // ツール引数作成
                            int argIdx = body.indexOf("\"arguments\"", searchFrom);
                            if (argIdx < 0) break;
                            int argObjStart = body.indexOf("{", argIdx + 11);
                            if (argObjStart < 0) break;
                            String argObj = extractJsonObject(body, argObjStart);

                            String coord = extractStringField(argObj, "\"coord\"");
                            if (coord.length() < 2) { searchFrom = argIdx + 1; continue; }
                            int col = "abcdefgh".indexOf(coord.charAt(0));
                            int row = "12345678".indexOf(coord.charAt(1));
                            if (col < 0 || row < 0) { searchFrom = argIdx + 1; continue; }

                            // ツール呼び出し実行
                            String toolContent;
                            try {
                                SimulateResult res = simulateMove(board, turn, row, col);
                                toolContent = buildSimulateResultJson(res);
                            } catch (IllegalArgumentException e) {
                                toolContent = "{\"error\":\"" + escapeForJson(e.getMessage()) + "\"}";
                            }
                            messages.add("{\"role\":\"tool\",\"content\":\""
                                    + escapeForJson(toolContent) + "\"}");
                            searchFrom = argIdx + 1;
                        }
                    // ツール呼び出しの指定がない
                    } else {
                        // ── 最終回答 ──────────────────────────────────
                        String content = assistantMsg != null
                                ? extractStringField(assistantMsg, "\"content\"") : "";
                        int thinkEnd = content.indexOf("</think>");
                        String moveArea = thinkEnd >= 0 ? content.substring(thinkEnd + 8) : content;

                        Matcher mv = Pattern.compile("[a-h][1-8]").matcher(moveArea);
                        List<String> candidates = new ArrayList<>();
                        while (mv.find()) candidates.add(mv.group());
                        for (int i = candidates.size() - 1; i >= 0; i--) {
                            int col = "abcdefgh".indexOf(candidates.get(i).charAt(0));
                            int row = "12345678".indexOf(candidates.get(i).charAt(1));
                            if (canPlace(row, col, board, turn)) {
                                place(row, col, board, turn);
                                return;
                            }
                        }
                        break; // 有効手が見つからない → このセッション失敗,次の attempt へ
                    }

                } catch (Exception e) {
                    System.err.println("Ollama リクエストエラー: " + e.getMessage());
                    break;
                }
            }
        }

        // フォールバック: ランダムに打つ
        System.err.println("LLM から有効な手を取得できなかったため,ランダムに打ちます");
        random(board, turn);
    }

実際の呼び出しツールは変更なしです.
引数形式の不整合はllmメソッド内で解消しています.

改めて性能評価

修正も終わりましたので,改めて他の思考方法との対戦を行っていきましょう.
ちなみに,時間がかかりすぎるので各思考方法との対戦回数は1回ずつに制限しています.
それでも二時間以上かかりましたけどね.
たった8試合に二時間以上とは凄まじい….

そして,その結果がこちら.
※分かりやすくするため,標準出力の一部のみ記載しています

[N_HAND] 完了 黒 0勝1敗0分 白 0勝1敗0分 | 経過 34m57s 残り約 104m51s
[N_LEAST] 完了 黒 0勝1敗0分 白 0勝1敗0分 | 経過 58m36s 残り約 58m36s
[N_MOST] 完了 黒 0勝1敗0分 白 0勝1敗0分 | 経過 88m45s 残り約 29m35s
[RANDOM] 完了 黒 1勝0敗0分 白 1勝0敗0分) | 経過 123m42s 残り約 0m00s

ランダム相手には勝っていますが,それ以外の思考方法に対しては全敗.
戦った回数が少ないので何とも言えませんが,正直この結果だけを見るとあまり強そうには思えません.

あまり期待は持てませんが,実際に対戦してみようと思います.
まずは僕が黒,LLMが白から.
その対戦結果がこちら.

  a b c d e f g h
1 B B B B B B B B
2 B B B B B B W W
3 B W B W B W W W
4 B W W B B W W W
5 B W B B B B W W
6 B B B W W W W W
7 B W W W W W W W
8 B W W W W W W B
白の手番
置ける位置:

白 はパスします

=== 終局 ===
黒(HUMAN): 31 石
白(LLM): 33 石
→ 白の勝ち!

め,めっちゃ強かった…
角全部取って負けることあるんだ…

想像以上に強かったです.
nhandなど既存の思考方法はそんなに強いと思ったことがなく,「その思考方法に勝てないLLMはそんなに強くないだろう」と思っていたのですが,相性とかあるんですかね.
LLMは機械的な思考をする敵相手には弱いですが,人間相手には強いかもしれないです.

では逆に,僕が白でLLMが黒のパターンをやってみましょう.
結果がこちら.

  a b c d e f g h
1 W W W W W W W W
2 W W B W B B B B
3 W W W B W W B B
4 W B B W W B B B
5 B B W W W B B B
6 W B W B W W B B
7 W W B B B B B B
8 W W W W W W W W
白の手番
置ける位置:

白 はパスします

=== 終局 ===
黒(LLM): 28 石
白(HUMAN): 36 石
→ 白の勝ち!

ざまあみろ!!!
AIごときが人間様に勝てると思うなよ!!!

フルバージョン

ちょこちょこ変わっているので一応フルバージョンを載せておきますね.

Osero.java
Osero.java
import java.io.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.regex.*;

/**
 * オセロAIクラス.
 * ほぼ Qiita 記事の実装に準拠 (https://qiita.com/tt_and_tk/items/22641b036d3b1e5fdbca)
 * 記事と異なる点: DataGen 用に thinkAndGetMove() メソッドを追加している.
 */
public class Osero extends OseroBase {
    /** 思考方法の識別子. */
    public enum PlayMethod {
        /** ランダムに置く */
        RANDOM,
        /** n手先で,自分の石数が最大になる位置に置く */
        N_HAND,
        /** n手先で,相手の取れる手数が最小になる位置に置く */
        N_LEAST,
        /** n手先で,自分の取れる手数が最大になる位置に置く */
        N_MOST,
        /** 人間が手を選ぶ */
        HUMAN,
        /** ツールを利用した LLM(Ollama 経由)が手を選ぶ */
        LLM,
    }

    /** 探索深さ.[0]=黒, [1]=白 */
    protected int[] readGoal = {3, 3};

    /** 黒と白の思考方法 */
    protected ArrayList<BiConsumer<long[], Boolean>> playMethod = new ArrayList<>();

    /** LLM プレイヤーが使用する Ollama モデル名 */
    protected static final String LLM_MODEL  = "gemma4-osero-tool";
    /** Ollama API の URL */
    protected static final String OLLAMA_URL = "http://localhost:11434";
    /** 人間プレイヤー入力用 */
    protected static final Scanner scanner   = new Scanner(System.in);
    /** LLM の API 往復ログの出力先.null の場合はログを出力しない */
    private PrintWriter logWriter = null;

    /**
     * LLM の API 往復ログの出力先を設定する.
     * @param w ログの出力先
     */
    public void setLogWriter(PrintWriter w) { this.logWriter = w; }

    /**
     * LLM の API 往復ログの出力先をリセットする(null に戻す).
     */
    public void resetLogWriter() { this.logWriter = null; }

    /**
     * 探索深さを設定する.
     * @param black 黒番の探索手数
     * @param white 白番の探索手数
     */
    public void setReadGoal(int black, int white) {
        this.readGoal[0] = black;
        this.readGoal[1] = white;
    }

    /**
     * 黒・白それぞれの思考方法を設定する.
     * @param black 黒番の思考方法
     * @param white 白番の思考方法
     */
    public void setPlayMethod(PlayMethod black, PlayMethod white) {
        this.playMethod.clear();
        this.playMethod.add(this.resolvePlayMethod(black));
        this.playMethod.add(this.resolvePlayMethod(white));
    }

    /**
     * PlayMethod enum を実際の思考メソッドに紐づく関数参照へ変換する.
     * setPlayMethod から呼ばれ,playMethod フィールドに格納する関数を作る.
     * @param algo 変換対象の思考方法
     * @return 指定した思考方法に対応する関数
     */
    protected BiConsumer<long[], Boolean> resolvePlayMethod(PlayMethod algo) {
        switch (algo) {
            case RANDOM:  return this::random;
            case N_HAND:  return this::nHand;
            case N_LEAST: return this::nLeast;
            case N_MOST:  return this::nMost;
            case HUMAN:   return this::human;
            case LLM:     return this::llm;
            default: throw new IllegalArgumentException("unknown algorithm: " + algo);
        }
    }

    // ── 盤面テキスト変換 ──────────────────────────────────────

    /**
     * 座標を "d3" 形式の文字列に変換する.
     * @param row 行インデックス(0〜7)
     * @param col 列インデックス(0〜7)
     * @return "a1"〜"h8" 形式の文字列
     */
    public String coordToStr(int row, int col) {
        return String.valueOf("abcdefgh".charAt(col)) + (row + 1);
    }

    /**
     * ビットボードをテキスト形式に変換する.
     * 盤面・手番・置ける位置の一覧を含む.
     * @param board ビットボード
     * @param turn  手番(false=黒, true=白)
     * @return 盤面テキスト
     */
    public String boardToText(long[] board, boolean turn) {
        StringBuilder sb = new StringBuilder();
        sb.append("  a b c d e f g h\n");
        for (int row = 0; row < SIZE; row++) {
            sb.append(row + 1).append(" ");
            for (int col = 0; col < SIZE; col++) {
                long bit = 1L << ((row << ROW_SHIFT) + col);
                if      ((board[0] & bit) != 0) sb.append("B ");
                else if ((board[1] & bit) != 0) sb.append("W ");
                else                             sb.append(". ");
            }
            sb.append("\n");
        }
        sb.append(turn ? "白" : "黒").append("の手番\n");
        List<String> validMoves = new ArrayList<>();
        for (int row = 0; row < SIZE; row++)
            for (int col = 0; col < SIZE; col++)
                if (canPlace(row, col, board, turn)) validMoves.add(coordToStr(row, col));
        sb.append("置ける位置: ").append(String.join(", ", validMoves));
        return sb.toString();
    }

    // ── AI 戦略メソッド ────────────────────────────────────────

    /**
     * ランダムに有効な手を選ぶ.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void random(long[] board, boolean turn) {
        int row, col;
        do {
            row = OseroBase.rand.nextInt(SIZE);
            col = OseroBase.rand.nextInt(SIZE);
        } while (!canPlace(row, col, board, turn));
        place(row, col, board, turn);
    }

    /**
     * n手先の自分の石数が最大になる手を選ぶ.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     *
     */
    public void nHand(long[] board, boolean turn) {
        this.exploreAssist(board, turn, this::exploreNHand);
    }

    /**
     * n手先の相手の手数が最小になる手を選ぶ.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void nLeast(long[] board, boolean turn) {
        this.exploreAssist(board, turn, this::exploreNLeast);
    }

    /**
     * n手先の自分の手数が最大になる手を選ぶ.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void nMost(long[] board, boolean turn) {
        this.exploreAssist(board, turn, this::exploreNMost);
    }

    // ── 人間・LLM プレイヤー ──────────────────────────────────

    /**
     * 人間が手を選ぶ.盤面を表示して標準入力から "d3" 形式で受け付ける.
     * 無効な入力は再入力を促す.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void human(long[] board, boolean turn) {
        while (true) {
            System.out.print("手を入力してください (例: d3): ");
            String input = scanner.nextLine().trim().toLowerCase();
            if (input.length() == 2) {
                int col = "abcdefgh".indexOf(input.charAt(0));
                int row = "12345678".indexOf(input.charAt(1));
                if (col >= 0 && row >= 0 && canPlace(row, col, board, turn)) {
                    place(row, col, board, turn);
                    return;
                }
            }
            System.out.println("無効な手です.有効な手を選んでください.");
        }
    }

    /**
     * Ollama 経由でツール利用 LLM に手を問い合わせる.
     * 最大 3 回の思考セッションを試み,各セッションで LLM が simulate_move ツールを
     * 呼び出しながら思考して最終的な着手を返す.全セッション失敗時はランダムにフォールバックする.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     */
    public void llm(long[] board, boolean turn) {
        final String toolsJson = "[{\"type\":\"function\",\"function\":{"
                + "\"name\":\"simulate_move\","
                + "\"description\":\"指定座標に石を置いた場合の結果をシミュレートする\","
                + "\"parameters\":{\"type\":\"object\",\"properties\":{"
                + "\"coord\":{\"type\":\"string\",\"description\":\"座標(列 a〜h + 行 1〜8,例: d3)\"}"
                + "},\"required\":[\"coord\"]}}}]";
        final String initialMsg = "{\"role\":\"user\",\"content\":\""
                + escapeForJson(boardToText(board, turn)) + "\"}";
        HttpClient client = HttpClient.newHttpClient();

        // 最大三回チャレンジする
        for (int attempt = 0; attempt < 3; attempt++) {
            // セッションごとにメッセージ履歴をリセット
            List<String> messages = new ArrayList<>();
            messages.add(initialMsg);

            // 一回のセッションで最大十回会話する
            for (int loop = 0; loop < 10; loop++) {
                try {
                    // リクエスト body 組み立て
                    StringBuilder reqBody = new StringBuilder();
                    reqBody.append("{\"model\":\"").append(LLM_MODEL)
                           .append("\",\"stream\":false,\"tools\":").append(toolsJson)
                           .append(",\"messages\":[");
                    for (int i = 0; i < messages.size(); i++) {
                        if (i > 0) reqBody.append(",");
                        reqBody.append(messages.get(i));
                    }
                    reqBody.append("]}");

                    // リクエスト送信
                    HttpRequest req = HttpRequest.newBuilder()
                            .uri(URI.create(OLLAMA_URL + "/api/chat"))
                            .header("Content-Type", "application/json")
                            .POST(HttpRequest.BodyPublishers.ofString(reqBody.toString()))
                            .build();
                    HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());

                    if (resp.statusCode() != 200) {
                        System.err.println("Ollama HTTP エラー: " + resp.statusCode());
                        break;
                    }

                    String body = resp.body();
                    if (logWriter != null) {
                        logWriter.println("{\"request\":" + reqBody + ",\"response\":" + body + "}");
                    }

                    // レスポンスの message オブジェクトを抽出(履歴追加用)
                    String assistantMsg = null;
                    int msgKeyIdx = body.indexOf("\"message\":");
                    if (msgKeyIdx >= 0) {
                        int braceIdx = body.indexOf("{", msgKeyIdx + 10);
                        if (braceIdx >= 0) assistantMsg = extractJsonObject(body, braceIdx);
                    }
                    if (assistantMsg == null) {
                        System.err.println("Ollama レスポンスに message フィールドがありません");
                        break;
                    }

                    // ツール呼び出しの指定があれば
                    if (body.indexOf("\"tool_calls\"") >= 0) {
                        // ── ツール呼び出し ──────────────────────────────
                        if (assistantMsg != null) messages.add(assistantMsg);

                        int searchFrom = body.indexOf("\"tool_calls\"");
                        while (searchFrom >= 0) { // 複数のツールが指定されている可能性があるのでループする
                            // ツール引数作成
                            int argIdx = body.indexOf("\"arguments\"", searchFrom);
                            if (argIdx < 0) break;
                            int argObjStart = body.indexOf("{", argIdx + 11);
                            if (argObjStart < 0) break;
                            String argObj = extractJsonObject(body, argObjStart);

                            String coord = extractStringField(argObj, "\"coord\"");

                            // ツール呼び出し実行
                            String toolContent;
                            try {
                                SimulateResult res = simulateMove(board, turn, coord);
                                toolContent = buildSimulateResultJson(res);
                            } catch (IllegalArgumentException e) {
                                toolContent = "{\"error\":\"" + escapeForJson(e.getMessage()) + "\"}";
                            }
                            messages.add("{\"role\":\"tool\",\"content\":\""
                                    + escapeForJson(toolContent) + "\"}");
                            searchFrom = argIdx + 1;
                        }
                    // ツール呼び出しの指定がない
                    } else {
                        // ── 最終回答 ──────────────────────────────────
                        String content = assistantMsg != null
                                ? extractStringField(assistantMsg, "\"content\"") : "";
                        int thinkEnd = content.indexOf("</think>");
                        String moveArea = thinkEnd >= 0 ? content.substring(thinkEnd + 8) : content;

                        Matcher mv = Pattern.compile("[a-h][1-8]").matcher(moveArea);
                        List<String> candidates = new ArrayList<>();
                        while (mv.find()) candidates.add(mv.group());
                        for (int i = candidates.size() - 1; i >= 0; i--) {
                            int col = "abcdefgh".indexOf(candidates.get(i).charAt(0));
                            int row = "12345678".indexOf(candidates.get(i).charAt(1));
                            if (canPlace(row, col, board, turn)) {
                                place(row, col, board, turn);
                                return;
                            }
                        }
                        break; // 有効手が見つからない → このセッション失敗,次の attempt へ
                    }

                } catch (Exception e) {
                    System.err.println("Ollama リクエストエラー: " + e.getMessage());
                    break;
                }
            }
        }

        // フォールバック: ランダムに打つ
        System.err.println("LLM から有効な手を取得できなかったため,ランダムに打ちます");
        random(board, turn);
    }

    /** JSON 文字列内の特殊文字をエスケープする(llm() のリクエスト組み立て用). */
    private String escapeForJson(String s) {
        return s.replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\n", "\\n")
                .replace("\r", "\\r")
                .replace("\t", "\\t");
    }

    /**
     * 指定位置を開始点とする JSON オブジェクト文字列を抽出する.
     * 文字列リテラル内の括弧を読み飛ばし,対応する '}' を正確に判定する.
     * @param s     検索対象の文字列
     * @param start '{' の位置
     * @return '{' から対応する '}' までの部分文字列
     */
    private String extractJsonObject(String s, int start) {
        int depth = 0;
        boolean inStr = false;
        for (int i = start; i < s.length(); i++) {
            char c = s.charAt(i);
            if (inStr) {
                if      (c == '\\') i++;
                else if (c == '"')  inStr = false;
            } else {
                if      (c == '"') inStr = true;
                else if (c == '{') depth++;
                else if (c == '}' && --depth == 0) return s.substring(start, i + 1);
            }
        }
        return s.substring(start);
    }

    /**
     * 文字列中の指定キーに対応する JSON 文字列フィールドの値を返す.
     * エスケープシーケンス(\n, \r, \t, \\, \")をデコードして返す.
     * @param s   検索対象の文字列(エスケープ済み)
     * @param key 検索キー(例: "\"content\"")
     * @return フィールドの値(デコード済み),キーが存在しない場合は空文字列
     */
    private String extractStringField(String s, String key) {
        int keyIdx = s.indexOf(key);
        if (keyIdx < 0) return "";
        int colonIdx = s.indexOf(":", keyIdx + key.length());
        if (colonIdx < 0) return "";
        int quoteIdx = s.indexOf("\"", colonIdx + 1);
        if (quoteIdx < 0) return "";
        StringBuilder sb = new StringBuilder();
        for (int i = quoteIdx + 1; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == '\\' && i + 1 < s.length()) {
                char esc = s.charAt(++i);
                if      (esc == 'n') sb.append('\n');
                else if (esc == 'r') sb.append('\r');
                else if (esc == 't') sb.append('\t');
                else                 sb.append(esc);
            } else if (c == '"') {
                break;
            } else {
                sb.append(c);
            }
        }
        return sb.toString();
    }

    /**
     * SimulateResult を LLM に返す JSON 文字列に変換する.
     * @param res シミュレーション結果
     * @return JSON 文字列
     */
    private String buildSimulateResultJson(SimulateResult res) {
        StringBuilder sb = new StringBuilder();
        sb.append("{\"board\":\"").append(escapeForJson(res.boardText)).append("\"");
        sb.append(",\"my_stones\":").append(res.myStones);
        sb.append(",\"opponent_stones\":").append(res.opponentStones);
        sb.append(",\"stable_gained\":").append(res.stableGained);
        sb.append(",\"flipped\":[");
        for (int i = 0; i < res.flipped.size(); i++) {
            if (i > 0) sb.append(",");
            sb.append("\"").append(res.flipped.get(i)).append("\"");
        }
        sb.append("],\"opponent_valid_moves\":[");
        for (int i = 0; i < res.opponentValidMoves.size(); i++) {
            if (i > 0) sb.append(",");
            sb.append("\"").append(res.opponentValidMoves.get(i)).append("\"");
        }
        sb.append("]}");
        return sb.toString();
    }

    // ── LLM ツールサポート ────────────────────────────────────

    /**
     * simulate_move ツールの戻り値を格納するデータクラス.
     */
    protected static class SimulateResult {
        /** 着手後の盤面テキスト */
        String boardText;
        /** 着手側の石数 */
        int myStones;
        /** 相手の石数 */
        int opponentStones;
        /** ひっくり返した石の座標リスト("d4" 形式) */
        List<String> flipped;
        /** 着手後に相手が置ける位置一覧 */
        List<String> opponentValidMoves;
        /** この着手で増えた自分の安定石の数 */
        int stableGained;
    }

    /**
     * 指定座標に石を置いた場合の結果をシミュレートする(実際の盤面は変更しない).
     * 座標は "d3" 形式(列 a〜h + 行 1〜8)で受け取り,内部で行・列インデックスに変換する.
     * @param board ビットボード(board[0]=黒, board[1]=白)
     * @param turn  手番(false=黒, true=白)
     * @param coord 座標文字列(例: "d3")
     * @return シミュレーション結果
     * @throws IllegalArgumentException 座標が不正または石を置けない場合
     */
    protected SimulateResult simulateMove(long[] board, boolean turn, String coord) {
        if (coord == null || coord.length() < 2)
            throw new IllegalArgumentException("無効な座標形式: " + coord);
        int col = "abcdefgh".indexOf(coord.charAt(0));
        int row = "12345678".indexOf(coord.charAt(1));
        if (col < 0 || row < 0)
            throw new IllegalArgumentException("無効な座標: " + coord);
        if (!canPlace(row, col, board, turn))
            throw new IllegalArgumentException("置けない位置が指定されました: " + coord);

        int my  = turn ? 1 : 0;
        int opp = turn ? 0 : 1;
        int before = countStableStones(board, turn);

        long[] boardCopy = {board[0], board[1]};
        place(row, col, boardCopy, turn);

        SimulateResult res = new SimulateResult();
        res.myStones       = countStones(boardCopy, turn);
        res.opponentStones = countStones(boardCopy, !turn);
        res.boardText      = boardToText(boardCopy, !turn);
        res.stableGained   = countStableStones(boardCopy, turn) - before;

        // 着手前に相手の石だったもので,着手後に自分の石になったもの = ひっくり返した石
        long flippedBits = board[opp] & boardCopy[my];
        res.flipped = new ArrayList<>();
        for (int r = 0; r < SIZE; r++)
            for (int c = 0; c < SIZE; c++)
                if ((flippedBits & (1L << ((r << ROW_SHIFT) + c))) != 0)
                    res.flipped.add(coordToStr(r, c));

        // 着手後に相手が置ける位置
        res.opponentValidMoves = new ArrayList<>();
        for (int r = 0; r < SIZE; r++)
            for (int c = 0; c < SIZE; c++)
                if (canPlace(r, c, boardCopy, !turn))
                    res.opponentValidMoves.add(coordToStr(r, c));

        return res;
    }

    // ── 探索フレームワーク ────────────────────────────────────

    /** 盤面評価用インターフェース */
    @FunctionalInterface
    interface ScoreFunction {
        /**
         * ある盤面のスコアを計算するメソッド
         * @param board   今の盤面
         * @param nowTurn 評価時の手番(false=黒, true=白)
         * @param turn    盤面の手番(false=黒, true=白)
         * @param num     今評価している盤面が何手先のものか
         * @return        その盤面のスコア
         */
        double getScore(long[] board, boolean nowTurn, boolean turn, int num);
    }

    /**
     * 全候補手を評価し,最高スコアの手をランダムに選んで実際に置く.
     * DataGen ではこのメソッドを拡張した thinkAndGetMove() を使う.
     * @param board 今の盤面
     * @param turn  手番(false=黒, true=白)
     * @param func  ある盤面のスコアを計算するメソッド
     */
    public void exploreAssist(long[] board, boolean turn, ScoreFunction func) {
        double maxScore = -Double.MAX_VALUE;
        int[] rowAns = new int[SIZE * 2];
        int[] colAns = new int[SIZE * 2];
        int placeNum = 0;
        long[] boardLeaf = new long[2];

        // 全てのマスでループ
        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                // 石を置けないマスは飛ばす
                if (!canPlace(row, col, board, turn)) continue;

                // 局面をコピーしてそこに石を置き,スコア計算
                boardLeaf[0] = board[0]; boardLeaf[1] = board[1];
                place(row, col, boardLeaf, turn);
                double score = func.getScore(boardLeaf, turn, !turn, 1);

                // 最大スコアとなる位置を記録しておく
                if (score > maxScore) {
                    maxScore  = score;
                    placeNum  = 0;
                    rowAns[0] = row; colAns[0] = col;
                } else if (score == maxScore) {
                    placeNum++;
                    rowAns[placeNum] = row; colAns[placeNum] = col;
                }
            }
        }

        // 同スコアの手が複数あればランダムに選ぶ
        if (placeNum > 0) {
            int idx = rand.nextInt(placeNum + 1);
            rowAns[0] = rowAns[idx]; colAns[0] = colAns[idx];
        }

        // 選んだ場所に実際に置く
        place(rowAns[0], colAns[0], board, turn);
    }

    // ── 再帰探索メソッド ──────────────────────────────────────

    /**
     * n手先の nowTurn の石数の平均を計算する(再帰).
     * @param board   今の盤面
     * @param nowTurn 評価基準となるプレイヤー(変化しない)
     * @param turn    現在手を打つプレイヤー
     * @param num     現在の探索手数
     * @return        nowTurn の石数の平均
     */
    public double exploreNHand(long[] board, boolean nowTurn, boolean turn, int num) {
        // 探索し切ったなら今の意思の数を返す
        if (num >= this.readGoal[nowTurn ? 1 : 0]) return countStones(board, nowTurn);

        double total = 0.0;
        int placeNum = 0;
        long[] boardLeaf = new long[2];

        // 全てのマスをループ
        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                // 置けないならスキップ
                if (!canPlace(row, col, board, turn)) continue;

                // ボード状態をコピーしてコピー先に石を置く
                boardLeaf[0] = board[0]; boardLeaf[1] = board[1];
                place(row, col, boardLeaf, turn);

                // 置いた先を探索
                total += this.exploreNHand(boardLeaf, nowTurn, !turn, num + 1);
                placeNum++;
            }
        }

        // 現在の手番がパス。相手も置けなければゲーム終了(両者パス)
        if (placeNum == 0) {
            if (canPlaceAny(board, !turn)) return this.exploreNHand(board, nowTurn, !turn, num);
            return countStones(board, nowTurn);
        }

        // 全手の平均スコアを返す
        return total / placeNum;
    }

    /**
     * n手先(nowTurn の手番基準)の相手の手数を最小化する(再帰).
     * nowTurn の手番のときだけ depth をインクリメントする.
     * 符号を反転しながら平均を取ることで最大化を最小化に変換する.
     * @param board   今の盤面
     * @param nowTurn 評価基準となるプレイヤー(変化しない)
     * @param turn    現在手を打つプレイヤー
     * @param num     現在の探索手数
     * @return        nowTurn にとっての評価値
     */
    public double exploreNLeast(long[] board, boolean nowTurn, boolean turn, int num) {
        // 探索し切ったなら今の手数を返す
        if (num >= this.readGoal[nowTurn ? 1 : 0]) {
            // 相手(-nowTurn)の手数を負値で返す(相手の手数が多いほど評価としては低い)
            int mobility = 0;
            for (int row = 0; row < SIZE; row++)
                for (int col = 0; col < SIZE; col++)
                    if (canPlace(row, col, board, !nowTurn)) mobility++;
            return -(double) mobility;
        }

        double total = 0.0;
        int placeNum = 0;
        long[] boardLeaf = new long[2];
        // nowTurn の手番のときだけ depth を進める
        int nextNum = (nowTurn == turn) ? num + 1 : num;

        // 全てのマスをループ
        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                // 置けないならスキップ
                if (!canPlace(row, col, board, turn)) continue;

                // ボード状態をコピーして石を置く
                boardLeaf[0] = board[0]; boardLeaf[1] = board[1];
                place(row, col, boardLeaf, turn);

                // 置いた先を探索
                total += this.exploreNLeast(boardLeaf, nowTurn, !turn, nextNum);
                placeNum++;
            }
        }

        // 現在の手番がパス。相手も置けなければゲーム終了(両者パス)
        if (placeNum == 0) {
            if (canPlaceAny(board, !turn)) return this.exploreNLeast(board, nowTurn, !turn, nextNum);
            return 0.0;
        }

        // 符号を反転して平均(相手のおける手数が多いほど評価は低い)
        return -total / placeNum;
    }

    /**
     * n手先(相手の手番基準)の nowTurn の手数を最大化する(再帰).
     * 相手の手番のときだけ depth をインクリメントする.
     * @param board   今の盤面
     * @param nowTurn 評価基準となるプレイヤー(変化しない)
     * @param turn    現在手を打つプレイヤー
     * @param num     現在の探索手数
     * @return        nowTurn にとっての評価値
     */
    public double exploreNMost(long[] board, boolean nowTurn, boolean turn, int num) {
        // 探索し切ったなら今の手数を返す
        if (nowTurn == turn && num >= this.readGoal[nowTurn ? 1 : 0]) {
            int mobility = 0;
            for (int row = 0; row < SIZE; row++)
                for (int col = 0; col < SIZE; col++)
                    if (canPlace(row, col, board, nowTurn)) mobility++;
            return (double) mobility;
        }

        double total = 0.0;
        int placeNum = 0;
        long[] boardLeaf = new long[2];
        // 相手の手番のときだけ depth を進める
        int nextNum = (nowTurn != turn) ? num + 1 : num;

        // 全てのマスをループ
        for (int row = 0; row < SIZE; row++) {
            for (int col = 0; col < SIZE; col++) {
                // 置けないならスキップ
                if (!canPlace(row, col, board, turn)) continue;

                // ボード状態をコピーして石を置く
                boardLeaf[0] = board[0]; boardLeaf[1] = board[1];
                place(row, col, boardLeaf, turn);

                // 置いた先を探索
                total += this.exploreNMost(boardLeaf, nowTurn, !turn, nextNum);
                placeNum++;
            }
        }

        // 現在の手番がパス。相手も置けなければゲーム終了(両者パス)
        if (placeNum == 0) {
            if (canPlaceAny(board, !turn)) return this.exploreNMost(board, nowTurn, !turn, nextNum);
            return 0.0;
        }

        return total / placeNum;
    }
}
Match.java
Match.java
import java.io.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.function.BiConsumer;

/**
 * 任意の2プレイヤーで1ゲーム対局するクラス.
 *
 * 使い方:
 *   javac *.java
 *   java Match <黒プレイヤー> <白プレイヤー>
 *
 * プレイヤー指定: human / llm / random / nhand / nleast / nmost
 *   例: java Match human llm
 *   例: java Match llm nhand
 */
public class Match extends Osero {

    private static final int READ_GOAL = 3;

    /**
     * 1ゲームを対局する.
     * 毎手番後に盤面を表示し,終局時に結果を出力する.
     * @param black 黒番のプレイヤー
     * @param white 白番のプレイヤー
     */
    public void runGame(PlayMethod black, PlayMethod white) {
        this.initBoard();
        BiConsumer<long[], Boolean> blackPlayer = resolvePlayMethod(black);
        BiConsumer<long[], Boolean> whitePlayer = resolvePlayMethod(white);
        boolean turn          = false; // 黒から開始
        boolean prevCouldPlay = true;

        // 初期盤面を表示
        System.out.println(boardToText(this.board, turn));
        System.out.println();

        while (true) {
            if (!canPlaceAny(this.board, turn)) {
                if (!prevCouldPlay) break; // 両者置けない → 終了
                prevCouldPlay = false;
                System.out.println((turn ? "白" : "黒") + " はパスします\n");
                turn = !turn;
                continue;
            }
            prevCouldPlay = true;

            System.out.println("【" + (turn ? "白" : "黒") + "の番】(" + (turn ? white : black) + ")");
            (turn ? whitePlayer : blackPlayer).accept(this.board, turn);
            turn = !turn;
            // 打った後に盤面を表示(次の手番の視点で valid moves も表示)
            System.out.println(boardToText(this.board, turn));
            System.out.println();
        }

        // 終局
        int blackStones = countStones(this.board, false);
        int whiteStones = countStones(this.board, true);
        System.out.println("=== 終局 ===");
        System.out.println("黒(" + black + "): " + blackStones + " 石");
        System.out.println("白(" + white + "): " + whiteStones + " 石");
        if      (blackStones > whiteStones) System.out.println("→ 黒の勝ち!");
        else if (whiteStones > blackStones) System.out.println("→ 白の勝ち!");
        else                                System.out.println("→ 引き分け");
    }

    /**
     * プレイヤー名の文字列を PlayMethod に変換する.
     * @param s プレイヤー名(human / llm / random / nhand / nleast / nmost)
     * @return 対応する PlayMethod
     */
    private static PlayMethod parsePlayMethod(String s) {
        switch (s.toLowerCase()) {
            case "human":  return PlayMethod.HUMAN;
            case "llm":    return PlayMethod.LLM;
            case "random": return PlayMethod.RANDOM;
            case "nhand":  return PlayMethod.N_HAND;
            case "nleast": return PlayMethod.N_LEAST;
            case "nmost":  return PlayMethod.N_MOST;
            default: throw new IllegalArgumentException(
                "不明なプレイヤー指定: " + s
                + "\n有効な値: human / llm / random / nhand / nleast / nmost");
        }
    }

    /**
     * 対局のエントリーポイント.
     * @param args args[0]=黒プレイヤー, args[1]=白プレイヤー
     */
    public static void main(String[] args) {
        if (args.length != 2) {
            System.err.println("使い方: java Match <黒プレイヤー> <白プレイヤー>");
            System.err.println("  例: java Match human llm");
            System.err.println("  例: java Match llm nhand");
            System.exit(1);
        }

        PlayMethod black, white;
        try {
            black = parsePlayMethod(args[0]);
            white = parsePlayMethod(args[1]);
        } catch (IllegalArgumentException e) {
            System.err.println(e.getMessage());
            System.exit(1);
            return;
        }

        Match match = new Match();
        match.setReadGoal(READ_GOAL, READ_GOAL);
        System.out.println("黒: " + black + " / 白: " + white + "\n");

        if (black == PlayMethod.LLM || white == PlayMethod.LLM) {
            String logFile = "../log/match_"
                    + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
                    + ".jsonl";
            try (PrintWriter lw = new PrintWriter(new FileWriter(logFile))) {
                match.setLogWriter(lw);
                match.runGame(black, white);
            } catch (IOException e) {
                System.err.println("ログファイル書き込みエラー: " + e.getMessage());
            }
        } else {
            match.runGame(black, white);
        }
    }
}
Evaluator.java
Evaluator.java
import java.io.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.function.BiConsumer;

/**
 * 学習済み LLM を既存 AI アルゴリズムと対戦させ,勝率を評価するクラス.
 *
 * 使い方:
 *   javac *.java
 *   java Evaluator
 */
public class Evaluator extends Osero {

    private static final int GAMES_PER_MATCHUP = 1;
    private static final int READ_GOAL         = 3;

    /** 対戦相手として使用する AI メソッドの一覧 */
    private static final PlayMethod[] OPPONENTS = {
        PlayMethod.N_HAND, PlayMethod.N_LEAST, PlayMethod.N_MOST, PlayMethod.RANDOM
    };

    /**
     * 1ゲームを実行し,勝者を返す.
     * @param black 黒番の思考方法
     * @param white 白番の思考方法
     * @return 1=黒勝ち, -1=白勝ち, 0=引き分け
     */
    public int runGame(PlayMethod black, PlayMethod white) {
        this.initBoard();
        BiConsumer<long[], Boolean> blackPlayer = resolvePlayMethod(black);
        BiConsumer<long[], Boolean> whitePlayer = resolvePlayMethod(white);
        boolean turn          = false;
        boolean prevCouldPlay = true;

        while (true) {
            if (!canPlaceAny(this.board, turn)) {
                if (!prevCouldPlay) break; // 両者置けない → 終了
                prevCouldPlay = false;
                turn = !turn;
                continue;
            }
            prevCouldPlay = true;
            (turn ? whitePlayer : blackPlayer).accept(this.board, turn);
            turn = !turn;
        }

        int blackStones = countStones(this.board, false);
        int whiteStones = countStones(this.board, true);
        if      (blackStones > whiteStones) return  1;
        else if (whiteStones > blackStones) return -1;
        else                                return  0;
    }

    /**
     * 秒数を "Xm Ys" 形式にフォーマットする.
     * @param seconds 秒数
     * @return フォーマットされた時間文字列
     */
    private static String formatTime(long seconds) {
        return String.format("%dm%02ds", seconds / 60, seconds % 60);
    }

    /**
     * 進捗行を標準出力に上書き表示する.
     * @param opp     現在の対戦相手
     * @param bDone   現在の対戦相手での黒番完了ゲーム数
     * @param wDone   現在の対戦相手での白番完了ゲーム数
     * @param done    全体完了ゲーム数
     * @param total   全体ゲーム数
     * @param startMs 開始時刻(ミリ秒)
     */
    private static void printProgress(PlayMethod opp, int bDone, int wDone,
                                      int done, int total, long startMs) {
        long elapsed   = (System.currentTimeMillis() - startMs) / 1000;
        long remaining = done > 0 ? elapsed * (total - done) / done : 0;
        System.out.printf("\r[%s] 黒 %d/%d 白 %d/%d | 全体 %d/%d (%d%%) | 経過 %s 残り約 %s   ",
                opp, bDone, GAMES_PER_MATCHUP, wDone, GAMES_PER_MATCHUP,
                done, total, done * 100 / total,
                formatTime(elapsed), formatTime(remaining));
    }

    /**
     * 評価のエントリーポイント.
     * LLM を各 AI と GAMES_PER_MATCHUP ゲームずつ対戦させ,結果を CSV に保存する.
     * @param args 未使用
     * @throws IOException CSV ファイルの書き込みに失敗した場合
     */
    public static void main(String[] args) throws IOException {
        Evaluator ev = new Evaluator();
        ev.setReadGoal(READ_GOAL, READ_GOAL);

        LocalDateTime now = LocalDateTime.now();
        String filename = "../csv/eval_" + LLM_MODEL + "_"
                + now.format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".csv";
        String date = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        int totalGames = OPPONENTS.length * GAMES_PER_MATCHUP * 2;
        int doneGames  = 0;
        long startMs   = System.currentTimeMillis();

        try (PrintWriter csv = new PrintWriter(new FileWriter(filename))) {
            csv.println("model,date,games_per_matchup,opponent,b_win,b_lose,b_draw,w_win,w_lose,w_draw");

            for (PlayMethod opp : OPPONENTS) {
                int bWin = 0, bLose = 0, bDraw = 0;
                int wWin = 0, wLose = 0, wDraw = 0;

                for (int i = 0; i < GAMES_PER_MATCHUP; i++) {
                    String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));

                    // LLM が黒
                    try (PrintWriter lw = new PrintWriter(new FileWriter(
                            "../log/eval_" + opp + "_b_" + (i + 1) + "_" + ts + ".jsonl"))) {
                        ev.setLogWriter(lw);
                        int r1 = ev.runGame(PlayMethod.LLM, opp);
                        if      (r1 ==  1) bWin++;
                        else if (r1 == -1) bLose++;
                        else               bDraw++;
                    }
                    ev.resetLogWriter();
                    doneGames++;
                    printProgress(opp, i + 1, i, doneGames, totalGames, startMs);

                    ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));

                    // LLM が白
                    try (PrintWriter lw = new PrintWriter(new FileWriter(
                            "../log/eval_" + opp + "_w_" + (i + 1) + "_" + ts + ".jsonl"))) {
                        ev.setLogWriter(lw);
                        int r2 = ev.runGame(opp, PlayMethod.LLM);
                        if      (r2 == -1) wWin++;
                        else if (r2 ==  1) wLose++;
                        else               wDraw++;
                    }
                    ev.resetLogWriter();
                    doneGames++;
                    printProgress(opp, i + 1, i + 1, doneGames, totalGames, startMs);
                }

                System.out.printf("\r[%s] 完了 黒 %d勝%d敗%d分 白 %d勝%d敗%d分%n",
                        opp, bWin, bLose, bDraw, wWin, wLose, wDraw);

                csv.printf("%s,%s,%d,%s,%d,%d,%d,%d,%d,%d%n",
                        LLM_MODEL, date, GAMES_PER_MATCHUP, opp,
                        bWin, bLose, bDraw, wWin, wLose, wDraw);
            }
        }

        System.out.println("結果を保存しました: " + filename);
    }
}

次回は

思っていたよりもかなり強いAIにはなりましたが,いくつか欠点もあります.

  • 考える時間が長すぎる
  • 機械的な思考方法が相手だと弱い
  • 一手先までしか読まない
  • セオリー通りにしか打たないので,どのような考えで打っているのかを知られると弱い(これは仕方ない気もするが)

次回以降ではこのあたりの欠点を克服出来たらなーと思います.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?