Cap 22: Structured Output, Validation Loops y Context Management
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:
- El schema se valida en el lado del modelo, no en el tuyo
- El SDK deserializa el JSON por ti (sin
JSON.parsemanual) tool_choice: { type: "any" }fuerza al modelo a llamar alguna herramienta- Los campos
requiredyenumse respetan con mucha mayor consistencia
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:
invoice_numberydetected_patternusan["string", "null"]— el modelo sabe que puede devolvernullexplícitamentestatususaenumcon tres estados bien definidos, no un booleanconfidencees numérico (0.0 a 1.0), no una etiqueta vagaline_itemstiene sus propiosrequiredinternos
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:
- El modelo devolvió una fecha en formato incorrecto (
"23/03/2026"en lugar de"2026-03-23") - Un campo
enumcontiene un valor no listado ("USD$"en lugar de"USD") amount_totales string en lugar de number- Los
line_itemssuman un valor diferente alamount_total(puede ser error de lectura)
NO retryable (isRetryable: false):
- El número de factura no aparece en ninguna parte del documento
- El documento está en un idioma no reconocido y no contiene fecha legible
- El documento es claramente no una factura (imagen corrupta, texto irrelevante)
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]
| Rango | Acción | SLA típico |
|---|---|---|
| >= 0.85 | Automático | Instantáneo |
| 0.60 – 0.84 | Revisión parcial | 4 horas |
| < 0.60 | Revisión completa | 1 hora |
Calibración del score
El confidence que devuelve el modelo es una auto-evaluación: puede estar mal calibrada. Para validarla:
- Crea un labeled validation set de 200-500 facturas con ground truth conocido
- Corre el pipeline y compara el confidence reportado vs. la precisión real por bucket
- 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:
- Fechas exactas (un resumen puede decir “principios de marzo” en lugar de “2026-03-07”)
- Montos exactos (un resumen puede perder decimales o la moneda)
- Números de identificación (facturas, órdenes, RFC)
- Decisiones condicionales (“si el monto es mayor a X” puede perder el valor de X)
Regla práctica:
- Usa
/compactcuando el contexto está lleno de código ejecutado y resultados de herramientas que ya no necesitas - Inicia una sesión fresca (
fresh start) cuando vayas a empezar una nueva tarea independiente sobre el mismo documento - Nunca uses
/compacten medio de una extracción de datos numéricos críticos
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:
- No puedes auditar errores
- No puedes detectar conflictos entre fuentes
- El modelo puede “alucinar” síntesis que mezcla datos de fuentes incompatibles
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
| Concepto | Regla |
|---|---|
| tool_use vs texto libre | Siempre tool_use para output estructurado crítico |
| tool_choice | { type: "any" } para forzar la llamada |
| Nullable fields | Usar ["string", "null"] en el schema, no omitir el campo |
| Retries máximos | 3 intentos, luego degradación elegante |
| isRetryable: false | Cuando la información genuinamente no está en el documento |
| ”lost in the middle” | Info clave al inicio Y al final del contexto |
| /compact | Solo cuando el contexto está lleno de artefactos ya procesados |
| Conflictos entre fuentes | Anotar la discrepancia, nunca elegir silenciosamente |
| Confidence | Auto-evaluación del modelo; calibrar con labeled validation set |
| Provenance | Cada 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:
FACTUAL: nunca elegir silenciosamente. Ponervalue: nullyconflictNotecon el delta exacto.INTERPRETIVE: ambas perspectivas son válidas. Presentarlas con su fuente, sin forzar síntesis.TEMPORAL: el más reciente es el canónico, pero el historial es trazable. RegistrardocumentDatede ambos.
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
| Modelo | Context window | Palabras aprox. | Páginas de texto | Archivos de 200 líneas |
|---|---|---|---|---|
| claude-haiku-3.5 | 200K tokens | ~150K palabras | ~500 páginas | ~400 archivos |
| claude-sonnet-4 | 200K tokens | ~150K palabras | ~500 páginas | ~400 archivos |
| claude-opus-4 | 200K 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
- Tomar 200-500 documentos con ground truth conocido (facturas ya procesadas manualmente)
- Correr el pipeline y registrar:
{ rawConfidence, wasCorrect, field } - Agrupar por buckets de 0.05 (0.85-0.90, 0.90-0.95, etc.)
- La accuracy real de cada bucket es el calibrated confidence para ese rango
- 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