Только Symfony, Redis и MySQL.
Я постоянно видел проекты ИИ-агентов, написанные на Python с использованием LangGraph. Каждый туториал, каждая статья, каждый репозиторий. Всё на Python. Я работаю с PHP уже много лет. Поэтому мне хотелось узнать одну вещь. Можем ли мы сделать то же самое на PHP?
Я попробовал. Это работает. И вот чему я научился.
Что я создал
Небольшого NL2SQL-агента. Вы задаете вопрос на обычном английском языке. Он пишет SQL-запрос, выполняет его в MySQL и возвращает вам ответ на английском.
Но это не просто промпт, который генерирует SQL. Он ведет себя как полноценный агент:
- Сначала он решает, можно ли вообще ответить на этот вопрос.
- Если вопрос неясен, он останавливается и просит вас уточнить детали.
- Если данных в базе нет, он отказывается отвечать вместо того, чтобы гадать.
- Если он пишет ошибочный запрос, он считывает ошибку MySQL и сам исправляет запрос.
Репозиторий находится здесь, если вы хотите посмотреть код:
https://github.com/premsgdev/agentic-nl2sql-with-php
Во-первых, что на самом деле делает LangGraph?
Когда я начинал, я думал, что LangGraph — это что-то сложное. Это не так.
Если убрать брендинг, то это всего три вещи:
- Объект состояния (state). Один контейнер с данными, который передается на протяжении всего запуска.
- Узлы (nodes) и ребра (edges). Узел — это функция. Она принимает состояние, что-то меняет и возвращает его. Ребро решает, какой узел запускается следующим.
- Контрольная точка (checkpoint). После каждого узла состояние сохраняется, чтобы выполнение можно было остановить и продолжить позже.
Возможно, это упрощение, но по сути ментальная модель представляет собой граф с состоянием: общее состояние перемещается по узлам, ребра определяют, что запускается дальше, а чекпоинты позволяют сохранять и возобновлять выполнение.
Проблема
В традиционной модели PHP-FPM состояние приложения не привязано к долгоживущему процессу. Каждый HTTP-запрос обрабатывается независимо, поэтому на то, что хранится только в памяти запроса, нельзя полагаться при следующем запросе.
Поэтому, когда мой агент задает уточняющий вопрос, процесс на этом завершается. Пользователь думает какое-то время и отвечает. Этот ответ приходит в совершенно новый PHP-процесс. Он понятия не имеет, что происходило раньше.
Я использую FrankenPHP, поэтому думал, что режим воркера (worker mode) решит эту проблему. Нет, не решит. Режим воркера только держит приложение «прогретым». Воркеров много, они перезапускаются после определенного количества запросов, а если запустить два контейнера, всё равно всё сломается. Вы не можете хранить контекст беседы в памяти.
Поэтому единственный способ — правильно сохранить состояние и загрузить его обратно по id.
Сначала мне показалось, что это ограничение PHP. Позже я понял, что так даже лучше. Python-агент тоже теряет состояние в памяти, когда его процесс или под исчезает, если только это состояние не было сохранено во внешнем хранилище. Но в Python вы можете игнорировать это долгое время. В PHP вы не сможете игнорировать это даже в первый день.
Движок графа
Это основная часть. В ней около 50 строк.
final class Graph
{
public const END = '__end__';
private array $nodes = [];
private array $routes = [];
public function __construct(private readonly int $maxSteps = 15) {}
public function node(string $name, NodeInterface $node): self
{
$this->nodes[$name] = $node;
return $this;
}
public function edge(string $from, string $to): self
{
$this->routes[$from] = static fn (State $state): string => $to;
return $this;
}
public function conditionalEdge(string $from, callable $router): self
{
$this->routes[$from] = $router;
return $this;
}
public function run(State $state, string $entry): State
{
$current = $state->resumeAt ?? $entry;
$state->resumeAt = null;
while (self::END !== $current) {
if (++$state->steps > $this->maxSteps) {
throw new StepLimitException($state->steps, $state->trace);
}
$state->trace[] = $current;
$state = ($this->nodes[$current])($state);
$current = ($this->routes[$current])($state);
}
return $state;
}
}Узел — это любой класс с одним этим методом:
interface NodeInterface
{
public function __invoke(State $state): State;
}В этом цикле важны две небольшие детали.
Лимит шагов (step limit). Без него одно неверное условие приведет к бесконечному циклу. А с вызовами LLM внутри узлов это означает сжигание вашего лимита API, пока вы ждете. Четыре строки кода предотвращают это.
Циклы разрешены. Узел может вернуться к предыдущему узлу. Именно это делает его графом, а не конвейером. Мой обычный запуск с уточнением выглядит так:
classify → clarify → classify → generate
Он вернулся к этапу классификации с обновленным вопросом и на этот раз пошел по другому пути.
Как агент останавливается и продолжает работу
Когда узел хочет спросить что-то у пользователя, он выбрасывает исключение:
final class Interrupt extends \RuntimeException
{
public function __construct(
public readonly string $question,
public readonly string $resumeAt,
) {
parent::__construct($question);
}
}Я использовал исключение, потому что оно автоматически прерывает цикл while. Узел просто говорит «остановись здесь». Вызывающий код решает, что делать дальше. Самому движку графа для этого не требуется дополнительный код.
Раннер перехватывает его, сохраняет состояние и возвращает вопрос:
private function execute(State $state): RunResult
{
try {
$state = $this->graph->run($state, 'classify');
} catch (Interrupt $interrupt) {
$state->resumeAt = $interrupt->resumeAt;
$this->checkpointer->save($state);
return RunResult::paused($state, $interrupt->question);
}
$this->checkpointer->save($state);
return RunResult::completed($state);
}В следующий раз run() начнется с resumeAt вместо первого узла.
Здесь я совершил одну ошибку. Сначала я установил resumeAt на узел уточнения (clarify). Поэтому, когда пользователь ответил, агент снова задал тот же вопрос. Должен быть узел классификации (classify), потому что вопрос теперь изменился и его нужно проверить заново.
Сохранение состояния
Я храню его в Redis в формате JSON. Не через PHP serialize().
public function save(State $state): void
{
$this->redis->setex(
$this->prefix . $state->threadId,
$this->ttl,
json_encode($state->toArray(), JSON_THROW_ON_ERROR),
);
}Вызов инструментов (Tool calling)
В Symfony теперь есть компонент symfony/ai. Вы помечаете обычный PHP-класс атрибутом, и модель может вызывать его.
#[AsTool('run_sql', 'Executes a read-only SELECT query and returns rows as JSON.')]
final readonly class RunSql
{
public function __construct(private \PDO $pdo) {}
public function __invoke(string $sql): string
{
$sql = trim($sql, " \t\n\r;");
if (!preg_match('/^(SELECT|WITH)\b/i', $sql)) {
return 'ERROR: only SELECT statements are allowed.';
}
if (!preg_match('/\bLIMIT\s+\d+/i', $sql)) {
$sql .= ' LIMIT 50';
}
try {
$rows = $this->pdo->query($sql)->fetchAll(\PDO::FETCH_ASSOC);
} catch (\PDOException $e) {
return 'ERROR: ' . $e->getMessage();
}
return json_encode(['row_count' => count($rows), 'rows' => $rows]);
}
}Важная строка здесь — это блок catch. Я возвращаю ошибку в виде строки. Я не выбрасываю исключение.
Эта строка возвращается модели. Поэтому, когда она пишет SELECT SUM(revenue), а MySQL отвечает "Unknown column 'revenue'", модель читает это и пишет вместо этого total_amount. Она сама исправляет свой запрос. Если бы я выбросил исключение, весь запуск остановился бы, и я получил бы stack trace вместо ответа.
О безопасности
Я знаю, что эта часть обязательна. На данный момент я создал пользователя с правами только для чтения для запроса данных, а также реализовал проверку регулярными выражениями, чтобы убедиться, что модель генерирует только операторы select, и заблокировать любые операции insert или delete. Проверка регулярными выражениями — это лишь первый барьер, а не полноценная граница безопасности SQL. Пользователь базы данных должен иметь права только на чтение, а приложение должно дополнительно валидировать распарсенный SQL/AST, ограничивать доступные таблицы и операции, принудительно устанавливать таймауты запросов и лимиты ресурсов, и в идеале выполнять запросы к реплике для чтения или изолированной аналитической базе данных.
Работа в действии
$ bin/console app:ask "How much did we sell last year?" I need one more detail ---------------------- Specify the order statuses (e.g., PAID, CONFIRMED, etc.) to include in the sales total // bin/console app:ask "your answer" --thread=40d87234bfc76bb2 $ bin/console app:ask "DELIVERED" --thread=40d87234bfc76bb2 Answer ------ We sold 1,132,046.00 in total for orders marked DELIVERED during last year.
Посмотрите, что произошло: агент не стал гадать. В моей таблице заказов есть такие статусы, как CANCELLED и RETURNED. Поэтому общая сумма меняется в зависимости от того, какие из них учитывать. Без уточнения правильного ответа нет. Затем процесс PHP завершился. Я запустил вторую команду отдельно. Другой процесс продолжил тот же разговор и выдал ответ. Два процесса. Один разговор.
Об инструментах ИИ, которые я использовал
Я использовал Qwen Coder, Trae AI и opencode, они оказались дешевле и отлично подошли для моих задач.
Что я не стал делать
Я думаю, эта часть важна, поэтому скажу о ней прямо.
Никакого RAG для схемы БД. В моей базе данных всего четыре таблицы. Вся схема легко помещается в промпт. RAG для схемы полезен, когда у вас несколько сотен таблиц. Если их меньше, это только ухудшает ситуацию, скрывая нужную для запроса таблицу.
Redis не является абсолютно надежным. При переполнении памяти Redis может удалить ключ. Тогда приостановленный разговор будет утерян. Для продакшена правильный путь — использовать MySQL в качестве основного хранилища, а Redis — в качестве кэша. Я скрыл это за интерфейсом, так что изменения будут минимальными.
Нет среды для оценки (evaluation). Я тестирую систему вручную на трех вопросах. Реальной системе нужен набор пар «вопрос-SQL» и процент успешных прохождений в CI, потому что при изменении промпта или модели точность может незаметно снизиться.
Чему я на самом деле научился
Вызов модели — это, пожалуй, процентов 5 от всего кода. И инструменты ИИ написали хорошую часть остального. Но решения принимал я, и именно на это ушло всё время.
Всё остальное было обычной инженерией. Куда уходит состояние. Что происходит, когда вывод некорректен. К какому объему данных разрешено обращаться этому запросу. Как остановить бесконечный цикл.
Именно эта часть превращает демо-версию в нечто пригодное для использования. PHP просто заставил меня задуматься об этом в первый же день, а не узнавать об этом позже в продакшене.
Вещь, на которую я потратил целый вечер
Мой классификатор просит модель ответить в формате JSON. Что-то вроде этого:
{"label": "ambiguous", "reason": "...", "missing": "..."}И это постоянно ломалось. Мой парсинг использовал такое регулярное выражение:
preg_match('/\{.*\}/s', $raw, $matches);Затем я вывел сырой ответ и понял проблему. Новые reasoning-модели сначала пишут свои рассуждения внутри блока <think>. И внутри этих рассуждений модель пишет черновой JSON два или три раза перед финальным ответом.
Таким образом, \{.*\} находило соответствие от самого первого { до самого последнего }. Оно захватывало несколько строк обычного текста между ними. Это был невалидный JSON.
Решение:
// Reasoning models write a <think> block first. Remove it.
if (false !== ($end = strripos($text, '</think>'))) {
$text = substr($text, $end + 8);
}
// Then take the last plain JSON object.
if (preg_match_all('/\{[^{}]*\}/s', $text, $matches)) {
$text = end($matches[0]);
}[^{}]* не может выходить за пределы других фигурных скобок, поэтому оно не будет захватывать текст между ними.
Если вы используете модели GPT-OSS, Qwen или DeepSeek, вы столкнетесь с этим. Я нигде не видел упоминания об этом.
Никогда не доверяйте выводу модели напрямую
Даже после парсинга я проверяю метку по фиксированному списку:
private const LABELS = ['answerable', 'ambiguous', 'out_of_scope'];
$label = strtolower(trim((string) ($data['label'] ?? '')));
if (!in_array($label, self::LABELS, true)) {
return [
'label' => 'ambiguous',
'reason' => 'Unknown label',
'missing' => 'Could you rephrase your question?',
];
}И смотрю, к какому варианту она откатывается в случае ошибки. Если что-то идет не так, она откатывается к ambiguous (неопределенный), а не к answerable (тот, на который можно ответить). Таким образом, вопрос, который не был должным образом классифицирован, никогда не дойдет до шага генерации SQL.
Это общее правило, которому я следовал везде: модель предлагает, PHP решает.
Стек
Symfony 8.1, PHP 8.4, symfony/ai, Groq, MySQL, Redis.
Комментарии (0)
Пока нет комментариев — будьте первым.