Running an AI Agent Locally: ADK, Gemma 4, and Docker Model Runner
This article was originally published on Medium (Google Cloud Community). Cloud LLMs are great for production. But during development, every API call has latency, costs money, and requires credentials. What if the LLM
This article was originally published on Medium (Google Cloud Community).
Cloud LLMs are great for production. But during development, every API call has latency, costs money, and requires credentials. What if the LLM ran on your machine, right next to your agent?
In my previous article, I built a football statistics agent using Google ADK, BigQuery MCP via Cloud API Registry, and Gemini 2.5 Flash as the LLM β deployed to Cloud Run and Vertex AI Agent Engine.
In this article, I take the same agent and replace Gemini with Gemma 4 running locally via Docker Model Runner. The agent code barely changes. The BigQuery MCP tools stay the same. But now the LLM runs on my laptop β no cloud inference, no API key, no cost.
The full source code is available on GitHub.
What is Docker Model Runner?
Docker Model Runner is a built-in feature of Docker Desktop that lets you pull and run LLMs locally β just like pulling container images. It exposes an OpenAI-compatible API on your machine, so any tool that speaks the OpenAI protocol can use it.
# Enable the Model Runner with TCP access
docker desktop enable model-runner --tcp 12434
# Pull the model
docker model pull ai/gemma4:E4B
# Verify
docker model ls
Note: On Docker Engine (Linux), TCP access is enabled by default on port 12434 β no extra flag needed. On Docker Desktop (macOS/Windows), TCP must be explicitly enabled with
--tcp 12434to expose the API on localhost. In this article, I'm using Docker Desktop on macOS, which is why this activation step is required.
Once pulled, the model is available at http://localhost:12434/engines/v1 β the same endpoint format as the OpenAI API. You can test it directly:
curl http://localhost:12434/engines/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "ai/gemma4:E4B",
"messages": [{"role": "user", "content": "Say hello"}],
"max_tokens": 20
}'
No API key needed. No cloud dependency. The model runs entirely on your machine.
What is Gemma 4?
Gemma 4 is Google's latest open-weight model family, available on Docker Hub in several sizes:
| Variant | On-disk size* | Raw params | Use case |
|---|---|---|---|
ai/gemma4:E2B |
2.94 GiB | 4.65B (2B effective) | Fastest, edge-friendly β great for rapid iteration |
ai/gemma4:E4B (= latest)
|
4.74 GiB | 7.52B (4B effective) | Best trade-off for local agents β current default |
ai/gemma4:4B |
~6 GB | 4B dense (Q4_K_XL quant) | Traditional dense variant, quantized |
ai/gemma4:26B |
~18 GB | 26B MoE (~4B active) | High quality, sparse mixture-of-experts |
ai/gemma4:31B |
~20 GB | 31B dense | Maximum quality, heavy on resources |
* Sizes reported by docker model ls after pull. The Docker Hub manifest shows slightly larger figures (e.g. E4B = 6.09 GB on Hub) because the GGUF is repackaged locally.
The E2B and E4B variants use the Per-Layer Embeddings (PLE) architecture β they have more actual parameters than their "effective" size, but run at the compute cost of the smaller effective size. It's the same Matryoshka-style approach introduced with Gemma 3n.
For an agent that makes 3-4 LLM roundtrips per question (reasoning, tool call, error handling, final answer), inference speed matters. The E4B variant gives the best trade-off between quality and speed for local development β it's also now the latest default.
Architecture: what changes, what stays
The beauty of this approach is how little changes. The agent framework (ADK), the tools (BigQuery MCP via Cloud API Registry), and the system instructions are identical to the cloud version. Only the model endpoint changes.
User Question
β
βΌ
ββββββββββββββββ ββββββββββββββββββββββββ
β Google ADK ββββββΆβ Docker Model Runner β
β LlmAgent βββββββ Gemma 4 (local) β
ββββββββ¬ββββββββ ββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββ βββββββββββββ
β Cloud API RegistryββββββΆβ BigQuery β
β BigQuery MCP βββββββ (cloud) β
ββββββββββββββββββββ βββββββββββββ
| Cloud version (previous article) | Local version (this article) | |
|---|---|---|
| LLM | Gemini 2.5 Flash (Vertex AI) | Gemma 4 E4B (Docker Model Runner) |
| Inference cost | Per-token pricing | Free (runs on your CPU/GPU) |
| Agent code |
LlmAgent + API Registry MCP |
Same |
| Tools | BigQuery MCP | Same |
| System instruction | Full schema + business rules | Same |
For the details on the ADK agent setup, BigQuery MCP via Cloud API Registry, the dataset, and the system instruction, refer to my previous article. Here, I'll focus on what's new: the Docker Model Runner integration and the challenges of running Gemma locally with an agent framework.
The agent code: one model string change
Here's the original agent.py from the cloud version:
MODEL = "gemini-2.5-flash"
root_agent = LlmAgent(
model=MODEL,
name="football_stats_agent",
instruction=SYSTEM_INSTRUCTION,
tools=[toolset],
)
And the local version:
GEMMA_MODEL = os.environ.get("GEMMA_MODEL", "ai/gemma4:E4B")
MODEL = f"openai/{GEMMA_MODEL}"
root_agent = LlmAgent(
model=MODEL,
name="football_stats_agent",
instruction=SYSTEM_INSTRUCTION,
tools=[toolset],
)
The openai/ prefix tells ADK to route through LiteLLM (included in google-adk[extensions]) to the OpenAI-compatible endpoint β which is Docker Model Runner. The OPENAI_API_BASE environment variable points LiteLLM to the local endpoint:
export OPENAI_API_BASE=http://localhost:12434/engines/v1
export OPENAI_API_KEY=docker-model-runner # Dummy key, required by LiteLLM
export GEMMA_MODEL=ai/gemma4:E4B
That's it for the model swap. But there's a catch.
The function calling problem
When I first ran the agent with Gemma, it crashed:
Conversation roles must alternate user/assistant/user/assistant/...
The issue: Gemma doesn't have native function calling support. When ADK sends tool-call messages (system β user β tool_result β assistant), Gemma's chat template rejects the non-alternating roles.
Gemini handles this natively β it understands function declarations and returns structured functionCall responses. Gemma needs a different approach: convert tool declarations into text prompts that the model can understand, then extract function calls from the model's text output.
ADK already has this built-in via GemmaFunctionCallingMixin. It also has a Gemma3Ollama class that combines this mixin with the Ollama provider. But Docker Model Runner uses the OpenAI API (not Ollama's /api/chat), so I needed a custom class.
GemmaModelRunner: bridging Gemma and Docker Model Runner
The solution is a custom LLM class that combines:
-
LiteLLM's OpenAI provider β routes requests to Docker Model Runner's
/engines/v1/chat/completions -
ADK's
GemmaFunctionCallingMixinβ converts tool declarations to text and extracts function calls from responses
from google.adk.models.gemma_llm import GemmaFunctionCallingMixin
from google.adk.models.lite_llm import LiteLlm
from google.adk.models.registry import LLMRegistry
class GemmaModelRunner(GemmaFunctionCallingMixin, LiteLlm):
"""Gemma model served by Docker Model Runner."""
@classmethod
def supported_models(cls) -> list[str]:
return [r"openai/ai/gemma.*"]
async def generate_content_async(self, llm_request, stream=False):
self._move_function_calls_into_system_instruction(llm_request)
async for response in super().generate_content_async(llm_request, stream):
self._extract_function_calls_from_response(response)
yield response
LLMRegistry.register(GemmaModelRunner)
This class:
-
Registers itself in ADK's LLM registry for model names matching
openai/ai/gemma.* - Before each LLM call: moves function declarations from the tool config into the system instruction as text, and converts tool-result messages into user messages
-
After each LLM call: parses the model's text output to extract any function calls (e.g., when Gemma writes
execute_sql(projectId="...", query="...")as text)
The import in agent.py triggers the registration:
import football_stats_agent.gemma_model_runner # noqa: F401 β registers GemmaModelRunner
Dependencies
The cloud version only needs google-adk. The local version needs google-adk[extensions] to include LiteLLM:
[project]
name = "football-agent-adk-gemma"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"google-adk[extensions]>=1.27.2",
"google-cloud-aiplatform>=1.141.0",
]
Running the agent locally
With ADK web UI
# Pull the model
docker desktop enable model-runner --tcp 12434
docker model pull ai/gemma4:E4B
# Install dependencies
direnv allow
uv sync
# Authenticate with GCP (for BigQuery MCP)
gcloud auth application-default login
# Run
uv run adk web
Open http://localhost:8000, select football_stats_agent, and ask a question.
With Docker Compose
services:
adk-agent:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- GCP_PROJECT_ID=gb-poc-373711
- GEMMA_MODEL=ai/gemma4:E4B
- OPENAI_API_BASE=http://host.docker.internal:12434/engines/v1
- OPENAI_API_KEY=docker-model-runner
- GOOGLE_APPLICATION_CREDENTIALS=/tmp/adc.json
volumes:
- ${HOME}/.config/gcloud/application_default_credentials.json:/tmp/adc.json:ro
Note host.docker.internal β from inside a container, this is how you reach Docker Model Runner running on the host.
docker compose up --build
The agent in action
I ran three test queries against the E4B agent to see how it handles different difficulty levels:
Simple query β top 3 scorers from France (~22s, one roundtrip)
When you ask "List the top 3 scorers from France", Gemma 4 E4B gets it right on the first try:
-
Reasons about the question and picks the
execute_sql_readonlytool -
Generates correct SQL directly using
SAFE_CAST(the system instruction warns about string-typed numeric columns) - BigQuery returns results β Kylian Mbappe (8), Olivier Giroud (4), Aurelien TchouamΓ©ni (1)
- Formats the answer in natural language
No self-correction needed β E4B follows the system prompt's SAFE_CAST guidance upfront. A smaller model like E2B typically needs a failed attempt before switching to SAFE_CAST.
Complex query β performance rating comparison (~2.5 min, three roundtrips)
When you ask "Compare France and Argentina players by performance rating. Top 3 from each team", things get more interesting:
- First attempt β generates a complex window-function SQL, BigQuery rejects it with a syntax error
-
Second attempt β fixes the syntax but fails on
Bad int64 value: "-"(some numeric string columns contain"-") -
Third attempt β wraps every cast in
SAFE_CAST, query succeeds - Formats the answer with the performance rating formula applied correctly
The self-correction behavior is the same pattern the original Gemini cloud version produced β read the BigQuery error, adjust the SQL, retry. That Gemma 4 E4B handles this loop reliably on a local ~8B-raw-param model is a strong quality signal.
Choosing the right model variant
Hardware used for these benchmarks
All timings below were measured on my personal laptop:
- MacBook Pro β Apple M1 Max (10 CPU cores: 8 performance + 2 efficiency)
- 32-core GPU, Metal 3 backend
- 32 GB unified memory
- macOS 15.3.1
The M1 Max's unified memory architecture is a big part of why Gemma 4 runs comfortably here β the GPU and CPU share the same 32 GB pool, so the model weights don't need to be copied across a PCIe bus. With 32 GB total, E2B (2.94 GiB) and E4B (4.74 GiB) both leave plenty of headroom for Docker Desktop, the ADK runtime, the browser, and the rest of your dev environment. The larger 26B / 31B variants are technically loadable but you'll feel the squeeze.
Performance will vary significantly on other hardware (base Apple Silicon, Intel Macs, Linux with or without a discrete GPU). I haven't benchmarked those, so treat the timings above as one data point on one specific machine.
Timings
Full agent loop end-to-end including BigQuery roundtrips:
| Query type | Roundtrips | E4B elapsed |
|---|---|---|
| Simple (e.g. "top 3 scorers") | 1 | ~22s |
| Complex (e.g. "compare two teams by performance rating") | 3 (with self-correction) | ~2.5 min |
Simple queries feel snappy. Complex queries requiring multiple SQL self-corrections take the time you'd expect from a local model making several inference passes.
The E4B variant (now the latest default) is the sweet spot: ~8B raw params running at the compute cost of a 4B model thanks to PLE. It generates correct SQL on the first try for straightforward questions and self-corrects cleanly on complex ones.
If you want maximum speed at the cost of some quality, drop down to E2B β smaller, roughly 2x faster per inference, but more likely to need an extra self-correction round before getting the SQL right.
If you need maximum quality, use 26B / 31B locally (expect multi-minute loops even on a beefy machine) β or switch back to Gemini for production, which is what I recommend:
| Environment | Model | Why |
|---|---|---|
| Local dev (default) | Gemma 4 E4B (Docker Model Runner) | Free, no API key, good quality |
| Local dev (fastest) | Gemma 4 E2B (Docker Model Runner) | 2x faster, lower quality |
| Production | Gemini 2.5 Flash (Vertex AI) | Higher quality, faster, scalable |
The agent code is the same β only the GEMMA_MODEL environment variable (or the model ID for Gemini) changes.
Docker Model Runner version matters
One issue I hit: an older Docker Model Runner version bundled with my Docker Desktop install cannot load Gemma 4 models at all. Every attempt fails with:
unable to load runner: error waiting for runner to be ready:
inference backend took too long to initialize
This looks like a memory or timeout issue, but it's actually an architecture incompatibility. Gemma 4 uses a newer model architecture that older versions of the llama.cpp backend don't support.
The fix: update Docker Desktop to get a recent Model Runner release. You can check your version with:
docker model version
Key takeaways
Docker Model Runner makes local LLMs trivial β
docker model pulland you have an OpenAI-compatible API on localhost. No Ollama, no manual setup.Gemma doesn't do native function calling β You need ADK's
GemmaFunctionCallingMixinto convert tool declarations into text prompts. The customGemmaModelRunnerclass bridges this gap for Docker Model Runner's OpenAI API.The agent code barely changes β Same ADK framework, same BigQuery MCP tools, same system instruction. Only the model string and one custom class change.
Pick the right Gemma 4 variant for local agents β
26B/31Bare too slow for multi-roundtrip loops. E4B (thelatestdefault) is the sweet spot: ~22s for simple queries, ~2.5 min for complex ones requiring SQL self-correction. Drop to E2B only if you need maximum speed. Both use PLE so they punch above their effective size.Keep Docker Desktop updated β Older Model Runner versions can't load newer model architectures. The error message is misleading β it's not a timeout, it's an incompatibility.
Local for dev, cloud for prod β Run Gemma 4 locally during development (free, private, fast feedback). Switch to Gemini on Vertex AI for production (better quality, scalable). The swap is one environment variable.
What's next
In the next article, I'll rebuild the same use case β football statistics agent on the Qatar 2022 World Cup dataset β but replace Google ADK with Docker Agent. Same Gemma 4 via Docker Model Runner, same BigQuery MCP tools, same system instructions β only the agent framework changes. A direct side-by-side comparison of the two approaches.
Try it yourself
The full source code is available on GitHub. Clone it, pull the model, and ask the agent about the 2022 World Cup:
- "Who scored the most goals?"
- "Show me the top 5 players by assists"
- "Which goalkeepers had the highest save percentage?"
- "Compare France and Argentina players by performance rating"
If you enjoyed this article, follow me for more content on AI agents, Google Cloud, Software, DevOps, Tech and data engineering:
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.