Inicio / Artículos

Prompts de sistema estructurados para agentes de IA Rust: capas, caché y verificaciones de desviación.

Crear un prompt de sistema en capas para un agente Rust: identidad, instrucciones, reglas de salida, ejemplos y contexto actualizado, además de un límite de caché y una huella digital para CI.

Editor: REACTAPP.TOP12 min de lectura2843 palabras

Fuentes: Building AI Agents in Rust — part 2

Un primer agente suele bastarse con un prompt del sistema que consiste en una sola cadena literal, quizás de sesenta palabras: responder preguntas sobre un proyecto Rust y llamar a read_file cuando se necesita ver el contenido de un archivo. Eso funciona porque hay exactamente una herramienta, un único usuario y ninguna regla sobre cómo deben ser las respuestas. Luego las cosas se complican: una segunda herramienta implica que el modelo debe aprender cuándo listar archivos y cuándo leerlos; una tercera exige enseñarle un orden específico. Una regla de salida como “citar el archivo que se leyó” necesita un lugar donde aplicarse. Una prueba de regresión que verifica si el prompt sigue dando la misma respuesta con entrada fija necesita una forma de detectar si alguien modificó accidentalmente el prompt. El caché de prompts hace que sea necesario saber qué partes cambian en cada turno y cuáles no. Una cadena literal simple no maneja todo esto de manera adecuada.

Lo que sigue es una instrucción estructurada para un agente llamado Eugene que sí puede manejarla: cuatro capas, un generador por tipo de entrada, contexto por solicitud, un único ejemplo de pocos ejemplos, un hash que indica desviaciones y un límite estático/dinámico para que la caché de instrucciones de Anthropic solo pague por la personalidad una vez en lugar de en cada turno. El ciclo del agente se mantiene igual; solo cambia la forma del mensaje del sistema.

Por qué la estructura ayuda

Una instrucción es como código, ya que dirige el comportamiento, y cada oración en ella compite por la atención del modelo. Un bloque largo e indiferenciado es ignorado parcialmente, mientras que un conjunto corto de secciones etiquetadas se lee con atención.

Parece supersticioso, pero se trata de un comportamiento observado. Claude y sus homólogos son entrenados con instrucciones en formatos familiares: prompts del sistema divididos en secciones, turnos del usuario que formulan solicitudes sencillas, y turnos del asistente que brindan respuestas directas. Al seguir esos formatos, el modelo tiene menos dudas sobre el significado de cada elemento, lo que reduce las alucinaciones, mejora la precisión de las respuestas y facilita una elección más adecuada de herramientas.

Las cuatro capas que se describen a continuación no son la única estructura válida. Son la más sencilla que logra sobrevivir a los cambios por los que pasa un agente real. Casi la misma estructura aparece en los prompts del sistema que Claude Code CLI crea para sí mismo, y eso no es casualidad: los agentes que resisten a los usuarios reales tienden a adoptar el mismo diseño.

Las capas

Identidad es un párrafo sobre quién está hablando, lo cual determina el tono, la experiencia y la postura. Ejemplo: Eugene es un asistente de investigación meticuloso en un proyecto de Rust que prefiere leer el código fuente en lugar de adivinar. No deben incluirse reglas ni hechos en este apartado.

Instrucciones son reglas de comportamiento; cada una es una afirmación verificable: utilice list_files para saber qué hay en el proyecto antes de leerlo, o no intente nuevamente una llamada que haya devuelto un error. Estas instrucciones se van acumulando a medida que el agente evoluciona. Guárdelas en una lista, ya que los viñetas son más fáciles de agregar y eliminar que los párrafos.

Restricciones de salida describen la forma de la respuesta y no su contenido: prosa sencilla sin formato Markdown, una cita entre paréntesis del archivo leído, respuestas de menos de 200 palabras. Se aplican independientemente de lo que haya preguntado el usuario. Separarlas de las instrucciones permite cambiar el formato sin afectar al comportamiento, y viceversa.

