Cap 22: Structured Output, Validation Loops y Context Management

Por: Artiko
claude-codestructured-outputvalidationcontext-managementtool-usejson-schemacertificacion

1. Structured Output con tool_use y JSON schemas

Por qué tool_use es más confiable que “responde en JSON”

Una instrucción como “responde únicamente en JSON con este formato…” produce resultados frágiles. Claude puede agregar texto antes del bloque JSON, usar comillas incorrectas o simplemente olvidar el formato bajo presión de contexto largo.

tool_use resuelve esto estructuralmente: el modelo debe llamar la herramienta para completar su turno. La respuesta llega garantizada en el campo input del bloque tool_use, parseada automáticamente por el SDK.

Ventajas concretas:

Anatomía de un schema robusto

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

const client = new Anthropic();

// Schema para extracción de facturas
const invoiceTool: Anthropic.Tool = {
  name: "extract_invoice",
  description:
    "Extrae los datos estructurados de una factura. Llama esta herramienta siempre, incluso si los datos son incompletos.",
  input_schema: {
    type: "object" as const,
    properties: {
      vendor: {
        type: "string",
        description: "Nombre del proveedor o empresa emisora",
      },
      amount_total: {
        type: "number",
        description: "Monto total de la factura en la moneda indicada",
      },
      currency: {
        type: "string",
        enum: ["USD", "EUR", "MXN", "ARS", "CLP", "COP"],
        description: "Código ISO de la moneda",
      },
      date_issued: {
        type: "string",
        description: "Fecha de emisión en formato ISO 8601 (YYYY-MM-DD)",
      },
      invoice_number: {
        type: ["string", "null"],
        description: "Número de factura. null si no está presente en el documento",
      },
      line_items: {
        type: "array",
        items: {
          type: "object",
          properties: {
            description: { type: "string" },
            quantity: { type: "number" },
            unit_price: { type: "number" },
            subtotal: { type: "number" },
          },
          required: ["description", "subtotal"],
        },
        description: "Ítems individuales de la factura",
      },
      status: {
        type: "string",
        enum: ["complete", "partial", "uncertain"],
        description:
          "complete: todos los campos principales extraídos. partial: faltan algunos campos no críticos. uncertain: datos ambiguos o posiblemente incorrectos",
      },
      confidence: {
        type: "number",
        description: "Score de confianza de 0.0 a 1.0 sobre la extracción completa",
      },
      detected_pattern: {
        type: ["string", "null"],
        description:
          "Patrón de factura detectado (ej: 'SAT México CFDi', 'EU VAT invoice', 'US standard'). null si no reconocido",
      },
    },
    required: [
      "vendor",
      "amount_total",
      "currency",
      "date_issued",
      "line_items",
      "status",
      "confidence",
    ],
  },
};

Puntos clave del schema:

Forzar la llamada con tool_choice

async function extractInvoice(documentText: string) {
  const response = await client.messages.create({
    model: "claude-opus-4-5",
    max_tokens: 2048,
    tools: [invoiceTool],
    tool_choice: { type: "any" }, // Fuerza al modelo a llamar alguna herramienta
    messages: [
      {
        role: "user",
        content: `Extrae los datos de la siguiente factura:\n\n${documentText}`,
      },
    ],
  });

  // El SDK garantiza que stop_reason === "tool_use" cuando tool_choice es "any"
  const toolUseBlock = response.content.find((b) => b.type === "tool_use");
  if (!toolUseBlock || toolUseBlock.type !== "tool_use") {
    throw new Error("El modelo no llamó la herramienta (no debería ocurrir con tool_choice: any)");
  }

  return toolUseBlock.input as InvoiceData;
}

2. Validation loops con retry y feedback

Flujo de validación

flowchart TD
    A[Documento de entrada] --> B[Extracción con tool_use]
    B --> C{Validación estructural}
    C -- válido --> D{Validación semántica}
    C -- inválido --> E{¿Es retryable?}
    D -- válido --> F[Routing por confidence]
    D -- inválido --> E
    E -- sí y retries < 3 --> G[Construir feedback específico]
    G --> H[Retry con contexto acumulado]
    H --> B
    E -- no retryable --> I[Degradación elegante]
    E -- retries >= 3 --> I
    I --> J[Enqueue para revisión humana]
    F -- confidence >= 0.85 --> K[Procesamiento automático]
    F -- confidence < 0.85 --> J

