perf(assist): кэш индекса (TTL 10 мин) + предрасчёт списков + дедлайн 40с + логирование этапов

This commit is contained in:
Repinoid
2026-10-07 17:20:24 +03:00
parent 9728fe4cf8
commit 99e034dfeb
2 changed files with 53 additions and 5 deletions
+44 -2
View File
@@ -27,22 +27,35 @@ const HTTPS = require('https');
const TOTAL_DEADLINE_MS = ENV.ASSIST_TOTAL_DEADLINE_MS || 40000; const TOTAL_DEADLINE_MS = ENV.ASSIST_TOTAL_DEADLINE_MS || 40000;
const RETRY_STAGE1_MIN_BUDGET_MS = 15000; const RETRY_STAGE1_MIN_BUDGET_MS = 15000;
const STAND_RE = /^[A-Za-z0-9._-]+$/; const STAND_RE = /^[A-Za-z0-9._-]+$/;
const INDEX_TTL_MS = Number(process.env.ASSIST_INDEX_TTL_MS) || 600000;
// Кэш индекса по stand (в памяти): экономит HTTP-запрос и парсинг на каждом вопросе.
const indexCache = new Map();
/** /**
* Загружает search_index.json с VM_DOCS_BASE_URL/<stand>/search/search_index.json. * Загружает search_index.json с VM_DOCS_BASE_URL/<stand>/search/search_index.json.
* Кэширует результат в памяти (на стенд). * Мемоизирует результат по stand с TTL. При сбое возвращает свежий кэш, если есть.
* *
* @param {string} stand * @param {string} stand
* @param {string} vmDocsBaseUrl * @param {string} vmDocsBaseUrl
* @returns {Promise<object|null>} - index из loadIndex() или null. * @returns {Promise<object|null>} - index из loadIndex() или null.
*/ */
async function fetchIndex(stand, vmDocsBaseUrl) { async function fetchIndex(stand, vmDocsBaseUrl) {
const now = Date.now();
const cached = indexCache.get(stand);
if (cached && (now - cached.at) < INDEX_TTL_MS) {
return cached.index;
}
try { try {
const url = `${vmDocsBaseUrl.replace(/\/+$/, '')}/${stand}/search/search_index.json`; const url = `${vmDocsBaseUrl.replace(/\/+$/, '')}/${stand}/search/search_index.json`;
const mod = url.startsWith('https:') ? HTTPS : HTTP; const mod = url.startsWith('https:') ? HTTPS : HTTP;
const text = await httpGet(mod, url); const text = await httpGet(mod, url);
return loadIndex(text); const index = loadIndex(text);
indexCache.set(stand, { index, at: now });
return index;
} catch (e) { } catch (e) {
// Свежий кэш лучше ошибки: продолжаем работу на устаревшем индексе.
if (cached) return cached.index;
return null; return null;
} }
} }
@@ -125,6 +138,9 @@ async function handleAssist(req, res, config) {
const start = Date.now(); const start = Date.now();
let holderActive = true; let holderActive = true;
// Общий бюджет времени: по истечении прерываем вызовы ЛЛМ.
const deadlineTimer = setTimeout(() => controller.abort(), TOTAL_DEADLINE_MS);
// Прерывание LLM при закрытии соединения клиентом. // Прерывание LLM при закрытии соединения клиентом.
const onClose = () => { const onClose = () => {
inflightEntry.holderCount -= 1; inflightEntry.holderCount -= 1;
@@ -135,6 +151,7 @@ async function handleAssist(req, res, config) {
req.on('close', onClose); req.on('close', onClose);
const release = () => { const release = () => {
clearTimeout(deadlineTimer);
try { sem.release(); } catch { } try { sem.release(); } catch { }
req.removeListener('close', onClose); req.removeListener('close', onClose);
config.shared.inflight.delete(normKey); config.shared.inflight.delete(normKey);
@@ -149,17 +166,34 @@ async function handleAssist(req, res, config) {
cache.set(normKey, body); cache.set(normKey, body);
sendJson(res, 200, body); sendJson(res, 200, body);
for (const w of inflightEntry.waiters) w.succeed(body); for (const w of inflightEntry.waiters) w.succeed(body);
logAssist('ok', stand, question.length, result.meta, Date.now() - start);
} catch (err) { } catch (err) {
holderActive = false; holderActive = false;
const code = err && err.code ? err.code : 'internal_error'; const code = err && err.code ? err.code : 'internal_error';
const status = mapPipelineError(code); const status = mapPipelineError(code);
for (const w of inflightEntry.waiters) w.fail(status, code); for (const w of inflightEntry.waiters) w.fail(status, code);
jsonError(res, status, code, err && err.message ? err.message : 'internal error'); jsonError(res, status, code, err && err.message ? err.message : 'internal error');
logAssist('error', stand, question.length, { error: code }, Date.now() - start);
} finally { } finally {
release(); release();
} }
} }
/** Однострочный JSON-лог без текста запроса (7.11 брифа). */
function logAssist(status, stand, questionLen, meta, totalMs) {
try {
const line = JSON.stringify({
t: new Date().toISOString(),
status,
stand,
question_len: questionLen,
total_ms: totalMs,
...meta,
});
process.stdout.write(line + '\n');
} catch { /* лог не должен ронять */ }
}
/** Возвращает IP клиента (7.3: X-Forwarded-For только для доверенных proxy). */ /** Возвращает IP клиента (7.3: X-Forwarded-For только для доверенных proxy). */
function clientIp(req) { function clientIp(req) {
const trusted = (ENV.ASSIST_TRUSTED_PROXY_CIDR || '').trim(); const trusted = (ENV.ASSIST_TRUSTED_PROXY_CIDR || '').trim();
@@ -202,6 +236,7 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
throw { code: 'index_unavailable', message: 'search index unavailable' }; throw { code: 'index_unavailable', message: 'search index unavailable' };
} }
const resourceTypes = index.listResourceTypes(); const resourceTypes = index.listResourceTypes();
const tIndex = Date.now();
// Этап 1 (resource_type). // Этап 1 (resource_type).
let resourceType = null; let resourceType = null;
@@ -215,11 +250,13 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
stage1Ok = r1.ok; stage1Ok = r1.ok;
if (r1.ok && r1.resourceType) resourceType = r1.resourceType; if (r1.ok && r1.resourceType) resourceType = r1.resourceType;
} }
const tStage1 = Date.now();
// Поиск по токенам исходного запроса — всегда (параллельно уже можно, но для простоты // Поиск по токенам исходного запроса — всегда (параллельно уже можно, но для простоты
// объединяем в mergeSearchResults, который сам делает поиск по query и по resource_type). // объединяем в mergeSearchResults, который сам делает поиск по query и по resource_type).
const merged = mergeSearchResults(index, question, resourceType, { topK: 5 }); const merged = mergeSearchResults(index, question, resourceType, { topK: 5 });
const context = buildContext(merged, 1500); const context = buildContext(merged, 1500);
const tSearch = Date.now();
// Этап 2. // Этап 2.
const r2 = await stage2Generate({ const r2 = await stage2Generate({
@@ -231,6 +268,7 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
maxTokens: 2048, maxTokens: 2048,
controller, controller,
}); });
const tStage2 = Date.now();
if (!r2.ok) { if (!r2.ok) {
throw { code: r2.error, message: r2.error === 'bad_llm_output' ? 'bad llm output' : 'llm error' }; throw { code: r2.error, message: r2.error === 'bad_llm_output' ? 'bad llm output' : 'llm error' };
@@ -246,6 +284,10 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
resource_type: resourceType, resource_type: resourceType,
stage1_ok: stage1Ok, stage1_ok: stage1Ok,
elapsed_ms: Date.now() - t1, elapsed_ms: Date.now() - t1,
index_ms: tIndex - t1,
stage1_ms: tStage1 - tIndex,
search_ms: tSearch - tStage1,
stage2_ms: tStage2 - tSearch,
}, },
}; };
} }
+9 -3
View File
@@ -145,13 +145,19 @@ function loadIndex(rawJson) {
index = { docs: Array.isArray(index) ? index : [] }; index = { docs: Array.isArray(index) ? index : [] };
} }
const docs = Array.isArray(index.docs) ? index.docs : [];
// Предрасчёт: списки ресурсов считаются ОДИН раз при загрузке индекса,
// а не на каждый запрос (экономия CPU на regex по всем докам).
const resourceTypes = extractResourceTypes({ docs });
const pages = resourcePages({ docs });
return { return {
docs: Array.isArray(index.docs) ? index.docs : [], docs,
listResourceTypes() { listResourceTypes() {
return extractResourceTypes({ docs: this.docs }); return resourceTypes;
}, },
resourcePages() { resourcePages() {
return resourcePages({ docs: this.docs }); return pages;
}, },
}; };
} }