Files
memind/scripts/benchmark-token-optimization.mjs
T
john 9332bacabd
Memind CI / Test, build, and release guards (push) Failing after 12m44s
feat(router): improve direct_chat fast-path and add 103 token env sync
Route explicit text-only Q&A away from Agent before page-generation rules,
and add a script to enable Router + tighter memory budgets on 103 without
changing the global deepseek-v4-pro model.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 15:13:22 +08:00

543 lines
17 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Local A/B benchmark: DeepSeek token usage vs scenario quality.
*
* Usage:
* node scripts/benchmark-token-optimization.mjs
* node scripts/benchmark-token-optimization.mjs --scenario john2-suzhou-page
* node scripts/benchmark-token-optimization.mjs --phase baseline
* node scripts/benchmark-token-optimization.mjs --phase optimized
* node scripts/benchmark-token-optimization.mjs --restore
*
* Does NOT touch production. Restores model + memory admin config on exit.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { createLlmProviderService } from '../llm-providers.mjs';
import { createMemoryV2AdminConfigService } from '../memory-v2-admin-config.mjs';
import { loadH5Environment } from './load-env.mjs';
import {
createReporter,
createAgentRun,
extractAssistantTexts,
extractPublicLinks,
getSession,
loadScenario,
loginViaApi,
resolvePortalBase,
snapshotPublicHtml,
verifyPageAccess,
waitForAssistantGrowth,
waitForRunTerminal,
} from './scenario-test-lib.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(scriptDir, '..');
const statePath = path.join(root, '.token-benchmark-state.json');
loadH5Environment(scriptDir);
const PROFILES = {
baseline: {
label: 'baseline (deepseek-v4-pro + 默认记忆预算)',
model: 'deepseek-v4-pro',
memoryPatch: null,
},
optimized: {
label: 'optimized (deepseek-v4-flash + 收紧记忆/Router)',
model: 'deepseek-v4-flash',
memoryPatch: {
runtimeControl: {
agentResolveLimit: '3',
},
retriever: {
limit: '4',
tokenBudget: '900',
},
persona: {
maxTokens: '300',
},
chatIntentRouter: {
enabled: true,
shadowMode: false,
model: 'deepseek-chat',
memoryResolveLimit: '2',
},
},
},
};
function parseArgs(argv) {
let scenarioId = 'token-benchmark-page';
let phase = 'both';
let port = Number(process.env.H5_PORT ?? 8081);
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--scenario' && argv[i + 1]) scenarioId = argv[++i];
else if (arg === '--phase' && argv[i + 1]) phase = argv[++i];
else if (arg === '--port' && argv[i + 1]) port = Number(argv[++i]);
else if (arg === '--restore') phase = 'restore';
else if (arg === '-h' || arg === '--help') {
console.log(`Usage:
node scripts/benchmark-token-optimization.mjs [--scenario <id>] [--phase baseline|optimized|both|restore]`);
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!['baseline', 'optimized', 'both', 'restore'].includes(phase)) {
throw new Error(`Invalid --phase: ${phase}`);
}
return { scenarioId, phase, port };
}
async function ensurePortalReady(baseUrl) {
const response = await fetch(`${baseUrl}/auth/status`);
if (!response.ok) {
throw new Error(`Portal 未就绪: ${baseUrl}/auth/status -> ${response.status}`);
}
}
async function readCurrentModel(pool) {
const [rows] = await pool.query(
`SELECT default_model FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = 'active' LIMIT 1`,
);
return rows[0]?.default_model ?? null;
}
async function saveState(pool, memoryConfigService) {
const model = await readCurrentModel(pool);
const memoryAdmin = await memoryConfigService.getAdminConfig().catch(() => null);
const payload = {
savedAt: Date.now(),
model,
memoryConfig: memoryAdmin?.config ?? null,
};
fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
return payload;
}
function loadSavedState() {
if (!fs.existsSync(statePath)) return null;
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
}
async function applyProfile(pool, llmProviderService, memoryConfigService, profileKey) {
const profile = PROFILES[profileKey];
if (!profile) throw new Error(`Unknown profile: ${profileKey}`);
const modelResult = await llmProviderService.setGlobalModel(profile.model);
if (!modelResult?.ok) {
throw new Error(`切换模型失败 (${profile.model}): ${modelResult?.message ?? 'unknown'}`);
}
if (profile.memoryPatch) {
await memoryConfigService.updateAdminConfig(profile.memoryPatch, {
updatedBy: 'token-benchmark',
});
}
const currentModel = await readCurrentModel(pool);
console.log(`\n==> 已应用 ${profile.label}`);
console.log(` 全局模型: ${currentModel}`);
if (profile.memoryPatch) {
console.log(' 记忆/Router 预算: 已写入 h5_memory_v2_admin_config');
}
}
async function restoreSavedState(pool, llmProviderService, memoryConfigService) {
const saved = loadSavedState();
if (!saved) {
console.log('无 .token-benchmark-state.json,跳过恢复');
return false;
}
if (saved.model) {
const result = await llmProviderService.setGlobalModel(saved.model);
if (!result?.ok) {
console.warn(`恢复模型失败: ${result?.message ?? 'unknown'}`);
} else {
console.log(`已恢复全局模型: ${saved.model}`);
}
}
if (saved.memoryConfig) {
await memoryConfigService.updateAdminConfig(saved.memoryConfig, {
updatedBy: 'token-benchmark-restore',
});
console.log('已恢复 memory v2 admin config');
}
fs.unlinkSync(statePath);
return true;
}
async function collectUsageMetrics(pool, requestIds) {
if (!requestIds.length) {
return { inputTokens: 0, outputTokens: 0, costCents: 0, records: [] };
}
const placeholders = requestIds.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT request_id, input_tokens, output_tokens, cost_cents, billing_source, created_at
FROM h5_usage_records
WHERE request_id IN (${placeholders})
ORDER BY created_at ASC`,
requestIds,
);
let inputTokens = 0;
let outputTokens = 0;
let costCents = 0;
for (const row of rows) {
inputTokens += Number(row.input_tokens ?? 0);
outputTokens += Number(row.output_tokens ?? 0);
costCents += Number(row.cost_cents ?? 0);
}
return { inputTokens, outputTokens, costCents, records: rows };
}
async function collectRunEventTokens(pool, runIds) {
if (!runIds.length) return { inputTokens: 0, outputTokens: 0, events: [] };
const placeholders = runIds.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT run_id, data_json, created_at
FROM h5_agent_run_events
WHERE run_id IN (${placeholders}) AND event_type = 'session_finished'
ORDER BY created_at ASC`,
runIds,
);
let inputTokens = 0;
let outputTokens = 0;
const events = [];
for (const row of rows) {
const data = typeof row.data_json === 'string'
? JSON.parse(row.data_json)
: row.data_json;
const tokenState = data?.tokenState ?? data?.token_state ?? null;
if (!tokenState) continue;
const input = Number(
tokenState.accumulatedInputTokens
?? tokenState.accumulated_input_tokens
?? tokenState.inputTokens
?? tokenState.input_tokens
?? 0,
);
const output = Number(
tokenState.accumulatedOutputTokens
?? tokenState.accumulated_output_tokens
?? tokenState.outputTokens
?? tokenState.output_tokens
?? 0,
);
inputTokens += input;
outputTokens += output;
events.push({ runId: row.run_id, input, output, tokenState });
}
return { inputTokens, outputTokens, events };
}
async function runScenarioBenchmark(scenario, port, { profileKey = 'run', runId = Date.now() } = {}) {
const reporter = createReporter();
const baseUrl = resolvePortalBase(port);
const account = {
username: scenario.account?.username ?? 'john2',
password: process.env.JOHN_PASSWORD
?? process.env.H5_ACCESS_PASSWORD
?? scenario.account?.password
?? '888888',
};
await ensurePortalReady(baseUrl);
const auth = await loginViaApi(baseUrl, account, reporter);
const publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username;
let sessionId = null;
let assistantCount = 0;
let assistantCombinedLength = 0;
const tracked = {
requestIds: [],
runIds: [],
sessionIds: [],
replies: [],
pageLinks: [],
elapsedMs: 0,
profileKey,
runId,
};
const started = Date.now();
for (const step of scenario.steps ?? []) {
if (step.action !== 'chat') continue;
const message = String(step.message ?? '')
.replaceAll('{{PROFILE}}', profileKey)
.replaceAll('{{RUN_ID}}', String(runId));
let htmlBefore = [];
if (step.expect?.page) {
htmlBefore = await snapshotPublicHtml(publishKey);
}
const run = await createAgentRun(baseUrl, auth.cookie, {
message,
sessionId: null,
selectedChatSkill: step.selectedChatSkill ?? null,
});
tracked.requestIds.push(run.requestId);
tracked.runIds.push(run.runId);
const terminal = await waitForRunTerminal(
baseUrl,
auth.cookie,
run.runId,
step.expect?.timeoutMs ?? 600_000,
);
sessionId = terminal.sessionId ?? terminal.agent_session_id ?? run.sessionId ?? sessionId;
if (sessionId) tracked.sessionIds.push(sessionId);
if (terminal.status === 'failed') {
reporter.fail('run 终态', terminal.error ?? 'failed');
continue;
}
reporter.pass('run 终态', terminal.status);
const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, {
previousCount: assistantCount,
previousCombinedLength: assistantCombinedLength,
minChars: step.expect?.assistantMinChars ?? 1,
timeoutMs: step.expect?.timeoutMs ?? 120_000,
});
if (!reply) {
reporter.fail('assistant 回复', '超时未收到新回复');
continue;
}
assistantCount = reply.count;
assistantCombinedLength = reply.combined.length;
tracked.replies.push({
label: step.label ?? step.message,
chars: reply.combined.length,
elapsedMs: reply.elapsedMs,
text: reply.combined,
});
reporter.pass('assistant 回复', `${reply.combined.length} 字 / ${reply.elapsedMs}ms`);
const keywords = step.expect?.replyKeywords ?? [];
if (keywords.length) {
const hit = keywords.filter((word) => reply.combined.includes(word));
if (hit.length === 0) reporter.fail('回复关键词', `未命中: ${keywords.join(', ')}`);
else reporter.pass('回复关键词', hit.join(', '));
}
tracked.pageLinks.push(...extractPublicLinks(reply.combined, baseUrl));
if (step.expect?.page) {
await verifyPageAccess({
baseUrl,
cookie: auth.cookie,
publishKey,
replyText: reply.combined,
htmlBefore,
expect: step.expect.page,
uploadedAssetIds: [],
reporter,
});
}
}
tracked.elapsedMs = Date.now() - started;
return {
reporter,
tracked,
passed: reporter.issues.length === 0,
issueCount: reporter.issues.length,
};
}
function pctDelta(before, after) {
if (!before) return after ? 100 : 0;
return Math.round(((after - before) / before) * 1000) / 10;
}
function printComparison(baseline, optimized) {
console.log('\n========================================');
console.log('Token 优化 A/B 对比(本地)');
console.log('========================================\n');
const rows = [
['指标', 'Baseline', 'Optimized', '变化'],
[
'输入 Token (usage)',
String(baseline.usage.inputTokens),
String(optimized.usage.inputTokens),
`${pctDelta(baseline.usage.inputTokens, optimized.usage.inputTokens)}%`,
],
[
'输出 Token (usage)',
String(baseline.usage.outputTokens),
String(optimized.usage.outputTokens),
`${pctDelta(baseline.usage.outputTokens, optimized.usage.outputTokens)}%`,
],
[
'总 Token (usage)',
String(baseline.usage.inputTokens + baseline.usage.outputTokens),
String(optimized.usage.inputTokens + optimized.usage.outputTokens),
`${pctDelta(
baseline.usage.inputTokens + baseline.usage.outputTokens,
optimized.usage.inputTokens + optimized.usage.outputTokens,
)}%`,
],
[
'扣费 (分)',
String(baseline.usage.costCents),
String(optimized.usage.costCents),
`${pctDelta(baseline.usage.costCents, optimized.usage.costCents)}%`,
],
[
'场景耗时 (s)',
String(Math.round(baseline.tracked.elapsedMs / 1000)),
String(Math.round(optimized.tracked.elapsedMs / 1000)),
`${pctDelta(baseline.tracked.elapsedMs, optimized.tracked.elapsedMs)}%`,
],
[
'回复字数',
String(baseline.tracked.replies.at(-1)?.chars ?? 0),
String(optimized.tracked.replies.at(-1)?.chars ?? 0),
`${pctDelta(
baseline.tracked.replies.at(-1)?.chars ?? 0,
optimized.tracked.replies.at(-1)?.chars ?? 0,
)}%`,
],
[
'质量检查',
baseline.passed ? '通过' : `失败 ${baseline.issueCount}`,
optimized.passed ? '通过' : `失败 ${optimized.issueCount}`,
optimized.passed === baseline.passed ? '持平' : (optimized.passed ? '改善' : '下降'),
],
];
const widths = [0, 1, 2, 3].map((col) => Math.max(...rows.map((row) => row[col].length)));
for (const row of rows) {
console.log(row.map((cell, i) => cell.padEnd(widths[i] + 2)).join(''));
}
console.log('\n--- Baseline 页面链接 ---');
for (const link of baseline.tracked.pageLinks) console.log(link);
console.log('\n--- Optimized 页面链接 ---');
for (const link of optimized.tracked.pageLinks) console.log(link);
if (baseline.passed && optimized.passed) {
const totalBefore = baseline.usage.inputTokens + baseline.usage.outputTokens;
const totalAfter = optimized.usage.inputTokens + optimized.usage.outputTokens;
const saved = totalBefore - totalAfter;
console.log(`\n结论: 质量检查均通过。Token 总量 ${totalBefore}${totalAfter}${saved >= 0 ? '节省' : '增加'} ${Math.abs(saved)}`);
if (saved > 0 && optimized.passed) {
console.log('效果未打折,可继续观察更多场景。');
} else if (saved <= 0) {
console.log('Token 未下降,需检查模型/Router/记忆配置是否生效。');
}
} else {
console.log('\n结论: 存在质量回归,不建议直接上生产。');
if (!optimized.passed) {
console.log('Optimized 失败项:');
for (const issue of optimized.reporter.issues) {
console.log(` - ${issue.label}: ${issue.detail}`);
}
}
}
}
async function runPhase(pool, llmProviderService, memoryConfigService, profileKey, scenario, port) {
await applyProfile(pool, llmProviderService, memoryConfigService, profileKey);
const runId = `${profileKey}-${Date.now()}`;
console.log(`\n>>> 开始 ${profileKey} 场景跑分 (runId=${runId})...`);
const result = await runScenarioBenchmark(scenario, port, { profileKey, runId });
const usage = await collectUsageMetrics(pool, result.tracked.requestIds);
const eventTokens = await collectRunEventTokens(pool, result.tracked.runIds);
console.log(`\n--- ${profileKey} token 汇总 ---`);
console.log(`usage records: in=${usage.inputTokens} out=${usage.outputTokens} cost=${usage.costCents}`);
console.log(`run events: in=${eventTokens.inputTokens} out=${eventTokens.outputTokens}`);
return { ...result, usage, eventTokens, profileKey };
}
async function main() {
const { scenarioId, phase, port } = parseArgs(process.argv);
const pool = createDbPool(process.env);
const llmProviderService = createLlmProviderService(pool, {
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
});
const memoryConfigService = createMemoryV2AdminConfigService(pool);
try {
if (phase === 'restore') {
await restoreSavedState(pool, llmProviderService, memoryConfigService);
return;
}
const scenario = await loadScenario(scenarioId);
console.log(`场景: ${scenario.name ?? scenario.id}`);
console.log(`Portal: ${resolvePortalBase(port)}`);
if (phase === 'both') {
await saveState(pool, memoryConfigService);
}
let baselineResult = null;
let optimizedResult = null;
if (phase === 'both' || phase === 'baseline') {
baselineResult = await runPhase(
pool,
llmProviderService,
memoryConfigService,
'baseline',
scenario,
port,
);
}
if (phase === 'both' || phase === 'optimized') {
optimizedResult = await runPhase(
pool,
llmProviderService,
memoryConfigService,
'optimized',
scenario,
port,
);
}
if (baselineResult && optimizedResult) {
printComparison(baselineResult, optimizedResult);
}
if (phase === 'both') {
await restoreSavedState(pool, llmProviderService, memoryConfigService);
console.log('\n已恢复 benchmark 前的模型与 memory 配置。');
}
} finally {
await pool.end?.();
}
}
main().catch(async (error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
try {
const pool = createDbPool(process.env);
const llmProviderService = createLlmProviderService(pool, {
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
});
const memoryConfigService = createMemoryV2AdminConfigService(pool);
await restoreSavedState(pool, llmProviderService, memoryConfigService);
await pool.end?.();
} catch {
// best effort restore
}
process.exit(1);
});