Фрік Ван дер Ертен (Freek Van der Herten) продовжує серію дописів про агента та інструменти, які команда Spatie побудувала на основі Laravel AI SDK для There There - гелпдеск-системи, що наразі розробляється. Попередній допис завершився невеликою інтригою: агент передається стрімінговому сервісу, який перетворює потік відповіді SDK на події, зрозумілі фронтенду. Саме цьому присвячено новий матеріал.
Підхід There There
Після двох десятиліть власної підтримки клієнтів команда хотіла створити гелпдеск, де ШІ робить агентів підтримки швидшими, а не намагається їх замінити. Людина читає, думає й спрямовує, а модель на її очах у реальному часі готує чернетки та шукає потрібну інформацію. There There зараз перебуває в закритій бета-версії, а заявку на ранній доступ можна подати на there-there.app.
Стрімінг із Laravel до React
Найочевидніший спосіб передати відповідь LLM у браузер - дочекатися її завершення й відрендерити все одразу. Це просто, але агент здається млявим. Натомість команда хоче, щоб відповідь з'являлася слово за словом, а міркування агента (наприклад, який інструмент він викликає) озвучувалися по ходу.
Laravel AI SDK надає кожен крок розмови як типізовану подію. Сервіс ітерує цей потік і надсилає фронтенду newline-delimited JSON (NDJSON) - один рядок на подію.
Ось ядро AgentStreamingService:
public function stream(Agent&HasTools $agent, string $message, AgentChat $chat): StreamedResponse
{
return response()->stream(function () use ($agent, $message, $chat): Generator {
yield from $this->streamAgent($agent, $message, $chat);
}, 200, [
'Content-Type' => 'application/x-ndjson',
'Cache-Control' => 'no-cache',
]);
}
private function streamAgent(Agent&HasTools $agent, string $message, AgentChat $chat): Generator
{
set_time_limit(120);
$fullContent = '';
$toolCalls = [];
foreach ($agent->stream($message) as $event) {
if ($event instanceof ToolCall) {
yield json_encode(['type' => 'tool_call', 'tool_name' => $event->toolCall->name."\n";
}
if ($event instanceof TextDelta) {
$fullContent .= $event->delta;
yield json_encode(['type' => 'delta', 'content' => $event->delta]))."\n";
}
if ($event instanceof ToolResult) {
$toolCalls[] = [
'name' => $event->toolResult->name,
'arguments' => $event->toolResult->arguments,
'result' => $event->toolResult->result,
];
}
}
$chat->messages()->create([
'role' => MessageRole::Assistant,
'content' => $fullContent,
'tool_calls' => $toolCalls ?: null,
]);
yield json_encode([
'type' => 'done',
'html' => $this->parser->buildFinalHtml($fullContent),
])."\n";
}
На що варто звернути увагу:
response()->stream() приймає генератор і негайно надсилає клієнту кожен yield - у цьому весь фокус із серверним пушем прогресу.
- Події типізовані (
delta, tool_call, done), тож фронтенд відрізняє фрагмент тексту від виклику інструмента.
- Готове повідомлення зберігається в базі даних в тому ж проході. Сам потік є джерелом істини, а збережена копія використовується для відновлення чату, коли користувач відкриє його наступного разу.
Споживання потоку на фронтенді
На клієнті потік читається звичайним fetch. Жодних бібліотек і жодного SSE-фреймінгу. Ось хук, скорочений до суттєвого:
const response = await fetch(sendUrl, {
method: 'POST',
headers: { ...csrfHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(buildBody(text, html)),
signal: controller.signal,
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
processLine(line);
}
}
if (buffer.trim()) {
processLine(buffer);
}
Єдине, що треба зробити правильно, - це буферизація. Один виклик reader.read() може повернути частковий рядок, цілий рядок або одразу кілька. Тому текст розбивається за \n, останній (можливо, неповний) фрагмент лишається в buffer, а наприкінці його також обробляють. Кожен завершений рядок - це одна подія.
Обробка подій у стані
Диспетчеризація кожної події у стан - це короткий switch:
if (event.type === 'tool_call') {
setMessages((prev) => updateLastStreaming(prev, {
toolStatus: formatToolName(event.tool_name),
}));
} else if (event.type === 'delta') {
setMessages((prev) => updateLastStreaming(prev, (last) => ({
content: last.content + event.content,
toolStatus: null,
})));
} else if (event.type === 'done') {
setMessages((prev) => updateLastStreaming(prev, {
html: event.html,
isStreaming: false,
}));
}
Коли надходить tool_call, над відповіддю, що ще стрімиться, з'являється невелика позначка на кшталт «Looking up tickets». Коли починають надходити delta, позначку прибирають і дописують текст до вмісту повідомлення. Із подією done підставляється відрендерений на сервері HTML, а прапорець стрімінгу вимикається. Загалом виглядає так, ніби агент друкує просто перед вами.
Підсумок
Існують і «важчі» способи: server-sent events, websockets, повноцінна стрімінгова бібліотека з обох боків. Для чат-інтерфейсу нічого з цього не потрібно. Генератор на сервері, NDJSON у каналі та невеликий буферний цикл на клієнті - це приблизно сотня рядків від початку до кінця, і працює це саме так, як має поводитися справжній стрімінговий LLM-чат.
Докладніше про події, які генерує SDK, можна дізнатися в репозиторії Laravel AI SDK. А якщо хочете спробувати There There, команда зараз у закритій бета-версії - подати заявку на ранній доступ можна на there-there.app.