Ejemplos muestran cómo funciona la persona del agente. Un ejemplo de pocos casos logra en una sola línea lo que una instrucción requiere en tres, ya que muestra una buena salida, incluido el estilo de cita. Los ejemplos forman parte de la identidad del agente, por lo que se incluyen en el prefijo estático junto con la identidad y las instrucciones.

Contexto almacena todo aquello que varía según la ejecución: la fecha, la configuración regional del usuario, la raíz del proyecto y las habilidades disponibles en ese momento. Dado que cambia, no puede estar contenido en una cadena estática. Cuenta con su propio nivel por debajo del límite de la caché, que se reconstruye en cada solicitud.

Un constructor tipado en Rust

En Rust, la estructura se convierte en un constructor donde cada método representa un nivel. Este sistema determina qué niveles son estáticos y cuáles dinámicos, y representa el límite entre ellos.

let prompt = SystemPromptBuilder::new()
    .identity("You are Eugene, a careful research assistant who answers \
               questions about a Rust project. You prefer reading the source \
               over guessing.")
    .instruction("Use `list_files` to discover what is in the project before reading.")
    .instruction("Use `read_file` to inspect a specific file. Do not call it on \
                  paths you have not seen listed.")
    .instruction("If a tool returns an error, do not retry the same call.")
    .output_constraints("Answer in plain prose. Cite the file you read in parentheses, \
                         for example: (src/main.rs).")
    .example("What edition does Cargo.toml use?",
             "I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.")
    .context(format!("<env>\ntoday: {today}\nproject_root: {sandbox}\n</env>"))
    .build();

El compilador no puede verificar mucho aquí, pero la legibilidad mejora considerablemente. Puedes examinar la cadena y saber qué verá el agente. Los primeros cinco métodos forman el prefijo estático y el último crea el sufijo dinámico. El prefijo resultante se ve así:

## Identity

You are Eugene, a careful research assistant ...

## Instructions

- Use `list_files` to discover what is in the project before reading.
- Use `read_file` to inspect a specific file. Do not call it on paths ...
- If a tool returns an error, do not retry the same call.

## Output

Answer in plain prose. Cite the file you read in parentheses ...

## Examples

Example 1:
User: What edition does Cargo.toml use?
Assistant: I'll check Cargo.toml directly. (Cargo.toml) ...

El sufijo es más corto y completamente dinámico:

## Context

<env>
today: 2026-05-22
project_root: /Users/me/code/eugene
</env>

Los encabezados tienen un significado específico. “## Instrucciones” le indica al modelo que los puntos siguientes son reglas, mientras que “## Contexto” señala que las líneas siguientes son hechos. Sin ellos, estas dos categorías se mezclan y el modelo podría interpretar los hechos como comandos o viceversa. Con encabezados, la selección de herramientas es claramente mejor.

Las etiquetas al estilo XML que rodean la información del entorno funcionan de manera similar. El contenedor <env> es un marcador con el que el modelo se ha encontrado miles de veces durante su entrenamiento, por lo que interpreta su contenido como estado en tiempo de ejecución y no como orientación. Claude Code CLI transmite su directorio actual, estado de Git, sistema operativo e ID del modelo en su propio prompt de esta forma, y se trata de una convención que vale la pena adoptar.

La identidad da voz, no verdad

Esta capa es la que la gente sobrescribe con mayor frecuencia. Los principiantes la llenan de afirmaciones como ser un ingeniero experto en Rust con veinte años de experiencia en grandes empresas tecnológicas. Decirle a un modelo que es experto no lo convierte en tal. La identidad controla la forma de hablar: un asistente de investigación cuidadoso duda antes de hablar, un ingeniero rápido y directo responde de manera concisa, y un profesor paciente explica las cosas.

También determina la actitud ante la incertidumbre. Una instrucción que pide al agente que prefiera leer la fuente en lugar de adivinar es una herramienta muy efectiva; el modelo llama a read_file con más frecuencia cuando está presente esa instrucción, ya que esto afecta qué respuesta parece acorde con su personalidad. Trate la identidad como una guía para el comportamiento del agente, no como un currículum.

