RLM en Ghostycode: mover la carga del modelo al harness

RLMGhostycodeAgentes IADeepSeekArquitectura15 min de lectura
RLM en Ghostycode: mover la carga del modelo al harness

Hace unas semanas escribimos sobre los 16 parches que costó meter OpenCode como provider out-of-process. Ese post terminaba con una lección incómoda: la frontera del proceso no era un detalle de implementación, era estructural. Este post es la otra cara de la misma moneda. Habla de lo que se gana cuando diseñas el harness en la dirección contraria: alrededor de un modelo concreto, para seguir sus patrones únicos. Sobre todo, de un modelo con "open-weights" ¡de 1.6 Trillones de parámetros!

Ghostycode es nuestro agente de terminal sobre DeepSeek V4: un TUI en Rust que lee, edita, corre shell, busca en el repo y coordina sub-agentes en sesiones largas. Lo jugoso vive debajo del TUI: la tesis que hereda, y dos patrones que la encarnan: cómo trata el prefix cache de DeepSeek, y RLM (Recursive Language Models). Pero, antes de eso, veamos de dónde sale.

El detonante: deepseek-tui

Voy a ser honesto con el origen, porque importa. Yo creé Ghostycode (Héctorbliss), pero no lo inventé en el vacío. El detonante fue deepseek-tui, el agente de terminal de Hunter Bown (Hmbown en GitHub) — hoy conocido como CodeWhale. Un harness open source en Rust para DeepSeek V4, MIT, community-driven, sin afiliación a ningún proveedor de modelos, que para mediados de 2026 ya rebasaba las 37 mil estrellas en GitHub. 🤩

Lo que me detonó no fue solo el código. Hunter ha sido explícito sobre construir una comunidad más allá del dinero — su propia frase para CodeWhale es que "the most eyes and the most hands, build the best agent harness". Pero lo técnicamente especial, lo que seguí, es otra cosa: un harness construido en torno al modelo para seguir sus patrones únicos, en vez de un adaptador genérico que trata a todos los LLMs igual.

Esa es la tesis de Ghostycode, heredada de deepseek-tui y de su autor, y es exactamente lo opuesto a un harness multi-provider tipo OpenCode. Lo más fácil de ver es el patrón que más dinero ahorra.

El harness construido en torno al modelo: prefix cache

DeepSeek V4 no factura cada turno como si fuera el primero. Cachea prefijos compartidos a granularidad de 128 tokens, con ~90% de descuento en lo que ya vio. El system prompt de Ghostycode lo dice:

"V4 caches shared prefixes at 128-token granularity with ~90% cost discount. Prefer appending to existing messages over mutating old ones — deletion or replacement breaks the cache and increases cost. Structure output to maximize prefix reuse across turns."

Léelo otra vez, porque tiene una consecuencia brutal: si mutas o borras mensajes viejos, rompes el cache y pagas precio completo. Un harness que reescribe el historial cada turno —para "limpiarlo", para recomprimir, para insertar contexto en medio— está tirando ese 90% a la basura sin enterarse.

Ghostycode está diseñado para no romperlo nunca. La regla más estricta vive en su constitución, como invariante protegido:

"Keep the active first-turn tool-catalog head byte-stable (DeepSeek KV prefix-cache invariant); changes to it must be one-time and deterministic."

El catálogo de herramientas del primer turno tiene que ser byte-idéntico corrida tras corrida. No "parecido": idéntico al byte. El comentario del core (crates/tui/src/core/engine/tool_catalog.rs) lo deja sin lugar a dudas:

"The head of the catalog (all non-deferred tools) must remain byte-identical across mode toggles (Plan ↔ Agent ↔ YOLO) [...] Deferred tool activations append to the tail and never reorder the head. This invariant is critical for DeepSeek's KV prefix cache: the tools array is part of the immutable prefix, and any byte-level change in the head forces a full re-prefill on the next turn."

Mira lo que esto implica en el código real. Las herramientas que no usas seguido no van en la cabeza: se difieren y se anexan a la cola sólo cuando hacen falta, para no mover un solo byte del prefijo. Los built-ins se ordenan por nombre y forman un bloque contiguo antes de los tools de MCP, así que agregar o quitar un servidor MCP nunca recorre la posición de un built-in. Y lo más fino: cambiar de modo Plan → Agent → YOLO no invalida el cache, porque la cabeza es la misma a nivel de byte en los tres modos. Agregar una tool nueva no es un parche incremental; es una edición determinista, de una sola vez. El mismo principio se propaga a todo:

  • Sub-agentes. Cuando un hijo necesita el contexto del padre (fork_context: true), el runtime preserva el prefill del padre byte-idéntico para que el prefix-cache de DeepSeek se reuse en el hijo, y recién entonces añade la instrucción del hijo al final.
  • Memoria. Los hechos recordados se ensamblan de forma que se queden dentro del prefix cache turno-a-turno, en vez de inyectarse en medio y partir el prefijo.
  • Compactación. Cerca del límite, el harness prefiere anexar y sugerir un /compact manual antes que reescribir en caliente y romper la afinidad de cache.

