Cap 21: Prompt Engineering & Structured Output

Por: Artiko
claude-codeprompt-engineeringfew-shotbatch-apici-cdstructured-output

Este capítulo cubre el Domain 4 del examen Claude Certified Architect – Foundations: técnicas de prompt engineering que producen outputs consistentes, reducen falsos positivos y escalan a producción.


1. Criterios explícitos para reducir falsos positivos

El problema con instrucciones vagas

Los falsos positivos destruyen la confianza. Si Claude marca como problema algo que no lo es, el equipo deja de confiar en las alertas y las ignora todas, incluidas las reales.

MALO: instrucción vaga

Revisa este código y señala cualquier problema de seguridad.

Este prompt produce inconsistencias graves:

BUENO: criterios categóricos específicos

Revisa este código y reporta ÚNICAMENTE los siguientes problemas de seguridad.
Sé conservador: si no estás seguro, NO lo reportes.

Categorías a reportar:
- CRÍTICO: credenciales hardcodeadas (contraseñas, API keys, tokens en literales de string)
- CRÍTICO: inyección SQL (concatenación de strings en queries sin parametrizar)
- CRÍTICO: API keys expuestas en código fuente o logs
- ALTO: deserialización de datos no confiables sin validación
- ALTO: ejecución de comandos del sistema con input de usuario

No reportar:
- Problemas de estilo o convenciones
- Posibles mejoras de performance
- Cualquier cosa que requiera contexto de negocio adicional

Por qué funciona

Los criterios categóricos establecen un contrato claro:

flowchart LR
    A[Input de código] --> B{¿Cumple criterio\nexacto?}
    B -->|Sí: credencial hardcodeada| C[REPORTAR CRÍTICO]
    B -->|Sí: SQL sin parametrizar| D[REPORTAR CRÍTICO]
    B -->|No encaja en ninguna categoría| E[IGNORAR]
    C --> F[Output confiable]
    D --> F
    E --> F

Ejercicio: reescribir instrucción vaga

Vago: “Verifica que el código siga buenas prácticas”

Categórico:

Verifica ÚNICAMENTE:
- [ ] Funciones con más de 50 líneas → reportar nombre y línea
- [ ] Variables sin tipo explícito en TypeScript → reportar nombre
- [ ] try/catch que captura Error genérico sin re-lanzar → reportar ubicación

No reportar convenciones de nombres, comentarios ni estructura de carpetas.

2. Few-shot prompting para consistencia

Qué es few-shot

Few-shot prompting consiste en incluir ejemplos de input/output deseado dentro del prompt. Es la diferencia entre decirle a Claude qué hacer y mostrarle cómo se ve el resultado correcto.

flowchart TD
    A[Zero-shot] --> B["Instrucción → Claude adivina formato"]
    C[Few-shot] --> D["Instrucción + Ejemplos → Claude replica patrón"]
    B --> E[Output inconsistente]
    D --> F[Output uniforme]

Cuándo usar few-shot

Estructura de un prompt few-shot

[Instrucción del rol y tarea]

[Ejemplo 1]
Input: <caso de ejemplo>
Output: <resultado exacto esperado>

[Ejemplo 2]
Input: <otro caso>
Output: <otro resultado>

[Query real]
Input: <el input actual>
Output:

Ejemplo: formatear hallazgos de code review

Sin ejemplos (zero-shot) — resultado inconsistente

Prompt:

Revisa este código y reporta los problemas encontrados.

Código: [código aquí]

Posibles outputs inconsistentes:

Con ejemplos (few-shot) — resultado uniforme

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();

const fewShotPrompt = `Eres un revisor de código. Analiza el fragmento y reporta hallazgos
usando EXACTAMENTE el formato de los ejemplos. Si no hay hallazgos, responde "Sin hallazgos."

---
Ejemplo 1
Input:
\`\`\`python
password = "admin123"
db.execute("SELECT * FROM users WHERE id=" + user_id)
\`\`\`
Output:
[CRÍTICO] L1 - Credencial hardcodeada: variable \`password\` contiene valor literal
[CRÍTICO] L2 - SQL Injection: concatenación de \`user_id\` sin parametrizar

---
Ejemplo 2
Input:
\`\`\`python
def calcular_total(items):
    return sum(item.precio for item in items)
\`\`\`
Output:
Sin hallazgos.

---
Ejemplo 3
Input:
\`\`\`python
api_key = os.environ.get("API_KEY")
response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"})
logging.info(f"Llamando API con key: {api_key}")
\`\`\`
Output:
[ALTO] L3 - API key expuesta en logs: \`api_key\` se imprime en logging.info

---
Input:
\`\`\`python
SECRET_TOKEN = "tok_live_abc123xyz"
conn.execute("DELETE FROM orders WHERE user=" + request.args.get("id"))
\`\`\`
Output:`;

