Xây Dựng AI Agents với Laravel: Tools, Memory, và Reasoning Loops

· 14 min read

AI chatbot trả lời câu hỏi. AI agent thực hiện hành động. Nó có thể search database, gửi email, tạo records, và tự quyết định bước tiếp theo — tất cả tự động. Agent là chatbot có tay chân.

Bài viết này build AI agent trong Laravel từ đầu: tools, reasoning loop, memory, safety, và streaming — không cần framework AI phức tạp.

Chatbot vs Agent

Chatbot:
  User: "Số dư tài khoản tôi bao nhiêu?"
  Bot:  "Tôi không có quyền truy cập thông tin đó."

Agent:
  User: "Số dư tài khoản tôi bao nhiêu?"
  Agent nghĩ: "Cần tra cứu số dư. Dùng tool get_balance."
  Agent gọi: get_balance(user_id: 42)
  Agent nhận: { balance: "$1,234.56" }
  Agent trả lời: "Số dư tài khoản bạn là $1,234.56."

Khác biệt cốt lõi: agents có tools (actions nó có thể thực hiện) và reasoning loop (vòng lặp suy luận → hành động → quan sát → suy luận tiếp).

Kiến Trúc Agent

User Message → [Agent Loop] → Gửi LLM (với tool definitions)
                                    ↓
                              LLM trả về:
                              a) Câu trả lời cuối → Trả user ✅
                              b) Tool call → Thực thi tool → Feed kết quả → Loop lại 🔄

Đây là ReAct pattern (Reasoning + Acting): LLM suy luận nên làm gì, thực hiện action (gọi tool), quan sát kết quả, rồi suy luận tiếp.

Bước 1: Định Nghĩa Tools

Mỗi tool cần: name, description (LLM đọc để quyết định dùng), parameters schema, và execute function.

// app/AI/Tools/SearchPostsTool.php

namespace App\AI\Tools;

use App\Models\Post;

class SearchPostsTool
{
    public static function definition(): array
    {
        return [
            'type' => 'function',
            'function' => [
                'name' => 'search_posts',
                'description' => 'Tìm kiếm blog posts theo từ khóa. Trả về title và slug của posts matching.',
                'parameters' => [
                    'type' => 'object',
                    'properties' => [
                        'query' => [
                            'type' => 'string',
                            'description' => 'Từ khóa tìm kiếm (tiếng Việt hoặc tiếng Anh)',
                        ],
                        'limit' => [
                            'type' => 'integer',
                            'description' => 'Số kết quả tối đa (mặc định 5)',
                        ],
                    ],
                    'required' => ['query'],
                ],
            ],
        ];
    }

    public static function execute(array $args): string
    {
        $query = $args['query'];
        $limit = min($args['limit'] ?? 5, 20); // Cap tại 20

        $posts = Post::where('title', 'like', "%{$query}%")
            ->orWhere('body', 'like', "%{$query}%")
            ->limit($limit)
            ->get(['title', 'slug', 'published_at']);

        if ($posts->isEmpty()) {
            return json_encode(['result' => 'Không tìm thấy bài viết nào.']);
        }

        return json_encode(['result' => $posts->toArray()]);
    }
}
// app/AI/Tools/GetWeatherTool.php — Ví dụ tool gọi API bên ngoài

class GetWeatherTool
{
    public static function definition(): array
    {
        return [
            'type' => 'function',
            'function' => [
                'name' => 'get_weather',
                'description' => 'Lấy thông tin thời tiết hiện tại tại một thành phố.',
                'parameters' => [
                    'type' => 'object',
                    'properties' => [
                        'city' => ['type' => 'string', 'description' => 'Tên thành phố'],
                    ],
                    'required' => ['city'],
                ],
            ],
        ];
    }

    public static function execute(array $args): string
    {
        $response = Http::get('https://api.weatherapi.com/v1/current.json', [
            'key' => config('services.weather.api_key'),
            'q' => $args['city'],
        ]);

        if ($response->failed()) {
            return json_encode(['error' => 'Không thể lấy thông tin thời tiết']);
        }

        $data = $response->json();
        return json_encode([
            'city' => $data['location']['name'],
            'temp_c' => $data['current']['temp_c'],
            'condition' => $data['current']['condition']['text'],
        ]);
    }
}

Tip thiết kế tool: Description là thứ quan trọng nhất — LLM đọc description để quyết định dùng tool nào. Viết rõ ràng, cụ thể, và bao gồm ví dụ input nếu cần.

Bước 2: Tool Registry

// app/AI/ToolRegistry.php

namespace App\AI;

use App\AI\Tools\SearchPostsTool;
use App\AI\Tools\GetWeatherTool;
use App\AI\Tools\CreateDraftTool;

