5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Genkit Dartでゲーム用のAI会話システムを作る

5
Posted at

Genkit Dart を使うと、Dart言語で生成AIを利用するサーバアプリケーションが簡単に実装できるらしい、ということで試してみました。
クライアントは Flutter + Flame のゲーム風サンプルにして、「街を歩いて村人に話しかけると、AI がその人物になりきって会話してくれる」ものを作ってみます。

プレイヤーが選べる返事の選択肢もサーバ側で生成させたので、自由入力なしで会話が成立します。

実行結果

こんな感じでゲーム内のNPCと会話ができます。

環境

バージョン
Dart 3.12.2
Flutter 3.44.8
genkit / genkit_shelf / genkit_anthropic 0.15.1 / 0.1.11 / 0.2.11
flame 1.38.0
LLM claude-haiku-4-5

実装方針

作り始める前に、このあたりを決めました。

  • サーバはステートレスにして、状態はアプリケーションが持つ
    会話履歴もプレイヤーの状態もクライアントに持たせ、毎回まるごと送る。サーバに DB は持たせない
  • クラウド環境は Google Cloud Platform を利用する
    Cloud Run + Artifact Registry + Secret Manager。コンテナを1つ置くだけの構成
  • 生成AIは Claude の Haiku を使用する
    村人の短い返事が用途なので、速くて安いモデルを既定に。環境変数で差し替えられるようにしておく
  • セキュリティは最低限
    クライアントに埋め込んだ共有シークレットを X-API-Key で検証するだけ

全体構成

モノレポで、3つのパッケージに分けました。

パッケージ 内容
packages/server Genkit の talk flow を HTTP で公開するサーバ。Cloud Run にデプロイする
packages/app Flutter + Flame のゲームクライアント
packages/talk_schema talk flow の通信スキーマ。server と app が共有する
app ──┐
      ├─→ talk_schema      app ──HTTP(talk flow)──→ server ──→ Claude API
server┘

パッケージ間の依存は talk_schema への path: 依存だけにして、app と server は互いを参照しないようにしています。
通信の型を1箇所で定義するためだけの共有です。

実装のポイント

1. Flutter + Flameによるゲーム風UI

手抜きをして以前作成したFlame(Flutter用2Dゲームライブラリ)を使ってみるからほぼ全て流用しています。
UIに興味がある場合は、こちらを参考にしてみてください。

2. Genkit Dart の使用感

サーバ側はプラグインを注入した Genkit を作って flow を定義し、HTTP で公開するだけでした。

void main() async {
  final ai = Genkit(plugins: [anthropic(apiKey: claudeApiKey)]);
  final talk = defineTalkFlow(ai);

  await startFlowServer(
    flows: [
      FlowWithContextProvider(flow: talk, context: requireApiKey(clientApiKey)),
    ],
    // Cloud Run は $PORT で listen することを要求する
    port: int.parse(Platform.environment['PORT'] ?? '8080'),
  );
}

flow の中身も「入力を検証して、プロンプトを組み立てて、ai.generate() を呼ぶ」だけです。ルーティングもシリアライズも書いていません。

ai.defineFlow<TalkRequest, TalkResponse, void, void>(
  name: 'talk',
  inputSchema: TalkRequest.$schema,
  outputSchema: TalkResponse.$schema,
  fn: (TalkRequest request, _) async {
    final response = await ai.generate(
      model: model,
      system: _systemPrompt(character, request.encounterCount, knownTopics),
      messages: [
        for (final turn in request.history)
          Message(
            // player の発話が user、NPC 自身の発話が model
            role: turn.speaker == Speaker.player ? Role.user : Role.model,
            content: [TextPart(text: turn.text)],
          ),
      ],
      // セリフと選択肢を1回の生成でまとめて作らせる
      outputSchema: NpcReply.$schema,
    );

    final npcReply = response.output; // 型付きで取り出せる
    ...
  },
);

Genkit Dartでは structured output を使います。出力スキーマを渡すと、モデルの応答が JSON としてパースされ、型付きのオブジェクトで返ってきます。
今回は「NPC のセリフ」と「プレイヤーの次の選択肢」を1回の生成でまとめて作らせました。2回に分けるより速く、選択肢が直前のセリフと自然につながるという副産物もありました。

@Schema(description: 'NPC の応答と、プレイヤーの次の選択肢')
abstract class $NpcReply {
  @StringField(description: 'NPC のセリフ。2〜3文程度。')
  String get reply;

  @Field(description: 'プレイヤーが次に言えるセリフの候補。…会話を続けるなら1個または2個。')
  List<String> get choices;

  @Field(description: '今回の reply で初めてプレイヤーに伝えた話題のID。…')
  List<String> get revealedTopics;
}

