如何在 Cloud Run 上部署由 Gemini 提供支持的聊天应用

1. 简介

概览

在此 Codelab 中,您将了解如何使用 Vertex AI Gemini API 和 Vertex AI 客户端库创建用 Node 编写的基本聊天机器人。此应用使用由 Google Cloud Firestore 提供支持的 Express 会话存储区

学习内容

  • 如何使用 htmx、tailwindcss 和 express.js 构建 Cloud Run 服务
  • 如何使用 Vertex AI 客户端库向 Google API 进行身份验证
  • 如何创建聊天机器人以与 Gemini 模型互动
  • 如何部署到没有 Docker 文件的 Cloud Run 服务
  • 如何使用由 Google Cloud Firestore 支持的 Express 会话存储区

2. 设置和要求

前提条件

激活 Cloud Shell

  1. 在 Cloud Console 中,点击激活 Cloud Shelld1264ca30785e435.png

cb81e7c8e34bc8d.png

如果您是第一次启动 Cloud Shell,系统会显示一个介绍其功能的过渡页面。如果您看到了过渡页面,请点击继续

d95252b003979716.png

预配和连接到 Cloud Shell 只需花几分钟时间。

7833d5e1c5d18f54.png

这个虚拟机已加载了所需的所有开发工具。它提供了一个持久的 5 GB 主目录,并且在 Google Cloud 中运行,大大增强了网络性能和身份验证。只需使用一个浏览器即可完成本 Codelab 中的大部分工作。

连接到 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

在开始使用此 Codelab 之前,您需要启用多个 API。此 Codelab 需要使用以下 API。您可以运行以下命令来启用这些 API:

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

设置环境变量

您可以设置本 Codelab 中全程使用的环境变量。

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. 您可以选择是否为此 Codelab 启用 Google Analytics。
  7. 点击添加 Firebase
  8. 项目创建完毕后,点击继续
  9. 构建菜单中,点击 Firestore 数据库
  10. 点击创建数据库
  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 User 角色。

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

现在,在 Secret Manager 中创建 Secret。Cloud Run 服务将以环境变量的形式访问此密文,该变量会在实例启动时解析。您可以详细了解 Secret 和 Cloud Run

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

并授予服务账号对 Secret Manager 中 Express 会话 Secret 的访问权限。

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

在该 public 目录中,为将使用 htmx 的前端创建 index.html 文件。

<!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. 在本地运行服务

首先,请确保您位于 Codelab 的根目录 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 User 和 Datastore User 角色);2) 通过模拟此 Codelab 中使用的服务账号登录。

方法 1)使用您的凭据进行 ADC

如果您想使用自己的凭据,可以先运行 gcloud auth list 来验证您在 gcloud 中的身份验证方式。接下来,您可能需要向您的身份授予 Vertex AI User 角色。如果您的身份拥有 Owner 角色,则您已拥有此 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 模拟服务账号

如果您想使用在此 Codelab 中创建的服务账号,您的用户账号需要具有 Service Account Token Creator 角色。您可以通过运行以下命令来获取此角色:

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

创建本地会话密钥

现在,为本地开发创建本地会话 Secret。

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. 测试服务

部署完成后,在 Web 浏览器中打开该服务网址。然后问问 Gemini,例如:“我练习吉他,但也是一名软件工程师。看到“C#”时,我应该将其视为编程语言还是音符?我应该选择哪一个?

10. 恭喜!

恭喜您完成此 Codelab!

建议您查看 Cloud RunVertex AI Gemini API 文档。

所学内容

  • 如何使用 htmx、tailwindcss 和 express.js 构建 Cloud Run 服务
  • 如何使用 Vertex AI 客户端库向 Google API 进行身份验证
  • 如何创建聊天机器人以与 Gemini 模型互动
  • 如何部署到没有 Docker 文件的 Cloud Run 服务
  • 如何使用由 Google Cloud Firestore 支持的 Express 会话存储区

11. 清理

为避免意外产生费用(例如,如果 Cloud Run 服务的调用次数意外超过免费层级中每月 Cloud Run 调用次数的分配额),您可以删除 Cloud Run 或删除您在第 2 步中创建的项目。

如需删除 Cloud Run 服务,请前往 Cloud Run Cloud 控制台 (https://console.cloud.google.com/run),然后删除 chat-with-gemini 服务。您可能还需要删除 vertex-ai-caller 服务账号或撤消 Vertex AI User 角色,以避免意外调用 Gemini。

如果您选择删除整个项目,可以前往 https://console.cloud.google.com/cloud-resource-manager,选择您在第 2 步中创建的项目,然后选择“删除”。如果您删除项目,则需要在 Cloud SDK 中更改项目。您可以运行 gcloud projects list 查看所有可用项目的列表。