class ToolRegistry
{
    private static array $tools = [
        'search_posts' => SearchPostsTool::class,
        'get_weather' => GetWeatherTool::class,
        'create_draft' => CreateDraftTool::class,
    ];

    public static function definitions(): array
    {
        return array_map(
            fn ($class) => $class::definition(),
            self::$tools,
        );
    }

    public static function execute(string $name, array $args): string
    {
        $class = self::$tools[$name] ?? null;

        if (!$class) {
            return json_encode(['error' => "Tool không tồn tại: {$name}"]);
        }

        try {
            return $class::execute($args);
        } catch (\Throwable $e) {
            Log::error("Tool execution failed: {$name}", [
                'args' => $args,
                'error' => $e->getMessage(),
            ]);
            return json_encode(['error' => "Tool {$name} gặp lỗi: {$e->getMessage()}"]);
        }
    }

    public static function has(string $name): bool
    {
        return isset(self::$tools[$name]);
    }
}

Bước 3: Agent Loop

// app/AI/Agent.php

namespace App\AI;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class Agent
{
    private array $messages = [];
    private int $maxIterations = 10;

    public function __construct(
        private string $systemPrompt = 'Bạn là assistant hữu ích cho blog Laravel.',
        private string $model = 'gpt-4o',
    ) {
        $this->messages[] = ['role' => 'system', 'content' => $this->systemPrompt];
    }

    public function chat(string $userMessage): string
    {
        $this->messages[] = ['role' => 'user', 'content' => $userMessage];

        for ($i = 0; $i < $this->maxIterations; $i++) {
            $response = $this->callLLM();
            $message = $response['choices'][0]['message'];

            // Lưu response vào conversation history
            $this->messages[] = $message;

            // Nếu LLM muốn gọi tools → thực thi rồi loop lại
            if (!empty($message['tool_calls'])) {
                Log::info('Agent calling tools', [
                    'iteration' => $i + 1,
                    'tools' => collect($message['tool_calls'])->pluck('function.name'),
                ]);

                $this->executeToolCalls($message['tool_calls']);
                continue; // Loop lại — feed tool results cho LLM
            }

            // Không tool calls = câu trả lời cuối cùng
            return $message['content'];
        }

        return 'Xin lỗi, tôi không thể hoàn thành tác vụ này trong số bước cho phép.';
    }

    private function callLLM(): array
    {
        $response = Http::withToken(config('services.openai.api_key'))
            ->timeout(60)
            ->post('https://api.openai.com/v1/chat/completions', [
                'model' => $this->model,
                'messages' => $this->messages,
                'tools' => ToolRegistry::definitions(),
                'tool_choice' => 'auto', // LLM tự quyết định dùng tool hay trả lời
            ]);

        if ($response->failed()) {
            throw new \RuntimeException('LLM API call failed: ' . $response->body());
        }

        return $response->json();
    }

    private function executeToolCalls(array $toolCalls): void
    {
        foreach ($toolCalls as $toolCall) {
            $name = $toolCall['function']['name'];
            $args = json_decode($toolCall['function']['arguments'], true);

            Log::info("Executing tool: {$name}", ['args' => $args]);

            $result = ToolRegistry::execute($name, $args);

            // Append tool result — LLM sẽ đọc kết quả ở iteration tiếp theo
            $this->messages[] = [
                'role' => 'tool',
                'tool_call_id' => $toolCall['id'],
                'content' => $result,
            ];
        }
    }
}

Giải thích flow chi tiết:

Iteration 1:
  User: "Tìm bài viết về caching và cho tôi tóm tắt"
  → LLM nhận message + tool definitions
  → LLM trả: tool_calls: [{ name: "search_posts", args: { query: "caching" } }]
  → Agent thực thi search_posts → kết quả: [{ title: "Laravel Caching Tips", slug: "..." }]

Iteration 2:
  → LLM nhận conversation (bao gồm tool result)
  → LLM trả: "Tôi tìm thấy bài 'Laravel Caching Tips'..."
  → Không tool calls → trả user

Bước 4: Memory

Short-Term Memory (Conversation)

Conversation history tự nhiên là short-term memory. Nhưng cần giới hạn size:

class Agent
{
    private int $maxContextMessages = 40; // Giữ system + 40 messages gần nhất

    private function trimMessages(): void
    {
        if (count($this->messages) <= $this->maxContextMessages + 1) {
            return;
        }

        // Luôn giữ system message (index 0)
        $system = $this->messages[0];
        $recent = array_slice($this->messages, -$this->maxContextMessages);
        $this->messages = [$system, ...$recent];
    }
}

Long-Term Memory (Database)

Lưu thông tin quan trọng về user để load vào system prompt cho conversations tương lai:

// app/Models/AgentMemory.php
class AgentMemory extends Model
{
    protected $fillable = ['user_id', 'key', 'content', 'type', 'importance'];

