【Ollama】RESTful APIストリーミング応答の実装方法とよくあるエラー解決ガイド

問題の概要:Ollama APIでストリーミング応答が正しく処理できない

Ollamaはローカル環境で大規模言語モデル(LLM)を実行するための優れたツールですが、そのRESTful APIを使用してストリーミング応答を実装する際、開発者はいくつかの典型的な問題に遭遇します。具体的には、以下のようなエラーや課題が発生します:

  • ストリーミングレスポンスがチャンクごとに表示されず、一括で返される
  • 「Connection reset by peer」や「Unexpected end of JSON input」などのエラー
  • クライアント側でストリームデータを正しくパースできない
  • 長時間の応答で接続が途中で切断される

実際のエラーメッセージ例:

Error: Unexpected token 'H' in JSON at position 0
Error: read ECONNRESET
Error: Premature close
TypeError: response.body is not a ReadableStream

原因の解説:なぜストリーミング実装で問題が発生するのか

1. クライアントとサーバーのストリーミングプロトコルの不一致

OllamaのAPIはServer-Sent Events(SSE)に似た形式でストリーミングを提供しますが、厳密なSSE仕様とは異なる場合があります。クライアント側が標準のEventSourceをそのまま使用すると、データのパースに失敗することがあります。

2. バッファリングの問題

中間プロキシ(nginx, Apache等)やクライアントライブラリ(axios, fetch等)がレスポンスをバッファリングし、チャンク単位での転送を妨げている可能性があります。特にNode.jsのhttpモジュールや一部のブラウザ実装では、デフォルトでバッファリングが行われることがあります。

3. 接続管理の不備

ストリーミング接続は長時間維持されるため、適切なタイムアウト設定やキープアライブメカニズムが必要です。デフォルト設定のままでは、ネットワークの不安定さやサーバーリソース制限により接続が切断されることがあります。

4. エラーハンドリングの不足

ストリーミング応答では、通常のHTTPリクエストとは異なり、接続が開いたままの状態でエラーが発生することがあります。部分的なレスポンスや途中切断に対する適切なエラーハンドリングが実装されていない場合、クライアントが予期せぬ状態に陥ります。

解決方法:ステップバイステップでの実装ガイド

ステップ1: 基本的なストリーミングAPIリクエストの作成

まず、OllamaのストリーミングAPIを正しく呼び出すための基本的なリクエストを作成します。`stream`パラメータを`true`に設定することが重要です。

// cURLでの基本的なストリーミングリクエスト例
curl -X POST http://localhost:11434/api/generate 
  -H "Content-Type: application/json" 
  -d '{
    "model": "llama2",
    "prompt": "日本の首都について説明してください",
    "stream": true
  }'

ステップ2: Node.jsでのストリーミングクライアント実装

Node.js環境では、`fetch` APIを使用してストリーミングレスポンスを処理します。

async function streamOllamaResponse(prompt, model = "llama2") {
  const response = await fetch('http://localhost:11434/api/generate', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: model,
      prompt: prompt,
      stream: true
    })
  });

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  if (!response.body) {
    throw new Error('ReadableStream not supported');
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let result = '';

  try {
    while (true) {
      const { done, value } = await reader.read();
      
      if (done) {
        console.log('nnストリーム終了');
        break;
      }
      
      const chunk = decoder.decode(value);
      
      // 複数のJSONオブジェクトが1つのチャンクに含まれる場合を処理
      const lines = chunk.split('n').filter(line => line.trim() !== '');
      
      for (const line of lines) {
        try {
          const data = JSON.parse(line);
          if (data.response) {
            process.stdout.write(data.response);
            result += data.response;
          }
          
          // 完了フラグのチェック
          if (data.done) {
            console.log('nn生成完了');
            return result;
          }
        } catch (e) {
          console.error('JSONパースエラー:', e.message, 'データ:', line);
        }
      }
    }
  } finally {
    reader.releaseLock();
  }
  
  return result;
}

// 使用例
streamOllamaResponse("AIの未来について教えてください")
  .then(result => console.log('nn最終結果:', result))
  .catch(error => console.error('エラー:', error));

ステップ3: Pythonでのストリーミングクライアント実装

Pythonでは、`requests`ライブラリを使用してストリーミングを実装します。

import requests
import json

def stream_ollama_response(prompt, model="llama2"):
    url = "http://localhost:11434/api/generate"
    
    payload = {
        "model": model,
        "prompt": prompt,
        "stream": True
    }
    
    try:
        response = requests.post(url, json=payload, stream=True, timeout=30)
        response.raise_for_status()
        
        full_response = ""
        
        for line in response.iter_lines():
            if line:
                try:
                    # 各行はJSONオブジェクト
                    data = json.loads(line.decode('utf-8'))
                    
                    if 'response' in data:
                        print(data['response'], end='', flush=True)
                        full_response += data['response']
                    
                    if data.get('done', False):
                        print("nn生成完了")
                        break
                        
                except json.JSONDecodeError as e:
                    print(f"nJSONデコードエラー: {e}")
                    print(f"生データ: {line}")
                except Exception as e:
                    print(f"n予期せぬエラー: {e}")
        
        return full_response
        
    except requests.exceptions.RequestException as e:
        print(f"リクエストエラー: {e}")
        return None
    except Exception as e:
        print(f"予期せぬエラー: {e}")
        return None

# 使用例
if __name__ == "__main__":
    result = stream_ollama_response("機械学習とは何ですか?")
    if result:
        print(f"nn最終的な応答: {result}")

ステップ4: ブラウザ(JavaScript)での実装