async function reviewCode(code: string): Promise<string> {
  const message = await client.messages.create({
    model: 'claude-opus-4-5',
    max_tokens: 1024,
    messages: [
      {
        role: 'user',
        content: fewShotPrompt.replace(
          '```python\nSECRET_TOKEN',
          `\`\`\`python\n${code}`
        ),
      },
    ],
  });

  const content = message.content[0];
  return content.type === 'text' ? content.text : '';
}

Output consistente garantizado:

[CRÍTICO] L1 - Credencial hardcodeada: variable `SECRET_TOKEN` contiene valor literal
[CRÍTICO] L2 - SQL Injection: concatenación de `request.args.get("id")` sin parametrizar

Los ejemplos deben cubrir edge cases

Malo: solo ejemplos de happy path

Ejemplo 1: credencial obvia → reportar
Ejemplo 2: código limpio → sin hallazgos

Bueno: incluir casos borderline

Ejemplo 3: variable con nombre "key" pero valor de variable de entorno → sin hallazgos
Ejemplo 4: API key en comentario (no en código ejecutable) → sin hallazgos
Ejemplo 5: token en test con valor "fake-token-for-testing" → sin hallazgos

Sin ejemplos de edge cases, Claude asume que el patrón es más amplio de lo que es.


3. Reducción de ambigüedad con exemplares

El problema con términos ambiguos

Términos como “escalar”, “borderline”, “apropiado” o “razonable” son ambiguos. Dos personas razonables los interpretarían diferente.

Ambiguo:

Si el cliente hace una solicitud borderline, escálala al equipo humano.

¿Qué es “borderline”? ¿Amenaza velada? ¿Solicitud de reembolso por monto alto? ¿Lenguaje agresivo?

Solución: few-shot examples que demuestran la decisión

const customerSupportPrompt = `Eres un agente de soporte. Decide si RESPONDER directamente
o ESCALAR al equipo humano. Usa exactamente el formato del ejemplo.

---
Caso 1
Cliente: "Mi pedido llegó roto, quiero mi dinero de vuelta"
Decisión: RESPONDER
Motivo: Solicitud estándar de reembolso, dentro de política de 30 días
Respuesta: "Lamentamos el inconveniente. Procesaremos tu reembolso en 3-5 días hábiles..."

---
Caso 2
Cliente: "Llevo 3 semanas esperando y nadie me responde. Voy a publicar esto en redes"
Decisión: ESCALAR
Motivo: Cliente insatisfecho con riesgo de impacto en reputación, requiere atención prioritaria
Respuesta: [ESCALAR: cliente con historial de espera prolongada + amenaza de publicación]

---
Caso 3
Cliente: "Quiero cancelar mi suscripción"
Decisión: RESPONDER
Motivo: Cancelación estándar, proceso automatizable
Respuesta: "Puedo ayudarte con la cancelación. ¿Puedes confirmar el email de tu cuenta?"

---
Caso 4
Cliente: "Saben que tengo abogado ¿verdad? Esto no va a quedar así"
Decisión: ESCALAR
Motivo: Mención de acción legal, requiere revisión del equipo legal y gestión senior
Respuesta: [ESCALAR: mención explícita de acción legal]

---
Caso actual
Cliente: "Pagué por el plan premium hace dos semanas y todavía no tengo acceso a las funciones"
Decisión:`;

Con estos 4 ejemplos, Claude aprende que:


4. Diseño de prompts para CI/CD

Requisitos de un prompt de CI/CD

Un prompt en un pipeline de CI/CD debe cumplir:

  1. Determinismo: misma entrada → misma categoría de salida
  2. Bajo false positive: mejor perder un positivo que generar ruido
  3. Formato parseable: el pipeline necesita leer el resultado programáticamente
  4. Fail-safe: si Claude no está seguro, no bloquear el pipeline
flowchart LR
    A[PR abierto] --> B[CI trigger]
    B --> C[Claude review]
    C --> D{¿Hallazgos CRÍTICOS?}
    D -->|Sí| E[Block PR + comentario]
    D -->|No| F[Approve + comentario opcional]
    E --> G[Developer fix]
    G --> A

Prompt real de review de PR para CI

import Anthropic from '@anthropic-ai/sdk';

