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

1. 简介

概览

在此 Codelab 中,您将了解如何使用 Vertex AI Gemini API 和 Vertex AI 客户端库创建一个以节点编写的基本聊天机器人。此应用使用由 Google Cloud Firestore 支持的快速会话存储

学习内容

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

2. 设置和要求

前提条件

激活 Cloud Shell

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

cb81e7c8e34bc8d.png

如果这是您第一次启动 Cloud Shell,系统会显示一个中间屏幕,说明它是什么。如果您看到中间屏幕,请点击继续

d95252b003979716.png

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

7833d5e1c5d18f54

这个虚拟机装有所需的所有开发工具。它提供了一个持久的 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 作为环境变量进行访问,该环境变量在实例启动时进行解析。您可以详细了解 Secret 和 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. 在本地运行服务

首先,确保您位于此 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 凭据用于 ADC

如果您想使用自己的凭据,可以先运行 gcloud auth list 来验证您在 gcloud 中是如何进行身份验证的。接下来,您可能需要向自己的身份授予 Vertex AI User 角色。如果您的身份具有 Owner 角色,则表明您已拥有此 Vertex AI 用户角色。如果没有,您可以运行此命令来授予您的身份 Vertex AI 用户角色和 Datastore User 角色。

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

创建本地会话密钥

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

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. 恭喜!

恭喜您完成此 Codelab!

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

所学内容

  • 如何使用 htmx、tailwindcss 和 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 服务。此外,为避免意外调用 Gemini,建议您删除 vertex-ai-caller 服务账号或撤消 Vertex AI User 角色。

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