同じ答えに二度払わない
モデルの応答をキャッシュするとは、応答を保存しておき、同じリクエストが来たときにそれを返すことです。呼び出しにはお金と時間がかかり、同じリクエストは何度も来るので、効果があります。
ヘルプページが、訪れた人ごとに同じ返品ルールの要約をモデルに頼むとします。千回呼べば、一つの答えに千回払うことになります。一回の呼び出しと九百九十九回のヒットなら、そうはなりません。
このレッスンのゴール
メモ
私は、モデルの応答を安全にキャッシュし、何がヒットを決めるのかを説明できます。
キーワード
| ことば | いみ |
|---|---|
| キャッシュ | 払い済みの応答をしまっておく場所 |
| キャッシュキー | 二つのリクエストが同じかを決める値 |
| ヒット | キャッシュから答えられたリクエスト |
| ミス | モデルを呼ぶ必要があったリクエスト |
| 有効期限 | エントリーを捨てる時点 |
| 個人情報 | 一人の人を特定できる情報すべて |
学生が入力するセルは、このプレビューでは空白です。
キーはリクエスト全体から作る
二つのリクエストが答えを共有してよいのは、モデルが同じものを見る場合だけです。そのため、キャッシュキーは、応答を変えるものすべてを含めます。モデル、役割付きの各メッセージ、そして温度や応答の上限などのパラメーターです。
一つでも抜くと、間違った答えを返してしまいます。このテキストのハッシュを使うと、キーを短く保てます。
例: キーを変えるもの
| 変更 | 同じキーか | 理由 |
|---|---|---|
| モデル、メッセージ、パラメーターが同じ | はい | モデルが同じリクエストを見る |
| メッセージの一語が違う | いいえ | メッセージはキーの一部 |
| 温度 0 のあとに 1 | いいえ | 応答が変わりうる |
| 別のモデル | いいえ | 別のモデルは別の答えを返す |
学生が入力するセルは、このプレビューでは空白です。
エントリーは期限で消え、個人情報は入れない
答えは古くなります。各エントリーに有効期限、つまり秒単位の寿命を付けましょう。期限が来たらエントリーを捨て、次のリクエストはミスになります。
個人情報は決してキャッシュしません。ある人のアドレスや注文から作った答えが、同じキーのほかの人に返るかもしれないからです。検索の前にリクエストを調べ、該当するものはキャッシュを使いません。
よくある間違い
注意
「答えはただの文字だから、キャッシュはいつも安全だ」と考える人が多くいます。ある人の情報を含む答えが保存されると、別の人に見えてしまうことがあります。最初のエントリーを保存する前に、何を保存してよいかを決めましょう。
モデル用の助っ人を書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' '// 応答に含まれるトークン数。なければ null。' 'function count(data, name) {' ' return data.usage && typeof data.usage[name] === \"number\" ? data.usage[name] : null;' '}' > model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
モデル用の助っ人を書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function ask(messages, sample, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", {' ' method: \"POST\",' ' headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' ' body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 100 }),' ' signal: AbortSignal.timeout(30000),' ' });' ' if (res.ok === false) throw new Error(\"status \" + res.status);' ' const d = await res.json();' ' return { text: d.choices[0].message.content, inTokens: count(d, \"prompt_tokens\"), outTokens: count(d, \"completion_tokens\"), recorded: false };' ' } catch (error) {' ' console.log(\"記録済みサンプル: モデルが答えませんでした (\" + error.message + \")。\");' ' return { text: sample, inTokens: null, outTokens: null, recorded: true };' ' }' '}' 'module.exports = { ask };' >> model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
キャッシュを書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const crypto = require(\"crypto\");' 'const { ask } = require(\"./model\");' '' 'let now = 0; // 秒。自分で進める時計' 'const TTL = 60; // エントリーの寿命は60秒' 'const cache = new Map();' > cache2.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
キャッシュを書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' '// キーはリクエスト全体から作る。モデル、メッセージ、パラメーター。' 'function keyOf(request) {' ' const whole = JSON.stringify([request.model, request.messages, request.temperature, request.max_tokens]);' ' return crypto.createHash(\"sha256\").update(whole).digest(\"hex\").slice(0, 12);' '}' '// 個人情報は決してキャッシュしない。ここでは、メールアドレスか長い数字。' 'function personal(request) {' ' return /@|[0-9]{7,}/.test(JSON.stringify(request.messages));' '}' >> cache2.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
キャッシュを書く(3)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function answer(request, sample) {' ' if (personal(request)) return \"スキップ(個人情報)\";' ' const key = keyOf(request);' ' const found = cache.get(key);' ' if (found && found.expires > now) return \"ヒット\";' ' const reply = await ask(request.messages, sample, request.max_tokens);' ' cache.set(key, { text: reply.text, expires: now + TTL });' ' return found ? \"期限切れ、ミス\" : \"ミス\";' '}' >> cache2.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
キャッシュを書く(4)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' const tip = [{ role: \"user\", content: \"キャッシュのコツを一文で教えてください。\" }];' ' const base = { model: \"lab\", temperature: 0, max_tokens: 40, messages: tip };' ' const other = { ...base, temperature: 1 };' ' const secret = { ...base, messages: [{ role: \"user\", content: \"[email protected] にコツを送ってください。\" }] };' ' const sample = \"全員に同じ内容になるものだけを保存しましょう。\";' ' for (const at of [0, 30, 90]) {' ' now = at;' ' console.log(\"t=\" + at + \" s: \" + (await answer(base, sample)));' ' }' ' console.log(\"温度を変えても同じキーか: \" + (keyOf(base) === keyOf(other)));' ' console.log(\"個人情報を含むリクエスト: \" + (await answer(secret, sample)));' '}' 'main();' >> cache2.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
キャッシュを実行する
docker exec -w /home/node/project agent node cache2.jsここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
これは練習用の代用品です
ラボにはキャッシュサーバーがありません。そこで、キャッシュはスクリプトの中の Map で、時計はスクリプトが決める数にしています。本物のシステムでは、すべてのサーバーが共有する本物のキャッシュサービスを使います。
決まりは同じです。リクエスト全体から作るキー、有効期限、そして個人情報の確認です。
出力の例
| 行 | 示していること |
|---|---|
| t=0 s: ミス | 最初のリクエストはモデルを呼ぶ |
| t=30 s: ヒット | 同じリクエストをキャッシュから答える |
| t=90 s: 期限切れ、ミス | エントリーが60秒たったので、モデルをもう一度呼ぶ |
| 温度を変えても同じキーか: false | パラメーターが違えば別のキー |
| 個人情報を含むリクエスト: スキップ(個人情報) | キャッシュには一度も触れていない |
学生が入力するセルは、このプレビューでは空白です。