Toidicode.com

Toidicode.com

BASIC TO ADVANCE

Bài 31: Laravel AI SDK trong Laravel 13

Ở bài trước TaskFlow đã có bộ test tự động. Tiếp tục với series, bài này mình sẽ giới thiệu với mọi người về Laravel AI SDK trong Laravel 13 để thêm nút tóm tắt tiến độ project bằng AI.

1. Cài đặt và cấu hình AI SDK.

AI SDK (package laravel/ai, Bài 1) là một API chung cho nhiều nhà cung cấp AI: OpenAI, Anthropic, Gemini, Ollama... Cài đặt và publish file config:

composer require laravel/ai
php artisan vendor:publish --tag=ai-config
#   Copying file [vendor/laravel/ai/config/ai.php] to [config/ai.php] ..... DONE

Trong config/ai.php, default là nhà cung cấp mặc định (openai), mảng providers đọc key từ .env. Các bạn tạo API key ở trang của nhà cung cấp rồi điền vào .env: OPENAI_API_KEY=..., dùng Claude thì điền ANTHROPIC_API_KEY và đổi default thành anthropic.

Chú ý: Mỗi lần gọi AI đều bị tính tiền theo số token. API key giống mật khẩu: chỉ để trong .env, không commit lên git.

2. Tạo agent.

Agent là class chứa hướng dẫn (instructions), tool và cấu trúc kết quả cho mô hình AI. Mình cần AI trả về hai trường (tóm tắt, mức rủi ro) nên thêm --structured:

php artisan make:agent ProjectSummarizer --structured
#   INFO  Agent [app/Ai/Agents/ProjectSummarizer.php] created successfully.

Mình bỏ messages() (hội thoại trước đó), tools() cùng interface Conversational, HasTools có sẵn trong file vì chưa dùng:

- app/Ai/Agents/ProjectSummarizer.php

<?php

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\MaxTokens;
use Laravel\Ai\Attributes\UseCheapestModel;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;

#[UseCheapestModel]
#[MaxTokens(500)]
class ProjectSummarizer implements Agent, HasStructuredOutput
{
    use Promptable;

    /**
     * Hướng dẫn (system prompt) cho mô hình AI.
     */
    public function instructions(): Stringable|string
    {
        return 'Bạn là trợ lý quản lý dự án. Dựa vào danh sách task được gửi, '
            .'hãy tóm tắt tiến độ project trong tối đa 3 câu tiếng Việt và đánh giá mức rủi ro trễ hạn.';
    }

    /**
     * Cấu trúc JSON mà mô hình phải trả về.
     */
    public function schema(JsonSchema $schema): array
    {
        return [
            'summary' => $schema->string()->required(),
            'risk' => $schema->string()->enum(['thấp', 'trung bình', 'cao'])->required(),
        ];
    }
}

Trong đó:

  • instructions(): hướng dẫn gửi kèm mọi prompt, quy định vai trò và cách trả lời.
  • schema() (interface HasStructuredOutput): structured output, mô hình phải trả về JSON đúng cấu trúc này, enum() giới hạn các giá trị được phép.
  • #[UseCheapestModel]: dùng model rẻ nhất của nhà cung cấp, #[MaxTokens(500)]: giới hạn độ dài câu trả lời, cả hai giúp đỡ tốn tiền. Ngoài ra còn #[Provider(...)], #[Timeout(120)]...

3. Gửi prompt và tóm tắt project.

Gửi prompt bằng ProjectSummarizer::make()->prompt($noiDung): make() tạo agent qua service container (Bài 25). Agent thường thì (string) $response là câu trả lời, có schema thì đọc như mảng: $response['summary'].

Mình tạo controller php artisan make:controller SummarizeProject --invokable, gửi mỗi task một dòng rồi hiện kết quả bằng flash status:

- app/Http/Controllers/SummarizeProject.php

<?php

namespace App\Http\Controllers;

use App\Ai\Agents\ProjectSummarizer;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse;
use Laravel\Ai\Exceptions\AiException;

class SummarizeProject extends Controller
{
    /**
     * Gửi danh sách task của project cho AI và hiện bản tóm tắt.
     */
    public function __invoke(Project $project): RedirectResponse
    {
        // mỗi task một dòng: tiêu đề | trạng thái | hạn | đã giao hay chưa (không gửi tên người)
        $lines = $project->tasks()->get()->map(fn (Task $task) => implode(' | ', [
            $task->title,
            $task->status->label(),
            'hạn '.($task->due_date?->format('d/m/Y') ?? 'chưa đặt'),
            $task->assignee_id ? 'đã giao' : 'chưa giao',
        ]));

        try {
            $response = ProjectSummarizer::make()->prompt(
                "Project: {$project->name}. Hôm nay: ".today()->format('d/m/Y').".\n".$lines->implode("\n")
            );
        } catch (AiException|RequestException $e) {
            // không kết nối được, sai key, hết quota...: ghi log và báo lỗi thân thiện
            report($e);

            return back()->with('status', 'Không gọi được AI, các bạn thử lại sau.');
        }

        return back()->with('status', $response['summary'].' Rủi ro: '.$response['risk'].'.');
    }
}

Mất kết nối, hết quota... SDK ném AiException, sai key thì là RequestException. Bắt cả hai thì người dùng thấy thông báo thay vì lỗi 500, report() vẫn ghi log.

Route đặt trong group route project có sẵn (đã có auth), chỉ ai xem được project (Bài 22) mới tóm tắt được. throttle:5,1: tối đa 5 lần mỗi phút cho mỗi user, lần thứ 6 nhận lỗi 429:

