有状态多轮 Gemini 对话(@google/genai)
该技能解决的核心问题
许多 AI 助手无意中将每个提示视为无状态的单次请求——在每个后续问题或顺序编辑时重新上传整个文档、代码库或工作区状态,或者将之前的回合扁平化为单个文本块。这会浪费令牌、增加延迟并降低推理质量。
相反,应维护一个有状态的多轮对话,其中:
- 初始上下文(代码、文档或项目状态)在第一轮建立。
- 后续轮次继续相同的对话(通过
previous_interaction_id或contents中的结构化user/modelturns),无需重新发送未更改的状态。 - 仅当用户在 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({});
// 第一轮:建立对话和初始工作区/文档状态
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);
// 第二轮:后续问题或顺序编辑——如果文件未更改,请勿重新发送!
// Google 的服务器已通过 previous_interaction_id 持有了第一轮的输入和模型输出。
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()
# 第一轮:建立初始上下文
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.",
)
# 第二轮:使用 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 工作流)时,请遵循以下五条规则,以避免冗余重新发送未更改的文件或工作区部分:
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 })。仅在本地保留基础快照,用于客户端 diff/撤销和后端验证。
- 在
- 如果
sha256(currentEditorCode) !== syncedState.activeDocumentSha256(sourceSynchronized: false):- 用户手动编辑了文件或切换了文件。在此回合中附加更新后的
currentSource以重新同步对话,并更新activeDocumentSha256。
- 用户手动编辑了文件或切换了文件。在此回合中附加更新后的
4. 仅在历史中保留一次完整源(压缩被取代的回合)
在一个 10 回合的编辑会话中,不要在全部 10 个历史回合中保留 10 个完整的文件副本。
- 仅将完整代码保留在最近引入或修改了活跃代码的回合上(即提出最新活跃编辑的
model回合,或最后附加了手动编辑代码的user回合)。 - 对于所有代码已被取代的旧回合,将内联代码字符串替换为紧凑的元数据(
{ supersededSourceSha256: "..." })。这可以在长时间会话中保持令牌使用量稳定,同时保留完整的对话推理链。
5. 对只读次要上下文(其他文件/标签页/部分)进行去重
如果您的应用程序包含非活跃文件的只读摘要:
- 在对话的第一轮发送该摘要。
- 从第二轮开始,仅在其哈希发生变化时重新发送摘要(
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? - 第二轮及以后是否保留了多轮
user/model角色(或previous_interaction_id),而不是将历史扁平化为单个提示字符串? - 在后续问题(
Ask模式)中,当编辑器代码未更改时,我们是否省略了从客户端重新上传代码? - 在顺序 AI 编辑(
Edit模式)中,当编辑器代码与上一回合匹配时,我们是否省略了contents中新用户提示中的重复currentSource? - 历史中较旧的被取代代码版本是否已被压缩,以便仅完整发送活跃代码版本?
- 如果用户手动编辑文档或撤销更改,SHA-256 不匹配是否会自动触发下一回合的重新同步?