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-системах — це не одна срібна куля, а комбінація підходів на кожному рівні стеку. Парадоксально, але найбільший вплив на задоволеність користувачів мав не скорочення загального часу, а стрімінг першого токена. Юрист, який бачить, що система "думає" і поступово формує відповідь, готовий чекати значно довше, ніж той, хто дивиться на порожній екран.