如何在 Cloud Run 部署採用 Gemini 的聊天應用程式

1. 簡介

總覽

在本程式碼研究室中,您將瞭解如何使用 Vertex AI Gemini API 和 Vertex AI 用戶端程式庫,建立以節點編寫的基本聊天機器人。這個應用程式使用 Google Cloud Firestore 支援的快速工作階段存放區

課程內容

  • 如何使用 htmx、tailwindcs 和 express.js 建構 Cloud Run 服務
  • 如何使用 Vertex AI 用戶端程式庫向 Google API 進行驗證
  • 如何建立與 Gemini 模型互動的聊天機器人
  • 如何在沒有 Docker 檔案的情況下部署至 Cloud Run 服務
  • 如何使用 Google Cloud Firestore 支援的快速工作階段儲存庫

2. 設定和需求

必要條件

啟用 Cloud Shell

  1. 在 Cloud 控制台中,按一下「啟用 Cloud Shell」圖示 d1264ca30785e435.png

cb81e7c8e34bc8d.png

如果您是第一次啟動 Cloud Shell,系統會顯示中繼畫面,說明這項服務的內容。如果系統顯示中繼畫面,請按一下「繼續」

d95252b003979716.png

佈建並連線至 Cloud Shell 只需幾分鐘的時間。

7833d5e1c5d18f54.png

這個虛擬機器已載入所有必要的開發工具。提供永久的 5 GB 主目錄,而且在 Google Cloud 中運作,大幅提高網路效能和驗證能力。在本程式碼研究室中,您的大部分作業都可透過瀏覽器完成。

連線至 Cloud Shell 後,您應會發現自己通過驗證,且專案已設為您的專案 ID。

  1. 在 Cloud Shell 中執行下列指令,確認您已通過驗證:
gcloud auth list

指令輸出

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. 在 Cloud Shell 中執行下列指令,確認 gcloud 指令知道您的專案:
gcloud config list project

指令輸出

[core]
project = <PROJECT_ID>

如果尚未設定,請使用下列指令進行設定:

gcloud config set project <PROJECT_ID>

指令輸出

Updated property [core/project].

3. 啟用 API 並設定環境變數

啟用 API

開始使用本程式碼研究室之前,您必須先啟用多個 API。本程式碼研究室需要使用下列 API。您可以執行下列指令來啟用這些 API:

gcloud services enable run.googleapis.com \
    cloudbuild.googleapis.com \
    aiplatform.googleapis.com \
    secretmanager.googleapis.com

設定環境變數

您可以設定將在本程式碼研究室中使用的環境變數。

PROJECT_ID=<YOUR_PROJECT_ID>
REGION=<YOUR_REGION, e.g. us-central1>
SERVICE=chat-with-gemini
SERVICE_ACCOUNT="vertex-ai-caller"
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com
SECRET_ID="SESSION_SECRET"

4. 建立及設定 Firebase 專案

  1. Firebase 控制台,按一下「新增專案」
  2. 輸入 <YOUR_PROJECT_ID>,將 Firebase 新增至現有的其中一項 Google Cloud 專案
  3. 如果出現提示訊息,請詳閱並接受 Firebase 條款
  4. 按一下「繼續」
  5. 按一下「確認方案」即可確認訂閱 Firebase 計費方案。
  6. 您可以選擇在本程式碼研究室中啟用 Google Analytics。
  7. 按一下「新增 Firebase」
  8. 專案建立完成後,按一下「Continue」
  9. 在「建構」選單中,按一下「Firestore 資料庫」
  10. 按一下 [Create database] (建立資料庫)。
  11. 從「位置」下拉式選單中選擇您的區域,然後點選「下一步」
  12. 使用預設的「在正式環境中啟動」,然後點選「建立」

5. 建立服務帳戶

Cloud Run 會使用這個服務帳戶呼叫 Vertex AI Gemini API。這個服務帳戶也有權讀取、寫入 Firestore,以及讀取 Secret Manager 的密鑰。

首先,請執行下列指令來建立服務帳戶:

gcloud iam service-accounts create $SERVICE_ACCOUNT \
  --display-name="Cloud Run to access Vertex AI APIs"

接著,將「Vertex AI 使用者」角色授予服務帳戶。

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/aiplatform.user

接著在 Secret Manager 中建立密鑰。Cloud Run 服務會以環境變數的形式存取這組密鑰,並在執行個體啟動時完成解析。進一步瞭解密鑰和 Cloud Run

gcloud secrets create $SECRET_ID --replication-policy="automatic"
printf "keyboard-cat" | gcloud secrets versions add $SECRET_ID --data-file=-

並將 Secret Manager 中快速工作階段密鑰的存取權授予服務帳戶。

