Detailed content
🧩 LLM, VLM, and VLA: what each one does in a robot
Three acronyms appear in every AI-powered robot project, and each has a different role. The LLM (large language model) takes text and returns text: it understands the request and decides which action makes sense. The VLM (vision-language model) takes an image along with text and answers questions about it: “there’s a blue cup on the left.” The VLA (vision-language-action model) goes a step further: it takes an image and an instruction and returns the joint movements themselves, several times per second.
🌱 New here?
A model is a file of numbers (the parameters, or weights) learned from many examples. Running the model to get a response is called inference. Text goes in and comes out in pieces called tokens (parts of words). In general, the more parameters a model has, the better its responses and the more memory and time inference requires.
How to read this map: Each row shows a type of model. On the left (cyan) is what it receives; on the right is what it returns. The LLM and VLM stop at words or a choice; the glowing VLA returns movement directly. This module uses the first two as planners; the VLA appears in module 5.4, with SmolVLA.
Turns “move a little forward” into move 20 cm. Good at understanding requests, bad at measuring distance.
Describes the camera scene. It helps the LLM know what’s on the table, but it can get the color, number, or position wrong.
Trained on demonstrations using the arm itself. It controls the joints, so it needs data, testing, and even stricter limits.
Why learn this
Knowing which model does what helps you avoid the most common beginner mistake: asking a text model to control a motor in real time. Each type has its place in the robot stack, and choosing the right place is half the safety work.
Key concepts
🛡️ Safe architecture: the model chooses, the robot executes
The language model stays at the high level: it reads the request and chooses an action. Everything that follows is regular code you wrote and tested. A validator on the Raspberry Pi or PC checks that the action exists and the numbers are within the limits. Only then does a line of text go over serial to the ESP32, which has its own limits and emergency stop. If the model makes something up, the response is stopped by the validator.
How to read this map: The dashed cyan box is the part that can make mistakes (the model); the teal box is deterministic code that always does the same thing. The dashed red arrow shows the refusal path. The STOP button acts directly on the ESP32, without going through the model or the Pi.
✓ Do
- ✓Let the model choose from a few clearly named actions.
- ✓Check everything in code before sending it over serial.
- ✓Repeat the limits on the ESP32: it’s the last line of defense.
- ✓Log every request, response, and decision.
✗ Don't
- ✗Let the model generate and run code without review.
- ✗Send the model’s text directly over serial.
- ✗Ask the model for raw PWM values or motor angles.
- ✗Depend on the model to stop the robot in an emergency.
Why learn this
Language models sometimes confidently give a wrong answer (this is called a hallucination). In a chat, that produces a bad sentence; in a robot, it could cause a collision. Separating “choosing” from “executing” lets you switch models, whether local or in the cloud, without changing what keeps things safe.
Key concepts
📋 Closed list of actions in JSON (function calling)
Instead of asking for an open-ended answer, you give the model a menu: the actions the robot knows how to perform, each one’s parameters, and the exact response format. This idea is called function calling (function call) or tool use. Ollama accepts a JSON Schema in the format field and forces the response to follow that structure; the enum field lists the only permitted action names.
🌱 New here?
JSON is a data format with keys and values: {"acao": "girar", "parametros": {"graus": 90}}. A JSON Schema is the rule that says which keys exist and what type each value must be. The schema helps the model get the format right, but it doesn’t guarantee that a value is safe: 9000 degrees is still a valid number. That’s why the validator checks the limits afterward.
How to read this map: On the left is the menu you write in the code. On the right are three possible model responses. Only the first becomes a command; the red ones are rejected by the validator, and the robot stays still and explains why.
Goal: see what a valid model response looks like. This is the format required by the script in Topic 4; notice that reason is just text for the log and is never executed.
{
"acao": "girar",
"parametros": { "graus": -90 },
"motivo": "the user asked to turn left"
}
How to check: paste the block into a JSON validator (or run python3 -m json.tool resposta-exemplo.json); it should be accepted without errors. Change -90 with "left" and see that the type is no longer a number: the validator in Topic 4 would reject it.
Why learn this
The closed list turns an open-ended problem ("what will the model answer?") into a closed, testable problem ("which of the four actions did it choose, and is the number within the limit?"). It’s the same principle commercial assistants use to call tools, applied to a robot.
Key concepts
💻 Run a local model (Ollama on the Pi 5 or PC)
O Ollama downloads open models and runs them on your machine, through an HTTP API at http://localhost:11434. Your script talks to it just as it would to an internet service, except nothing leaves your computer. Cloud models also work with this architecture (the validator is the same); the course shows the local setup because it works without internet, has no per-request cost, and doesn’t send audio or images from your home outside.
How to read this map: Everything inside the teal frame runs on your machine. The two arrows between the script and Ollama show the request and response over the local network (localhost). The dashed cyan cable is the serial connection to the ESP32, the same one from Module 3.5.
| Where it runs | Model size that fits | In practice |
|---|---|---|
| Raspberry Pi 5 (8 GB), CPU only | small models, with 1 to 3 billion parameters | a few seconds per short response |
| Pi 5 + AI HAT+ 2 | small LLMs and VLMs on the accelerator | check the Pi documentation for supported models |
| Jetson Orin Nano Super | small and medium models, with a GPU | faster than the Pi, more expensive |
| PC with an NVIDIA GPU | medium models (7 to 14 billion) with room to spare | almost instant response |
Step by step
Install Ollama
Follow the installer for your system at ollama.com/download (on Linux and the Pi, it’s a single command). It starts a service that listens on port 11434.
Choose and download a model
At ollama.com/library, filter for models that support "tools" and are small enough for your memory. Download with ollama pull <modelo>. Names and versions change quickly: check the current list.
Test in the terminal and install pyserial
Run the commands below. If curl returns JSON with "message", the server is ready for the script.
ollama pull <modelo> # replace with the chosen name
ollama list # the model should appear here
curl http://localhost:11434/api/chat -d '{"model": "<modelo>", "stream": false,
"messages": [{"role": "user", "content": "Reply only: ok"}]}'
python3 -m venv ~/robo-ia && source ~/robo-ia/bin/activate
pip install pyserial
Goal: the complete script for the safe architecture. It sends the request and schema to the local model, validates the response (action is on the list, parameters are within limits, nothing extra), asks for confirmation for large movements, and only then sends a line of text to the ESP32. With --simular, it prints the line instead of sending it, so you can test without hardware.
#!/usr/bin/env python3
"""Local-model brain: the model CHOOSES an action from the closed list,
the code VALIDATES it, and only then sends a line of text to the ESP32 over serial.
Usage: python3 cerebro_llm.py (with ESP32)
python3 cerebro_llm.py --simular (without hardware: only prints)"""
import json, sys, time, urllib.request, urllib.error
OLLAMA_URL = "http://localhost:11434/api/chat"
MODELO = "<modelo>" # replace with the name shown by: ollama list
PORTA = "/dev/ttyUSB0" # USB: /dev/ttyUSB0 or /dev/ttyACM0 · Pi UART: /dev/serial0 · Windows: COM3
BAUD = 115200
CONFIRMAR_ACIMA_CM = 50 # moving farther than this requires typing "s" on the keyboard
# Closed list: action -> {parameter: (minimum, maximum)} or {parameter: [permitted values]}
ACOES = {
"andar": {"cm": (5, 100)},
"girar": {"graus": (-180, 180)},
"garra": {"estado": ["abrir", "fechar"]},
"parar": {},
}
ESQUEMA = {
"type": "object",
"properties": {
"acao": {"type": "string", "enum": list(ACOES)},
"parametros": {"type": "object"},
"motivo": {"type": "string"},
},
"required": ["acao", "parametros"],
}
SISTEMA = (
"You control a small robot. Choose ONE action and respond only in JSON.\n"
"Actions: andar {cm: 5 to 100, negative values aren’t allowed}; girar {graus: -180 to 180, "
"negative = left}; garra {estado: open or close}; parar {}.\n"
"If the request doesn't fit any action, or is dangerous, use stop and explain why."
)
def perguntar_modelo(pedido):
"""Send the request to Ollama and return the response dictionary (or raise an error)."""
corpo = {
"model": MODELO,
"messages": [{"role": "system", "content": SISTEMA},
{"role": "user", "content": pedido}],
"stream": False, # one complete response, not pieces
"format": ESQUEMA, # force the JSON format
"options": {"temperature": 0},
}
req = urllib.request.Request(OLLAMA_URL, data=json.dumps(corpo).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
dados = json.loads(r.read())
return json.loads(dados["message"]["content"])
def validar(resp):
"""Return (ok, line_or_reason, needs_confirmation). Never trust the model."""
if not isinstance(resp, dict):
return False, "response is not a JSON object", False
acao = resp.get("acao")
if acao not in ACOES:
return False, f"action '{acao}' is not on the list", False
params = resp.get("parametros") or {}
if not isinstance(params, dict):
return False, "parameters are not an object", False
sobra = set(params) - set(ACOES[acao])
if sobra:
return False, f"unknown parameter: {', '.join(sorted(sobra))}", False
valores = []
for nome, regra in ACOES[acao].items():
if nome not in params:
return False, f"missing parameter '{nome}'", False
v = params[nome]
if isinstance(regra, list): # allowed values
if v not in regra:
return False, f"'{v}' is not allowed for {nome}", False
valores.append(str(v).upper())
else: # numeric range
if isinstance(v, bool) or not isinstance(v, (int, float)):
return False, f"{nome} must be a number", False
minimo, maximo = regra
if not (minimo <= v <= maximo):
return False, f"{nome}={v} is outside the limit ({minimo} to {maximo})", False
valores.append(str(int(round(v))))
linha = " ".join([acao.upper()] + valores) # e.g.: ANDAR 30 · GARRA ABRIR · PARAR
confirmar = acao == "andar" and params["cm"] > CONFIRMAR_ACIMA_CM
return True, linha, confirmar
def abrir_serial(simular):
if simular:
return None
import serial # pip install pyserial
s = serial.Serial(PORTA, BAUD, timeout=2)
time.sleep(2) # opening the port resets the ESP32: wait
s.reset_input_buffer()
return s
def enviar(s, linha):
if s is None:
return f"(simulated) {linha}"
s.write((linha + "\n").encode())
return s.readline().decode(errors="replace").strip() or "(no response from ESP32)"
def main():
simular = "--simular" in sys.argv
s = abrir_serial(simular)
print("Brain ready. Enter a request (or 'sair').")
while True:
pedido = input("You: ").strip()
if pedido.lower() in ("sair", "exit"):
break
if not pedido:
continue
try:
resp = perguntar_modelo(pedido)
except urllib.error.URLError as e:
print(f"Ollama did not respond ({e}). Is it running? Try: ollama list"); continue
except (json.JSONDecodeError, KeyError) as e:
print(f"Rejected: unreadable response ({e})."); continue
print("Modelo:", json.dumps(resp, ensure_ascii=False))
ok, linha, confirmar = validar(resp)
if not ok:
print("Recusado:", linha); continue
if confirmar and input(f"Confirm '{linha}'? (y/n) ").strip().lower() != "s":
print("Canceled by you."); continue
print("ESP32:", enviar(s, linha))
if s:
s.write(b"PARAR\n"); s.close()
if __name__ == "__main__":
main()
How to check
- ✓With --simular, "walk thirty centimeters" shows the model's JSON, and "ESP32: (simulated) ANDAR 30".
- ✓"walk two meters" is rejected (cm is outside the limit) or becomes stop; "walk 80 cm" asks for confirmation first.
- ✓"make a coffee" ends in a refusal or in PARAR, never in an invented action.
- ✗"Ollama did not respond"? The service may be stopped or the model name may not match ollama list.
💡 Test the validator without a model
In the Python interactive shell, run from cerebro_llm import validar and call validar({"acao": "girar", "parametros": {"graus": 900}}). Write about ten invalid cases (nonexistent action, text instead of a number, extra parameter). If all are rejected, your robot is protected against any model you plug in later.
Why learn this
With a local model, you can test as much as you like, with no quota or account, and the robot keeps working without Wi-Fi. And since the validator is in your code, switching Ollama for a cloud model means changing only the function perguntar_modelo.
Key concepts
👁️ VLM: “what do you see?” through the camera
A VLM receives a photo and a question. In Ollama, the image is sent as base64 in the images field of the message. The safe approach is the same as before: the VLM describes, and that description is included as context in the request to the LLM (or in the same model, if it accepts images and tools). It doesn't control a motor. For exact positioning, the detector from module 3.3 is still more reliable; the VLM helps with open-ended questions: “is there anything in the way?”, “which object is red?”
Goal: take a photo with the USB camera (or Camera Module 3 via OpenCV) and ask the local VLM what's in the scene. You need a model with vision: in the Ollama library, filter by “vision”.
#!/usr/bin/env python3
"""Take a photo and ask the local VLM what's in it. pip install opencv-python"""
import base64, json, sys, urllib.request
import cv2
MODELO_VISAO = "<modelo-de-visao>" # a model with 'vision' at ollama.com/library
PERGUNTA = sys.argv[1] if len(sys.argv) > 1 else "List the objects on the table, with their color and position (left, center, right)."
cam = cv2.VideoCapture(0) # 0 = first camera
for _ in range(5): # discard dark frames at the start
ok, quadro = cam.read()
cam.release()
if not ok:
sys.exit("Camera didn't open: check the index (0, 1...) and the cable.")
quadro = cv2.resize(quadro, (640, 480)) # smaller image = faster response
cv2.imwrite("foto.jpg", quadro)
_, jpg = cv2.imencode(".jpg", quadro)
corpo = {
"model": MODELO_VISAO, "stream": False, "options": {"temperature": 0},
"messages": [{"role": "user", "content": PERGUNTA + " Answer in English, in up to 3 sentences.",
"images": [base64.b64encode(jpg.tobytes()).decode()]}],
}
req = urllib.request.Request("http://localhost:11434/api/chat", data=json.dumps(corpo).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=180) as r:
print(json.loads(r.read())["message"]["content"])
How to check: the file foto.jpg appears in the folder, and the terminal shows a short description of the scene. Put two objects of different colors on the table and switch their sides: the description should follow them. If it gets it wrong, you've just seen why the VLM can't control the motor.
✓ Good use of the VLM
- ✓Give the LLM context: “there's a blue cup and a ball on the table”.
- ✓Answer open-ended questions from the user about the scene.
- ✓Check after the action: “is the gripper holding something?”.
✗ Bad use of the VLM
- ✗Ask for coordinates in millimeters and send the arm there.
- ✗Use it as a safety sensor (“is anyone nearby?”).
- ✗Call it on every video frame: it's too slow for that.
Why learn this
Combining vision and language lets the robot respond to requests that refer to the world (“pick up the red one”). Knowing where the VLM performs well and where it makes mistakes prepares you for the VLA in module 5.4, which takes the same idea all the way to movement.
Key concepts
🚨 Safeguards: limits, confirmation, and emergency stop
Safety in an AI-powered robot is built in layers, and each layer trusts none of the previous ones. The validator on the Pi is the first. The ESP32 checks the limits again and rejects any line it doesn't understand. A command watchdog stops the motors if no command arrives for a short time. And a physical stop button cuts off movement without depending on any software.
How to read this map: the motor is in the center; for it to move, none of the four surrounding layers can have blocked the command. The outer red layer is the only one that works even if the program freezes: it's physical. If one layer fails, the outer one still holds.
Goal: the ESP32 side of the text-line protocol (the same one used in module 3.5), with its own limits, an OK/ERROR response, a 2-second watchdog, and a stop button on GPIO 13. The motor functions only print messages; replace them with your motor code (Track 1).
// Safe executor: receives "ANDAR 30", "GIRAR -90", "GARRA ABRIR", "PARAR" (ESP32, pacote esp32 3.x)
const int PINO_PARADA = 13; // button connected to GND (uses internal pull-up)
const unsigned long WATCHDOG_MS = 2000; // no command for 2 s: stop
unsigned long ultimoComando = 0;
bool movendo = false;
void pararMotores() { movendo = false; Serial.println("# motors stopped"); } // replace with your driver
void andar(int cm) { movendo = true; Serial.printf("# moving %d cm\n", cm); }
void girar(int graus) { movendo = true; Serial.printf("# turning %d degrees\n", graus); }
void garra(bool abrir) { Serial.println(abrir ? "# opening gripper" : "# closing gripper"); }
void setup() {
Serial.begin(115200);
pinMode(PINO_PARADA, INPUT_PULLUP);
pararMotores();
Serial.println("PRONTO");
}
void loop() {
if (digitalRead(PINO_PARADA) == LOW) { pararMotores(); while (digitalRead(PINO_PARADA) == LOW) delay(10); }
if (movendo && millis() - ultimoComando > WATCHDOG_MS) pararMotores();
if (!Serial.available()) return;
String linha = Serial.readStringUntil('\n'); linha.trim();
if (linha.length() == 0 || linha.length() > 32) { Serial.println("ERRO linha invalida"); return; }
ultimoComando = millis();
int esp = linha.indexOf(' ');
String cmd = esp < 0 ? linha : linha.substring(0, esp);
String arg = esp < 0 ? "" : linha.substring(esp + 1);
if (cmd == "PARAR") { pararMotores(); Serial.println("OK PARAR"); }
else if (cmd == "ANDAR") { int v = arg.toInt(); if (v < 5 || v > 100) Serial.println("ERRO limite"); else { andar(v); Serial.println("OK ANDAR"); } }
else if (cmd == "GIRAR") { int v = arg.toInt(); if (v < -180 || v > 180) Serial.println("ERRO limite"); else { girar(v); Serial.println("OK GIRAR"); } }
else if (cmd == "GARRA" && (arg == "ABRIR" || arg == "FECHAR")) { garra(arg == "ABRIR"); Serial.println("OK GARRA"); }
else { pararMotores(); Serial.println("ERRO comando desconhecido"); }
}
How to check
- ✓In the Serial Monitor (115200, line ending "Newline"), "ANDAR 30" responds with "OK ANDAR" and, 2 s later, "# motors stopped".
- ✓"ANDAR 500" responds with "ERRO limite" even if the Pi failed to block it.
- ✓With the button on GPIO 13 pressed, any movement stops immediately. Test it in Wokwi too.
💡 A real stop is physical
The button on GPIO 13 depends on the program running. For a powerful robot (an arm or a heavy base), also install a switch in series with the motors' power supply, within reach. Before each test, press it once to make sure it works.
Why learn this
The next modules add an arm that picks up objects and a voice that gives commands. With all four layers in place, any model you test—local or cloud-based, large or small—operates within a box you control.
Key concepts
✅ Module summary
To go further
- Ollama — installation and model library
- Ollama API — /api/chat, format, images, tools
- JSON Schema step by step — the response template
- AI on Raspberry Pi (AI HAT+ and AI HAT+ 2) — accelerators for Pi 5
- pySerial — serial communication on the Python side
Next module
5.2 · Local voice: listen with Whisper and speak with Piper, connecting voice to this module's brain.