ブラウザ環境では、Fetch APIとReadableStreamを使用します。

async function streamOllamaInBrowser(prompt, model = "llama2") {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 60000); // 60秒タイムアウト
  
  try {
    const response = await fetch('http://localhost:11434/api/generate', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: model,
        prompt: prompt,
        stream: true
      }),
      signal: controller.signal
    });
    
    clearTimeout(timeoutId);
    
    if (!response.ok) {
      throw new Error(`HTTPエラー: ${response.status}`);
    }
    
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let result = '';
    
    // ストリームの読み取り
    while (true) {
      const { done, value } = await reader.read();
      
      if (done) {
        console.log('ストリーム終了');
        break;
      }
      
      const chunk = decoder.decode(value);
      const lines = chunk.split('n').filter(line => line.trim() !== '');
      
      for (const line of lines) {
        try {
          const data = JSON.parse(line);
          
          if (data.response) {
            // UIに表示(例: div要素に追加)
            const outputDiv = document.getElementById('output');
            if (outputDiv) {
              outputDiv.textContent += data.response;
            }
            result += data.response;
          }
          
          if (data.done) {
            console.log('生成完了');
            return result;
          }
        } catch (e) {
          console.error('JSONパースエラー:', e, 'データ:', line);
        }
      }
    }
    
    return result;
    
  } catch (error) {
    if (error.name === 'AbortError') {
      console.error('リクエストがタイムアウトしました');
    } else {
      console.error('エラーが発生しました:', error);
    }
    throw error;
  }
}

// 使用例
document.getElementById('generate-btn').addEventListener('click', async () => {
  const prompt = document.getElementById('prompt-input').value;
  try {
    const result = await streamOllamaInBrowser(prompt);
    console.log('生成結果:', result);
  } catch (error) {
    console.error('生成中にエラーが発生しました:', error);
  }
});

よくあるエラーとその解決策

エラー1: 「Unexpected token in JSON」

原因: 不完全なJSONデータや不正なフォーマットの受信

解決策:

// エラーハンドリングを強化
try {
  const data = JSON.parse(line);
  // 正常処理
} catch (e) {
  // 不完全なJSONをスキップまたはバッファリング
  console.warn('不完全なJSONデータをスキップ:', line);
  // 必要に応じてバッファに追加して次回のチャンクと結合
}

エラー2: 「Connection reset」またはタイムアウト

原因: 長時間の接続によるサーバーまたはネットワークの制限

解決策:

// タイムアウト設定の追加
const controller = new AbortController();
const timeoutId = setTimeout(() => {
  controller.abort();
  console.log('ストリーミングタイムアウト');
}, 120000); // 2分

// fetchオプションにsignalを追加
const response = await fetch(url, {
  method: 'POST',
  // ... 他のオプション
  signal: controller.signal
});

// リクエスト完了後はタイムアウトをクリア
clearTimeout(timeoutId);

エラー3: ストリームが途中で止まる

原因: バッファリングやフロー制御の問題

解決策:

// Node.jsでバッファリングを無効化
const http = require('http');

const req = http.request({
  hostname: 'localhost',
  port: 11434,
  path: '/api/generate',
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  }
}, (res) => {
  // バッファリングを無効化
  res.setEncoding('utf8');
  
  res.on('data', (chunk) => {
    // 即時処理
    console.log('受信データ:', chunk);
  });
});

req.write(JSON.stringify({
  model: "llama2",
  prompt: "テスト",
  stream: true
}));
req.end();

まとめ・補足情報

OllamaのRESTful APIを使用したストリーミング応答の実装では、以下のポイントに注意することが重要です:

  1. ストリーミングモードの有効化: リクエストボディに必ず"stream": trueを設定してください。
  2. 適切なパーシング: レスポンスは改行区切りのJSONオブジェクトとして送信されるため、行ごとのパーシングが必要です。
  3. エラーハンドリング: ストリーミング接続は長時間維持されるため、ネットワークエラーやタイムアウトへの対応が不可欠です。
  4. パフォーマンス考慮: UIをブロックしないように非同期処理を適切に実装し、必要に応じてスロットリングを追加します。
  5. CORS設定: ブラウザから直接Ollamaサーバーにアクセスする場合は、OllamaサーバーのCORS設定を確認または変更する必要があります。

高度な使用例: より複雑なシナリオでは、以下のような拡張が考えられます:

// 複数モデルの切り替え機能
async function streamWithModelSelection(prompt, model, options = {}) {
  const defaultOptions = {
    temperature: 0.7,
    top_p: 0.9,
    max_tokens: 1000,
    ...options
  };
  
  const response = await fetch('http://localhost:11434/api/generate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: model,
      prompt: prompt,
      stream: true,
      options: defaultOptions
    })
  });
  
  // ... ストリーミング処理
}

// プログレス表示の追加
function createProgressTracker(totalSteps = 100) {
  let currentStep = 0;
  
  return {
    update: (increment = 1) => {
      currentStep += increment;
      const percentage = Math.min((currentStep / totalSteps) * 100, 100);
      console.log(`進行状況: ${percentage.toFixed(1)}%`);
      return percentage;
    },
    complete: () => {
      console.log('処理完了');
    }
  };
}

OllamaのストリーミングAPIを正しく実装することで、大規模言語モデルの応答をリアルタイムで表示するインタラクティブなアプリケーションを構築できます。本記事で紹介したパターンとベストプラクティスを参考に、堅牢でユーザーフレンドリーなAIアプリケーションの開発に役立ててください。

この記事は役に立ちましたか?