g2api / test /e2e-prompt-ab.mjs
LerinaOwO's picture
Upload 98 files
097fb32 verified
/**
* test/e2e-prompt-ab.mjs
*
* behaviorRules 提示词 A/B 对比测试
*
* 目标:量化衡量不同 behaviorRules 变体对模型行为的影响
*
* 测量维度:
* 1. tool_call_count — 每轮产生的工具调用数量
* 2. narration_ratio — 文本叙述 vs 工具调用的比例(越低越好)
* 3. format_correct — ```json action 格式是否正确
* 4. parallel_rate — 独立工具是否被并行调用
* 5. empty_response — 是否出现空响应(无工具也无文本)
* 6. first_turn_action — 第一轮是否直接行动(vs 纯文字描述计划)
*
* 用法:
* node test/e2e-prompt-ab.mjs # 使用当前线上版本
* VARIANT=baseline node test/e2e-prompt-ab.mjs # 标记为 baseline
* VARIANT=candidate_a node test/e2e-prompt-ab.mjs # 标记为 candidate_a
*
* # 对比结果:
* node test/e2e-prompt-ab.mjs --compare
*/
const BASE_URL = `http://localhost:${process.env.PORT || 3010}`;
const MODEL = 'claude-sonnet-4-5-20251120';
const MAX_TURNS = 8;
const VARIANT = process.env.VARIANT || 'current';
const COMPARE_MODE = process.argv.includes('--compare');
// ─── 颜色 ─────────────────────────────────────────────────────────────
const C = {
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m',
cyan: '\x1b[36m', blue: '\x1b[34m', magenta: '\x1b[35m', gray: '\x1b[90m',
white: '\x1b[37m',
};
const ok = s => `${C.green}${s}${C.reset}`;
const fail = s => `${C.red}${s}${C.reset}`;
const warn = s => `${C.yellow}${s}${C.reset}`;
const hdr = s => `\n${C.bold}${C.cyan}━━━ ${s} ━━━${C.reset}`;
const info = s => ` ${C.gray}${s}${C.reset}`;
// ─── 工具集(精简版,覆盖关键场景) ──────────────────────────────────
const TOOLS = [
{
name: 'Read',
description: 'Reads a file from the local filesystem.',
input_schema: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Absolute path to the file' },
},
required: ['file_path'],
},
},
{
name: 'Write',
description: 'Write a file to the local filesystem.',
input_schema: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Absolute path to the file' },
content: { type: 'string', description: 'Content to write' },
},
required: ['file_path', 'content'],
},
},
{
name: 'Bash',
description: 'Executes a bash command in a persistent shell session.',
input_schema: {
type: 'object',
properties: {
command: { type: 'string', description: 'The command to execute' },
},
required: ['command'],
},
},
{
name: 'Grep',
description: 'Fast content search tool.',
input_schema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Regex pattern to search for' },
path: { type: 'string', description: 'Path to search' },
},
required: ['pattern'],
},
},
{
name: 'LS',
description: 'Lists files and directories.',
input_schema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Directory path' },
},
required: ['path'],
},
},
{
name: 'attempt_completion',
description: 'Present the final result to the user.',
input_schema: {
type: 'object',
properties: {
result: { type: 'string', description: 'Result summary' },
},
required: ['result'],
},
},
{
name: 'ask_followup_question',
description: 'Ask the user a follow-up question.',
input_schema: {
type: 'object',
properties: {
question: { type: 'string', description: 'The question to ask' },
},
required: ['question'],
},
},
];
// ─── 虚拟工具执行 ────────────────────────────────────────────────────
const MOCK_FS = {
'/project/package.json': '{"name":"my-app","version":"1.0.0","dependencies":{"express":"^4.18.0"}}',
'/project/src/index.ts': 'import express from "express";\nconst app = express();\napp.listen(3000);',
'/project/src/utils.ts': 'export function add(a: number, b: number) { return a + b; }\nexport function sub(a: number, b: number) { return a - b; }',
'/project/src/config.ts': 'export const config = { port: 3000, host: "localhost", debug: false };',
'/project/README.md': '# My App\nA simple Express application.\n## Setup\nnpm install && npm start',
};
function mockExecute(name, input) {
switch (name) {
case 'Read': return MOCK_FS[input.file_path] || `Error: File not found: ${input.file_path}`;
case 'Write': return `Wrote ${(input.content || '').length} chars to ${input.file_path}`;
case 'Bash': return `$ ${input.command}\n(executed successfully)`;
case 'Grep': return `/project/src/index.ts:1:import express`;
case 'LS': return Object.keys(MOCK_FS).join('\n');
case 'attempt_completion': return `__DONE__:${input.result}`;
case 'ask_followup_question': return `__ASK__:${input.question}`;
default: return `Tool ${name} executed`;
}
}
// ─── 单轮请求发送器(用于第一轮分析) ──────────────────────────────────
async function sendSingleTurn(userMessage, { tools = TOOLS, systemPrompt = '', toolChoice } = {}) {
const body = {
model: MODEL,
max_tokens: 4096,
system: systemPrompt || 'You are an AI coding assistant. Working directory: /project.',
tools,
...(toolChoice ? { tool_choice: toolChoice } : {}),
messages: [{ role: 'user', content: userMessage }],
};
const t0 = Date.now();
const resp = await fetch(`${BASE_URL}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'dummy' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`HTTP ${resp.status}: ${text.substring(0, 200)}`);
}
const data = await resp.json();
const latencyMs = Date.now() - t0;
return { data, latencyMs };
}
// ─── 多轮 Agentic 循环(用于完整任务分析) ─────────────────────────────
async function runMultiTurn(userMessage, { tools = TOOLS, systemPrompt = '', toolChoice, maxTurns = MAX_TURNS } = {}) {
const messages = [{ role: 'user', content: userMessage }];
const system = systemPrompt || 'You are an AI coding assistant. Working directory: /project.';
let totalToolCalls = 0;
let totalTextChars = 0;
let turns = 0;
let firstTurnHasToolCall = false;
const toolCallLog = [];
while (turns < maxTurns) {
turns++;
const resp = await fetch(`${BASE_URL}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'dummy' },
body: JSON.stringify({
model: MODEL,
max_tokens: 4096,
system,
tools,
...(toolChoice ? { tool_choice: toolChoice } : {}),
messages,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const textBlocks = data.content?.filter(b => b.type === 'text') || [];
const toolUseBlocks = data.content?.filter(b => b.type === 'tool_use') || [];
totalTextChars += textBlocks.reduce((s, b) => s + (b.text?.length || 0), 0);
totalToolCalls += toolUseBlocks.length;
if (turns === 1 && toolUseBlocks.length > 0) firstTurnHasToolCall = true;
for (const tb of toolUseBlocks) {
toolCallLog.push({ turn: turns, tool: tb.name, input: tb.input });
}
if (data.stop_reason === 'end_turn' || toolUseBlocks.length === 0) break;
messages.push({ role: 'assistant', content: data.content });
const toolResults = toolUseBlocks.map(tb => ({
type: 'tool_result',
tool_use_id: tb.id,
content: mockExecute(tb.name, tb.input),
}));
messages.push({ role: 'user', content: toolResults });
// Check for completion signal
if (toolResults.some(r => r.content.startsWith('__DONE__'))) break;
}
return { totalToolCalls, totalTextChars, turns, firstTurnHasToolCall, toolCallLog };
}
// ─── 指标分析器 ──────────────────────────────────────────────────────
function analyzeResponse(data) {
const content = data.content || [];
const textBlocks = content.filter(b => b.type === 'text');
const toolUseBlocks = content.filter(b => b.type === 'tool_use');
const textLength = textBlocks.reduce((s, b) => s + (b.text?.length || 0), 0);
const toolCallCount = toolUseBlocks.length;
const hasToolCalls = toolCallCount > 0;
const toolNames = toolUseBlocks.map(b => b.name);
// 叙述占比:文本字符数 / (文本字符数 + 工具调用数 * 预估等效字符)
// 工具调用等效约 100 字符
const narrationRatio = textLength / Math.max(textLength + toolCallCount * 100, 1);
// 格式检查:检查是否所有工具调用都有正确的 id 和 name
const formatCorrect = toolUseBlocks.every(b => b.id && b.name && b.input !== undefined);
return {
textLength,
toolCallCount,
hasToolCalls,
toolNames,
narrationRatio: Math.round(narrationRatio * 100) / 100,
formatCorrect,
stopReason: data.stop_reason,
};
}
// ─── 测试场景定义 ───────────────────────────────────────────────────
const TEST_SCENARIOS = [
{
id: 'single_tool',
name: '单工具调用',
description: '请求读取一个文件,期望:1个工具调用,最少叙述',
prompt: 'Read the file /project/package.json',
expected: { minTools: 1, maxNarration: 0.7 },
mode: 'single',
},
{
id: 'parallel_tools',
name: '并行工具调用',
description: '请求同时读取两个文件,期望:2个工具调用在同一轮',
prompt: 'Read both /project/src/index.ts and /project/src/utils.ts at the same time.',
expected: { minTools: 2, maxNarration: 0.6 },
mode: 'single',
},
{
id: 'action_vs_plan',
name: '行动 vs 计划描述',
description: '期望模型直接行动,而不是先描述计划',
prompt: 'Check what dependencies this project uses.',
expected: { firstTurnAction: true },
mode: 'single',
},
{
id: 'minimal_narration',
name: '最少叙述',
description: '简单任务期望极少解释文字',
prompt: 'List all files in /project',
expected: { maxNarration: 0.6, minTools: 1 },
mode: 'single',
},
{
id: 'multi_step_task',
name: '多步任务完成度',
description: '复杂任务,期望多轮调用,最终完成',
prompt: 'Read /project/src/index.ts, then read /project/src/config.ts, and tell me what port the server listens on.',
expected: { minTotalTools: 2 },
mode: 'multi',
},
{
id: 'no_echo_ready',
name: '避免无意义命令',
description: '模型不应输出 echo ready 等无意义命令',
prompt: 'What is 2 + 2? Just answer directly.',
expected: { noMeaninglessTools: true },
mode: 'single',
},
{
id: 'completion_signal',
name: '完成信号使用',
description: '任务完成后应使用 attempt_completion',
prompt: 'Read /project/README.md and summarize it. Then call attempt_completion with your summary.',
expected: { usesCompletion: true },
mode: 'multi',
toolChoice: { type: 'any' },
},
{
id: 'format_precision',
name: '格式精确度',
description: '所有工具调用都应该有正确的格式',
prompt: 'Read /project/package.json and then search for "express" in /project/src',
expected: { formatCorrect: true },
mode: 'multi',
},
];
// ─── 对比模式 ─────────────────────────────────────────────────────────
if (COMPARE_MODE) {
const fs = await import('fs');
const resultFiles = fs.readdirSync('test')
.filter(f => f.startsWith('prompt-ab-results-') && f.endsWith('.json'))
.sort();
if (resultFiles.length < 2) {
console.log(`\n${fail('需要至少 2 个结果文件才能对比')}。已找到: ${resultFiles.length}`);
console.log(info('运行测试: VARIANT=baseline node test/e2e-prompt-ab.mjs'));
console.log(info('修改提示词后: VARIANT=candidate_a node test/e2e-prompt-ab.mjs'));
process.exit(1);
}
const results = resultFiles.map(f => {
const data = JSON.parse(fs.readFileSync(`test/${f}`, 'utf-8'));
return { file: f, ...data };
});
console.log(`\n${C.bold}${C.magenta}══ behaviorRules A/B 对比报告 ══${C.reset}\n`);
console.log(`已加载 ${results.length} 个结果文件:\n`);
results.forEach(r => console.log(` ${C.cyan}${r.variant}${C.reset} (${r.file}) — ${r.timestamp}`));
// 对比表格
console.log(`\n${'─'.repeat(100)}`);
const header = `${'场景'.padEnd(20)}` + results.map(r => `${r.variant.padEnd(16)}`).join('');
console.log(`${C.bold}${header}${C.reset}`);
console.log(`${'─'.repeat(100)}`);
const scenarioIds = [...new Set(results.flatMap(r => r.scenarios.map(s => s.id)))];
for (const sid of scenarioIds) {
const row = [sid.padEnd(20)];
for (const r of results) {
const s = r.scenarios.find(x => x.id === sid);
if (!s) { row.push('N/A'.padEnd(16)); continue; }
const metrics = s.metrics;
if (metrics) {
const emoji = s.passed ? '✅' : '❌';
const brief = `${emoji} T:${metrics.toolCallCount || metrics.totalToolCalls || 0} N:${Math.round((metrics.narrationRatio || 0) * 100)}%`;
row.push(brief.padEnd(16));
} else {
row.push('ERR'.padEnd(16));
}
}
console.log(row.join(''));
}
console.log(`${'─'.repeat(100)}`);
// 汇总分数
console.log(`\n${C.bold}汇总:${C.reset}`);
for (const r of results) {
const passCount = r.scenarios.filter(s => s.passed).length;
const totalTools = r.scenarios.reduce((s, x) => s + (x.metrics?.toolCallCount || x.metrics?.totalToolCalls || 0), 0);
const avgNarration = r.scenarios.reduce((s, x) => s + (x.metrics?.narrationRatio || 0), 0) / r.scenarios.length;
console.log(` ${C.cyan}${r.variant}${C.reset}: ${passCount}/${r.scenarios.length} 通过, 总工具调用: ${totalTools}, 平均叙述占比: ${Math.round(avgNarration * 100)}%`);
}
process.exit(0);
}
// ─── 主测试流程 ──────────────────────────────────────────────────────
console.log(`\n${C.bold}${C.magenta} behaviorRules A/B 测试${C.reset}`);
console.log(info(`VARIANT=${VARIANT} BASE_URL=${BASE_URL} MODEL=${MODEL}`));
// 检测服务器
try {
const r = await fetch(`${BASE_URL}/v1/models`, { headers: { 'x-api-key': 'dummy' } });
if (!r.ok) throw new Error();
console.log(`\n${ok('服务器在线')}`);
} catch {
console.log(`\n${fail('服务器未运行')}`);
process.exit(1);
}
const scenarioResults = [];
let passed = 0, failedCount = 0;
for (const scenario of TEST_SCENARIOS) {
console.log(hdr(`${scenario.id}: ${scenario.name}`));
console.log(info(scenario.description));
const t0 = Date.now();
try {
let metrics;
let testPassed = true;
const failReasons = [];
if (scenario.mode === 'single') {
// 单轮分析
const { data, latencyMs } = await sendSingleTurn(scenario.prompt, {
toolChoice: scenario.toolChoice,
});
metrics = { ...analyzeResponse(data), latencyMs };
// 检查期望
if (scenario.expected.minTools && metrics.toolCallCount < scenario.expected.minTools) {
testPassed = false;
failReasons.push(`工具调用数 ${metrics.toolCallCount} < 期望最低 ${scenario.expected.minTools}`);
}
if (scenario.expected.maxNarration && metrics.narrationRatio > scenario.expected.maxNarration) {
testPassed = false;
failReasons.push(`叙述占比 ${metrics.narrationRatio} > 上限 ${scenario.expected.maxNarration}`);
}
if (scenario.expected.firstTurnAction && !metrics.hasToolCalls) {
testPassed = false;
failReasons.push('第一轮未执行工具调用(只是描述计划)');
}
if (scenario.expected.noMeaninglessTools && metrics.toolNames?.some(n => n === 'Bash')) {
// Check if Bash was called with meaningless command
const bashCalls = data.content?.filter(b => b.type === 'tool_use' && b.name === 'Bash') || [];
for (const bc of bashCalls) {
const cmd = bc.input?.command || '';
if (/^(echo|printf|cat\s*$)/i.test(cmd.trim())) {
testPassed = false;
failReasons.push(`无意义命令: ${cmd}`);
}
}
}
// 输出详情
console.log(info(` 工具调用: ${metrics.toolCallCount} [${metrics.toolNames?.join(', ') || 'none'}]`));
console.log(info(` 文本长度: ${metrics.textLength} chars`));
console.log(info(` 叙述占比: ${Math.round(metrics.narrationRatio * 100)}%`));
console.log(info(` 格式正确: ${metrics.formatCorrect ? '✅' : '❌'}`));
console.log(info(` 延迟: ${metrics.latencyMs}ms`));
} else {
// 多轮分析
const result = await runMultiTurn(scenario.prompt, {
toolChoice: scenario.toolChoice,
});
metrics = {
totalToolCalls: result.totalToolCalls,
totalTextChars: result.totalTextChars,
turns: result.turns,
firstTurnHasToolCall: result.firstTurnHasToolCall,
narrationRatio: result.totalTextChars / Math.max(result.totalTextChars + result.totalToolCalls * 100, 1),
toolLog: result.toolCallLog.map(t => `${t.turn}:${t.tool}`).join(' → '),
};
// 检查期望
if (scenario.expected.minTotalTools && result.totalToolCalls < scenario.expected.minTotalTools) {
testPassed = false;
failReasons.push(`总工具调用 ${result.totalToolCalls} < 期望 ${scenario.expected.minTotalTools}`);
}
if (scenario.expected.usesCompletion) {
const usedCompletion = result.toolCallLog.some(t => t.tool === 'attempt_completion');
if (!usedCompletion) {
// Only warn, don't fail
failReasons.push('未使用 attempt_completion(警告)');
}
}
console.log(info(` 总工具调用: ${result.totalToolCalls}`));
console.log(info(` 总轮数: ${result.turns}`));
console.log(info(` 文本长度: ${result.totalTextChars} chars`));
console.log(info(` 第一轮行动: ${result.firstTurnHasToolCall ? '✅' : '❌'}`));
console.log(info(` 叙述占比: ${Math.round(metrics.narrationRatio * 100)}%`));
console.log(info(` 调用链: ${metrics.toolLog}`));
}
const ms = ((Date.now() - t0) / 1000).toFixed(1);
if (testPassed) {
console.log(` ${ok('通过')} (${ms}s)`);
passed++;
} else {
console.log(` ${warn('部分未达标')} (${ms}s)`);
failReasons.forEach(r => console.log(` ${C.yellow}${r}${C.reset}`));
failedCount++;
}
scenarioResults.push({
id: scenario.id,
name: scenario.name,
passed: testPassed,
failReasons,
metrics,
});
} catch (err) {
const ms = ((Date.now() - t0) / 1000).toFixed(1);
console.log(` ${fail('错误')} (${ms}s): ${err.message}`);
failedCount++;
scenarioResults.push({
id: scenario.id,
name: scenario.name,
passed: false,
failReasons: [err.message],
metrics: null,
});
}
}
// ─── 汇总 ────────────────────────────────────────────────────────────
const total = passed + failedCount;
console.log(`\n${'═'.repeat(62)}`);
console.log(`${C.bold} [${VARIANT}] 结果: ${C.green}${passed} 通过${C.reset}${C.bold} / ${failedCount > 0 ? C.yellow : ''}${failedCount} 未达标${C.reset}${C.bold} / ${total} 场景${C.reset}`);
console.log('═'.repeat(62));
// 关键指标汇总
const singleScenarios = scenarioResults.filter(s => s.metrics?.toolCallCount !== undefined);
const multiScenarios = scenarioResults.filter(s => s.metrics?.totalToolCalls !== undefined);
if (singleScenarios.length > 0) {
const avgTools = singleScenarios.reduce((s, x) => s + (x.metrics?.toolCallCount || 0), 0) / singleScenarios.length;
const avgNarration = singleScenarios.reduce((s, x) => s + (x.metrics?.narrationRatio || 0), 0) / singleScenarios.length;
const avgLatency = singleScenarios.reduce((s, x) => s + (x.metrics?.latencyMs || 0), 0) / singleScenarios.length;
console.log(`\n${C.bold}单轮指标:${C.reset}`);
console.log(` 平均工具调用/轮: ${avgTools.toFixed(1)}`);
console.log(` 平均叙述占比: ${Math.round(avgNarration * 100)}%`);
console.log(` 平均延迟: ${Math.round(avgLatency)}ms`);
}
if (multiScenarios.length > 0) {
const avgTotalTools = multiScenarios.reduce((s, x) => s + (x.metrics?.totalToolCalls || 0), 0) / multiScenarios.length;
const avgTurns = multiScenarios.reduce((s, x) => s + (x.metrics?.turns || 0), 0) / multiScenarios.length;
console.log(`\n${C.bold}多轮指标:${C.reset}`);
console.log(` 平均总工具调用: ${avgTotalTools.toFixed(1)}`);
console.log(` 平均轮数: ${avgTurns.toFixed(1)}`);
}
// 保存结果
const resultData = {
variant: VARIANT,
timestamp: new Date().toISOString(),
model: MODEL,
scenarios: scenarioResults,
summary: {
passed,
failed: failedCount,
total,
},
};
const fs = await import('fs');
const resultFile = `test/prompt-ab-results-${VARIANT}.json`;
fs.writeFileSync(resultFile, JSON.stringify(resultData, null, 2));
console.log(`\n${info(`结果已保存: ${resultFile}`)}`);
console.log(info(`对比命令: node test/e2e-prompt-ab.mjs --compare`));
console.log();