Community编程与开发github.com

EBIKEBUILDER/ai-skills

在构建或升级 AI 聊天、编程助手或文档/工作区编辑器时,使用此技能配合 Google Gemini(@google/genai)实现有状态的多轮对话,避免在每次提示时重新发送完整的代码库、文档或上下文。

ai-skills 是什么?

该技能旨在解决 AI 助手将每个提示视为无状态单次请求的常见问题,这种处理方式会浪费令牌、增加延迟,并降低推理质量。它通过维护有状态的多轮对话来解决这一问题:第一轮建立初始上下文(代码、文档或项目状态),后续轮次利用 `previous_interaction_id` 或结构化的 `user`/`model` turns 在 `contents` 中继续相同对话,无需重新发送未更改的状态。技能提供了两种实现模式:一是利用 Google 的 Interactions API(Pattern 1),让 Gemini 在服务器端维护对话上下文,通过 `previous_interaction_id` 链接回合;二是应用程序管理的多轮 `contents` 模式(Pattern 2),结合 SHA-256 哈希同步工作区状态,并遵循五项规则(使用原生角色数组、哈希追踪同步状态、在源未更改时省略上传、压缩历史中的旧版本源、去冗余只读次要上下文)。该技能适用于所有使用 `@google/genai` 的开发场景,包括 TypeScript 和 Python 平台。

兼容平台~Claude Code~Codex CLI~Cursor✓Gemini CLI
npx skills add https://github.com/EBIKEBUILDER/ai-skills/tree/HEAD/gemini-multiturn-conversation

Installed? Explore more 编程与开发 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

有状态多轮 Gemini 对话(@google/genai)

该技能解决的核心问题

许多 AI 助手无意中将每个提示视为无状态的单次请求——在每个后续问题或顺序编辑时重新上传整个文档、代码库或工作区状态,或者将之前的回合扁平化为单个文本块。这会浪费令牌、增加延迟并降低推理质量。

相反,应维护一个有状态的多轮对话,其中:

  1. 初始上下文(代码、文档或项目状态)在第一轮建立。
  2. 后续轮次继续相同的对话(通过 previous_interaction_id 或 contents 中的结构化 user/model turns),无需重新发送未更改的状态。
  3. 仅当用户在 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 不匹配是否会自动触发下一回合的重新同步?

相关技能