πŸ‘Ÿ

Building Multimodal
AI Agents with Java

SneakerLens β€” an AI sneaker authenticator built live

Java Meetup Β· June 2026

Agenda

What we'll cover in the next hour

  • 01
    IntroWhy sneaker authentication, why this matters
  • 02
    Agentic Β· Multimodal Β· HybridWhat these words actually mean, when to use each, when to avoid
  • 03
    Demo architectureThe 3-step pipeline: local guardrail β†’ identify β†’ authenticate
  • 04
    Code walkthroughSwitching to VS Code β€” the real implementation
  • 05
    Live demoUpload real sneakers, watch the agent work
  • 06
    Lessons learnedThe real bugs we hit building this β€” more useful than a clean happy path
  • 07
    Q&A
Intro

The problem: sneaker fakes are everywhere

  • β†’
    Counterfeit sneaker resale is a multi-billion dollar problem globally β€” and growing fast in the Indian resale market
  • β†’
    Authentication today requires human experts who look at stitching, fonts, materials β€” knowledge that took years to build
  • β†’
    What if a multimodal LLM could do a first-pass check, instantly, from a phone photo?
  • β†’
    That's SneakerLens β€” and building it taught me more about agentic system design than any tutorial
Intro

A familiar scenario: buying sneakers off OLX

Manual sneaker inspection β€” squinting at photos, checking forums, gut feeling

😬 Today β€” manual inspection

  • Zoom into seller photos, squint at the logo font
  • Cross-check a forum post or "spot the fake" video
  • Result: gut feeling β€” inconsistent, unexplainable
Agent pipeline β€” local check, identify, re-ground, structured verdict

βœ… As an agent β€” same photos

  • Step 0: local model confirms it's actually footwear
  • Step 1 β†’ 2: identify the model, then re-ground on that model's specific tells
  • Result: structured verdict + evidence β€” every time, the same way

Notice the shape of Step 1 β†’ Step 2: identify first, then use that identity to know what to look for. That two-step reasoning is exactly what "agentic" means β€” and it's what we're about to define properly.

Concepts

What actually makes something "agentic"?

It's not just "calling an LLM"

A single prompt β†’ single response is not an agent. It's a function call.

  • βœ“
    An agent exists when one model's output changes what happens next β€” a different prompt, a different tool, a different path
  • βœ“
    In SneakerLens: Step 1 identifies "Nike Air Jordan 1" β†’ that exact string gets woven into Step 2's system prompt, changing what Step 2 looks for
  • βœ“
    That's the whole trick. No framework magic β€” just code-controlled branching driven by a prior LLM call's output
Concepts

Three tiers β€” only escalate when you actually need to

1
Single LLM call
You know exactly what to ask, and one request gives you everything you need β€” no follow-up decisions required.
e.g. classify, summarize, extract a field, answer a question
↓ escalate only if step 1 isn't enough
2
Workflow (code-orchestrated)
Multiple steps are needed, but you already know the order and logic ahead of time β€” the model doesn't need to "decide" anything about the flow.
e.g. validate input β†’ call LLM β†’ save to DB β†’ send a notification
↓ escalate only if the next step's logic genuinely depends on a prior LLM result
3
Agent
One step's output changes what you ask in the next step β€” not just what data you pass, but the actual question/prompt itself.
SneakerLens: Step 1 says "Air Jordan 1" β†’ that string is woven into Step 2's system prompt, changing what it looks for

Most "agent" demos online are actually Tier 1 or 2 wearing a fancier name. The real signal is whether the question changes, not just the data.

Concepts

Before reaching for Tier 3, check all four

