perf(assist): кэш индекса (TTL 10 мин) + предрасчёт списков + дедлайн 40с + логирование этапов
This commit is contained in:
+44
-2
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user