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