Las instrucciones son una lista

Cada instrucción debe aprobar o fallar por sí misma. “Usar list_files antes de leer” es verificable, ya que el modelo lo llama primero o no. “Ser útil para el usuario” no se puede verificar, así que hágalo desaparecer.

El orden es importante cuando las reglas entran en conflicto, porque el modelo las lee de arriba hacia abajo, con las reglas anteriores como punto de referencia y las posteriores para aclarar. Si dos reglas realmente chocan, la instrucción está mal formulada y el modelo elegirá al azar. Corrija el conflicto en la instrucción, no en el momento de la inferencia.

Tres o cuatro reglas son adecuadas para un agente pequeño. Diez ya es demasiado. Veinte representa un fracaso en sí mismo, porque el modelo no puede recordarlas todas y descarta algunas silenciosamente. Una lista larga suele indicar que falta una restricción de salida o un dato contextual que haría innecesarias varias reglas.

Las restricciones de salida separan el formato del comportamiento

Tome una instrucción que pida responder de manera cortés en prosa sencilla, citar el archivo entre paréntesis y mantenerse por debajo de 200 palabras. Esta combina tres aspectos: la cortesía forma parte de la identidad del mensaje, mientras que la citación y la longitud corresponden al contenido a generar. Cuando alguien solicita cambiar el formato de citación, basta editar una sola línea sin tener que volver a leer todo el resto.

Aquí también se integra la salida estructurada. Si un sistema posterior requiere formato JSON, la capa de salida indica responder en JSON que se ajuste al esquema proporcionado. Si el usuario pide que se indiquen los pasos, dicha capa indica numerar cada uno de ellos. El comportamiento del sistema sigue siendo el mismo, pero el formato se adapta según sea necesario.

Los ejemplos muestran, no explican

Un solo ejemplo suele reemplazar a varias instrucciones:

.example(
    "What edition does Cargo.toml use?",
    "I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.",
)

Ese único ejemplo demuestra el formato de citación, muestra que una breve agradecimiento es suficiente, establece la longitud esperada y modela la relación entre una llamada a la herramienta y una citación. Nada de esto aparece en las instrucciones, ni es necesario que lo haga.

El riesgo es el sobreajuste. Si cada ejemplo involucra a Cargo.toml, el modelo se vuelve extrañamente obsesivo con él. Utilice uno o dos ejemplos que abarquen el rango real de entradas. Si no puede escribir uno representativo, probablemente algo más en la instrucción esté insuficientemente especificado.

El contexto se reconstruye en cada solicitud

Al principio todos olvidan este aspecto. El modelo tiene un límite de conocimiento y no sabe la fecha actual, el nombre de su proyecto ni qué archivos existen. Adivinará, y esas conjeturas suenan plausibles aunque estén equivocadas. “Probablemente a mediados de 2024” no es una fecha útil.

La solución es sencilla: proporcione los datos necesarios cada vez.

let today = OffsetDateTime::now_utc()
    .date()
    .format(&Iso8601::DATE)
    .unwrap_or_else(|_| "unknown".into());
let sandbox = sandbox_root()?.display().to_string();

let prompt = system_prompt(&today, &sandbox);

Dos hechos: alrededor de medio kilobyte de tokens, y el agente ahora sabe cuándo y dónde se encuentra. El costo es insignificante y la precisión en preguntas sensibles a la fecha o al camino mejora mucho.

La capa de contexto de un agente real crece rápidamente: nombres de usuarios y proyectos, habilidades activas, la página que se está viendo, mensajes de error recientes, el hash del último commit. Nada de esto es opinión ni instrucción; son datos relacionados con una sola solicitud y proporcionados por el código.

El límite del caché