Qué es retryable y qué no

Esta distinción es crítica para evitar loops infinitos:

Retryable:

NO retryable (isRetryable: false):

interface ValidationResult {
  valid: boolean;
  errors: ValidationError[];
}

interface ValidationError {
  field: string;
  message: string;
  isRetryable: boolean;
  hint?: string; // Feedback específico para el LLM
}

function validateInvoice(data: InvoiceData): ValidationResult {
  const errors: ValidationError[] = [];

  // Validación de formato de fecha
  const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
  if (!dateRegex.test(data.date_issued)) {
    errors.push({
      field: "date_issued",
      message: `Formato de fecha inválido: "${data.date_issued}"`,
      isRetryable: true,
      hint: "La fecha debe estar en formato ISO 8601: YYYY-MM-DD. Ejemplo: 2026-03-23",
    });
  }

  // Validación semántica: ¿los line items suman el total?
  if (data.line_items.length > 0) {
    const sumFromItems = data.line_items.reduce((acc, item) => acc + item.subtotal, 0);
    const tolerance = data.amount_total * 0.02; // 2% de tolerancia por redondeos
    if (Math.abs(sumFromItems - data.amount_total) > tolerance) {
      errors.push({
        field: "amount_total",
        message: `Los line_items suman ${sumFromItems.toFixed(2)} pero amount_total es ${data.amount_total}`,
        isRetryable: true,
        hint: "Revisa si hay impuestos, descuentos o cargos adicionales no capturados en line_items",
      });
    }
  }

  // Validación de confidence consistente con status
  if (data.status === "complete" && data.confidence < 0.7) {
    errors.push({
      field: "confidence",
      message: "Status 'complete' pero confidence < 0.7 es inconsistente",
      isRetryable: true,
      hint: "Si extraíste todos los campos pero con baja seguridad, usa status: 'uncertain'",
    });
  }

  return { valid: errors.length === 0, errors };
}

Loop de retry con feedback acumulado

const MAX_RETRIES = 3;

async function extractWithRetry(documentText: string): Promise<ExtractionResult> {
  const conversationHistory: Anthropic.MessageParam[] = [
    {
      role: "user",
      content: `Extrae los datos de la siguiente factura:\n\n${documentText}`,
    },
  ];

  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
    const response = await client.messages.create({
      model: "claude-opus-4-5",
      max_tokens: 2048,
      tools: [invoiceTool],
      tool_choice: { type: "any" },
      messages: conversationHistory,
    });

    const toolUseBlock = response.content.find((b) => b.type === "tool_use");
    if (!toolUseBlock || toolUseBlock.type !== "tool_use") continue;

    const extracted = toolUseBlock.input as InvoiceData;
    const validation = validateInvoice(extracted);

    if (validation.valid) {
      return { data: extracted, attempts: attempt + 1, success: true };
    }

    // Filtrar errores retryables
    const retryableErrors = validation.errors.filter((e) => e.isRetryable);
    const nonRetryableErrors = validation.errors.filter((e) => !e.isRetryable);

    if (nonRetryableErrors.length > 0 || retryableErrors.length === 0) {
      // No tiene sentido reintentar
      return {
        data: extracted,
        attempts: attempt + 1,
        success: false,
        errors: validation.errors,
        requiresHumanReview: true,
      };
    }

    // Construir feedback específico para el siguiente intento
    const feedbackLines = retryableErrors.map(
      (e) => `- Campo "${e.field}": ${e.message}${e.hint ? `\n  Sugerencia: ${e.hint}` : ""}`
    );

    // Agregar la respuesta del modelo y el feedback como parte del historial
    conversationHistory.push(
      { role: "assistant", content: response.content },
      {
        role: "user",
        content: [
          {
            type: "tool_result",
            tool_use_id: toolUseBlock.id,
            content: `Encontré los siguientes problemas en la extracción:\n${feedbackLines.join("\n")}\n\nPor favor corrige estos campos y vuelve a llamar la herramienta.`,
          },
        ],
      }
    );
  }

  // Se agotaron los retries
  return {
    data: null,
    attempts: MAX_RETRIES,
    success: false,
    requiresHumanReview: true,
    errors: [{ field: "general", message: "Máximo de reintentos alcanzado", isRetryable: false }],
  };
}

