【GPT】驚きのコスト削減！5ドルで始めるGPTチャット開発ガイド

## はじめに
本記事では、OpenAI APIを利用したチャットアプリの開発を通じて、コスト削減の検証を行います。
調査や開発時に大変便利なOpenAIのChatGPTですが、頻繁に使用する場合は月額課金をすることが多いはず。
執筆時点で個人向けプランの月額料金は20ドル（約3,000円）です。
OpenAI APIを使うことでどれだけ費用を抑えられるのでしょうか。

## OpenAI APIの料金体系
OpenAI APIの料金は、リクエストごとにトークン単位で課金されます。 
※日本語の場合、1トークンはおおよそ1文字に相当します。

gpt-4o-miniモデルの料金は以下のようになっています：

入力トークン: \\$0.150 / 1,000,000 トークン
出力トークン: \\$0.600 / 1,000,000 トークン 
※[料金│OpenAI](https://openai.com/ja-JP/api/pricing/)より

たとえば、1回のやりとりで総トークン数が300（入力100、出力200）であれば、約0.020円となります。
ただし、同一スレッドでのやり取りが長引くと、入力トークン量が増加し、費用が高くなる点に注意が必要です。

## APIのキー発行
### OpenAI APIのクレジット購入
[Billing│OpenAI](https://platform.openai.com/settings/organization/billing/overview)にてOpenAI APIのクレジットを購入します。
最低購入金額の5ドルを購入します。

### キー発行
[API Keys│OpenAI](https://platform.openai.com/settings/organization/api-keys)を開き、画面中央の[+Create new secret key]から任意の名前のキーを作成します。
設定はデフォルトのままで問題ありません。
作成されたキーのシークレット文字列が表示されるので、必ず控えておいてください。

## スレッド管理ができる簡単なチャットアプリの作成
### 開発のゴール
・OpenAI API（gpt-4o-mini）を利用し、簡易的にChatGPTの使用感を再現
・スレッド切り替え/削除機能を実装
・出入力トークン数を表示する
・今回はチャット履歴の永続化は行わない
・個人用途のため、簡単に起動して簡単に使えること重視

### 使用した技術
・Node.js
・React + Vite

### 作成手順
#### Node.jsのインストール
[Node.js](https://nodejs.org/en/)をインストールします。

#### アプリの初期化
コマンドにてViteアプリを作成します。
```npm create vite@latest my-gpt -- --template react```
テンプレートの種類を聞かれた場合はReact - JavaScriptを選択します。

#### package.jsonの記載
作成されたpackage.jsonの内容を書き換えます。
```json:package.json
{
  "name": "my-gpt",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@emotion/react": "^11.11.1",
    "@emotion/styled": "^11.11.0",
    "@mui/icons-material": "^5.11.16",
    "@mui/material": "^5.13.6",
    "axios": "^1.4.0",
    "marked": "^15.0.3",
    "openai": "^4.76.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.28",
    "@types/react-dom": "^18.0.11",
    "@vitejs/plugin-react": "^4.0.0",
    "eslint": "^9.16.0",
    "vite": "^4.3.9"
  }
}
```

#### パッケージのインストール
アプリのルートディレクトリにて、以下コマンドを実行します。
```npm install```

#### OpenAI APIキーの記述
環境変数を記載するファイルを作成し、APIキーとAPIのモデルバージョンを記載します。
アプリのルートディレクトリに```.env```ファイルを新規作成します。
```.env
VITE_GPT_MODEL=gpt-4o-mini
VITE_API_KEY=ここに作成したAPIKeyのシークレットをペースト
```
※環境変数名の頭には"VITE"を付けてください。

#### コーディング
以下の構造になるようにコードを作成していきます。
```
my-gpt/
├── node_modules/          # 依存関係（自動生成）
├── src/
│   ├── components/
│   │   ├── Chat.jsx       # Chatエリア
│   │   └── CodeBlock.jsx  # コードブロック
│   ├── App.css            # スタイル
│   ├── App.jsx            # アプリケーションのルートコンポーネント
│   └── main.jsx           # エントリーポイント（自動生成）
├── index.html             # HTML（自動生成）
├── package.json           # プロジェクト設定と依存関係
├── vite.config.js         # Viteの設定ファイル（自動生成）
└── .env                   # 環境変数
```

```css:src/App.css
pre code {
    background-color: #b5b5b5;
    padding: 10px;
    border-radius: 4px;
    margin: 0;
}
```

```jsx:src/App.jsx
import React, { useState, useEffect } from 'react';
import { CssBaseline, Box, ThemeProvider, createTheme } from '@mui/material';
import Sidebar from './components/Sidebar';
import Chat from './components/Chat';
import OpenAI from 'openai';
import './app.css';

// OpenAIのAPIクライアントを初期化（環境変数からAPIキーを取得）
const openai = new OpenAI({ apiKey: import.meta.env.VITE_API_KEY, dangerouslyAllowBrowser: true });

// ダークテーマを定義
const darkTheme = createTheme({ palette: { mode: 'dark' } });

const App = () => {
    // スレッド一覧と現在のスレッドを管理する状態変数
    const [threads, setThreads] = useState([]); // スレッド一覧
    const [currentThread, setCurrentThread] = useState(null); // 現在選択中のスレッド

    // 初回ロード時にlocalStorageからスレッド情報を読み込む
    useEffect(() => {
        const storedThreads = localStorage.getItem('threads');
        if (storedThreads) setThreads(JSON.parse(storedThreads));
    }, []);

    // スレッドが変更されるたびにlocalStorageに保存
    useEffect(() => {
        localStorage.setItem('threads', JSON.stringify(threads));
    }, [threads]);

    // 現在選択されているスレッドを状態に同期
    useEffect(() => {
        if (currentThread) {
            const updatedThread = threads.find(thread => thread.id === currentThread.id);
            if (updatedThread) setCurrentThread(updatedThread);
        }
    }, [threads, currentThread]);

    // 新しいスレッドを作成
    const handleNewThread = () => {
        const newThread = { id: Date.now(), messages: [] }; // 新スレッドの初期構造
        setThreads(prevThreads => {
            const newThreads = [newThread, ...prevThreads];
            setCurrentThread(newThread); // 新スレッドを選択
            return newThreads;
        });
    };

    // スレッドを選択
    const handleThreadSelect = (threadId) => {
        const selectedThread = threads.find(thread => thread.id === threadId);
        setCurrentThread(selectedThread);
    };

    // スレッドを削除
    const handleThreadDelete = (threadId) => {
        setThreads(prevThreads => {
            const updatedThreads = prevThreads.filter(thread => thread.id !== threadId);
            if (currentThread?.id === threadId) setCurrentThread(updatedThreads[0] || null);
            return updatedThreads;
        });
    };

    // メッセージ送信処理とAIからの応答取得
    const handleSendMessage = async (message) => {
        if (!currentThread) {
            handleNewThread(); // スレッドがない場合は新規作成
            return;
        }

        // ユーザーのメッセージを現在のスレッドに追加
        const newMessage = { role: 'user', content: message };
        setThreads(prevThreads =>
            prevThreads.map(thread =>
                thread.id === currentThread.id
                    ? { ...thread, messages: [...thread.messages, newMessage] }
                    : thread
            )
        );

        try {
            // OpenAI APIで応答を取得
            const response = await openai.chat.completions.create({
                model: import.meta.env.VITE_GPT_MODEL,
                messages: [
                    ...currentThread.messages,
                    newMessage,
                    { role: 'system', content: 'You are a Japanese assistant' }
                ]
            });

            // AIのメッセージとトークン使用量を取得
            const assistantMessage = {
                role: 'assistant',
                content: response.choices[0]?.message?.content || '',
            };
            const inputTokens = response.usage ? response.usage.prompt_tokens : 'N/A';
            const outputTokens = response.usage ? response.usage.completion_tokens : 'N/A';
            assistantMessage.content += `\n\n入力token: ${inputTokens}\n出力token: ${outputTokens}`;

            // スレッドを更新
            setThreads(prevThreads =>
                prevThreads.map(thread =>
                    thread.id === currentThread.id
                        ? { ...thread, messages: [...thread.messages, assistantMessage] }
                        : thread
                )
            );
        } catch (error) {
            console.error('Error while fetching GPT response:', error);
        }
    };

    // アプリケーションのメインレイアウト
    return (
        <ThemeProvider theme={darkTheme}>
            <CssBaseline /> {/* マテリアルUIの標準化スタイル */}
            <Box sx={{ display: 'flex', height: '100vh' }}> {/* レイアウト全体を包むコンテナ */}
                <Sidebar
                    threads={threads}
                    currentThread={currentThread}
                    onNewThread={handleNewThread}
                    onThreadSelect={handleThreadSelect}
                    onThreadDelete={handleThreadDelete}
                />
                <Box sx={{ flexGrow: 1, overflow: 'hidden', overflowY: 'auto' }}>
                    <Chat thread={currentThread} onSendMessage={handleSendMessage} />
                </Box>
            </Box>
        </ThemeProvider>
    );
};

export default App;
```

```jsx:src/components/Chat.jsx
import React, { useState, useRef, useEffect } from 'react';
import { Box, TextField, IconButton, Typography, CircularProgress } from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import { marked } from 'marked';

const Chat = ({ thread, onSendMessage }) => {
    const [message, setMessage] = useState(''); // 入力中のメッセージ
    const [isLoading, setIsLoading] = useState(false); // メッセージ送信中の状態
    const messagesEndRef = useRef(null); // チャットの末尾を参照するためのref

    // メッセージリストが更新されたら自動的にスクロール
    useEffect(() => { 
        messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
    }, [thread?.messages]);

    // マークダウンをHTMLに変換する関数
    const renderMarkdown = (markdown) => (
        <div dangerouslySetInnerHTML={{ __html: marked(markdown, { gfm: true, breaks: true }) }} />
    );

    // メッセージ送信処理
    const handleSubmit = async (event) => {
        event.preventDefault(); // フォームのデフォルトの送信動作を防止
        if (message.trim() === '') return; // 空白メッセージは送信しない
        setIsLoading(true); // ローディング状態にする
        await onSendMessage(message); // メッセージ送信処理を実行
        setMessage(''); // 入力欄をリセット
        setIsLoading(false); // ローディング状態を解除
    };

    // Enterキー押下時の処理 (Shiftキーが押されていない場合に送信)
    const handleKeyDown = (event) => {
        if (event.key === 'Enter' && !event.shiftKey) {
            event.preventDefault(); // デフォルトの改行動作を防止
            handleSubmit(event); // メッセージ送信処理を実行
        }
    };

    return (
        <Box component="main" sx={{ flexGrow: 1, p: 3, overflowY: 'auto', height: '100%' }}>
            {/* スレッドIDを表示。スレッドが選択されていない場合は通知メッセージ */}
            <Typography variant="h6" gutterBottom>{thread ? `スレッド ${thread.id}` : 'スレッドを選択してください'}</Typography>
            {/* メッセージ一覧を表示 */}
            {thread?.messages.map((msg, index) => (
                <Box key={index} mb={2} sx={{ display: 'flex' }}>
                    <Box sx={{ maxWidth: '70%', padding: '8px 12px', borderRadius: '12px', backgroundColor: msg.role === 'user' ? '#1976d2' : '#f1f1f1', color: msg.role === 'user' ? '#fff' : '#000' }}>
                        {/* メッセージの送信者を表示 */}
                        <Typography variant="body2" sx={{ fontWeight: 'bold', mb: 0.5 }}>{msg.role === 'user' ? 'You' : 'GPT'}</Typography>
                        {/* マークダウンをレンダリング */}
                        {renderMarkdown(msg.content)}
                    </Box>
                </Box>
            ))}
            {/* メッセージ入力フォーム */}
            {thread && (
                <Box component="form" onSubmit={handleSubmit}>
                    <TextField
                        fullWidth
                        value={message}
                        onChange={(e) => setMessage(e.target.value)}
                        placeholder="メッセージを入力してください"
                        onKeyDown={handleKeyDown} // Enterキーの挙動を処理
                        multiline // 改行対応
                        InputProps={{
                            endAdornment: (
                                <IconButton type="submit" disabled={isLoading}>
                                    {isLoading ? <CircularProgress size={24} /> : <SendIcon />}
                                </IconButton>
                            ),
                        }}
                    />
                </Box>
            )}
            {/* 自動スクロール用の要素 */}
            <div ref={messagesEndRef} />
        </Box>
    );
};

export default Chat;

```

```jsx:src/components/Sidebar.jsx
import React from 'react';
import {
    List, ListItem, ListItemButton, ListItemIcon, ListItemText,
    IconButton, Drawer, Toolbar, Divider, Box
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';

// サイドバーの幅を定義
const drawerWidth = 240;

/**
 * Sidebar コンポーネント
 * スレッドのリストを表示し、スレッドの選択、新規作成、削除の操作を提供するサイドバー
 * 
 * @param {Array} threads - スレッドのリスト
 * @param {Object} currentThread - 現在選択されているスレッド
 * @param {Function} onNewThread - 新しいスレッドを作成するための関数
 * @param {Function} onThreadSelect - スレッドを選択するための関数
 * @param {Function} onThreadDelete - スレッドを削除するための関数
 */
const Sidebar = ({ threads, currentThread, onNewThread, onThreadSelect, onThreadDelete }) => {
    return (
        <Drawer
            sx={{
                width: drawerWidth, /* サイドバーの幅設定 */
                flexShrink: 0, /* 幅の縮小を防ぐ */
                '& .MuiDrawer-paper': {
                    width: drawerWidth, /* ドロワー内のコンテンツ幅 */
                    boxSizing: 'border-box' /* ボックスのサイズを調整 */
                },
            }}
            variant="permanent" /* 常に表示されるサイドバー */
            anchor="left" /* サイドバーの位置を左側に指定 */
        >
            <Toolbar /> {/* ツールバーを追加してコンテンツを少し下げる */}
            <Divider /> {/* セクションを区切るための線 */}
            <List>
                {/* 新しいスレッドを作成するためのボタン */}
                <ListItem disablePadding>
                    <ListItemButton onClick={onNewThread}>
                        <ListItemIcon>
                            <AddIcon /> {/* 新しいスレッド作成アイコン */}
                        </ListItemIcon>
                        <ListItemText primary="新しいスレッド" />
                    </ListItemButton>
                </ListItem>
                {/* 各スレッドをリストとして表示 */}
                {threads.map((thread) => (
                    <ListItem key={thread.id} disablePadding>
                        <ListItemButton
                            selected={currentThread?.id === thread.id} /* 現在選択されているスレッドを強調 */
                            onClick={() => onThreadSelect(thread.id)} /* スレッド選択時の処理 */
                        >
                            <ListItemText primary={`スレッド ${thread.id}`} />
                            {/* スレッド削除ボタン */}
                            <IconButton
                                edge="end"
                                aria-label="delete"
                                onClick={(e) => {
                                    e.stopPropagation(); /* ボタンクリック時にリストアイテムのクリックイベントを防止 */
                                    onThreadDelete(thread.id);
                                }}
                            >
                                <DeleteIcon /> {/* 削除アイコン */}
                            </IconButton>
                        </ListItemButton>
                    </ListItem>
                ))}
            </List>
            <Divider /> {/* 下部のセクションを区切る線 */}
            <Box sx={{ flexGrow: 1 }} /> {/* 残りのスペースを占有 */}
        </Drawer>
    );
};

export default Sidebar;
```

#### アプリの起動
アプリのルートディレクトリにて以下コマンドを実行し、表示されたURLをブラウザで開きます。
```npm run dev```

## 完成したアプリ
チャットで質問してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935991/f751bb89-9920-fd25-aa5b-224fa90c8d77.png)

スレッドでの連続した話題を参照できるかを試します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935991/73935293-8e9d-844f-bdf5-e752cac93d15.png)

モデルが4o-miniのため少し回答が遅いですが、欲しい機能としては足りるものができました。


## 料金の検証
本アプリを実際に使用した際のトークン使用量を基に、料金の概算を行います。

### シナリオ
・1スレッドあたり5回のやり取り
・1スレッドの平均トークン数は、入力3,000トークン、出力1,500トークン
・1日10スレッド利用した場合の月間総額を試算

### トークン量概算
・1スレッドのトークン数: 入力30,000トークン、出力15,000トークン
・1日のトークン数: 入力300,000トークン、出力150,000トークン
・月間トークン数: 入力9,000,000トークン、出力4,500,000トークン

### 料金概算
・1日の料金: 入力\\$0.045、出力\\$0.09 → 合計\\$0.135（約20円）
・月間の料金: 入力\\$1.35、出力\\$2.7 → 合計\\$4.05（約607円）

**合計: 月あたり\\$4.05（約607円）**

※実際にはOpenAI側で適宜キャッシュが使用されるため、実際の料金はこの試算よりも低くなる可能性があります。


## さいごに
本記事では、OpenAI APIを利用したチャットアプリの運営にかかるコストについて検討しました。
その結果、ChatGPTの個人プラン（月額約3,000円）と比較して、かなりのコスト削減が可能であると示されました。

もちろん、利用の仕方によっては必ずしもコストが安く抑えられるわけではないことに注意が必要です。
特に、大量の会話を処理したり、大規模なデータを扱う場面では、トークン数が急激に増加する可能性が高くなります。
また、APIキーの管理にも十分な注意が必要です。
不正に使用された場合には高額請求のリスクがあるため、キーの適切な取り扱いが不可欠です。