gcloud secrets add-iam-policy-binding $SECRET_ID \
    --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
    --role='roles/secretmanager.secretAccessor'

最後,授予服務帳戶 Firestore 的讀取及寫入權限。

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/datastore.user

6. 建立 Cloud Run 服務

首先,建立原始碼的目錄,並以 cd 指向該目錄。

mkdir chat-with-gemini && cd chat-with-gemini

接著,建立含有以下內容的 package.json 檔案:

{
  "name": "chat-with-gemini",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "nodemon": "nodemon app.js",
    "cssdev": "npx tailwindcss -i ./input.css -o ./public/output.css --watch",
    "tailwind": "npx tailwindcss -i ./input.css -o ./public/output.css",
    "dev": "npm run tailwind && npm run nodemon"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/connect-firestore": "^3.0.0",
    "@google-cloud/firestore": "^7.5.0",
    "@google-cloud/vertexai": "^0.4.0",
    "axios": "^1.6.8",
    "express": "^4.18.2",
    "express-session": "^1.18.0",
    "express-ws": "^5.0.2",
    "htmx.org": "^1.9.10"
  },
  "devDependencies": {
    "nodemon": "^3.1.0",
    "tailwindcss": "^3.4.1"
  }
}

接著,建立含有以下內容的 app.js 來源檔案。此檔案包含服務的進入點,且包含應用程式的主要邏輯。

const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
const path = require("path");

const fs = require("fs");
const util = require("util");
const { spinnerSvg } = require("./spinnerSvg.js");

// cloud run retrieves secret at instance startup time
const secret = process.env.SESSION_SECRET;

const { Firestore } = require("@google-cloud/firestore");
const { FirestoreStore } = require("@google-cloud/connect-firestore");
var session = require("express-session");
app.set("trust proxy", 1); // trust first proxy
app.use(
    session({
        store: new FirestoreStore({
            dataset: new Firestore(),
            kind: "express-sessions"
        }),
        secret: secret,
        /* set secure to false for local dev session history testing */
        /* see more at https://expressjs.com/en/resources/middleware/session.html */
        cookie: { secure: true },
        resave: false,
        saveUninitialized: true
    })
);

const expressWs = require("express-ws")(app);

app.use(express.static("public"));

// Vertex AI Section
const { VertexAI } = require("@google-cloud/vertexai");

// instance of Vertex model
let generativeModel;

// on startup
const port = parseInt(process.env.PORT) || 8080;
app.listen(port, async () => {
    console.log(`demo1: listening on port ${port}`);

    // get project and location from metadata service
    const metadataService = require("./metadataService.js");

    const project = await metadataService.getProjectId();
    const location = await metadataService.getRegion();

    // Vertex client library instance
    const vertex_ai = new VertexAI({
        project: project,
        location: location
    });

    // Instantiate models
    generativeModel = vertex_ai.getGenerativeModel({
        model: "gemini-1.0-pro-001"
    });
});

app.ws("/sendMessage", async function (ws, req) {
    if (!req.session.chathistory || req.session.chathistory.length == 0) {
        req.session.chathistory = [];
    }

    let chatWithModel = generativeModel.startChat({
        history: req.session.chathistory
    });

    ws.on("message", async function (message) {

        console.log("req.sessionID: ", req.sessionID);
        // get session id

        let questionToAsk = JSON.parse(message).message;
        console.log("WebSocket message: " + questionToAsk);

        ws.send(`<div hx-swap-oob="beforeend:#toupdate"><div
                        id="questionToAsk"
                        class="text-black m-2 text-right border p-2 rounded-lg ml-24">
                        ${questionToAsk}
                    </div></div>`);

        // to simulate a natural pause in conversation
        await sleep(500);

        // get timestamp for div to replace
        const now = "fromGemini" + Date.now();

        ws.send(`<div hx-swap-oob="beforeend:#toupdate"><div
                        id=${now}
                        class=" text-blue-400 m-2 text-left border p-2 rounded-lg mr-24">
                        ${spinnerSvg} 
                    </div></div>`);

        const results = await chatWithModel.sendMessage(questionToAsk);
        const answer =
            results.response.candidates[0].content.parts[0].text;

        ws.send(`<div
                        id=${now}
                        hx-swap-oob="true"
                        hx-swap="outerHTML"
                        class="text-blue-400 m-2 text-left border p-2 rounded-lg mr-24">
                        ${answer}
                    </div>`);

                    // save to current chat history
        let userHistory = {
            role: "user",
            parts: [{ text: questionToAsk }]
        };
        let modelHistory = {
            role: "model",
            parts: [{ text: answer }]
        };

        req.session.chathistory.push(userHistory);
        req.session.chathistory.push(modelHistory);

        // console.log(
        //     "newly saved chat history: ",
        //     util.inspect(req.session.chathistory, {
        //         showHidden: false,
        //         depth: null,
        //         colors: true
        //     })
        // );
        req.session.save();
    });

    ws.on("close", () => {
        console.log("WebSocket was closed");
    });
});

