Запуск агента редко ограничивается одним запросом к API. Модель возвращает вызовы инструментов (tools), SDK их исполняет, отправляет результаты обратно и повторяет этот процесс до завершения работы модели. До Laravel AI v0.11.0 цикл генерации не имел событий для отдельных шагов, сообщая лишь об PromptingAgent в начале и AgentPrompted в самом конце выполнения. Запуск, потребовавший пять сетевых запросов к провайдеру, выглядел абсолютно так же, как и запуск с одним запросом, а если в середине цикла возникало исключение, то ничего не логировалось, так как событие AgentPrompted так и не вызывалось.
Серия из семи пулл-реквестов от @pushpak1300, замерженных в рамках PR от #870 до #876, меняет эту ситуацию. Теперь у каждого запуска есть единый ID, а каждый запрос к провайдеру и вызов инструмента отправляют события начала и завершения с точным хронометражем выполнения.
#Единый ID для всего запуска
Метод streamPrompt() и раньше генерировал ID вызова на уровне всего запуска, но prompt() этого не делал. Синхронные middleware видели $prompt->invocationId === null , в то время как потоковые middleware получали реальное значение. А поскольку провайдер генерировал собственный ID при каждой попытке, запуск с переключением при сбое (failover) через три провайдера создавал три не связанных между собой ID для того, что с точки зрения вызывающего кода было единым запуском.
Теперь prompt() сразу генерирует ID, а провайдер повторно использует то, что было передано вызывающей стороной (#871). Каждое из описанных ниже событий принимает его в качестве первого аргумента конструктора, что позволяет группировать все события запуска по одной строке:
public function __construct(
public string $invocationId,
public int $stepNumber,
// ...
) {}
Событие AgentFailedOver также получило этот ID: теперь оно принимает обязательный аргумент string $invocationId первым параметром конструктора. Это не влияет на слушатели (listeners), однако любой код, создающий это событие вручную, требует обновления.
#События шагов (Step Events)
События StartingStep , StepCompleted и StepFailed срабатывают до и после каждого запроса к провайдеру как при синхронном выполнении, так и при потоковой передаче (#873).
StartingStep содержит сообщения, отправленные для текущего шага, включая результаты выполнения инструментов из предыдущих шагов, а также опции, разрешенные для этого шага (которые могут отличаться от собственных опций агента после того, как было удовлетворено требование принудительного выбора инструмента). Оно также содержит stepNumber и isFinalStep :
use Laravel\Ai\Events\StartingStep;
Event::listen(StartingStep::class, function (StartingStep $event) {
// $event->stepNumber, $event->model, $event->isFinalStep
// $event->messages, $event->options
});
StepCompleted содержит весь объект StepResponse , поэтому слушателю больше не нужно собирать его заново, а также float $time — время, потраченное на запрос к провайдеру в миллисекундах. Единица измерения совпадает с QueryExecuted::$time , поэтому код, обрабатывающий время выполнения база-данных запросов, может работать с этими значениями аналогично.
use Laravel\Ai\Events\StepCompleted;
Event::listen(StepCompleted::class, function (StepCompleted $event) {
Log::info('AI step completed', [
'invocation' => $event->invocationId,
'step' => $event->stepNumber,
'ms' => $event->time,
'prompt_tokens' => $event->response->usage->promptTokens,
'finish' => $event->response->finishReason->value,
]);
});
Ранее статистика использования по шагам была доступна через $response->steps , но только в виде единой агрегированной структуры в итоговом событии и без информации о времени. Теперь расход токенов и длительность каждого запроса логируются непосредственно по ходу выполнения запуска.
StepFailed обрабатывает случаи, когда шаг завершается без получения ответа, передавая объект Throwable и время, прошедшее до выброса исключения.
#События инструментов (Tool Events)
События InvokingTool и ToolInvoked существовали и раньше. Однако они вызывались через пару callback-функций, которые каждый провайдер регистрировал в цикле генерации, а ID текущего вызова инструмента хранился в единственном изменяемом свойстве провайдера, что ломалось при вложенных вызовах. Поскольку менеджер возвращает один экземпляр провайдера на имя, агент, вызванный в качестве инструмента, перезаписывал ID до того, как срабатывал внешний ToolInvoked , в результате чего внешнее событие содержало ID внутреннего вызова.
Теперь объект RunContext хранит контекст запуска и отправляет эти события напрямую, а ID вызова инструмента генерируется внутри executeTool() , благодаря чему каждый вызов получает свой уникальный ID (#872). Инструменты могут самостоятельно прочитать его из запроса:
public function handle(Request $request): string
{
$request->toolInvocationId(); // string|null
}
Новое событие — ToolFailed (#874). Ранее метод executeTool() не имел блока catch , поэтому если обработчик инструмента выбрасывал исключение, оно пробрасывалось наверх из цикла генерации: вызов инструмента так и не фиксировал свое завершение, а запуск прерывался без сохранения информации о том, какой именно инструмент вызвал сбой. ToolFailed фиксирует эту ошибку, содержа тот же toolInvocationId , что и открывавший вызов InvokingTool . Исключение при этом всё равно пробрасывается повторно, поэтому поведение системы в остальном не изменилось. Защищен только вызов самого обработчика, поэтому если исключение выбросит слушатель событий, это не будет ошибочно зафиксировано как сбой инструмента.
Событие ToolInvoked также получилось обязательный аргумент float $time — время выполнения обработчика. Если вы создаете эти события вручную, обновите вызов конструктора, передав длительность.
Оба события теперь передают экземпляр класса Tool , а не его имя, поэтому связывайте их по toolInvocationId и получайте наименование из объекта:
use Laravel\Ai\Events\ToolFailed;
Event::listen(ToolFailed::class, function (ToolFailed $event) {
Log::error('AI tool failed', [
'invocation' => $event->invocationId,
'tool_invocation' => $event->toolInvocationId,
'tool' => class_basename($event->tool),
'arguments' => $event->arguments,
'ms' => $event->time,
'exception' => $event->exception->getMessage(),
]);
});
#События сбоев запуска (Run Failure Events)
Все события, завершающие интервал выполнения в пакете, ранее вызывались только при успешном сценарии. Если шлюз выбрасывал исключение, AgentPrompted никогда не вызывался, из-за чего слушатель, отслеживающий запуск, не мог узнать о его ошибке.
Событие AgentFailed сообщает об этом один раз за запуск и только после его фактического завершения (#876). Если настроен failover, то первый провайдер, выбросивший FailoverableException , не приводит к критическому сбою, поэтому событие сработает только после того, как будет исчерпана вся цепочка провайдеров. Оно содержит ID вызова, промпт и само исключение.
Кроме того, AgentFailedOver больше не вызывается для последнего провайдера в цепочке. У этой попытки больше нет запасных вариантов, поэтому она фиксируется непосредственно как ошибка запуска. Ранее событие failover отправлялось безусловно в каждом блоке catch , включая последний.
#Связывание под-агента с родителем
Агент, вызванный в качестве инструмента, ранее выглядел как отдельный запуск, никак не связанный с родительским. Теперь вызов инструмента отслеживает ID своего запуска и ID вызова инструмента на всём протяжении своей работы. Любой агент, вызванный во время выполнения этого инструмента, получает их в свойствах parentInvocationId и parentToolInvocationId своего промпта (#875).
Это работает и для кастомных инструментов, вызывающих агента, а не только для AgentTool . Эти ID хранятся в статическом свойстве, а не в контексте, что предотвращает попадание делегирующего вызова в payload очередей задач (queued jobs).
Эта связь не пересекает границы очередей. Промпт, отправленный через promptOnQueue() изнутри инструмента, создаст собственный независимый запуск без родителя.
#Очередность слушателей и история сообщений
StartingStep передает всю историю сообщений запуска. Слушатель, реализующий интерфейс ShouldQueue , будет сериализовать всю историю вместе со всеми вложениями. Это осознанный компромисс, так как слушателю, открывающему интервал отслеживания, нужен исходный запрос, с которым был отправлен шаг. Если вам нужны только тайминги и количество токенов, подписывайтесь на StepCompleted , который содержит ответ конкретного шага, а не всю историю.
#Что еще почитать
Эти события были выпущены в v0.11.0 вместе с возможностью поиска инструментов на стороне сервиса и расширенным failover для провайдеров. Подробнее о чтении сырых HTTP-ответов провайдера во время запуска (включая заголовки rate limit и ID запросов) читайте в заметке про свойство raw HTTP response, добавленное в v0.10.3.
Для ознакомления с пакетом изучите анонс AI SDK и наш материал про подтверждение вызова инструментов человеком (human-in-the-loop). Исходный код доступен на GitHub в репозитории laravel/ai.
Комментарии (0)
Пока нет комментариев — будьте первым.