Esto es lo que diferencia a un agente basado en pasatiempos de uno cuyo funcionamiento es económico. La API de Anthropic ofrece caché de prompts: se debe pasar el campo system como un array de bloques de texto en lugar de una única cadena, etiquetar un bloque con cache_control: {"type": "ephemeral"}, y todo lo que esté dentro de ese bloque se almacenará en caché durante cinco minutos. Cualquier solicitud posterior que envíe el mismo prefijo accederá al caché, y esos tokens de entrada almacenados se facturarán a aproximadamente una décima parte del precio habitual.

Para un agente que opera en un bucle de seis turnos con una personaaje formada por mil tokens, eso representa una reducción de unos 5 veces en la facturación por entrada en cada turno posterior al primero, además de una menor latencia. Los ahorros aumentan según cuánto sea estable el prompt. La personaaje casi siempre lo es, pero la fecha de hoy no.

let blocks = prompt.into_system_blocks();
// blocks[0] = { type: "text", text: <static prefix>, cache_control: {type: "ephemeral"} }
// blocks[1] = { type: "text", text: <dynamic suffix> }   // no cache_control

El constructor se divide de forma natural. La identidad, las instrucciones, las restricciones de salida y los ejemplos son estáticos; el contexto es dinámico, y cada mitad se renderiza por separado con sus propios encabezados. Solo la primera contiene el marcador de caché, por lo que la segunda se vuelve a tokenizar en cada turno mientras la personalidad permanece en caché.

Esa lógica matemática es la razón por la cual el código fuente de Claude Code incluye un marcador literal SYSTEM_PROMPT_DYNAMIC_BOUNDARY en el que se basa su lógica de caché para dividir los datos. Todo lo que cambia entre turnos, como las conexiones MCP durante la sesión, las sobrescripciones del modelo o las preferencias de idioma, se encuentra por debajo de esa línea. La personalidad estática que está encima puede ser almacenada en caché entre usuarios siempre y cuando lo permita el proceso de construcción.

Dos reglas mantienen el caché activo. El prefijo debe ser idéntico en bytes entre las solicitudes; por lo tanto, un espacio adicional, una instrucción reordenada o un formato de fecha diferente dentro del prefijo cambian el hash y se debe volver a crear el caché. Además, el prefijo debe alcanzar 1024 tokens antes de que Anthropic lo almacene en caché. Las personasas pequeñas no ganan nada, mientras que las grandes obtienen grandes beneficios. Si la tuya es corta, agrega contenido que le permita ocupar ese espacio y no texto de relleno.

La respuesta incluye un comprobante:

[turn 0] in=4 cache_read=0 cache_create=1247 out=89
[turn 1] in=3 cache_read=1247 cache_create=0 out=42

La vuelta cero crea la caché, y cada vuelta posterior lee de ella. Imprima las estadísticas de uso durante el desarrollo, ya que la caché permanece invisible hasta que se mide.

Memorización de secciones dinámicas

Los límites de la caché permiten guardar tokens. Una versión más pequeña del contenido dentro del proceso ahorra recursos de cómputo. Algunos contextos dinámicos son costosos de crear, como escanear el sistema de archivos, cargar una instantánea de memoria o llamar a un almacén remoto de configuraciones. Repetir esas operaciones en cada vuelta desperdicia tiempo y ensucia los registros de seguimiento.

Memorice cada sección dinámica por su nombre y vuelva a calcularla solo en caso de invalidación explícita, como una nueva solicitud del usuario, un /clear o un /compact. El código fuente de Claude Code separa las secciones que pueden cachearse (systemPromptSection) de las volátiles (DANGEROUS_uncachedSystemPromptSection), y estas últimas exigen un argumento reason para que el autor justifique la invalidación de la caché. El nombre tan llamativo es merecido: una sola sección volátil que cambia en cada turno invalida todo lo posterior para esa solicitud.

En un agente de Rust, lo equivalente es un registro de entradas Section { name, compute_fn, cache_break } que se resuelven al crear la prompt, siendo cada resultado asociado a un identificador de turno. Por ahora, el paquete eugene-prompt sigue siendo más simple y vuelve a calcular cada sección dinámica en cada compilación. Si alguna de ellas es costosa, envuélvala en su propio caché e introdúzca el valor allí, ya que la función del paquete es el diseño y no la memorización.

