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