interface ReviewResult {
  status: 'BLOCK' | 'APPROVE';
  findings: Finding[];
}

interface Finding {
  severity: 'CRÍTICO' | 'ALTO';
  file: string;
  line: number;
  description: string;
}

async function reviewPullRequest(diff: string): Promise<ReviewResult> {
  const client = new Anthropic();

  const prompt = `Eres un revisor de seguridad en un pipeline de CI/CD.
Analiza el diff de Git y responde ÚNICAMENTE con JSON válido.

REGLAS:
- Sé conservador: en caso de duda, NO reportes
- Solo reporta problemas que BLOQUEEN el merge
- No reportes problemas de estilo, performance ni arquitectura

CRITERIOS DE BLOQUEO:
- Credenciales hardcodeadas (strings con patrones de key/token/password/secret)
- Inyección SQL directa (concatenación en queries)
- API keys o tokens en código fuente (no en .env ni variables de entorno)
- Datos de usuarios expuestos en logs

FORMATO DE RESPUESTA (solo JSON, sin texto adicional):
{
  "status": "BLOCK" | "APPROVE",
  "findings": [
    {
      "severity": "CRÍTICO" | "ALTO",
      "file": "nombre del archivo",
      "line": número de línea,
      "description": "descripción concisa del problema"
    }
  ]
}

Si status es APPROVE, findings debe ser un array vacío [].

DIFF A REVISAR:
${diff}`;

  const message = await client.messages.create({
    model: 'claude-opus-4-5',
    max_tokens: 2048,
    messages: [{ role: 'user', content: prompt }],
  });

  const content = message.content[0];
  if (content.type !== 'text') throw new Error('Unexpected response type');

  return JSON.parse(content.text) as ReviewResult;
}

Minimizar false positives: la regla “be conservative”

Incluir explícitamente “en caso de duda, no reportes” reduce los falsos positivos porque Claude entiende que el costo de un falso positivo (bloquear un PR legítimo) es mayor que el costo de un falso negativo en este contexto.

Esto es diferente a un contexto de auditoría de seguridad donde quieres máxima sensibilidad.


5. Multi-instance y multi-pass review

Por qué self-review es limitado

Cuando Claude revisa su propio output, retiene el razonamiento original. Si ese razonamiento tenía un error, es probable que el mismo error pase la revisión.

flowchart TD
    A[Claude genera código] --> B[Claude se auto-revisa]
    B --> C{¿Encuentra error?}
    C -->|Razonamiento incorrecto retenido| D[Error pasa la revisión]
    C -->|Razonamiento correcto| E[Error detectado]
    style D fill:#ff6b6b
    style E fill:#51cf66

Multi-pass: dividir por responsabilidad

En lugar de un solo prompt “revisa todo esto”, divide la revisión en passes con responsabilidades distintas:

async function multiPassReview(files: Record<string, string>) {
  const client = new Anthropic();

  // Pass 1: seguridad (alta prioridad, criterios estrictos)
  const securityPass = await client.messages.create({
    model: 'claude-opus-4-5',
    max_tokens: 2048,
    messages: [{
      role: 'user',
      content: `SOLO analiza seguridad. Criterios: [lista exacta]

      Archivos: ${JSON.stringify(files)}`
    }]
  });

  // Pass 2: correctitud lógica (independiente del pass 1)
  const logicPass = await client.messages.create({
    model: 'claude-opus-4-5',
    max_tokens: 2048,
    messages: [{
      role: 'user',
      content: `SOLO analiza lógica de negocio y casos edge.
      No revises seguridad ni estilo.

      Archivos: ${JSON.stringify(files)}`
    }]
  });

  return { security: securityPass, logic: logicPass };
}

Independent instances para verificación cruzada

Para decisiones críticas, usa dos instancias independientes y cruza resultados:

flowchart LR
    A[Código a revisar] --> B[Instancia A\nrevisor de seguridad]
    A --> C[Instancia B\nrevisor independiente]
    B --> D[Hallazgos A]
    C --> E[Hallazgos B]
    D --> F{Intersección}
    E --> F
    F -->|Ambas instancias coinciden| G[Alta confianza]
    F -->|Solo una instancia reporta| H[Revisión manual]