function sleep(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

// gracefully close the web sockets
process.on("SIGTERM", () => {
    server.close();
});

建立 tailwindCSS 的 tailwind.config.js 檔案。

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: ["./**/*.{html,js}"],
    theme: {
        extend: {}
    },
    plugins: []
};

建立 metadataService.js 檔案,取得已部署 Cloud Run 服務的專案 ID 和區域。這些值將用於對 Vertex AI 用戶端程式庫的執行個體執行個體化。

const your_project_id = "YOUR_PROJECT_ID";
const your_region = "YOUR_REGION";

const axios = require("axios");

module.exports = {
    getProjectId: async () => {
        let project = "";
        try {
            // Fetch the token to make a GCF to GCF call
            const response = await axios.get(
                "http://metadata.google.internal/computeMetadata/v1/project/project-id",
                {
                    headers: {
                        "Metadata-Flavor": "Google"
                    }
                }
            );

            if (response.data == "") {
                // running locally on Cloud Shell
                project = your_project_id;
            } else {
                // running on Clodu Run. Use project id from metadata service
                project = response.data;
            }
        } catch (ex) {
            // running locally on local terminal
            project = your_project_id;
        }

        return project;
    },

    getRegion: async () => {
        let region = "";
        try {
            // Fetch the token to make a GCF to GCF call
            const response = await axios.get(
                "http://metadata.google.internal/computeMetadata/v1/instance/region",
                {
                    headers: {
                        "Metadata-Flavor": "Google"
                    }
                }
            );

            if (response.data == "") {
                // running locally on Cloud Shell
                region = your_region;
            } else {
                // running on Clodu Run. Use region from metadata service
                let regionFull = response.data;
                const index = regionFull.lastIndexOf("/");
                region = regionFull.substring(index + 1);
            }
        } catch (ex) {
            // running locally on local terminal
            region = your_region;
        }
        return region;
    }
};

建立名為 spinnerSvg.js 的檔案

module.exports.spinnerSvg = `<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500"
                    xmlns="http://www.w3.org/2000/svg"
                    fill="none"
                    viewBox="0 0 24 24"
                >
                    <circle
                        class="opacity-25"
                        cx="12"
                        cy="12"
                        r="10"
                        stroke="currentColor"
                        stroke-width="4"
                    ></circle>
                    <path
                        class="opacity-75"
                        fill="currentColor"
                        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                    ></path></svg>`;

最後,為 tailwindCSS 建立 input.css 檔案。

@tailwind base;
@tailwind components;
@tailwind utilities;

現在,建立新的 public 目錄。

mkdir public
cd public

在該公開目錄中,為前端建立 index.html 檔案,用來使用 htmx。

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
        />
        <script
            src="https://unpkg.com/htmx.org@1.9.10"
            integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
            crossorigin="anonymous"
        ></script>

        <link href="./output.css" rel="stylesheet" />
        <script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>

        <title>Demo 1</title>
    </head>
    <body>
        <div id="herewego" text-center>
            <!-- <div id="replaceme2" hx-swap-oob="true">Hello world</div> -->
            <div
                class="container mx-auto mt-8 text-center max-w-screen-lg"
            >
                <div
                    class="overflow-y-scroll bg-white p-2 border h-[500px] space-y-4 rounded-lg m-auto"
                >
                    <div id="toupdate"></div>
                </div>
                <form
                    hx-trigger="submit, keyup[keyCode==13] from:body"
                    hx-ext="ws"
                    ws-connect="/sendMessage"
                    ws-send=""
                    hx-on="htmx:wsAfterSend: document.getElementById('message').value = ''"
                >
                    <div class="mb-6 mt-6 flex gap-4">
                        <textarea
                            rows="2"
                            type="text"
                            id="message"
                            name="message"
                            class="block grow rounded-lg border p-6 resize-none"
                            required
                        >
Is C# a programming language or a musical note?</textarea
                        >
                        <button
                            type="submit"
                            class="bg-blue-500 text-white px-4 py-2 rounded-lg text-center text-sm font-medium"
                        >
                            Send
                        </button>
                    </div>
                </form>
            </div>
        </div>
    </body>
</html>

7. 在本機執行服務

首先,請確認您位於程式碼研究室的根目錄 chat-with-gemini

cd .. && pwd

接著執行下列指令,安裝依附元件:

npm install

在本機執行時使用 ADC

