TECH 9 мин

Как мы уменьшили латентность чата: 7 фаз оптимизации

От 12 секунд до 2.8 — история о том, как мы превратили медленный юридический чат в инструмент, которым приятно пользоваться

Как мы уменьшили латентность чата: 7 фаз оптимизации

Когда юрист задаёт вопрос системе искусственного интеллекта, каждая секунда ожидания — это секунда, когда он начинает сомневаться в технологии. Вот как мы сократили время ответа с 12 секунд до 2.8.


Исходная точка: почему чат был медленным

LEX AI работает не как обычный чат-бот. Наш ChatService реализует агентный цикл: получив запрос пользователя, LLM самостоятельно решает, какие инструменты вызвать, анализирует результаты и может сделать до 5 итераций прежде чем сформировать финальный ответ. Типичный запрос вроде «Какая судебная практика по возмещению морального ущерба при ДТП?» проходит такой путь:

  1. LLM анализирует запрос и выбирает инструменты
  2. Вызов search_court_decisions (семантический поиск в Qdrant + PostgreSQL)
  3. Вызов get_court_decision для 3-5 найденных решений
  4. LLM анализирует тексты и формирует ответ
  5. SSE стриминг результата клиенту

Каждый шаг — это сетевой запрос, и они выполнялись последовательно. Мы профилировали типичный запрос и получили такую картину:

Этап Время (мс) Доля
Первый вызов LLM (выбор инструментов) 2,400 20%
Поиск в Qdrant (эмбеддинг + query) 1,800 15%
Загрузка 4 решений из ZakonOnline 4,200 35%
Второй вызов LLM (анализ + ответ) 3,100 26%
Сериализация, SSE, накладные расходы 500 4%
Итого 12,000 100%

Медиана ответа — 12 секунд. P95 — 18.4 секунды. Для интерактивного чата это неприемлемо.


Фаза 1: Параллельное выполнение инструментов

Проблема: Когда LLM запрашивал вызов нескольких инструментов одновременно (например, search_court_decisions + get_legislation_section), мы выполняли их последовательно через простой for...of цикл.

Решение: Заменили последовательное выполнение на Promise.allSettled():

// Было:
for (const toolCall of toolCalls) {
  const result = await this.executeTool(toolCall);
  results.push(result);
}

// Стало:
const promises = toolCalls.map(tc => this.executeTool(tc));
const settled = await Promise.allSettled(promises);

Мы добавили семафор с ограничением в 6 параллельных вызовов, чтобы не перегрузить ни ZakonOnline API, ни базу. Каждый вызов получил индивидуальный таймаут в 8 секунд вместо общего.

Результат: -2,100 мс на запросах с 3+ инструментами. Наибольший выигрыш — когда LLM запрашивает сразу 4-5 судебных решений.


Фаза 2: SSE стриминг с первого токена

Проблема: Мы ждали полный ответ от LLM и только тогда отправляли его клиенту одним SSE-сообщением. Пользователь видел пустой экран 3+ секунды во время генерации текста.

Решение: Переключили OpenAI API на режим stream: true и пробросили токены напрямую в SSE:

// SSE события теперь летят по мере генерации
for await (const chunk of openaiStream) {
  const token = chunk.choices[0]?.delta?.content;
  if (token) {
    res.write(\`data: \${JSON.stringify({ type: 'token', content: token })}\\n\\n\`);
  }
}

На фронтенде useAIChat() хук теперь обновляет UI на каждый полученный токен. Первый текст появляется через 200-400 мс после начала генерации.

Результат: Воспринимаемая латентность (Time to First Token) упала с 3,100 мс до 380 мс. Общее время не изменилось, но UX улучшился кардинально.


Фаза 3: Кеширование на уровне инструментов

Проблема: Один и тот же запрос get_court_decision для популярного решения Верховного Суда вызывался десятки раз в день, каждый раз обращаясь к ZakonOnline API.

Решение: Добавили трёхступенчатый кеш: Redis (TTL 4 часа) -> PostgreSQL (TTL 30 дней) -> API:

async getDocumentFullText(docId: string): Promise<string> {
  const cached = await this.redis.get(\`doc:fulltext:\${docId}\`);
  if (cached) return cached; // ~2ms

  const pgCached = await this.db.query(
    'SELECT full_text FROM document_cache WHERE zakononline_id = $1', [docId]
  );
  if (pgCached.rows[0]) {
    await this.redis.setex(\`doc:fulltext:\${docId}\`, 14400, pgCached.rows[0].full_text);
    return pgCached.rows[0].full_text; // ~15ms
  }

  const text = await this.zoAdapter.fetchFullText(docId); // ~800ms
  // ... сохранить в оба кеша
  return text;
}

После недели работы cache hit rate стабилизировался на 73% для Redis и 91% для PostgreSQL.

Результат: -1,900 мс на повторных запросах (большинство). Экономия трафика к ZakonOnline: ~68%.


Фаза 4: Пул соединений и keep-alive

Проблема: Каждый HTTP-запрос к ZakonOnline открывал новое TCP-соединение. TLS handshake добавлял 120-180 мс на каждый вызов.

Решение: Настроили HTTP Agent с keep-alive и пулом:

const zoAgent = new https.Agent({
  keepAlive: true,
  maxSockets: 15,
  maxFreeSockets: 5,
  timeout: 10000,
});

Также увеличили пул PostgreSQL-соединений с 10 до 25 (через PgBouncer в transaction mode) и включили pipelining в Redis.

Результат: -380 мс на каждый внешний вызов после первого. При 4 вызовах за запрос — это -1,100 мс суммарно.


Фаза 5: Оптимизация промптов

Проблема: Системный промпт для ChatService содержал 2,800 токенов — детальное описание всех 36 инструментов, формат ответа, юридическая терминология. LLM тратил время на обработку этого контекста при каждой итерации.

Решение: Мы реструктурировали промпт:

Результат: -420 мс на каждом вызове LLM. При 2 вызовах за запрос — -840 мс.


Фаза 6: Предварительный расчёт эмбеддингов

Проблема: Каждый поисковый запрос генерировал эмбеддинг через OpenAI text-embedding-ada-002 — это 300-600 мс на API-вызов.

Решение: Ввели кеш эмбеддингов в Redis с нормализацией запросов:

function normalizeQuery(q: string): string {
  return q.toLowerCase().trim()
    .replace(/[\u00AB\u00BB"']/g, '')
    .replace(/\s+/g, ' ');
}

const cacheKey = \`emb:\${crypto.createHash('md5')
  .update(normalizeQuery(query)).digest('hex')}\`;

Дополнительно реализовали фоновую задачу, которая каждую ночь пре-вычисляет эмбеддинги для топ-200 самых частых запросов из аналитики.

Результат: -450 мс для повторных запросов (cache hit ~41% в первую неделю, ~58% через месяц).


Фаза 7: Материализация результатов поиска

Проблема: Семантический поиск в Qdrant возвращал ID документов, после чего мы делали N запросов к PostgreSQL для получения метаданных (название суда, дата, номер дела).

Решение: Создали материализованный view, который обновляется каждые 15 минут:

CREATE MATERIALIZED VIEW mv_court_decision_search AS
SELECT d.zakononline_id, d.title, d.court_name, d.case_number,
       d.judgment_date, d.justice_kind, d.doc_type,
       LEFT(d.full_text, 500) AS snippet
FROM court_decisions d
WHERE d.full_text IS NOT NULL;

CREATE INDEX idx_mv_search_zoid ON mv_court_decision_search(zakononline_id);

Теперь после получения ID из Qdrant мы делаем один batch-запрос к материализованному view вместо N отдельных.

Результат: -680 мс при поиске с 10+ результатами.


Итог: до и после

Метрика До После Изменение
Медиана ответа (p50) 12.0 с 2.8 с -77%
P95 18.4 с 5.2 с -72%
Time to First Token 3,100 мс 380 мс -88%
Cache hit rate (Redis) 0% 73% --
Внешние API-вызовы/запрос 6.2 2.1 -66%
Стоимость OpenAI за запрос $0.034 $0.021 -38%

Наибольшее влияние оказали три вещи: параллельное выполнение инструментов (фаза 1), кеширование (фаза 3) и стриминг (фаза 2, для восприятия). Остальные фазы дали меньший, но стабильный выигрыш, который накапливается.


Вывод

Оптимизация латентности в LLM-системах — это не одна серебряная пуля, а комбинация подходов на каждом уровне стека. Парадоксально, но наибольшее влияние на удовлетворённость пользователей оказало не сокращение общего времени, а стриминг первого токена. Юрист, который видит, что система «думает» и постепенно формирует ответ, готов ждать значительно дольше, чем тот, кто смотрит на пустой экран.