開発中は Dev UI がとても便利でした。genkit start 経由で起動すると reflection server が立ち上がり、ブラウザから flow を直接叩いて、プロンプトと応答、トークン数まで確認できます。プロンプトを調整しているときはアプリを起動せずここだけで回していました。

genkit start -- fvm dart run

はまりどころ: inputSchema は検証してくれない

defineFlowinputSchema を渡しても、受け取った JSON をラップするだけで検証はされません
型が違うフィールドに触った瞬間にキャスト例外が発生し、クライアントには 500 が返ってきて、しばらく原因が分かりませんでした。処理の最初にバリデーションしておくほうが安全です。

final errors = await TalkRequest.$schema.validate(request.toJson());
if (errors.isNotEmpty) {
  throw GenkitException(
    'Invalid request: ${errors.join(', ')}',
    status: StatusCodes.INVALID_ARGUMENT, // 400 になる
  );
}

GenkitExceptionStatusCodes を添えると、そのまま HTTP のステータスに反映されます
(NOT_FOUND なら 404、PERMISSION_DENIED なら 403)。

3. アプリケーションとの通信モデル

talk flow の入出力はこうなりました。

// リクエスト
{"data": {
  "characterId": "maid",
  "encounterCount": 1,                    // 今回を含む話しかけた回数
  "knownTopics": ["forest_monsters"],     // プレイヤーが既に知っている話題
  "history": [                            // 会話履歴。末尾は必ず player
    {"speaker": "player",    "text": "こんにちは"},
    {"speaker": "character", "text": "こんにちは、あなた様。今は買い出しの途中でして。"},
    {"speaker": "player",    "text": "何を買いに行くの?"}
  ]
}}

// レスポンス
{"result": {
  "characterId": "maid",
  "reply": "館の夕食のぶんですわ。主人が気難しくて、同じ店でないと駄目なのです。",
  "choices": ["どんな主人なの?", "引き止めてごめん"],   // 0〜2要素。空なら会話終了
  "knownTopics": ["forest_monsters", "mansion_master"]  // 会話によって更新されたプレイヤーの知識
}}

この型を talk_schema パッケージに置いて、server と app で共有しました。
schemantic@Schema() を付けた抽象クラスから、
Dart のクラスと JSON Schema の両方が生成されます。

@Schema(description: 'NPC との会話リクエスト')
abstract class $TalkRequest {
  @StringField(description: 'サーバが保持するキャラクター設定を特定するID')
  String get characterId;

  @IntegerField(description: '…1 なら初対面、2 以上なら再会。', minimum: 1)
  int get encounterCount;

  @Field(description: 'プレイヤーが既に知っている話題のID…')
  List<String> get knownTopics;

  @Field(description: '会話履歴。…末尾は必ず player の発話であること。')
  List<$Turn> get history;
}

クライアント側は package:genkit/client.dartdefineRemoteAction に同じスキーマを渡すだけで、型付きの呼び出し口ができました。URL とヘッダを組み立てる手書きのコードは書いていません。

_action = defineRemoteAction(
  url: '$serverUrl/talk',
  defaultHeaders: {'X-API-Key': apiKey},
  inputSchema: TalkRequest.$schema,
  outputSchema: TalkResponse.$schema,
);

final response = await _action(
  input: TalkRequest(
    characterId: characterId,
    encounterCount: encounterCount,
    knownTopics: Topic.idsOf(knownTopics),
    history: history,
  ),
);

エラーは通信・認証・サーバエラーのすべてが GenkitException で飛んでくるので、status を見てプレイヤー向けのメッセージに変換しました。

return switch (error.status) {
  StatusCodes.PERMISSION_DENIED ||
  StatusCodes.UNAUTHENTICATED => 'サーバーに拒否された。CLIENT_API_KEY を確認して。',
  StatusCodes.NOT_FOUND => 'このキャラクターの設定がサーバーに無い。',
  ...
};

4. ステートレスな設計

会話に必要な状態はすべてクライアントに持たせ、リクエストに乗せるようにしました。

状態 持ち主 役割
会話履歴 NPC ごとの TalkSession 過去のやり取り。毎回まるごと送る
話しかけた回数 NPC ごとの TalkSession 1 なら初対面、2 以上なら再会として応対させる
プレイヤーの知識 プレイヤーに1つの PlayerKnowledge 知っている話題。選択肢の出し方に効く

サーバが持つのは「変わらないもの」だけです。キャラクターの人物設定、村の共通設定 (森に魔物が出るようになった、討伐部隊が向かっているらしい…)、話題の定義。これらを system プロンプトに組み立てて渡しています。

# 人物設定
…
# 村の今の情勢 (村人なら誰でも知っていること)
…
# 今回の状況
- プレイヤーが衛兵ロルフに話しかけるのは3回目。顔見知りとの再会。
- 会話履歴には前回までのやり取りが入っている。今回のプレイヤーの発話はその続きではなく、
  一度別れたあとに改めて話しかけてきたもの。