async function crossReview(code: string): Promise<{
  highConfidence: string[];
  needsManualReview: string[];
}> {
  const client = new Anthropic();

  const [reviewA, reviewB] = await Promise.all([
    client.messages.create({
      model: 'claude-opus-4-5',
      max_tokens: 1024,
      messages: [{ role: 'user', content: `Revisor A: ${code}` }],
      system: 'Eres el revisor A. Reporta hallazgos en formato JSON array de strings.',
    }),
    client.messages.create({
      model: 'claude-opus-4-5',
      max_tokens: 1024,
      messages: [{ role: 'user', content: `Revisor B: ${code}` }],
      system: 'Eres el revisor B. Reporta hallazgos en formato JSON array de strings.',
    }),
  ]);

  const findingsA: string[] = JSON.parse(
    reviewA.content[0].type === 'text' ? reviewA.content[0].text : '[]'
  );
  const findingsB: string[] = JSON.parse(
    reviewB.content[0].type === 'text' ? reviewB.content[0].text : '[]'
  );

  // Simplificado: en producción usar similitud semántica
  const highConfidence = findingsA.filter((f) => findingsB.includes(f));
  const needsManualReview = [
    ...findingsA.filter((f) => !findingsB.includes(f)),
    ...findingsB.filter((f) => !findingsA.includes(f)),
  ];

  return { highConfidence, needsManualReview };
}

Flujo completo de multi-pass review

sequenceDiagram
    participant CI as CI/CD Pipeline
    participant P1 as Pass 1: Seguridad
    participant P2 as Pass 2: Lógica
    participant P3 as Pass 3: Cross-verify críticos
    participant R as Resultado final

    CI->>P1: diff del PR
    P1-->>CI: hallazgos de seguridad
    CI->>P2: diff del PR (independiente)
    P2-->>CI: hallazgos de lógica
    CI->>P3: solo hallazgos CRÍTICOS de P1+P2
    P3-->>CI: confirmación o descarte
    CI->>R: merge de hallazgos confirmados

6. Batch processing con Message Batches API

Cuándo usar batch vs sincrónico

flowchart TD
    A{¿Necesitas resultado\nen menos de 1 hora?} -->|Sí| B[API sincrónica]
    A -->|No| C{¿Tienes más de\n10 requests similares?}
    C -->|No| B
    C -->|Sí| D[Message Batches API]
    D --> E[50% ahorro de costo]
    D --> F[Ventana de 24 horas]
    B --> G[Resultado inmediato\ncosto completo]

Características clave de Message Batches API

CaracterísticaValor
Ahorro de costo50% vs API sincrónica
Tiempo de procesamientoHasta 24 horas
Tamaño máximo de batch10,000 requests
Soporte multi-turnNo soportado
custom_idRequerido para correlación

Implementación completa

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();

interface CodeFile {
  path: string;
  content: string;
}

// Paso 1: crear el batch
async function createReviewBatch(files: CodeFile[]): Promise<string> {
  const requests = files.map((file) => ({
    custom_id: `review-${file.path.replace(/\//g, '-')}`,
    params: {
      model: 'claude-opus-4-5' as const,
      max_tokens: 1024,
      messages: [
        {
          role: 'user' as const,
          content: `Revisa este archivo con criterios de seguridad estrictos.
Responde solo con JSON: {"findings": [], "status": "CLEAN"|"ISSUES"}

Archivo: ${file.path}
\`\`\`
${file.content}
\`\`\``,
        },
      ],
    },
  }));

  const batch = await client.beta.messages.batches.create({ requests });
  console.log(`Batch creado: ${batch.id}`);
  return batch.id;
}

// Paso 2: verificar estado del batch
async function checkBatchStatus(batchId: string) {
  const batch = await client.beta.messages.batches.retrieve(batchId);
  console.log(`Estado: ${batch.processing_status}`);
  console.log(`Completados: ${batch.request_counts.succeeded}`);
  console.log(`Fallidos: ${batch.request_counts.errored}`);
  return batch;
}

// Paso 3: obtener resultados y resubmitir fallidos
async function processBatchResults(batchId: string): Promise<{
  results: Record<string, unknown>;
  failed: string[];
}> {
  const results: Record<string, unknown> = {};
  const failed: string[] = [];

  for await (const result of await client.beta.messages.batches.results(batchId)) {
    if (result.result.type === 'succeeded') {
      const content = result.result.message.content[0];
      if (content.type === 'text') {
        try {
          results[result.custom_id] = JSON.parse(content.text);
        } catch {
          failed.push(result.custom_id);
        }
      }
    } else {
      // errored o expired
      failed.push(result.custom_id);
      console.warn(`Falló ${result.custom_id}: ${result.result.type}`);
    }
  }

  return { results, failed };
}