Aquí está la diferencia de fondo con OpenCode. Un harness multi-provider normaliza todos los modelos detrás de una sola interfaz genérica — su gracia es que cualquier LLM funcione igual. Pero esa generalidad tiene un costo estructural: no puede explotar la economía de cache de ningún modelo en particular, porque optimizar para el prefix cache de DeepSeek significaría tratar a DeepSeek distinto de los demás, y eso es precisamente lo que un adaptador genérico se prohíbe hacer. El harness genérico cobra el mínimo común denominador. El harness construido en torno a DeepSeek cobra el 10%. Me recuerda a las escuelas...

Eso es lo que quería decir Hunter con "patrones únicos", y es lo que seguí en Ghostycode. RLM es el segundo patrón — y el más profundo.

Qué es RLM (y quién lo firma)

RLM viene del blog post "Recursive Language Models" de Alex L. Zhang y Omar Khattab (octubre de 2025). El crate de Ghostycode lo cita en la cabecera del módulo — aunque, para ser honestos, con una cita adornada:

rust
//! Recursive Language Model (RLM) loop — paper-spec Algorithm 1.
//! Implements Zhang, Kraska & Khattab (arXiv:2512.24601, §2 Algorithm 1)

Esa cita lleva dos adornos que vale la pena confesar: suma a un tercer autor, Tim Kraska, que no está en el original, y apunta a un arXiv:2512.24601 que no existe — 24601 es el número de preso de Jean Valjean en Los Miserables, un placeholder que se coló en el comentario del código. La fuente real es el blog de Zhang y Khattab que enlacé arriba. Lo dejo a la vista porque maquillar la fuente sería justo lo que el Artículo II del propio harness prohíbe.

El nombre que importa aquí es Omar Khattab: autor de ColBERT, de DSPy y de GEPA — toda una línea de trabajo sobre tratar a los LLMs como módulos componibles y optimizables en lugar de como oráculos a los que les rezas un prompt. RLM es la continuación natural de esa idea, y por eso el roadmap de Ghostycode menciona DSPy y GEPA en la misma frase que RLM: son la misma familia de pensamiento.

La idea central de RLM es engañosamente simple. El problema de fondo es el context rot: a medida que metes más texto en la ventana de un modelo, su capacidad de razonar sobre ese texto se degrada. Más contexto no es más inteligencia; a partir de cierto punto es menos. La respuesta habitual es comprar ventanas más grandes y rezar.

RLM hace lo contrario. En vez de meter el prompt gigante P dentro de la ventana del modelo, lo guarda como una variable en un REPL de Python. El modelo raíz nunca ve P crudo. Lo que ve es metadata: longitud, un preview, la lista de helpers disponibles, un resumen de la ronda anterior. El modelo escribe código para inspeccionar, cortar y consultar esa variable — y, cuando hace falta, llama recursivamente a otro modelo (sub_RLM) sobre un pedazo.

Los invariantes del módulo lo dicen sin ambigüedad:

text
Invariants:
- `P` is held only as a REPL variable (`context` / `ctx`); never
  appears in the root LLM's window.
- The root LLM receives small metadata messages — length, preview,
  helper list, prior-round summary.
- Code rounds and sub-LLM calls travel over a single stdin/stdout
  pipe to a long-lived Python subprocess. No HTTP sidecar.

El loop es el Algorithm 1 del paper, casi calcado:

text
state ← InitREPL(prompt=P)
state ← AddFunction(state, sub_RLM)
hist  ← [Metadata(state)]
while True:
    code ← LLM(hist)
    (state, stdout) ← REPL(state, code)
    hist ← hist ∥ code ∥ Metadata(stdout)
    if state[Final] is set:
        return state[Final]

Fíjate en el detalle: el historial que crece (hist) acumula código y metadata de stdout, nunca el contenido completo. Un modelo deepseek-v4-flash —barato, sin thinking— puede así razonar sobre un repositorio entero o un log de 200k caracteres sin que su ventana colapse, porque nunca carga los 200k. Carga el len(), los primeros 4k, y el código para mirar el resto.