Versionado y pruebas de regresión

El error más difícil en las prompts es el no intencionado. Alguien edita una oración para solucionar otro problema, el comportamiento cambia y rompe un caso de uso del que nadie se había dado cuenta, y ninguna prueba falla porque no existe ninguna prueba.

Una huella digital ayuda. El generador genera un hash del texto renderizado y lo expone en dos ocasiones: prefix_fingerprint para el prefijo por separado, que es lo que se utiliza como clave del caché, y fingerprint para toda la prompt.

const EXPECTED_PROMPT_FINGERPRINT: u64 = 0; // set after first successful run

if EXPECTED_PROMPT_FINGERPRINT != 0
    && prompt.fingerprint() != EXPECTED_PROMPT_FINGERPRINT
{
    eprintln!(
        "warning: system prompt fingerprint drifted (was {EXPECTED_PROMPT_FINGERPRINT}, \
         is {}). Update the constant if the change was intentional.",
        prompt.fingerprint()
    );
}

En CI, se compara la huella digital del prefijo con una constante. Cuando estas difieren, CI emite una alerta, y uno debe aceptar el cambio (actualizando la constante e informando al equipo del motivo) o rechazarlo (revertiendo). En cualquier caso, esto queda visible. La misma huella digital que evita las regresiones también indica con exactitud cuándo cambió el prefijo del caché, es decir, cuando la siguiente solicitud no encuentra el contenido en el caché.

Una huella digital es necesaria, pero no suficiente. La otra mitad consiste en pruebas de comportamiento: entradas fijas, comportamientos esperados fijos y un evaluador que compara la salida con lo esperado. La huella digital detecta el 90% de los casos triviales de desviación.

El resultado en Eugene

El bucle permanece sin cambios. Lo que difiere es el prompt estructurado y la forma en que se envía a través de las conexiones:

let prompt = system_prompt(&today, &sandbox);
let system_blocks = prompt.into_system_blocks();

let response = send(&http, &api_key, &system_blocks, &tools, &messages).await?;

Con list_files y read_file disponibles, las instrucciones y el ejemplo enseñan al agente a combinarlos. Al preguntársele qué hay en la carpeta src, enumera los archivos del directorio, elige uno probable, lo lee y responde con una cita. Al preguntársele sobre la edición en Cargo.toml, el agente primero dice que abrirá el archivo de configuración y luego indica la edición (2024) con el nombre del archivo entre paréntesis. La colocación de las citas es responsabilidad de la capa de salida; verificar en lugar de adivinar forma parte de las instrucciones, y el estilo de las citas proviene del ejemplo. El costo es solo una fracción de lo que sería sin los límites del caché.

Conclusión

Un literal codificado de forma fija está bien hasta que deja de estarlo. Cuando se dispone de varias herramientas, varios formatos de salida, varios ingenieros que editan la instrucción o cualquier expectativa presupuestaria, la estructura resulta muy útil. Las capas no son arbitrarias: la identidad es quién, las instrucciones son qué, la salida es cómo y el contexto es el momento actual. Si algo no encaja en alguna de ellas, son los requisitos del agente los que son ambiguos, no la estructura.

Los límites del caché convierten a la estructura en una forma de ahorrar tiempo. El constructor por escritura es la herramienta que Rust ofrece gratuitamente, y las huellas digitales representan pruebas de regresión económicas. Juntos permiten crear una instrucción de sistema que se pueda editar sin temor en una tarde de viernes.

Las dos herramientas aquí siguen siendo funciones ad hoc dentro de una instrucción match, sin interfaz compartida, sin generación de esquemas, sin posibilidad de intentos repetidos y sin forma de que un tercero proporcione una herramienta que el agente pueda utilizar. El siguiente paso lógico es contar con un rasgo Skill, un registro que gestione la tabla de distribución y los schemars para derivar JSON Schema a partir de una estructura Rust, de modo que el agente evolucione mediante composición en lugar de acumulación.