有狀態多輪 Gemini 對話 (@google/genai)
此技能解決的核心問題
許多 AI 助手無意中將每次提示視為無狀態的單輪請求——在每次後續提問或順序編輯時重新上傳整個文件、程式碼庫或工作區狀態,或將先前的輪次扁平化成單一文字區塊。這會浪費 token、增加延遲並降低推理品質。
相反地,應維護一個有狀態的多輪對話,其中:
- 初始上下文(程式碼、文件或專案狀態)在第一輪建立。
- 後續輪次繼續同一對話(使用
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:\n```py\n{initial_code}\n```\nRefactor 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是 interaction 作用域範圍——需要在每一輪重新指定。- 保持
store: true(預設值);設定store: false會停用previous_interaction_id。
模式 2:應用程式管理的多輪 contents 搭配雜湊同步的工作區狀態
當您的後端在自己的資料庫中儲存對話輪次(或使用 ai.models.generateContent / ai.chats.create 搭配結構化 JSON schema 與 diff/apply 工作流程)時,請遵循以下五個規則,以避免冗餘地重新發送未變更的檔案或工作區部分:
1. 使用原生的 role: 'user' | 'model' 輪次陣列
切勿將先前的輪次扁平化成單一字串放在一個使用者訊息中。始終建構一個正確的多輪 contents 陣列:
type ContentTurn = {
role: "user" | "model";
parts: Array<{ text: string }>;
};
2. 透過 SHA-256 雜湊追蹤工作區同步
在客戶端維護一個每對話的同步記錄:
interface SyncedConversationState {
activeDocumentSha256: string | null;
secondaryContextHash: string | null; // 例如,其他開啟的分頁、場景或專案樹摘要
}
- 當一個
ask(問答)輪次完成時: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(問答)模式下:完全不傳送檔案(source: null)。模型已經在對話歷史中擁有確切的作用中程式碼。 - 在
Edit模式下:不要將currentSource重複包含在發送給 Gemini 的新user輪次中(使用{ mode: "edit", prompt, sourceSynchronized: true })。僅保留本地基底快照以供客戶端 diffs/復原和後端驗證使用。
- 在
- 如果
sha256(currentEditorCode) !== syncedState.activeDocumentSha256(sourceSynchronized: false):- 使用者手動編輯了檔案或切換了檔案。在此輪次中附加更新後的
currentSource以重新同步對話,並更新activeDocumentSha256。
- 使用者手動編輯了檔案或切換了檔案。在此輪次中附加更新後的
4. 在歷史記錄中僅保留一次完整原始碼(壓縮已取代的輪次)
在一個 10 輪的編輯會話中,不要在所有 10 個歷史輪次中都保留 10 份完整的檔案副本。
- 僅在引入或修改了作用中程式碼的最新輪次中保留完整程式碼(可以是提出最新作用中編輯的
model輪次,也可以是最後一次附加手動編輯程式碼的user輪次)。 - 對於所有較舊的、其程式碼已被取代的輪次,將內聯程式碼字串替換為簡潔的中繼資料(
{ supersededSourceSha256: "..." })。這可以在長時間的會話中保持 token 使用量平穩,同時保留完整的對話推理鏈。
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 不匹配是否會自動觸發下一次輪次的重新同步?