El patrón que me reventó la cabeza: objetos simbólicos

Aquí es donde Ghostycode se separa del resto. Un harness convencional, cuando quiere que el modelo razone sobre el transcript de la sesión o sobre un archivo grande, hace lo obvio: lo concatena en el prompt. El system prompt, el historial completo, el output de la última herramienta — todo apilado en la ventana, turno tras turno. Es lo que hace casi cualquier harness construido alrededor del estilo OpenAI/assistants: el contexto es la lista de mensajes.

Ghostycode trata esas mismas cosas como objetos simbólicos con nombre. El prompt activo, la metadata de sesión, el transcript, el último mensaje del usuario, cada mensaje por su índice — todos son objetos que RLM puede abrir sin copiar su texto crudo al transcript del padre:

text
session://active/system_prompt
session://active/transcript          (JSONL)
session://active/latest_user
session://active/messages/{index}
session://active/session             (metadata)

La herramienta rlm_session_objects los lista como tarjetas compactas; pasas un id a rlm_open y lo inspeccionas dentro de un REPL sin que un solo byte del transcript completo entre al contexto padre. Y cuando el código produce mucho output, no se vuelca crudo: por encima de 1.000 caracteres stdout/stderr se guardan como var_handles que recuperas a propósito con handle_read. El transcript padre se mantiene flaco a propósito.

rust
const MAX_INLINE_CONTENT_CHARS:    usize = 200_000;
const FULL_STDOUT_HEAD_CHARS:      usize = 4_096;
const FULL_STDOUT_TAIL_CHARS:      usize = 1_024;
const STDOUT_HANDLE_THRESHOLD_CHARS: usize = 1_000;

La diferencia es filosófica, no cosmética. En un harness clásico, el contexto es empujado al modelo: tú decides qué meter y el modelo carga con ello. En Ghosty, el contexto es referenciado y el modelo lo jala cuando lo necesita, en la cantidad que necesita. El primero hace que el modelo aguante la carga cognitiva del estado completo. El segundo se la quita de encima.

La tesis: el harness piensa, el modelo decide

El documento de visión de Ghostycode pone esto en una frase que conviene leer despacio:

"The harness handles memory, search, routing, state, and guardrails so a weaker model can just think. [...] A deepseek-v4-flash-class model should not have to remember ~80 tool names, hold the codebase index in its head, track which layer of memory a fact lives in, or re-derive a recovery path after a malformed tool call. The harness does that. The model decides what it wants; the harness figures out how."

Esto es exactamente lo contrario de la apuesta de un harness multi-provider. OpenCode —y la familia de harnesses que normalizan todo a una interfaz tipo OpenAI— optimizan para opcionalidad de modelo: que cualquier LLM detrás del socket funcione igual. Para lograrlo, el harness tiene que ser delgado y genérico, y la inteligencia tiene que vivir en el modelo. Funciona genial cuando tu modelo es GPT-class y caro.

Ghostycode apuesta lo contrario: un harness grueso y específico, que asume un modelo más débil y barato, y le quita trabajo hasta que ese modelo solo tenga que pensar. RLM es la expresión más afilada de esa apuesta — pero no la única. Hay tres patrones más que vale la pena nombrar, porque cada uno mueve carga del modelo al harness.

1. La constitución (el harness tiene leyes, no buenas intenciones)

El system prompt de Ghostycode es, literal, una constitución: con artículos y una jerarquía de autoridad explícita. Cuando dos directivas chocan, se resuelven en orden:

text
1. Constitution (Artículos I–VII)   — no negociable
2. Case Command (mensaje actual)    — máxima directiva del usuario
3. Statutes (permisos de modo)
4. Regulations (patrones, budgets)
5. Local Law (AGENTS.md, CLAUDE.md, .ghosty/instructions.md)
6. Evidence (output de herramientas, estado real del repo)
7. Memory (hechos y preferencias, nunca órdenes)
8. Personality (voz y tono)
9. Precedent (handoffs de sesiones previas)

El Artículo II —The Primacy of Truth— es el más importante:

"You shall not fabricate tool results. You shall not claim verification you did not perform. You shall not present memory as evidence. [...] This Article is non-negotiable."

Por qué importa para un modelo débil: un modelo barato es más propenso a inventar que vio un resultado, a confundir un recuerdo con evidencia, o a dejar que el CLAUDE.md del repo pise la orden directa del usuario. La constitución no le pide al modelo que sea listo; le da una máquina de resolución de conflictos para que no tenga que serlo.