如果您是在 Cloud Shell 中執行,代表已經在 Google Compute Engine 虛擬機器上執行。應用程式預設憑證會自動使用與這個虛擬機器相關聯的憑證 (如執行 gcloud auth list 後所示),因此您不必使用 gcloud auth application-default login 指令。您可以跳到「建立本機工作階段密鑰」一節

不過,如果您是在本機終端機 (而非 Cloud Shell 中) 執行應用程式,則必須使用應用程式預設憑證向 Google API 進行驗證。您可以 1) 以憑證登入 (前提是您同時擁有 Vertex AI 使用者和 Datastore 使用者角色);或是 2) 您可以模擬在這個程式碼研究室中使用的服務帳戶,藉此登入。

做法 1) 針對 ADC 使用憑證

如要使用憑證,可以先執行 gcloud auth list 以驗證您在 gcloud 中的驗證方式。接下來,您可能需要向身分授予「Vertex AI 使用者」角色。如果您的身分具備「擁有者」角色,則代表具備這個 Vertex AI 使用者角色。如果沒有,您可以執行以下指令,授予身分識別 Vertex AI 使用者角色和 Datastore 使用者角色。

USER=<YOUR_PRINCIPAL_EMAIL>

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/aiplatform.user

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/datastore.user

接著執行下列指令

gcloud auth application-default login

選項 2) 模擬 ADC 的服務帳戶

如要使用在本程式碼研究室中建立的服務帳戶,您的使用者帳戶必須具備服務帳戶權杖建立者角色。如要取得這個角色,請執行下列指令:

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/iam.serviceAccountTokenCreator

接著執行下列指令,以透過服務帳戶使用 ADC

gcloud auth application-default login --impersonate-service-account=$SERVICE_ACCOUNT_ADDRESS

建立本機工作階段密鑰

現在,請為本機開發作業建立本機工作階段密鑰。

export SESSION_SECRET=local-secret

在本機執行應用程式

最後,您可以執行以下指令來啟動應用程式。這個指令碼也會產生 tailwindCSS 的 output.css 檔案。

npm run dev

您可以預覽網站,方法是開啟「網頁預覽」按鈕,然後選取「預覽通訊埠 8080」

網頁預覽 - 可透過通訊埠 8080 按鈕預覽

8. 部署服務

首先,請執行下列指令來啟動部署作業,並指定要使用的服務帳戶。如未指定服務帳戶,系統會使用預設的運算服務帳戶。

gcloud run deploy $SERVICE \
 --service-account $SERVICE_ACCOUNT_ADDRESS \
 --source . \
  --region $REGION \
  --allow-unauthenticated \
  --set-secrets="SESSION_SECRET=$(echo $SECRET_ID):1"

如果系統顯示「透過來源部署時,必須有 Artifact Registry Docker 存放區來儲存建構的容器」訊息,系統會在區域 [us-central1] 中建立名為 [cloud-run-source-deploy] 的存放區。"請按一下「y」接受並繼續

9. 測試服務

部署完成後,請在網路瀏覽器中開啟服務網址。然後向 Gemini 提問,例如:「我練習了吉他,但也是軟體工程師。看到「C#」時,我應該將其視為程式設計語言或音符?我該選擇哪一個?」

10. 恭喜!

恭喜您完成本程式碼研究室!

建議您查看說明文件 Cloud RunVertex AI Gemini API

涵蓋內容

  • 如何使用 htmx、tailwindcs 和 express.js 建構 Cloud Run 服務
  • 如何使用 Vertex AI 用戶端程式庫向 Google API 進行驗證
  • 如何建立與 Gemini 模型互動的聊天機器人
  • 如何在沒有 Docker 檔案的情況下部署至 Cloud Run 服務
  • 如何使用 Google Cloud Firestore 支援的快速工作階段儲存庫

11. 清除所用資源

為避免產生意外費用 (舉例來說,如果 Cloud Run 服務意外叫用次數超過免費方案的每月 Cloud Run 叫用分配數量),您可以刪除 Cloud Run 或刪除步驟 2 中建立的專案。

如要刪除 Cloud Run 服務,請前往 https://console.cloud.google.com/run 的 Cloud Run Cloud 控制台,然後刪除 chat-with-gemini 服務。建議您刪除 vertex-ai-caller 服務帳戶或撤銷 Vertex AI 使用者角色,以免不慎呼叫 Gemini。

如果選擇刪除整個專案,您可以前往 https://console.cloud.google.com/cloud-resource-manager,選取您在步驟 2 建立的專案,然後選擇「刪除」。如果刪除專案,您必須變更 Cloud SDK 中的專案。您可以執行 gcloud projects list 來查看可用專案的清單。