3. Confidence scoring y routing a revisión humana

Thresholds estratificados

flowchart LR
    A[Resultado extraído] --> B{confidence score}
    B -- ">= 0.85" --> C[Procesamiento automático]
    B -- "0.60 - 0.84" --> D[Revisión suave: solo campos dudosos]
    B -- "< 0.60" --> E[Revisión completa humana]
    C --> F[(Base de datos)]
    D --> G[Cola de revisión prioritaria baja]
    E --> H[Cola de revisión prioritaria alta]
RangoAcciónSLA típico
>= 0.85AutomáticoInstantáneo
0.60 – 0.84Revisión parcial4 horas
< 0.60Revisión completa1 hora

Calibración del score

El confidence que devuelve el modelo es una auto-evaluación: puede estar mal calibrada. Para validarla:

  1. Crea un labeled validation set de 200-500 facturas con ground truth conocido
  2. Corre el pipeline y compara el confidence reportado vs. la precisión real por bucket
  3. Si el modelo reporta 0.9 pero la precisión real es 0.75, aplica una función de calibración (Platt scaling o isotonic regression)
function routeByConfidence(result: InvoiceData): RoutingDecision {
  // Calibrated thresholds — ajustar según tu validation set
  if (result.confidence >= 0.85 && result.status === "complete") {
    return { route: "automatic", priority: null };
  }

  if (result.confidence >= 0.60) {
    const uncertainFields = getUncertainFields(result);
    return { route: "partial_review", priority: "low", fields: uncertainFields };
  }

  return {
    route: "full_review",
    priority: result.confidence < 0.40 ? "high" : "medium",
  };
}

function getUncertainFields(data: InvoiceData): string[] {
  // Heurística: campos con valores "sospechosos" según reglas de negocio
  const uncertain: string[] = [];
  if (data.amount_total > 100_000) uncertain.push("amount_total"); // montos muy altos
  if (!data.invoice_number) uncertain.push("invoice_number");
  if (data.line_items.length === 0) uncertain.push("line_items");
  return uncertain;
}

4. Manejo de contexto largo y “lost in the middle”

El efecto “lost in the middle”

Estudios con LLMs muestran que la información en el centro de un contexto largo recibe menos atención que la que está al inicio o al final. Para documentos de 50+ páginas esto se vuelve crítico.

graph LR
    A[Inicio del contexto] -->|Alta atención| B[...]
    B -->|Baja atención| C[Información crítica en el medio]
    C -->|Baja atención| D[...]
    D -->|Alta atención| E[Final del contexto]
    style C fill:#ff6b6b,color:#fff
    style A fill:#51cf66,color:#fff
    style E fill:#51cf66,color:#fff

Estrategias de mitigación

1. Poner información clave al inicio y al final

function buildPromptForLongDocument(
  documentChunks: string[],
  keyFacts: string[]
): string {
  const keyFactsSummary = keyFacts.join("\n");

  return `
INFORMACIÓN CLAVE A RECORDAR DURANTE TODA LA TAREA:
${keyFactsSummary}

--- DOCUMENTO COMPLETO ---
${documentChunks.join("\n\n---SIGUIENTE SECCIÓN---\n\n")}
--- FIN DEL DOCUMENTO ---

RECORDATORIO FINAL: ${keyFactsSummary}

Ahora extrae los datos solicitados teniendo en cuenta toda la información anterior.
  `.trim();
}

2. Scratchpad files para persistir hallazgos entre pasos

Cuando el pipeline procesa un documento en múltiples pasos, no confíes en que el modelo “recordará” hallazgos de pasos anteriores. Persiste explícitamente:

interface ScratchpadEntry {
  step: string;
  timestamp: string;
  findings: Record<string, unknown>;
  confidence: number;
}

class DocumentScratchpad {
  private entries: ScratchpadEntry[] = [];

  record(step: string, findings: Record<string, unknown>, confidence: number) {
    this.entries.push({
      step,
      timestamp: new Date().toISOString(),
      findings,
      confidence,
    });
  }

  // Genera un resumen compacto para incluir al inicio del siguiente prompt
  toContextSummary(): string {
    return this.entries
      .map(
        (e) =>
          `[Paso: ${e.step} | confianza: ${e.confidence}]\n${JSON.stringify(e.findings, null, 2)}`
      )
      .join("\n\n");
  }
}