- routes/web.php (trích, thêm use App\Http\Controllers\SummarizeProject;)

        // tóm tắt project bằng AI, mỗi lần bấm là một lần gọi API (tốn tiền)
        // nên mỗi user chỉ được bấm 5 lần mỗi phút
        Route::post('/projects/{project}/summary', SummarizeProject::class)
            ->name('projects.summary')
            ->middleware('throttle:5,1')
            ->can('view', 'project');

Trong projects/show.blade.php mình thêm một form POST tới route('projects.summary', $project) có @csrf và nút "Tóm tắt bằng AI".

Prompt gửi đi cho project 1 sau php artisan migrate:fresh --seed (ngày tùy lúc seed):

Project: Website toidicode.com. Hôm nay: 15/11/2026.
Viết bài Seeder và Factory | Đang làm | hạn 17/11/2026 | đã giao
Viết bài Blade template | Hoàn thành | hạn chưa đặt | đã giao
Thiết kế trang chủ | Cần làm | hạn 22/11/2026 | chưa giao
Tối ưu ảnh trên trang | Cần làm | hạn chưa đặt | chưa giao

Lưu ý: Prompt chỉ gồm tên project, ngày hôm nay, tiêu đề, trạng thái, hạn của task và task đã giao hay chưa. Tên người được giao là dữ liệu cá nhân nên mình không gửi cho nhà cung cấp AI.

4. Tool cho agent.

Tool là class mà mô hình có thể gọi khi cần thêm dữ liệu, VD tra database. Tạo bằng php artisan make:tool TenTool, class có description(), schema() (tham số) và handle(Request $request), rồi trả về trong tools() của agent (interface HasTools). TaskFlow gửi sẵn mọi task trong prompt nên chưa cần tool.

5. Embeddings và vector search.

Embedding là mảng số biểu diễn ý nghĩa của một đoạn văn bản, hai đoạn cùng ý thì hai mảng gần nhau:

use Illuminate\Support\Str;

$vector = Str::of('Viết báo cáo tuần cho khách hàng')->toEmbeddings(); // mảng 1536 số thực

Embedding lưu vào cột $table->vector('embedding', dimensions: 1536) (cast AsVector), rồi tìm bản ghi gần nghĩa bằng whereVectorSimilarTo('embedding', 'chuỗi cần tìm') (Bài 1). Tính năng này cần PostgreSQL với pgvector hoặc MariaDB 11.7+, TaskFlow dùng SQLite nên mình chỉ giới thiệu.

6. Fake AI khi test.

Giống Mail::fake() ở Bài 30, ProjectSummarizer::fake() thay agent bằng bản giả: không gọi API, không tốn tiền. Truyền vào mảng các câu trả lời (có schema thì mỗi câu là một mảng), bỏ trống thì SDK tự sinh dữ liệu đúng schema. Chạy composer dump-autoload (Bài 15) rồi thử trong tinker:

ProjectSummarizer::fake([['summary' => 'Còn 2 task chưa xong, 1 task quá hạn.', 'risk' => 'cao']]);
$response = ProjectSummarizer::make()->prompt('Tóm tắt giúp mình project TaskFlow API');
$response['summary']; // "Còn 2 task chưa xong, 1 task quá hạn."
$response['risk']; // "cao"

Test cho nút tóm tắt, assertPrompted() kiểm tra prompt có chứa tiêu đề task:

- tests/Feature/ProjectSummaryTest.php (trích)

<?php

use App\Ai\Agents\ProjectSummarizer;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\Exceptions;
use Laravel\Ai\Exceptions\ProviderConnectionException;
use Laravel\Ai\Prompts\AgentPrompt;

test('tóm tắt project bằng AI', function () {
    // không gọi API thật: agent trả về đúng mảng này
    ProjectSummarizer::fake([
        ['summary' => 'Project mới bắt đầu, còn 1 task cần làm.', 'risk' => 'thấp'],
    ]);
    $task = Task::factory()
        ->for(Project::factory()->for(User::factory(), 'owner'))
        ->for(User::factory()->state(['name' => 'Trần Thị Bình']), 'assignee')
        ->create(['title' => 'Viết tài liệu API']);
    $project = $task->project;

    $this->actingAs($project->owner)
        ->from("/projects/{$project->id}")
        ->post("/projects/{$project->id}/summary")
        ->assertRedirect("/projects/{$project->id}")
        ->assertSessionHas('status', 'Project mới bắt đầu, còn 1 task cần làm. Rủi ro: thấp.');

    // prompt có tiêu đề task nhưng không có tên người được giao
    ProjectSummarizer::assertPrompted(fn (AgentPrompt $prompt) => $prompt->contains('Viết tài liệu API | Cần làm')
        && $prompt->contains('đã giao')
        && ! $prompt->contains('Trần Thị Bình'));
});

File đầy đủ còn kiểm tra user khác bị 403, lần bấm thứ 6 bị 429 (assertTooManyRequests()) và lỗi từ nhà cung cấp (fake bằng closure ném ProviderConnectionException).

Lúc này php artisan test báo 35 passed (116 assertions).

Lưu ý: fake() còn nhận closure; ở laravel/ai 1.4.0 closure nhận string $prompt, không phải AgentPrompt như tài liệu.

7. Lời kết.

Như vậy chúng ta đã tìm hiểu xong Laravel AI SDK trong Laravel 13: agent, structured output, tool, embedding và fake khi test. Các bạn xem thêm tại tài liệu chính thức. Bài sau mình sẽ hướng dẫn deploy ứng dụng Laravel 13 lên VPS.

Đăng ký nhận tin.

Chúng tôi chỉ gửi tối đa 2 lần trên 1 tháng. Tuyên bố không spam mail!

Vũ Thanh Tài

About author
The best way to learn is to share
Xem tất cả bài đăng

0 Comments

Bài viết chưa có ai bình luận, hãy là người đầu tiên đi bạn!

Bình luận

Captcha