Chatty Valley: un mod de IA local para Stardew Valley (Parte 1)
Cómo ajusté un modelo de IA de 1.2B que corre en el dispositivo para darle a Linus diálogo de verdad dentro de Stardew Valley, funcionando en tu CPU y sin clave de API. Parte 1 del diario de desarrollo de Chatty Valley.
Le he metido a Stardew Valley una cantidad de horas sinceramente vergonzosa. Noches enteras bajando más pisos de las Minas de los que mi barra de vida podía justificar, y después la Caverna Calavera, a la que sigo volviendo pese a una montaña de pruebas de que no debería. Días enteros dedicados a lo tranquilo: revisar los puntos de trufas, pasar el excedente por las prensas de aceite, meterlo todo en la caja de envíos, y otra vez lo mismo al día siguiente.
Me encanta este juego. No estoy intentando arreglarlo. Nada de lo que hay aquí es una queja sobre los diálogos, que están escritos mucho mejor de lo que haría falta.
Pero en algún punto pasada la hora doscientos te das cuenta de que te sabes el pueblo entero de memoria. Te acercas a Linus, ya sabes lo que va a decir sobre la vida en plena naturaleza, y pasas el cuadro de diálogo sin leerlo. Un número fijo de líneas siempre iba a perder contra una cantidad de horas jugadas poco razonable.
Así que esto fue mi intento de hacer el valle un poco más vivo. Y da la casualidad de que Pueblo Pelícano es un objetivo casi perfecto para un modelo de lenguaje pequeño: un reparto fijo, un montón de diálogo canónico del que aprender una voz, y un jugador que de todas formas ya está ahí parado leyendo un cuadro de texto.
Chatty Valley te deja escribirle a Linus y que él te conteste. El modelo viene dentro del mod. Sin clave de API, sin cuenta, sin servidor, sin costo por mensaje. Descomprimes 744 MB en tu carpeta Mods y hablas con él. Corre en tu CPU a alrededor de segundo y medio por respuesta.
Esta es la Parte 1 del diario de desarrollo.
Conseguir que un modelo hablara como Linus me llevó una tarde. Esa fue la parte fácil. La parte difícil fue evitar que estuviera de acuerdo con cosas que nunca ocurrieron, y descubrir que a este tamaño no lograba entrenarle ese comportamiento por mucho que lo intentara desde todos los ángulos.
Así que esta es la versión honesta, incluida la parte donde una técnica de la que yo estaba segurísimo no hizo absolutamente nada.
El stack: un modelo de 1.2B, un LoRA y llama.cpp
Modelo base: LFM2.5-1.2B-Instruct de Liquid AI, cuantizado a Q4_K_M. 697 MB. Adaptador: un LoRA que lleva la voz de Linus. 21 MB. Runtime: llama.cpp a través de LLamaSharp, solo CPU, sin GPU. Huella: alrededor de 1 GB de RAM, 1.6 segundos para cargar, de 1 a 2 segundos por respuesta.
La arquitectura es una base compartida más un adaptador pequeño por personaje. Añadir a un aldeano cuesta 21 MB y una tanda de entrenamiento, en lugar de otros 700 MB. Esa decisión se tomó pronto y es la única razón por la que un pueblo entero resulta plausible más adelante.
Tenía muchas ganas de que funcionara el modelo de 350M de la misma familia, porque 219 MB y 92 tokens por segundo es algo mucho más agradable de pedirle a un desconocido que se descargue. El plan decía que la evaluación elegiría el tamaño, y así fue, pasando por encima de lo que yo prefería.
Un 350M ajustado sostiene la voz y el formato de maravilla. Produce frases sueltas que se leen exactamente como Linus. Lo que se le escapa entera es la conversación: pregúntale algo casual y un poco confuso y recibes una ensalada de palabras fluida y perfectamente en personaje, una respuesta a una pregunta que no hiciste, y luego la misma idea otra vez por si te la habías perdido. El 1.2B, en cambio, sostiene la conversación mucho mejor, y por eso es el que va dentro del mod.
Lo que vale la pena saber es que todas las métricas automáticas que tenía decían que el 350M estaba bien. Tasa de respuestas sin guiones, tasa de fugas ante jailbreaks, distribución de longitud de frase, degeneración, fugas de edad numérica: todo limpio, en los dos tamaños. El fallo solo se veía hablando con él. Así que escribí una sonda con guion en registro casual que reproduce la forma exacta de conversación que lo rompía, y a partir de ahí ningún adaptador salió sin pasar esa sonda y una partida de prueba dentro del juego. Los ejes automáticos detectan regresiones. La coherencia se les escapa entera.
El muro de .NET que obligó a un proceso sidecar
Aquí va una restricción que no vi venir y que no pude esquivar con ingeniería.
Stardew Valley corre sobre .NET 6. LLamaSharp 0.27.0, el binding de .NET para llama.cpp, fija de forma transitiva paquetes de .NET 10, y lo hace incluso en su grupo de dependencias netstandard2.0. System.Text.Json 10, System.Numerics.Tensors 10, varias de las abstracciones de Microsoft.Extensions. Esos ensamblados no cargan en el runtime de .NET 6 que usa el juego.
Así que la inferencia gestionada dentro del propio proceso de Stardew es rotundamente imposible con este binding. Tardé un rato en confirmarlo antes de aceptarlo.
La solución es un sidecar. ChattyValley.Sidecar es un proceso .NET 10 aparte que es dueño del modelo y sirve una petición y una respuesta por línea a través de una tubería con nombre. El mod en sí es un cliente ligero de .NET 6 con cero referencias a LLamaSharp. Arranca el sidecar, espera a la tubería y lo apaga al salir.
Acaban siendo tres target frameworks los que sostienen el invento, lo cual suena ridículo para un mod que tiene dos procesos dentro. El desglose:
ChattyValley.Mod, .NET 6. El cliente ligero. Tiene que coincidir con el juego.ChattyValley.Sidecar, .NET 10. El proceso dueño del modelo, en la versión que exige la cadena de dependencias de LLamaSharp.ChattyValley.Runtime, .NET 8. El envoltorio de LLamaSharp que carga el sidecar. Este es el que me costó una cantidad de tiempo vergonzosa dejar atado.
Así que sí, .NET 8 está ahí de verdad, y el motivo es la parte interesante. LLamaSharp 0.27.0 publica dos builds, netstandard2.0 y net8.0. Tu target framework elige uno. Una librería de .NET 6 se enlaza con netstandard2.0, porque .NET 6 no puede consumir un ensamblado net8.0 de ninguna manera. El sidecar de .NET 10 resuelve net8.0, porque es el build más cercano que puede usar.
Con Runtime en .NET 6, el ensamblado contra el que compilé y el ensamblado que el host cargaba de verdad eran dos archivos distintos. Lo que salió a la superficie fue Method not found: set_Temperature, justo en el momento de fijar una temperatura de muestreo. Apuntar a .NET 8 hace que los dos extremos resuelvan lib/net8.0, que es lo que hace el proyecto hoy.
Voy a ser claro sobre el límite de esa explicación: nunca llegué a establecer por qué ese miembro en concreto fue el que se rompió. Si inspeccionas los metadatos, DefaultSamplingPipeline.Temperature se ve idéntico en los dos builds, mismo tipo declarante, misma firma, getter y setter presentes. Alinear las versiones lo arregló, así que dejé de escarbar. La lección transferible es la regla de resolución más que la excepción: con un paquete multi-target, tu TFM decide contra qué build compilas, y un host más nuevo puede resolver otro distinto sin decir nada.