// Uso completo
async function batchReviewPipeline(files: CodeFile[]) {
  // Refinamiento: primero revisar una muestra sincrónicamente
  const sample = files.slice(0, 3);
  console.log('Refinando prompt con muestra de 3 archivos...');

  // Validar que el prompt produce resultados parseables
  for (const file of sample) {
    const response = await client.messages.create({
      model: 'claude-opus-4-5',
      max_tokens: 1024,
      messages: [
        {
          role: 'user',
          content: `Revisa: ${file.path}\n${file.content}`,
        },
      ],
    });
    console.log(`Muestra OK: ${file.path}`);
  }

  // Ahora enviar el batch completo
  const batchId = await createReviewBatch(files);

  // Polling simple (en producción usar webhook o job scheduler)
  let batch = await checkBatchStatus(batchId);
  while (batch.processing_status === 'in_progress') {
    await new Promise((resolve) => setTimeout(resolve, 60_000)); // 1 min
    batch = await checkBatchStatus(batchId);
  }

  const { results, failed } = await processBatchResults(batchId);

  // Resubmitir fallidos sincrónicamente
  if (failed.length > 0) {
    console.log(`Resubmitiendo ${failed.length} requests fallidos...`);
    const failedFiles = files.filter((f) =>
      failed.includes(`review-${f.path.replace(/\//g, '-')}`)
    );
    for (const file of failedFiles) {
      const response = await client.messages.create({
        model: 'claude-opus-4-5',
        max_tokens: 1024,
        messages: [{ role: 'user', content: file.content }],
      });
      const content = response.content[0];
      if (content.type === 'text') {
        results[file.path] = JSON.parse(content.text);
      }
    }
  }

  return results;
}

SLA constraints: batch puede no ser adecuado

Si tienes un SLA de respuesta, evalúa la viabilidad de batch:

flowchart TD
    A[SLA requerido] --> B{¿Menos de 4 horas?}
    B -->|Sí| C[API sincrónica\no streaming]
    B -->|No| D{¿Menos de 24 horas?}
    D -->|Sí| E[Batch con monitoreo\n+ fallback sincrónico]
    D -->|No| F[Batch sin restricciones\nmáximo ahorro]
    E --> G{¿Batch terminó\na tiempo?}
    G -->|No| H[Fallback: sincrónico\npara los pendientes]
    G -->|Sí| I[Resultado batch]

Ejemplo: pipeline de auditoría nocturna de 500 archivos

Contra-ejemplo: notificación en tiempo real al usuario

Flujo recomendado: sample refinement → batch completo

sequenceDiagram
    participant Dev as Developer
    participant API as Claude API sync
    participant Batch as Batch API

    Dev->>API: 3-5 archivos de muestra
    API-->>Dev: resultados + validación de formato
    Dev->>Dev: ajustar prompt si es necesario
    Dev->>Batch: batch completo (500 archivos)
    Note over Batch: procesamiento async\nhasta 24 horas
    Batch-->>Dev: resultados con custom_id
    Dev->>Dev: procesar resultados\nresubmitir fallidos

El paso de refinamiento con muestra sincrónica es crítico: detecta errores de prompt antes de enviar el batch completo. Un error de parseo en el sample te ahorra horas de espera y el costo del batch entero.


Resumen: decisiones de diseño de prompt

flowchart TD
    A[¿Outputs inconsistentes?] -->|Sí| B[Agregar few-shot examples]
    A -->|No| C[¿Falsos positivos altos?]
    C -->|Sí| D[Agregar criterios categóricos\n+ 'be conservative']
    C -->|No| E[¿Términos ambiguos?]
    E -->|Sí| F[Agregar ejemplos de edge cases\ny decisiones borderline]
    E -->|No| G[¿Pipeline de CI/CD?]
    G -->|Sí| H[Formato JSON parseable\n+ fail-safe]
    G -->|No| I[¿Muchos archivos similares?]
    I -->|Sí| J{¿SLA < 4 horas?}
    J -->|Sí| K[API sincrónica]
    J -->|No| L[Batch API\n50% ahorro]
TécnicaProblema que resuelveCosto
Criterios categóricosFalse positivesPrompt más largo
Few-shot examplesInconsistencia de formatoTokens adicionales por ejemplos
Ejemplos de edge casesAmbigüedad en casos borderlineMás ejemplos en el prompt
Multi-pass reviewSelf-review sesgadoMúltiples llamadas API
Independent instancesVerificación cruzada2x costo para hallazgos críticos
Batch APICosto en volumenLatencia de hasta 24 horas

Siguiente: Structured Output