T-CREATOR

Gemini CLI のストリーミング出力:逐次生成を活かした UX 改善手法

Gemini CLI のストリーミング出力:逐次生成を活かした UX 改善手法

現代の CLI アプリケーション開発において、ユーザー体験の向上は重要な課題となっています。特に、Google が提供する Gemini CLI は、AI とのインタラクションを革新的に変える可能性を秘めています。

本記事では、Gemini CLI のストリーミング出力機能を活用し、逐次生成による UX 改善手法について詳しく解説いたします。従来のバッチ処理では実現できなかった、リアルタイムで応答性の高いアプリケーション開発手法をマスターできるでしょう。

これからご紹介する技術は、AI を活用したアプリケーション開発において、ユーザーの満足度を大幅に向上させる強力な手法となります。

背景

CLI ツールにおけるリアルタイム出力の重要性

現代のソフトウェア開発では、ユーザーとの対話性が重視されています。特に CLI ツールにおいて、処理の進行状況や結果をリアルタイムで表示することは、ユーザー体験の向上に直結します。

mermaidflowchart LR
    user[ユーザー] -->|コマンド実行| cli[CLI アプリ]
    cli -->|リアルタイム出力| display[画面表示]
    display -->|フィードバック| user

    cli -->|従来の方式| batch[バッチ処理]
    batch -->|完了後一括出力| delayed[遅延表示]
    delayed -.->|不安感| user

図解要点:

  • リアルタイム出力により即座にフィードバックが得られる
  • 従来のバッチ処理では処理完了まで待機が必要
  • フィードバックの遅延がユーザーの不安感を増大させる

リアルタイム出力は、ユーザーが現在の処理状況を把握でき、安心感を与える重要な要素です。特に AI を活用したアプリケーションでは、生成プロセスの可視化により、ユーザーの理解と信頼を深めることができます。

従来のバッチ処理出力の課題

従来の CLI アプリケーションでは、処理が完了してから結果を一括で表示するバッチ処理が一般的でした。この方式には以下のような課題がありました。

課題項目従来の問題点ユーザーへの影響
待機時間処理完了まで何も表示されない不安感、離脱率向上
メモリ使用量全結果をメモリに蓄積システムリソース圧迫
処理中断途中結果の取得不可時間とリソースの無駄
デバッグエラー発生箇所の特定困難開発効率の低下

特に大容量のデータ処理や AI による長文生成では、処理時間が数分から数十分に及ぶケースも珍しくありません。この間、ユーザーは処理が正常に動作しているのか、フリーズしているのかを判断できず、不安を抱えることになります。

ストリーミング技術の基本概念

ストリーミング技術は、データを連続的な流れとして扱い、生成されたデータを即座に転送・表示する技術です。Web 開発では WebSocket や Server-Sent Events として知られていますが、CLI アプリケーションでも同様の概念が適用できます。

mermaidsequenceDiagram
    participant User as ユーザー
    participant CLI as CLI アプリ
    participant API as Gemini API

    User->>CLI: コマンド実行
    CLI->>API: ストリーミングリクエスト

    loop 逐次生成
        API-->>CLI: データチャンク
        CLI-->>User: リアルタイム表示
    end

    API->>CLI: 完了通知
    CLI->>User: 処理完了

補足:Gemini API はストリーミング形式でデータを返すため、生成されたテキストを即座に表示できます。これにより、ユーザーは生成プロセスを視覚的に確認しながら、結果を待つことができます。

課題

レスポンス時間による UX の課題

AI を活用した CLI アプリケーションでは、レスポンス時間の長さがユーザー体験に大きな影響を与えます。特に Gemini のような大規模言語モデルを使用する場合、複雑な処理には相応の時間が必要です。

以下のような状況では、ユーザーの満足度が著しく低下します:

  • 初回応答まで 10 秒以上の沈黙:ユーザーが「動作していない」と判断
  • 処理進捗の不透明性:残り時間や進行状況が分からない
  • 途中終了の不可能性:必要のない処理を止められない
mermaidstateDiagram-v2
    [*] --> waiting: コマンド実行
    waiting --> anxiety: 5秒経過
    anxiety --> frustration: 15秒経過
    frustration --> abandon: 30秒経過

    waiting --> satisfied: 即座にフィードバック
    satisfied --> engaged: 継続的な更新
    engaged --> completed: 処理完了