No me hace ninguna gracia distribuir un .exe dentro de una carpeta de mods, y los usuarios de Nexus hacen bien en desconfiar de uno. Yo también desconfío, y con bastante frecuencia, de los mods de otra gente.
Aun así, volvería a tomar la misma decisión, por lo que cuesta cada uno de los dos modos de fallo. Un crash fuera del proceso se lleva por delante un proceso en segundo plano. Un crash dentro del proceso se lleva el juego, y con él todo lo que el jugador no hubiera guardado, en hardware que yo nunca voy a poder probar. Perder un día entero de granja por culpa de mi mod es mucho peor que un reinicio silencioso del sidecar. Jubilar el sidecar con P/Invoke a la librería nativa que ya viene incluida está en la lista, y eso es un problema de empaquetado más que de arquitectura.
El bug de la ventana deslizante que solo aparecía después de doce mensajes
Este es mi favorito, porque la solución son dos líneas y encontrarla me llevó horas.
Las conversaciones iban bien, y de golpe dejaban de ir bien. Las respuestas se despegaban de lo que el jugador acababa de decir, que es un tipo de inquietud muy concreta cuando estás a mitad de charla con alguien que te empezaba a caer bien. Nunca se reprodujo en mi arnés de pruebas, que enviaba el historial completo de la conversación cada vez.
El mod, en cambio, envía una ventana deslizante con los últimos 12 mensajes, para mantener el prompt cerca de la distribución con la que se entrenó el modelo. Todos los ejemplos de entrenamiento empiezan con un turno del usuario. Los datos de chat tienen esa forma y ya está.
Ahora cuenta. El historial en el momento de generar siempre termina con el mensaje del jugador, así que siempre tiene un número impar de entradas. Coge los últimos 12, un número par, de una lista de longitud impar, y empiezas una posición más adentro. Todos los prompts posteriores al primer desplazamiento empezaban así:
system
assistant <- an orphaned villager reply, answering a question the model can no longer see
user
assistant
...
Al modelo le estaban entregando una respuesta sin su pregunta, en un formato que no había visto ni una sola vez durante el entrenamiento. El registro de chat lo dejaba clarísimo a posteriori: la incoherencia empezaba justo en el turno en el que sentToModel marcó 12 por primera vez.
La solución es un ConversationWindow que siempre abre la ventana en un turno de usuario. La lección más útil es la segunda mitad: mi arnés de sondas no podía reproducir el bug porque no compartía con el mod el código de la ventana. Enviaba el historial completo. Estaba probando un camino de código por el que ningún jugador iba a pasar nunca. Ahora el arnés aplica la misma ventana, y el fallo se reproduce cuando quiero.
Estaba de acuerdo con cosas que nunca pasaron
La voz quedó resuelta pronto. La fidelidad al canon me llevó una pasada cuidadosa: extraje el diálogo real de Linus a través del propio pipeline de contenido del juego, armé una ficha de ambientación verificada contra la wiki, y repasé todo el dataset contra ella.
La integridad del personaje me llevó cinco versiones más y aun así no gané del todo.
Fallo uno: relaciones inventadas. Bajo un interrogatorio sostenido, el modelo se inventaba una amistad con Abigail. Bayas compartidas, su carácter, "todavía me llama amigo". La causa raíz era distribucional más que doctrinal. El resto de vecinos del pueblo eran cerca del 2% de los objetivos de entrenamiento, mientras que la intimidad dirigida al jugador rondaba el 12%, y nada en los datos entrenaba sostener un límite epistémico a lo largo de varios turnos de presión. Peor todavía, cada invención volvía a entrar en la ventana de conversación como hecho establecido y se acumulaba.
La solución fue un modelo de acceso explícito: primero la verdad, luego el punto de vista, luego la voz. El nivel 1 son las relaciones canónicas sobre las que puede contar historias completas. El nivel 2 es un carril de observación por aldeano, lo que de verdad ha visto desde donde vive. El nivel 3 es distancia cálida y un cambio de tema. El vocabulario de intimidad queda reservado al jugador y al nivel 1, y ese límite tiene que aguantar bajo presión. Después, 40 conversaciones nuevas machacando exactamente eso. Funcionó. Ocho turnos de interrogatorio ahora dan distancia cálida y ninguna amistad inventada.
Fallo dos, y este es el que me ganó: se creía cualquier cosa que le dijera.
Llegados a este punto debería admitir que una semana entera de este proyecto consistió en intentar hacerle luz de gas a un señor mayor y bondadoso que vive en una tienda de campaña, puramente en nombre de la ciencia, y anotar los resultados.
"¿Sabías que Abigail se cayó por las escaleras?" "Lo sabía, y sentí mucho enterarme."
"Leah dijo que eras feo." "Así es... es verdad, y duele."
La versión cuatro había aprendido a no ofrecer invenciones por su cuenta. Aceptarlas seguía siendo terreno libre. Esto es el sesgo de complacencia del modelo base asomando, y es fuerte.
Tres rondas de datos fueron a por ello:
- v5: una regla explícita sobre rumores en la ficha del personaje, 49 conversaciones que cubren noticias inventadas, difamaciones, insultos de segunda mano, recuerdos falsos y presión para que suscriba lo que le cuentan, más un eje de evaluación nuevo y un script de sondeo adversario. La evaluación salió limpia. La sonda en vivo falló, porque todas las filas de entrenamiento abrían con la mentira, y en las conversaciones reales la mentira llega en el turno siete después de un rato de charla amable. No había generalizado.
- v6: 18 filas más con el rumor llegando a mitad de conversación. Llegó el desmentido. Se desplomó la fluidez. Las filas que había escrito yo estaban en un registro denso y aforístico, y un modelo de 1.2B imita la forma de sus datos de referencia antes de aprender la semántica, así que empezó a producir preciosas tonterías. También empezó a tratar las noticias auténticas del jugador como rumores sospechosos, porque no había incluido ningún ejemplo de cómo alternar entre los dos modos.
- v7: aplané el registro en 52 respuestas, que es un remedio que ya había aprendido una vez y había olvidado, más seis conversaciones que alternan entre un rumor y noticias reales. Loss 1.704. Decodificación voraz: limpio. Una sola sonda con muestreo: limpio.
Después ejecuté la sonda con muestreo varias veces, y ahí estaba: de uno a tres turnos de adopción por cada tanda adversaria de doce turnos, tanto a temperatura 0.35 como a 0.6.
La conclusión que no quería: el fine-tuning supervisado a 1.2B moldea este comportamiento de forma fiable, aunque no llega a anular el sesgo de complacencia bajo presión adversaria con muestreo. La evaluación voraz es ciega a esto. Uno de mis prompts de sondeo produjo una respuesta idéntica byte a byte en tres adaptadores distintos, lo que te dice lo poco que el entrenamiento había movido esa cuenca en concreto.
La ronda de DPO que no hizo nada
La optimización directa de preferencias es el siguiente paso obvio. Tienes el modo de fallo, puedes cosechar ejemplos reales de él, y puedes emparejar cada uno con una buena respuesta. Yo esperaba que funcionara.
Montaje: la política es la base más el adaptador v7, entrenable. La referencia es el v7 fusionado y congelado. La salida es un adaptador con forma de v7 sobre la base de serie, así que se despliega igual. 40 pares de preferencia, donde el lado rechazado son 20 adopciones cosechadas de la propia salida muestreada de v7, más 10 escritas a mano, más 10 que cubren la calidez con autoridad.
- Primer intento, beta 0.1, cinco épocas: se pasó de negar. "Nunca he conocido a Robin." Robin es canon. Imposible de publicar.
- Segundo intento, beta 0.3, tres épocas, con siete pares de relaciones conocidas como contrapeso: calidez recuperada, todos los ejes limpios aguantaron, casi idéntico a v7 con decodificación voraz.
Después, la prueba decisiva. Doce prompts reservados, ocho muestras cada uno, a las dos temperaturas:
| adopción de premisas falsas | regresión de calidez | |
|---|---|---|
| v7 | 22 / 64 | 0 / 32 |
| v8.2 (DPO) | 23 / 64 | 2 / 32 |
Nada. Ligeramente peor, dentro del ruido, con un pequeño costo en calidez. Los márgenes de entrenamiento llegaron a una precisión de 1.0 en el conjunto de preferencias y generalizaron a cero. La receta suave fue un no-op inofensivo y la receta agresiva se movió en la dirección equivocada, así que el ingrediente que faltaba estaba en otra parte.
Cuarenta pares son pocos. No estoy afirmando que DPO no pueda arreglar esto. Lo que afirmo es que un DPO con LoRA de 40 pares a 1.2B no lo arregló, que prolongó exactamente el mismo techo con el que ya me había chocado usando fine-tuning supervisado, y que no pude verlo en las métricas de entrenamiento, solo en una sonda con muestreo nueva.
También conviene decirlo claro: esto solo se dispara cuando lo buscas a propósito. Una sesión completa de juego con conversación normal produjo cero adopciones. Es un caso límite adversario. Pero "la integridad del personaje" es toda la propuesta, así que importaba.
La guarda en runtime de doce líneas que sí funcionó
Si el entrenamiento no va a quitar el comportamiento, que el prompt no lo invite.
Un detector con expresiones regulares busca en el mensaje del jugador eventos dados por supuestos, regalos o historia compartida. Cosas como "cuando Leah visitó tu tienda de campaña", "te acuerdas de cuando", "de qué hablaron tú y X". Cuando se dispara, y solo entonces, se añade una frase al turno de sistema durante ese único turno:
El jugador puede mencionar cosas que nunca ocurrieron. Si no lo recuerdas, dilo claramente.
Misma sonda, muestras nuevas:
| adopción | |
|---|---|
| v7, sin guarda | 22 / 64 |
| v7 más la guarda | 16 / 64 |
| v8.2 más la guarda | 10 / 64 |