2. Verificación dirigida por evidencia

El Artículo V lo convierte en disciplina operativa:

"After writing a file, read it back. After running a test, check the output. After making a claim, cite the tool result that supports it. Never declare success on faith. Verification is not optional. It is the difference between working code and a story about working code."

Y el repo lo cablea en su propia .ghosty/constitution.json, que define qué verificar antes de declarar algo terminado (correr los tests del crate tocado, releer los archivos editados) y cuándo escalar (acciones destructivas no autorizadas, cambios de provider/auth, borrar archivos que no creaste). El harness no confía en que el modelo "se acuerde" de verificar. Lo vuelve ley.

3. Budgets de RLM en la capa de Rust

La recursión es peligrosa: un sub-modelo que llama a otro sub-modelo puede explotar en profundidad, llamadas, tiempo o costo. Ghostycode no le pide al modelo que se autocontrole — pone los límites en Rust, fuera del alcance del modelo:

rust
const HARD_SUB_RLM_DEPTH_CAP: u32 = 3;   // tope duro de recursión
const MAX_RLM_ITERATIONS:     u32 = 25;  // iteraciones del loop
const MAX_CONSECUTIVE_NO_CODE: u32 = 3;  // si el modelo deja de emitir código
const DEFAULT_CHILD_MODEL: &str = "deepseek-v4-flash";

Y cuando el presupuesto de profundidad se agota, no devuelve un error: degrada con gracia a una completion normal, tal como pide el paper:

rust
async fn dispatch_rlm(&self, prompt: String, _model: Option<String>) -> SingleResp {
    if self.depth_remaining == 0 {
        // Presupuesto agotado — cae a una completion de un solo tiro
        // en vez de devolver error. ("sub_RLM gracefully degrades
        // to llm_query at depth=0").
        return self.dispatch_llm(prompt, None, None, None).await;
    }

Otra vez el mismo principio: el límite vive en el harness, no en la cabeza del modelo.

In-process vs out-of-process

Hay un eco directo con el post de OpenCode. Allá, el problema era que el LLM vivía en otro proceso, conectado por TCP, y eso traía binarios huérfanos que no morían y puertos que no se liberaban. RLM toma la decisión opuesta de forma deliberada: las rondas de código y las llamadas a sub-LLM viajan por un solo pipe stdin/stdout a un subproceso de Python de vida larga. No HTTP sidecar.

Aquí hay un trade-off real, y vale la pena ponerlo en palabras simples. Un socket HTTP genérico —el de OpenCode— acepta cualquier modelo: hoy DeepSeek, mañana GPT, pasado el que salga. Esa flexibilidad es su gracia. El precio es que la capa que conecta el harness con el modelo tiene que hablarles igual a todos, así que no puede aprovechar nada propio de ninguno: ni el prefix cache de DeepSeek, ni su REPL recursivo. Y, como contamos en el post de OpenCode, vivir en otro proceso te regala de propina los binarios huérfanos y los puertos colgados.

Ghostycode hace la apuesta inversa, a propósito: se casa con DeepSeek. Renuncia a cambiar de modelo con un flag, y a cambio sabe exactamente con quién habla — por eso puede exprimir el prefix cache, correr RLM sobre un pipe local y no pelearse nunca con TCP. Para un agente que corre miles de turnos baratos, esa boda paga sola.

Por qué nos importa esto en Formmy

No construimos Ghostycode para reemplazar a Claude en producción. Lo construimos porque la pregunta que resuelve —cómo hago que un modelo barato haga trabajo de modelo caro— es la misma que enfrentamos todos los días en los agentes de Formmy. La respuesta de la industria suele ser "usa un modelo más grande". La respuesta de RLM, de Khattab y compañía, y de Ghostycode, es más interesante: mueve la carga del modelo al harness. Hazte cargo de la memoria, la búsqueda, el estado, los límites y la verificación, y deja que el modelo haga lo único que ningún harness puede hacer por él: decidir qué quiere.

Es una apuesta a contramano de la moda multi-provider. Y, cuanto más tiempo pasamos del lado de los modelos baratos, más nos parece la apuesta correcta.


Prueba Ghostycode

Ghostycode es open source y vive en GitHub. Lo instalas, le conectas tu API key de DeepSeek, y empiezas a programar con un agente que exprime el modelo en vez de pelearse con él — con todo lo que viste en este post adentro: prefix cache, RLM y el harness constitucional.

Ghostycode en GitHub — el agente de terminal open source, construido en torno a DeepSeek V4.

¿Tienes preguntas sobre cómo lo construimos? Escríbenos.