Keiko Labs

LLMが返した指し手を、盤に載せる前に全手検証する

2026-10-27

チェス・将棋の検討盤 Ponder には、AIの回答の中の指し手に ▶ ボタンが付き、押すとその手が盤に反映される機能があります。文章で「ここで Qb8+ が決め手です」と読むより、押して盤が動くほうが圧倒的に分かりやすい。この記事は、その裏側で LLMの出力をどう扱っているか の記録です。

結論から言うと、LLMが返した指し手は、1手も信用せずに全部ルールエンジンで検証してから盤に載せています。

前提:LLMに合法手判定をさせない

このアプリでは、盤面のロジックはすべてルールエンジン(チェスは chess.js、将棋は tsshogi)が持っています。LLMには「解説を書く」ことだけを頼み、「この手は合法か」を判断させることはしません。LLMは局面の理解を間違えることがあり、そのまま盤に反映すると、ルール上ありえない駒の動きが表示されてしまうからです。

LLMには「本文」と「手順データ」を分けて返させる

回答は JSON で受け取ります。本文(answer)とは別に、moves という配列を返してもらい、そこに ボタンにしたい指し手の正規表記 を入れさせます。

本文の中では、指し手に触れた直後に [[1]] [[2]] のような通し番号のトークンを置いてもらいます。moves の各要素は次の形です。

ポイントは、本文の言い回しは自由にさせ、動作に使う line にだけ正規表記を要求することです。「ここで飛車を寄る」という口語の本文と、8b2b という機械が読める表記を分けることで、解説の自然さと検証の厳密さを両立させています。

検証は二段階

受け取った moves は、まず 形式検証 を通します。token が文字列か、kind が jump / line のどちらかか、fromPly が0以上の整数か、line が文字列の配列か。どれか一つでも欠けていれば、その要素は捨てます。同じトークンが2回出てきたら先勝ちです。line なのに手が空、というものも無効です。

const fromPly = typeof m.fromPly === 'number' && Number.isInteger(m.fromPly) && m.fromPly >= 0 ? m.fromPly : null;
const line = Array.isArray(m.line) && m.line.every((s) => typeof s === 'string') ? (m.line as string[]) : null;
if (!token || !kind || fromPly === null || line === null || !label) continue;

形式が通ったものだけが、次の 合法性検証 に進みます。これは盤面の状態が要るので、描画する側で行います。エンジンの共通インターフェースには、こういうメソッドを一つ置いています。

/**
 * notation列(チェス=SAN / 将棋=USI)を position から検証しながら適用。
 * 1手でも非合法なら null(部分適用はしない)。basePly = position までに指された手数
 */
tryLine(position: string, notations: string[], basePly: number): MoveRecord[] | null;

fromPly の局面から line の手を順に適用し、1手でも非合法なら全体を null にする。「3手目までは合法だったので3手目まで再生する」という部分適用はしません。途中まで動いた盤面は、LLMが意図した局面と違うものになっているからです。

検証を通らなかったトークンは、本文からも取り除きます。読み手には、その指し手にボタンが無かったことすら分からない。中途半端に「押せないボタン」を見せるより、その方が良いと判断しました。

実際に起きた誤り

検証を入れてから、LLMの出力で何が起きるかが見えるようになりました。プロンプトには「よくある誤り」としてそのまま書いてあります。

一番多いのは fromPly の取り違え です。チェスの棋譜番号(12.O-O-O の「12」)と、両者の手を1手ずつ通しで数えた手数(この場合は23手目)は違います。LLMは棋譜番号のほうを fromPly に入れがちで、そうすると全く別の局面から手を適用することになり、当然非合法になります。

実際に弾かれた例を一つ。不滅の一局(Anderssen vs Kieseritzky, 1851)の35手目、18.Bd6 の直後の局面で「黒の最善手は?」と聞いたときの出力です。

{ "token": "1", "kind": "line", "fromPly": 36, "line": ["Bxg1"], "label": "18...Bxg1" }

手そのものは正しい(実戦もこう進んだ)のに、fromPly が 36 になっています。36手目を指した後の局面、つまり Bxg1 をすでに指し終えた局面 から Bxg1 を適用しようとしているので、ビショップはもう g1 にいて非合法。正しくは 35 です。「その手を指す前の局面」と「指した後の局面」を取り違える、という誤りが、ちょうど1ずれた数字になって現れます。

プロンプトには「12.O-O-O は白の12手目ですが、通し手数では23手目です」と具体例まで書いて、さらに「現在の局面」の手数を明示して渡すようにしました。それでも、ゼロにはなりません。だから検証で落とすのです。

もう一つは、同じ側の手が2つ続く手順 です。「A を指して、その次に B」のつもりで line: ["A", "B"] と返してくると、A と B の間に相手の応手が入っていないため、B は相手の手として解釈されて非合法になります。将棋なら「☗4六歩から☗4五歩と伸ばす」のつもりで ["4g4f", "4f4e"] と返してくる形で、2手目は後手の手として読まれるので歩が動かせません。これはプロンプトで「間の応手も含めた完全な手順を書くか、そのトークン自体を付けないこと」と指示しています。

この経験から、プロンプトの最後にはこう書いています。確信が持てないときはトークンを付けないほうが良い結果になる。 検証を通らなかったトークンは誰にも表示されないので、無理に付けても得がないからです。

なぜここまでやるのか

AIの解説文は「参考情報で、誤ることがある」と言えば済みます。しかし、盤に載る駒の動きが間違っているのは別の話です。検討盤として、盤面だけは常にルール通りであってほしい。

LLMを「解説を書く係」に限定し、盤面の正しさはルールエンジンが担保する。この役割分担のおかげで、LLMがどれだけ間違えても、盤に載る手はルール上ありえるものだけになっています。

最後に、うまくいったときの見え方を。オペラ座の対局の22手目で「この局面の最善手は?」と聞くと、こう返ってきます。

この局面での最善手は 12.O-O-O(クイーンサイドキャスリング)です [▶ O-O-O]。キャスリングによってルークをdファイルに配置し、ピン留めされた黒のナイト(d7)への圧力を一気に強めます。

裏では { "kind": "line", "fromPly": 22, "line": ["O-O-O"] } が届き、22手目の局面から O-O-O が合法であることを確認してから、はじめてボタンになります。押すとキングが c1 に、ルークが d1 に動く。解説を読んで、▶ を押して、盤で確かめる。その流れが成り立つのは、押した先が信用できるからだと思っています。

Ponder — チェス・将棋の棋譜に「なぜ?」を聞ける検討盤

棋譜を読み込んで、局面についてAIに質問できます。回答に出てくる指し手を押すと、そのまま盤が動きます。

無料デモを試す(登録不要) Ponderについて