図解要点:

  • 初期フィードバックの有無でユーザーの心理状態が大きく変化
  • ストリーミング出力により satisfied → engaged のポジティブな流れを作れる

大容量データ処理時の待機問題

大容量のデータ処理や長文生成では、従来のバッチ処理方式では以下の問題が発生します:

メモリ消費の増大

typescript// 従来のバッチ処理方式(問題のあるパターン)
async function generateLongContent(
  prompt: string
): Promise<string> {
  // 全ての結果をメモリに蓄積
  let fullResult = '';

  // 処理が完了するまで何も返さない
  const response = await geminiAPI.generateContent(prompt);

  // メモリ使用量が急激に増加
  fullResult += response.text();

  return fullResult; // 完了後に一括返却
}

この方式では、生成されるコンテンツが大容量になるほど、メモリ使用量が線形に増加し、システムリソースを圧迫します。

ユーザビリティの低下

大容量処理では、以下のような UX 上の課題が顕著に現れます:

  • 処理時間の予測不可能性:残り時間が分からない
  • 中断不可能性:途中で処理を止められない
  • 結果の部分確認不可:完了まで内容を確認できない

ユーザーフィードバックの欠如

従来の CLI アプリケーションでは、処理中のフィードバックが不十分でした。これにより、以下のような問題が発生していました:

フィードバック項目従来の状況ユーザーの感じる問題
処理状況完全に不明「動いているか分からない」
進捗率表示なし「いつ終わるか分からない」
エラー情報事後通知のみ「何が問題か分からない」
部分結果確認不可「途中で修正したい」

特に AI を活用したアプリケーションでは、生成プロセスの透明性がユーザーの信頼性確保に直結します。

解決策

Gemini CLI のストリーミング API 活用

Gemini CLI が提供するストリーミング API を活用することで、これらの課題を効果的に解決できます。

基本的なストリーミング設定

typescriptimport { GoogleGenerativeAI } from '@google/generative-ai';

// Gemini API の初期化
const genAI = new GoogleGenerativeAI(
  process.env.GEMINI_API_KEY!
);

Gemini API を使用するための基本的な設定から始めます。環境変数から API キーを取得し、クライアントを初期化します。

typescript// ストリーミング対応のモデル設定
const model = genAI.getGenerativeModel({
  model: 'gemini-pro',
  generationConfig: {
    temperature: 0.7,
    topK: 40,
    topP: 0.95,
  },
});

モデルの設定では、生成パラメータを調整して、適切な品質とレスポンス速度のバランスを取ります。

ストリーミングレスポンスの実装

typescriptasync function streamResponse(
  prompt: string
): Promise<void> {
  try {
    // ストリーミングリクエストの開始
    const result = await model.generateContentStream(
      prompt
    );

    console.log('📝 生成開始...\n');

    // 逐次生成されるコンテンツの処理
    for await (const chunk of result.stream) {
      const chunkText = chunk.text();

      // リアルタイムで画面に出力
      process.stdout.write(chunkText);
    }

    console.log('\n\n✅ 生成完了');
  } catch (error) {
    console.error('❌ エラーが発生しました:', error);
  }
}

この実装により、生成されたテキストが即座に画面に表示され、ユーザーは生成プロセスをリアルタイムで確認できます。

逐次生成の実装手法

逐次生成を効果的に実装するためには、以下の手法を組み合わせます:

チャンク処理の最適化

typescriptinterface StreamChunk {
  text: string;
  timestamp: number;
  chunkIndex: number;
}

class StreamProcessor {
  private chunks: StreamChunk[] = [];
  private onChunk: (chunk: StreamChunk) => void;

  constructor(onChunk: (chunk: StreamChunk) => void) {
    this.onChunk = onChunk;
  }

チャンク処理用のクラスを作成し、各データ片を効率的に管理します。

typescript  async processStream(result: any): Promise<void> {
    let chunkIndex = 0;

    for await (const chunk of result.stream) {
      const streamChunk: StreamChunk = {
        text: chunk.text(),
        timestamp: Date.now(),
        chunkIndex: chunkIndex++
      };

      // 即座にコールバック実行
      this.onChunk(streamChunk);

      // チャンク履歴の保存
      this.chunks.push(streamChunk);
    }
  }
}

