#!/usr/bin/env bash # Deja el carril verde listo para usar: mide, configura y dictamina. # # ./arrancar.sh medir, configurar y dictaminar # ./arrancar.sh --solo-medir medir sin tocar la configuracion # ./arrancar.sh --igual no plantarse aunque dataset.py siga vivo # # ── Por que existe ───────────────────────────────────────────────────────── # # El primer arranque del verde topo con algo que no se anticipaba: el prompt # fijo de Hermes son ~20.500 tokens (26 KB de sistema + 56 KB de esquemas de 19 # herramientas) y qwen3.5:4b-jarvis va con num_ctx 4096. Cinco veces por encima. # No es que rinda mal: no entra la pregunta. # # Para eso existe qwen3.5:4b-verde. Pero cuanto contexto cabe en una GPU de # 4 GB no se puede calcular sobre el papel —ollama no reporta head_count_kv—, # hay que medirlo. Este script lo mide, escribe el resultado donde toca y # contesta la unica pregunta que de verdad decide si esto va a funcionar. # # ── El suelo de 64k, que manda sobre todo lo anterior (16 ago) ────────────── # # La primera version de este script buscaba el mayor num_ctx que cupiera en la # tarjeta y encontro 24576, con 32 capas en GPU y 3301 MiB. Todo correcto y # todo inutil: al abrir Hermes salta # # Model qwen3.5:4b-verde has a context window of 24,576 tokens, which is # below the minimum 64,000 required by Hermes Agent. # # Es un tope DURO del arnes, no un aviso: agent/model_metadata.py:405, # MINIMUM_CONTEXT_LENGTH = 64_000, comprobado en agent/agent_init.py:2656. # # Hay una puerta de atras en ese mismo if —si provider es "lmstudio" y # context_length esta puesto a mano, se salta el suelo— y NO se usa: el # proveedor lmstudio llama a /api/v1/models, que es la API nativa de LM Studio # y ollama no tiene, ademas de intentar precargar el modelo por su cuenta. # Fingir ser otro servidor romperia mas adelante y de forma mas confusa. # # Tampoco se miente en el config.yaml poniendo 64000 con el modelo a 24576: el # arnes creeria tener 64k, no comprimiria hasta acercarse, y ollama iria tirando # los tokens mas viejos —que son el prompt de sistema y los esquemas de las # herramientas—. El sintoma seria que el agente deja de llamar herramientas a # mitad de sesion, sin un solo error en el log. # # Asi que el numero a buscar ya no es el contexto: es CUANTAS CAPAS caben en la # tarjeta con num_ctx 65536 fijo. Medido el 16 ago: # # num_gpu 32 -> cudaMalloc failed num_gpu 16 -> 2795 MiB, CARGA # num_gpu 24 -> cudaMalloc failed num_gpu 8 -> 1772 MiB, CARGA # num_gpu 0 -> 0 MiB, todo en RAM # # ── Se planta en vez de seguir ───────────────────────────────────────────── # # Cada paso comprueba lo suyo y aborta si no cuadra. Es a proposito: un fallo # aqui es de una linea, y el mismo fallo tres pasos mas adelante sale como # "respuestas raras" y cuesta una tarde. set -uo pipefail AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd) RAIZ=$(cd -P "$AQUI/.." && pwd) CONFIG=$HOME/.hermes/config.yaml OLLAMA=${OLLAMA_URL:-http://127.0.0.1:11434} MODELO=qwen3.5:4b-verde NARANJA=qwen3.5:4b-jarvis # El suelo duro de Hermes es 64.000 (agent/model_metadata.py:405). Se pide la # potencia de dos justo por encima, que es lo que entiende ollama sin discutir. CTX_MINIMO=65536 SOLO_MEDIR=no IGUAL=no for a in "$@"; do case "$a" in --solo-medir) SOLO_MEDIR=si ;; --igual) IGUAL=si ;; -h|--help) sed -n '2,10p' "$0"; exit 0 ;; esac done rojo() { printf '\033[31m%s\033[0m\n' "$*"; } verde() { printf '\033[32m%s\033[0m\n' "$*"; } gris() { printf '\033[90m%s\033[0m\n' "$*"; } titulo(){ printf '\n\033[1m── %s ─────────────────────────────────────\033[0m\n' "$*"; } abortar(){ rojo " ✗ $*"; exit 1; } vram() { nvidia-smi --query-gpu=memory.used --format=csv,noheader 2>/dev/null | tr -d ' MiB'; } # ═══ 1. La tarjeta ═════════════════════════════════════════════════════════ titulo "1. Liberar la tarjeta" if pgrep -f 'entrena/dataset.py' >/dev/null 2>&1; then if [ "$IGUAL" = no ]; then rojo " ✗ dataset.py sigue corriendo." gris " Es un trabajo de 7-10 h que usa $NARANJA en la grafica." gris " Competir con el por la VRAM estropea las dos cosas: el verde" gris " no cargara y el dataset ira mas lento." gris "" gris " Por donde va:" [ -f "$RAIZ/entrena/dataset.log" ] && sed 's/^/ /' "$RAIZ/entrena/dataset.log" | tail -3 gris "" gris " Espera a que acabe, o --igual para seguir de todas formas." exit 1 fi rojo " ! dataset.py corriendo y --igual puesto: siguiendo bajo tu responsabilidad" fi # Restos del naranja. Se para el proceso; NO se toca ni un fichero suyo. for p in 'nucleo/jarvis.py' 'whisper-server'; do if pgrep -f "$p" >/dev/null 2>&1; then gris " cerrando $p" pkill -f "$p" 2>/dev/null fi done ollama stop "$NARANJA" >/dev/null 2>&1 sleep 3 LIBRE=$(vram) if [ -z "$LIBRE" ]; then abortar "no hay nvidia-smi: esto necesita la grafica" elif [ "$LIBRE" -gt 400 ]; then rojo " ! la tarjeta sigue con $LIBRE MiB usados" nvidia-smi --query-compute-apps=pid,used_memory,name --format=csv,noheader | sed 's/^/ /' gris " Algo la esta usando. Mira la lista de arriba antes de seguir." [ "$IGUAL" = no ] && exit 1 else verde " ✓ tarjeta libre: $LIBRE MiB" fi # ═══ 2. Cuantas capas caben con el contexto que Hermes exige ═══════════════ titulo "2. Cuantas capas caben a 65536 (medido, no supuesto)" gris " El contexto NO se negocia: Hermes rechaza cualquier modelo por debajo de" gris " 64.000 tokens. Lo que se busca es el mayor num_gpu que cargue a 65536:" gris " cada capa que baja a la CPU cuesta velocidad, asi que se cogen todas las" gris " que quepan." TMP=$(mktemp --suffix=.Modelfile); trap 'rm -f "$TMP"' EXIT GANADOR=""; GANADOR_VRAM="" probar_ctx() { # $1 = num_ctx $2 = num_gpu printf 'FROM qwen3.5:4b\nPARAMETER num_gpu %s\nPARAMETER num_ctx %s\nPARAMETER num_predict 4096\nPARAMETER temperature 0.2\n' "$2" "$1" > "$TMP" ollama create "$MODELO" -f "$TMP" >/dev/null 2>&1 || return 1 local r r=$(curl -s --max-time 240 "$OLLAMA/v1/chat/completions" -H 'Content-Type: application/json' \ -d "{\"model\":\"$MODELO\",\"messages\":[{\"role\":\"user\",\"content\":\"ok\"}],\"max_tokens\":5}" 2>/dev/null) echo "$r" | grep -q '"choices"' || return 1 return 0 } for GPU in 32 24 16 8 0; do printf ' num_ctx %-6s num_gpu %-3s ... ' "$CTX_MINIMO" "$GPU" if probar_ctx "$CTX_MINIMO" "$GPU"; then V=$(curl -fsS --max-time 5 "$OLLAMA/api/ps" 2>/dev/null | python3 -c " import json,sys m=json.load(sys.stdin)['models'] print(m[0]['size_vram']//1048576 if m else 0)" 2>/dev/null) verde "CABE (en tarjeta $V MiB, tarjeta $(vram) MiB)" GANADOR=$CTX_MINIMO; GANADOR_GPU=$GPU; GANADOR_VRAM=$V break fi rojo "no cabe" ollama stop "$MODELO" >/dev/null 2>&1; sleep 2 done if [ -z "$GANADOR" ]; then rojo " ✗ no carga a 65536 ni con TODAS las capas en la CPU." gris " Eso ya no es la grafica: mira la RAM libre con free -h." exit 1 fi verde " ✓ num_ctx $GANADOR con $GANADOR_GPU de 32 capas en GPU, $GANADOR_VRAM MiB" if [ "$GANADOR_GPU" -lt 32 ]; then gris "" gris " $(( 32 - GANADOR_GPU )) capas van por CPU y eso se paga en el prefill: el prompt fijo" gris " son ~20.500 tokens que hay que procesar en CADA vuelta del agente." gris "" gris " Palanca para recuperar capas, y NO la aplico yo porque es del SERVICIO" gris " de ollama, no del modelo: afectaria tambien al naranja la proxima vez" gris " que arranque. Esa decision es tuya." gris "" gris " sudo systemctl edit ollama" gris " [Service]" gris " Environment=\"OLLAMA_FLASH_ATTENTION=1\"" gris " Environment=\"OLLAMA_KV_CACHE_TYPE=q8_0\"" gris " sudo systemctl restart ollama" gris "" gris " Parte el KV cache por la mitad. Si lo haces, anotalo en NOTAS.md y en" gris " config/qwen3.5-4b-jarvis.Modelfile, o en tres semanas nadie sabra de" gris " donde salio el cambio." fi HUECO=$(( GANADOR - 20500 )) gris " quedan ~$HUECO tokens de conversacion tras el prompt fijo" # ═══ 3. Configurar Hermes ══════════════════════════════════════════════════ titulo "3. Decirle a Hermes la verdad" if [ "$SOLO_MEDIR" = si ]; then gris " --solo-medir: no toco $CONFIG" gris " Para aplicarlo a mano: model.default=$MODELO y context_length=$GANADOR" else cp "$CONFIG" "$CONFIG.antes-de-arrancar" 2>/dev/null python3 - "$CONFIG" "$MODELO" "$GANADOR" <<'PY' import re, sys ruta, modelo, ctx = sys.argv[1], sys.argv[2], int(sys.argv[3]) lineas = open(ruta).read().splitlines(True) # Se edita por LINEAS, no reescribiendo el YAML: el fichero son 1890 lineas de # documentacion propia y volcarlo con yaml.dump la perderia entera. hecho_modelo = hecho_ctx = False for i, l in enumerate(lineas): if not hecho_modelo and re.match(r'^ default: ', l): lineas[i] = f' default: "{modelo}"\n'; hecho_modelo = True # context_length viene comentada de fabrica dentro del bloque model:. # La propia plantilla dice que es justo para "a local server with a custom # num_ctx", que es exactamente este caso. elif not hecho_ctx and re.match(r'^ # context_length: ', l): lineas[i] = (f' # JARVIS verde: el num_ctx REAL del Modelfile. Sin esto Hermes\n' f' # se cree lo que dice /api/show, que son 262144 (el maximo del\n' f' # modelo) y no lo configurado.\n' f' context_length: {ctx}\n') hecho_ctx = True if not hecho_modelo: sys.exit("no encontre la linea 'default:' en el bloque model") if not hecho_ctx: sys.exit("no encontre la linea '# context_length:' comentada") open(ruta, 'w').writelines(lineas) PY [ $? -ne 0 ] && abortar "no pude escribir la configuracion (copia en $CONFIG.antes-de-arrancar)" # Verificar PARSEANDO, nunca leyendo: este fichero declara provider y # base_url DOS veces dentro de model: y en YAML gana la ultima. Leyendolo # parecia configurado y el trafico salia por OpenRouter. python3 - "$CONFIG" "$MODELO" "$GANADOR" <<'PY' import sys, yaml ruta, modelo, ctx = sys.argv[1], sys.argv[2], int(sys.argv[3]) m = yaml.safe_load(open(ruta))['model'] fallos = [] if m.get('default') != modelo: fallos.append(f"default={m.get('default')}") if m.get('context_length') != ctx: fallos.append(f"context_length={m.get('context_length')}") if '127.0.0.1' not in str(m.get('base_url','')): fallos.append(f"base_url={m.get('base_url')}") if fallos: sys.exit("la configuracion NO quedo como se pedia: " + ", ".join(fallos)) print(f" modelo={m['default']} context_length={m['context_length']} base_url={m['base_url']}") PY [ $? -ne 0 ] && abortar "verificacion fallida; restaura con: cp $CONFIG.antes-de-arrancar $CONFIG" verde " ✓ configurado y verificado parseando" fi # ═══ 4. La prueba decisiva ═════════════════════════════════════════════════ titulo "4. ¿Sabe el modelo llamar herramientas?" gris " Esto va ANTES de abrir el TUI a proposito. Separa dos preguntas que" gris " juntas se contestan mal: ¿sabe el modelo emitir una llamada? y" gris " ¿funciona Hermes? Si falla aqui, no hay nada que depurar en Hermes." R=$(curl -s --max-time 300 "$OLLAMA/v1/chat/completions" -H 'Content-Type: application/json' -d "{ \"model\": \"$MODELO\", \"messages\": [{\"role\":\"user\",\"content\":\"¿Cuánto espacio libre queda en el disco?\"}], \"tools\": [{\"type\":\"function\",\"function\":{ \"name\":\"ejecuta_comando\", \"description\":\"Ejecuta un comando de shell y devuelve su salida\", \"parameters\":{\"type\":\"object\",\"properties\":{\"comando\":{\"type\":\"string\"}},\"required\":[\"comando\"]}}}] }" 2>/dev/null) echo "$R" | python3 -c " import json, sys try: d = json.load(sys.stdin) except Exception: print(' RESPUESTA ILEGIBLE'); sys.exit(2) if 'error' in d: print(' ERROR:', str(d['error'])[:200]); sys.exit(2) msg = d['choices'][0]['message'] tc = msg.get('tool_calls') or [] if not tc: print(' SIN tool_calls. Contesto en prosa:') print(' ', repr((msg.get('content') or '')[:200])) sys.exit(1) f = tc[0]['function'] print(' herramienta :', f['name']) print(' argumentos :', f['arguments']) try: json.loads(f['arguments']); print(' JSON valido') except Exception: print(' JSON ROTO'); sys.exit(1) sys.exit(0) " VEREDICTO=$? titulo "Resumen" printf ' contexto %s tokens (%s capas en GPU, %s MiB)\n' "$GANADOR" "$GANADOR_GPU" "$GANADOR_VRAM" printf ' prompt fijo ~20.500 tokens -> quedan ~%s de conversacion\n' "$HUECO" if [ "$VEREDICTO" -eq 0 ]; then verde " ✓ el modelo llama herramientas. Lo que falle a partir de aqui es de Hermes." gris "" gris " Siguiente:" gris " $AQUI/pruebas/ejecutar.sh las comprobaciones, ninguna abre el TUI" gris " $AQUI/verde-launcher.sh el TUI en verde -> sigue ESCALERA.md" else rojo " ✗ el modelo NO emite llamadas usables en este formato." gris "" gris " Abrir el TUI ahora solo daria un fallo mas confuso. Dos salidas:" gris " · modelo mayor por CPU: hay 49 GB de RAM y 8 nucleos fisicos." gris " qwen3.5:9b esta en disco. Lento pero es un banco de pruebas." gris " · recortar herramientas en platform_toolsets del config.yaml:" gris " cli: [hermes-cli] -> una lista corta. Menos esquema, menos lio." exit 1 fi