如何将 Cloud Run 与 Gemini 函数调用搭配使用

1. 简介

概览

在此 Codelab 中,您将了解如何使用一项名为 函数调用 的新功能,让 Gemini 访问实时数据。为了模拟实时数据,您将构建一个天气服务端点,该端点会返回 2 个位置的当前天气。然后,您将构建一个由 Gemini 提供支持的聊天应用,该应用使用函数调用来检索当前天气。

让我们通过一个简短的直观示例来了解函数调用。

  • 提示要求获取给定位置的当前天气位置
  • 此提示 + getWeather() 的函数协定会发送给 Gemini
  • Gemini 要求聊天机器人应用代表其调用“getWeather(Seattle)”
  • 应用会发回结果(40 华氏度,有雨)
  • Gemini 会将结果发回给调用方

总而言之,Gemini 不会调用函数。您作为开发者必须调用函数,并将结果发回给 Gemini。

函数调用流程图

学习内容

  • Gemini 函数调用的工作原理
  • 如何将由 Gemini 提供支持的聊天机器人应用部署为 Cloud Run 服务

2. 设置和要求

前提条件

激活 Cloud Shell

  1. 在 Cloud Console 中,点击 激活 Cloud Shell d1264ca30785e435.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

设置环境变量

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

PROJECT_ID=<YOUR_PROJECT_ID>
REGION=<YOUR_REGION, e.g. us-central1>
WEATHER_SERVICE=weatherservice
FRONTEND=frontend
SERVICE_ACCOUNT="vertex-ai-caller"
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com

启用 API

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

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

4. 创建用于调用 Vertex AI 的服务账号

Cloud Run 将使用此服务账号来调用 Vertex AI Gemini API。

首先,运行以下命令来创建服务账号:

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

5. 创建后端 Cloud Run 服务

首先,为源代码创建一个目录,然后进入该目录。

mkdir -p gemini-function-calling/weatherservice gemini-function-calling/frontend && cd gemini-function-calling/weatherservice

然后,创建一个包含以下内容的 package.json 文件:

{
    "name": "weatherservice",
    "version": "1.0.0",
    "description": "",
    "main": "app.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
        "express": "^4.18.3"
    }
}

接下来,创建一个包含以下内容的 app.js 源文件。此文件包含该服务的入口点,并包含应用的主要逻辑。

const express = require("express");
const app = express();

app.get("/getweather", (req, res) => {
    const location = req.query.location;
    let temp, conditions;

    if (location == "New Orleans") {
        temp = 99;
        conditions = "hot and humid";
    } else if (location == "Seattle") {
        temp = 40;
        conditions = "rainy and overcast";
    } else {
        res.status(400).send("there is no data for the requested location");
    }

    res.json({
        weather: temp,
        location: location,
        conditions: conditions
    });
});

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

app.get("/", (req, res) => {
    res.send("welcome to hard-coded weather!");
});

部署天气服务

您可以使用此命令来部署天气服务。

gcloud run deploy $WEATHER_SERVICE \
  --source . \
  --region $REGION \
  --allow-unauthenticated

测试天气服务

您可以使用 curl 验证 2 个位置的天气:

WEATHER_SERVICE_URL=$(gcloud run services describe $WEATHER_SERVICE \
              --platform managed \
              --region=$REGION \
              --format='value(status.url)')

curl $WEATHER_SERVICE_URL/getweather?location=Seattle

curl $WEATHER_SERVICE_URL/getweather?location\=New%20Orleans

西雅图的天气为 40 华氏度,有雨;新奥尔良的天气为 99 华氏度,潮湿。

6. 创建前端服务

首先,进入前端目录。

cd gemini-function-calling/frontend

然后,创建一个包含以下内容的 package.json 文件:

{
  "name": "demo1",
  "version": "1.0.0",
  "description": "",
  "main": "index.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/vertexai": "^0.4.0",
    "axios": "^1.6.7",
    "express": "^4.18.2",
    "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");

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

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

const {
    VertexAI,
    FunctionDeclarationSchemaType
} = require("@google-cloud/vertexai");

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

// instance of Gemini model
let generativeModel;

