Community程式設計與開發github.com

EBIKEBUILDER/ai-skills

在建立或升級 AI 聊天、程式碼助手,或文件/工作區編輯器時,使用此技能搭配 Google Gemini (@google/genai) 來進行有狀態的多輪對話,而非在每次提示中重新傳送完整的程式碼庫、文件或上下文。

ai-skills 是什麼?

此技能旨在解決 AI 助手浪費 token、增加延遲並降低推理品質的問題——這些問題源於將每次提示視為無狀態的單輪請求,在每次後續提問或順序編輯中重新上傳整個文件、程式碼庫或工作區狀態。透過實作有狀態的多輪對話,它在第一輪建立初始上下文,然後在後續輪次中繼續同一對話(使用 previous_interaction_id 或結構化的 user/model 輪次),無需重新傳送未變更的狀態。此技能支援兩種主要模式:伺服器管理的多輪狀態(透過 Google 的 Interactions API,由 GenAI 平台維護上下文)和應用程式管理的多輪 contents(使用 sha256 雜湊同步來追蹤工作區狀態,並僅在偵測到手動編輯時才重新傳送已變更的文件)。它透過在歷史記錄中僅保留最新有效程式碼副本、以雜湊取代舊的已取代版本,以及針對唯讀次要上下文(例如其他檔案或分頁摘要)進行去重,來最佳化 token 使用。此技能涵蓋 TypeScript 和 Python 實作,並提供一個參考的 TypeScript 請求建構器,示範如何建構多輪 contents 陣列,避免在長時間編輯會話中重複傳送整個檔案。

相容平台~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 助手無意中將每次提示視為無狀態的單輪請求——在每次後續提問或順序編輯時重新上傳整個文件、程式碼庫或工作區狀態,或將先前的輪次扁平化成單一文字區塊。這會浪費 token、增加延遲並降低推理品質。

相反地,應維護一個有狀態的多輪對話,其中:

  1. 初始上下文(程式碼、文件或專案狀態)在第一輪建立。
  2. 後續輪次繼續同一對話(使用 previous_interaction_id 或 contents 中結構化的 user/model 輪次),無需重新發送未變更的狀態。
  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({});

// 第 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 不匹配是否會自動觸發下一次輪次的重新同步?

相關技能