Xây Dựng AI Agents với Laravel: Tools, Memory, và Reasoning Loops
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:
- Principle of least privilege — chỉ expose tools user cần
- Validate tool arguments — LLM có thể generate arguments sai/malicious
- Rate limit tool calls — prevent runaway loops
- Log everything — audit trail cho mọi tool execution
- 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:
- Định nghĩa tools với schema + execute method — phần quan trọng nhất
- Xây loop: gửi LLM → thực thi tools → feed kết quả → lặp
- Thêm memory: conversation cho short-term, database cho long-term
- Streaming: Server-Sent Events cho UX mượt
- 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)