Tích Hợp OpenAI Vào Laravel Theo Kiến Trúc Service-First

· 10 min read

Giới Thiệu

Sai lầm phổ biến khi thêm AI vào Laravel là gọi thẳng SDK trong controller. Cách này giúp demo nhanh, nhưng rất khó test, khó thay provider, và sớm biến logic AI thành một mớ điều kiện rải rác khắp codebase.

Hướng service-first phù hợp hơn với app production: controller chỉ nhận request, service chịu trách nhiệm gọi model, và action hoặc job xử lý phần business workflow.

Mục lục

  • Mục tiêu của kiến trúc service-first
  • Vì sao controller-centric AI code hay hỏng sau demo
  • Flow hợp lý hơn cho AI feature
  • Tách prompt builder sẽ giúp gì về sau
  • Các khả năng nên nghĩ từ ngày đầu
  • Test strategy thực dụng
  • Những sai lầm phổ biến và checklist trước khi merge

Mục Tiêu Của Kiến Trúc

Ta muốn đạt 4 điều:

  • Không gọi OpenAI trực tiếp trong controller
  • Có interface rõ để thay provider sau này
  • Dễ fake trong unit test và feature test
  • Tách prompt building khỏi phần transport

Một Cấu Trúc Đơn Giản Nhưng Đủ Dùng

namespace App\Services\AI;

interface GeneratesText
{
    public function generate(string $prompt, array $options = []): string;
}
namespace App\Services\AI;

use OpenAI\Laravel\Facades\OpenAI;

class OpenAITextGenerator implements GeneratesText
{
    public function generate(string $prompt, array $options = []): string
    {
        $response = OpenAI::responses()->create([
            'model' => $options['model'] ?? 'gpt-4.1-mini',
            'input' => $prompt,
            'temperature' => $options['temperature'] ?? 0.2,
        ]);

        return trim($response->outputText);
    }
}
namespace App\Http\Controllers;