3. Separar metadata de contenido resumido

interface DocumentMetadata {
  source: string;
  pageCount: number;
  language: string;
  documentType: string;
  criticalDates: string[]; // Fechas encontradas — no resumir, preservar exactas
  criticalAmounts: number[]; // Montos exactos — no resumir
}

// ⚠️ Los valores numéricos y fechas NUNCA deben resumirse
// Progressive summarization solo para texto descriptivo, no para datos factuales

Riesgos de progressive summarization

Cuando usas /compact o summarización automática en Claude Code, existe riesgo de pérdida de precisión en:

Regla práctica:


5. Provenance tracking (rastreo de procedencia)

Por qué es crítico en síntesis multi-fuente

Cuando un agente sintetiza información de múltiples documentos, cada afirmación en el output debe poder trazarse a su fuente original. Sin esto:

Claim-source mappings

interface Claim {
  value: unknown;
  sources: ClaimSource[];
  conflictsDetected: boolean;
  conflictNote?: string;
}

interface ClaimSource {
  documentId: string;
  documentName: string;
  url?: string;
  retrievedAt: string;
  pageOrSection?: string;
  excerpt: string; // Fragmento textual exacto que respalda el claim
}

interface ProvenanceAwareReport {
  summary: string;
  claims: Record<string, Claim>;
}

Herramienta con provenance integrado

const synthesisWithProvenanceTool: Anthropic.Tool = {
  name: "synthesize_with_provenance",
  description:
    "Sintetiza información de múltiples fuentes. SIEMPRE incluye las fuentes para cada afirmación.",
  input_schema: {
    type: "object" as const,
    properties: {
      summary: {
        type: "string",
        description: "Resumen ejecutivo de los hallazgos",
      },
      claims: {
        type: "array",
        items: {
          type: "object",
          properties: {
            claim_id: { type: "string" },
            statement: { type: "string" },
            value: {},
            sources: {
              type: "array",
              items: {
                type: "object",
                properties: {
                  document_id: { type: "string" },
                  document_name: { type: "string" },
                  excerpt: { type: "string" },
                  page_or_section: { type: ["string", "null"] },
                },
                required: ["document_id", "document_name", "excerpt"],
              },
            },
            conflicts_detected: { type: "boolean" },
            conflict_note: { type: ["string", "null"] },
          },
          required: ["claim_id", "statement", "sources", "conflicts_detected"],
        },
      },
    },
    required: ["summary", "claims"],
  },
};

Manejo de conflictos entre fuentes

Regla fundamental: anotar la discrepancia, NO elegir una fuente sobre otra.

// ❌ Mal: el modelo elige silenciosamente
// { value: 15000, sources: [docA] }  // ¿Por qué no docB que dice 14500?

// ✅ Bien: el conflicto es explícito
const conflictingClaim: Claim = {
  value: null, // No resuelto
  sources: [
    {
      documentId: "invoice-2026-001",
      documentName: "Factura Proveedor A",
      retrievedAt: "2026-03-23T10:00:00Z",
      excerpt: "Total: $15,000 USD",
    },
    {
      documentId: "po-2026-055",
      documentName: "Orden de Compra #055",
      retrievedAt: "2026-03-23T10:00:00Z",
      excerpt: "Monto aprobado: $14,500 USD",
    },
  ],
  conflictsDetected: true,
  conflictNote:
    "La factura reporta $15,000 pero la OC aprobada es $14,500. Diferencia de $500. Requiere validación con el proveedor.",
};

Metadata requerida en subagentes

Cuando un subagente recupera información, el mensaje de retorno al orquestador debe incluir:

interface SubagentRetrieval {
  agentId: string;
  taskCompleted: string;
  retrievedAt: string; // ISO 8601
  sources: Array<{
    documentId: string;
    documentName: string;
    url?: string;
    dateOfDocument?: string; // Fecha del documento fuente, no de la recuperación
  }>;
  findings: unknown;
  uncertainty: "low" | "medium" | "high";
  uncertaintyNote?: string; // Siempre presente cuando uncertainty !== "low"
}

Incertidumbre explícita vs. falsa confianza:

// ❌ Falsa confianza — el subagente "adivina"
{ findings: { ceo: "María García" }, uncertainty: "low" }

// ✅ Incertidumbre explícita
{
  findings: { ceo: "posiblemente María García" },
  uncertainty: "medium",
  uncertaintyNote: "El documento menciona 'M. García, Directora General' pero no hay nombre completo confirmado"
}

6. Ejemplo completo: pipeline de extracción de facturas

Diagrama del pipeline

flowchart TD
    A[📄 Documento PDF/texto] --> B[Ingesta y chunking]
    B --> C[Extracción con tool_use\nclause-opus-4-5]
    C --> D{Validación estructural\ny semántica}
    D -- errores retryables --> E[Feedback loop\nmáx 3 intentos]
    E --> C
    D -- errores no retryables --> F[Enqueue revisión humana\nhigh priority]
    D -- válido --> G{Routing por confidence}
    G -- ">= 0.85" --> H[✅ Base de datos\nautomático]
    G -- "0.60-0.84" --> I[👤 Revisión parcial\nlow priority]
    G -- "< 0.60" --> J[👤 Revisión completa\nhigh priority]
    H --> K[Provenance log]
    I --> K
    J --> K

Código TypeScript end-to-end

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

const client = new Anthropic();

// --- Tipos ---

interface InvoiceData {
  vendor: string;
  amount_total: number;
  currency: string;
  date_issued: string;
  invoice_number: string | null;
  line_items: Array<{
    description: string;
    quantity?: number;
    unit_price?: number;
    subtotal: number;
  }>;
  status: "complete" | "partial" | "uncertain";
  confidence: number;
  detected_pattern: string | null;
}

interface ExtractionResult {
  data: InvoiceData | null;
  attempts: number;
  success: boolean;
  errors?: ValidationError[];
  requiresHumanReview?: boolean;
}

interface ValidationError {
  field: string;
  message: string;
  isRetryable: boolean;
  hint?: string;
}

type RoutingDecision =
  | { route: "automatic" }
  | { route: "partial_review"; priority: "low"; fields: string[] }
  | { route: "full_review"; priority: "high" | "medium" };

// --- Tool definition ---

const invoiceTool: Anthropic.Tool = {
  name: "extract_invoice",
  description:
    "Extrae los datos estructurados de una factura. Llama esta herramienta siempre, incluso si los datos son incompletos.",
  input_schema: {
    type: "object" as const,
    properties: {
      vendor: { type: "string" },
      amount_total: { type: "number" },
      currency: { type: "string", enum: ["USD", "EUR", "MXN", "ARS", "CLP", "COP"] },
      date_issued: { type: "string" },
      invoice_number: { type: ["string", "null"] },
      line_items: {
        type: "array",
        items: {
          type: "object",
          properties: {
            description: { type: "string" },
            quantity: { type: "number" },
            unit_price: { type: "number" },
            subtotal: { type: "number" },
          },
          required: ["description", "subtotal"],
        },
      },
      status: { type: "string", enum: ["complete", "partial", "uncertain"] },
      confidence: { type: "number" },
      detected_pattern: { type: ["string", "null"] },
    },
    required: [
      "vendor",
      "amount_total",
      "currency",
      "date_issued",
      "line_items",
      "status",
      "confidence",
    ],
  },
};

// --- Validación ---

function validate(data: InvoiceData): ValidationError[] {
  const errors: ValidationError[] = [];

  if (!/^\d{4}-\d{2}-\d{2}$/.test(data.date_issued)) {
    errors.push({
      field: "date_issued",
      message: `Formato inválido: "${data.date_issued}"`,
      isRetryable: true,
      hint: "Usa formato ISO 8601: YYYY-MM-DD",
    });
  }

  if (data.line_items.length > 0) {
    const sum = data.line_items.reduce((acc, i) => acc + i.subtotal, 0);
    if (Math.abs(sum - data.amount_total) > data.amount_total * 0.02) {
      errors.push({
        field: "amount_total",
        message: `Line items suman ${sum.toFixed(2)}, total declarado ${data.amount_total}`,
        isRetryable: true,
        hint: "Revisa si hay impuestos o descuentos no capturados",
      });
    }
  }

  return errors;
}

// --- Pipeline principal ---

