Open5
Next.js+API RoutesでOpenAI ChatAPIのレスポンスをストリーミングする

OpenAIのAPIではstream
オプションをtrueにすることで、本家と同じように逐次トークンを受け取って表示ができる。(但し、全てのトークンを受け取る時間は変わらない)

中継サーバーでOpenAIへのリクエストをプロキシしている場合の実装の参考

text/event-stream
とは?
サーバーからのイベント送信を実装する際にサーバー側の応答で指定するMIMEタイプ

サーバー送信イベントを実装してみる
サーバー側の実装
// server.js
const http = require("http");
const url = require("url");
const sendServerEvent = (req, res) => {
// 接続を受けたら「Content-Type: text/event-stream」で応答する
res.writeHead(200, {
"Content-Type": "text/event-stream",
Connection: "keep-alive",
"Cache-Control": "no-cache",
});
// 3秒ごとにメッセージを送信する
const timer1 = setInterval(() => {
// 「data: 」に続けて文字列と空行を出力すると、クライアント側でmessageイベントが発生する
res.write("data: サーバーからのメッセージです。\n\n");
}, 3000);
// 5秒ごとに名前付きメッセージを送信する
const timer2 = setInterval(() => {
// 「data: 」の前に「event: 」でイベント名を出力すると、クライアント側でその名前のイベントが発生する
res.write("event: originalEvent\n");
res.write("data: サーバーからの名前付きメッセージです。\n\n");
}, 5000);
// 接続が切断されたら終了する
req.connection.addListener(
"close",
() => {
clearInterval(timer1);
clearInterval(timer2);
},
false
);
};
const server = http.createServer((req, res) => {
// CORSの全て許可
res.setHeader("Access-Control-Allow-Origin", "*");
const pathname = url.parse(req.url).pathname;
if (pathname === "/sse") {
sendServerEvent(req, res);
} else {
// 指定パス以外は404を返す
res.writeHead(404);
res.end("Not Found");
return;
}
});
server.listen(8081);
フロントエンド側の実装(EventSource)
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div style="display: flex; gap: 16px;">
<button id="start-sse">サーバーイベント送信の接続開始</button>
<button id="stop-sse">サーバーイベント送信の接続を閉じる</button>
</div>
<div id="messages"></div>
<script>
const messages = document.getElementById('messages');
document.getElementById('start-sse').addEventListener('click', () => {
// Server-Sent Eventsを実装したサーバーに接続する。
const evtSource = new EventSource('http://localhost:8081/sse');
// 接続成功時に発生するイベント
evtSource.addEventListener('open', (e) => {
console.log('接続しました。');
});
// メッセージ受信時に発生するイベント
evtSource.addEventListener('message', (e) => {
console.log('メッセージを受信しました。');
const message = e.data;
// 受信したメッセージを表示する
const messageElement = document.createElement('p');
messageElement.textContent = message;
messages.appendChild(messageElement);
});
// 名前付きメッセージ受信時に発生するイベント
evtSource.addEventListener('originalEvent', (e) => {
console.log('名前付きメッセージ: originalEventを受信しました。');
const message = e.data;
// 受信したメッセージを表示する
const messageElement = document.createElement('p');
messageElement.textContent = message;
messages.appendChild(messageElement);
});
// 接続失敗時に発生するイベント
evtSource.addEventListener('error', (e) => {
console.error('接続できません。');
});
// 切断するにはclose()を呼び出す
document.getElementById('stop-sse').addEventListener('click', () => {
evtSource.close();
console.log('切断しました。');
});
});
</script>
</body>
</html>
フロント側の実装(Fetch API)
EventSource
だとサーバー側にデータを渡すことができないので、Fetch API で実装すると良さそう。
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div style="display: flex; gap: 16px;">
<button id="start-sse">サーバーイベント送信の接続開始</button>
<button id="stop-sse">サーバーイベント送信の接続を閉じる</button>
</div>
<div id="messages"></div>
<script>
const messages = document.getElementById('messages');
const readStreamByFetch = async () => {
const res = await fetch('http://localhost:8081/sse');
// TextDecoderStreamを通して、Uint8Arrayを文字列に変換する
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
const decoder = new TextDecoder();
const read = async () => {
const { done, value } = await reader.read();
if (done) {
return;
}
// 受け取ったデータをJSオブジェクトにフォーマット
const message = value.split('\n').reduce((acc, line) => {
if (line.startsWith('data: ')) {
acc['data'] = line.replace('data: ', '');
return acc;
} else if (line.startsWith('event: ')) {
acc['event'] = line.replace('event: ', '');
return acc;
}
return acc;
}, {data: '', event: null});
// 受信したメッセージを表示する
const messageElement = document.createElement('p');
messageElement.textContent = message.data;
messages.appendChild(messageElement);
console.log(message.data);
if(message.event === 'originalEvent') {
console.log('名前付きメッセージ: originalEventを受信しました。');
}
// 次のイベントを読み取る
read();
};
read();
// 切断するにはcancel()を呼び出す
document.getElementById('stop-sse').addEventListener('click', () => {
reader.cancel();
console.log('切断しました。');
});
}
document.getElementById('start-sse').addEventListener('click', readStreamByFetch);
</script>
</body>
</html>

Next.jsのAPI RoutesでOpenAI APIの結果をストリーミングで返す
const chat = async (
messages: string[],
onStreamChunk: (message: string) => void,
onStreamEnd: () => void
) => {
const postMessages = [
...messages.map<{ role: "user" | "assistant"; content: string }>(
(message, i) => {
return {
role: i % 2 === 0 ? "user" : "assistant",
content: message,
};
}
),
];
const stream = await openai.chat.completions.create({
messages: postMessages,
model: "gpt-4-0613",
stream: true,
});
for await (const chunk of stream) {
onStreamChunk(chunk.choices[0].delta.content ?? "");
}
onStreamEnd();
};
export async function POST(req: Request) {
const body = await req.json();
const responseStream = new TransformStream();
const writer = responseStream.writable.getWriter();
chat(
body.messages,
(message) => {
writer.write(`data: ${message}\n\n`);
},
() => writer.close()
);
return new Response(responseStream.readable, {
headers: {
"Content-Type": "text/event-stream",
Connection: "keep-alive",
"Cache-Control": "no-cache, no-transform",
},
});
}