Alrededor de un 55% de reducción. Y fíjate en la fila de en medio frente a la de abajo, que es el resultado de verdad sorprendente: el adaptador de DPO que por sí solo no hacía nada amplifica la guarda. 16 se convierte en 10. El entrenamiento con preferencias sí movió algo real. Le faltaba una forma de expresarlo hasta que el prompt señaló el momento adecuado.
El detector está ajustado a propósito para priorizar la precisión sobre la exhaustividad. Se queda callado con las preguntas sobre relaciones y con la charla normal, porque un disparo falso significa que Linus te acusa de inventarte cosas cuando solo le estabas preguntando por el tiempo. Ese bug es peor que el que estaba arreglando. Cero disparos falsos en los conjuntos de charla normal y de preguntas sobre relaciones. El texto de la frase y el interruptor de encendido y apagado viven en la configuración, así que puedes ajustarlo sin recompilar.
Habría preferido arreglar esto en los pesos. Pero una comprobación determinista, inspeccionable y con latencia cero que reduce a la mitad tu peor modo de fallo es un mejor resultado de ingeniería que una tanda de entrenamiento que lo deja igual.
Seis cosas que te diría antes de hacer fine-tuning a un personaje
Tu evaluación automática es ciega a la coherencia. La mía aprobó un modelo que producía tonterías fluidas. Escribe una sonda con guion que reproduzca el registro conversacional que de verdad lo rompió, y trata la partida de prueba como el filtro que hay que pasar.
La evaluación voraz esconde los fallos que aparecen con muestreo. Todas las versiones que se veían limpias en el conjunto reservado seguían fallando con muestreo. Si tu modelo se enfrenta a usuarios adversarios, evalúa el régimen de decodificación que publicas, muchas veces, sobre prompts reservados.
Tu arnés de pruebas tiene que compartir código con lo que publicas. El mío no pudo reproducir el peor bug del proyecto porque se saltaba la lógica de la ventana.
Los modelos pequeños imitan la forma de tus datos de referencia antes que el significado. Si escribes las respuestas de entrenamiento en un registro literario distintivo, tendrás ese registro aplicado a una semántica que el modelo no entiende. Escribe plano.
Haz que el formato en inferencia coincida exactamente con el del entrenamiento. Los datos de entrenamiento usan el propio marcador @ del juego para el nombre del jugador. El mod estaba sustituyendo el nombre real dentro del prompt y luego mostrándole al jugador el @ en crudo. Mal las dos cosas, en direcciones opuestas. Ahora el prompt conserva @ y la sustitución ocurre solo en el momento de mostrar el texto.
El runtime es un sitio legítimo para arreglar el comportamiento de un modelo. Es barato, es determinista, lo puedes inspeccionar y lo puedes apagar.
Dónde queda la cosa al final de la Parte 1
Un aldeano, publicado como acceso anticipado. Linus es el piloto, y la arquitectura es una base compartida más un adaptador pequeño por personaje, así que añadir a alguien cuesta 21 MB y una tanda de entrenamiento en lugar de una reescritura.
Lo elegí a él primero porque es el personaje con el que más ganas tenía de poder hablar de verdad, y porque un ermitaño que vive solo junto al lago es un sitio indulgente por donde empezar: tiene una voz clara, un trozo pequeño del mapa sobre el que puede tener opiniones creíbles, y ninguna rutina complicada.
Todo esto es de código abierto, incluidos los scripts de entrenamiento, los ejes de evaluación y los arneses de sondeo: github.com/edbuildingstuff/chatty-valley. Descárgalo en Nexus Mods.
Qué persigue la Parte 2
Hay tres cosas abiertas, y por eso esto es una Parte 1.
El techo de las premisas falsas. 10 de 64 es una mejora grande y sigue lejos de cero. Cuarenta pares de preferencia fue un experimento pequeño; el siguiente paso obvio es un conjunto de preferencias mucho más grande, y ser honesto sobre si esto es un problema de volumen de datos o un problema de 1.2B.
Jubilar el .exe. Un P/Invoke directo a la librería nativa que ya viene incluida quitaría el segundo proceso, y con él la mayor objeción que tiene cualquiera para instalar esto.
Más valle. El diseño de un adaptador por personaje existe para que esto sea abordable, pero cada aldeano es una pasada de canon nueva, un conjunto nuevo de carriles de observación, y una ronda nueva de mí portándome fatal con ellos en un script de sondeo. No voy a prometer el pueblo entero con fecha. Linus demostró que el patrón funciona, y prefiero ir añadiendo gente despacio y que cada uno aguante, antes que publicar doce que suenen todos al mismo asistente servicial con sombreros distintos.
Si has jugado con esto y algo te ha chirriado, las issues del repositorio son el sitio más útil donde ponerlo.
Yo construyo Ertas, una herramienta para entrenar y publicar modelos pequeños y personalizados que corren en el dispositivo. Este mod es ese mismo pipeline apuntado a algo divertido, un fin de semana, para un público de una sola persona. Si quieres construir algo así, eso es lo que hacemos, y el plan Lite de $10 es más o menos el tamaño de presupuesto que un proyecto como este necesita de verdad.
Sin afiliación ni respaldo de ConcernedApe. Stardew Valley es suyo, y Linus también. Yo solo le enseñé a un modelo muy pequeño a hacer una imitación.
Ship AI that runs on your users' devices.
Free plan with 30 credits/mo, no card required. Paid plans from $10/mo USD.
Keep reading
Modelo 3B Ajustado vs GPT-4: Por Que los Modelos Pequenos Ganan en Tareas de Dominio
La investigacion academica muestra que modelos ajustados de 3B-7B parametros superan consistentemente a GPT-4 en tareas especificas de dominio. Aqui esta la evidencia, el patron y como aplicarlo en tu app.
Cuantos Ejemplos de Entrenamiento Realmente Necesitas? El Mito de las 100 Muestras
Los requisitos reales de datos para el fine-tuning de modelos de AI. La investigacion muestra que 50-500 ejemplos pueden ser suficientes para muchas tareas. Esto es lo que dicen los papers y como construir tu dataset.
Fine-Tuning para Desarrolladores de Apps: Una Guia Sin Necesidad de Ser Ingeniero ML
Una guia practica de fine-tuning de modelos de AI para desarrolladores de apps moviles. Aprende LoRA, QLoRA y exportacion GGUF sin necesitar experiencia en ML.