Разработка ИИ-агентов · Модуль 4 · Урок 4.6
Стриминг и UX: частичный вывод, показ шагов, прерывание
Зачем стримить ответ
В уроке 1.2 мы получали ответ модели целиком, одним куском. Для агента, который думает несколько секунд и делает несколько оборотов, это плохой опыт: пользователь смотрит на пустой экран и не знает, жив ли агент.
Стриминг — это получение ответа по частям, по мере генерации. Модель отдаёт токены потоком, а вы сразу показываете их пользователю. Ключевая выгода — time to first token (TTFT): первые слова появляются почти мгновенно, и ожидание перестаёт ощущаться «зависанием». Сам ответ не становится быстрее, но воспринимается гораздо живее.
Как это устроено технически
Провайдер отдаёт поток событий (обычно Server-Sent Events, SSE): маленькие чанки с дельтами текста, события начала/конца блоков, финальное событие с причиной остановки и usage. Ваш код читает поток построчно и склеивает дельты.
Между агентом и пользователем у вас тоже канал: тот же SSE или WebSocket. Получили дельту от модели — тут же переслали в браузер. Важно не забыть: стриминг — это про доставку, а не про логику. Стоп-сигнал, выполнение инструментов и safety-cap (урок 1.4) работают как раньше; вы лишь раньше начинаете показывать результат.
Показ шагов и прозрачность
У агента ответ — не только финальный текст, но и шаги: «ищу в базе…», «вызываю калькулятор…», «читаю документ…». Стриминг позволяет показывать этот ход работы: какой инструмент вызывается и что он вернул. Это резко повышает доверие — пользователь видит, что агент делает, а не ждёт молча.
Полезно стримить три вещи: (1) частичный текст ответа, (2) события «вызываю инструмент X», (3) при работе с источниками — ссылки/цитаты, чтобы ответ был проверяемым. Тонкая грань: показывать прогресс, но не вываливать сырые внутренности (полные аргументы инструментов с чувствительными данными).
Прерывание и steering
Раз пользователь видит процесс — дайте ему управление. Два приёма из продакшн-практики:
- Прерывание (cancel). Пользователь нажал «стоп» — нужно прервать генерацию и текущие вызовы. В Go это естественно делается через
context.Context: отмена контекста рвёт HTTP-поток к модели и останавливает инструменты. - Steering (коррекция на лету). Пользователь уточняет задачу, не дожидаясь конца («нет, только за июнь»). Агент должен учесть это в следующем обороте, а не игнорировать до завершения.
Оба приёма опираются на то, что цикл агента — ваш код, и вы контролируете каждый оборот. context пронизывает весь цикл: один cancel() — и поток к модели, и инструменты, и ожидание корректно сворачиваются.
sequenceDiagram
participant U as Пользователь
participant A as Агент (цикл)
participant M as LLM (SSE)
U->>A: запрос
A->>M: запрос со stream=true
M-->>A: дельта «Сей…»
A-->>U: «Сей…» (TTFT мал)
M-->>A: дельта «час…»
A-->>U: «час…»
M-->>A: tool_use: get_invoice
A-->>U: «вызываю get_invoice…»
A->>A: выполнить инструмент
A->>M: результат + продолжение
M-->>A: финальные дельты + stop
A-->>U: финал ответа// streamReply читает поток дельт от модели и отдаёт их в onDelta по мере
// поступления. Отмена ctx (пользователь нажал «стоп») рвёт поток.
func streamReply(ctx context.Context, body io.Reader, onDelta func(string)) (string, error) {
var full strings.Builder
sc := bufio.NewScanner(body)
for sc.Scan() {
select {
case <-ctx.Done(): // пользователь прервал — выходим немедленно
return full.String(), ctx.Err()
default:
}
line := sc.Text()
if !strings.HasPrefix(line, "data: ") {
continue // SSE: интересны только строки данных
}
payload := strings.TrimPrefix(line, "data: ")
if payload == "[DONE]" {
break
}
var ev struct {
Delta struct {
Text string `json:"text"`
} `json:"delta"`
}
if err := json.Unmarshal([]byte(payload), &ev); err != nil {
continue // пропускаем служебные события
}
if ev.Delta.Text != "" {
full.WriteString(ev.Delta.Text)
onDelta(ev.Delta.Text) // сразу пересылаем дельту в браузер
}
}
return full.String(), sc.Err()
}Anti-patterns
| Анти-паттерн | Почему плохо | Как правильно |
|---|---|---|
| Ждать весь ответ, потом показать | Долгое «зависание», плохой UX | Стримить дельты — низкий TTFT, живой отклик |
| Стримить, но без возможности прервать | Пользователь не может остановить ненужную работу | Прерывание через context.Context, отмена рвёт поток и инструменты |
| Показывать сырые аргументы инструментов | Утечка чувствительных данных в UI | Показывать факт вызова и суть, без секретов |
| Класть логику завершения в стриминг | Стоп-сигнал и safety-cap размываются | Стриминг — только доставка; логика цикла неизменна (урок 1.4) |
| Игнорировать ошибки посреди потока | Обрыв выглядит как зависание | Ловить ошибку чтения, показывать понятный статус/ретрай |
Практическое задание
- Включите stream в вызове модели и читайте SSE-поток, склеивая дельты; выведите их в консоль по мере поступления.
- Прокиньте context.Context через цикл и реализуйте «стоп»: отмена контекста должна прерывать и поток модели, и инструменты.
- Добавьте показ шагов: при tool_use печатайте «вызываю <инструмент>…» до выполнения и краткий итог после.
- Скройте чувствительные аргументы инструментов из того, что показываете пользователю, оставив суть.
- Сымитируйте обрыв потока на середине и убедитесь, что пользователь видит понятный статус, а не вечную «загрузку».
Проверка знаний
Что в первую очередь улучшает стриминг ответа?
Верный ответ: B
B верно. Стриминг отдаёт ответ по частям, поэтому первые слова видны почти мгновенно — ожидание не ощущается зависанием. Суммарное время (A) и стоимость (C) не меняются; на точность (D) стриминг не влияет.
Как в Go естественно реализовать прерывание стриминга и инструментов по кнопке «стоп»?
Верный ответ: B
B верно. context.Context — идиоматичный способ отмены в Go: один cancel() сворачивает HTTP-поток, ожидание и вызовы инструментов. A — гонки и не прерывает блокирующее чтение; C — грубо; D — не экономит работу и время.
Что НЕ должно меняться при добавлении стриминга?
Верный ответ: A
A верно. Стриминг — это про доставку результата, а не про логику. Стоп-сигнал, выполнение инструментов и safety-cap (урок 1.4) остаются прежними. B, C, D — как раз то, что стриминг меняет к лучшему.