Files
ai-group-chat/tools/model-shootout.mjs
T

190 lines
7.0 KiB
JavaScript
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
/**
* model-shootout.mjs — 给 opencode 的免费模型跑同一套可判分题,挑出免费档里最强的那个。
*
* 用法:
* node model-shootout.mjs # 跑默认题组 × 全部免费模型
* node model-shootout.mjs --models a,b # 只跑指定模型
* node model-shootout.mjs --timeout 240 # 每个模型超时(秒)
*
* 判分是客观字符串/数值匹配,不打印象分。结果同时打印成表并写入 shootout-result.json。
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_MODELS = [
'opencode/big-pickle',
'opencode/muse-spark-1.3-contributor-free',
'opencode/muse-spark-1.2-contributor-free',
'opencode/nemotron-3-ultra-free',
'opencode/nemotron-3.5-lightning-free',
'opencode/ling-3.0-flash-fin-free',
'opencode/mimo-v2.5-free',
];
const argv = process.argv.slice(2);
const getFlag = (name, def) => {
const i = argv.indexOf(`--${name}`);
if (i === -1) return def;
const v = argv[i + 1];
return v === undefined || v.startsWith('--') ? true : v;
};
const models = String(getFlag('models', '')).trim()
? String(getFlag('models')).split(',').map((s) => s.trim()).filter(Boolean)
: DEFAULT_MODELS;
const timeoutSec = Number(getFlag('timeout', 300));
/* --------------------------------------------------------------- 题组 */
// 长文本里埋一个只出现一次的事实,考长上下文里的定位能力。
const HAYSTACK = (() => {
const facts = [
'仓库代号是 KESTREL-7。',
'构建编号是 4412。',
'负责人是林工。',
'发布窗口在周四凌晨。',
'数据库用的是 SQLite。',
];
const filler = [
'这条记录只用于填充上下文,不包含任何需要记忆的信息。',
'例行巡检没有发现异常,指标都在阈值内。',
'文档结构保持不变,仅调整了少量措辞。',
'监控面板显示过去一小时流量平稳。',
'值班同学已交接,交接内容无特殊事项。',
];
const lines = [];
for (let i = 0; i < 60; i++) {
lines.push(filler[i % filler.length]);
if (i === 37) lines.push(`【关键】本段的唯一标识是 ${facts[1]} 备用代号 ${facts[0]}`);
}
return lines.join('');
})();
const TASKS = [
{
id: 'Q1',
q: '球拍和球一共 1.10 元,球拍比球贵 1.00 元。球多少钱?',
// 接受 0.05 / .05 / 五分 / 5分 —— 中文作答也算对(第一版只认 0.05,把「五分」误判成错)
grade: (t) => (/(^|[^\d.])0?\.05([^\d]|$)/.test(t) || /[五5]\s*分/.test(t) ? 1 : 0),
expect: '0.05(或「五分」/「5分」)',
},
{
id: 'Q2',
q: '小于 1000 的正整数里,有多少个满足「除以 7 余 3」?',
grade: (t) => (/(^|[^\d])143([^\d]|$)/.test(t) ? 1 : 0),
expect: '143',
},
{
id: 'Q3',
q: 'Python 表达式 len(list(range(10))[2:5]) 的值是多少?',
grade: (t) => (/(^|[^\d])3([^\d]|$)/.test(t) ? 1 : 0),
expect: '3',
},
{
id: 'Q4',
q: '已知「所有 A 都是 B」且「有些 B 是 C」。能否必然推出「有些 A 是 C」?只回答 能 或 不能。',
grade: (t) => (/不能/.test(t) && !/能[^不]/.test(t.replace(/不能/g, '')) ? 1 : 0),
expect: '不能',
},
{
id: 'Q5',
q: '下面这段文本里出现过一次「构建编号」,它的数字是多少?\n\n' + HAYSTACK,
grade: (t) => (/(^|[^\d])4412([^\d]|$)/.test(t) ? 1 : 0),
expect: '4412',
},
];
const PROMPT = [
'按顺序回答下面的问题。严格要求输出格式:每题一行,形如 `Q1) 答案`,',
'答案要极短(数字或两个字),不要解释、不要复述题目、不要用 markdown 代码块。',
'',
...TASKS.map((t) => `${t.id}. ${t.q}`),
].join('\n');
/* --------------------------------------------------------------- 运行 */
function opencodeExe() {
const cands = [
path.join(process.env.APPDATA || '', 'npm', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
path.join(os.homedir(), '.opencode', 'bin', 'opencode'),
];
for (const c of cands) if (c && fs.existsSync(c)) return c;
return 'opencode';
}
function extract(text, id) {
const re = new RegExp(`${id}\\s*[).::]\\s*([^\\n]+)`, 'i');
const m = text.match(re);
return m ? m[1].trim() : text;
}
function runModel(model) {
const started = Date.now();
const r = spawnSync(opencodeExe(), ['run', '-m', model, PROMPT], {
encoding: 'utf8',
windowsHide: true,
timeout: timeoutSec * 1000,
maxBuffer: 16 * 1024 * 1024,
cwd: os.tmpdir(),
env: { ...process.env, NO_COLOR: '1', AIGROUP_NO_WAKE: '1' },
});
const ms = Date.now() - started;
const stdout = (r.stdout || '').replace(/\u001b\[[0-9;]*m/g, '').trim();
const stderr = (r.stderr || '').trim();
if (r.error || r.status !== 0) {
return { model, ok: false, ms, error: r.error?.message || `exit ${r.status}`, stderr: stderr.slice(-400), raw: stdout.slice(0, 400) };
}
const per = {};
let score = 0;
for (const t of TASKS) {
const ans = extract(stdout, t.id);
const g = t.grade(ans) || t.grade(stdout);
per[t.id] = { answer: ans.slice(0, 60), expect: t.expect, pass: Boolean(g) };
if (g) score++;
}
return { model, ok: true, ms, score, total: TASKS.length, per, raw: stdout.slice(0, 1200) };
}
/* --------------------------------------------------------------- main */
const results = [];
for (const model of models) {
process.stderr.write(`… 跑 ${model}\n`);
const res = runModel(model);
results.push(res);
process.stderr.write(` → ${res.ok ? `${res.score}/${res.total} (${(res.ms / 1000).toFixed(1)}s)` : `失败 ${res.error}`}\n`);
}
results.sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || (a.ms ?? 1e9) - (b.ms ?? 1e9));
const report = {
at: new Date().toISOString(),
tasks: TASKS.map((t) => ({ id: t.id, expect: t.expect, q: t.q.slice(0, 80) })),
results,
};
fs.writeFileSync(path.join(HERE, 'shootout-result.json'), JSON.stringify(report, null, 2), 'utf8');
const head = ['排名', '模型', '得分', '耗时', ...TASKS.map((t) => t.id)];
const rows = results.map((r, i) => [
String(i + 1),
r.model.replace('opencode/', ''),
r.ok ? `${r.score}/${r.total}` : 'ERR',
r.ok ? `${(r.ms / 1000).toFixed(1)}s` : '-',
...TASKS.map((t) => (r.ok ? (r.per[t.id].pass ? '✔' : `✘(${r.per[t.id].answer})`) : '-')),
]);
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? '').length)));
const line = (cells) => cells.map((c, i) => String(c ?? '').padEnd(widths[i])).join(' ');
process.stdout.write(`\n${line(head)}\n${line(widths.map((w) => '─'.repeat(w)))}\n`);
for (const r of rows) process.stdout.write(`${line(r)}\n`);
const winner = results.find((r) => r.ok);
process.stdout.write(`\n最强免费档(本次同题实测):${winner ? winner.model : '(全部失败)'}\n`);
process.stdout.write(`结果已写入 ${path.join(HERE, 'shootout-result.json')}\n`);