👗 使用 Flutter、ADK Go 和 Gemini 建構虛擬試衣間和 AI 時尚顧問

1. 簡介

建構項目

在本程式碼研究室中,您將化身為開發人員,建構虛構零售品牌的 Flutter 購物應用程式「Fashion App」。你的任務:新增兩項 AI 輔助功能,徹底改變網路購物體驗。

  1. 虛擬試衣間:使用者上傳自己的相片並選取服飾,即可查看自己穿上該服飾的 AI 生成圖像。
  2. AI 造型師:AI 代理程式會根據使用者的所在地、場合和風格偏好,提供完整的穿搭建議,使用者也可以透過對話調整建議。

概念很簡單:消費者在試衣間試穿衣服後,購買意願會大幅提升。但線上呢?你只是在瞎猜。這項專案運用 AI 技術,消弭了這個落差。

架構一覽

Flutter App  ──── HTTP/REST ────▶  ADK Go Backend
                                       
                            ┌──────────┼──────────┐
                       Fitting Room  Stylist    Catalog
                         Agent       Agent      Agent
                                       
                            Gemini API + Cloud Storage

核心技術

元件

科技

目的

代理程式架構

Go 適用的 ADK (Agent Development Kit)

多代理自動化調度管理、工作階段、構件

代理程式推理 (專業版)

Gemini 3 Pro 預先發布版

為試衣間和造型師代理提供支援

Agent Reasoning (Flash)

Gemini 3 Flash 預先發布版

為根目錄和目錄代理程式提供支援 (輕量型路徑/查閱)

圖像生成

Gemini 2.5 Flash Image

生成試穿和服裝圖片

前端

Flutter (Dart)

跨平台應用程式 (網頁、iOS、Android)

儲存空間

Google Cloud Storage

儲存產品圖片和生成的構件

代管

Cloud Run

無伺服器容器部署

2. 📦 前提條件和 Cloud Shell 設定

1. 開啟 Cloud Shell 編輯器

👉 在瀏覽器中開啟 Cloud Shell 編輯器

如果畫面底部未顯示終端機:

  • 依序點選「View」(檢視) →「Terminal」(終端機)

2. 設定 Flutter SDK

Cloud Shell 預先安裝了 Flutter (位於 /google/flutter)。由於該目錄是由其他系統使用者擁有,因此您第一次執行 flutter 時會發生 fatal: detected dubious ownership 錯誤。將其新增至 git 的安全目錄清單一次:

git config --global --add safe.directory /google/flutter

確認 Flutter 是否已安裝在 PATH 上並正常運作:

flutter --version

第一次執行時,系統會下載 Dart SDK 並建構 Flutter 工具,請稍候片刻。畫面應如下所示:Flutter 3.x • channel stable

3. 複製存放區

cd ~
git clone https://github.com/gca-americas/fashion-app-demo
cd fashion_app_demo

4. 探索專案結構

fashion_app_demo/
├── adk_backend/                 # Go backend with ADK agents
   ├── main.go                  # Entry point — wires all agents + REST server
   ├── catalog/                 # Catalog Agent — product lookup
      ├── agent.go
      ├── catalog.yaml         # Product database (YAML)
      └── instructions.md      # Agent persona prompt
   ├── fittingroom/             # Fitting Room Agent — virtual try-on
      ├── agent.go
      ├── instructions.md      # Agent persona prompt
      └── tool_instructions.md # Image generation prompt
   ├── stylist/                 # Stylist Agent — outfit curation
      ├── agent.go
      └── instructions.md      # Agent persona + output format
   ├── rootagent/               # Root Agent — routes to the right agent
      └── agent.go
   └── tools/                   # Shared tools
       ├── imagetool.go         # getProductImage — loads product images
       ├── outfit_gen_tool.go   # generate_outfit_image — creates outfit images
       └── cors_helper.go      # CORS middleware + request logging

├── flutter_frontend/            # Flutter cross-platform app
   ├── lib/
      ├── main.dart            # App entry point + Provider setup
      ├── app_config.dart      # Backend URL configuration
      ├── core_app/            # Pre-built shopping app (browse, cart, etc.)
      └── workshop_tasks/      # AI feature code
          ├── step_1_try_it_on/  # Virtual Try-On flow
             ├── providers/     # TryItOnProvider (state management)
             ├── services/      # AdkFittingRoomService (HTTP calls)
             └── ui/            # Screens (product detail → try on → fitting room)
          └── step_2_style_me/   # Style Me flow
              ├── models/        # Outfit, StyleRequest data classes
              ├── providers/     # StylingProvider (state management)
              ├── services/      # AdkStylingService (HTTP calls)
              └── ui/            # Screens (form sheet → outfit carousel)
   └── assets/images/           # Product catalog images

3. ☁️ 設定 Google Cloud 專案

1. 建立新專案

gcloud projects create fashion-app-demo --name="Fashion App Demo"
gcloud config set project fashion-app-demo

列出帳單帳戶:

gcloud billing accounts list

查看

OPEN

欄。必須顯示「True」。如果顯示 False (常見於免費試用期結束後),表示帳戶已關閉,不會實際支付任何費用,請先跳至下方的疑難排解區塊,再繼續操作。

複製 ACCOUNT_ID 帳戶的 OPEN: True (看起來像 0X0X0X-0X0X0X-0X0X0X),然後連結至專案:

gcloud billing projects link fashion-app-demo \
 --billing-account=YOUR_BILLING_ACCOUNT_ID

驗證連結:

gcloud billing projects describe fashion-app-demo

您應該會看到 billingEnabled: true。如果連結後仍看到 billingEnabled: false,表示帳戶已關閉 (OPEN: False) - 請參閱下方的疑難排解區塊。

3. 啟用必要的 API

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

API

目的

aiplatform.googleapis.com

Vertex AI - fitting_tool 透過 Vertex AI 呼叫 Gemini 的圖像生成功能

storage.googleapis.com

