Як ми зменшили латентність чату: 7 фаз оптимізації
Від 12 секунд до 2.8 — історія про те, як ми перетворили повільний юридичний чат на інструмент, яким приємно користуватись
Як ми зменшили латентність чату: 7 фаз оптимізації
Коли юрист ставить питання системі штучного інтелекту, кожна секунда очікування — це секунда, коли він починає сумніватись у технології. Ось як ми скоротили час відповіді з 12 секунд до 2.8.
Вихідна точка: чому чат був повільним
LEX AI працює не як звичайний чат-бот. Наш ChatService реалізує агентний цикл: отримавши запит користувача, LLM самостійно вирішує, які інструменти викликати, аналізує результати, і може зробити до 5 ітерацій перш ніж сформувати фінальну відповідь. Типовий запит на кшталт "Яка судова практика щодо відшкодування моральної шкоди за ДТП?" проходить такий шлях:
- LLM аналізує запит і обирає інструменти
- Виклик
search_court_decisions(семантичний пошук у Qdrant + PostgreSQL) - Виклик
get_court_decisionдля 3-5 знайдених рішень - LLM аналізує тексти та формує відповідь
- 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 витрачав час на обробку цього контексту при кожній ітерації.
Рішення: Ми реструктуризували промпт:
- Скоротили опис інструментів до ключових параметрів (з 2,800 до 1,400 токенів)
- Додали
DOMAIN_TOOL_MAP— коротку маршрутизацію за доменом запиту замість повного списку - Перенесли приклади використання з системного промпту в few-shot секцію, яка додається тільки при першому виклику
Результат: -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-системах — це не одна срібна куля, а комбінація підходів на кожному рівні стеку. Парадоксально, але найбільший вплив на задоволеність користувачів мав не скорочення загального часу, а стрімінг першого токена. Юрист, який бачить, що система "думає" і поступово формує відповідь, готовий чекати значно довше, ніж той, хто дивиться на порожній екран.