50行未満のNode.jsでストリーミング応答するClaudeチャットボットを作る方法

Dev.to / 2026/4/21

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical Usage

要点

  • ストリーミング応答は、生成結果を全文待たずに逐次表示することで、チャットボットのユーザー体験を大きく改善できることが述べられています。
  • 記事では、AnthropicのSDKを使ってClaudeのチャットボットをNode.jsで構築する方法を、コンパクトな例として紹介しています。
  • サンプルコードは会話履歴配列をシンプルに管理し、送信前に各ユーザーメッセージを履歴へ追加します。
  • 受信したトークンをその場で標準出力(stdout)へ書き出すことで、リアルタイム表示を実現する流れが示されています。
  • 実装全体は50行未満の小さなスクリプトとして提示され、素早い導入と理解を重視しています。

Node.js 50行未満でストリーミング応答するClaudeチャットボットを作る方法

ストリーミングは、難しそうに聞こえるのに、ユーザー体験をまったく別物にしてしまう機能のひとつです。3〜5秒間スピナーを見つめる代わりに、ユーザーは応答が単語ごとに表示されるのを見ることになります――まるで誰かが入力しているのを眺めているように。

Node.jsでClaudeを使ってそれを行う方法を紹介します。全体は50行未満です。

完全なコード

const Anthropic = require('@anthropic-ai/sdk');
const readline = require('readline');

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const history = [];

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

async function chat(userMessage) {
  history.push({ role: 'user', content: userMessage });

  process.stdout.write('
Claude: ');
  let fullResponse = '';

  const stream = await client.messages.stream({
    model: 'claude-opus-4-5',
    max_tokens: 1024,
    messages: history
  });

  for await (const chunk of stream) {
    if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
      process.stdout.write(chunk.delta.text);
      fullResponse += chunk.delta.text;
    }
  }

  console.log('
');
  history.push({ role: 'assistant', content: fullResponse });
}

function prompt() {
  rl.question('You: ', async (input) => {
    if (input.toLowerCase() === 'quit') return rl.close();
    await chat(input);
    prompt();
  });
}

prompt();

以上です。次のコマンドで実行してください:

npm install @anthropic-ai/sdk
ANTHROPIC_API_KEY=your_key node chatbot.js

ストリーミングが実際にどう動いているか

ClaudeのAPIはServer-Sent Events(SSE)を使用します。messages.stream() を呼び出すと、接続が開いたままになり、サーバーが生成に合わせてチャンクをプッシュしてきます。

各チャンクには type があります。注目すべきものは:

返却形式: {"translated": "翻訳されたHTML"}
Type 意味
content_block_start Claudeがテキストブロックの開始を行おうとしています
content_block_delta ここに次のテキストの断片があります
content_block_stop そのブロックは完了です
message_stop レスポンス全体が完了です

実際の単語が入っているのは text_delta 付きの content_block_delta だけです。ここです。

Expressサーバーに追加する

テスト目的ならターミナル版で問題ありません。これをHTTPエンドポイントとして公開する方法は次のとおりです:

const express = require('express');
const Anthropic = require('@anthropic-ai/sdk');

const app = express();
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

app.use(express.json());

app.post('/chat', async (req, res) => {
  const { messages } = req.body;

  // SSEヘッダーを設定する
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const stream = await client.messages.stream({
    model: 'claude-opus-4-5',
    max_tokens: 1024,
    messages
  });

  for await (const chunk of stream) {
    if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
      res.write(`data: ${JSON.stringify({ text: chunk.delta.text })}

`);
    }
  }

  res.write('data: [DONE]

');
  res.end();
});

app.listen(3000);

フロントエンドでストリームを消費する

async function sendMessage(messages) {
  const response = await fetch('/chat', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ messages })
  });

返却形式: {"translated": "翻訳されたHTML"}const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, {stream: true });
    const lines = buffer.split('
');
    buffer = lines.pop(); // 不完全な行をバッファに保持する

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = line.slice(6);
        if (data === '[DONE]') return;
        const { text } = JSON.parse(data);
        // テキストをUI要素に追加する
        document.getElementById('response').textContent += text;
      }
    }
  }
}

よくある間違い

1. バッファの扱いを正しくない

SSEのチャンクは、常にJSONの境界に揃っているとは限りません。上記の buffer のパターンは、チャンクがJSONの途中で分割された場合に対応します。

2. レスポンスの終了処理を忘れる

ストリームが終わった後に res.end() を呼ばないと、クライアントはそれ以上のデータを待ち続けてしまいます。

3. 正しいヘッダーを設定していない

Content-Type: text/event-stream がないと、ブラウザはそれをSSEとして扱わず、何も描画せずにレスポンス全体が返ってくるのを待ちます。

4. 分離せずに複数ユーザーへストリーミングする

それぞれのリクエストには、そのリクエスト専用のストリームインスタンスが必要です。リクエスト間でストリームの状態を共有しないでください。

コストの疑問

ストリーミングはトークン数を変えません。ストリームしてもしなくても同じトークン数を支払うことになります。変わるのは体感のレイテンシです。2秒の応答がストリーミングされると、まとめて一度に表示される2秒の応答よりも体感で3倍速く感じられます。

トークン単位のAPIを構築しているなら、このレイテンシ改善は無料です。もし定額制API(SimplyLouie のような月$2)を構築しているなら、コスト計算を気にする必要がまったくありません—常にすべてストリーミングしましょう。

次に作るべきもの

ストリーミングが動くようになったら、自然な次のステップは以下です:

  • タイプ表示(インジケータ): 最初のチャンクが届く前に「Claudeが考えています...」を表示する
  • 停止ボタン: 長い応答をストリームの途中でユーザーに中断させる
  • トークンカウント: 応答がストリームされるのに合わせてライブのトークンカウンタを表示する
  • 会話のエクスポート: message_stop の後に、完全なストリーミング応答を履歴に保存する

上記の完全なコードはそのままで動きます。そのままコピーして、APIキーを設定して、実行してください。2分以内にストリーミング対応のClaudeチャットボットができあがります。

ClaudeのAPIを使って開発していますか? SimplyLouie は月$2の定額でAPIアクセスを提供しています—トークンカウントなし、請求のサプライズなし。