async function processInvoice(documentText: string): Promise<void> {
  console.log("Iniciando extracción...");

  const result = await extractWithRetry(documentText);

  if (!result.success || !result.data) {
    console.log(`❌ Extracción fallida tras ${result.attempts} intento(s). Enviando a revisión humana.`);
    await enqueueHumanReview(documentText, result.errors ?? []);
    return;
  }

  const decision = routeByConfidence(result.data);
  console.log(`✅ Extracción exitosa en ${result.attempts} intento(s). Ruta: ${decision.route}`);
  console.log(`   Vendor: ${result.data.vendor}`);
  console.log(`   Total: ${result.data.amount_total} ${result.data.currency}`);
  console.log(`   Confidence: ${(result.data.confidence * 100).toFixed(0)}%`);
  console.log(`   Status: ${result.data.status}`);

  if (decision.route === "automatic") {
    await saveToDatabase(result.data);
  } else {
    await enqueueHumanReview(documentText, [], result.data, decision);
  }
}

async function extractWithRetry(documentText: string): Promise<ExtractionResult> {
  const history: Anthropic.MessageParam[] = [
    {
      role: "user",
      content: `Extrae los datos de la siguiente factura:\n\n${documentText}`,
    },
  ];

  for (let attempt = 0; attempt < 3; attempt++) {
    const response = await client.messages.create({
      model: "claude-opus-4-5",
      max_tokens: 2048,
      tools: [invoiceTool],
      tool_choice: { type: "any" },
      messages: history,
    });

    const toolBlock = response.content.find((b) => b.type === "tool_use");
    if (!toolBlock || toolBlock.type !== "tool_use") continue;

    const data = toolBlock.input as InvoiceData;
    const errors = validate(data);

    if (errors.length === 0) return { data, attempts: attempt + 1, success: true };

    const retryable = errors.filter((e) => e.isRetryable);
    if (retryable.length === 0) {
      return { data, attempts: attempt + 1, success: false, errors, requiresHumanReview: true };
    }

    const feedback = retryable
      .map((e) => `- "${e.field}": ${e.message}${e.hint ? `\n  Sugerencia: ${e.hint}` : ""}`)
      .join("\n");

    history.push(
      { role: "assistant", content: response.content },
      {
        role: "user",
        content: [
          {
            type: "tool_result",
            tool_use_id: toolBlock.id,
            content: `Correcciones necesarias:\n${feedback}\n\nPor favor vuelve a llamar la herramienta con los datos corregidos.`,
          },
        ],
      }
    );
  }

  return { data: null, attempts: 3, success: false, requiresHumanReview: true };
}

function routeByConfidence(data: InvoiceData): RoutingDecision {
  if (data.confidence >= 0.85 && data.status === "complete") {
    return { route: "automatic" };
  }
  if (data.confidence >= 0.60) {
    const fields = [];
    if (!data.invoice_number) fields.push("invoice_number");
    if (data.line_items.length === 0) fields.push("line_items");
    return { route: "partial_review", priority: "low", fields };
  }
  return { route: "full_review", priority: data.confidence < 0.40 ? "high" : "medium" };
}

// Stubs para ilustrar la integración
async function saveToDatabase(data: InvoiceData): Promise<void> {
  console.log("Guardando en base de datos:", data.vendor);
}

async function enqueueHumanReview(
  _document: string,
  _errors: ValidationError[],
  _data?: InvoiceData,
  _decision?: RoutingDecision
): Promise<void> {
  console.log("Encolando para revisión humana...");
}

// Punto de entrada
const sampleInvoice = `
FACTURA
Proveedor: Servicios Cloud SRL
Fecha: 2026-03-23
Factura No: FC-2026-0042
Descripción: Hosting mensual  - $800.00 USD
Descripción: Soporte técnico - $200.00 USD
TOTAL: $1,000.00 USD
`;

processInvoice(sampleInvoice).catch(console.error);

Resumen: puntos clave para el examen

ConceptoRegla
tool_use vs texto libreSiempre tool_use para output estructurado crítico
tool_choice{ type: "any" } para forzar la llamada
Nullable fieldsUsar ["string", "null"] en el schema, no omitir el campo
Retries máximos3 intentos, luego degradación elegante
isRetryable: falseCuando la información genuinamente no está en el documento
”lost in the middle”Info clave al inicio Y al final del contexto
/compactSolo cuando el contexto está lleno de artefactos ya procesados
Conflictos entre fuentesAnotar la discrepancia, nunca elegir silenciosamente
ConfidenceAuto-evaluación del modelo; calibrar con labeled validation set
ProvenanceCada claim necesita su fuente con excerpt exacto