各チャンクに metadata を付与し、処理順序や時刻を記録することで、デバッグや分析に活用できます。

プログレッシブエンハンスメント

typescriptclass ProgressiveRenderer {
  private buffer: string = '';
  private wordCount: number = 0;

  render(chunk: StreamChunk): void {
    this.buffer += chunk.text;
    this.updateWordCount();
    this.displayProgress();
    this.enhanceDisplay();
  }

  private updateWordCount(): void {
    this.wordCount = this.buffer.split(/\s+/).length;
  }

バッファを使用してコンテンツを蓄積し、単語数などの統計情報をリアルタイムで更新します。

typescript  private displayProgress(): void {
    // プログレス情報の表示
    const stats = `[${this.wordCount} words generated...]`;

    // カーソル位置制御でプログレス表示
    process.stdout.write(`\r${stats}`);
    process.stdout.write('\n');
  }

  private enhanceDisplay(): void {
    // 構文ハイライトや整形の適用
    const enhanced = this.applyFormatting(this.buffer);
    console.clear();
    console.log(enhanced);
  }
}

プログレッシブエンハンスメントにより、生成されたコンテンツの品質をリアルタイムで向上させます。

パフォーマンス最適化テクニック

ストリーミング出力のパフォーマンスを最適化するための具体的なテクニックをご紹介します。

バッファリング戦略

typescriptclass BufferedStreamRenderer {
  private buffer: string[] = [];
  private bufferSize: number = 10;
  private flushInterval: number = 100; // ms

  constructor(bufferSize: number = 10) {
    this.bufferSize = bufferSize;
    this.startPeriodicFlush();
  }

バッファサイズと更新間隔を調整することで、表示頻度とパフォーマンスのバランスを最適化します。

typescript  addChunk(text: string): void {
    this.buffer.push(text);

    // バッファが満杯になったら即座にフラッシュ
    if (this.buffer.length >= this.bufferSize) {
      this.flush();
    }
  }

  private startPeriodicFlush(): void {
    setInterval(() => {
      if (this.buffer.length > 0) {
        this.flush();
      }
    }, this.flushInterval);
  }

  private flush(): void {
    const content = this.buffer.join('');
    this.buffer = [];
    process.stdout.write(content);
  }
}

定期的なフラッシュとサイズベースのフラッシュを組み合わせることで、効率的な表示更新を実現します。

メモリ使用量の最適化

typescriptclass MemoryEfficientProcessor {
  private maxBufferSize: number = 1024 * 1024; // 1MB
  private currentSize: number = 0;
  private writeStream: NodeJS.WriteStream;

  constructor(outputPath?: string) {
    this.writeStream = outputPath
      ? require('fs').createWriteStream(outputPath)
      : process.stdout;
  }

大容量のコンテンツ処理時には、メモリ使用量を制限し、必要に応じてファイル出力に切り替えます。

typescript  processChunk(chunk: string): void {
    const chunkSize = Buffer.byteLength(chunk, 'utf8');

    // メモリ制限チェック
    if (this.currentSize + chunkSize > this.maxBufferSize) {
      this.writeToFile(chunk);
    } else {
      this.writeToConsole(chunk);
      this.currentSize += chunkSize;
    }
  }

  private writeToFile(chunk: string): void {
    this.writeStream.write(chunk);
  }

  private writeToConsole(chunk: string): void {
    process.stdout.write(chunk);
  }
}

メモリ制限を超える場合の適切な処理により、システムリソースを効率的に活用します。

具体例

基本的なストリーミング実装

実際に動作するストリーミング CLI アプリケーションを段階的に実装してみましょう。

プロジェクトのセットアップ

bash# 新しいプロジェクトの作成
mkdir gemini-streaming-cli
cd gemini-streaming-cli

# package.json の初期化
yarn init -y

必要な依存関係をインストールします。

bash# Gemini API と TypeScript 関連パッケージ
yarn add @google/generative-ai
yarn add -D typescript @types/node ts-node

# CLI ツール開発用パッケージ
yarn add commander chalk ora
yarn add -D @types/inquirer

