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 RETRY_STAGE1_MIN_BUDGET_MS = 15000;
|
||||
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.
|
||||
* Кэширует результат в памяти (на стенд).
|
||||
* Мемоизирует результат по stand с TTL. При сбое возвращает свежий кэш, если есть.
|
||||
*
|
||||
* @param {string} stand
|
||||
* @param {string} vmDocsBaseUrl
|
||||
* @returns {Promise<object|null>} - index из loadIndex() или null.
|
||||
*/
|
||||
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 {
|
||||
const url = `${vmDocsBaseUrl.replace(/\/+$/, '')}/${stand}/search/search_index.json`;
|
||||
const mod = url.startsWith('https:') ? HTTPS : HTTP;
|
||||
const text = await httpGet(mod, url);
|
||||
return loadIndex(text);
|
||||
const index = loadIndex(text);
|
||||
indexCache.set(stand, { index, at: now });
|
||||
return index;
|
||||
} catch (e) {
|
||||
// Свежий кэш лучше ошибки: продолжаем работу на устаревшем индексе.
|
||||
if (cached) return cached.index;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -125,6 +138,9 @@ async function handleAssist(req, res, config) {
|
||||
const start = Date.now();
|
||||
let holderActive = true;
|
||||
|
||||
// Общий бюджет времени: по истечении прерываем вызовы ЛЛМ.
|
||||
const deadlineTimer = setTimeout(() => controller.abort(), TOTAL_DEADLINE_MS);
|
||||
|
||||
// Прерывание LLM при закрытии соединения клиентом.
|
||||
const onClose = () => {
|
||||
inflightEntry.holderCount -= 1;
|
||||
@@ -135,6 +151,7 @@ async function handleAssist(req, res, config) {
|
||||
req.on('close', onClose);
|
||||
|
||||
const release = () => {
|
||||
clearTimeout(deadlineTimer);
|
||||
try { sem.release(); } catch { }
|
||||
req.removeListener('close', onClose);
|
||||
config.shared.inflight.delete(normKey);
|
||||
@@ -149,17 +166,34 @@ async function handleAssist(req, res, config) {
|
||||
cache.set(normKey, body);
|
||||
sendJson(res, 200, body);
|
||||
for (const w of inflightEntry.waiters) w.succeed(body);
|
||||
logAssist('ok', stand, question.length, result.meta, Date.now() - start);
|
||||
} catch (err) {
|
||||
holderActive = false;
|
||||
const code = err && err.code ? err.code : 'internal_error';
|
||||
const status = mapPipelineError(code);
|
||||
for (const w of inflightEntry.waiters) w.fail(status, code);
|
||||
jsonError(res, status, code, err && err.message ? err.message : 'internal error');
|
||||
logAssist('error', stand, question.length, { error: code }, Date.now() - start);
|
||||
} finally {
|
||||
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). */
|
||||
function clientIp(req) {
|
||||
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' };
|
||||
}
|
||||
const resourceTypes = index.listResourceTypes();
|
||||
const tIndex = Date.now();
|
||||
|
||||
// Этап 1 (resource_type).
|
||||
let resourceType = null;
|
||||
@@ -215,11 +250,13 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
|
||||
stage1Ok = r1.ok;
|
||||
if (r1.ok && r1.resourceType) resourceType = r1.resourceType;
|
||||
}
|
||||
const tStage1 = Date.now();
|
||||
|
||||
// Поиск по токенам исходного запроса — всегда (параллельно уже можно, но для простоты
|
||||
// объединяем в mergeSearchResults, который сам делает поиск по query и по resource_type).
|
||||
const merged = mergeSearchResults(index, question, resourceType, { topK: 5 });
|
||||
const context = buildContext(merged, 1500);
|
||||
const tSearch = Date.now();
|
||||
|
||||
// Этап 2.
|
||||
const r2 = await stage2Generate({
|
||||
@@ -231,6 +268,7 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
|
||||
maxTokens: 2048,
|
||||
controller,
|
||||
});
|
||||
const tStage2 = Date.now();
|
||||
|
||||
if (!r2.ok) {
|
||||
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,
|
||||
stage1_ok: stage1Ok,
|
||||
elapsed_ms: Date.now() - t1,
|
||||
index_ms: tIndex - t1,
|
||||
stage1_ms: tStage1 - tIndex,
|
||||
search_ms: tSearch - tStage1,
|
||||
stage2_ms: tStage2 - tSearch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user