「無料3,000リクエスト」を使い切る! 第1回
※再現可能なソースコード全文は文末にあります。
SAKURA Internetは無料で3000リクエストも使えて太っ腹です。
夏休みの自由研究のため、セットアップ後のファーストアプリ作成にチャレンジした記事!
この記事で作ったもの
今回作るのは、こんな感じのシンプルなチャットアプリ。
- PHP
- HTML / CSS / JavaScript
- Docker
- さくらのAI Engine
- OpenAI互換API
- Qwen
- ストリーミング表示
特別なフレームワークなし。
「LLM APIって、実際にはどうやって呼び出しているの?」
が分かる最小構成。
前回のセットアップ
下記の記事でさくらのAI Engineを使うセットアップをしました。
はじめに
さくらのAI Engineが面白い。
APIが公開されてるから、GPUのインフラを気にしないで色んなLLMが RESTでスパスパ叩ける。
公式のコントロールパネルに行けばブラウザ上で動くプレイグラウンドがあるんだけど。
あれって使うたびにいちいちさくらのコントロールパネルにログインしなきゃいけないから、ちょっと触りたいときに最高に面倒くさい。
仕組みを覚えるついでに、ログイン不要で手元から一発で動く自分専用のチャット画面が欲しくなった。
面倒な画面構築はAIに丸投げして、爆速で自作してみたからメモを残しておく。
ローカルで動く極小のフロントエンドを用意する
さくらのAPIはOpenAI互換だからめちゃくちゃ扱いやすい。
わざわざヘビーなフレームワークを入れるのはだるいので、PHP1ファイルで完結するチャットUIを作ることにした。
極小のサンプルにしたので、これをもとに、改造すれば自分専用のチャットが作れるはず。
※起動方法やソース全文は後半の付録に記載
まとめ
デバッグしたおかげでリクエストのペイロードがどう飛んでいるのか通信の中身もバッチリ理解できた。
AIを相棒にすれば、これくらいの実装なら30分もかからずに動くものが出来上がるから本当にいい時代になったと思う。(ただし完全に動作確認終えるには30分では足りない)
手元でサクサク動くようになって大満足したから、
さっそく自作チャットをちょっと改造してエラーも強化して「これからよろしくな!」って打ってみた。
そしたら画面に
「Authorizationエラーです。トークンが切れているか無効です」
って真っ赤な文字で冷たく返された。
保存したアカウントトークンのコピペが最後の一文字だけ足りていなくて、
認証の仕組みを身をもって知ることになった。
ーーー以上ーーー
付録1 解説
構成
構成はかなりシンプル。
┌──────────────┐
│ Browser │
│ HTML/JS │
└──────┬───────┘
│ POST
▼
┌──────────────┐
│ PHP │
│ API Proxy │
└──────┬───────┘
│ OpenAI互換API
▼
┌────────────────────┐
│ さくらのAI Engine │
│ │
│ Qwen │
└────────────────────┘
PHPをAPIの中継役に。
APIキーをブラウザ側のJavaScriptに直接書かなくて済むのがメリット。
STEP 1:APIを利用
ここで重要なのが、APIキーをフロントエンドに直接書かないこと。
例えば、
const API_KEY = "xxxxxxxxxxxxxxxx";
のようにJavaScriptへ書いてしまうと、ブラウザから誰でも見え、再利用できてしまう。
今回は、
Browser
↓
PHP
↓
API
とすることで、APIキーはPHP側だけで扱う。
STEP 2:PHPからLLMを呼び出す
PHPのcURLでAPIを叩く。
基本的な処理はこれだけ。
$ch = curl_init($host . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json'
],
CURLOPT_POSTFIELDS => json_encode([
'model' => $model,
'messages' => $messages,
'stream' => true
]),
]);
ポイントは、
'model' => $model,
と、
'messages' => $messages,
OpenAI互換形式なので、LLMチャットでおなじみの形式。
STEP 3:ストリーミング表示する
LLMチャットでは、回答が全部生成されてから表示するより、
こんにちは
↓
こんにちは!
↓
こんにちは!今日は
↓
こんにちは!今日はどうしましたか?
のように、生成された文章を少しずつ表示するアレ。
'stream' => true
PHP側では、受け取ったデータを順次ブラウザへ渡す。
CURLOPT_WRITEFUNCTION => function ($ch, $data) {
echo $data;
flush();
return strlen($data);
}
STEP 4:チャット画面を作る
フロントエンドはHTML、CSS、JavaScript。
超絶シンプル。
┌──────────────────────────────────┐
│ Sakura LLM Chat │
├──────────────────────────────────┤
│ │
│ you │
│ こんにちは │
│ │
│ AI │
│ こんにちは!今日はどうしましたか? │
│ │
├──────────────────────────────────┤
│ メッセージを入力 [送信] │
└──────────────────────────────────┘
STEP 5:Dockerで動かす
環境を簡単に再現できるようにDockerで。
今回のような小さなアプリなら、
Docker
└── PHP
└── index.php
くらいの構成でも十分。
完成!
ここまでで、ブラウザからLLMと会話。
実際に質問。
ちゃんと回答が返る。
しかもストリーミング表示で、回答が少しずつ表示。
普通にLLMチャット。
「LLMチャットを作ること自体は、思ったより難しくない」でしょ!!
ただし、本格的なサービスを作るなら、
- 認証
- レート制限
- エラーハンドリング
- ログ
- セキュリティ
- 会話履歴
- DB
- コスト管理
など、考えてね。
付録2 全ソースと起動方法
Dockerfileとindex.phpを同一フォルダに格納し次のコマンドを実行
docker build --tag phpsample ./
docker run -d -p 8001:80 phpsample:latest
Dockerfileとindex.phpはこちら(クリックして展開)
Dockerfile
FROM php:8.3-alpine
RUN adduser -D -s /bin/sh -u 1000 www
WORKDIR /var/www/html
RUN echo -e "error_log=/dev/stderr\nlog_errors=On\ndisplay_errors=Off" > /usr/local/etc/php/conf.d/99-custom.ini
COPY --chown=www:www . /var/www/html/
RUN chmod -R 550 /var/www/html && chown -R www:www /var/www/html
EXPOSE 80
USER www
CMD ["php", "-S", "0.0.0.0:80", "index.php"]
index.php
2行目 keyにAPIキーを記載する
3行目 model 利用するモデルを記載する
例はpreview/Qwen3.6-35B-A3Bを利用。変えたい場合は、文字列を変更するだけ
<?php
$key = getenv('LLM_API_KEY') ?: '★APIキーを記載';
$model = getenv('LLM_MODEL') ?: 'preview/Qwen3.6-35B-A3B';
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$host = getenv('LLM_HOST') ?: 'https://api.ai.sakura.ad.jp/v1';
if ($uri === '/api/chat') {
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
$input = json_decode(file_get_contents('php://input'), true);
$messages = $input['messages'] ?? [];
array_unshift($messages, ['role' => 'system', 'content' => 'あんたは親友や。1200字程度で返信して。']);
$ch = curl_init($host . '/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => json_encode(['model' => $model, 'messages' => $messages, 'stream' => true]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $key],
CURLOPT_WRITEFUNCTION => function($c, $data) {
foreach (explode("\n", $data) as $line) {
if (strpos(trim($line), 'data: ') === 0) {
$json = json_decode(substr($line, 6), true);
if ($s = ($json['choices'][0]['delta']['content'] ?? '')) { echo $s; @ob_flush(); flush(); }
}
}
return strlen($data);
}
]);
curl_exec($ch); curl_close($ch); exit;
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sakura LLM Chat デモ</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html{height:100%}
body{height:100%;font-family:sans-serif;background:#e0e0e0;display:flex;flex-direction:column;padding:12px;gap:10px}
#chat-box{flex:1;overflow-y:auto;padding:12px;background:#fff;display:flex;flex-direction:column;gap:8px}
.msg{padding:10px 14px;line-height:1.6;max-width:75%;word-break:break-word}
.user{background:#e3f2fd;margin-left:auto}
.assistant{background:#eceff1;margin-right:auto;max-width:87.5%}
.input-area{display:flex;border-top:2px solid #ddd;background:#fff}
input{flex:1;padding:14px;border:0;font-size:16px;outline:none}
button{padding:14px 24px;border:0;background:#2196f3;color:#fff;font-size:16px;cursor:pointer}
button:disabled{background:#bbb}
header{padding:8px 14px;background:#fff;font-size:18px;font-weight:700;color:#1976d2}
</style>
</head>
<body>
<header>Sakura LLM Chat デモ <small><?php echo date('Y/m/d'); ?> | <?php echo htmlspecialchars($model); ?> | API:<?php echo htmlspecialchars(substr($key,0,5)); ?>...</small></header>
<div id="chat-box"></div>
<div class="input-area"><input id="i" placeholder="メッセージを入力..." onkeydown="if(event.key==='Enter')send()"><button id="b" onclick="send()">送信</button></div>
<script>
const h=[];
async function send(){
const i=document.getElementById('i'),b=document.getElementById('b'),t=i.value.trim();
if(!t)return;
const a=m('user',t);h.push({role:'user',content:t});i.value='';i.disabled=b.disabled=1;
const e=m('assistant','');
try{
const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:h})});
const d=r.body.getReader(),c=new TextDecoder;let f='';
while(1){const{done,value}=await d.read();if(done)break;f+=c.decode(value,{stream:1});e.innerText=f;e.scrollIntoView({behavior:'smooth',block:'end'});}
h.push({role:'assistant',content:f});
}catch(x){e.innerText='エラー';}
i.disabled=b.disabled=0;i.focus();
}
function m(r,t){const d=document.createElement('div');d.className='msg '+r;d.innerText=t;const x=document.getElementById('chat-box');x.appendChild(d);x.scrollTop=x.scrollHeight;return d;}
</script>
</body>
</html>