基本的な CLI 構造

typescript// src/cli.ts
import { Command } from 'commander';
import { GoogleGenerativeAI } from '@google/generative-ai';
import chalk from 'chalk';

const program = new Command();

// CLI プログラムの基本設定
program
  .name('gemini-stream')
  .description('Gemini API を使用したストリーミング CLI')
  .version('1.0.0');

Commander.js を使用して CLI インターフェースを構築します。

typescript// メインのストリーミングコマンド
program
  .command('generate')
  .description('テキストを生成します')
  .argument('<prompt>', '生成プロンプト')
  .option(
    '-s, --stream',
    'ストリーミング出力を有効化',
    true
  )
  .option(
    '-t, --temperature <value>',
    '生成の創造性',
    '0.7'
  )
  .action(async (prompt, options) => {
    await handleGenerate(prompt, options);
  });

program.parse();

コマンドラインオプションを定義し、ユーザーからの入力を処理します。

ストリーミング生成の実装

typescriptasync function handleGenerate(prompt: string, options: any): Promise<void> {
  console.log(chalk.blue('🚀 Gemini API に接続中...'));

  // API キーの検証
  const apiKey = process.env.GEMINI_API_KEY;
  if (!apiKey) {
    console.error(chalk.red('❌ GEMINI_API_KEY が設定されていません'));
    process.exit(1);
  }

環境変数から API キーを取得し、適切なエラーハンドリングを行います。

typescript  try {
    const genAI = new GoogleGenerativeAI(apiKey);
    const model = genAI.getGenerativeModel({
      model: "gemini-pro",
      generationConfig: {
        temperature: parseFloat(options.temperature),
      }
    });

    console.log(chalk.green('✅ 接続完了\n'));
    console.log(chalk.yellow('📝 生成中...\n'));

    // ストリーミング開始
    await streamGeneration(model, prompt);

  } catch (error) {
    console.error(chalk.red('❌ エラー:'), error);
    process.exit(1);
  }
}

モデルの初期化と基本的なエラーハンドリングを実装します。

リアルタイム表示の実装

typescriptasync function streamGeneration(
  model: any,
  prompt: string
): Promise<void> {
  const startTime = Date.now();
  let totalChars = 0;
  let chunkCount = 0;

  try {
    const result = await model.generateContentStream(
      prompt
    );

    // ストリーミング処理のメインループ
    for await (const chunk of result.stream) {
      const chunkText = chunk.text();

      // 統計情報の更新
      totalChars += chunkText.length;
      chunkCount++;

      // リアルタイム出力
      process.stdout.write(chunkText);

      // パフォーマンス情報の表示(デバッグ用)
      if (chunkCount % 10 === 0) {
        const elapsed = Date.now() - startTime;
        const speed = totalChars / (elapsed / 1000);
        process.stderr.write(
          `\r${chalk.gray(
            `[${totalChars} chars, ${speed.toFixed(
              1
            )} chars/sec]`
          )}`
        );
      }
    }

    // 完了時の統計表示
    const totalTime = Date.now() - startTime;
    console.log(
      chalk.green(
        `\n\n✅ 生成完了 (${totalTime}ms, ${totalChars} characters)`
      )
    );
  } catch (error) {
    console.error(
      chalk.red('\n❌ 生成中にエラーが発生しました:'),
      error
    );
    throw error;
  }
}

この実装により、生成プロセスをリアルタイムで可視化し、パフォーマンス情報も併せて表示します。

エラーハンドリングとリトライ機構

本格的な運用では、ネットワークエラーや API 制限への対応が必要です。

堅牢なエラーハンドリング

typescriptinterface RetryConfig {
  maxRetries: number;
  baseDelay: number;
  maxDelay: number;
  backoffMultiplier: number;
}

class StreamingClient {
  private config: RetryConfig;

