ステートフルなマルチターンGemini会話(@google/genai)
このスキルが解決するコアな問題
多くのAIアシスタントは、すべてのプロンプトをステートレスな単一ターンリクエストとして誤って扱い、後続の質問や連続した編集のたびにドキュメント全体、コードベース、ワークスペースの状態を再アップロードしたり、以前のターンを単一のテキストブロックに平坦化したりします。これはトークンの浪費、レイテンシの増加、推論品質の低下を招きます。
代わりに、ステートフルなマルチターン会話を維持します。
- 初期コンテキスト(コード、ドキュメント、プロジェクトの状態)はターン1で確立されます。
- 後続のターンでは、未変更の状態を再送信せずに、同じ会話を継続します(
previous_interaction_idまたはcontents内の構造化されたuser/modelターン)。 - コードまたはドキュメントの完全な状態は、ユーザーがAIフローの外部でワークスペースを手動で編集した場合(SHA-256ハッシュ比較で検出)にのみ再送信されます。
パターン1: サーバー管理型マルチターン状態(Interactions API)
GoogleのInteractions API(@google/genai >= 2.3.0)を使用する場合、Geminiは会話コンテキストをGoogleのサーバー上で保持します(デフォルトでstore: true)。previous_interaction_id: interaction.id を渡すことでターンを連鎖させます。
TypeScript(@google/genai)
import { GoogleGenAI } from "@google/genai";
const client = new GoogleGenAI({});
// ターン1: 会話と初期のワークスペース/ドキュメント状態を確立
const turn1 = await client.interactions.create({
model: "gemini-3.7-flash",
system_instruction: "You are an expert coding assistant. Remember the active code across turns.",
input: `Here is my current file:\n\`\`\`ts\n${initialCode}\n\`\`\`\nAdd input validation.`,
});
console.log(turn1.output_text);
// ターン2: フォローアップの質問または連続編集 — ファイルが未変更の場合は再送信しません!
// Googleのサーバーは previous_interaction_id を介してターン1の入力とモデル出力を既に保持しています。
const turn2 = await client.interactions.create({
model: "gemini-3.7-flash",
system_instruction: "You are an expert coding assistant. Remember the active code across turns.",
previous_interaction_id: turn1.id,
input: "Why did you choose that validation pattern, and can we also log rejected inputs?",
});
console.log(turn2.output_text);
Python(google-genai)
from google import genai
client = genai.Client()
# ターン1: 初期コンテキストを確立
turn1 = client.interactions.create(
model="gemini-3.7-flash",
system_instruction="You are an expert coding assistant. Remember the active code across turns.",
input=f"""Here is my current file:
```py
{initial_code}
Refactor this into a class.""", )
ターン2: ファイルを再アップロードせずに previous_interaction_id で連鎖
turn2 = client.interactions.create( model="gemini-3.7-flash", system_instruction="You are an expert coding assistant. Remember the active code across turns.", previous_interaction_id=turn1.id, input="Now add a unit test method for edge cases.", )
> **`interactions.create` の重要なルール**:
> - `system_instruction`、`tools`、`generation_config` はインタラクションスコープです。各ターンで再指定してください。
> - `store: true`(デフォルト)を維持してください。`store: false` に設定すると `previous_interaction_id` が無効になります。
---
## パターン2: アプリケーション管理型マルチターン `contents` とハッシュ同期ワークスペース状態
バックエンドが独自のデータベースで会話ターンを管理する場合(または `ai.models.generateContent` / `ai.chats.create` と構造化JSONスキーマ、diff/applyワークフローを使用する場合)、以下の5つのルールに従うことで、未変更のファイルやワークスペースセクションを冗長に再送信することを防ぎます。
### 1. ネイティブな `role: 'user' | 'model'` ターン配列を使用する
以前のターンを単一のユーザーメッセージ内の1つの文字列に平坦化しないでください。常に適切なマルチターン `contents` 配列を構築します。
```typescript
type ContentTurn = {
role: "user" | "model";
parts: Array<{ text: string }>;
};
2. SHA-256ハッシュでワークスペースの同期を追跡する
クライアント上で、会話ごとの同期レコードを維持します。
interface SyncedConversationState {
activeDocumentSha256: string | null;
secondaryContextHash: string | null; // 例: 他の開いているタブ、シーン、プロジェクトツリーの概要
}
ask(Q&A)ターンが完了したとき:activeDocumentSha256は送信されたドキュメントのSHA-256のままです。editターンが提案で完了したとき:activeDocumentSha256はproposal.sha256に更新されます(会話履歴内のmodelターンに提案コードが含まれるため)。- ユーザーが「適用」をクリックしたとき:
activeDocumentSha256は適用されたproposal.sha256と一致します。 - ユーザーが「破棄」または「元に戻す」をクリックしたとき:
activeDocumentSha256は提案前のbase.sha256に戻ります。
3. フォローアップターンで未変更のワークスペースコードを省略する(sourceSynchronized: true)
新しいターンを送信する前に、sha256(currentEditorCode) を syncedState.activeDocumentSha256 と比較します。
sha256(currentEditorCode) === syncedState.activeDocumentSha256(sourceSynchronized: true)の場合:Ask(Q&A)モード: ファイルをまったくアップロードしません(source: null)。モデルは会話履歴内に正確なアクティブコードをすでに持っています。Editモード: Geminiに送信する新しいuserターン内でcurrentSourceを重複させません({ mode: "edit", prompt, sourceSynchronized: true })。ローカルベーススナップショットは、クライアント側の差分/元に戻しとバックエンド検証のためだけに保持します。
sha256(currentEditorCode) !== syncedState.activeDocumentSha256(sourceSynchronized: false)の場合:- ユーザーがファイルを手動で編集したか、ファイルを切り替えました。更新された
currentSourceをこのターンで添付して会話を再同期し、activeDocumentSha256を更新します。
- ユーザーがファイルを手動で編集したか、ファイルを切り替えました。更新された
4. 完全なソースは履歴内で一度だけ保持する(古いターンをコンパクト化)
10ターンの編集セッションで、10の履歴ターンすべてに10個の完全なファイルコピーを保持しないでください。
- 完全なコードは、アクティブコードを導入または変更した最新のターン(最新のアクティブ編集を提案した
modelターン、または最後に手動編集コードを添付したuserターン)にのみ保持します。 - コードが置き換えられた古いターンについては、インラインコード文字列をコンパクトなメタデータ(
{ supersededSourceSha256: "..." })に置き換えます。これにより、完全な会話推論スレッドを維持しながら、長いセッションでもトークン使用量を一定に保ちます。
5. 読み取り専用の二次コンテキスト(他のファイル/タブ/セクション)を重複排除する
アプリがアクティブでないファイルやセクションの読み取り専用サマリーを含む場合:
- そのサマリーは会話のターン1で送信します。
- ターン2以降は、ハッシュが変更された場合(
secondaryContextHash !== currentSecondaryContextHash)にのみサマリーを再送信します。
参考実装: マルチターンリクエストビルダー(TypeScript)
interface HistoryTurn {
prompt: string;
response: string;
mode: "ask" | "edit";
baseSource?: string | null;
baseSha256?: string | null;
proposalSource?: string | null;
proposalSha256?: string | null;
outcomeStatus?: "none" | "proposed" | "applied" | "discarded" | "undone";
}
export function buildMultiTurnContents(params: {
history: HistoryTurn[];
mode: "ask" | "edit";
prompt: string;
currentSource: string | null;
sourceSynchronized: boolean;
}) {
const { history, mode, prompt, currentSource, sourceSynchronized } = params;
// 現在アクティブなソースを保持している履歴ターンを特定する
let latestActiveSourceIndex = -1;
if (sourceSynchronized || currentSource === null) {
for (let i = history.length - 1; i >= 0; i--) {
const item = history[i];
const hasValidProposal =
item.mode === "edit" &&
item.proposalSource &&
item.outcomeStatus !== "discarded" &&
item.outcomeStatus !== "undone";
if (hasValidProposal || item.baseSource) {
latestActiveSourceIndex = i;
break;
}
}
}
const contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> = [];
for (let i = 0; i < history.length; i++) {
const item = history[i];
const isLatestSourceTurn = i === latestActiveSourceIndex;
const hasActiveProposal =
item.mode === "edit" &&
Boolean(item.proposalSource) &&
item.outcomeStatus !== "discarded" &&
item.outcomeStatus !== "undone";
// 履歴内のユーザーターン
const userPayload: Record<string, unknown> = {
mode: item.mode ?? "ask",
prompt: item.prompt,
};
if (isLatestSourceTurn && !hasActiveProposal && item.baseSource) {
userPayload.currentSource = item.baseSource;
} else if (item.baseSha256) {
userPayload.supersededSourceSha256 = item.baseSha256;
}
contents.push({ role: "user", parts: [{ text: JSON.stringify(userPayload) }] });
// 履歴内のモデルターン
const modelPayload: Record<string, unknown> = {
kind: item.mode === "edit" ? "code_proposal" : "answer",
explanation: item.response,
};
if (isLatestSourceTurn && hasActiveProposal && item.proposalSource) {
modelPayload.proposedSource = item.proposalSource;
} else if (item.proposalSha256) {
modelPayload.supersededProposalSha256 = item.proposalSha256;
}
contents.push({ role: "model", parts: [{ text: JSON.stringify(modelPayload) }] });
}
// 最終(現在の)ユーザーターン
const omitDuplicateSource = sourceSynchronized && latestActiveSourceIndex !== -1;
const currentTurnPayload: Record<string, unknown> = { mode, prompt };
if (omitDuplicateSource) {
currentTurnPayload.sourceSynchronized = true;
} else {
currentTurnPayload.currentSource = currentSource;
}
contents.push({ role: "user", parts: [{ text: JSON.stringify(currentTurnPayload) }] });
return contents;
}
マルチターンAIをレビューまたは実装する際のチェックリスト
-
@google/genai(GoogleGenAI)を使用しており、非推奨の@google/generative-aiを使用していませんか? - ターン2以降では、履歴を単一のプロンプト文字列に平坦化する代わりに、マルチターンの
user/modelロール(またはprevious_interaction_id)を保持していますか? - フォローアップの質問(
Askモード)で、エディタのコードが変更されていない場合、クライアントからのコードの再アップロードを省略していますか? - 連続したAI編集(
Editモード)で、エディタのコードが最後のターンと一致する場合、contents内の新しいユーザープロンプトから重複するcurrentSourceを省略していますか? - 古い置き換えられたコードバージョンは履歴内でコンパクト化され、アクティブなコードバージョンのみが完全に送信されていますか?
- ユーザーがドキュメントを手動で編集したり、変更を元に戻したりした場合、SHA-256の不一致が次のターンで自動的に再同期をトリガーしますか?