👗 使用 Flutter、ADK Go 和 Gemini 构建虚拟试衣间和 AI 造型师

1. 简介

构建内容

在此 Codelab 中,您将扮演开发者的角色,构建 Fashion App,这是一款面向虚构零售品牌的 Flutter 购物应用。您的任务:添加两项可改变在线购物体验的 AI 赋能功能。

  1. 虚拟试衣间 - 用户上传自己的照片,选择一件服装,然后查看 AI 生成的自己穿着该服装的图片。
  2. AI Stylist - AI 智能体根据用户的位置、场合和风格偏好,精心挑选完整的服装搭配建议,用户可以通过对话对其进行优化。

这个想法很简单:当人们在试衣间试穿衣服时,他们更有可能购买这些衣服。但在线呢?你只是在瞎猜。该项目利用 AI 弥合了这一差距。

架构概览

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

核心技术

组件

技术

用途

代理框架

适用于 Go 的 ADK(智能体开发套件)

多代理编排、会话、制品

智能体推理(专业版)

Gemini 3 Pro 预览版

为试衣间和造型师代理提供支持

Agent Reasoning (Flash)

Gemini 3 Flash 预览版

为根代理和目录代理(轻量级路由/查找)提供支持

图片生成

Gemini 2.5 Flash 图片

生成试穿图片和穿搭图片

前端

Flutter (Dart)

跨平台应用(Web、iOS、Android)

存储

Google Cloud Storage

存储商品图片和生成的制品

托管

Cloud Run

无服务器容器部署

2. 📦 前提条件和 Cloud Shell 设置

1. 打开 Cloud Shell Editor

👉 在浏览器中打开 Cloud Shell 编辑器

如果终端未显示在屏幕底部:

  • 依次点击查看终端

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(免费试用期已结束时通常会显示此状态),则表示账号已关闭,实际上不会支付任何费用 - 请先跳到下面的问题排查部分,然后再继续。

复制 OPEN: True 账号的 ACCOUNT_ID(看起来像 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 - 通过 Vertex AI 调用 Gemini 的图片生成功能fitting_tool

storage.googleapis.com

Cloud Storage - 存储产品目录图片和生成的试穿结果

run.googleapis.com

Cloud Run - 将后端托管为无服务器容器

cloudbuild.googleapis.com

Cloud Build - 从源代码构建 Docker 映像

artifactregistry.googleapis.com

Artifact Registry - 存储构建的 Docker 映像

4. 创建 GCS 存储分区

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. 🏗️ 架构概览

现在环境已准备就绪,在查看代码之前,我们先来了解一下系统的工作原理。

四智能体系统

后端使用 ADK (智能体开发套件) for Go 构建为多智能体系统。四个智能体协同工作,每个智能体都承担特定的责任:

                   ┌──────────────┐
                    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 
                                  └────────────────┘

代理

模型

角色

根代理

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(智能体开发套件)深入探究

什么是 ADK?

智能体开发套件 (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 的运行时服务,其中最重要的是 artifact

// 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},
}

如果回调返回非 null 响应,则会跳过默认行为。例如,返回缓存响应的 BeforeModelCallback 会完全跳过实际的 LLM 调用。

JSON 架构强制执行

试衣间代理和造型师代理都会强制 LLM 以结构化 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 流水线:智能体的实际应用

现在,我们来了解一下两种最复杂的智能体,它们可以生成图片并搭配服装。

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),回调会发出信号:“我已完成预处理,现在正常运行代理。”如果它返回了非 nil 内容,则会完全短路代理。

6.2 造型师代理

文件:

adk_backend/stylist/agent.go

样式代理是系统中最为复杂的代理。它会精心挑选个性化的服装推荐,并通过对话支持迭代优化。

三次回调 - 发型师的记忆

样式设计器使用三个回调来在多轮对话中保持上下文:

回调 1:

InjectPreviousProducts (BeforeModel)

问题:如果用户说“向我展示其他选项”,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},
   })
}

它使用 gemini-3-flash-preview(最快的模型),因为路由决策很简单 - LLM 只需要读取用户意图并选择正确的分代理。无需任何工具;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() 向界面广播更改
  • 视图(微件):使用 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;

私有状态转换可确保一致性 - 您绝不会在不清除过时数据和通知界面的情况下更新状态:

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

界面:作为状态路由器的屏幕

文件:

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() 时,此 widget 都会重建,并且 AnimatedSwitcher 会在屏幕之间平滑过渡。没有 Navigator.push - 屏幕内容会根据状态枚举就地更改。

智能体交接:试衣间 → 造型师

最有趣的用户体验模式是应用如何将上下文从试衣间智能体传递给造型师智能体。

5_fitting_room.dart 中,试穿图片生成后,“Style Me”按钮会打开一个表单。当用户提交以下内容时:

// 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 智能体无缝转移到另一个 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 Web 应用。一个流程、一个预览网址,无需担心跨源问题,无需修改配置文件。

开始之前 - 对 ADC 进行健全性检查

后端需要应用默认凭证才能调用 Vertex AI。如果您已在此 Cloud Shell 会话和此 Google 账号中完成项目设置的第 7 步,则可以继续。如果您在休息一段时间后重新开始使用 Google Ads,或者切换了账号,或者不确定自己是否已登录,请花 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 Web build (flutter build web)。完成后退出。

顺序无关紧要,您可以先执行任一操作。不过,为了获得最简洁的首次运行体验,请先构建 Flutter,以便后端在启动时即可提供界面。

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 Web 应用(购物界面)
  • /api/ - ADK REST 端点(由 Flutter 应用调用)
  • ADK 开发者界面 - 如果没有 Flutter build,则位于 /;可用于直接调试智能体

3. 打开网页预览

  1. 在 Cloud Shell 中,依次点击网页预览图标(右上角)→ 在端口 8080 上预览
  2. Flutter 购物应用在新标签页中加载
  3. 浏览商品目录并选择商品
  4. 点按人形图标 (👤) 以开始试穿流程
  5. 上传照片,然后观看 AI 生成的试穿效果图
  6. 点按“为我搭配”即可获取服装搭配建议
  7. 输入后续反馈,例如“让它更随意一些” - 同会话细化

9. ☁️ 部署到 Cloud Run

将 Flutter build 捆绑到后端中

Cloud Run 容器通过一个映像同时提供 API 和界面。将 Flutter Web build 复制到 adk_backend/flutter_web/ 中,这是 Go 服务器在选择要提供的界面时检查的第一个路径:

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 + 界面)

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 密钥。

验证 Deployment

在浏览器中打开 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 端点

资源