  constructor(config: Partial<RetryConfig> = {}) {
    this.config = {
      maxRetries: 3,
      baseDelay: 1000,
      maxDelay: 30000,
      backoffMultiplier: 2,
      ...config
    };
  }

リトライ設定を管理するクラスを作成し、柔軟な設定を可能にします。

typescript  async generateWithRetry(model: any, prompt: string): Promise<void> {
    let attempt = 0;

    while (attempt <= this.config.maxRetries) {
      try {
        await this.attemptGeneration(model, prompt);
        return; // 成功時は即座に終了

      } catch (error) {
        attempt++;

        if (attempt > this.config.maxRetries) {
          throw new Error(`最大リトライ回数(${this.config.maxRetries})に達しました: ${error}`);
        }

        await this.handleRetry(attempt, error);
      }
    }
  }

指数バックオフアルゴリズムを使用したリトライ機構を実装します。

typescript  private async handleRetry(attempt: number, error: any): Promise<void> {
    const delay = Math.min(
      this.config.baseDelay * Math.pow(this.config.backoffMultiplier, attempt - 1),
      this.config.maxDelay
    );

    console.log(chalk.yellow(`⚠️  リトライ ${attempt}/${this.config.maxRetries} (${delay}ms 後)`));
    console.log(chalk.gray(`エラー: ${error.message}`));

    await new Promise(resolve => setTimeout(resolve, delay));
  }

  private async attemptGeneration(model: any, prompt: string): Promise<void> {
    // 前述のストリーミング生成ロジック
    const result = await model.generateContentStream(prompt);

    for await (const chunk of result.stream) {
      process.stdout.write(chunk.text());
    }
  }
}

各リトライ間での適切な待機時間とユーザーフィードバックを提供します。

接続状態の監視

typescriptclass ConnectionMonitor {
  private isConnected: boolean = false;
  private lastResponseTime: number = 0;
  private timeoutThreshold: number = 30000; // 30秒

  startMonitoring(): void {
    setInterval(() => {
      this.checkConnection();
    }, 5000); // 5秒間隔でチェック
  }

接続状態を監視し、タイムアウトを検出する機構を実装します。

typescript  private checkConnection(): void {
    const now = Date.now();
    const timeSinceLastResponse = now - this.lastResponseTime;

    if (timeSinceLastResponse > this.timeoutThreshold && this.isConnected) {
      console.log(chalk.yellow('\n⚠️  応答が遅延しています...'));
      this.isConnected = false;
    }
  }

  updateLastResponse(): void {
    this.lastResponseTime = Date.now();
    if (!this.isConnected) {
      console.log(chalk.green('✅ 接続が復旧しました'));
      this.isConnected = true;
    }
  }
}

接続状態の変化をユーザーに適切に通知し、透明性を確保します。

プログレス表示とユーザビリティ向上

ユーザビリティを大幅に向上させるプログレス表示機能を実装します。

高度なプログレス表示

typescriptimport ora from 'ora';

class AdvancedProgressDisplay {
  private spinner: any;
  private startTime: number;
  private estimatedTotal: number;
  private currentProgress: number = 0;

  constructor(estimatedTotal: number = 1000) {
    this.estimatedTotal = estimatedTotal;
    this.startTime = Date.now();
  }

Ora ライブラリを使用してスピナーアニメーションを実装します。

typescript  start(message: string): void {
    this.spinner = ora({
      text: message,
      spinner: 'dots',
      color: 'cyan'
    }).start();
  }

  update(currentChars: number, additionalInfo?: string): void {
    this.currentProgress = currentChars;
    const elapsed = Date.now() - this.startTime;
    const speed = currentChars / (elapsed / 1000);
    const eta = this.calculateETA(speed);

    const progressText = `生成中... ${currentChars} 文字 (${speed.toFixed(1)} chars/sec)${eta ? ` ETA: ${eta}` : ''}${additionalInfo ? ` | ${additionalInfo}` : ''}`;

    this.spinner.text = progressText;
  }

リアルタイムで進捗情報を更新し、推定完了時刻も表示します。

typescript  private calculateETA(speed: number): string | null {
    if (speed === 0 || this.currentProgress === 0) return null;

    const remaining = this.estimatedTotal - this.currentProgress;
    const etaSeconds = remaining / speed;

    if (etaSeconds > 60) {
      return `${Math.round(etaSeconds / 60)}分`;
    } else {
      return `${Math.round(etaSeconds)}秒`;
    }
  }

  succeed(message: string): void {
    const totalTime = Date.now() - this.startTime;
    this.spinner.succeed(`${message} (${totalTime}ms)`);
  }