// 1: define the function
const functionDeclarations = [
    {
        function_declarations: [
            {
                name: "getweather",
                description: "get weather for a given location",
                parameters: {
                    type: FunctionDeclarationSchemaType.OBJECT,
                    properties: {
                        location: {
                            type: FunctionDeclarationSchemaType.STRING
                        },
                        degrees: {
                            type: FunctionDeclarationSchemaType.NUMBER,
                            "description":
                                "current temperature in fahrenheit"
                        },
                        conditions: {
                            type: FunctionDeclarationSchemaType.STRING,
                            "description":
                                "how the weather feels subjectively"
                        }
                    },
                    required: ["location"]
                }
            }
        ]
    }
];

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

    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"
    });
});

const axios = require("axios");
const baseUrl = "https://weatherservice-k6msmyp47q-uc.a.run.app";

app.ws("/sendMessage", async function (ws, req) {

    // this chat history will be pinned to the current 
    // Cloud Run instance. Consider using Firestore &
    // Firebase anonymous auth instead.

    // start ephemeral chat session with Gemini
    const chatWithModel = generativeModel.startChat({
        tools: functionDeclarations
    });

    ws.on("message", async function (message) {
        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);

        // Function calling demo
        let response1 = await results.response;
        let data = response1.candidates[0].content.parts[0];

        let methodToCall = data.functionCall;
        if (methodToCall === undefined) {
            console.log("Gemini says: ", data.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">
                        ${data.text}
                    </div>`);

            // bail out - Gemini doesn't want to return a function
            return;
        }

        // otherwise Gemini wants to call a function
        console.log(
            "Gemini wants to call: " +
                methodToCall.name +
                " with args: " +
                util.inspect(methodToCall.args, {
                    showHidden: false,
                    depth: null,
                    colors: true
                })
        );

        // make the external call
        let jsonReturned;
        try {
            const responseFunctionCalling = await axios.get(
                baseUrl + "/" + methodToCall.name,

                {
                    params: {
                        location: methodToCall.args.location
                    }
                }
            );
            jsonReturned = responseFunctionCalling.data;
        } catch (ex) {
            // in case an invalid location was provided
            jsonReturned = ex.response.data;
        }

        console.log("jsonReturned: ", jsonReturned);

        // tell the model what function we just called
        const functionResponseParts = [
            {
                functionResponse: {
                    name: methodToCall.name,
                    response: {
                        name: methodToCall.name,
                        content: { jsonReturned }
                    }
                }
            }
        ];

        // // Send a follow up message with a FunctionResponse
        const result2 = await chatWithModel.sendMessage(
            functionResponseParts
        );

        // This should include a text response from the model using the response content
        // provided above
        const response2 = await result2.response;
        let answer = response2.candidates[0].content.parts[0].text;
        console.log("answer: ", answer);

        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>`);
    });

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

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

为 tailwindCSS 创建 input.css 文件。

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

为 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 {
                // 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 {
                // 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>`;

创建一个新的 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 2</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
                        >
What&apos;s is the current weather in Seattle?</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 的 frontend 目录中。

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 User 角色。否则,您可以运行此命令来向您的身份授予 Vertex AI User 角色和 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

在本地运行应用

最后,您可以通过运行以下脚本来启动应用。此开发脚本还会从 tailwindCSS 生成 output.css 文件。

npm run dev

您可以打开“网页预览”按钮,然后选择“预览端口 8080”来预览网站

网页预览 -“在端口 8080 上预览”按钮

8. 部署和测试前端服务

首先,运行此命令以启动部署并指定要使用的服务账号。如果未指定服务账号,则使用默认计算服务账号。

gcloud run deploy $FRONTEND \
  --service-account $SERVICE_ACCOUNT_ADDRESS \
  --source . \
  --region $REGION \
  --allow-unauthenticated

在浏览器中打开前端的服务网址。提出问题“西雅图的当前天气如何?”,Gemini 应回答“目前 40 度,有雨。”如果您问“波士顿的当前天气如何?”,Gemini 将回答“我无法满足此请求。可用的天气 API 没有波士顿的数据。”

9. 恭喜!

恭喜您完成此 Codelab!

我们建议您查看 Cloud RunVertex AI Gemini API函数调用文档。

所学内容

  • Gemini 函数调用的工作原理
  • 如何将由 Gemini 提供支持的聊天机器人应用部署为 Cloud Run 服务

10. 清理

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

如需删除 Cloud Run 服务,请前往 Cloud Run Cloud Console (https://console.cloud.google.com/functions/),然后删除您在此 Codelab 中创建的 $WEATHER_SERVICE 和 $FRONTEND 服务。

您可能还需要删除 vertex-ai-caller 服务账号或撤消 Vertex AI User 角色,以避免意外调用 Gemini。

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