
Kevin LuperaUn decision model es un modelo que no escribe texto: lee una situación más una pregunta tipada y...
Un decision model es un modelo que no escribe texto: lee una situación más una pregunta tipada y devuelve un único número calibrado. Este post conecta el Strands Decider 2B de AWS a un agente de Strands de dos maneras: como guardrail que bloquea una tool-call construida sobre un argumento adivinado, y como router que mantiene las queries triviales fuera de un modelo caro. Ambas decisiones corren localmente y sub-segundo (en caliente, bastante menos), y cada una lleva un confidence score sobre el que tu if puede ramificar.
📦 Clona y ⭐ strands-decider-demo
Imagina un asistente del clima demasiado servicial. Le preguntas "¿Qué clima hace?" y nunca dices dónde. En vez de preguntar qué ciudad, adivina una y llama la tool igual:
USER: What's the weather?
assistant -> get_weather(location="Seattle") # nunca dijiste Seattle
"It's 21C and sunny in Seattle."
Es un modo de fallo real, no un experimento mental: cualquier agente con un prompt ansioso y un argumento requerido llenará el hueco con una suposición. La solución es un chequeo barato que corre antes que la tool.
Qué vas a aprender:
before_tool_call que puntúa una llamada propuesta.Porque el trabajo de un chat model es producir un siguiente paso plausible, y una tool-call es un siguiente paso plausible. Nada en el loop pregunta "¿estos valores de argumento están realmente grounded en lo que dijo el usuario?". Un LLM puede responder esa pregunta, pero pedirle al modelo que audite su propia salida es como pedirle a un estudiante que corrija su propio examen: se puede convencer de aprobarse.
Un decision model es el examinador externo. Le pasas la conversación y la llamada propuesta, le haces un sí/no concreto, y recibes una probabilidad: un número, no prosa de la que te puedan hacer dudar.
Strands Decider 2B responde tres formas de pregunta en un solo forward pass:
| Tipo de pregunta | Devuelve | Ejemplo |
|---|---|---|
noul |
P(true) en 0..1 — eso es la confianza | "¿Están grounded los argumentos?" |
choice |
una de N opciones nombradas + confianza | "¿billing, sales o retail?" |
score |
un punto en una rúbrica ordenada | "¿Qué tan frustrado: calm/frustrated/depressed?" |
Instala el CLI y arranca el server. En Apple silicon, el extra mlx lo corre más rápido:
pip install "strands-decider[mlx]" strands-agents
strands-decider serve StrandsAgents/strands-decider-2B-hobson-v19 --port 8099
El server expone POST /v1/systemone. Confírmalo cargado con un ask:
strands-decider ask StrandsAgents/strands-decider-2B-hobson-v19 \
--state "user: What's the weather? | assistant wants get_weather(location='Seattle'), but the user never named a city" \
--noul "Are the tool's argument values grounded in facts the user actually provided?" \
--noul "Is it premature to call this tool now, before clarifying with the user?"
noul_0 noul = 0.285
noul_1 noul = 0.740
Por qué importa: 0.285 en "grounded" significa que el modelo está seguro de que los argumentos no están grounded, y 0.740 en "premature" significa que está seguro de que la llamada es demasiado temprana. Dos números reales, un forward pass local.
El gate vive en before_tool_call. El modelo clasifica; unas pocas líneas de Python plano deciden:
from strands.interventions import Guide, InterventionHandler, Proceed
YES = 0.45 # perilla de policy, propiedad del código — no del modelo
class ToolCallReviewer(InterventionHandler):
name = "tool-call-reviewer"
def before_tool_call(self, event, **kwargs):
answers = self._decider.ask(self._state(event), QUESTIONS)
grounded = answers["args_grounded"]["noul"]
premature = answers["premature"]["noul"]
if grounded < YES:
return Guide(feedback="Los argumentos parecen adivinados — pide al usuario que los confirme.")
if premature >= YES:
return Guide(feedback="Demasiado pronto — aclara con el usuario primero.")
return Proceed()
Fíjate que el veredicto es Guide, no Deny: no bloquea la llamada, le devuelve el turno al modelo con feedback, así el agente le pregunta al usuario qué ciudad en vez de negarse.
Pasa el handler a Agent(interventions=[...]) y corre antes de cualquier tool:
agent = Agent(
model="us.anthropic.claude-haiku-4-5-20251001-v1:0",
tools=[get_weather],
interventions=[ToolCallReviewer(decider)],
system_prompt="You are an eager weather assistant...",
)
result = agent("What's the weather?")
Con el server arriba y el prompt ansioso, el gate lee la llamada propuesta, el decider la
puntúa, y las dos respuestas noul resuelven en un Guide. El agente retoma su turno y
pregunta por la ciudad en vez de reportar Seattle:
strands-decider: StrandsAgents/strands-decider-2B-hobson-v19 on Qwen/Qwen3.5-2B-Base [mps, window 4096]
USER: What's the weather?
[intervention] classifications (P of 'yes'):
args_grounded : 0.16
premature : 0.62
decided in : 1072 ms
~> guided
1 decisions, 250 input tokens; guided 1 of 1 proposed calls back to clarify
--- what the agent finally replied ---
I'd be happy to help with the weather! Could you let me know which city you'd like the weather for?
Por qué importa: 0.16 en "grounded" es el modelo diciendo, con confianza, que
"Seattle" fue inventado — así que el gate guía al agente a volver a preguntar. Ese
1072 ms es una primera llamada real en una Mac M3 por MPS; mira el gotcha de abajo para
saber por qué está muy por encima del número de titular.
Asumí que Python 3.14 era demasiado nuevo: las wheels de PyTorch suelen ir detrás de un release reciente de Python, así que esperaba que pip install strands-decider fallara. No falló — torch 2.14.1 ya trae una wheel 3.14 arm64, y la instalación fue limpia. Me equivoqué al anticiparlo; la lección es correr la instalación antes de escribir la advertencia.
La segunda trampa es la medición de latencia. El subcomando ask carga el modelo entero en cada llamada, así que medir su tiempo reporta el arranque, no la inferencia. La latencia real solo aparece con el proceso serve de larga vida — haz el benchmark contra el server, nunca contra ask repetido.
Y una tercera, medida a los golpes: mi primera llamada real contra el server tardó 1072 ms, no los ~115–153 ms de titular. Dos razones, ambas esperadas — el primer request a un largo de input dado paga un shape-compile de MPS que ocurre una sola vez (el cliente oficial avisa justo de esto), y MPS a secas es más lento que la ruta mlx. Los números de titular son en caliente y en una RTX 3090 / M3 Pro. Lección: cita tu propio p50 en caliente desde un loop de benchmark, no una primera llamada en frío.
💡 causal_conv1d is not installed ... falling back to its reference PyTorch implementation: inofensivo, solo más lento. Instala causal_conv1d para el kernel optimizado.
El segundo demo rutea cada query con un noul ("¿es compleja?") a un tier cheap o frontier, y luego compara contra always-frontier en un set etiquetado de 14 queries.
| Always frontier | Decider-routed | |
|---|---|---|
| Routing accuracy vs gold | — | 100% (14/14)¹ |
| Decider latency p50 / p95 | — | ~115 ms (RTX 3090) / ~153 ms (M3 Pro)² |
| Costo por corrida (precios ilustrativos) | $0.08400 | $0.03840 (54% más barato) |
¹ Coincidencia de routing con los tiers etiquetados a mano en el set de 14 queries.
² Cifras oficiales de v19 del evaluation/ del repo,
en caliente. Mi propia primera llamada (en frío) en una Mac M3 por MPS fue 1072 ms —
vuelve a correr bench.py contra tu server para tu propio p50/p95 en caliente.
El costo sale de
bench.pycon precios ilustrativos de Bedrock (cheap $0.0005,
frontier $0.0100 por 1K tokens, ~600 tokens/query) — verifícalos contra el pricing en
vivo antes de citarlos. Las cifras de latencia son los números oficiales del proyecto,
no medidos en tu hardware.
| Strands Decider 2B (local) | Decider hosted (Jev) | LLM frontier como gate | |
|---|---|---|---|
| Costo por inferencia | $0 (tu hardware) | API por llamada | API por llamada (el más alto) |
| Confidence score | ✅ calibrado | ✅ calibrado | ❌ no expuesto |
| Genera texto | ❌ | ❌ | ✅ (excesivo) |
| Localidad de datos | nunca sale de la máquina | sale de la red | sale de la red |
Detén el server (Ctrl-C) y borra el virtualenv:
rm -rf .venv
Los pesos descargados viven en tu cache de Hugging Face (~/.cache/huggingface); borra esa entrada si quieres recuperar el disco.
¿Un decision model es solo un clasificador? Es uno general que no tienes que entrenar: defines las etiquetas en el request y devuelve probabilidades calibradas sobre ellas. Un clasificador tradicional es más rápido y barato una vez entrenado, pero tienes que construirlo y mantenerlo.
¿Puedo hacer varias preguntas a la vez? Sí — el state se codifica una vez y cada pregunta solo agrega sus propios tokens, así que dos preguntas cuestan mucho menos que dos requests.
¿El guardrail necesita Bedrock? Solo la mitad del agente. El gate habla con el server local; cambia el model provider y el lado del decider queda igual.
if difuso, no un mini-LLM. Elige y puntúa; nunca escribe.Guide le gana a Deny. Corrige el rumbo del agente en vez de bloquearlo, y termina preguntando lo que debió preguntar primero.El asistente ansioso del principio nunca llega a reportar el clima de una ciudad que no nombraste — un chequeo local y sub-segundo lo manda a preguntar, antes de que la respuesta equivocada salga de la máquina.
¿Qué decisión en tu loop de agente está corriendo calladamente sobre un LLM frontier cuando un noul local podría manejarla? Cuéntame en los comentarios.
¡Gracias!