  fail(message: string): void {
    this.spinner.fail(message);
  }
}

処理の成功・失敗に応じて適切なフィードバックを提供します。

インタラクティブな制御機能

typescriptimport readline from 'readline';

class InteractiveController {
  private rl: readline.Interface;
  private isPaused: boolean = false;
  private shouldStop: boolean = false;

  constructor() {
    this.rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout
    });

    this.setupKeyHandlers();
  }

キーボード入力を処理して、リアルタイムでストリーミングを制御します。

typescript  private setupKeyHandlers(): void {
    process.stdin.setRawMode(true);
    process.stdin.resume();

    process.stdin.on('data', (key) => {
      const keyStr = key.toString();

      switch (keyStr) {
        case ' ': // スペースキーで一時停止
          this.togglePause();
          break;
        case 'q': // qキーで終了
        case '\u0003': // Ctrl+C
          this.requestStop();
          break;
        case 's': // sキーで統計表示
          this.showStats();
          break;
      }
    });
  }

  private togglePause(): void {
    this.isPaused = !this.isPaused;
    console.log(chalk.yellow(this.isPaused ? '\n⏸️  一時停止中 (スペースで再開)' : '\n▶️  再開'));
  }

  private requestStop(): void {
    this.shouldStop = true;
    console.log(chalk.red('\n🛑 停止要求を受信しました...'));
  }

  isGenerationPaused(): boolean {
    return this.isPaused;
  }

  shouldStopGeneration(): boolean {
    return this.shouldStop;
  }
}

ユーザーがリアルタイムで生成プロセスを制御できる機能を提供します。

統合された体験の実装

typescriptasync function enhancedStreamGeneration(
  model: any,
  prompt: string
): Promise<void> {
  const progressDisplay = new AdvancedProgressDisplay();
  const controller = new InteractiveController();
  const monitor = new ConnectionMonitor();

  console.log(
    chalk.blue(
      '🎮 制御: スペース=一時停止, s=統計, q=終了\n'
    )
  );

  progressDisplay.start('Gemini API に接続中...');
  monitor.startMonitoring();

  try {
    const result = await model.generateContentStream(
      prompt
    );
    let totalChars = 0;

    progressDisplay.update(0, '生成開始');

    for await (const chunk of result.stream) {
      // 停止要求のチェック
      if (controller.shouldStopGeneration()) {
        progressDisplay.fail(
          'ユーザーによって停止されました'
        );
        break;
      }

      // 一時停止の処理
      while (controller.isGenerationPaused()) {
        await new Promise((resolve) =>
          setTimeout(resolve, 100)
        );
      }

      const chunkText = chunk.text();
      totalChars += chunkText.length;

      // 出力とプログレス更新
      process.stdout.write(chunkText);
      progressDisplay.update(totalChars);
      monitor.updateLastResponse();
    }

    progressDisplay.succeed(`生成完了: ${totalChars} 文字`);
  } catch (error) {
    progressDisplay.fail(
      `エラーが発生しました: ${error.message}`
    );
    throw error;
  }
}

全ての機能を統合し、シームレスなユーザー体験を提供します。

まとめ

本記事では、Gemini CLI のストリーミング出力機能を活用した UX 改善手法について、技術実装の観点から詳しく解説いたしました。

ストリーミング技術の導入により、従来のバッチ処理では実現できなかった以下の価値を提供できます:

技術的な成果

  • リアルタイム出力による体感速度の向上
  • メモリ効率的な大容量データ処理
  • 堅牢なエラーハンドリングとリトライ機構
  • インタラクティブな制御機能

ユーザー体験の改善

  • 処理進捗の透明性確保
  • 待機時間中の不安感解消
  • 途中停止・再開による柔軟性
  • 統計情報による処理状況の把握

これらの技術を組み合わせることで、AI を活用した CLI アプリケーションのユーザビリティを大幅に向上させることができます。特に、長時間の処理が必要なタスクにおいて、ユーザーの満足度と生産性の向上が期待できるでしょう。

今後の AI アプリケーション開発において、ストリーミング技術は標準的な手法となることが予想されます。本記事でご紹介した実装パターンを参考に、ユーザー体験を重視したアプリケーション開発に取り組んでいただければと思います。

関連リンク