# プレイヤーの知識
- 既に知っていること:
  - 森の魔物 (forest_monsters): …
- まだ知らないこと:
  - 夜明けの騎士団 (dawn_knights): …
# 会話の終わり方
choices を空配列にすると、その場の会話はそこで終わる。
「会話が終わったかどうか」を状態を持たずに表現する

選択肢の配列が空なら会話終了、という約束にしました。
プレイヤーが別れの挨拶を選んだとき、NPC が用事を理由に切り上げるとき、話題が一巡したときに空で返すようモデルに指示しておき、クライアントは空を受け取ったら締めのセリフと「会話を終える」だけを表示します。
最初は「会話が続かなくなると困る」と思って空のときは既定の選択肢で埋めていたのですが、それだと逆に会話が終われなくなってしまい、やめました。

再会を履歴を消さずに表現する

会話ウインドウを閉じたら「話しかけた回数」を1つ増やし、次からは同じ履歴を送りつつ「一度別れたあとに改めて話しかけられた」ことをプロンプトで伝えています。これで「また来たね」「さっきの話の続きだが」といった応対になりました。
履歴だけを渡すと会話の続きとして解釈されてしまうので、「一度別れた」と明示的に書く必要がありました。

この設定の利点

サーバがただの関数になります。DBもセッションストアも不要になり、インスタンスが再起動しても、Cloud Run が勝手にスケールしても何も壊れません。デプロイもコンテナを差し替えるだけです。

この設計の問題点

  • 会話が長くなるほどリクエストが膨らみ、毎回すべてが入力トークンとして課金される
  • 状態がクライアント側にあるので、その気になれば改竄できます。「知らないはずの話題を知っていることにする」といった改竄は成立します。本気で防ぐならサーバに持たせる必要がある

今回はプロトタイプなので、後者は許容しました。

5. 認証

genkit_shelfContextProvider で、flow に入る前にヘッダを検証しています。
例外を投げると 403 PERMISSION_DENIED になってくれるので、認証の実装はこれだけで済みました。

ContextProvider requireApiKey(String expectedKey) {
  final expected = utf8.encode(expectedKey);

  return (Request request) {
    final provided = request.headers[apiKeyHeader];
    if (provided == null ||
        !_constantTimeEquals(expected, utf8.encode(provided))) {
      throw const UnauthorizedException();
    }
    return const <String, dynamic>{'auth': {'method': 'apiKey'}};
  };
}

キーは Secret Manager に置き、Cloud Run には --set-secrets で環境変数として注入、アプリには --dart-define で渡しています。とはいえクライアントに埋め込む共有シークレットなので、バイナリから抽出される可能性は残ります。無防備な公開を避ける一段目、という位置づけです。

6. デプロイ

Cloud Run 用のイメージは、マルチステージで AOT コンパイルして scratch に置くだけにしました。
Dart の公式イメージには必要な共有ライブラリと CA 証明書が /runtime にまとまっているので、これをコピーすれば HTTPS も通ります。

FROM dart:3.12.2 AS build
...
RUN dart compile exe bin/server.dart -o bin/server

FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /src/server/bin/server /app/bin/server
CMD ["/app/bin/server"]

モデル名はシークレットではないので、平文の環境変数で渡すようにしました。
デプロイし直さずにモデルだけ差し替えられるので、速度と応答の質を比べるときに重宝しました。

gcloud run services update gd-test-server \
  --region asia-northeast1 --update-env-vars TALK_MODEL=claude-sonnet-5

プロンプトキャッシュについて

Claude API にはプロンプトキャッシュがあり、条件が揃えば料金とレイテンシを削減できます。応答時間が課題だったので調べましたが、今回は使えませんでした。

Genkit の Claude アダプタが未対応

cache_control は Anthropic API のコンテンツブロックに付ける指定ですが、genkit_anthropic はシステムプロンプトを文字列のまま送るので、付ける先のブロックがありません (convertSystemMessageSystemPrompt.text() を返す)。プロバイダ固有オプションの AnthropicOptions にもキャッシュ関連のフィールドはありません。

ただ、下位の anthropic_sdk_dart には SystemTextBlock(cacheControl:) があり、Genkit 側も MessageTextPartmetadata を持っています。抽象化の限界というより未実装のようなので、いずれ対応されるかもしれません。

仮に対応していても、今回の設計では効果が薄い

キャッシュは前方一致で、プレフィックスが1バイト変わると以降がすべて無効になります。今回はシステムプロンプトに世界観・キャラクター性・プレイヤーの知識・話しかけた回数をまとめているため、会話のたびに書き換わります。

また、プロンプトキャッシュにはモデルごとの最小長があり、下回ると無視されます。