    public function scopeForUser($query, int $userId)
    {
        return $query->where('user_id', $userId)->orderByDesc('importance');
    }
}
// Tool để agent tự lưu memory
class SaveMemoryTool
{
    public static function definition(): array
    {
        return [
            'type' => 'function',
            'function' => [
                'name' => 'save_memory',
                'description' => 'Lưu thông tin quan trọng về user để nhớ cho lần sau.',
                'parameters' => [
                    'type' => 'object',
                    'properties' => [
                        'key' => ['type' => 'string', 'description' => 'Tên ngắn cho memory (VD: preferred_language)'],
                        'content' => ['type' => 'string', 'description' => 'Nội dung cần nhớ'],
                    ],
                    'required' => ['key', 'content'],
                ],
            ],
        ];
    }

    public static function execute(array $args): string
    {
        $userId = auth()->id();
        if (!$userId) {
            return json_encode(['error' => 'Cần đăng nhập']);
        }

        AgentMemory::updateOrCreate(
            ['user_id' => $userId, 'key' => $args['key']],
            ['content' => $args['content'], 'type' => 'preference'],
        );

        return json_encode(['success' => true, 'message' => 'Đã lưu']);
    }
}

Load memories vào system prompt:

$memories = AgentMemory::forUser($user->id)->limit(20)->get();
$memoryContext = $memories->map(fn ($m) => "- {$m->key}: {$m->content}")->join("\n");

$agent = new Agent(
    systemPrompt: "Bạn là assistant cho blog Laravel.\n\nThông tin đã biết về user:\n{$memoryContext}",
);

Bước 5: Streaming Responses

User không muốn chờ 5-10 giây cho full response. Stream từng token:

class Agent
{
    public function streamChat(string $userMessage): \Generator
    {
        $this->messages[] = ['role' => 'user', 'content' => $userMessage];

        for ($i = 0; $i < $this->maxIterations; $i++) {
            $response = Http::withToken(config('services.openai.api_key'))
                ->withOptions(['stream' => true])
                ->post('https://api.openai.com/v1/chat/completions', [
                    'model' => $this->model,
                    'messages' => $this->messages,
                    'tools' => ToolRegistry::definitions(),
                    'stream' => true,
                ]);

            $content = '';
            $toolCalls = [];

            foreach ($this->parseSSE($response->getBody()) as $chunk) {
                $delta = $chunk['choices'][0]['delta'] ?? [];

                // Stream text content
                if (!empty($delta['content'])) {
                    $content .= $delta['content'];
                    yield ['type' => 'text', 'content' => $delta['content']];
                }

                // Collect tool calls
                if (!empty($delta['tool_calls'])) {
                    // Accumulate tool call data across chunks
                    foreach ($delta['tool_calls'] as $tc) {
                        $idx = $tc['index'];
                        $toolCalls[$idx] ??= ['id' => '', 'function' => ['name' => '', 'arguments' => '']];
                        if (!empty($tc['id'])) $toolCalls[$idx]['id'] = $tc['id'];
                        if (!empty($tc['function']['name'])) $toolCalls[$idx]['function']['name'] .= $tc['function']['name'];
                        if (!empty($tc['function']['arguments'])) $toolCalls[$idx]['function']['arguments'] .= $tc['function']['arguments'];
                    }
                }
            }

            if (!empty($toolCalls)) {
                yield ['type' => 'tool_start', 'tools' => collect($toolCalls)->pluck('function.name')];
                $this->messages[] = ['role' => 'assistant', 'content' => $content, 'tool_calls' => array_values($toolCalls)];
                $this->executeToolCalls(array_values($toolCalls));
                yield ['type' => 'tool_done'];
                continue;
            }

            $this->messages[] = ['role' => 'assistant', 'content' => $content];
            return;
        }
    }
}

Controller Cho Streaming

class AgentController extends Controller
{
    public function stream(Request $request)
    {
        $request->validate(['message' => 'required|string|max:2000']);

        $agent = new Agent();

        return response()->stream(function () use ($agent, $request) {
            foreach ($agent->streamChat($request->input('message')) as $chunk) {
                echo "data: " . json_encode($chunk) . "\n\n";
                ob_flush();
                flush();
            }
            echo "data: [DONE]\n\n";
        }, 200, [
            'Content-Type' => 'text/event-stream',
            'Cache-Control' => 'no-cache',
            'Connection' => 'keep-alive',
        ]);
    }
}

An Toàn: Tool Permissions

Agent có thể gọi bất kỳ tool nào nó "nghĩ" nên gọi. Bạn phải control:

class ToolRegistry
{
    // Tools cần xác nhận trước khi thực thi
    private static array $dangerousTools = ['send_email', 'delete_post', 'update_user'];

    // Tools chỉ available cho admin
    private static array $adminOnlyTools = ['delete_post', 'manage_users'];