use App\Services\AI\GeneratesText;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class SummaryController extends Controller
{
    public function __invoke(Request $request, GeneratesText $generator): JsonResponse
    {
        $request->validate([
            'content' => ['required', 'string', 'max:10000'],
        ]);

        $summary = $generator->generate(
            "Summarize this article in 3 concise bullet points:\n\n" . $request->string('content')
        );

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

Tách Prompt Ra Khỏi Transport

Khi app lớn dần, prompt không nên nằm trong controller hoặc service transport. Hãy tách nó thành class riêng hoặc một prompt builder đơn giản.

Lợi ích:

  • Prompt có thể review như business rule
  • Dễ versioning và A/B testing
  • Dễ tái sử dụng cho job, queue, batch processing

Vì Sao Controller-Centric AI Code Hay Hỏng Sau Giai Đoạn Demo

Giai đoạn đầu, rất nhiều team viết kiểu này:

public function summarize(Request $request): JsonResponse
{
    $response = OpenAI::responses()->create([
        'model' => 'gpt-4.1-mini',
        'input' => 'Summarize: ' . $request->string('content'),
    ]);

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

Đoạn code trên không sai về mặt cú pháp. Nó chỉ có quá nhiều trách nhiệm trong một chỗ:

  • validate input
  • build prompt
  • chọn model
  • gọi provider
  • xử lý response
  • quyết định response schema

Lúc feature còn nhỏ, bạn chưa thấy vấn đề. Đến khi cần thêm timeout, cache, fallback, prompt versioning, logging token usage hoặc đổi provider, controller bắt đầu phình ra rất nhanh.

Service-First Không Có Nghĩa Là Tạo Thêm Quá Nhiều Abstraction

Nhiều người phản xạ rằng service-first sẽ dẫn đến over-engineering. Điều đó chỉ đúng nếu bạn tạo abstraction trước khi có nhu cầu thật. Phiên bản thực dụng của service-first chỉ cần tách đúng 3 ranh giới:

  1. interface hoặc contract cho capability chính
  2. implementation giao tiếp với provider
  3. lớp hoặc builder chịu trách nhiệm prompt/domain instruction

Chỉ với 3 lớp trách nhiệm đó, bạn đã có nhiều lợi ích mà chưa phải dựng cả một framework AI nội bộ.

Một Flow Hợp Lý Hơn Cho AI Feature

HTTP request -> validation -> prompt builder -> AI service -> response normalizer -> controller response

Khi phức tạp hơn, bạn có thể thêm:

HTTP request -> validation -> action -> cache -> AI service -> usage logger -> response normalizer

Điểm quan trọng là controller vẫn mỏng và domain flow vẫn đọc được.

Tách Prompt Builder Sẽ Giúp Gì Về Sau?

Prompt thường bị đánh giá thấp vì nó không giống "code business logic" theo nghĩa truyền thống. Nhưng trong AI feature, prompt chính là business rule ở tầng ngôn ngữ.

Ví dụ:

namespace App\Services\AI\Prompts;

class SummaryPrompt
{
    public function build(string $content): string
    {
        return <<<PROMPT
        Summarize the following article in exactly 3 bullet points.
        Keep each bullet under 24 words.
        Focus on factual statements, not opinions.

        Article:
        {$content}
        PROMPT;
    }
}

Khi prompt nằm riêng:

  • reviewer có thể đọc và góp ý như một rule
  • bạn có thể bump version khi thay đổi instruction
  • test được trường hợp output format kỳ vọng
  • dễ so sánh chất lượng giữa hai prompt khác nhau

Các Khả Năng Nên Nghĩ Từ Ngày Đầu

Một AI service trong production thường sớm cần thêm các mảnh sau:

  • timeout: tránh request treo quá lâu
  • retry: xử lý network error hoặc 429
  • cache: giảm chi phí cho input lặp lại
  • logging: đo latency, token usage, error rate
  • fallback: đổi model nhỏ hơn hoặc trả về response an toàn

Nếu kiến trúc không có chỗ đặt các năng lực này, chúng sẽ bị chèn thẳng vào controller hoặc rải rác ở middleware một cách rất khó theo dõi.

Test Strategy Thực Dụng

Không cần mock mọi thứ quá chi tiết. Thường nên tách test theo 3 tầng:

1. Unit test cho prompt builder

Mục tiêu là kiểm tra prompt có chứa đúng instruction và input quan trọng.

2. Unit test cho action hoặc domain service

Mock GeneratesText để xác nhận business flow dùng AI output đúng cách.

3. Feature test cho HTTP layer

Chỉ kiểm tra validation, response schema và status code. Không để test HTTP phụ thuộc vào network thật.

Khi Nào Nên Tách Thêm Provider Adapter?

Nếu app chỉ dùng một provider và một loại capability đơn giản, interface GeneratesText là đủ. Nhưng khi bắt đầu có:

  • nhiều provider khác nhau
  • nhiều capability như text, embeddings, moderation, image
  • logic chọn provider theo cost hoặc latency

lúc đó nên tách thêm adapter hoặc orchestrator layer. Trước thời điểm đó, đừng dựng abstraction quá lớn chỉ để "phòng tương lai".

Những Sai Lầm Phổ Biến

  • Nhét prompt trực tiếp vào controller
  • Cho response từ model đi thẳng ra API mà không normalize
  • Không log usage nên không biết feature đang đắt cỡ nào
  • Không version prompt nên thay instruction xong cache bị sai khó debug
  • Không fake service trong test nên CI chậm và thiếu ổn định

Checklist Trước Khi Merge Một AI Feature

  • Controller đã đủ mỏng chưa?
  • Prompt có nằm ở nơi đọc và review được không?
  • Output đã được chuẩn hóa trước khi trả về client chưa?
  • Có cách fake hoặc mock service trong test chưa?
  • Có logging cho latency và token usage chưa?
  • Có timeout và failure path chưa?

FAQ

Có cần repository pattern cho AI service không?

Thông thường không. AI provider không phải persistence layer. Một interface theo capability sẽ hợp hơn nhiều so với cố ép nó vào repository pattern.

Có nên bind service bằng singleton không?

Thường là được nếu service stateless. Nhưng prompt builder và normalizer nên giữ càng đơn giản càng tốt để dễ test.

Key takeaways:

  1. Controller chỉ nên giữ request/response, còn AI provider call nên đi qua service riêng.
  2. Prompt builder là business rule ở tầng ngôn ngữ và nên được version hóa, test được.
  3. Service-first giúp dễ thêm timeout, retry, cache, fallback và usage logging.
  4. Test nên tách theo prompt builder, domain service và HTTP boundary.
  5. Đừng dựng abstraction quá lớn trước khi thực sự có nhiều provider hoặc nhiều capability.

Test Sẽ Dễ Hơn Rất Nhiều

Khi đã đi qua interface, bạn có thể fake service trong test mà không phụ thuộc network hoặc token.

$this->app->bind(GeneratesText::class, fn () => new class implements GeneratesText {
    public function generate(string $prompt, array $options = []): string
    {
        return '- Fast summary\n- Lower cost\n- Cleaner architecture';
    }
});

Lúc đó feature test chỉ cần xác nhận validation, response schema và business flow.

Những Điều Nên Chuẩn Bị Từ Đầu

  • Logging cho token usage và latency
  • Timeout và retry policy rõ ràng
  • Cache cho những prompt có tính lặp lại
  • Rate limiting nếu endpoint public
  • Cơ chế fallback khi provider chậm hoặc lỗi

Nếu không chuẩn bị sớm, AI feature rất dễ trở thành phần đắt nhất nhưng cũng ít ổn định nhất trong hệ thống.

Kết Luận

Tích hợp OpenAI vào Laravel không khó. Phần khó là giữ cho codebase vẫn sạch sau 3 tháng. Service-first là một lựa chọn thực dụng: đủ đơn giản để bắt đầu nhanh, nhưng đủ rõ ràng để tiếp tục mở rộng khi sản phẩm đi vào thực tế.

Bài liên quan

Bình luận