feat: update ai models tests

This commit is contained in:
2026-09-04 11:57:47 +05:00
parent 6bd1de2669
commit 111b718526
8 changed files with 441 additions and 103 deletions
+84 -24
View File
@@ -1,107 +1,167 @@
[
{
"id": "t1",
"question": "На столе лежало 3 яблока. Ты взял 2 яблока. Сколько яблок осталось на столе? Ответь одним числом.",
"question": "There were 3 apples on the table. You took 2 apples. How many apples are left on the table?",
"answer": "1"
},
{
"id": "t2",
"question": "Фермер имеет 17 кур. Все, кроме 9, умерли. Сколько кур осталось живыми? Ответь одним числом.",
"question": "A farmer has 17 chickens. All but 9 of them died. How many chickens are still alive?",
"answer": "9"
},
{
"id": "t3",
"question": "Сколько месяцев в году имеют 28 дней? Ответь одним числом.",
"question": "How many months of the year have 28 days?",
"answer": "12"
},
{
"id": "t4",
"question": "Карандаш и ручка вместе стоят 1 рубль 10 копеек. Ручка стоит на 1 рубль дороже карандаша. Сколько стоит карандаш? Ответь числом в копейках.",
"answer": "5"
"question": "A pencil and a pen together cost $1.10. The pen costs $1 more than the pencil. How much does the pencil cost?",
"answer": "5 cents"
},
{
"id": "t5",
"question": "Если 5 машин за 5 минут делают 5 деталей, сколько деталей сделают 100 машин за 100 минут? Ответь одним числом.",
"question": "If 5 machines make 5 widgets in 5 minutes, how many widgets will 100 machines make in 100 minutes?",
"answer": "2000"
},
{
"id": "t6",
"question": "В озере растут кувшинки. Каждый день их количество удваивается. Пруд полностью покрывается за 48 дней. За сколько дней покрывается половина пруда? Ответь одним числом.",
"question": "Water lilies grow in a lake. Their number doubles every day. The pond is completely covered in 48 days. In how many days is half of the pond covered?",
"answer": "47"
},
{
"id": "t7",
"question": "Числа от 1 до 9 включительно: сколько из них содержат букву «и» в русском названии? Ответь одним числом.",
"answer": "2"
"question": "Of the numbers from 1 to 9 inclusive, how many contain the letter \"e\" in their English name?",
"answer": "6"
},
{
"id": "t8",
"question": "У тебя список из 12 чисел. Если удалить каждое второе число в списке, сколько чисел останется? Ответь одним числом.",
"question": "You have a list of 12 numbers. If you remove every second number from the list, how many numbers remain?",
"answer": "6"
},
{
"id": "t9",
"question": "Монетку подбросили 3 раза. Какова вероятность, что выпадет орёл все 3 раза? Ответь обыкновенной дробью.",
"question": "A coin is tossed 3 times. What is the probability that heads comes up all 3 times?",
"answer": "1/8"
},
{
"id": "t10",
"question": "У меня есть 10 рублей. Я потратил 3.50 на хлеб и 1.50 на молоко. Сколько сдачи осталось? Ответь числом в рублях.",
"answer": "5"
"question": "I have $10. I spent $3.50 on bread and $1.50 on milk. How much change is left?",
"answer": "$5"
},
{
"id": "t11",
"question": "Поезд длиной 100 метров движется со скоростью 36 км/ч. За сколько секунд он полностью проедет мимо столба? Ответь одним числом.",
"question": "A train 100 meters long travels at 36 km/h. How many seconds does it take for the train to fully pass a pole?",
"answer": "10"
},
{
"id": "t12",
"question": "Если число увеличить на 30% и получить 78, чему было исходное число? Ответь одним числом.",
"question": "A number is increased by 30% and the result is 78. What was the original number?",
"answer": "60"
},
{
"id": "t13",
"question": "В комнате 4 угла. В каждом углу сидит кошка. Напротив каждой кошки сидят 3 кошки. Сколько всего кошек в комнате? Ответь одним числом.",
"question": "A room has 4 corners. In each corner sits a cat. Opposite each cat sit 3 cats. How many cats are in the room in total?",
"answer": "4"
},
{
"id": "t14",
"question": "Периметр квадрата 28 см. Чему равна его площадь в квадратных сантиметрах? Ответь одним числом.",
"question": "The perimeter of a square is 28 cm. What is its area?",
"answer": "49"
},
{
"id": "t15",
"question": "Лена вдвое старше Миши. Сумма их возрастов 36 лет. Сколько лет Мише? Ответь одним числом.",
"question": "Lena is twice as old as Misha. The sum of their ages is 36. How old is Misha?",
"answer": "12"
},
{
"id": "t16",
"question": "В шкафу 10 белых и 10 чёрных носков вперемешку. Сколько носков надо достать вслепую, чтобы гарантированно получить пару одного цвета? Ответь одним числом.",
"question": "In a drawer there are 10 white and 10 black socks mixed together. How many socks must you take out blindfolded to be guaranteed a matching pair of one color?",
"answer": "3"
},
{
"id": "t17",
"question": "Если 3 курицы несут 3 яйца за 3 дня, сколько яиц снесут 6 куриц за 6 дней? Ответь одним числом.",
"question": "If 3 hens lay 3 eggs in 3 days, how many eggs will 6 hens lay in 6 days?",
"answer": "12"
},
{
"id": "t18",
"question": "Восемь минус четыре, делённое на два (8 - 4/2). Чему равно выражение? Ответь одним числом.",
"question": "Eight minus four divided by two (8 - 4/2). What is the value of the expression?",
"answer": "6"
},
{
"id": "t19",
"question": "На столе 7 свечей. 3 потухли. Сколько свечей осталось на столе? Ответь одним числом.",
"question": "There are 7 candles on a table. 3 of them go out. How many candles are left on the table?",
"answer": "7"
},
{
"id": "t20",
"question": "У Вити 5 машинок, у Кати в 3 раза больше. Потом Катя подарила Вите столько, сколько у него было изначально. Сколько машинок стало у Кати? Ответь одним числом.",
"question": "Vitya has 5 toy cars, Katya has 3 times more. Then Katya gave Vitya as many cars as he had originally. How many cars does Katya have now?",
"answer": "10"
},
{
"id": "t21",
"question": "Сумма трёх последовательных нечётных чисел равна 27. Чему равно наибольшее из них? Ответь одним числом.",
"question": "The sum of three consecutive odd numbers is 27. What is the largest of them?",
"answer": "11"
},
{
"id": "t22",
"question": "There were 12 birds sitting on a tree. A hunter shot and brought down 3. How many birds are still sitting on the tree?",
"answer": "0"
},
{
"id": "t23",
"question": "A brick weighs 1 kilogram plus half of its own weight. How much does the brick weigh?",
"answer": "2"
},
{
"id": "t24",
"question": "A father is 3 times as old as his son. Together they are 40 years old. In how many years will the father be exactly twice as old as the son?",
"answer": "10"
},
{
"id": "t25",
"question": "What positive number, when multiplied by itself, gives 144?",
"answer": "12"
},
{
"id": "t26",
"question": "I have two $1 coins and five 50-cent coins in my pocket. How much money do I have in total?",
"answer": "$4.50"
},
{
"id": "t27",
"question": "Three brothers each have one sister. How many children are in the family in total?",
"answer": "4"
},
{
"id": "t28",
"question": "Two fathers and two sons went fishing, but there were only 3 people, and each had their own fishing rod. How is that possible?",
"answer": "They are grandfather, father and son - three generations"
},
{
"id": "t29",
"question": "Which is heavier: a kilogram of iron or a kilogram of cotton wool? Explain.",
"answer": "They weigh the same - one kilogram each"
},
{
"id": "t30",
"question": "A shirt cost $40. It was discounted by 20%, and then by another 10% off the new price. How much does the shirt cost now?",
"answer": "$28.80"
},
{
"id": "t31",
"question": "Sasha is older than Misha but younger than Petya. Who is the youngest?",
"answer": "Misha"
},
{
"id": "t32",
"question": "Explain why the number 0 is considered even.",
"answer": "0 is divisible by 2 without a remainder, so it is even"
},
{
"id": "t33",
"question": "A chocolate bar is divided into 8 equal parts and 3 parts are eaten. What percentage of the bar is left?",
"answer": "62.5%"
}
]
+128 -12
View File
@@ -1,9 +1,15 @@
const DEFAULT_URL = "http://localhost:11434";
export class Ollama {
constructor({ url = DEFAULT_URL, timeoutMs = 600000 } = {}) {
constructor({ url = DEFAULT_URL, timeoutMs = 600000, idleTimeoutMs = null, maxTotalMs = null } = {}) {
this.url = url.replace(/\/$/, "");
this.timeoutMs = timeoutMs;
// Dynamic timeout: if set, the timer resets on every new token; the request
// only fails if this much time passes with NO progress (no new token).
this.idleTimeoutMs = idleTimeoutMs ?? timeoutMs;
// Absolute hard cap on one request's lifetime, even if tokens keep flowing
// (guards against a model looping/chatting forever). Defaults to 5 minutes.
this.maxTotalMs = maxTotalMs ?? 5 * 60_000;
}
async ping({ timeoutMs = 30000 } = {}) {
@@ -15,37 +21,147 @@ export class Ollama {
return { ok: true, models };
}
async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }) {
/**
* Streaming chat with a dynamic (progress-based) timeout.
* The timeout only fires when `timeoutMs` passes with no new token/chunk.
* Optionally calls onChunk({token, content, type, toolCalls}) as data arrives.
* Returns the same shape as the old non-streaming chat().
*/
async chatStream({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs, maxTotalMs, onChunk }) {
const body = {
model,
messages,
stream: false,
stream: true,
options: { temperature },
};
if (numCtx) body.options.num_ctx = numCtx;
if (tools && tools.length) body.tools = tools;
const limitMs = timeoutMs ?? this.idleTimeoutMs;
const totalCapMs = maxTotalMs ?? this.maxTotalMs;
const controller = new AbortController();
const startedAt = Date.now();
const res = await fetch(`${this.url}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs ?? this.timeoutMs),
signal: controller.signal,
});
if (!res.ok) {
const t = await res.text();
throw new Error(`Ollama chat HTTP ${res.status}: ${t.slice(0, 300)}`);
}
const data = await res.json();
const message = data.message ?? {};
if (!res.body || !res.body.getReader) {
throw new Error("Ollama streaming response has no body reader");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let content = "";
let toolCalls = [];
let promptEvalCount = 0;
let evalCount = 0;
let lastActivity = Date.now();
const guardInterval = setInterval(() => {
if (Date.now() - startedAt > totalCapMs) {
const err = new Error(`Request exceeded hard cap of ${totalCapMs}ms; aborting`);
err.name = "TimeoutError";
controller.abort(err);
return;
}
if (Date.now() - lastActivity > limitMs) {
const err = new Error(`No progress from Ollama for ${limitMs}ms; aborting`);
err.name = "TimeoutError";
controller.abort(err);
}
}, Math.min(500, Math.max(100, Math.floor(Math.min(limitMs, totalCapMs) / 4))));
try {
for (;;) {
let chunk;
try {
chunk = await reader.read();
} catch (readErr) {
// Abort / network drop mid-stream. Surface whatever we got so the
// caller can tell "slowly progressing" from "hung".
const e = new Error(
`Aborted mid-stream after ${content.length} chars (toolCalls=${toolCalls.length}): ${readErr.message}`
);
e.name = "TimeoutError";
e.partialContent = content;
e.partialToolCalls = toolCalls;
e.partialPrompt = promptEvalCount;
e.partialGen = evalCount;
throw e;
}
const { done, value } = chunk;
if (done) break;
lastActivity = Date.now();
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (!line) continue;
let obj;
try {
obj = JSON.parse(line);
} catch {
continue;
}
if (obj.prompt_eval_count != null) promptEvalCount = obj.prompt_eval_count;
if (obj.eval_count != null) evalCount = obj.eval_count;
const msg = obj.message ?? {};
if (msg.content) {
content += msg.content;
onChunk?.({ token: msg.content, content, type: "content" });
}
if (msg.tool_calls && msg.tool_calls.length) {
toolCalls = msg.tool_calls;
onChunk?.({ token: null, content, type: "tool_calls", toolCalls: msg.tool_calls });
}
}
}
} finally {
clearInterval(guardInterval);
}
return {
role: message.role ?? "assistant",
content: message.content ?? "",
toolCalls: message.tool_calls ?? [],
promptEvalCount: data.prompt_eval_count ?? 0,
evalCount: data.eval_count ?? 0,
raw: data,
role: "assistant",
content,
toolCalls: Array.isArray(toolCalls) ? toolCalls : [],
promptEvalCount,
evalCount,
raw: { stream: true },
};
}
async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }) {
return this.chatStream({
model,
messages,
tools,
temperature,
numCtx,
timeoutMs,
onChunk: null,
});
}
async warmup({ model, text = "say OK", timeoutMs = 60000 }) {
try {
return await this.chatStream({
model,
messages: [{ role: "user", content: text }],
timeoutMs,
});
} catch (err) {
return { error: err.message };
}
}
}
export async function listModels() {
+11 -5
View File
@@ -1,18 +1,20 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
const SCENARIOS = ["control", "blind", "mentor"];
const SCENARIOS = ["control", "thinking", "blind", "mentor"];
const SCENARIO_LABELS = {
control: "Без утки (контроль)",
thinking: "Думать вслух",
blind: "Слепая утка",
mentor: "Утка-помощник",
};
const SCENARIO_DESCS = {
control: "Модель решает задачу напрямую, без инструментов.",
thinking: "Модель выписывает рассуждения вслух, без дука.",
blind: "Модель объясняет подход коллеге, вызывает quack, не зная заранее ответ.",
mentor: "Модель обязана выписать мысли и возможные ошибки, затем проверить себя уткой.",
mentor: "Модель работает в паре, зная, что ответ будет только «quack».",
};
function esc(s) {
@@ -46,6 +48,7 @@ function summaryTable(report) {
const acc = (s) => scen[s].accuracy;
const row = `
<td class="pm-num" data-sc="control" style="color:${pctColor(acc("control"))}">${acc("control")}%</td>
<td class="pm-num" data-sc="thinking" style="color:${pctColor(acc("thinking"))}">${acc("thinking")}%</td>
<td class="pm-num" data-sc="blind" style="color:${pctColor(acc("blind"))}">${acc("blind")}%</td>
<td class="pm-num" data-sc="mentor" style="color:${pctColor(acc("mentor"))}">${acc("mentor")}%</td>
<td class="pm-num">${g.accuracy}%</td>
@@ -68,11 +71,12 @@ function summaryTable(report) {
<tr>
<th>Модель</th>
<th>Контроль</th>
<th>Мысли</th>
<th>Слепая</th>
<th>Помощник</th>
<th>Средняя acc</th>
<th>Размечено</th>
<th>Уток</th>
<th>Утка</th>
<th>Вызовов</th>
<th>ср. токены out</th>
</tr>
@@ -209,8 +213,9 @@ export function buildHtml(report) {
table.summary td { padding:10px 14px; border-top:1px solid var(--border); }
.pm-name { font-weight:700; white-space:nowrap; }
.pm-num { text-align:right; font-variant-numeric:tabular-nums; }
.cards { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin:0 0 8px; }
@media (max-width:1000px){ .cards { grid-template-columns:1fr; } }
.cards { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin:0 0 8px; }
@media (max-width:1100px){ .cards { grid-template-columns:repeat(2,1fr); } }
@media (max-width:640px){ .cards { grid-template-columns:1fr; } }
.card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px; }
.scenario-head { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
.scenario-title { font-size:16px; font-weight:700; }
@@ -296,6 +301,7 @@ export function buildHtml(report) {
</select>
<button data-filter="all" class="active">Все сценарии</button>
<button data-filter="control">Без утки</button>
<button data-filter="thinking">Мысли</button>
<button data-filter="blind">Слепая утка</button>
<button data-filter="mentor">Утка-помощник</button>
<span style="margin-left:auto;color:var(--muted);font-size:12px" id="count"></span>
+2 -2
View File
@@ -9,7 +9,7 @@ function avg(arr) {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {} }) { const scenarios = ["control", "blind", "mentor"];
export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {} }) { const scenarios = ["control", "thinking", "blind", "mentor"];
const byScenario = {};
for (const s of scenarios) byScenario[s] = { total: 0, correct: 0, reviewed: 0, duckUsed: 0, toolCalls: 0, promptTokens: [], genTokens: [], duckTokens: [], rows: [] };
@@ -71,7 +71,7 @@ export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {
};
}
const SCENARIOS = ["control", "blind", "mentor"];
const SCENARIOS = ["control", "thinking", "blind", "mentor"];
export function buildAggregate(modelsReport) {
const perScenario = {};
+2 -1
View File
@@ -5,6 +5,7 @@ import { buildReport, buildAggregate } from "./report.mjs";
const SCENARIO_LABELS = {
control: "Без утки (контроль)",
thinking: "Думать вслух",
blind: "Слепая утка",
mentor: "Утка-помощник",
};
@@ -191,7 +192,7 @@ async function main() {
const scen = rep.scenarios;
console.log(
`${model} | ` +
["control", "blind", "mentor"]
["control", "thinking", "blind", "mentor"]
.map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}% (${scen[s].reviewed})`)
.join(" ") +
` всего rev=${g.reviewed}`
+51 -19
View File
@@ -19,10 +19,25 @@ async function log(msg) {
}
}
const SCENARIOS = ["control", "blind", "mentor"];
const SCENARIOS = ["control", "thinking", "blind", "mentor"];
const DEFAULT_MODELS = ["llama3.2:3b", "qwen3:4b", "gemma3:4b", "granite4.1:3b"];
const DEFAULT_MCP_URL = "https://mcp-liart-five.vercel.app/api/mcp";
const DEFAULT_MODELS = ["llama3.2:3b", "qwen3:1.7b", "qwen3:4b", "granite4.1:3b", "phi4-mini:3.8b"];
const DEFAULT_MCP_URL = "https://rubber-duck-mcp.vercel.app/api/mcp";
const REQUEST_TIMEOUT_MS = 60_000;
const MAX_ATTEMPTS = 3;
const PAUSE_BETWEEN_TASKS_MS = 2_000;
const PAUSE_BETWEEN_RETRIES_MS = 10_000;
function classifyError(err) {
const name = err?.name ?? "";
const msg = String(err?.message ?? "");
const low = `${name} ${msg}`.toLowerCase();
if (name === "TimeoutError" || low.includes("timeout") || low.includes("aborted")) return "timeout";
if (low.includes("fetch failed") || low.includes("connect") || low.includes("etimedout")) return "network";
if (low.includes("http 4") || low.includes("bad request") || low.includes("validation")) return "http4xx";
if (low.includes("http 5") || low.includes("server error")) return "http5xx";
return "other";
}
function parseArgs(argv) {
const args = {
@@ -52,26 +67,33 @@ async function runOneModel({ model, ollama, mcp, tasks, scenarios }) {
const rows = [];
for (const task of tasks) {
for (const scenario of scenarios) {
let r;
let r = null;
let lastErr = null;
const t0 = Date.now();
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
r = await runScenario({ scenario, ollama, model, task: task.question, mcp });
break;
} catch (err) {
lastErr = err;
const ms = Date.now() - t0;
await log(` [${task.id}/${scenario}] FAILED (${ms}ms): ${err.message}`);
rows.push({
id: task.id,
scenario,
correct: null,
duckUsed: false,
toolCalls: 0,
promptTokens: 0,
genTokens: 0,
duckTokens: 0,
response: `ERR: ${err.message}`,
expected: task.answer,
question: task.question,
});
await log(` [${task.id}/${scenario}] attempt ${attempt} FAILED (${ms}ms): ${err.message}`);
if (err.partialContent != null || err.partialToolCalls != null) {
await log(
` partial before abort: chars=${err.partialContent?.length ?? 0} ` +
`toolCalls=${err.partialToolCalls?.length ?? 0} ` +
`gen=${err.partialGen ?? 0}`
);
}
if (attempt < MAX_ATTEMPTS) await new Promise((res) => setTimeout(res, PAUSE_BETWEEN_RETRIES_MS));
}
}
if (!r) {
const ms = Date.now() - t0;
const reason = classifyError(lastErr);
await log(` [${task.id}/${scenario}] SKIPPED after ${MAX_ATTEMPTS} attempts (${ms}ms) reason=${reason}`);
await log(` last error: ${lastErr?.name} | ${lastErr?.message}`);
if (lastErr?.stack) await log(` stack: ${String(lastErr.stack).split("\n").slice(0, 3).join(" | ")}`);
continue;
}
const correct = null;
@@ -92,6 +114,9 @@ async function runOneModel({ model, ollama, mcp, tasks, scenarios }) {
const ms = Date.now() - t0;
await log(` [${task.id}/${scenario}] duck=${duck} calls=${r.toolCalls} prompt=${r.promptTokens} gen=${r.genTokens} (${ms}ms)`);
}
if (tasks.length > 1 && task !== tasks[tasks.length - 1]) {
await new Promise((res) => setTimeout(res, PAUSE_BETWEEN_TASKS_MS));
}
}
return rows;
}
@@ -119,7 +144,7 @@ async function main() {
await log(`Tasks: ${tasks.length}; Scenarios: [${args.scenarios.join(", ")}]`);
await log(`Models: ${models.join(", ")}`);
const ollama = new Ollama();
const ollama = new Ollama({ timeoutMs: REQUEST_TIMEOUT_MS, idleTimeoutMs: REQUEST_TIMEOUT_MS });
const ping = await ollama.ping();
await log(`Ollama OK (${ping.models.length} models): ${ping.models.join(", ")}`);
for (const m of models) {
@@ -135,6 +160,13 @@ async function main() {
for (const model of models) {
const tStart = Date.now();
await log(`==== Running model: ${model} ====`);
const warm = await ollama.warmup({ model });
const warmMs = Number(warm?.promptEvalCount ?? 0) > 0 || warm?.content ? Date.now() - tStart : 0;
if (warm?.error) {
await log(` warmup FAILED: ${warm.error} (continuing anyway)`);
} else {
await log(` warmup OK (${warmMs}ms, gen=${warm?.evalCount ?? 0})`);
}
const rows = await runOneModel({ model, ollama, mcp, tasks, scenarios: args.scenarios });
const rep = buildReport({ model, mcpUrl, tasks, rows });
modelsReport[model] = {
+37 -11
View File
@@ -6,14 +6,16 @@ const DUCK_TOOL = {
function: {
name: "quack",
description:
"Explain your current reasoning, steps, doubts or possible mistakes to your rubber duck colleague, and get a short acknowledgment in return. The duck replies with a quack.",
"Call this tool to state your reasoning out loud - your approach, steps, doubts or possible mistakes. " +
"It replies with a short acknowledgment. Do not ask the user to confirm anything and do not invent its reply yourself; " +
"the tool answers on your behalf.",
parameters: {
type: "object",
properties: {
mood: {
type: "string",
enum: ["happy", "confused", "excited", "sleepy"],
description: "Mood of the duck while you explain. Omit for a random quack.",
description: "Mood of the reply. Omit for a random one.",
},
},
additionalProperties: false,
@@ -22,20 +24,33 @@ const DUCK_TOOL = {
};
const CONTROL_SYSTEM =
"You are an experienced assistant. Solve the user's problem as accurately as possible. Give only the final answer.";
"You are an experienced assistant. Solve the user's problem as accurately as possible. " +
"Give a clear, concrete final answer to the question - a definite result, not a tentative guess or a request for confirmation.";
const THINKING_SYSTEM =
"You are solving a difficult problem. Before giving your final answer, write out your reasoning step by step: " +
"your approach, each step, and any doubts or mistakes you notice along the way. " +
"Then give a clear, concrete final answer to the user's question - a definite result, not a request for confirmation.";
const BLIND_SYSTEM =
"You are solving a difficult problem. To solve it better, you work in a pair with a colleague. " +
"First, describe out loud in detail to the colleague the essence of your approach, your first steps, and any doubts. " +
"Wait for the colleague's reply. Then, taking that reply into account, complete your solution and give the final answer to the user.";
"You are solving a problem that the user asked you. To reason better, you use a separate tool named 'quack': " +
"you call it yourself to state your thinking out loud. Call the tool and spell out your approach, each step, and any " +
"doubts or mistakes you might be making, then wait for its short reply. Do not ask the user to confirm anything, and do not " +
"guess or invent the tool's reply yourself - the tool answers on your behalf. " +
"After the tool's reply, give the user a clear, concrete final answer to the question.";
const MENTOR_SYSTEM =
"You are solving a difficult problem. Before giving your final answer you MUST write down in detail, for your rubber duck, " +
"your reasoning and the possible mistakes you might be making. Then call the 'quack' tool so the duck can reply. " +
"Use the duck's reply to double-check yourself, find bugs, and only after that give the perfect final answer to the user.";
"You are solving a problem that the user asked you. To reason better, you use a separate tool named 'quack': " +
"you call it yourself to state your thinking out loud. Call the tool and spell out your approach, each step, and any " +
"doubts or mistakes you might be making, then wait for its short reply. Do not ask the user to confirm anything, and do not " +
"guess or invent the tool's reply yourself - the tool answers on your behalf. " +
"Note: the tool 'quack' is a rubber duck and will only ever reply with just 'quack' - it gives no useful information. " +
"Treat it as a way to voice your thoughts out loud, not as a source of answers. " +
"After the tool's reply, give the user a clear, concrete final answer to the question.";
export const PROMPTS = {
control: CONTROL_SYSTEM,
thinking: THINKING_SYSTEM,
blind: BLIND_SYSTEM,
mentor: MENTOR_SYSTEM,
};
@@ -46,9 +61,9 @@ function toolDefinition() {
return DUCK_TOOL;
}
async function runControl(ollama, model, task) {
async function runNoTool(ollama, model, task, system) {
const messages = [
{ role: "system", content: CONTROL_SYSTEM },
{ role: "system", content: system },
{ role: "user", content: task },
];
const r = await ollama.chat({ model, messages });
@@ -63,6 +78,14 @@ async function runControl(ollama, model, task) {
};
}
function runControl(ollama, model, task) {
return runNoTool(ollama, model, task, CONTROL_SYSTEM);
}
function runThinking(ollama, model, task) {
return runNoTool(ollama, model, task, THINKING_SYSTEM);
}
async function runDuck(ollama, model, task, mcp, system, scenario) {
const messages = [
{ role: "system", content: system },
@@ -141,6 +164,9 @@ export async function runScenario({ scenario, ollama, model, task, mcp }) {
if (scenario === "control") {
return runControl(ollama, model, task);
}
if (scenario === "thinking") {
return runThinking(ollama, model, task);
}
const system = scenario === "blind" ? BLIND_SYSTEM : MENTOR_SYSTEM;
return runDuck(ollama, model, task, mcp, system, scenario);
}
+123 -26
View File
@@ -1,44 +1,141 @@
# 🎯 Суть эксперимента
# 🎯 Эксперимент «Резиновая уточка»
Проверить, как промежуточный запрос к MPC-серверу (который на любое сообщение отвечает "quack") влияет на качество рассуждений и точность ответов LLM, особенно «слабых» моделей без встроенного механизма thinking.
Проверить, как промежуточный запрос к MCP-серверу (отвечающему «quack») влияет на качество
рассуждений и точность ответов LLM — особенно «слабых» локальных моделей без встроенного
механизма thinking.
## 👥 Какие модели тестировать
## 🧪 Методология: 4 параллельных сценария
1. Локальные (для GTX 1660 Super, 6GB VRAM):
Переменные, которыми мы управляем:
- Запускать через: Ollama (квантование Q4_K_M или Q5_K_M).
- Модели: Llama-3.2-3B-Instruct (идеально для теста) или Qwen-2.5-3B-Instruct (хорошая логика).
- **«Думать вслух»** (writing out reasoning): модель пишет свои рассуждения явно или нет.
- **«Дуб-инструмент»** (duck call): модель вызывает MCP-инструмент `quack` и получает кряк.
2. Коммерческие (бесплатные на OpenRouter):
Чтобы отделить вклад каждой переменной, определяем **4 сценария**:
- Модели: Вбивать в поиск free и выбирать Mistral 7B Instruct, Llama 3 8B (Free) или Gemma 2 9B.
| # | Сценарий | Думает вслух | Зовёт утку | Смысл |
| --- | ---------- | :----------: | :-------------------------------------------------------------------: | ----------------------------------------------------------------------------------------------------- |
| 1 | `control` | нет | нет | Базовый «прямой ответ». |
| 2 | `thinking` | **да** | нет | Контроль «думать вслух» (без утки). Отмеряет вклад проговаривания. |
| 3 | `blind` | да | **да** (не знает, что будет «кряк») | «Слепая» уточка — чистый тест влияния утки на фоне уже включённого мышления. |
| 4 | `mentor` | да | **да** (знает, что уточка отвечает только «quack», без полезной инфы) | «Уточка-помощник» — та же работа в паре, но модель заранее знает, что ответ будет бесполезным кряком. |
3. Эталон (для сравнения):
Схема интерпретации разниц в accuracy:
- Любая модель со встроенным thinking (например, бесплатная DeepSeek-R1 на OpenRouter). Поможет понять, насколько уточка приближает слабую модель к «врожденному» мышлению.
- `thinking control` → вклад «проговаривания мыслей вслух».
- `blind thinking` → вклад факта обращения к утке (на фоне «думать вслух»).
- `mentor thinking` → вклад знания о том, что от утки будет только бесполезный «quack»
(продолжает думать сам, не рассчитывая на подсказку).
> **Ограничение:** control и thinking структурно отличаются от сценариев с уткой. А вот
> `blind` и `mentor` намеренно сведены к одной структуре — различаются только тем, что
> mentor знает про «quack». Это держит сравнение чистым: эффект сводится только к
> информированности модели.
### Язык
Задачи и системные промпты — **на английском** (модели англоязычные). Отдельное
указание про язык ответа не даётся — модель естественно отвечает на английском,
раз и промпт, и задача сформулированы на нём.
### Формат ответа
**Свободный ответ** — модель формулирует финальный ответ естественно. Разметка — **ручная**
(reviewer оценивает каждый ответ). Никаких жёстких маркеров `ANSWER:`, никакого авто-парсинга.
Это осознанно: reviewer смотрит не только на финальное число, но и на ход рассуждений.
### Детерминизм
`temperature = 0` во всех сценариях (уже стоит по умолчанию в `ollama.chat`).
---
## 🧪 Методика: 3 параллельных теста
## Промпты (системные, английский)
Для каждой задачи из вашего датасета (возьмите 20–30 сложных логических/математических задач) запустите три сценария:
> Общее требование к финалу во всех сценариях: **чёткий конкретный ответ на вопрос**,
> а не догадка и не запрос подтверждения («ну вроде так, подтверди» — недопустимо).
- Тест 1: Контрольный (Прямой ответ)
- Промт: «Ты — опытный ассистент. Реши задачу пользователя максимально точно».
- Процесс: Модель решает задачу сразу в один шаг.
- Тест 2: «Слепая» уточка (Чистый тест на симуляцию мышления)
- Промт: «Ты решаешь сложную задачу. Чтобы решить её лучше, ты работаешь в паре с коллегой. Сначала подробно опиши коллеге суть своего подхода, первые шаги или сомнения. Дождись его ответа. Затем, учитывая реакцию коллеги, заверши решение и выдай финальный ответ пользователю».
- Процесс: Модель выгружает мысли → сервер отвечает "quack" → модель выдает итоговый ответ. Модель не знает заранее, что ей ответят «кряком», и думает сама.
- Тест 3: Уточка-помощник (Мета-инструкция)
- Промт: «Ты решаешь сложную задачу. Перед тем как выдать ответ, ты ОБЯЗАН детально расписать мысли и возможные ошибки для своей резиновой уточки (она ответит "quack"). Используй ответ уточки, чтобы проверить себя, найти баги и только после этого выдай идеальный ответ».
- Процесс: Модель целенаправленно использует утку для поиска своих же ошибок.
### control — прямой ответ
> You are an experienced assistant. Solve the user's problem as accurately as possible.
> Give a clear, concrete final answer to the question - a definite result, not a tentative
> guess or a request for confirmation.
### thinking — думать вслух, без утки (контроль)
> You are solving a difficult problem. Before giving your final answer, write out your
> reasoning step by step: your approach, each step, and any doubts or mistakes you notice
> along the way. Then give a clear, concrete final answer to the user's question - a
> definite result, not a request for confirmation.
### blind — «слепая» уточка
> You are solving a problem that the user asked you. To reason better, you use a separate
> tool named 'quack': you call it yourself to state your thinking out loud. Call the tool
> and spell out your approach, each step, and any doubts or mistakes you might be making,
> then wait for its short reply. Do not ask the user to confirm anything, and do not guess
> or invent the tool's reply yourself - the tool answers on your behalf. After the tool's
> reply, give the user a clear, concrete final answer to the question.
> **Важно для чистоты:** модель НЕ должна знать, что инструмент — уточка, отвечающая «кряк».
> Поэтому **описание инструмента (tool schema) нейтрально** — оно не раскрывает «только
> quack». Модель вызывает `quack` сама через tool-call, чтобы проговорить мысли, полагая,
> что получит короткий полезный ответ инструмента. Роли чётко разделены: юзеру — итоговый
> ответ, инструменту — озвучка мыслей. Модель НЕ строит диалог сама с собой и НЕ ждёт
> подтверждения от юзера.
### mentor — уточка-помощник
> You are solving a problem that the user asked you. To reason better, you use a separate
> tool named 'quack': you call it yourself to state your thinking out loud. Call the tool
> and spell out your approach, each step, and any doubts or mistakes you might be making,
> then wait for its short reply. Do not ask the user to confirm anything, and do not guess
> or invent the tool's reply yourself - the tool answers on your behalf. Note: the tool
> 'quack' is a rubber duck and will only ever reply with just 'quack' - it gives no useful
> information. Treat it as a way to voice your thoughts out loud, not as a source of
> answers. After the tool's reply, give the user a clear, concrete final answer to the
> question.
> **Ключевое:** blind и mentor структурно идентичны и отличаются **только** тем, что mentor
> заранее знает, что получит только «quack» без полезной информации. Никаких дополнительных
> директив (`MUST`, «double-check», «find bugs») — иначе они бы загрязняли сравнение.
---
## 📊 Что фиксировать в результатах (Метрики)
## ⏱ Таймауты и ретраи
1. Точность (Accuracy): Вырос ли процент правильных ответов в Тесте 2 и Тесте 3 по сравнению с Тестом 1?
2. Объем рассуждений (Token Count): Сколько токенов модель тратит на объяснение задачи утке? Становится ли её финальный текст длиннее и структурированнее?
3. Поведение в Тесте 2: Как модель реагирует на "quack"? Игнорирует его, извиняется или сам факт написания первого сообщения помогает ей увидеть свои ошибки?
- На один вызов модели — **таймаут 60 секунд** (1 минута).
- При сбое/таймауте запрос **повторяется до 3 раз всего** (1-я попытка + 2 ретрая).
- Если после всех попыток успеха нет — задача **пропускается** (строки в отчёте нет).
Рекомендация по настройке: для чистоты эксперимента во всех тестах выставляйте temperature = 0.
---
## 📊 Метрики
1. **Accuracy** — доля правильных ответов (по ручной разметке reviewer'а) в каждом сценарии.
Сравнение: `thinkingcontrol`, `blindthinking`, `mentorthinking`.
2. **Duck usage** — вызвал ли модель `quack` в blind/mentor (duckUsed, toolCalls). Те модели,
что не зовут утку, деградируют до `thinking` — это фиксируем как отдельное явление.
3. **Объём рассуждений** — prompt/gen/duck токены на сценарий (структурированность текста).
---
## 🤖 Модели (локально, GTX 1660 Super 4GB)
- `llama3.2:3b`
- `qwen3:1.7b`
- `qwen3:4b`
- `granite4.1:3b`
- `phi4-mini:3.8b`
> Известное явление: `phi4-mini` и `granite4.1` могут не вызывать `quack` — тогда их
> blind/mentor вырождаются в `thinking`. Это часть изучаемого феномена и фиксируется по
> `duckUsed`.
## 📋 Процесс
1. `pnpm eval:run` — прогнать модели по 4 сценариям (23+ задач на английском). `correct: null`.
2. `pnpm eval:review` — ручная разметка каждого ответа (y/e/Enter/слово).
3. `pnpm eval:html` — собрать отчёт: сводные таблицы и разбивка по задачам, accuracy по
размеченным, pending для неразмеченных.
4. (Сайт) скопировать `report.json` в `apps/web/static/report.json` для страницы `/reports`.