🧩 Complexity
Is the task genuinely multi-step and hard to fully specify in advance? ("Turn this design doc into a PR" β€” yes. "Extract the title from this PDF" β€” no, that's Tier 1.)
πŸ’° Value
Does the outcome justify the extra cost and latency? Agents are slower and pricier than a single call β€” that has to be worth it.
🎯 Viability
Is the model actually capable at this specific task type? Sneaker authentication leans on strong vision + domain knowledge β€” Gemini can do it. Not every task is a good fit yet.
πŸ›Ÿ Cost of error
Can mistakes be caught and recovered from? Tests, human review, rollback. If a wrong answer is expensive and silent, that's a reason to stay simpler.

If the answer to any one of these is "no," stay at a simpler tier β€” a workflow or a single call will serve you better than an agent.

Concepts

Multimodal: not just text in, text out

What it means here

Gemini 2.5 Flash takes images + text in the same request. No separate OCR step, no separate vision model glued on with string parsing.

We send up to 4 photos (top / side / sole / label) as Part.fromBytes() alongside the prompt text β€” one request, native understanding of both.

Why it matters for this use case

Authentication is inherently visual: stitching density, font kerning on logos, sole texture β€” things a human expert looks at, not reads.

A text-only LLM literally cannot do this task. Multimodal isn't a nice-to-have here β€” it's the entire premise.

Concepts

Hybrid: local model + cloud LLM

  • β†’
    The idea: not every check needs a $$ multimodal LLM call. A cheap, fast, local model can pre-filter before the expensive call happens
  • β†’
    In SneakerLens: a local ResNet18 (ImageNet) classifier checks "is this even a shoe?" before Gemini is called at all
  • β†’
    Cost asymmetry: local inference β‰ˆ free + ~0.2s. A rejected Gemini call still would've cost tokens + 5-10s of latency

When to reach for this pattern

Whenever a cheap signal can reject obviously-bad input before your expensive step runs: image quality checks, language detection, profanity filters, "is this even on-topic" gates.

Concepts

When to avoid the hybrid pattern

Skip it when…

  • Traffic volume is low β€” the engineering cost of running a second model outweighs savings
  • The "cheap" model needs constant retraining/tuning to stay accurate β€” now you have two ML systems to maintain
  • Latency budget doesn't allow a sequential local-then-cloud hop

Use it when…

  • A pretrained, off-the-shelf model already solves your filter (like ImageNet for "is this a shoe")
  • You expect meaningful volume of bad/abusive input
  • Cost-per-call on the expensive step is high enough to justify the guardrail
Architecture

SneakerLens: the full pipeline

Input
πŸ“· 1–4 Photos
β†’
Step 0 Β· Local
ResNet18
"Is this a shoe?" Β· ~0.2s Β· free
β†’
Step 1 Β· Gemini
Identify
Brand, model, colorway, year
β†’
Step 2 Β· Gemini
Authenticate
Verdict, condition, resale (β‚Ή)

Step 1's output (e.g. "Air Jordan 1") is woven into Step 2's system prompt β€” that's the agentic part.

Architecture

The Java stack, end to end

🌱 Spring Boot 3.4

Web MVC + Thymeleaf for server-rendered UI. JPA + MariaDB for users & analysis history. HttpSession for auth.

πŸ€– Gemini Calls

com.google.genai SDK directly (API key, not Vertex). spring-ai-model's BeanOutputConverter for structured JSON output.

🧠 Local Model

DJL (Deep Java Library) + PyTorch engine running a pretrained ResNet18 β€” pure Java, no Python sidecar process.

πŸ’» Switch to VS Code
πŸ“¦

The domain models

Records as the contract Gemini's JSON has to match β€” @JsonPropertyDescription becomes the schema sent to the model

model/SneakerIdentity.java Β· model/AuthenticityVerdict.java
πŸ’» Switch to VS Code
✍️

The dynamic prompt

This is where the "agent" actually happens β€” Step 1's identity gets formatted directly into Step 2's system prompt string

service/PromptBuilder.java
πŸ’» Switch to VS Code
πŸ”—

The two-step chain

Guardrail β†’ Step 1 β†’ Step 2. Plus the retry-with-backoff wrapper for Gemini's transient 503s

service/SneakerAgentService.java
πŸ’» Switch to VS Code
πŸ›‘οΈ

The local guardrail

DJL Criteria builder loading a pretrained ResNet18 β€” runs once at startup, predicts per-upload at request time

service/ShoeGuardrailService.java
🎬 Live Demo

Let's break it on stage

Login β†’ upload real sneaker photos β†’ watch Step 0 / 1 / 2 run live β†’ check the admin dashboard

Lessons Learned

Bug #1 β€” Gemini returned an array, not an object

What happened

Uploaded photos contained two visually distinct sneakers. Gemini "helpfully" described both β€” as a JSON array of two identity objects. Our schema expected exactly one. Jackson threw MismatchedInputException and the whole analysis crashed.

Fix: explicit prompt instruction ("always one object, never an array") + defensive code that unwraps an array and takes the first element rather than crashing.

Lessons Learned

Bug #2 β€” refresh kept re-running the whole analysis

What happened

The POST handler returned the result view directly. Browser's last action stayed a POST β€” hitting refresh (or even our own "reload" button) silently resubmitted the form, re-running both Gemini calls.

Fix: classic POST β†’ Redirect β†’ GET. Then went further: AJAX fetch() submission so the page never navigates at all β€” uploaded photos survive across errors and re-analysis too.

Lessons Learned

Bug #3 β€” the model underneath you can just... change

What happened

gemini-2.0-flash started returning 404 β€” model no longer available mid-build, with no code change on our side. A different model also briefly hit a free-tier quota of limit: 0.

This isn't a Gemini-specific quirk β€” it's the industry norm. OpenAI deprecates and retires model snapshots constantly (gpt-4-0314, gpt-4-32k, multiple preview tags gone within months). Anthropic and Google do the same on a slower cadence.

Fix: moved to gemini-2.5-flash, added retry-with-exponential-backoff for transient 503/429s, and β€” the real lesson β€” never hardcode a model ID as a fact you can forget about. Treat it as a config value you'll revisit, the same way you'd treat a third-party API version string.

Cost & Economics

What does one analysis actually cost?

Step 0 Β· Local
β‚Ή0

Runs on your own CPU. No API cost, ~0.2-0.7s.

Step 1 Β· Identify
~1.5-3K

tokens (input-heavy β€” images dominate the cost)

Step 2 Β· Authenticate
~2-4K

tokens (more output β€” evidence, verdict, resale)

Shown live in the app's own "Agent Token Usage" panel β€” built so the audience can see the real cost, not a guess.

Cost & Economics

How does an image become "tokens"?

  • β†’
    Gemini tiles each image into fixed chunks β€” roughly 258 tokens per tile for images ≀384Γ—384; bigger photos get split into multiple tiles. A typical phone photo can run 1-2 tiles.
  • β†’
    Today, SneakerLens sends raw bytes inline via Part.fromBytes() β€” JSON over HTTP requires this to travel as base64 on the wire (~33% size inflation over raw bytes).
  • β†’
    The expensive mistake: we send all 4 photos in Step 1, then send the same 4 photos again in Step 2 β€” full image-token cost paid twice for identical bytes.

The fix we didn't ship (yet)

Gemini's Files API lets you upload media once, get back a file reference, and pass that reference into multiple generateContent calls (valid 48h) β€” pay the upload cost once, not per step.

Cost & Economics

Inline bytes vs. URI reference β€” does it matter?

ApproachHow it worksTradeoff
Inline base64
(what we do today)
Image bytes embedded directly in the JSON request bodySimple, but re-sent on every call β€” bigger payloads, more latency, no reuse
Files API reference
(Gemini Developer API)
Upload once β†’ get a file:// handle β†’ reference it in N callsUpload cost paid once across our 2-step chain; smaller request bodies
GCS URI
(Vertex AI only)
Point Gemini at gs://bucket/photo.jpg directlyNo re-upload at all if the file already lives in GCS; needs Vertex AI, not the plain API key
S3 URINot natively supported by GeminiGoogle's stack only understands GCS β€” an S3 file has to be copied/proxied first

For a 2-step agent chain reusing the same photos, Files API is the easy win β€” it's a same-vendor, same-API-key change.

Going to Production

What this looks like as a real GCP deployment

🌐 Browser / Mobile
↓ TLS 1.3
Cloud Load Balancer
TLS termination Β· Cloud Armor (WAF/DDoS)
↓ HTTPS
Cloud Run
Spring Boot container Β· autoscaled Β· stateless instances
↓
Cloud SQL
MySQL/MariaDB Β· users + analyses
Memorystore (Redis)
Spring Session store Β· replaces in-memory HttpSession
Cloud Storage (GCS)
Uploaded sneaker photos
Secret Manager
Gemini API key Β· DB credentials
↓ HTTPS
πŸ€– Gemini API
External call, over the public internet

Key change from today's demo: Redis-backed sessions β€” our current HttpSession lives in one JVM's memory and breaks the moment Cloud Run scales past 1 instance.

Going to Production

Encryption: at rest vs. in transit

πŸ”’ In transit

Data moving between systems β€” browser↔LB, LB↔Cloud Run, Cloud Run↔Cloud SQL, Cloud Run↔Gemini.

  • TLS everywhere, no exceptions β€” including internal hops
  • Cloud SQL: enforce SSL or use the Cloud SQL Auth Proxy (mTLS, no manual cert management)
  • Gemini calls already go over HTTPS by default β€” nothing extra to configure

πŸ—„οΈ At rest

Data sitting still β€” on disk in Cloud SQL, objects in GCS, Redis snapshots.

  • Google-managed encryption is on by default for Cloud SQL, GCS, Memorystore β€” no extra setup
  • Stricter compliance (e.g. PII, finance)? Use CMEK β€” Customer-Managed Encryption Keys via Cloud KMS, so you control key rotation/revocation
  • Sneaker photos are low-sensitivity, but user phone numbers in users table are exactly the kind of field CMEK is meant for

Rule of thumb: in transit is about who can intercept; at rest is about who can read the disk if they get physical/storage-level access.

Going to Production

What if the data going to the LLM is encrypted?

The hard constraint: LLMs need plaintext

There's no shortcut here β€” Gemini (or any model) cannot reason over ciphertext. Homomorphic encryption + LLM inference is still research, not something you ship. If the model needs to "see" the data, it has to be decrypted first.

  • β†’
    So where does decryption happen? Inside Cloud Run, in memory, right before the Gemini call β€” using a key pulled from Cloud KMS / Secret Manager. Never decrypt earlier, never persist the plaintext, never log it.
  • β†’
    That makes the LLM call itself a deliberate decryption boundary β€” not a side effect you discover later in a security review.
  • β†’
    Important caveat: TLS only protects data in transit to Gemini's servers β€” once it arrives, Google's infrastructure sees plaintext to actually run inference. That's a different trust boundary than "encrypted everywhere," and worth calling out explicitly to stakeholders.

If true confidentiality is non-negotiable

Minimize what you send (crop/redact anything not needed for the task), use enterprise terms with data-retention guarantees (Vertex AI vs. default consumer API), or self-host an open model inside your own VPC so plaintext never leaves your trust boundary at all.

Takeaways

Three things to remember

  • 1
    "Agent" = output reshapes the next step. Don't reach for agent frameworks when a single call or a fixed pipeline does the job.
  • 2
    Hybrid local+cloud is a cost pattern, not a buzzword. A cheap pretrained classifier can save real money by gating an expensive multimodal call.
  • 3
    Java is a perfectly good place to build this. Spring AI + the official Gemini SDK + DJL β€” no Python required anywhere in this stack.
πŸ™‹

Questions?