5つの部品を一つにまとめる
動くチャットボットは、5つの部品を一つのループにまとめます。状態を保ち、切り詰め、システムプロンプトに従い、答えを流れで送り、出典を示します。部品は一つずつなら動くので、問題は、つなぎ目で起こるからです。
図書館のチャットボットが、3つの質問に続けて答えます。最初の質問を覚え、予算に収め、毎回出典を示さなければなりません。
このレッスンのゴール
メモ
私は、状態、切り詰め、プロンプト、ストリーム、出典を一つのチャットボットにまとめられます。
キーワード
| ことば | いみ |
|---|---|
| チャットのループ | 利用者のメッセージごとに繰り返す手順 |
| ターン | 利用者のメッセージ一つと、その返事 |
| 履歴 | ここまでのチャットのすべてのメッセージ |
| 予算 | 送ると自分で決めた最大のトークン数 |
| 代わりの対応 | どのモデルも答えないときの、ボットの動き |
学生が入力するセルは、このプレビューでは空白です。
1ターンで5つの部品を使う
どのターンも、同じ手順です。履歴を読み込み、利用者のメッセージを足し、送る分を切り詰め、モデルを呼び、返事を確かめ、履歴を保存します。
この手順を、turn という一つの関数に書きます。チャットは、その関数を何度も呼ぶことになります。
例: 1ターンの手順
| 手順 | 出てきたレッスン | 起こること |
|---|---|---|
| 読み込んで足す | 会話を覚える仕組み | 利用者のメッセージが、セッションのリストに入る |
| 切り詰める | 履歴の切り詰め | 古いメッセージは、この呼び出しから外れる |
| プロンプトと出典 | システムプロンプト、出典 | system メッセージに、規則と番号付きの出典が入る |
| 呼んで確かめる | 出典 | 出典のない印がないか、返事を確かめる |
| 流して保存する | ストリーム表示 | 断片がページへ行き、履歴が保存される |
学生が入力するセルは、このプレビューでは空白です。
保管するものと送るものは別
履歴は、すべてのメッセージを保管します。呼び出しで送るのは、予算に収まる分だけです。だから、切り詰めても記録は変わりません。変わるのは、一つの呼び出しだけです。
あとから予算を変えることもできますし、人はチャット全体を読み返せます。
モデルが答えない場合に備える
どのモデルも答えないことがあります。AI が切られている、その日のクレジットを使い切った、ネットワークが切れた、などです。代わりの対応は、そう伝える、わかりやすいメッセージです。チャットが止まったり、壊れたりしません。
このラボでは、そのとき Run が、記録済みサンプルとはっきり示して表示します。本物の製品なら、独自のお知らせを出します。
よくある間違い
注意
「デモが動けば、チャットボットは完成だ」と考える人が多いです。デモでは、答えやすい質問をします。答えにくい質問、とても長いチャット、モデルがない状態も試しましょう。ほかの人が使う前に、1回のチャットのトークン数を数えましょう。
モデルの補助を書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' '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 || 200 }),' ' signal: AbortSignal.timeout(30000),' ' });' ' if (res.ok === false) throw new Error(\"status \" + res.status);' ' return (await res.json()).choices[0].message.content;' > model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
モデルの補助を書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' } catch (error) {' ' console.log(\"記録済みサンプル: モデルが答えませんでした (\" + error.message + \")。\");' ' const list = [].concat(sample);' ' const turn = messages.filter(function (m) { return m.role === \"assistant\"; }).length;' ' return list[Math.min(turn, list.length - 1)];' ' }' '}' 'module.exports = { ask };' >> model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チャットボットを書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const fs = require(\"fs\");' 'const { ask } = require(\"./model\");' 'const BUDGET = 85; // system メッセージと履歴を合わせた、トークンの予算。3回目で切り詰めが起きる小ささ' 'const SOURCES = [' ' \"会員は、本を6冊まで、3週間借りられます。\",' ' \"返却が遅れた本は、1日につき10円かかります。\",' ' \"図書館は、平日の午前9時に開き、午後5時に閉まります。\",' '];' > chatbot6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チャットボットを書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function tokens(m) { return Math.ceil(m.content.length / 4) + 4; }' 'function total(list) {' ' return list.reduce(function (sum, m) { return sum + tokens(m); }, 0);' '}' 'function systemMessage() {' ' const numbered = SOURCES.map(function (t, at) { return \"[\" + (at + 1) + \"] \" + t; }).join(\" \");' ' const rule = \"あなたは図書館の案内係です。次の資料だけを使い、2文までで答えてください。事実のあとに、資料の番号を角かっこで書いてください。資料に書かれていなければ、「わかりません」と答えてください。\";' ' return { role: \"system\", content: rule + numbered };' '}' >> chatbot6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チャットボットを書く(3)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function trim(history, system) {' ' const kept = history.slice();' ' while (total([system].concat(kept)) > BUDGET && kept.length > 1) kept.shift();' ' return kept;' '}' 'function badMarkers(reply) {' ' const bad = [];' ' const re = /\[(\d+)\]/g;' ' let m = re.exec(reply);' ' while (m) {' ' if (Number(m[1]) < 1 || Number(m[1]) > SOURCES.length) bad.push(m[1]);' ' m = re.exec(reply);' ' }' ' return bad;' '}' >> chatbot6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チャットボットを書く(4)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function turn(state, text, sample) {' ' state.history.push({ role: \"user\", content: text });' ' const system = systemMessage();' ' const sent = trim(state.history, system);' ' const held = state.history.length;' ' const reply = await ask([system].concat(sent), sample, 70);' ' state.history.push({ role: \"assistant\", content: reply });' ' fs.writeFileSync(\"chat.json\", JSON.stringify(state, null, 1));' ' console.log(\"あなた: \" + text);' ' console.log(\"ボット: \" + reply);' ' console.log(\" 送信 \" + sent.length + \" 件 / 保持 \" + held + \" 件、誤った印: \" + badMarkers(reply).length);' '}' >> chatbot6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チャットボットを書く(5)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' const state = { history: [] };' ' // 記録済みのサンプル。モデルが答えないときだけ使う。' ' await turn(state, \"本は何冊借りられますか。\", \"本は6冊まで、3週間借りられます[1]。\");' ' await turn(state, \"遅れた場合の料金はいくらですか。\", \"遅れると、1日10円かかります[2]。\");' ' await turn(state, \"金曜日は何時に閉まりますか。\", \"図書館は、平日の午後5時に閉まります[3]。\");' ' console.log(\"保存: chat.json に \" + state.history.length + \" 件\");' '}' 'main();' >> chatbot6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
3ターンのチャットを行う
docker exec -w /home/node/project agent node chatbot6.jsここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
出力の一例
| 行 | 表示されるかもしれないもの |
|---|---|
| あなたとボット | 3つの質問と、出典を付けるかもしれない短い答え |
| 送信 n 件 / 保持 m 件 | 1 件 / 1 件、3 件 / 3 件、4 件 / 5 件。3回目は予算を超えるので、古いメッセージが 1 件外れる |
| 誤った印 | たいてい 0。0 より大きければ、存在しない出典を挙げている |
| 保存 | 保存: chat.json に 6 件 |
学生が入力するセルは、このプレビューでは空白です。