AIエージェントに脱出ゲームを解かせ、行動ログからステージを改善する

数年前に、友人とiOS向けの脱出ゲームを作っていたのですが、ステージ作成に時間がかかりすぎることが課題の一つでした。
原理的に解けるステージになっているかどうかはプログラム的に検証できますが、ユーザーに適切なヒントが出せているか、自然な推論でゴールにたどり着けるかは、通しでプレイしてみないと分かりません。ステージの設計者は何度もプレイするので適切な難易度かの判断も難しくなります。

GPT-4のAPIが出始めのころに、LLMにチャットで指示しながら脱出ゲームを解かせるデモを作ったのですが、今回はAgentを使って全自動でステージを解かせるところまで実行するものを作りました。

↓以前作ったデモのPost

Agentの思考過程はテキストとして残せるので、ステージ改善のアイデアを得られることが期待できます。
Agentは何度でも新鮮な気持ちでプレイできますし、ペルソナを与えれば様々なスキルレベルのプレイヤーによるテストプレイと似た効果を得られるかもしれません。

Agentのフレームワークとしては、2024/9にOpenAIから公開された実験的・教育向けのFWであるSwarmを使ってみることにしました。
(LangGraphもちょっと眺めたのですが、Swarmの方が初学者に優しそうだでした)
Swarmはマルチエージェントを簡単に実装できることが特徴の一つですが、今回は練習として簡単なシングルエージェントで脱出ゲームを解く例を実装し、マルチエージェントの活用については末尾で考察しています。

ソースコード(ステージ定義含む)はgistで公開しています。https://gist.github.com/oshizo/6743c793d4cdd2c7ec61d7717e8bfbcd


ゲームの仕様

今回は、以下のような単純化されたゲームを考えます。

  • アクションは「オブジェクトを調べる」「アイテムをオブジェクトに使う」「2つのアイテムを組合わせる」の3つのみ

  • 最終的に"goal"という名前のアイテムを入手するとクリア

アクションの結果として以下の処理が行われます

  • アクション「オブジェクトを調べる」「アイテムをオブジェクトに使う」

    • ゲームを進めるヒントになるメッセージが返される

    • 使ったアイテムは指定がない限り消滅する

    • オブジェクトに設定されたアイテムを最大1つ入手できる

    • オブジェクトの表示、非表示が変更される

      • オブジェクトの状態変化(例:装置が通電する)は、オブジェクトの表示/非表示で制御しています(例:通電していない装置が非表示になり、通電した装置が表示される)

  • アクション「2つのアイテムを組合わせる」

    • 組合わせた2つのアイテムは消滅し、別のアイテムを1つ入手できる

実際のゲームはナンバーロックを解除するギミックや、NPCとの会話、時間制限のある操作などいろいろな要素がありますが、このシンプルな仕様でも脱出ゲームと呼べる程度のステージは実装できます。

ステージの設計

上記の仕様を2つのjsonで表現します。
1つめのjsonでは、画面上に見えているオブジェクト1つ1つに対し、名前、メッセージ、アクションに対する反応のリストを定義しました。