モデル 最小キャッシュ長
Haiku 4.5 (今回の既定) 4,096 トークン
Sonnet 5 / Sonnet 4.6 1,024 トークン
Opus 5 512 トークン

今回のシステムプロンプトは約1,850文字 (概算1,500〜2,500トークン) です。Haiku を使う限り、変数を全部排除して完全に固定したとしても最小長に届きません。

キャッシュが効く構造にする場合

システムプロンプトはルールだけに固定し、可変部分は messages 側へ移します。1回の来訪を1つの # session(番号) として、その先頭に変数を書く形です。

# session1
* variables: プレイヤーの知識...
user      : こんにちは
character : こんにちは、あなた様。今は買い出しの途中でして。
user      : 何を買いに行くの?
character : 館の夕食のぶんですわ。……
# session2
* variables: プレイヤーの知識...
user      :また来たよ

リクエストのたびに末尾へ追記される形になるので、それ以前は前回とバイト単位で同一になります。会話が伸びるほどキャッシュ領域も伸びるので、システムプロンプト単体では届かなかった最小長にも到達し得ます。話しかけた回数は # session の数から分かるため、変数として持つ必要もなくなります。

ただし、システマチックな要素をシステムプロンプトからメッセージに移動したことでNPCの会話にシステム的な要素が紛れ込んで、ロールプレイの品質が低下するリスクは増加します。

残る問題点

キャッシュの既定 TTL は5分です。このゲームは「話しかける → 数往復 → 街を歩き回る → しばらくして戻る」という遊び方なので、同じ NPC への再訪はたいてい5分以上の間隔が開きそうです。
1時間 TTL にすれば生き残りますが書き込み料が2倍になり、損益分岐は3リクエスト目です。1回の来訪が2〜3往復という今の会話の長さでは、ちょうど分岐点の上をうろうろします。

プロンプトキャッシュについて、今回の結論

ゲーム用のAI会話システムとして見ると、「プロンプトキャッシュを使えば安く速くなる」とは単純に言えませんでした。有効にするための構造変更には副作用があり、モデルの最小長・プロンプトの大きさ・会話の頻度を具体的に当てはめないと判断できないところです。

ソースコード

ソースコードはこのリポジトリにあります。
コーディングエージェントとしてClaude Codeを利用しています。

前述のとおりモノレポで、packages/ の下に server / app / talk_schema が並んでいます。
pub workspace は使わず、pubspec.yaml はパッケージごとに持たせました
(Docker のビルドコンテキストをサーバだけに絞りたかったためです)。

なお、アプリのアセット (スプライト画像とタイルマップ) は配布ライセンスの都合でリポジトリに含めていません。 動かす場合は packages/app/README.md の手順に従って、手元で用意した素材をassets/imagesassets/tiles に置いてください。

まとめ

生成AI を呼ぶサーバは、想像していたよりずっと短くなっています。flow を定義してstartFlowServer に渡すだけで型付きの HTTP API になり、同じスキーマをそのまま Flutter 側の呼び出し口にも使えます。structured output のおかげで「セリフと選択肢を同時に作る」ような要求も素直に書けて、サーバ側で考慮が必要だったのはプロンプトの書き方だけでした。

一方、ゲームとして見たときに引っかかったのは応答時間でした。Haiku を選び、返すセリフも2〜3文に制限していますが、それでも話しかけてから返事が出るまでに待ちが入ります。
オンデバイス LLM ほどではないにせよ、テンポよく街を歩き回る体験とは相性が良くありませんでした。
「話しかけた瞬間に返ってくる」が当たり前のゲームUIに生成AIをどう馴染ませるかは、別途工夫が要りそうです。

この後、どんなふうに拡張できるか

  • ゲームらしくする
    シナリオを用意して、世界情勢や NPC が知っている情報、振る舞いをその進行に応じて変えていく。今回の「プレイヤーの知識」はその足がかりで、会話で得た情報をフラグとして扱えるようにしてあります
  • セキュリティ強化
    共有シークレットではなく、ユーザー認証方式にする。ContextProvider を差し替えるだけなので、Firebase Auth のトークン検証などに置き換えられます
  • サーバ側にプレイヤーの状態を保存する
    改竄を防ぐ、複数端末で続きから遊ぶ、といった要求が出てきたらサーバ側にデータストア用意するのが良さそうです
  • もっと速い応答
    Gemini の Flash 系など速度重視のモデルを使ったり、高性能な LLM 用サーバをホストする方法がありそうです。LLMのライセンスによっては、応答をキャッシュしておくのも良いかもしれません

使用したアセット

アプリではitch.ioで購入したアセットを利用しています。

最後に

株式会社ボトルキューブでは Flutter を使ったお仕事を募集中です。
お問い合わせフォームからご連絡ください。

また、一緒に働く仲間も募集しています。
詳細は採用情報ページをご覧ください。

5
1
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
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?