Tích Hợp OpenAI Vào Laravel Theo Kiến Trúc Service-First
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:
- interface hoặc contract cho capability chính
- implementation giao tiếp với provider
- 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âuretry: xử lý network error hoặc 429cache: giảm chi phí cho input lặp lạilogging: đo latency, token usage, error ratefallback: đổ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:
- Controller chỉ nên giữ request/response, còn AI provider call nên đi qua service riêng.
- Prompt builder là business rule ở tầng ngôn ngữ và nên được version hóa, test được.
- Service-first giúp dễ thêm timeout, retry, cache, fallback và usage logging.
- Test nên tách theo prompt builder, domain service và HTTP boundary.
- Đừ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ế.