Cloud Storage:儲存產品目錄圖片和產生的試穿結果

run.googleapis.com

Cloud Run:以無伺服器容器的形式託管後端

cloudbuild.googleapis.com

Cloud Build:從來源建構 Docker 映像檔

artifactregistry.googleapis.com

Artifact Registry:儲存建構的 Docker 映像檔

4. 建立 GCS bucket

export PROJECT_ID=$(gcloud config get-value project)
gcloud storage buckets create gs://fashion-app-$PROJECT_ID \
 --location=us-central1 \
 --uniform-bucket-level-access

5. 上傳產品目錄圖片

後端的 getProductImage 工具會從 gs://$GCS_BUCKET/catalog-assets/images/ 讀取資料。將目錄圖片上傳至該確切路徑:

cd ~/fashion_app_demo
gcloud storage cp flutter_frontend/assets/images/*.png \
 gs://fashion-app-$PROJECT_ID/catalog-assets/images/

確認上傳作業 (您應該會看到 .png 檔案清單):

gcloud storage ls gs://fashion-app-$PROJECT_ID/catalog-assets/images/

6. 設定 .env 檔案

cd ~/fashion_app_demo/adk_backend
cat > .env << EOF
GOOGLE_CLOUD_PROJECT=$PROJECT_ID
GCS_BUCKET=fashion-app-$PROJECT_ID
EOF

7. 使用應用程式預設憑證進行驗證

您必須先執行這項操作,才能在本機啟動後端。Go 後端會使用 ADC 驗證對 Vertex AI (Gemini) 和 Cloud Storage 的每次呼叫。如果沒有 ADC,後端會啟動,但每次試穿要求都會失敗,並傳回 401 CREDENTIALS_MISSING

一組憑證即可存取這兩項服務。依序執行下列兩個指令:

# 1. Log in (opens a browser; in Cloud Shell, paste the verification code back)
gcloud auth application-default login

# 2. Attach your project as the quota / billing project for ADC
gcloud auth application-default set-quota-project $(gcloud config get-value project)

確認 ADC 運作正常:

gcloud auth application-default print-access-token | head -c 20 && echo "..."

畫面上應該會顯示約 20 個字元的權杖,後面接著 ...。如果發生錯誤,表示登入失敗,請重新執行步驟 1。

4. 🏗️ 架構總覽

環境準備就緒後,我們就來瞭解系統運作方式,再查看程式碼。

四代理系統

後端是使用 Go 適用的 ADK (Agent Development Kit) 建構的多代理系統。四個代理會共同運作,各自負責特定工作:

                   ┌──────────────┐
                    Root Agent      Routes requests to the right agent
                    (gemini-3-   
                     flash-      
                     preview)    
                   └──────┬───────┘
                          
           ┌──────────────┼──────────────┐
                                       
  ┌────────────────┐ ┌──────────┐ ┌────────────────┐
   Fitting Room     Catalog    Stylist        
   Agent            Agent      Agent          
   (gemini-3.1-     (gemini-   (gemini-3.1-   
    pro-preview)     3-flash-   pro-preview)  
                     preview)                 
   Tools:                      Tools:         
    fitting_tool   Tools:      fitting_tool 
    getProduct      list      getProduct   
     Image          Products     Image        
    catalog_agent   get       catalog_agent
     (delegation)   Product      (delegation) 
                     Image      generate_    
  └────────────────┘ └──────────┘    outfit_image 
                                  └────────────────┘

代理

型號

角色

Root Agent

gemini-3-flash-preview

交通警察。讀取使用者訊息,並委派給合適的專員。由於只需要做出路徑決策,因此使用快速輕巧的模型。

目錄代理程式

gemini-3-flash-preview

產品專家。從 YAML 檔案載入產品目錄,並回答產品查詢。而且很輕量,因為只是查詢資料。

試衣間服務專員

gemini-3.1-pro-preview

虛擬試妝專家。系統會使用使用者相片和產品圖片,生成使用者穿戴該產品的合成圖片。由於需要推論圖片內容,因此使用功能更強大的模型。

Stylist Agent

gemini-3.1-pro-preview

時尚顧問。系統會根據地點、場合和偏好,從目錄中精選 3 種服裝組合。可為每套服裝生成試穿圖片。並使用功能強大的模型進行創意推論。

進入點:main.go

一切都從 main.go 開始,這個檔案會將代理程式連結在一起,並啟動 HTTP 伺服器:

// main.go  simplified for clarity


func main() {
   godotenv.Load()  // Load .env file


   // 1. Create the artifact storage (GCS-backed)
   artifacts, _ := gcsartifact.NewService(ctx, bucket)


   // 2. Build agents bottom-up (dependencies first)
   catagent, _    := catalog.NewCatalogAgent(apikey, "catalog/catalog.yaml")
   fitagent, _    := fittingroom.NewFittingRoomAgent(apikey, catagent)
   stylistAgent, _ := stylist.NewStylistAgent(apikey, catagent)
   ragent, _      := rootagent.NewRootAgent(apikey, fitagent, catagent, stylistAgent)


   // 3. Register all agents with a multi-loader
   loader, _ := agent.NewMultiLoader(ragent, fitagent, catagent, stylistAgent)


   // 4. Create the ADK REST server
   restHandler, _ := adkrest.NewServer(adkrest.ServerConfig{
       SessionService:  session.InMemoryService(),
       MemoryService:   memory.InMemoryService(),
       AgentLoader:     loader,
       ArtifactService: artifacts,
   })


   // 5. Mount behind /api/ with CORS support
   r := mux.NewRouter()
   r.Use(tools.LocalhostCORS)
   r.PathPrefix("/api/").Handler(
       http.StripPrefix("/api", tools.LogHandler(restHandler)))


   http.Server{Addr: ":8080", Handler: r}.ListenAndServe()
}

請注意以下幾項重點:

  • 代理程式是從底層建構:目錄代理程式會先建立,因為試衣間和造型師代理程式都依賴目錄代理程式 (會將產品查詢作業委派給目錄代理程式)。
  • agent.NewMultiLoader 註冊所有四個代理,因此 REST API 可以依名稱將流量導向任何代理。
  • adkrest.NewServer 會自動提供 REST API,您不必自行編寫端點處理常式。ADK 提供工作階段管理、構件儲存和代理執行功能,可直接使用。
  • session.InMemoryService() 會將工作階段儲存在記憶體中。也就是說,伺服器重新啟動時,工作階段就會遺失,這對示範來說沒問題。在實際工作環境中,您會使用永久儲存空間。
  • gcsartifact.NewService 會將構件 (產生的圖片) 儲存在 Google Cloud Storage 中,因此這些構件會在要求之間保留,並可透過 GCS URI 共用。

5. 🤖 ADK (Agent Development Kit) 深入剖析

什麼是 ADK?

Agent Development Kit (ADK) 是 Google 的開放原始碼框架,可用於以 Go (以及 Python/Java) 建構 AI 代理。這是應用程式和 Gemini API 之間的層。

您可以直接呼叫 Gemini API。但如果應用程式需要:

  • 查詢目錄中的產品
  • 根據使用者相片生成圖像
  • 記住先前建議的服裝
  • 協調多個 AI 代理

你需要結構。ADK 提供這種結構。

代理程式迴圈

每個 ADK 代理都會遵循以下迴圈:

1. Receive a message (from user or another agent)
2. Think  the LLM reasons about what to do
3. Act  call a tool, delegate to a sub-agent, or respond
4. Return  send the result back

單一要求中可重複多次執行這個迴圈。舉例來說,造型師代理程式可能會:

  1. 收到「為海灘假期搭配造型」
  2. 呼叫 catalog_agent 工具來取得產品清單
  3. 選取 3 種穿搭組合
  4. 為每套服裝呼叫 fitting_tool,生成圖片
  5. 傳回結構化 JSON 回應

核心概念 (使用這個存放區的程式碼)

LLM 代理程式

主要構成元素。使用 llmagent.New() 建立:

// From catalog/agent.go
agent, err := llmagent.New(llmagent.Config{
   Name:        "catalog_agent",                    // Unique identifier
   Model:       m,                                  // Which LLM to use
   Description: "An agent that can search and list products from the catalog",
   Instruction: instructions,                       // Persona prompt (embedded from .md file)
   Tools:       []tool.Tool{listTool, imageTool},   // What the agent can do
})

Instruction 欄位是代理的角色,可讓 LLM 瞭解代理的身分和行為方式。在這個存放區中,操作說明會以 Markdown 檔案的形式編寫,並在編譯時間使用 Go 的 //go:embed 指令嵌入:

//go:embed instructions.md
var instructions string

這樣一來,提示就會成為可獨立版本化的文件,而不是內嵌字串。

工具

工具是 LLM 可呼叫的 Go 函式。ADK 會處理 LLM 工具呼叫格式與您輸入的 Go 函式之間的轉換:

// From catalog/agent.go
type ListProductsArgs struct{}                    // Input (can be empty)
type ListProductsResult struct {
   Products []Product `json:"products"`          // Output
}


func ListProducts(ctx tool.Context, args ListProductsArgs) (ListProductsResult, error) {
   return ListProductsResult{Products: catalogProducts}, nil
}


// Register it:
listTool, _ := functiontool.New(functiontool.Config{
   Name:        "listProducts",
   Description: "list all products in the catalog",
}, ListProducts)

ADK 會自動根據 Go 結構體產生 JSON 結構定義,並傳送至 LLM。當 LLM 決定呼叫 listProducts 時,ADK 會將引數還原序列化、呼叫函式,然後將結果傳回。

tool.Context 參數可讓工具存取 ADK 的執行階段服務,其中最重要的就是 artifacts

// Save an image as an artifact
ctx.Artifacts().Save(ctx, "my_image", imagePart)


// Load an artifact
resp, _ := ctx.Artifacts().Load(ctx, "my_image")

子代理委派

代理可透過 agenttool.New() 將其他代理當做工具使用:

// From fittingroom/agent.go
Tools: []tool.Tool{
   loadartifactstool.New(),              // List available artifacts
   imgtool,                              // Get product images
   agenttool.New(catalogAgent, nil),     // Delegate to catalog agent
   fittingTool,                          // Generate try-on image
},

如果試衣間服務專員需要產品資訊,可以像呼叫一般工具一樣呼叫目錄服務專員。LLM 會在工具清單中看到這項工具,並決定是否要叫用。

工作階段

工作階段會追蹤對話記錄。ADK 的 REST API 會自動管理這些項目:

POST /api/apps/{appName}/users/{userId}/sessions    Creates a new session
POST /api/run  (with sessionId)                     Runs agent within that session

這個應用程式的重要設計決策:試衣間會為每個要求建立新的工作階段 (每次試穿都是獨立的),而造型師會重複使用相同的工作階段 (因此會記住先前的建議,並根據意見回饋進行調整)。

狀態是附加至工作階段的鍵/值儲存區。代理程式會讀取及寫入狀態來協調:

// Write to state
ctx.State().Set("previously_used_products", "[\"id_bomber\",\"id_hat\"]")


// Read from state
val, err := ctx.State().Get("previously_used_products")

造型師代理會使用狀態記憶已建議的產品,下次會挑選其他產品。

構件

構件是依工作階段儲存的具名二進位物件 (通常是圖片)。與文字回覆不同,這些回覆會分開儲存,並依名稱擷取:

// Save a generated image as an artifact
artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
ctx.Artifacts().Save(ctx, artName, imagePart)


// The frontend fetches it via:
// GET /api/apps/{app}/users/{user}/sessions/{session}/artifacts/{artName}

這樣可確保回應輕量化,因為代理程式只會傳回構件名稱,前端則會另外擷取二進位圖片資料。

回呼

回呼是掛鉤,會在代理迴圈的特定時間點執行。他們可以檢查、修改或中斷執行:

llmagent.Config{
   // Runs before the agent starts  used to save uploaded images
   BeforeAgentCallbacks: []agent.BeforeAgentCallback{SaveIncomingBlobs},


   // Runs before each LLM call  used to inject context
   BeforeModelCallbacks: []llmagent.BeforeModelCallback{
       ExtractAndInjectUserImage,
       InjectPreviousProducts,
   },


   // Runs after each LLM response  used to extract data
   AfterModelCallbacks: []llmagent.AfterModelCallback{SaveSelectedProducts},
}

如果回呼傳回非空值的回應,系統會略過預設行為。舉例來說,如果 BeforeModelCallback 傳回快取回應,就會完全略過實際的 LLM 呼叫。

強制執行 JSON 結構定義

試衣間和造型師代理程式都會強制大型語言模型以結構化 JSON 回覆:

GenerateContentConfig: &genai.GenerateContentConfig{
   ResponseMIMEType:   "application/json",
   ResponseJsonSchema: fittingSchemaMap(),  // Defines the expected structure
}

確保 Flutter 前端一律收到可剖析的資料,而非任意形式的文字。

目錄代理程式:最簡單的範例

目錄代理程式 (catalog/agent.go) 是系統中最簡單的代理程式,是瞭解 ADK 模式的絕佳起點。

這項工具包含以下兩種功能:

  1. listProducts - 從 YAML 檔案傳回完整產品目錄
  2. getProductImage:從 GCS (或本機備援) 載入產品圖片,並儲存為構件

getProductImage 工具會顯示重要模式:透過構件快取進行多來源載入

// From tools/imagetool.go  simplified
func GetProductImage(ctx tool.Context, args GetProductImageArgs) (GetProductImageResult, error) {
   // First: check if it's already an artifact
   _, err := ctx.Artifacts().Load(ctx, args.ImageName)
   if err == nil {
       return GetProductImageResult{Image: args.ImageName}, nil  // Cache hit!
   }


   // Second: try loading from GCS bucket
   rc, err := client.Bucket(bucket).Object("catalog-assets/images/" + args.ImageName).NewReader(ctx)
   if err != nil {
       // Third: fall back to local filesystem
       localData, _ := os.ReadFile("../flutter_frontend/assets/images/" + args.ImageName)
       ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
       return GetProductImageResult{Image: args.ImageName}, nil
   }


   // Save to artifact cache and return
   ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
   return GetProductImageResult{Image: args.ImageName}, nil
}

這項工具會先嘗試使用構件,然後是 GCS,最後是本機檔案。載入後,圖片會快取為構件,因此後續呼叫會立即執行。

6. 🧪 AI 管道:AI 代理應用實例

現在,我們來看看最先進的兩種代理程式,也就是實際生成圖片和策劃服裝的代理程式。

6.1 試衣間服務人員

檔案:

adk_backend/fittingroom/agent.go

試衣間代理程式是「虛擬試妝」功能背後的引擎。使用者上傳相片並選取產品後,這個代理程式就會生成該使用者穿戴該產品的合成圖片。

fitting_tool - 逐步說明

核心邏輯位於 doFitting 函式中。代理程式呼叫時會發生以下情況:

步驟 1:解決使用者圖片問題

func doFitting(ctx tool.Context, args FittingToolArgs) (FittingToolResult, error) {
   if len(args.Accessories) > 2 {
       args.Accessories = args.Accessories[:2]  // Safety limit: max 2 items
   }


   var userPart *genai.Part
   if strings.HasPrefix(args.UserImage, "gs://") {
       // If we have a GCS URI from a previous fitting, use it directly
       userPart = &genai.Part{FileData: &genai.FileData{
           FileURI:  args.UserImage,
           MIMEType: gcsURIMimeType(args.UserImage),
       }}
   } else {
       // Otherwise, load the image from artifact storage
       userImgResp, err := ctx.Artifacts().Load(ctx, args.UserImage)
       userPart = userImgResp.Part
   }

使用者圖片可來自以下兩種來源:

  • 構件名稱 (例如 upload_abc123_1) - 這是初始上傳內容,由 SaveIncomingBlobs 回呼儲存
  • gs:// URI - 這是先前產生的試裝結果,儲存在 GCS 中,可跨工作階段重複使用

這項雙路徑設計是刻意為之:造型師代理程式稍後生成試穿結果時,會重複使用初始試衣間結果中的 GCS 網址,確保使用者身分在所有服裝中保持一致。

步驟 2:建構多模態提示

   parts := []*genai.Part{
       genai.NewPartFromText(toolInstructions),           // Identity preservation prompt
       genai.NewPartFromText("Reference Person Photo:"),
       userPart,                                          // The user's photo
   }


   for _, acc := range args.Accessories {
       accResp, _ := ctx.Artifacts().Load(ctx, acc)       // Load product image artifact
       parts = append(parts, genai.NewPartFromText("Product Image to Apply:"))
       parts = append(parts, accResp.Part)                // The product photo
   }

toolInstructions (從 tool_instructions.md 內嵌) 至關重要,可讓 Gemini 保留使用者的身分 (臉部、體型、膚色、頭髮),同時只套用服裝項目。如果沒有這項提示工程,模型可能會變更人物外觀。

步驟 3:呼叫 Gemini 生成圖片

   client, _ := genai.NewClient(ctx, &genai.ClientConfig{
       Backend:  genai.BackendVertexAI,                 // Vertex AI endpoint
       Project:  os.Getenv("GOOGLE_CLOUD_PROJECT"),     // From your .env
       Location: "global",                              // Multi-region endpoint
   })


   resp, _ := client.Models.GenerateContent(ctx, "gemini-2.5-flash-image",
       []*genai.Content{genai.NewContentFromParts(parts, "user")},
       &genai.GenerateContentConfig{
           ResponseModalities: []string{"TEXT", "IMAGE"},  // Request both text and image output
           Temperature:        genai.Ptr(float32(0.2)),    // Low temperature for consistency
       })

四個代理程式和圖像生成工具共用單一驗證路徑:Backend: genai.BackendVertexAI,並透過應用程式預設憑證進行驗證。編排模型 (gemini-3.1-pro-previewgemini-3-flash-preview) 和圖片模型 (gemini-2.5-flash-image) 都位於同一個 Vertex AI 端點後方,且同一個 ADC 也授權 Cloud Storage 存取權,因此每次呼叫都使用同一組憑證。

步驟 4:儲存結果

   // Find the image in the response (may contain both text + image parts)
   var genPart *genai.Part
   for _, p := range resp.Candidates[0].Content.Parts {
       if p.InlineData != nil {
           genPart = p
           break
       }
   }


   // Save as an ADK artifact
   artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
   ctx.Artifacts().Save(ctx, artName, genPart)


   // Also upload to GCS for cross-session reuse
   objectName := fmt.Sprintf("generated-fittings/%s.jpg", ctx.InvocationID())
   w := storageClient.Bucket(bucket).Object(objectName).NewWriter(ctx)
   w.Write(genPart.InlineData.Data)
   gcsURI := fmt.Sprintf("gs://%s/%s", bucket, objectName)


   return FittingToolResult{ArtifactName: artName, GCSUrl: gcsURI}, nil

雙重儲存 (構件 + GCS) 是試衣間和造型師之間交接代理程式的關鍵。構件可在目前的工作階段中立即存取,而 GCS URI 則可讓設計師 (在不同的工作階段中執行) 稍後參照同一張圖片。

SaveIncomingBlobs回呼

在代理程式開始推論之前,系統會先執行這項 BeforeAgentCallback,儲存使用者上傳的任何圖片:

func SaveIncomingBlobs(ctx agent.CallbackContext) (*genai.Content, error) {
   for pindex, p := range ctx.UserContent().Parts {
       if p.InlineData != nil {
           aname := fmt.Sprintf("upload_%s_%d", ctx.InvocationID(), pindex)
           ctx.Artifacts().Save(ctx, aname, p)
       }
   }
   return nil, nil  // Return nil to proceed with normal agent execution
}

回呼會傳回 (nil, nil),表示「我已完成前置處理,現在請照常執行代理程式」。如果傳回非空值內容,系統會完全短路代理程式。

6.2 造型師服務專員

檔案:

adk_backend/stylist/agent.go

造型師代理程式是系統中最精密的代理程式,這項功能會提供個人化的服裝建議,並支援透過對話進行反覆修正。

三次回呼:造型師的回憶

造型師會使用三個回呼,在多輪對話中保留脈絡:

回呼 1:

InjectPreviousProducts (BeforeModel)

問題:如果使用者說「顯示其他選項」,LLM 可能會再次建議相同的產品,因為 LLM 本身不會追蹤已建議的產品。

解決方案:每次回應後,產品 ID 都會儲存至工作階段狀態。在下一次呼叫 LLM 之前,這個回呼會讀取這些訊息並插入提示:

func InjectPreviousProducts(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
   prev, err := ctx.State().Get(stateKeyPreviousProducts)  // Read from session state
   if err != nil {
       return nil, nil  // No previous state  first run
   }


   // Append hint to the user's message
   for i := len(req.Contents) - 1; i >= 0; i-- {
       if req.Contents[i].Role == "user" {
           req.Contents[i].Parts = append(req.Contents[i].Parts,
               genai.NewPartFromText(fmt.Sprintf(
                   "IMPORTANT: You previously suggested these products: %s. "+
                   "You MUST pick DIFFERENT complementary products this time.", prev)))
           break
       }
   }
   return nil, nil  // Continue to LLM call
}

回呼 2:

ExtractAndInjectUserImage (BeforeModel)

問題:使用者提供意見回饋 (例如「讓它更隨性一點」) 後,後續訊息不會再顯示使用者的相片。但裝配工具需要這項資訊。

解決方法:在第一個要求中,這個回呼會擷取使用者圖片參照,並儲存至狀態。在後續要求中,系統會重新注入:

func ExtractAndInjectUserImage(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
   var foundImgStr string


   // Search for user image in the latest message
   for i := len(req.Contents) - 1; i >= 0; i-- {
       if req.Contents[i].Role == "user" {
           for _, part := range req.Contents[i].Parts {
               if strings.Contains(part.Text, "User try-on base image") {
                   foundImgStr = part.Text  // Found the GCS URI reference
               }
           }
           break
       }
   }


   if foundImgStr != "" {
       ctx.State().Set(stateKeyUserImageStr, foundImgStr)  // Save for later
   } else {
       // Not in current message  retrieve from state and inject
       val, _ := ctx.State().Get(stateKeyUserImageStr)
       if savedImgStr, ok := val.(string); ok {
           // Inject into the latest user message
           req.Contents[last].Parts = append(req.Contents[last].Parts,
               genai.NewPartFromText("REMINDER: Use this image: " + savedImgStr))
       }
   }
   return nil, nil
}

回呼 3:

SaveSelectedProducts (AfterModel)

LLM 回應服裝建議後,這個回呼會剖析 JSON 來擷取產品 ID,並儲存這些 ID,供下次 InjectPreviousProducts 回呼使用:

func SaveSelectedProducts(ctx agent.CallbackContext, resp *model.LLMResponse, respErr error) (*model.LLMResponse, error) {
   for _, part := range resp.Content.Parts {
       ids := extractProductIDs(part.Text)  // Parse JSON  extract product IDs
       if len(ids) > 0 {
           data, _ := json.Marshal(ids)
           ctx.State().Set(stateKeyPreviousProducts, string(data))  // Save to state
       }
   }
   return nil, nil  // Don't modify the response
}

這三個回呼會共同建立意見回饋迴圈:

Request 1:  User sends styling request + user image
            ExtractAndInjectUserImage SAVES image to state
            LLM generates 3 outfits
            SaveSelectedProducts SAVES product IDs to state


Request 2:  User says "make it more casual"
            ExtractAndInjectUserImage INJECTS saved image into prompt
            InjectPreviousProducts INJECTS "don't reuse these IDs"
            LLM generates 3 NEW outfits
            SaveSelectedProducts UPDATES product IDs in state

6.3 根層級代理

檔案:

adk_backend/rootagent/agent.go

最簡單的代理程式,只有 31 行:

func NewRootAgent(project string, fittingAgent, catalogAgent, stylistAgent agent.Agent) (agent.Agent, error) {
   m, _ := gemini.NewModel(ctx, "gemini-3-flash-preview", &genai.ClientConfig{
       Backend:  genai.BackendVertexAI,
       Project:  project,
       Location: "global",
   })


   return llmagent.New(llmagent.Config{
       Name:        "root_agent",
       Model:       m,
       Description: "A root agent that delegates to other agents",
       Instruction: "You are a helpful shopping assistant. If the user asks about fitting " +
           "items or generating images, delegate to the fitting room agent. If the user " +
           "asks about products, delegate to the catalog agent. If the user asks for " +
           "styling advice, delegate to the stylist agent.",
       SubAgents: []agent.Agent{fittingAgent, catalogAgent, stylistAgent},
   })
}

由於路徑決策很簡單,LLM 只要讀取使用者的意圖並選擇正確的子代理即可,因此會使用 gemini-3-flash-preview (最快的模型)。無須任何工具,SubAgents 會自動處理委派作業。

7. 📱 Flutter 前端架構

Flutter 前端是功能齊全的零售購物應用程式。AI 功能位於 flutter_frontend/lib/workshop_tasks/,與 core_app/ 中的預先建構購物體驗分開。

MVVM 模式

應用程式採用 Model-View-ViewModel 架構,並使用 Provider 套件:

┌──────────────────┐    ┌────────────────────┐    ┌──────────────────┐
  View (Widget)   │◀───│ ViewModel (Provider)│◀───│  Service (HTTP)  
                                                                 
  Renders UI           Holds state             Makes API calls
  User gestures  │───▶│  Business logic    │───▶│  Parses response
  Listens for          notifyListeners()       Returns data   
   state changes                                                 
└──────────────────┘    └────────────────────┘    └──────────────────┘

每個層都有明確的角色:

  • 模型:資料類別 (例如 ProductOutfitStyleRequest) 和列舉 (例如 TryOnState)
  • ViewModel (ChangeNotifier):保留目前狀態,並透過 notifyListeners() 將變更內容廣播至 UI
  • 檢視區塊 (小工具):使用 context.watch() 訂閱 ViewModel,並在狀態變更時重建
  • 服務:對 ADK 後端發出 HTTP 呼叫,並傳回型別資料

服務層

服務定義為抽象介面,並採用 ADK 專屬實作方式:

// Abstract interface  defines WHAT the service does
abstract class TryItOnService {
 Future<(Uint8List?, String?)> generateTryOnImage(
   Uint8List userImageBytes,
   Uint8List productImageBytes,
 );
}


// Concrete implementation  defines HOW (via ADK REST API)
class AdkFittingRoomService implements TryItOnService { ... }

這種分離方式代表您可以將 ADK 後端換成 Firebase AI、模擬服務或任何其他實作項目,而不必變更應用程式的其餘部分。

3 步驟 API 模式

AdkFittingRoomServiceAdkStylingService 都遵循相同的模式與 ADK 後端通訊:

步驟 1:建立工作階段

Future<String> _createSession() async {
 final url = Uri.parse(
   '$_baseUrl/apps/${Uri.encodeComponent(_appName)}/users/$_userId/sessions');
 final response = await _client.post(url,
   headers: {'Content-Type': 'application/json'},
   body: jsonEncode({}));
 final body = jsonDecode(response.body) as Map<String, dynamic>;
 return body['id'] as String;  // Returns the session ID
}

步驟 2:執行代理

Future<(String?, String?)> _runAgent({required String sessionId, ...}) async {
 final requestBody = jsonEncode({
   'appName': _appName,
   'userId': _userId,
   'sessionId': sessionId,
   'newMessage': {
     'role': 'user',
     'parts': [
       {'text': 'Generate a virtual try-on...'},
       {'inlineData': {'mimeType': 'image/jpeg', 'data': base64Encode(userImageBytes)}},
       {'inlineData': {'mimeType': 'image/png', 'data': base64Encode(productImageBytes)}},
     ],
   },
 });
 final response = await _client.post(Uri.parse('$_baseUrl/run'), body: requestBody);
 // Parse response events for artifact name and GCS URL...
}

步驟 3:擷取構件

Future<Uint8List?> _loadArtifact({required String sessionId, required String artifactName}) async {
 final url = Uri.parse(
   '$_baseUrl/apps/$_appName/users/$_userId/sessions/$sessionId/artifacts/$artifactName');
 final response = await _client.get(url);
 final part = jsonDecode(response.body) as Map<String, dynamic>;
 final data = part['inlineData']['data'] as String;
 return base64Decode(data);  // Returns raw image bytes
}

設計上的重大差異:試衣間服務會為每個要求建立新工作階段 (每次都會呼叫 _createSession()),而造型服務會重複使用相同的工作階段 (_sessionId ??= await _createSession()),以進行多輪對話。

狀態管理:TryItOnProvider

檔案:

workshop_tasks/step_1_try_it_on/providers/try_it_on_provider.dart

TryItOnProvider 會管理整個試穿流程。這個列舉會做為狀態機:TryOnState

enum TryOnState { initial, imagePicked, generating, success, error }


class TryItOnProvider with ChangeNotifier {
 TryOnState _state = TryOnState.initial;
 Uint8List? _userImageBytes;
 Uint8List? _generatedImage;
 String? _errorMessage;

私有狀態轉換可確保一致性,您絕不會在更新狀態時,未一併清除過時資料並通知 UI:

 void _setGenerating() {
   _state = TryOnState.generating;
   _errorMessage = null;            // Clear any previous error
   _wasLastGenerationCached = false;
   notifyListeners();               // Tell the UI to rebuild
 }


 void _setSuccess(Uint8List image, {bool isCached = false}) {
   _generatedImage = image;
   _errorMessage = null;
   _wasLastGenerationCached = isCached;
   _state = TryOnState.success;
   notifyListeners();
 }

主要生成方法會將所有內容綁在一起:

 Future<String?> generateTryOnImage(String productImagePath) async {
   final userImageBytes = _userImageBytes;
   if (userImageBytes == null) {
     _setError('No image selected.');
     return _errorMessage;
   }


   // Check local cache first
   final cachedImage = ImageUtils.getCachedImage(_sessionCache, userImageBytes, productUint8List);
   if (cachedImage != null) {
     _setSuccess(cachedImage, isCached: true);
     return null;
   }


   _setGenerating();  // Triggers loading state in UI


   try {
     final (generatedBytes, gcsUrl) = await _aiService.generateTryOnImage(
       userImageBytes, productUint8List);
     if (generatedBytes != null) {
       _fittingGcsUrl = gcsUrl;     // Save for the stylist agent later
       _setSuccess(generatedBytes);
     }
   } catch (e) {
     _setError(e.toString());
   }
   return _state == TryOnState.success ? null : _errorMessage;
 }

UI:將畫面當做狀態路由器

檔案:

workshop_tasks/step_1_try_it_on/ui/2_try_it_on_screen.dart

試穿畫面會使用 Dart 3 的模式比對功能搭配 AnimatedSwitcher,根據供應商的狀態在子畫面之間進行路徑設定:

class TryItOnScreen extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   final tryOnProvider = context.watch<TryItOnProvider>();


   return ScaffoldWithBackgroundNoise(
     appBar: const TryOnAppBar(),
     body: AnimatedSwitcher(
       duration: AppDurations.medium,
       child: switch (tryOnProvider.state) {
         TryOnState.initial || TryOnState.error => const ChooseImageScreen(),
         TryOnState.imagePicked || TryOnState.generating => LoadingScreen(
           userImage: tryOnProvider.userImageBytes),
         TryOnState.success => const FittingRoomScreen(),
       },
     ),
   );
 }
}

context.watch()訂閱供應商。每當呼叫 notifyListeners() 時,這個小工具就會重建,而 AnimatedSwitcher 會在畫面之間順暢轉換。沒有 Navigator.push,畫面內容會根據狀態列舉在適當位置變更。

專員式交接:試衣間 → 造型師

最有趣的使用者體驗模式是應用程式如何將內容從試衣間代理傳遞至造型師代理。

5_fitting_room.dart 中,試穿圖片生成後,「幫我搭配」按鈕會開啟表單。使用者提交:

// From 1_style_me_form_sheet.dart
Navigator.pop(context, StyleRequest(
 location: _locationController.text.trim(),
 occasion: _occasionController.text.trim(),
 notes: _notesController.text.trim(),
 gcsUserImageUrl: provider.fittingGcsUrl,          // GCS URI from fitting result
 userImageData: provider.fittingGcsUrl == null
     ? provider.userImageBytes : null,              // Fallback to raw bytes
 selectedProductId: provider.selectedProduct?.id,   // Product they already tried on
 selectedProductTitle: provider.selectedProduct?.title,
));

StyleRequest 組合包內含造型師所需的一切:

  • 地點和場合:設定樣式的文字情境
  • GCS 使用者圖片網址,讓造型師可以重複使用完全相同的使用者代表
  • 選定產品:造型師會將這項產品納入每套服裝

這就是代理式交接,可將多模態情境從一個 AI 代理無縫轉移至另一個,使用者只會看到簡單的表單。

樣式設定流程:StylingProvider

檔案:

workshop_tasks/step_2_style_me/providers/styling_provider.dart

StylingProviderTryItOnProvider 簡單,因為它會將大部分複雜性委派給後端:

class StylingProvider with ChangeNotifier {
 StylingState _state = StylingState.initial;
 List<Outfit> _outfits = [];


 Future<bool> getStyleSuggestions(StyleRequest request) async {
   if (_state == StylingState.loading) return false;  // Prevent spam
   _currentRequest = request;
   _setLoading();
   try {
     final suggestions = await _stylingService.getStyleSuggestions(request);
     _setSuccess(suggestions);
     return true;
   } catch (e) {
     _setError('Something went wrong.');
   }
   return false;
 }


 // Multi-turn refinement  uses the same session
 Future<bool> refineWithFeedback(String feedback) async {
   if (_state == StylingState.loading) return false;
   _setLoading();
   try {
     final suggestions = await _stylingService.refineWithFeedback(feedback);
     _setSuccess(suggestions);
     return true;
   } catch (e) {
     _setError('Something went wrong.');
   }
   return false;
 }
}

refineWithFeedback 方法會將純文字訊息傳送至相同的工作階段,後端的 InjectPreviousProductsExtractAndInjectUserImage 回呼會自動處理所有內容管理作業。

8. 🚀 在本機執行應用程式

為確保 Cloud Shell 體驗順暢,Go 後端會從相同通訊埠 (8080) 提供已編譯的 Flutter 網頁應用程式。一個程序、一個預覽網址,不必擔心跨來源問題,也不必編輯設定檔。

開始前 - 檢查 ADC 是否正常運作

後端需要應用程式預設憑證才能呼叫 Vertex AI。如果您已在這個 Cloud Shell 工作階段和這個 Google 帳戶中完成專案設定的步驟 7,則可略過這個步驟。如果你休息一段時間後重返 YouTube、切換帳戶或不確定,請花 5 秒確認:

gcloud auth application-default print-access-token | head -c 20 && echo "..."

如果列印出約 20 個字元的權杖,就表示設定完成。如果發生錯誤,請重新執行專案設定的步驟 7

gcloud auth application-default login
gcloud auth application-default set-quota-project $(gcloud config get-value project)

您將使用兩個 Cloud Shell 終端機

  • 終端機 A:持續執行後端 (./run.sh)。請保持開啟狀態。
  • 終端機 B:執行一次 Flutter 網頁建構 (flutter build web)。完成後即會結束。

順序不重要,你可以先從任一項開始。但為了提供最乾淨的首次執行體驗,請先建構 Flutter,這樣後端就能在啟動時提供 UI。

1. 終端機 B - 建構 Flutter Web Bundle (一次性)

開啟新的 Cloud Shell 分頁 (終端機面板頂端的 +),然後執行下列操作:

cd ~/fashion_app_demo/flutter_frontend
flutter pub get
flutter build web

這會產生 flutter_frontend/build/web/,也就是靜態檔案 (HTML、JS、資產) 的目錄,並在完成時結束。後端一看到目錄存在,就會立即提供這些檔案。

2. 終端機 A - 啟動後端 (長時間執行)

在原始 Cloud Shell 終端機中:

cd ~/fashion_app_demo/adk_backend
./run.sh

您應該會看到類似下方的內容:

Serving Flutter web build from ../flutter_frontend/build/web

讓這個終端機保持運作:只要 run.sh 保持運作,後端就會持續運作。如要停止,請按下 Ctrl+C

伺服器會公開通訊埠 8080 上的所有內容:

  • / - Flutter 網頁應用程式 (購物 UI)
  • /api/ - ADK REST 端點 (由 Flutter 應用程式呼叫)
  • ADK 開發 UI - 如果沒有 Flutter 建構作業,也會顯示在 /,方便直接偵錯代理

3. 開啟網頁預覽

  1. 在 Cloud Shell 中,依序按一下「網頁預覽」圖示 (右上角) 和「透過以下通訊埠預覽:8080」
  2. Flutter 購物應用程式會在新分頁中載入
  3. 瀏覽產品目錄並選取項目
  4. 輕觸人形圖示 (👤) 即可開始試穿流程
  5. 上傳相片,讓 AI 生成試穿模擬圖
  6. 輕觸「幫我搭配」即可取得服裝建議
  7. 輸入後續意見回饋,例如「讓內容更輕鬆隨性」— 同一工作階段的修正

9. ☁️ 部署至 Cloud Run

將 Flutter 建構作業併入後端

Cloud Run 容器會從單一映像檔運送 API 和 UI。將 Flutter 網頁建構作業複製到 adk_backend/flutter_web/,這是 Go 伺服器在選擇要提供哪個 UI 時,檢查的第一個路徑:

cd ~/fashion_app_demo/flutter_frontend
flutter build web
rm -rf ../adk_backend/flutter_web
cp -r build/web ../adk_backend/flutter_web

(如果您已在本機進行疊代,可能已在「在本機執行」步驟中取得 build/web。重新執行 flutter build web 仍可正常運作)。

部署後端 (提供 API + UI)

cd ~/fashion_app_demo/adk_backend

gcloud run deploy fashion-app-backend \
 --source . \
 --region us-central1 \
 --allow-unauthenticated \
 --set-env-vars "GOOGLE_CLOUD_PROJECT=$PROJECT_ID,GCS_BUCKET=fashion-app-$PROJECT_ID" \
 --memory 1Gi \
 --cpu 2 \
 --timeout 300s \
 --min-instances 0 \
 --max-instances 3

部署完成後,您會取得類似 https://fashion-app-backend-xyz-uc.a.run.app服務網址。在瀏覽器中開啟,Flutter 購物應用程式會從 / 載入,而 API 呼叫會傳送至相同主機上的 /api/不需要編輯前端設定,也不會傳遞 API 金鑰。

驗證部署作業

在瀏覽器中開啟 Cloud Run 網址,並完成整個流程:

  1. 瀏覽 → 選取產品
  2. 試穿 → 上傳相片 → 查看 AI 生成圖像
  3. Style Me → 填寫地點/場合 → 查看精選服裝
  4. 意見回饋 → 輸入「休閒一點」→ 查看更新後的服裝
  5. 加入購物袋 → 完成購物流程

10. 🎉 結語

您建構的內容

您已透過下列方式,探索完整的 AI 輔助零售體驗:

  • 多代理後端,由 4 個專屬代理協同運作
  • 虛擬試衣間,可生成個人化試穿圖片
  • AI 時尚顧問:可透過對話搭配服裝並加以調整
  • ✅ 連結至代理程式後端的跨平台 Flutter 應用程式
  • Cloud Run 部署作業,提供可擴充的無伺服器主機

核心概念

概念

看到該內容的位置

ADK 多代理自動化調度管理

根代理會將要求轉送給試衣間、目錄和造型師代理

多模態版 Gemini 圖像生成

fitting_tool 將使用者相片與產品圖片合併

對話式 AI 的工作階段狀態

造型師重複使用工作階段,以取得反覆的意見回饋

二進位資料的構件儲存空間

將圖片儲存空間與文字回覆分開

中介軟體邏輯的回呼

SaveIncomingBlobsInjectPreviousProductsSaveSelectedProducts

Flutter 中的 MVVM + Provider

TryItOnProviderStylingProvider (含ChangeNotifier)

代理接手

StyleRequest 在代理程式之間傳遞多模態內容

後續步驟

  • 🎨 自訂代理程式提示:編輯 instructions.md 即可變更造型師的個性
  • 🛍️ 新增更多產品:使用新項目更新 catalog.yaml
  • 📱 考量行動裝置的需求:執行 flutter build iosflutter build apk
  • 🔄 新增持續性工作階段 - 將 InMemoryService 替換為以資料庫為基礎的實作方式
  • 🔒 新增驗證 - 使用 IAM 保護 Cloud Run 端點

資源