7. Detección de hallazgos contradictorios entre múltiples agentes

En un sistema multi-agente de investigación, 3 subagentes pueden devolver datos sobre el mismo tema desde fuentes distintas. Los conflictos son inevitables — la clave es detectarlos y clasificarlos antes de sintetizar.

Tipos de conflicto

type ConflictType =
  | "FACTUAL"      // Fechas, números, identificadores — solo uno puede ser correcto
  | "INTERPRETIVE" // Opiniones, calificaciones, evaluaciones — pueden coexistir
  | "TEMPORAL";    // El dato cambió con el tiempo — ambos correctos en su momento

interface ConflictingFinding {
  claim: string;           // La afirmación en disputa
  source1: SubagentRetrieval;
  source2: SubagentRetrieval;
  conflictType: ConflictType;
  delta?: string;          // Descripción cuantitativa de la diferencia (ej: "$500 USD")
}

Función de detección

interface Finding {
  agentId: string;
  field: string;
  value: unknown;
  retrievedAt: string; // ISO 8601
  documentDate?: string;
}

function detectConflicts(findings: Finding[]): ConflictingFinding[] {
  const conflicts: ConflictingFinding[] = [];
  const byField = groupBy(findings, (f) => f.field);

  for (const [field, group] of Object.entries(byField)) {
    if (group.length < 2) continue;

    for (let i = 0; i < group.length - 1; i++) {
      for (let j = i + 1; j < group.length; j++) {
        const a = group[i];
        const b = group[j];

        if (JSON.stringify(a.value) === JSON.stringify(b.value)) continue;

        const conflictType = classifyConflict(a, b);
        conflicts.push({
          claim: field,
          source1: a as unknown as SubagentRetrieval,
          source2: b as unknown as SubagentRetrieval,
          conflictType,
          delta: computeDelta(a.value, b.value),
        });
      }
    }
  }

  return conflicts;
}

function classifyConflict(a: Finding, b: Finding): ConflictType {
  // Si los documentos tienen fechas distintas → probablemente temporal
  if (a.documentDate && b.documentDate && a.documentDate !== b.documentDate) {
    return "TEMPORAL";
  }
  // Si los valores son numéricos o fechas → factual
  if (typeof a.value === "number" || isDateString(String(a.value))) {
    return "FACTUAL";
  }
  // Strings descriptivos → interpretivo
  return "INTERPRETIVE";
}

Cómo el synthesis-agent maneja cada tipo

flowchart TD
    A[Conflicto detectado] --> B{conflictType}
    B -- FACTUAL --> C[No elegir\nAnotar ambos valores\nEscalar a humano]
    B -- INTERPRETIVE --> D[Incluir ambas perspectivas\ncon su fuente\nNo sintetizar en uno]
    B -- TEMPORAL --> E[Ordenar cronológicamente\nUsar el más reciente\nAnexar versión anterior]
    C --> F[conflictsDetected: true\nvalue: null\nconflictNote con delta]
    D --> G[Mantener múltiples claims\ncon atribución explícita]
    E --> H[value = valor más reciente\ndocumentDate registrada]

Regla por tipo:


8. Context window math — cuántos tokens tengo?

Estimación práctica

La regla más usada en producción: 4 caracteres ≈ 1 token para texto en español/inglés. Para código, la ratio es mejor (~3.5 chars/token). Para JSON denso, similar al código.

function estimateTokens(messages: Array<{ role: string; content: string }>): number {
  const totalChars = messages.reduce((acc, msg) => {
    // +4 tokens por overhead de estructura de mensaje (role, separadores)
    return acc + msg.content.length + 16;
  }, 0);
  return Math.ceil(totalChars / 4);
}

// Uso:
const history = [
  { role: "user", content: "Analiza este documento..." },
  { role: "assistant", content: "He analizado el documento y encontré..." },
];
const estimated = estimateTokens(history);
const windowUsed = estimated / 200_000; // Para claude-opus-4
console.log(`Ventana usada: ${(windowUsed * 100).toFixed(1)}%`);

