付録J 会話の保存 — いつ・何を保存する?(ツール対応のデータ形式)
📖 このページのゴール:user / assistant をいつ保存するかを判断でき、ツール(function calling)を含む会話も壊さず保存・復元できるようになる。 ← 目次・はじめにへもどる
🤔 なぜ「保存」が難しいのか(🟢 基礎)
第4章で見たとおり、LLMは毎回まっさら。だから DB が会話の“正本(しょうほん=オリジナル)” で、送るたびに DB から messages を組み立て直します(第5章)。
つまり保存は「ただ文字を残す」のではなく、“次にLLMへ渡せる形”で過不足なく残す必要があります。難しいのはこの2つ:
- タイミング:user と assistant を、いつ書く? 途中で失敗・中断したら?
- ツールのとき:1回のやりとりが複数のメッセージにふくらむ。その特殊な形をどう持つ?
⏱ いつ保存する?(基本の user / assistant)(🟢 基礎)
鉄則はこれだけです。
user は「送られた瞬間」に保存。assistant は「返事が完成した瞬間」に保存。
順番にすると:
① ユーザーが送信
→ user メッセージを保存(status: done) ← まず残す
② LLM を呼ぶ
③ 返事が完成
→ assistant メッセージを保存(status: done)
失敗・中断したら → assistant は保存しない(or status:'error' で復元から除外)
なぜこの順番?
- user を先に保存:LLMが落ちても、ユーザーの発言と会話そのものは消えません。
- assistant は“完成品”だけ:途中で切れた半端な返事を正本に混ぜない(第7章「順番が崩れるとき」の対策)。
// 擬似コード:保存のタイミング
await saveMessage(conversationId, { role: "user", content: text }); // ①
const reply = await callLLM(await loadMessages(conversationId)); // ②
await saveMessage(conversationId, { role: "assistant", content: reply }); // ③
1行ずつ読むと:
saveMessage(... "user" ...):送信された瞬間に user を1行 insert する。loadMessages(...):DB から全履歴を読み、messagesを組み立てる(第5章)。saveMessage(... "assistant" ...):返事が返ってきてから assistant を保存。②が落ちたらここには来ない=半端な返事は残らない。
ストリーミング(第11章)のとき
1文字ずつ来ても、1トークン=1行で保存しない(行が爆発します)。やり方は2つ:
- かんたん:完成したときに1行だけ保存(上と同じ)。
- ていねい:開始時に
status:'pending'の空の assistant 行を作り、完了時に最終テキストへ UPDATE(中断なら'aborted'、付録F)。再開時に pending は復元から外す。
具体的には、ストリーム中はサーバー側で文字を貯めておき、終わったらまとめて1回だけ保存します。
// サーバー:画面へ1文字ずつ流しつつ全文を貯め、最後に1回だけ保存
let full = "";
const stream = await openai.chat.completions.create({ model, messages, stream: true });
for await (const chunk of stream) {
const piece = chunk.choices[0]?.delta?.content ?? "";
full += piece; // ① 自分用に全文を組み立てる
res.write(piece); // ② 同時に画面へ流す(第11章のSSE)
}
res.end();
await saveMessage(conversationId, { role: "assistant", content: full }); // ③ 完成してから保存
1行ずつ読むと:
let full = "":流れてくる断片(chunk)をつなげる入れ物。chunk.choices[0]?.delta?.content:ストリームで届く 今回ぶんの文字のかけら。full += piece:画面へ流すのと同時に、サーバーでも全文を組み立てる。res.write(piece):ユーザーには1文字ずつ見せる(これは“表示”の流れで、保存とは別)。await saveMessage(... content: full):ストリームが終わってから、できあがった全文を1行だけ保存。
途中で切れたら(ユーザーが停止/回線断、付録F)、full は半端なので 保存しないか、status:'aborted' で残して復元から外します。pending 行を先に作る方式なら、③を update(pending → done / aborted)に変えるだけです。
💡 SDKのヘルパーを使うともっと楽:OpenAI
client.chat.completions.stream(...).finalChatCompletion()/Anthropicclient.messages.stream(...).finalMessage()/Vercel AI SDKawait result.text(+usage)など、ストリームの後に「REST と同じ形の完成レスポンス」を1発で取れるSDKが多いです。これなら手でfull += pieceせずに、tool_calls・usage込みでそのまま保存できます(→ 付録E)。
二重保存を防ぐ(第7章)
「送信が二重に飛ぶ」「リトライ」で同じ行が2つできないよう、クライアントが作ったメッセージIDで upsert(あれば更新・無ければ作成)すると安全=冪等(べきとう)。
🧰 ツール(function calling)のときの形(🔧 応用)
ツールを使うと(第12章)、1回のやりとりが4つのメッセージになります(OpenAIの形):
1. user … 「東京の天気は?」
2. assistant … content は空、代わりに tool_calls=[getWeather("東京")] ← 「この関数を呼んで」
3. tool … tool_call_id 付きで関数の結果("晴れ, 28℃")
4. assistant … 「東京は晴れ、28℃です」 ← 最終回答
ここが肝:この 2〜4を全部保存しないと、次の呼び出しで履歴が壊れます(「呼んでと言ったのに結果が無い」=APIエラー)。
だから messages テーブルに列を足します(付録Hの拡張)。
-- 付録H の messages に、ツール用の列を追加
alter table messages add column tool_calls jsonb; -- assistantが「呼んで」と指示した中身
alter table messages add column tool_call_id text; -- tool行が「どの呼び出しの結果か」
alter table messages add column name text; -- 呼ばれたツール名(任意・あると読みやすい)
1行ずつ読むと:
tool_calls jsonb:assistantメッセージ用。[{ id, name, arguments }]をそのまま入れる(JSONなので jsonb 型)。tool_call_id text:toolメッセージ用。どの呼び出しへの結果かをひも付ける(2の id と一致させる)。name text:呼ばれた関数名。
保存タイミングは、できた順に1行ずつ:
user 保存 →(LLM)→ assistant(tool_calls) 保存 → ツール実行 → tool結果 保存 →(LLM再)→ 最終assistant 保存
復元(次に送るとき)は、行を created_at 順に読み、保存した role / tool_calls / tool_call_id をそのままLLMの形に戻して messages に並べるだけです。
🔁 OpenAIとAnthropicで形が違う(付録C):OpenAIは上のように
tool_calls+role:"tool"+tool_call_id。Anthropicは assistant の中にtype:"tool_use"、結果は user メッセージのtype:"tool_result"+tool_use_idで表します。どちらか一方のネイティブ形で保存し、付録CのcallLLMで変換するのが現実的です。
⚠️ ハマりどころ
- tool_calls だけ保存して、tool結果を保存し忘れる → 履歴が「呼んだのに答えが無い」状態で終わり、次回APIエラー。tool_calls と tool結果は必ずペアで保存。
- assistant を“表示前”に空で保存 → 生成失敗で空の返事が正本に残る。完成後に保存、または pending 管理(第7章・付録F)。
- ストリーミングを1トークン1行で保存 → 行が爆発。完成時に1行、または pending→UPDATE。
- 復元時に role を取り違える(tool を user にする等)→ 会話が壊れる。保存した role をそのまま使う。
- content に PII や秘密を生のまま → ログ・メモリと同じ注意(第10章・付録G)。
📝 ことばメモ
- 正本(しょうほん):オリジナル=ここが正しい、という大もと。会話ではDBが正本
- tool_calls:assistantが「この関数を呼んで」と指示する中身(id・名前・引数)
- tool_call_id:tool結果が「どの呼び出しへの返事か」を結ぶ札
- pending(ペンディング):まだ完成していない・保留中。中断した返事の印
- 冪等(べきとう):同じ操作を何回しても結果が1回分になること(二重保存しない)
➡️ 関連
第5章 会話ログと複数セッション(保存の土台)/第7章 順番が崩れるとき(未完了・冪等性)/第12章 ツール(呼び出しの流れ)/付録H スキーマ(テーブル定義)/付録F 割り込み(中断の後始末)/付録C 2社の違い(保存形の変換)。