Gemini 함수 호출과 함께 Cloud Run을 사용하는 방법

1. 소개

개요

이 Codelab에서는 함수 호출이라는 새로운 기능을 사용하여 Gemini에 실시간 데이터 액세스 권한을 부여하는 방법을 알아봅니다. 실시간 데이터를 시뮬레이션하려면 두 위치의 현재 날씨를 반환하는 날씨 서비스 엔드포인트를 빌드합니다. 그런 다음 함수 호출을 사용하여 현재 날씨를 가져오는 Gemini 기반의 채팅 앱을 빌드합니다.

간단한 시각 자료를 사용하여 함수 호출을 이해해 보겠습니다.

  • 지정된 위치의 현재 날씨 위치에 대한 프롬프트 요청
  • 이 프롬프트 + getWeather()의 함수 계약이 Gemini로 전송됩니다.
  • Gemini가 채팅 봇 앱에서 'getWeather(Seattle)'를 호출하도록 요청합니다. 위임자
  • 앱이 결과를 다시 보냅니다 (40디그레스 F 및 비).
  • Gemini가 발신자에게 결과를 반환합니다.

요약하자면 Gemini는 함수를 호출하지 않습니다. 개발자는 함수를 호출하고 결과를 Gemini로 다시 전송해야 합니다.

함수 호출 흐름 다이어그램

학습할 내용

  • Gemini 함수 호출 작동 방식
  • Gemini 기반 챗봇 앱을 Cloud Run 서비스로 배포하는 방법

2. 설정 및 요구사항

기본 요건

  • Cloud 콘솔에 로그인했습니다.
  • 이전에 2세대 함수를 배포했습니다. 예를 들어 Cloud 함수 2세대 빠른 시작의 배포를 시작할 수 있습니다.

Cloud Shell 활성화

  1. Cloud Console에서 Cloud Shell 활성화d1264ca30785e435.png를 클릭합니다.

cb81e7c8e34bc8d.png

Cloud Shell을 처음 시작하는 경우에는 무엇이 있는지 설명하는 중간 화면이 표시됩니다. 중간 화면이 표시되면 계속을 클릭합니다.

d95252b003979716.png

Cloud Shell을 프로비저닝하고 연결하는 데 몇 분 정도만 걸립니다.

7833d5e1c5d18f54.png

가상 머신에는 필요한 개발 도구가 모두 들어 있습니다. 영구적인 5GB 홈 디렉터리를 제공하고 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 사용자 역할을 부여합니다.

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

5. 백엔드 Cloud Run 서비스 만들기

먼저 소스 코드용 디렉터리를 만들고 해당 디렉터리로 cd하세요.

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을 사용하여 두 위치의 날씨를 확인할 수 있습니다.

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합니다.

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: []
};

배포된 Cloud Run 서비스의 프로젝트 ID와 리전을 가져오기 위한 metadataService.js 파일을 만듭니다. 이러한 값은 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

이제 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 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 사용자 및 Datastore 사용자 역할이 모두 있는 경우) 2) 이 Codelab에서 사용된 서비스 계정을 가장하여 로그인할 수 있습니다.

옵션 1) ADC에 사용자 인증 정보 사용

사용자 인증 정보를 사용하려면 먼저 gcloud auth list를 실행하여 gcloud에서 인증된 방법을 확인합니다. 다음으로 ID에 Vertex AI 사용자 역할을 부여해야 할 수 있습니다. ID에 소유자 역할이 있다면 이 Vertex AI 사용자 역할이 이미 있는 것입니다. 그렇지 않은 경우 이 명령어를 실행하여 ID에 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에서 만든 서비스 계정을 사용하려면 사용자 계정에 서비스 계정 토큰 생성자 역할이 있어야 합니다. 다음 명령어를 실행하여 이 역할을 가져올 수 있습니다.

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

브라우저에서 프런트엔드의 서비스 URL을 엽니다. "시애틀 현재 날씨는 어때?"라고 물어보세요. 그러면 Gemini가 "현재 기온은 40도이며 비가 내립니다"라고 대답해야 합니다. "보스턴의 현재 날씨는 어때?"라고 물으면 Gemini에서 "이 요청을 처리할 수 없습니다. 사용 가능한 날씨 API에는 보스턴 관련 데이터가 없습니다.'

9. 축하합니다.

축하합니다. Codelab을 완료했습니다.

Cloud Run, Vertex AI Gemini API, 함수 호출 문서를 검토하시기 바랍니다.

학습한 내용

  • Gemini 함수 호출 작동 방식
  • Gemini 기반 챗봇 앱을 Cloud Run 서비스로 배포하는 방법

10. 삭제

실수로 인한 요금 청구를 피하려면(예: 이 Cloud Run 서비스가 무료 등급의 월별 Cloud Run 호출 할당보다 실수로 더 많이 호출되는 경우) Cloud Run 서비스를 삭제하거나 2단계에서 만든 프로젝트를 삭제하면 됩니다.

Cloud Run 서비스를 삭제하려면 Cloud Run Cloud 콘솔(https://console.cloud.google.com/functions/)으로 이동하여 이 Codelab에서 만든 $WEATHER_SERVICE 및 $FRONTEND 서비스를 삭제합니다.

또한 의도치 않은 Gemini 호출을 방지하기 위해 vertex-ai-caller 서비스 계정을 삭제하거나 Vertex AI 사용자 역할을 취소할 수 있습니다.

전체 프로젝트를 삭제하려면 https://console.cloud.google.com/cloud-resource-manager로 이동하여 2단계에서 만든 프로젝트를 선택한 후 삭제를 선택하면 됩니다. 프로젝트를 삭제하면 Cloud SDK에서 프로젝트를 변경해야 합니다. gcloud projects list를 실행하면 사용 가능한 모든 프로젝트의 목록을 볼 수 있습니다.