Saltar al contenido principal

Laravel AI SDK Sub-agents: arquitectura multi-agente en PHP para producción

Autor
Ignacio AmatIgnacio Amat
Publicado
Lectura9 min
Editor de código PHP con arquitectura de agentes y automatización de tareas

Laravel News publicó el 12 de mayo de 2026 una novedad pequeña en superficie pero importante para producto: Laravel AI SDK ahora permite usar sub-agents. La idea es que un agente principal pueda delegar una tarea concreta a otro agente especializado, con sus propias instrucciones, herramientas, proveedor, modelo y configuración.

Para equipos PHP que ya tienen Laravel en producción, esto cambia cómo se integran flujos de IA: de un único monolito prompt a una arquitectura de agentes desacoplados.


Por qué los sub-agents cambian las reglas

Una aplicación Laravel normal separa responsabilidades en controladores, servicios, jobs, policies, listeners y comandos. No tendría sentido que la parte de IA volviera a mezclarlo todo en una función gigante.

Los sub-agents aplican esa misma disciplina a flujos con modelos:

  • Aislamiento de contexto: cada sub-agente recibe solo los datos que necesita, no el historial completo del padre.
  • Herramientas mínimas: un agente de reembolsos no necesita acceso a herramientas de facturación.
  • Coste granular: sabes exactamente qué tokens consume cada sub-agente.
  • Testeable: cada agente se prueba de forma independiente.

Arquitectura recomendada: Agentes como servicios

app/AI/
├── Agents/
│   ├── SupportAgent.php          # Agente principal (orquestador)
│   ├── RefundsAgent.php          # Sub-agente especializado
│   ├── BillingAgent.php
│   └── DocumentationAgent.php
├── Contracts/
│   └── AgentContract.php         # Interface común
├── DTOs/
│   ├── AgentTask.php
│   └── AgentResult.php
├── Logging/
│   └── AgentLogger.php           # Observabilidad centralizada
└── Exceptions/
    └── AgentFailedException.php

1. Interfaz común para todos los agentes

// app/AI/Contracts/AgentContract.php
namespace App\AI\Contracts;

use App\AI\DTOs\{AgentTask, AgentResult};

interface AgentContract
{
    public function identifier(): string;
    public function run(AgentTask $task): AgentResult;
    public function tools(): array;
    public function maxTokens(): int;
}

2. Agente principal: orquesta sin microgestionar

// app/AI/Agents/SupportAgent.php
namespace App\AI\Agents;

use App\AI\Contracts\AgentContract;
use App\AI\DTOs\{AgentTask, AgentResult};
use App\AI\Logging\AgentLogger;

class SupportAgent implements AgentContract
{
    private array $subAgents = [];

    public function __construct(
        private AgentLogger $logger,
    ) {
        $this->subAgents = [
            'refunds' => app(RefundsAgent::class),
            'billing' => app(BillingAgent::class),
            'documentation' => app(DocumentationAgent::class),
        ];
    }

    public function identifier(): string
    {
        return 'support_agent';
    }

    public function tools(): array
    {
        return [
            'classify_ticket',
            'escalate_to_human',
            'search_knowledge_base',
        ];
    }

    public function maxTokens(): int
    {
        return 2048;
    }

    public function run(AgentTask $task): AgentResult
    {
        $start = hrtime(true);
        $this->logger->start($this->identifier(), $task);

        // 1. Clasificar el ticket
        $classification = $this->classify($task);

        // 2. Delegar al sub-agente adecuado si aplica
        if ($classification['delegates_to'] ?? null) {
            $subAgent = $this->subAgents[$classification['delegates_to']] ?? null;

            if (!$subAgent) {
                return AgentResult::error("No sub-agent found for: {$classification['delegates_to']}");
            }

            $subTask = new AgentTask(
                id: $task->id,
                payload: $classification['context'] ?? $task->payload,
                metadata: [
                    'parent_agent' => $this->identifier(),
                    'delegation_reason' => $classification['reason'],
                ],
            );

            $subResult = $subAgent->run($subTask);

            // 3. Consolidar respuesta
            $result = AgentResult::success([
                'classification' => $classification,
                'resolution' => $subResult->data,
            ]);
        } else {
            $result = AgentResult::success([
                'classification' => $classification,
                'resolution' => $classification['direct_response'],
            ]);
        }

        $latencyMs = (hrtime(true) - $start) / 1e6;
        $this->logger->end($this->identifier(), $task, $result, $latencyMs);

        return $result;
    }

    private function classify(AgentTask $task): array
    {
        // Aquí iría la llamada al modelo para clasificar
        // y decidir si delegar o responder directamente
        return [
            'delegates_to' => 'refunds',
            'reason' => 'ticket_contains_refund_request',
            'context' => $task->payload,
        ];
    }
}

3. Sub-agente: acotado y revisable

// app/AI/Agents/RefundsAgent.php
namespace App\AI\Agents;

use App\AI\Contracts\AgentContract;
use App\AI\DTOs\{AgentTask, AgentResult};

class RefundsAgent implements AgentContract
{
    public function identifier(): string
    {
        return 'refunds_agent';
    }