Capacidad por modelo

ModeloContext windowPalabras aprox.Páginas de textoArchivos de 200 líneas
claude-haiku-3.5200K tokens~150K palabras~500 páginas~400 archivos
claude-sonnet-4200K tokens~150K palabras~500 páginas~400 archivos
claude-opus-4200K tokens~150K palabras~500 páginas~400 archivos

Nota: todos los modelos Claude actuales comparten ventana de 200K. La diferencia está en costo y capacidad de razonamiento, no en contexto.

Cuándo activar estrategias de compresión

flowchart LR
    A[Medir uso actual\nestimateTokens] --> B{% de ventana usada}
    B -- "< 50%" --> C[Sin acción necesaria]
    B -- "50% - 70%" --> D[Considera /compact\npara artefactos ya procesados]
    B -- "> 70%" --> E[Activar compresión activa]
    B -- "> 90%" --> F[Sesión fresca obligatoria\npara nueva tarea]
    E --> G[Resumir tool results antiguos\nEliminar contexto irrelevante\nPersistir findings en archivo]

Threshold recomendado: 70%. Por debajo, el modelo tiene suficiente espacio para razonar. Por encima, el riesgo de “lost in the middle” aumenta significativamente y los costos por llamada se elevan.

Para datos numéricos y fechas críticas, nunca comprimas — persiste en un archivo externo antes de hacer /compact.


9. Diagrama de confidence calibration

El modelo reporta un confidence score como auto-evaluación. Este score puede estar mal calibrado: un modelo que dice 0.9 puede tener precisión real de 0.75 en tu dominio específico.

flowchart TD
    A[Modelo dice confidence: 0.9] --> B{¿Tienes validation set?}
    B -- no --> C[Usar threshold raw\ncon margen de seguridad]
    B -- sí --> D[Medir accuracy real\nen bucket 0.85-0.95]
    D --> E{accuracy real vs reportada}
    E -- "accuracy 0.75\nmodelo dice 0.90" --> F[Overconfident en 15%\nAplicar calibración]
    E -- "accuracy 0.88\nmodelo dice 0.90" --> G[Bien calibrado\nUsar thresholds directos]
    F --> H[Ajustar threshold:\nsi modelo dice 0.90\ntratar como 0.75]
    H --> I{confidence calibrado >= 0.85?}
    I -- sí --> J[Procesamiento automático]
    I -- no --> K[Routing a revisión humana]

Implementación de calibración

// Mapa de calibración: confidence reportado → accuracy real observada
// Se construye midiendo en un labeled validation set de 200-500 muestras
const calibrationMap: Record<string, number> = {
  "0.95": 0.82, // El modelo dice 0.95, la precisión real es 0.82
  "0.90": 0.75,
  "0.85": 0.70,
  "0.80": 0.65,
  "0.70": 0.58,
};

function calibratedConfidence(rawConfidence: number): number {
  // Redondear al bucket más cercano
  const bucket = (Math.round(rawConfidence * 20) / 20).toFixed(2);
  return calibrationMap[bucket] ?? rawConfidence * 0.85; // fallback: 15% de descuento
}

function routeWithCalibration(result: InvoiceData): RoutingDecision {
  const calibrated = calibratedConfidence(result.confidence);

  // Los thresholds se aplican sobre el confidence CALIBRADO, no el raw
  if (calibrated >= 0.85 && result.status === "complete") {
    return { route: "automatic" };
  }
  if (calibrated >= 0.60) {
    return { route: "partial_review", priority: "low", fields: [] };
  }
  return { route: "full_review", priority: calibrated < 0.40 ? "high" : "medium" };
}

Cómo construir el validation set

  1. Tomar 200-500 documentos con ground truth conocido (facturas ya procesadas manualmente)
  2. Correr el pipeline y registrar: { rawConfidence, wasCorrect, field }
  3. Agrupar por buckets de 0.05 (0.85-0.90, 0.90-0.95, etc.)
  4. La accuracy real de cada bucket es el calibrated confidence para ese rango
  5. Re-evaluar el validation set cada 3 meses o cuando cambies el modelo base

Si no tienes validation set: aplica un descuento conservador del 10-15% al confidence reportado hasta tener datos reales.


Siguiente: Agentic Loop Avanzado