はじめに
現在、Sideways という小さな水平思考ゲームを作っています。
いわゆる「ウミガメのスープ」に近いゲームで、プレイヤーには謎めいた状況だけを提示します。
そしてプレイヤーは、
客を起こしたのは光?
その仕組みは音を出す?
のような質問を繰り返しながら真相に近づいていきます。
英語版なので、実際の入力は例えば次のようになります。
is it light that wakes the guest?
ゲームマスターなら、
Yes.
と答えればよい。
一見すると、とても簡単なAI機能に見えます。
しかし実際に作ってみると、これがかなり難しい問題でした。
今回は、Jevを使ってAIゲームマスターを作る中で、意味判定を細かく設計すればするほど、逆に実際のゲーム体験が悪くなってしまった話を書きます。
なぜJevを使ったのか
このゲームでは、一般的なチャットAIのように長い文章を生成してほしいわけではありません。
必要なのは、
YES
NO
UNKNOWN
IRRELEVANT
のような「判断」です。
そこでJevを選びました。
Jevは公式ドキュメントでもチャットモデルではなく decision model として説明されており、application stateとtyped questionを渡し、構造化された判断と確率を受け取る設計になっています。
Choice、Score、Noul といった型を利用できます。
これは今回作りたかったアーキテクチャと非常に相性が良さそうでした。
最初に考えたアーキテクチャ
最初から、AIにアプリケーション全体を制御させるつもりはありませんでした。
考えた構造は次の通りです。
プレイヤーの文章
↓
AIによる確率的な意味判定
↓
型付き出力
↓
TypeScriptによる決定論的ロジック
↓
ユーザーに返す結果
考え方としては、
AI = 意味を判断する
Code = ポリシーを決める
User = 重要な意図を選択する
です。
AIが直接、
このユーザーにはこの画面を表示しよう
と判断するわけではありません。
AIには意味だけを判断してもらい、その結果から何をするかはTypeScript側で決めます。
この構造自体は、今でもかなり気に入っています。
問題は、AIにどれだけ細かい意味情報を返してもらうかでした。
Version 1:比較的シンプルな判定
最初のQuestion Judgeでは、プレイヤーの質問について、おおよそ次のような情報を判定していました。
{
wellFormed: number,
relevant: number,
evidence: {
entailed: number,
contradicted: number,
undetermined: number
}
}
そしてTypeScript側で、
if (wellFormed < 0.8) return "UNCLEAR";
if (relevant <= 0.2) return "IRRELEVANT";
if (entailed >= 0.8) return "YES";
if (contradicted >= 0.8) return "NO";
return "UNCLEAR";
のように結果へ変換します。
この方式には良いところがありました。
例えば、
YESの確率が低い
からといって、
NO
とは限りません。
問題文からは分からないことなら、
UNKNOWN
として扱えます。
これはかなり重要です。
しかし、実際にゲームとして使い始めると別の問題が出てきました。
人間の文章はそんなに綺麗ではない
プレイヤーは必ずしも正しい英文を書きません。
例えば、
light wake him?
だったり、
does it make sound
だったり、
maybe lamp?
だったりします。
スペルミスもあります。
冠詞も抜けます。
主語も省略します。
子どもや英語の非ネイティブユーザーなら、なおさらです。
そこで、
もっと意味を細かく判定すれば、こうした文章にも柔軟に対応できるのでは?
と考えました。
これがVersion 2です。
Version 2:意味判定をかなり細かくした
Question Judgeを次のように拡張しました。
{
wellFormed,
relevant,
evidence: {
entailed,
contradicted,
undetermined
},
mixedClaims: {
supported,
contradicted
}
}
さらに、
PARTLY
という結果も追加しました。
例えば、
There is no alarm. Only a lamp wakes the guest.
という文章には、
アラームは存在しない
という誤りと、
光が客を起こす
という重要な正解が同居しています。
単純に NO にするのは少し厳しすぎます。
そこで、
Partly
と返せる構造にしました。
Theory判定はさらに細かくした
プレイヤーが「これは答えだ」とTheoryとして送った場合には、Questionとは別のSolution Judgeを使います。
Version 2では、次の5軸を判定するようにしました。
{
coreMechanism,
causalRelation,
supportingInsight,
contextualError,
competingMechanism
}
例えば、
There is a light.
と、
The light wakes the guest.
は同じではありません。
前者なら、
重要な物体には気づいた
だけかもしれません。
後者なら、
原因まで理解した
と言えます。
さらに、
The alarm uses a light,
but vibration wakes the guest.
なら、lightという正しい単語が入っていても、
客を起こしているのは振動
という間違った原因を主張しています。
そこで、
There is a light.
→ PARTIAL
The light wakes the guest.
→ SOLVED
Light exists, but vibration wakes the guest.
→ NOT_SOLVED
と区別できるようにしたかったわけです。
設計上はかなり綺麗になりました。
テストもかなり通った
Version 2では、
880 unit/API tests
82 desktop/mobile E2E tests
Leak scan
Lint
Typecheck
Build
などをすべて通しました。
さらに6つすべての問題について、
Calibration corpus
Generalization corpus
も作成しました。
Hidden Solutionはserver-only。
ブラウザには秘密情報を返さない。
通常の質問ならAI呼び出しは1回。
Theoryも1回。
セキュリティや配線としては、かなり良い状態になっていました。
そこで実際のJevを使ってPreview環境で試しました。
実際に遊ぶと、全然うまくいかなかった
例えば次の質問です。
is it light?
結果:
Not enough information
次。
is it light that wakes the guest?
結果:
Not enough information
これをTheoryとして送っても、
Not quite yet.
でした。
さらに、
does the wake up mechanism make sound?
結果:
Please rephrase
多少スペルを間違えて、
does the wake up mechines make sound?
としても、
Please rephrase
です。
しかし、人間なら意味はほぼ確実に理解できます。
さらに、
Instead of an alarm, lights from a device wakes the guest.
Questionとして送ると、
Not enough information
Theoryとして送ると、
Not quite yet.
でした。
英語として完璧ではありません。
しかし、
デバイスの光が客を起こす
という重要な因果関係はかなり明確です。
ここで、
Version 2の方向性そのものに問題があるのでは?
と考え始めました。
原因1:独立していないものを独立した数値にしていた
Version 2では、
文章を理解できるか
関連しているか
正しいか
複数の主張があるか
因果関係まで理解しているか
などをそれぞれ独立したsignalとして扱っていました。
しかし人間は、おそらくそんなふうには理解していません。
例えば、
does the wake up mechines make sound?
を読んだ人間は、
mechines は mechanism のミスだろう
↓
wake-up mechanism のことだろう
↓
音が出るか聞いている
↓
答えは No
くらいをほぼ同時に処理します。
ところがAIには、
wellFormedは何点?
relevanceは何点?
contradictedは何点?
と分割して答えさせていました。
TypeScriptとしては綺麗です。
しかし自然言語理解としては、必ずしも自然な分割ではなかった可能性があります。
原因2:確率を使ったのに、0.8が「崖」になった
Version 2では例えば、
if (wellFormed < 0.8) {
return "REPHRASE";
}
という処理があります。
AI側が、
意味はほぼ理解できる
と思っていたとしても、
wellFormed = 0.77
なら、
Please rephrase
です。
その後にどれだけ正しいsemantic evidenceがあっても関係ありません。
本来、
0.77
という連続的な確率を使っていたはずなのに、
0.7999
→ NG
0.8000
→ OK
という急激な境界をアプリ側で作っていました。
意味の軸を増やせば増やすほど、この「崖」も増えていきます。
原因3:小さなDecisionをさせるつもりが、全体では大きな推論になった
Jevでは、Choice、Score、Noul といった型付きのdecisionを扱えます。
公式ドキュメントでも、それぞれのquestionをspecificかつwell-scopedにすることが推奨されています。
しかしVersion 2で実際にやらせていたことをまとめると、
崩れた英文を理解する
↓
代名詞を解決する
↓
何を主張しているか理解する
↓
謎と関係があるか判断する
↓
Hidden Truthと比較する
↓
複数の主張を分解する
↓
正しい部分と間違った部分を区別する
↓
因果関係の完成度を判断する
となります。
各出力項目は小さく見えます。
しかし、モデルに要求している意味理解全体は小さくありませんでした。
原因4:「問題文」はあったが「何を解こうとしているか」が明示されていなかった
これは今回かなり重要な発見でした。
例えば、
is it light?
という文章だけを見れば、
itって何?
となるのは正しいです。
しかしウミガメのスープのゲームマスターなら、
今プレイヤーは
「なぜ客が起きたのか」
を調べている
という文脈を当然持っています。
すると、
is it light?
はかなり自然に、
客を起こした原因は光?
と解釈できます。
そこで現在のVersion 3では、
mysteryFocus
というserver-onlyの情報を追加する予定です。
例えば、
音や物理的接触を使わず、
何が眠っている客を起こしたのか?
という情報です。
これは正解ではありません。
プレイヤーが何を推理しようとしているのかを明示しているだけです。
原因5:「正しい」と「十分詳しい」を混同していた可能性
Solution Judgeでは、
coreMechanism
causalRelation
などを別々に評価していました。
しかし、
The light wakes the guest.
という文章を考えてみます。
短いです。
でも、
何が原因なのか
↓
light
何が起きるのか
↓
wakes the guest
まで言っています。
人間なら十分正解でしょう。
ところが、
coreMechanism = 高い
causalRelation = やや低い
のような結果になれば、TypeScript側ではSOLVEDになりません。
つまり、
正解の意味は理解できているのに、採点項目の充足度によって不正解になる
という現象が起きていました。
Version 3では、逆にシンプルにする
そこで現在試しているVersion 3では、大きく方針を変えています。
Question Judgeでは、多数の独立した0〜1 signalをやめます。
代わりに、1つのcategorical probability distributionだけを返してもらいます。
例えば、
type QuestionSemanticClass =
| "SUPPORTED"
| "CONTRADICTED"
| "MIXED"
| "UNKNOWN"
| "IRRELEVANT"
| "AMBIGUOUS";
です。
その後TypeScriptで、
SUPPORTED
→ YES
CONTRADICTED
→ NO
MIXED
→ PARTLY
UNKNOWN
→ NOT ENOUGH INFORMATION
IRRELEVANT
→ NOT RELEVANT
AMBIGUOUS
→ PLEASE REPHRASE
と変換します。
AIが最終的なUIを決めるわけではありません。
そこはこれまでと同じです。
変わるのは、
5個、6個の意味スコア
ではなく、
1つの意味分類
だけをAIにお願いすることです。
Solution Judgeも1つの分類にする
Theory判定も同じように簡素化します。
例えば、
type SolutionSemanticClass =
| "CORE_CAUSAL_EXPLANATION"
| "CORE_WITH_CONTEXT_ERROR"
| "SUPPORTING_INSIGHT_ONLY"
| "COMPETING_WRONG_MECHANISM"
| "NO_MEANINGFUL_INSIGHT";
です。
TypeScriptでは、
CORE_CAUSAL_EXPLANATION
→ SOLVED
CORE_WITH_CONTEXT_ERROR
→ PARTIAL
SUPPORTING_INSIGHT_ONLY
→ PARTIAL
COMPETING_WRONG_MECHANISM
→ NOT_SOLVED
NO_MEANINGFUL_INSIGHT
→ NOT_SOLVED
と変換します。
つまり、
There is a light.
と、
The light wakes the guest.
と、
Vibration wakes the guest.
の違いは残します。
ただし、その違いを判断するために5種類の独立した確率を要求するのはやめます。
それでもダメなら「YES / OTHER」まで簡素化する
Version 3でも実際のJevで不安定だった場合は、それ以上複雑にしない予定です。
逆方向へ行きます。
最終候補は、
YES
OTHER
です。
つまりQuestion Judgeには、
このプレイヤーの主張はHidden Truthによって支持されているか?
だけを聞きます。
支持されていれば、
YES
それ以外は全部、
OTHER
です。
そうすると、
NO
UNKNOWN
IRRELEVANT
AMBIGUOUS
の違いは失われます。
情報量は減ります。
しかし、ゲームとしてはこちらの方が自然になる可能性があります。
これはかなり面白いトレードオフです。
AIから情報をたくさん取れば良いとは限らない
今回の経験から、
意味情報を増やす
↓
判断材料が増える
↓
精度が上がる
とは限らないことが分かってきました。
むしろ、
意味signalを増やす
↓
不確実な境界が増える
↓
thresholdの相互作用が増える
↓
UXが不安定になる
こともあります。
AIインテグレーションでは、
AIから最大限多くの情報を取り出す
ことより、
プロダクトが本当に必要としている最小の判断だけを依頼する
ことの方が重要なのかもしれません。
880テストが通ってもAIの意味理解は保証できない
もう1つ、大きな学びがありました。
Version 2では880件のunit/API testと82件のE2Eが通っていました。
テストには大きな価値があります。
実際、
schemaが壊れていない
APIが壊れていない
hidden dataが漏れていない
余計なAI呼び出しがない
TypeScript mappingが正しい
UIが壊れていない
ことは確認できます。
しかし、
正しいsemantic signal
↓
正しいproduct result
を確認するテストと、
未知の人間の文章
↓
正しいsemantic signal
を確認するテストは別物です。
前者が全部通っていても、後者は失敗します。
今後はこの2つを、
Layer 1
Software correctness
Layer 2
Real model behavior
として完全に分けて考えようと思っています。
それでもTyped Decisionの設計は気に入っている
今回の結果を見ても、
普通のChat LLMに全部任せた方がいい
とは思っていません。
むしろ逆です。
Jevはapplication stateに対してtyped decisionを返し、probability distributionをアプリケーションコードで利用できる設計です。
私は今でも、
AI
→ bounded semantic decision
TypeScript
→ application policy
という分離は良いと思っています。
今回の学びは、
Typed Decisionが単純すぎる
ではありません。
むしろ、
その単純さを尊重すべきだった
なのだと思います。
アプリケーションが1つの分類しか必要としていないなら、
1つの良く設計されたChoice
の方が、
小さなsemantic ontology
+
大量のthreshold
より良い可能性があります。
現在地
この記事を書いている時点では、
Version 1
比較的シンプルな確率判定
↓
Version 2
意味signalを増やした
↓
実モデルで不自然な判定が発生
↓
Version 3
Judgeごとに1つのcategorical distribution
+
mysteryFocus
↓
現在検証中
という状態です。
Version 3が未知表現に対して十分安定するなら、ここでJudgeをFreezeします。
ダメなら、
YES / OTHER
まで落とします。
それ以上semantic complexityを増やすつもりはありません。
まとめ
今回一番大きかった学びは、かなりシンプルです。
プロダクトが必要としている以上に複雑な判断を、AIに要求しない。
複雑なsemantic modelは、TypeScript上では綺麗に見えます。
型も綺麗になります。
テストもたくさん書けます。
理論的にも高度に見えます。
それでも、
実際にユーザーが遊んだときに不自然
なら意味がありません。
AIアーキテクチャでは、
Understand more.
より、
Decide less.
の方が良いこともある。
今回のSideways開発では、それをかなり強く感じています。
Version 3の実Jev検証が終わったら、この記事にも結果を追記する予定です。
Sidewaysについて
Sidewaysは、Next.js / TypeScript / Jevを使って開発している実験的な水平思考ゲームです。
中心となる設計思想は、
Probabilistic Semantic Judgment
→ Typed Output
→ Deterministic TypeScript Policy
→ Product Result
です。
現在試しているのは、
この「Semantic Judgment」をどこまで小さくできるか?
という実験でもあります。