    public function tools(): array
    {
        return [
            'get_order_by_id',
            'get_refund_policy',
            'calculate_refund_amount',
            'draft_refund_response',
        ];
    }

    public function maxTokens(): int
    {
        return 1024; // Sub-agente más ligero
    }

    public function run(AgentTask $task): AgentResult
    {
        $orderId = $task->payload['order_id'] ?? null;
        $reason = $task->payload['reason'] ?? 'not_specified';

        if (!$orderId) {
            return AgentResult::error('Missing order_id in refund request');
        }

        // Llamada al modelo con instrucciones muy específicas
        $response = $this->callModel(
            prompt: $this->buildPrompt($orderId, $reason),
            maxTokens: $this->maxTokens(),
            temperature: 0.2, // Baja temperatura para outputs predecibles
        );

        // Validar que la respuesta tenga el formato esperado
        $parsed = $this->validateResponse($response);

        return AgentResult::success([
            'recommendation' => $parsed['recommendation'],
            'reason' => $parsed['reason'],
            'amount_cents' => $parsed['amount_cents'],
            'customer_message_draft' => $parsed['customer_message_draft'],
            'requires_human_approval' => $parsed['requires_human_approval'],
        ]);
    }

    private function buildPrompt(int $orderId, string $reason): string
    {
        $order = \App\Models\Order::find($orderId);

        return <<<PROMPT
You are a refund processing assistant.
Review the following order and reason, then determine the appropriate refund action.

ORDER:
- ID: {$order->id}
- Total: {$order->total_cents} cents
- Status: {$order->status}
- Created: {$order->created_at->format('Y-m-d')}
- Items: {$order->items->count()}

REFUND REASON: {$reason}

POLICY: Full refund within 30 days, prorated after.

Respond in strict JSON:
{
  "recommendation": "approve|partial|manual_review|reject",
  "reason": "short explanation",
  "amount_cents": 0,
  "customer_message_draft": "...",
  "requires_human_approval": true|false
}
PROMPT;
    }

    private function callModel(string $prompt, int $maxTokens, float $temperature): string
    {
        // Llamada real al proveedor configurado
        return json_encode([
            'recommendation' => 'manual_review',
            'reason' => 'order_has_partial_delivery',
            'amount_cents' => 4999,
            'customer_message_draft' => 'We need to verify the shipment status before proceeding.',
            'requires_human_approval' => true,
        ]);
    }

    private function validateResponse(string $response): array
    {
        $data = json_decode($response, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new \RuntimeException('Invalid JSON from refunds agent: ' . json_last_error_msg());
        }
        return $data;
    }
}

4. DTOs tipados

// app/AI/DTOs/AgentTask.php
namespace App\AI\DTOs;

class AgentTask
{
    public function __construct(
        public readonly string $id,
        public readonly array $payload,
        public readonly array $metadata = [],
    ) {}
}

// app/AI/DTOs/AgentResult.php
namespace App\AI\DTOs;

class AgentResult
{
    private function __construct(
        public readonly bool $success,
        public readonly ?array $data = null,
        public readonly ?string $error = null,
    ) {}

    public static function success(array $data): self
    {
        return new self(success: true, data: $data);
    }

    public static function error(string $message): self
    {
        return new self(success: false, error: $message);
    }

    public function requiresHumanApproval(): bool
    {
        return $this->data['requires_human_approval'] ?? false;
    }
}

Testing: cada agente como unidad independiente

// tests/Feature/AI/Agents/RefundsAgentTest.php
use App\AI\Agents\RefundsAgent;
use App\AI\DTOs\{AgentTask, AgentResult};

test('refunds agent returns valid result for known order', function () {
    $order = \App\Models\Order::factory()->create([
        'total_cents' => 9999,
        'status' => 'delivered',
    ]);

    $agent = new RefundsAgent();
    $task = new AgentTask(
        id: 'test-1',
        payload: ['order_id' => $order->id, 'reason' => 'defective_product'],
    );

    $result = $agent->run($task);
  
    expect($result)->toBeInstanceOf(AgentResult::class)
        ->and($result->success)->toBeTrue()
        ->and($result->data)->toHaveKey('recommendation')
        ->and($result->data)->toHaveKey('amount_cents')
        ->and($result->data)->toHaveKey('requires_human_approval');
});

test('refunds agent fails gracefully when order is missing', function () {
    $agent = new RefundsAgent();
    $task = new AgentTask(
        id: 'test-2',
        payload: ['reason' => 'no_order_id_provided'],
    );

    $result = $agent->run($task);
  
    expect($result->success)->toBeFalse()
        ->and($result->error)->toContain('Missing order_id');
});

test('support agent delegates correctly to refunds sub-agent', function () {
    // Test de integración ligero que verifica la orquestación
    $support = app(\App\AI\Agents\SupportAgent::class);
    $task = new AgentTask(
        id: 'test-3',
        payload: ['ticket' => 'I want a refund for order #123', 'user_id' => 1],
    );

    $result = $support->run($task);
  
    expect($result->success)->toBeTrue()
        ->and($result->data)->toHaveKey('classification')
        ->and($result->data)->toHaveKey('resolution');
});

