LEX — AI Legal Platform for Law Firms

AI-powered legal analysis platform for law firms and corporate counsel.

Features

Resources

Blog Articles

Technology

Built on AWS (EC2, Bedrock Claude AI, ALB, WAF, S3, ACM, KMS). PostgreSQL, Redis, Qdrant vector database. TypeScript, React, Node.js.

Start free — 50 credits on registration. Sign up

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