    public static function execute(string $name, array $args, ?User $user = null): string
    {
        // Kiểm tra quyền
        if (in_array($name, self::$adminOnlyTools) && !$user?->is_admin) {
            return json_encode(['error' => 'Bạn không có quyền thực hiện hành động này.']);
        }

        // Kiểm tra dangerous actions
        if (in_array($name, self::$dangerousTools)) {
            Log::warning("Agent thử tool nguy hiểm: {$name}", [
                'user_id' => $user?->id,
                'args' => $args,
            ]);

            if (!session("confirmed_tool_{$name}")) {
                return json_encode([
                    'error' => 'Hành động này cần xác nhận từ người dùng.',
                    'requires_confirmation' => true,
                    'action' => $name,
                ]);
            }
        }

        $class = self::$tools[$name] ?? null;
        return $class ? $class::execute($args) : json_encode(['error' => 'Unknown tool']);
    }

    /**
     * Trả về tool definitions được filter theo user role
     */
    public static function definitionsForUser(?User $user): array
    {
        return collect(self::$tools)
            ->reject(fn ($class, $name) =>
                in_array($name, self::$adminOnlyTools) && !$user?->is_admin
            )
            ->map(fn ($class) => $class::definition())
            ->values()
            ->toArray();
    }
}

Security rules:

  1. Principle of least privilege — chỉ expose tools user cần
  2. Validate tool arguments — LLM có thể generate arguments sai/malicious
  3. Rate limit tool calls — prevent runaway loops
  4. Log everything — audit trail cho mọi tool execution
  5. Confirmation cho write operations — human-in-the-loop cho actions nguy hiểm

Testing

class AgentTest extends TestCase
{
    use RefreshDatabase;

    public function test_agent_uses_search_tool(): void
    {
        Post::factory()->create(['title' => 'Laravel Caching Tips']);

        $agent = new Agent();
        $response = $agent->chat('Tìm bài về caching');

        $this->assertStringContainsString('Caching', $response);
    }

    public function test_agent_handles_no_results(): void
    {
        $agent = new Agent();
        $response = $agent->chat('Tìm bài về quantum computing');

        $this->assertStringContainsString('không tìm thấy', strtolower($response));
    }

    public function test_agent_respects_max_iterations(): void
    {
        // Mock LLM to always return tool calls → should stop after maxIterations
        $agent = new Agent();
        $agent->maxIterations = 2;

        // Agent với tool luôn trả kết quả cần tool tiếp → sẽ dừng
        $response = $agent->chat('Loop forever');

        $this->assertStringContainsString('không thể hoàn thành', $response);
    }

    public function test_dangerous_tool_requires_confirmation(): void
    {
        $result = ToolRegistry::execute('delete_post', ['id' => 1]);
        $data = json_decode($result, true);

        $this->assertTrue($data['requires_confirmation']);
    }

    public function test_admin_tool_blocked_for_regular_user(): void
    {
        $user = User::factory()->create(['is_admin' => false]);

        $result = ToolRegistry::execute('delete_post', ['id' => 1], $user);
        $data = json_decode($result, true);

        $this->assertArrayHasKey('error', $data);
    }
}

Sử Dụng Trong Laravel Controller

// routes/web.php
Route::middleware('auth')->group(function () {
    Route::post('/agent/chat', [AgentController::class, 'chat']);
    Route::post('/agent/stream', [AgentController::class, 'stream']);
});

// app/Http/Controllers/AgentController.php
class AgentController extends Controller
{
    public function chat(Request $request)
    {
        $request->validate(['message' => 'required|string|max:2000']);

        $memories = AgentMemory::forUser($request->user()->id)->limit(10)->get();
        $memoryContext = $memories->map(fn ($m) => "- {$m->key}: {$m->content}")->join("\n");

        $agent = new Agent(
            systemPrompt: "Bạn là assistant cho blog.\n\nThông tin về user:\n{$memoryContext}",
        );

        $response = $agent->chat($request->input('message'));

        return response()->json(['response' => $response]);
    }
}

Kết Luận

AI agents trong Laravel đơn giản hơn bạn nghĩ — core chỉ là loop + tool execution:

  1. Định nghĩa tools với schema + execute method — phần quan trọng nhất
  2. Xây loop: gửi LLM → thực thi tools → feed kết quả → lặp
  3. Thêm memory: conversation cho short-term, database cho long-term
  4. Streaming: Server-Sent Events cho UX mượt
  5. Thêm safety: permission checks, logging, max iterations, confirmation

Phần khó không phải code — mà là:

  • Thiết kế tool descriptions tốt (LLM đọc description để quyết định)
  • Viết system prompt hướng dẫn agent suy luận đúng
  • Xử lý edge cases (LLM hallucinate tool names, malformed arguments)
  • Balancing safety vs usability (quá nhiều confirmation = khó dùng)

Bình luận