Claude, de nuevo
Hace un tiempo comenté sobre Claude, y cómo, por cuestiones del trabajo, me veo obligado a usarlo. También comenté sobre mi experiencia al usarlo. Pero eso fue básicamente una primera impresión, después de pocos días de uso. Quería dar una pequeña acrualización, para ver qué tanto de mis impresiones iniciales se mantienen. También, ya que estoy, tengo algunas cosas más para agregar después de varias semanas de usarlo.
Revisando mis Opiniones
Claude no va a reemplazar a un programador
Esto es tal vez la opinión más fuerte que tenía en ese momento. Y la mantengo. Claude comete errores, muchas veces muy graves, que requieren que estés al tanto de lo que está haciendo. Sí, hay un montón de cosas que podés delegarle y olvidarte, y confiar en que no te va a romper todo, pero también hay montones de otras cosas que no, no es una buena idea.
Esto no significa que Claude (y herramientas similares) no vayan a causar una disrupción enorme en la industria. Cory Doctorow ha dicho varias veces que un modelo de lenguajes no puede hacer tu trabajo, pero un vendedor de modelos de lenguajes puede fácilmente convencer a tu jefe de que sí puede. Además de eso, incluso sin reemplazarte, si Claude te hace más rápido, significa menos horas de programador necesarias para completar las mismas tareas. Y eso me lleva al primer punto donde cambié un poco mi opinión.
Sí, Claude es más rápido que hacerlo a mano
¿Por qué cambié mi postura? Bueno, en parte porque empecé a aplicarlo a tareas más complejas, donde la diferencia de velocidad es más notoria (por una simple cuestión de escala), y en parte porque un poco de toquetear la configuración y adaptarme al flujo de trabajo hace bastante diferencia.
Admito que cometí un error que debería haber sabido que era un error. Asumí que, como tiene la interfaz más fácil de entender de la historia de las computadoras, entonces el aprender a usarlo iba a ser inmediato. Que la curva de aprendizaje era esencialmente una constante. Eso obviamente es falso. La curva de aprendizaje es bastante plana, sí, pero no quiere decir que no haya algunas cosas que aprender. Y esas pocas cosas hacen pila la diferencia.
Igual no siempre va a ser más rápido usar Claude que hacerlo a mano. Claude no tiene contexto. No tiene memoria. Emula esas cosas mediante ciertas instrucciones escondidas, pero las tiene que evaluar cada vez. Por poner un ejemplo bien básico, si yo te digo que hay que cambiar un mensaje de error por otro, y conocés el código, vas a saber derecho a qué archivo ir. O si no sabés exactamente, tenés una idea de por dónde puede estar, y con un grep rápido lo encontrás al toque. Claude no puede tener esa capacidad, por la misma forma en la que está construído un modelo de lenguajes. Para poder aprender realmente, y tener memoria, tendrías que poder ir adaptando los pesos del modelo con el uso. Eso no es posible por un montón de razones, entonces lo que pasa es que Claude simula memorias.
La forma en la que Claude simula memorias y conocimiento del contexto particular del proyecto es metiendo instrucciones y referencias en archivos Markdown. Por ejemplo, si Claude encuentra un archivo CLAUDE.md en la raíz del proyecto, lo lee y lo interpreta como un mensaje enviado al principio de cada sesión. Por el mismo formato con el que funcionan los LLMs, eso significa que esas mismas instrucciones se releen con cada mensaje que mandes. Esto simula conocimiento, pero obviamente no lo es. Las sesiones de Claude son igual de stateless que los requests HTTP. Que vos estés mandando cookies con cada request no hace que HTTP sea stateful, y de la misma manera que vos estés mandando un montón de instrucciones al principio de cada sesión no hace que Claude tenga conocimiento sobre el proyecto. Lo que estás haciendo es esconder todo ese trámite abajo de la alfombra.
Claude también te permite guardar cosas en “memoria”, que es el mismo concepto del CLAUDE.md pero en otro archivo y en general localizado a tu instancia local del proyecto. Claude incluso puede escribir esas memorias, lo cual profundiza un poco la simulación de memoria. Tenés que ser explícito en pedirlo. Vos le podés decir que, volviendo al ejemplo de arriba, tus mensajes están todos en el directorio ./assets/i18n, y Claude va a usar esa información durante esta sesión, pero cerrás y volvés a abrir y esa información desapareció. Para que se acuerde tenés que decirle algo bien explícito, tipo “acordate siempre en el futuro que los mensajes están todos en ese directorio”, y ahí va a escribir eso en un archivo (creo que es MEMORY.md) que va a ejecutar, de nuevo, al principio de cada sesión. Por razones bastante obvias, el conocimiento que se puede almacenar es mucho más limitado que el que podés tener con una memoria humana real.
Entonces, de nuevo, suponete que querés cambiar el mensaje de error. Si lo hacés a mano, en un proyecto que conocés, vas directo al archivo, o, como mucho, un grep rápido. Pero si le decís a Claude que lo haga, le mandás un mensaje diciendo “hay que cambiar este mensaje de error”, y lo que le estás mandando en realidad es un mensaje que dice todas sus memorias y contexto del proyecto (las que le dijiste explícitamente que guarde), y luego “hay que cambiar este mensaje de error”. Claude no va a “saber” qué archivo es. En el contexto, en todo ese mensaje enorme e invisible que le mandaste, capaz que dice algo tipo “los mensajes de la aplicación se guardan en este directorio”, pero ahí tiene que encontrar el mensaje específico. El modelo de lenguaje de fondo responde algo tipo “con esta información el siguiente paso es hacer esto”. Capaz que es un grep, y hace el grep, y entonces repite todo ese contexto pero agregándole ahora el resultado del grep, y entonces el modelo responde “ok, ese es el archivo, hay que leerlo, y ver exactamente qué cambiar”, y entonces manda a ejecutar otro grep pero diciéndole que imprima más contexto, y entonces pasa todo el chat anterior (contexto, memorias, mensaje original, resultado del grep) agregando el nuevo grep, y así hasta que eventualmente dice “lo que hay que hacer es generar este diff”, y te lo presenta y vos lo aprobás.
Todo ese ciclo es esencialmente invisible para vos. Lo que vos viste es que dijiste “hay que cambiar este mensaje de error”, y después de un rato de procesar, y tal vez pedirte permiso para usar grep, y varios mensajes diciendo que cargó “memorias” o “skills” varias, eventualmente te muestra un diff y te dice “¿cambio esto?”. Pero es fácil ver cómo para muchos casos bien sencillos puede ser más rápido hacerlo a mano.
El tema es que una vez que estás en la rosca de hacer todo con Claude (porque te acostumbraste, o porque te obligan, o por lo que sea) es re fácil caer en la pereza. Por ejemplo, el otro día hice un ls y me encontré con dos archivos en la raíz del proyecto que no tenían que estar ahí. Verifiqué rápido, y sí, los había agregado yo (sí, mal yo) en un par de merge requests en días anteriores. Obviamente había que borrarlos, así que mi primer reflejo fue ir a la terminal de Claude y decir “hay que borrar estos dos archivos”. Ridículo, sí, y no, no lo hice al final, me frené a tiempo y lo hice a mano (era literalmente un git rm y un git commit), pero el punto es que mi instinto fue ir a lo fácil.
Claude sigue siendo aburrido
Mi gran queja en el post anterior era justamente esa, que Claude no es divertido. Y no hay vuelta, sigue siendo aburrido. Sí, es más rápido, pero realmente sentís que no estás haciendo nada, sos como Homero Simpson en aquel capítulo en el que dejaba el patito apretando la Y. Y tampoco podés dejarlo andando solo al patito, porque tenés que revisar y ver que Claude no haya decidido que, no sé, capaz que en lugar de actualizar el proyecto original de la dependencia, era más fácil modificar los archivos dentro de node_modules, y al carajo todo. Sí, eso es una anécdota.
Mi queja nueva: Claude tiene la comunicación más ineficiente posible
En la ciencia ficción, cuando tenés inteligencias artificiales, siempre se pueden comunicar de maneras super eficientes, pasando datos crudos, bien compactos, sin redundancia, sin ambigüedades. Claude no funciona así.
En este momento, en el proyecto en el que estoy trabajando, hay un LLM en cada paso. Alguien a un nivel medio alto tiene una idea, la tira a un LLM para que genere una descripción. La pasa a un product owner, que la tira a un LLM para que genere una epic más detallada. Esa epic la agarra un tech lead, que se la tira a un LLM para que, mirando el proyecto, genere una serie de historias para implementar esa epic. Luego cada una de esas historias la agarra un desarrollador, que se la pasa a un LLM que escribe el código, y genera el merge request, y luego le avisa a otro desarrollador para que se lo pase a su LLM y para que revise el código y diga si aprueba o no. Hay cinco LLMs diferentes ahí, comunicándose a cada paso mediante las formas más ineficientes de lenguaje natural corporativo.
Pero ok, está bien, digamos que en realidad acá el problema es que los LLMs tienen que mantener a los humanos enterados de lo que están haciendo, y en cada paso lo que genera el LLM es un artefacto para consumo humano, no sólo de otro LLM, y ta, te la llevo. Pero volvamos al sistema de contexto y memoria que mencionaba arriba. ¿Todas esas memorias que escribe Claude? Sí, todo en lenguaje natural. Y con las posibles ambigüedades que siempre vas a tener cuando tenés lenguaje natural.
Y sí, esas ambigüedades sí aparecen. Por un ejemplo, tengo un proceso que corre y me manda un email cada día con un resumen de merge requests y mensajes de Slack y otras cosas en el proyecto. Ese email se genera con Claude (sí, quería jugar). Resulta que de un día al otro me cambió el formato de una sección del resumen. No sé por qué, pero sí es cierto que el nuevo formato cumple igual de bien la especificación que el formato anterior. Y otro día decidió no agregar los archivos adjuntos que tenía que agregar. Tampoco sé por qué, sólo pasó. Y no, no es sólo porque mi especificación es ambigua, porque la versión actual de la especificación para ese email es 100% escrita por Claude. Y de esa misma manera escribe las memorias. Y entonces le decís “acordate de siempre escribir tests primero, luego ejecutarlos, luego escribir la implementación”, y te dice “sí, ya está, me lo acuerdo”, y sale un mensaje de que escribió una nueva memoria, y después en una sesión dada es totalmente aleatorio si le da bola a ese cacho de instrucciones o no, y no hay mucho que puedas hacer al respecto, excepto toquetear esas instrucciones un poco y ver si por alguna casualidad le pegás al encantamiento correcto.
Y no sólo en esas cosas, sino también en la comunicación interna entre distintas “partes” de Claude. Si Claude ejecuta un subagent, las instrucciones para este van en forma de lenguaje natural, y sí, de nuevo, el tema de ambigüedades está presente.
El lenguaje natural tampoco es ideal para la comunicación humano-máquina
Las computadoras, en general, son cosas simples. Ejecutan una secuencia de comandos, uno atrás de otro, y dado el mismo estado inicial y la misma secuencia de comandos en el mismo orden, el resultado va a ser el mismo cada vez. Sí, ta, sobre eso construímos una torre enorme de cosas re complejas que simulan muchas tareas corriendo a la vez, y eso agrega un poco de impredecibilidad, y un montón de otras cosas, pero al final, si vas a lo más básico, una computadora es una máquina determinista. Y los lenguajes de programación que usamos son, también, deterministas. Un programa en cualquier lenguaje de programación de uso corriente es algo totalmente determinista y no ambiguo, asumiendo entradas constantes. Hasta para los números al azar usamos algoritmos deterministas para simularlos (o leemos entradas desde afuera del sistema). Sí, podés errarle y hacer un programa que no haga lo que querías, pero sí va a hacer lo que le dijiste que haga.
Los humanos no pensamos así. Los humanos pensamos en lenguajes ambiguos y desordenados. Crecemos con eso desde chicos, y aprendemos, algunos más y otros menos, a manejarnos con la ambigüedad constante. Cuando decimos algo, ese algo viene con un montón de contexto cultural y social y también específico de la relación con la otra persona, y todo eso lo tenemos en cuenta automáticamente cuando interpretamos el mensaje. Sí, a veces le erramos, y tenemos todo un género de películas esencialmente basado en la idea de que, a veces, uno dice algo y otro entiende otra cosa.
El trabajo de los programadores es, justamente, traducir desde esa sopa extraña de ambigüedades y conocimiento y contexto que es el lenguaje natural a algo concreto y específico como es el código de un programa.
El tema es que esa falta de ambigüedad de las computadoras es un feature, no un bug. Si yo escribo ls o ls -a, está muy clara la diferencia en la intención. Si yo te digo “mostrame todos los archivos de este directorio”, no está claro si estoy incluyendo o no los archivos ocultos. Ok, es un ejemplo boludo, y el “costo” de errarle es esencialmente cero, pero cuando tu interfaz deja de ser comandos concretos y no ambiguos y pasa a ser lenguaje natural, estás eliminando la posibilidad de tener procesos repetibles.
Y no estoy simplemente hablando en teoría. Aparte de lo que mencionaba arriba del email de resumen, que cambia de día a día, esto es algo que pasa constantemente con Claude. Si le decís a Claude algo tan simple como “monitoreá el continuous integration y avisame si falla algo”, el cómo implementa ese monitoreo es totalmente impredecible. A veces pone algo en un comando llamado /loop, que le dice “dentro de tanto tiempo ejecutá este mensaje”, y a veces hace un loop en bash con un for y un sleep y lee la salida de ese comando en el fondo. Por qué elige uno u otro, y en qué casos, es un poco misterioso. Capaz que hay algunas palabras mágicas específicas que le podés decir para que use uno u otro, estoy seguro que la hay, sí, pero el punto es que por defecto no sabés qué va a hacer.
Y mirá, capaz que es porque soy viejo y no me adapto a las formas modernas, pero cuando era joven si un programa funcionaba de maneras impredecibles con la misma entrada, lo considerábamos bugs.