object_definitions = [
    {
        "name": "古い装置",
        "message": "大きな装置が置かれている。ランプが点灯していない",
        "reactions": [
            {
                "required item": "",
                "item to retrieve": "操作マニュアル",
                "message": "配線が切れているのか、ボタンを押しても反応がない。装置の横に操作マニュアルが置かれていた。どこか広い机に広げてじっくり読もう。"
            },
            {
                "required item": "銅線",
                "message": "露出した端子に銅線を接続すると、ランプが点灯した。",
                "objects to show": ["通電した装置"],
                "objects to hide": ["古い装置"]
            }
        ],
        "hidden": False
    },
(略)

反応には、必要なアイテムが空(調べるアクションに反応)と、必要なアイテム指定されているもの(アイテムを使うアクションに反応)があります。

この例では、「古い装置」オブジェクトについて以下のような定義をしています。

  • 調べる→操作マニュアルを取得できる

  • 「銅線」アイテムを使う→「古い装置」が非表示になり「通電した装置」が表示されます(通電状態に変化するイメージ)

LLMに状況変化を伝えるため、ステージ定義にないメッセージもいくつかシステムから返されます。
例えば、アイテムを入手した時は「{item to retrieve}を入手しました」というメッセージを返したり、持っていないアイテムを使おうとした場合は「{item}を所持していません」と返すことで、メッセージを読んでLLMが行動を改善できるようにします。

2つ目のjsonでは、アイテムの組合せルールを定義します。

item_combinations = [
    {"item1": "純水", "item2": "高濃度燃料ゲル", "resultItem": "装置用燃料"},
    {"item1": "古い配線の束", "item2": "ニッパー", "resultItem": "銅線"},
]

item1の純水と、item2の高濃度燃料ゲルを組合わせると、装置用燃料を取得します。
このリストにないアイテムの組合せを実行すると、「{item1}と{item2}は組合わせられないようだ」という固定のメッセージが返されます。

ツールの定義

3つのアクション、調べる、アイテムを使う、アイテムを組合わせる、をそれぞれAgentに渡すツールの関数として定義します。
調べるアクションの実装は次のようなイメージです(簡略化したもの)

def examine(context_variables, target):
    visible_objects = context_variables.get("visible_objects", [])

    # オブジェクト定義の取得
    object_def = next(
        (obj for obj in object_definitions if obj["name"] == target), None
    )
    # リアクション定義の取得
    reaction = next(
        (reaction for reaction in object_def["reactions"] if reaction["required item"] == ""),
        None
    )
    
    message = object_def["message"]
    
    if reaction:
        message += " " + reaction["message"]
        # アイテムを取得する場合
        if "item to retrieve" in reaction:
            message += f" [{reaction['item to retrieve']}]を入手した。"
            context_variables["inventory"].append(reaction["item to retrieve"])
        
        # オブジェクトの表示/非表示を更新
        context_variables = update_visible_objects(context_variables, reaction)
            
    return Result(value=message, context_variables=context_variables)

ゲームの状態は引数の context_variablesから取得します。
Resultオブジェクトを返すことでツールの呼び出し元に実行結果を通知します。

Agentの設計

Agentは1ターンで複数の関数コールを同時にしてよいことにしています。
(OpenAIのFunction calling API にある parallel_tool_calls=True のこと)
画面上のオブジェクトを同時に調べられるようにすることで、ゴールまでのターン数を短くできます。

instructionとして、Agentに3つのアクションの実行方法やゴール条件を説明してあげます。

def game_instructions(context_variables):
    return f"""あなたはアドベンチャーゲームを解くAIです。goalというアイテムを入手することが目的です。
    
    以下の行動が可能です:
    1. 物を調べる: {{"action":"examine", "target":"物体の名前"}}
    2. アイテムを使う: {{"action":"use item", "item":"アイテム名", "target":"物体の名前"}}
    3. アイテムを組み合わせる: {{"action":"combine item", "item_1":"アイテム名", "item_2":"アイテム名"}}
    
    状況を分析して、次の行動を決定してください。
    - 所持品にないアイテムは使用できません
    - 見える物体にないものは調べられません
    - 所持品のアイテムを調べることはできません
    - 最終的にgoalという名前のアイテムを取得するまで、中断せずにアクションを継続せよ
    思考の過程は日本語で表示すること。

    最新のアクション実行後の所持品: {context_variables['inventory']}
    見える物体: {context_variables['visible_objects']}
    """


game_agent = Agent(
    name="Adventure Game Solver",
    instructions=game_instructions,
    functions=[examine, use_item, combine_item]
)

最後の行で、instructionsとツールを与えてAgentを初期化しています。

ゲームの実行

SwarmオブジェクトにGPT-4oに設定し、context_variableとmessagesの初期値を指定してclient.runすることでゲームを開始します。

initial_context = {
    "room_state": "古い研究室の装置を起動して古代の実験を再開しよう",
    "inventory": [],
    "visible_objects": initialize_visible_objects(),
    "reaction_history": {},
}


from openai import OpenAI

messages = [{"role": "user", "content": "次の行動を考え、実行せよ"}]
context_variables = initial_context
client = Swarm(OpenAI(api_key=API_KEY))

response = client.run(
    agent=game_agent,
    messages=messages,
    context_variables=context_variables,
    debug=True,
    max_turns=100
)

client.runの引数にmax_turns=100としており、Agentは自分の判断で100ターン、ツール実行と思考のループを繰り返します。
Swarmでは、max_turnsに達するか、tool callを行わずに応答した場合のどちらかでclient.runを終了する仕様になっているため、ゲームをクリアした場合Agentはtoolを呼ばずに、クリアしたことをユーザーに伝えるテキスト出力をしてclient.runが終了します。

簡単なステージはスムーズにクリアしますが、難しいステージでは詰まると関数コールをせずに諦めて終了することがありました。

実装の紹介は以上です。完全なソースコードはここにあります。
https://gist.github.com/oshizo/6743c793d4cdd2c7ec61d7717e8bfbcd

実行結果例の紹介

上記のgistに実装されているサンプルのステージは最短で以下のような10ステップでクリアになります。

  • 装置を動かすための配線修理:

    • 配電盤を調べる → 古い配線の束を入手

    • 工具箱を調べる → ニッパーを入手

    • 古い配線の束とニッパーを組み合わせる → 銅線を入手

    • 銅線を古い装置に使用 → 通電した装置が出現

  • 燃料の作成:

    • 薬品棚を調べる → 高濃度燃料ゲルを入手

    • 水道を調べる → 詰まった配水管を入手

    • 水槽に詰まった配水管を使用 → 錆が酸で落ち、修理した配水管を入手

    • 修理した配水管を水道に使用 → 純水を入手

    • 純水と高濃度燃料ゲルを組み合わせる → 装置用燃料を入手

  • ゴールまで:

    • 装置用燃料を通電した装置に使用

装置を動かすために、配線修理と燃料の作成という並行タスクをこなす必要があり、まあまあ複雑になっていると思います。

Agentの実行結果

以下が実際のAgentの行動と、システムが返したメッセージのログです。
太字にしているのが脱出のための必須の行動です。
最初の7つのexamineアクションはparallelで実行されていました。

行動: システムメッセージ
1. examine(古い装置): 大きな装置が置かれている。ランプが点灯していない 配線が切れているのか、ボタンを押しても反応がない。装置の横に操作マニュアルが置かれていた。どこか広い机に広げてじっくり読もう。 [操作マニュアル]を入手した。
2. examine(実験台): 実験器具が並べてある実験台だ。
3. examine(薬品棚): 様々な薬品が並んでいる。 高濃度の燃料ゲルを見つけた。粘性が高く、そのまま装置に使うには適さない [高濃度燃料ゲル]を入手した。
4. examine(工具箱): 重そうな工具箱がある。 使えそうなニッパーが入っている。 [ニッパー]を入手した。
5. examine(水槽): 実験台の上に水槽がある。水槽は黄色の液体で満たされている。 指を突っ込んでみたらぴりぴりする。酸性の液体が入っているようだ
6. examine(水道): 実験室用の水道がある。 配水管の一部が大量の錆で詰まっていて水が流れない。配水管内部の錆は工具では落とせない。錆が落とせるような薬品はないだろうか。 [詰まった配水管]を入手した。
7. examine(配電盤): 研究室の配電盤だ。適切に動作している。 配電盤に余分な配線の束が掛けられていた。ぼろぼろな部分が多くそのままでは使えない。 [古い配線の束]を入手した。
8. combine_item(高濃度燃料ゲル, 詰まった配水管): 高濃度燃料ゲルと詰まった配水管は組合わせられないようだ。
9. use_item(詰まった配水管, 酸性の液体): 酸性の液体という物体がありません。
10. use_item(詰まった配水管, 水槽): 実験台の上に水槽がある。水槽は黄色の液体で満たされている。 しばらく配水管を液体につけていると、錆が取れてきた。 [修理した配水管]を入手した。
11. use_item(修理した配水管, 水道): 実験室用の水道がある。 配水管を取り付けると水が出るようになった。 [純水]を入手した。
12. use_item(古い配線の束, 配電盤): 古い配線の束は配電盤には使用できないようだ。
13. use_item(ニッパー, 古い配線の束): 古い配線の束という物体がありません。
14. combine_item(古い配線の束, ニッパー): [古い配線の束]と[ニッパー]を組合わせた。[銅線]を入手した。
15. use_item(銅線, 配電盤): 銅線は配電盤には使用できないようだ。
16. use_item(銅線, 古い装置): 大きな装置が置かれている。ランプが点灯していない 露出した端子に銅線を接続すると、ランプが点灯した。
17. examine(通電した装置): 大きな装置が置かれている。
18. use_item(純水, 通電した装置): 純水は通電した装置には使用できないようだ。
19. use_item(高濃度燃料ゲル, 通電した装置): 高濃度燃料ゲルは通電した装置には使用できないようだ。
20. use_item(操作マニュアル, 通電した装置): 操作マニュアルは通電した装置には使用できないようだ。
21. combine_item(純水, 高濃度燃料ゲル): [純水]と[高濃度燃料ゲル]を組合わせた。[装置用燃料]を入手した。
22. use_item(装置用燃料, 通電した装置): 大きな装置が置かれている。 装置に燃料をセットすると、装置が起動した! [goal]を入手した。

最短10ターンの所、22ターンで脱出できました。なかなか優秀だと思います。
今回はAgentにクリアしてもらうこと自体は目的でなく、その思考過程からステージの改善を狙うことが目的なので、思考過程を見ていきましょう。

LLMの思考過程(ロジカルな行動)

それぞれの行動のタイミングで、LLMがどのツールを使うかを検討している様子を観察します。

8. combine_item(高濃度燃料ゲル, 詰まった配水管)が失敗した前後の思考を見てみます。

次のステップとしては、以下のことを考えて行動を決定します:
- 詰まった配水管に対する対処
- 装置の動作状況の改善
次の行動として、高濃度燃料ゲルと酸性の液体を組み合わせて、配水管を対処するための液体を作ることを試みます。

# combine_item(高濃度燃料ゲル, 詰まった配水管)
# → [system]高濃度燃料ゲルと詰まった配水管は組合わせられないようだ。

上記の行動は持っているアイテムを適当に組み合わせただけのように見え、ロジカルとは感じないですが、この失敗を受けて↓では推論に基づいて行動しています。

高濃度燃料ゲルと詰まった配水管は組み合わせることができなかったようです。次に、酸性の液体を使って詰まった配水管の錆を落とすことを試みます。酸性の液体は錆を溶かすのに役立ちそうです。
# use_item(詰まった配水管, 酸性の液体)
# → [system]酸性の液体という物体がありません。

酸性の液体という、存在しないものを使おうとしてしまいましたが、システムメッセージを参考に次の行動(↓)ですぐに正しい行動に修正できていますね。

酸性の液体は正確には「水槽」に含まれているのでした。失礼しました。次に、水槽にある酸性の液体を使用して、詰まった配水管の錆を落とすことを試みます。
# use_item(詰まった配水管, 水槽)

LLMの思考過程(あまりロジカルではない行動)

ゴール近くで、装置を通電させてからの行動を見てみると、かなり総当たり的な試みをしています。
以下の18-20では持っているアイテム3つを順番に装置に使っているだけです。

18. use_item(純水, 通電した装置): 純水は通電した装置には使用できないようだ。
19. use_item(高濃度燃料ゲル, 通電した装置): 高濃度燃料ゲルは通電した装置には使用できないようだ。
20. use_item(操作マニュアル, 通電した装置): 操作マニュアルは通電した装置には使用できないようだ。
21. combine_item(純水, 高濃度燃料ゲル): [純水]と[高濃度燃料ゲル]を組合わせた。[装置用燃料]を入手した。

ステージ設計としては、マニュアルを読むことで21で実行している装置用燃料の入手方法が分かるようになっています。
「操作マニュアル」を持っており、マニュアルを「実験台」に広げることで読むことができるという情報を得ているにもかかわらず、マニュアルを読むことができていません。

21実行前の思考は以下のようになっています。

「操作マニュアル」も通電した装置には使用できないようです。装置を動かすためには他に何か必要です。
これまでの試みで得た情報やアイテムに基づいて再考します。新しいアプローチとして、何か特定のアイテムの組み合わせや、まだ試していない使用パターンがあるか確認します。
もう一度見直し、他のアイテムでの組み合わせを試みることも考えます。
持ち物を再確認し次の手段を考える必要があります。例えば、「修理した配水管」と「純水」や、「高濃度燃料ゲル」との組み合わせを見直します。
「純水」と「高濃度燃料ゲル」を組み合わせて、何か新しい物質を作成できるかどうかを試します。

詰まったので適当にアイテムを組み合わせてみますという思考で、操作マニュアルを読もうという意識はないようです。
この点は、ステージ設計でのヒントの出し方に改善の余地があるかもしれません。

マニュアルを入手した時には
「装置の横に操作マニュアルが置かれていた。どこか広い机に広げてじっくり読もう。」
というメッセージが流れるのですが、このメッセージでマニュアルを実験台に使うというのはたしかに分かりづらいかもしれません。

このように、Agentの思考過程を観察するとステージ改善を考えられそうですが、毎回これをやるのは面倒なので、LLMにやってもらえるかを試してみます。

ステージの改善をLLMに提案させる

Agentの思考過程はすべてテキストとして残るので、long contextの扱えるLLMに考察を丸投げすることができます。

ステージ構成(3000文字)と、Agentの思考過程のログ(1万2000文字)をClaude-3.5-Sonnetに渡して、Agentのプレイを分析させ、ステージの改善を考察させてみました。

まず、現在のagentのプレイを分析します:

賢い点
- 最初に全てのオブジェクトを調査して情報収集
- 試行錯誤的ながらも論理的な順序で解決(配線→電源→燃料)
- アイテム組み合わせの可能性を積極的に探索

非効率な点
- 使えないアイテムの組み合わせを何度も試行
- 操作マニュアルの内容を実験台で確認せず、装置に直接使おうとした
- 水槽の酸性液体の用途を最初は誤解

現在のステージの改善点:
1. 情報の明確化
- 操作マニュアルを読むべき場所(実験台)の説明が不十分
- 配水管を水槽に入れる必要性の示唆が弱い
- マニュアルの情報が重要なのに、読むまでその価値が不明確

2. 誤った試行の防止
- 使えないアイテム組み合わせへのより明確なフィードバック
- 論理的に不可能な組み合わせの試行を減らせる説明の追加

3. 手がかりの連鎖
- アイテム入手時のメッセージにより明確な次のステップの示唆を含める
- 関連するオブジェクト間の関係性をより明確に

私もマニュアルに関する行動が問題と考察しましたが、Claude-3.5-Sonnetも同じ点に着目し、ステージ設計の改善点を挙げてくれています。
(長いので省略しましたが、改善後のステージ定義のjsonも作ってくれました)

このように、Agentを使ったテストプレイ→プレイをLLMに分析させるという方法で、作ったステージの課題を発見して改善することができそうです。

制限

Agentの能力の限界

今回は最短9ターンの小さいステージで実験しましたが、もっと長い手順になると、解ききれないようなケースが出てくると思います。
今回のAgentは、過去に得た情報を使うには、長いcontextからそのメッセージが帰ってきた部分を見つける必要があり、それがマニュアルを適切に使えない原因の一つでもあったと思います。

より長いステージに対応するには、有用な情報をLLMが選別してcontext_variableとして蓄積・参照できるようにするなど、Agent設計の工夫が必要になりそうです。

APIコストの問題

これまでの探索と思考の履歴をすべてcontextに含め続けると、ターンの2乗でAPIコストがかかります。長いステージで、ステージを改善するたびにAgentを動かすようなシナリオを考えると、コストが問題になってくると思います。

contextを要約するような方法でAPI呼び出し当たりのコストを削減したり、ReWOOのように先に数ターン分の計画をして、まとめて実行するような方法でAPI呼び出し回数を減らすような工夫が有効かもしれません。

マルチエージェントの活用について

Swarmはマルチエージェントを簡単に書けるフレームワークなので、マルチエージェントの活用も検討しましょう。

Agentの思考能力を伸ばす方向性として、脱出の全体計画を立てるAgentと、ツールを使うだけのシンプルなAgentに分けてはどうかと考えましたが、この程度だとシングルエージェントのプロンプトを工夫すれば同等のことができそうです。

コードとプロンプトの管理の効率化の方向性においてはメリットがありそうです。
例えば、天秤に分銅を乗せて左右を釣り合わせるようなステージ固有のギミックがある場合、このギミック特有の関数(天秤の左右の皿に分銅を乗せる、取り除くアクション)を扱う必要があるので、専門のAgentを用意することで、元の探索エージェントのプロンプトをシンプルに保つことができそうです。


いいなと思ったら応援しよう!