Observabilidad: saber qué pasó dentro de cada agente

// app/AI/Logging/AgentLogger.php
namespace App\AI\Logging;

use App\AI\DTOs\{AgentTask, AgentResult};
use Illuminate\Support\Facades\Log;

class AgentLogger
{
    public function start(string $agentId, AgentTask $task): void
    {
        Log::channel('ai')->info('agent.start', [
            'agent' => $agentId,
            'task_id' => $task->id,
            'payload_size' => strlen(json_encode($task->payload)),
            'timestamp' => now()->toIso8601String(),
        ]);
    }

    public function end(string $agentId, AgentTask $task, AgentResult $result, float $latencyMs): void
    {
        $logData = [
            'agent' => $agentId,
            'task_id' => $task->id,
            'success' => $result->success,
            'latency_ms' => round($latencyMs, 2),
            'timestamp' => now()->toIso8601String(),
        ];

        if ($result->requiresHumanApproval()) {
            $logData['requires_human_approval'] = true;
            Log::channel('ai')->warning('agent.requires_human_approval', $logData);
        } else {
            Log::channel('ai')->info('agent.end', $logData);
        }
    }

    public function delegation(string $parentAgent, string $subAgent, string $reason): void
    {
        Log::channel('ai')->info('agent.delegation', [
            'parent' => $parentAgent,
            'sub_agent' => $subAgent,
            'reason' => $reason,
        ]);
    }
}

Control de costes por agente

// app/AI/Agents/Traits/HasCostTracking.php
namespace App\AI\Agents\Traits;

use Illuminate\Support\Facades\Cache;

trait HasCostTracking
{
    public function trackCost(string $agentId, int $inputTokens, int $outputTokens, string $model = 'gpt-4o-mini'): void
    {
        $pricing = [
            'gpt-4o-mini' => ['input' => 0.15, 'output' => 0.60],
            'gpt-4o' => ['input' => 2.50, 'output' => 10.00],
            'claude-sonnet-4' => ['input' => 3.00, 'output' => 15.00],
        ][$model] ?? ['input' => 0.15, 'output' => 0.60];

        $costUsd = ($inputTokens * $pricing['input'] / 1_000_000)
                 + ($outputTokens * $pricing['output'] / 1_000_000);

        $dailyKey = "ai:cost:daily:" . date('Y-m-d') . ":{$agentId}";
        $monthlyKey = "ai:cost:monthly:" . date('Y-m') . ":{$agentId}";

        Cache::increment($dailyKey, (int) ($costUsd * 1_000_000));
        Cache::increment($monthlyKey, (int) ($costUsd * 1_000_000));
    }
}

Dónde aplicarlo primero en producto

Los mejores casos iniciales son flujos de bajo riesgo y alto volumen:

FlujoAgente principalSub-agentesRiesgo
Clasificar tickets de soporteSupportAgentRefundsAgent, BillingAgent, DocsAgentBajo
Resumir bugs reproduciblesBugAgentPriorityAgent, AssignAgentBajo
Preparar respuestas de facturaciónBillingAgentInvoiceAgent, PaymentAgentMedio
Generar borradores de documentaciónDocsAgentTechWriterAgent, ReviewerAgentBajo
Revisar contenido antes de publicarReviewAgentSEOAgent, GrammarAgentMedio

No empezaría por autorizaciones, pagos directos o acciones irreversibles. Ahí la IA puede asistir, pero la decisión final debe quedar protegida por reglas de negocio tradicionales.


Conclusión: Agentes como ciudadanos de primera clase

Laravel AI SDK con sub-agents permite tratar los agentes como ciudadanos de primera clase en tu arquitectura: con contracts, tests, logging, costes asignables y un ciclo de vida gobernado.

El salto cualitativo no es técnico, es de diseño: pasar de “un prompt que lo hace todo” a agentes pequeños, auditables y conectados a casos de uso reales. Ese es el tipo de integración que merece producción.


¿Llevas esto a tu equipo?

Disponible para consultoría técnica o contratación como Senior que ya trabaja con flujos multi-agente en producción:

  • Arquitectura de agentes (2-3 días): diseñar el árbol de agentes, herramientas y políticas para tu producto.
  • Implementación hands-on (1-2 semanas): sub-agentes, testing, observabilidad, control de costes.
  • Auditoría de integraciones IA existentes (2 días): encontrar mejoras rápidas y reducir riesgo.

Modalidades: EOR (Deel, Remote, Oyster), freelance B2B (Autónomo, factura intracomunitaria 0% IVA), o contrato directo si tienes entidad en España.

Ver mi perfil, stack y condiciones →


Artículos relacionados

Artículos relacionados

Revisa mi perfil como desarrollador

Si este artículo encaja con los retos técnicos de tu equipo, revisa mi stack o mi disponibilidad profesional.

Cuéntame qué necesitas

Puedes escribirme por un rol, contrato, colaboración técnica, una duda o una consulta general. Con 2-3 líneas de contexto suelo poder responder en menos de 24 horas hábiles.

0/500
Disponibilidad