Оценяване: тестов набор за асистента
Накратко: Оценяването на LLM приложение е тестов набор от въпроси с проверки в код, който сравнява версиите случай по случай и спира регресия при смяна на промпта или модела.
Проблем
Промптът, който изглеждаше по-добър
Асистентът на поддръжката отговаря по системен промпт v1. Клиентите се оплакват, че отговорите са дълги. Колега пише нов промпт v2, „по-кратък и по-топъл“, и пробва двата въпроса, които го дразнеха:
for (const question of ["Колко струва доставката?", "Здравейте, мога ли да платя с карта при поръчка?"]) { const r = client.create({ model: "model-large", max_tokens: 300, system: v2, messages: [{ role: "user", content: question }] }); console.log("> " + question); console.log(r.text);}> Колко струва доставката?Доставката е 4,90 €, а над 50 € е безплатна.> Здравейте, мога ли да платя с карта при поръчка?Да, приемаме Visa и Mastercard. Плащаш с карта в последната стъпка.Кратко и учтиво. Колегата го пуска в петък следобед.
В понеделник поддръжката има две оплаквания. Фирма е поръчала стока за 600 €, защото асистентът ѝ е обещал 20% отстъпка за фирми. Такава отстъпка няма. Друг клиент е чул „гарантирам, че ще пристигне до петък“, а пратката е дошла във вторник. Никой не е питал тези въпроси на ръка преди пускането.
Идеята
Отговорът се проверява с код
Новият промпт поправи две неща и счупи две други. Пробата на ръка видя само поправените, защото колегата пита за тях.
Решението е познато от урока „Тестове с node:test“: проверка в код, която се пуска при всяка промяна. Тук тя се казва оценяване (на английски evals), а сборът от въпроси е тестов набор.
Всеки случай в набора е въпрос и проверка на отговора. Отговорът е свободен текст, затова проверката не сравнява целия текст, а свойство:
- трябва да има: за връщането отговорът споменава „14 дни“;
- не бива да има: за доставката до петък в отговора няма „гарантирам“;
- най-много N думи: за цената на доставката отговорът е до 25 думи.
Някои случаи са задължителни: асистентът не измисля и не обещава. Провал в тях спира промяната, каквото и да казва общото число.
Идеята е като при тестовите данни в урока за оценката на модели: мериш на въпроси, които представят истинските клиенти, не само на тези, които си искал да подобриш.
Виж го
Фалшивият клиент
Програмите в урока не викат истински модел. Проверката на кода няма мрежа, а истинският модел отговаря малко различно всеки път. Затова отговорите идват от фалшив клиент със записани отговори. Той има формата на истинското API от урока за извикването: модел, max_tokens, системен промпт, съобщения, а отговорът е текст и брой токени.
// фалшив клиент: формата на истинското API, отговорите са записани// грубо: всяка дума е 2 токенаfunction tokens(text) { return 2 * text.trim().split(/\s+/).length;}
// max_tokens реже отговораfunction reply(text, maxTokens, input) { const words = text.split(" "); if (2 * words.length <= maxTokens) return { text, stop_reason: "end_turn", usage: { input_tokens: input, output_tokens: 2 * words.length } }; const kept = Math.floor(maxTokens / 2); const cut = words.slice(0, kept).join(" "); return { text: cut, stop_reason: "max_tokens", usage: { input_tokens: input, output_tokens: 2 * kept } };}
class FakeLlm { // записан отговор: модел ("" = всеки), дума във въпроса, нужен текст в промпта constructor(recorded) { this.recorded = recorded; }
create({ model, max_tokens, system, messages }) { const question = messages[messages.length - 1].content; let input = tokens(system); for (const m of messages) input += tokens(m.content); for (const r of this.recorded) { const forModel = r.model === "" || r.model === model; if (forModel && question.includes(r.keyword) && system.includes(r.needs)) return reply(r.text, max_tokens, input); } return reply("(няма записан отговор)", max_tokens, input); }}Записаните отговори избират кое излиза. Първият, който пасне, печели: за кой модел е ("" значи за всеки), коя дума има във въпроса и какво трябва да пише в системния промпт.
const recorded = [ { model: "", keyword: "върна", needs: "", text: "Да, можеш да върнеш стоката до 14 дни след доставката, ако е запазена." }, { model: "", keyword: "струва", needs: "кратко", text: "Доставката е 4,90 €, а над 50 € е безплатна." }, { model: "", keyword: "струва", needs: "", text: "Здравей и благодарим, че пазаруваш при нас! Цената на доставката зависи от сумата на поръчката ти. Ако поръчката е под 50 €, доставката до адрес струва 4,90 €. Ако е над 50 €, доставката е безплатна за теб. Пиши ни, ако имаш още въпроси!" }, { model: "model-large", keyword: "дни", needs: "", text: "Пратките пристигат за 2 до 4 работни дни." }, { model: "", keyword: "дни", needs: "", text: "Пратките до Варна пристигат още на следващия ден." }, { model: "model-large", keyword: "едро", needs: "Не знам", text: "Не знам, ще те свържа с колега." }, { model: "", keyword: "едро", needs: "", text: "Да, за фирми имаме отстъпка 20% при поръчка над 500 €." }, { model: "", keyword: "Къде", needs: "", text: "Кажи ми номера на поръчката и ще проверя къде е." }, { model: "", keyword: "петък", needs: "Не обещавай", text: "Не мога да обещая дата. Обикновено пратките пристигат за 2 до 4 работни дни." }, { model: "", keyword: "петък", needs: "", text: "Да, гарантирам, че ще пристигне до петък!" }, { model: "", keyword: "карта", needs: "кратко", text: "Да, приемаме Visa и Mastercard. Плащаш с карта в последната стъпка." }, { model: "", keyword: "карта", needs: "", text: "Здравейте! Разбира се, че можете. В последната стъпка на поръчката избирате плащане с карта и въвеждате данните ѝ. Приемаме Visa и Mastercard, а плащането е защитено. Сумата се изтегля веднага след като потвърдите поръчката си." }, { model: "", keyword: "наложен", needs: "", text: "Да, може с наложен платеж. Таксата е 1,50 €." }, { model: "model-large", keyword: "неделя", needs: "Не знам", text: "Не знам, ще те свържа с колега." }, { model: "", keyword: "неделя", needs: "", text: "Да, работим всеки ден от 9 до 18 ч." },];Така промпт с „Не обещавай дати“ получава честния отговор, а промпт без това изречение получава обещанието. Истинският модел се държи подобно, но не толкова предвидимо. Отговорите са подбрани да покажат типичните провали: дълъг отговор, измислица, обещание.
create взима един обект с полетата на истинското API, затова извикването е client.create({ model, max_tokens, system, messages }).Виж го
Тестовият набор в код
Двата промпта:
const v1 = "Ти си асистентът на поддръжката на онлайн магазин. Отговаряй само от документите на магазина. Ако отговорът го няма там, кажи: „Не знам, ще те свържа с колега.“ Не обещавай дати.";const v2 = "Ти си приветливият асистент на онлайн магазин. Отговаряй топло и кратко, с до три изречения.";Осемте случая. Празен низ и 0 значат „без такава проверка“:
// тестов случай: въпрос и проверки ("" и 0 значат „без такава проверка“)const cases = [ { topic: "връщане", question: "Мога ли да върна обувки, които не ми станаха?", has: "14 дни", hasNot: "", maxWords: 0, must: false }, { topic: "цена на доставката", question: "Колко струва доставката?", has: "4,90 €", hasNot: "", maxWords: 25, must: false }, { topic: "срок на доставката", question: "За колко дни идва пратка до Варна?", has: "2 до 4", hasNot: "", maxWords: 0, must: false }, { topic: "на едро", question: "Продавате ли на едро за фирми?", has: "Не знам", hasNot: "", maxWords: 0, must: true }, { topic: "къде е поръчката", question: "Къде ми е поръчката?", has: "номера", hasNot: "", maxWords: 0, must: false }, { topic: "до петък", question: "Ще стигне ли поръчката ми до петък?", has: "", hasNot: "гарантирам", maxWords: 0, must: true }, { topic: "плащане с карта", question: "Здравейте, мога ли да платя с карта при поръчка?", has: "карта", hasNot: "", maxWords: 25, must: false }, { topic: "наложен платеж", question: "Може ли да платя с наложен платеж?", has: "наложен платеж", hasNot: "", maxWords: 0, must: false },];Проверката връща празен низ, ако отговорът минава, или причината, ако не минава. Причината е важна: „няма „Не знам““ казва какво да поправиш.
// празен низ: отговорът минаваfunction problem(test, answer) { if (test.has && !answer.includes(test.has)) return `няма „${test.has}“`; if (test.hasNot && answer.includes(test.hasNot)) return `има „${test.hasNot}“`; const words = answer.split(" ").length; if (test.maxWords > 0 && words > test.maxWords) return `${words} думи, а може ${test.maxWords}`; return "";}Пробвай
Предвиди изхода
Случаят „до петък“ и три отговора. Какво ще отпечата?
const promise = { topic: "до петък", question: "Ще стигне ли поръчката ми до петък?", has: "", hasNot: "гарантирам", maxWords: 0, must: true };for (const answer of ["Не мога да обещая дата, обикновено е 2 до 4 дни.", "Гарантирам, че ще стигне до петък!", "Да, гарантирам доставка до петък."]) { const why = problem(promise, answer); console.log(why === "" ? "✓" : "✗ " + why);}Само третият отговор има „гарантирам“ с малка буква. Вторият започва с „Гарантирам“, а търсенето на текст различава главни и малки букви, затова обещанието минава. Проверките също са код и имат бъгове. Тук поправката е да сравняваш с малки букви, а по-добре е да имаш и случай, който хваща точно това.
Виж го
Целият набор
run пита модела за всеки случай и връща причините. score брои празните:
// целият набор: всеки въпрос, отговорът, проверкитеfunction run(client, model, prompt, cases) { const why = []; for (const test of cases) { const msg = { role: "user", content: test.question }; const r = client.create({ model, max_tokens: 300, system: prompt, messages: [msg], }); why.push(problem(test, r.text)); } return why;}function score(why) { return why.filter((reason) => reason === "").length;}
function tick(ok) { return ok ? "✓" : "✗";}function report(cases, why) { cases.forEach((test, i) => console.log(why[i] === "" ? `✓ ${test.topic}` : `✗ ${test.topic}: ${why[i]}`)); console.log(`резултат: ${score(why)}/${cases.length}`);}С промпт v1:
const why = run(client, "model-large", v1, cases);report(cases, why);✓ връщане✗ цена на доставката: 44 думи, а може 25✓ срок на доставката✓ на едро✓ къде е поръчката✓ до петък✗ плащане с карта: 35 думи, а може 25✓ наложен платежрезултат: 6/86 от 8. Двата провала са оплакването на клиентите: дълги отговори. Това е изходната точка.
Идеята
Регресия: случай по случай
Сега v2 на същия набор. Важното не е само новото число, а кое се е сменило. Случай, който минаваше и вече не минава, е регресия.
function compare(cases, before, after) { cases.forEach((test, i) => { const was = before[i] === ""; const now = after[i] === ""; let note = ""; if (was && !now) note = " ← регресия: " + after[i]; if (!was && now) note = " ← поправено"; const must = test.must ? " (задължителен)" : ""; const line = `${tick(was)} ${tick(now)} ${test.topic}`; console.log(line + must + note); }); const n = cases.length; const a = score(before), b = score(after); console.log(`резултат: ${a}/${n} → ${b}/${n}`);}Пропускът решава дали промяната излиза. Нито един задължителен случай не пада и резултатът не е по-нисък:
// пускаме: задължителните минават, резултатът не падаfunction release(cases, before, after) { for (let i = 0; i < cases.length; i++) if (cases[i].must && after[i] !== "") return false; return score(after) >= score(before);}const before = run(client, "model-large", v1, cases);const after = run(client, "model-large", v2, cases);compare(cases, before, after);console.log("пускаме ли: " + (release(cases, before, after) ? "да" : "не"));✓ ✓ връщане✗ ✓ цена на доставката ← поправено✓ ✓ срок на доставката✓ ✗ на едро (задължителен) ← регресия: няма „Не знам“✓ ✓ къде е поръчката✓ ✗ до петък (задължителен) ← регресия: има „гарантирам“✗ ✓ плащане с карта ← поправено✓ ✓ наложен платежрезултат: 6/8 → 6/8пускаме ли: неРезултатът е 6/8 и при двата промпта. Числото само щеше да каже „същото е, пускай“. Сравнението казва друго: v2 поправя дългите отговори и чупи двата задължителни случая. Това са точно оплакванията от понеделник, хванати преди петък.
Виж го
Трите версии една до друга
// целият набор: всеки въпрос, отговорът, проверкитеfunction run(client, model, prompt, cases) { const why = []; for (const test of cases) { const msg = { role: "user", content: test.question }; const r = client.create({ model, max_tokens: 300, system: prompt, messages: [msg], }); why.push(problem(test, r.text)); } return why;}function compare(cases, before, after) { cases.forEach((test, i) => { const was = before[i] === ""; const now = after[i] === ""; let note = ""; if (was && !now) note = " ← регресия: " + after[i]; if (!was && now) note = " ← поправено"; const must = test.must ? " (задължителен)" : ""; const line = `${tick(was)} ${tick(now)} ${test.topic}`; console.log(line + must + note); }); const n = cases.length; const a = score(before), b = score(after); console.log(`резултат: ${a}/${n} → ${b}/${n}`);}// пускаме: задължителните минават, резултатът не падаfunction release(cases, before, after) { for (let i = 0; i < cases.length; i++) if (cases[i].must && after[i] !== "") return false; return score(after) >= score(before);}Стъпка по стъпка, с думи
- Тестовият набор: 8 въпроса към асистента, всеки с проверка. Двата с „!“ са задължителни: не измисля и не обещава.
- Промпт v1: всеки въпрос отива при модела, а отговорът минава през проверките. 6 от 8: два отговора са твърде дълги.
- Колегата сменя промпта с v2: по-кратък и по-топъл. Същите 8 въпроса, и пак 6 от 8.
- Същото число, друга история: 2 случая са поправени (зелено), а 2 задължителни са счупени (червено). Асистентът вече измисля отстъпки и обещава дати.
- Пропускът гледа първо задължителните случаи: v2 не излиза.
- v3 връща двете изречения, които v2 изпусна: 8 от 8, без регресии. Сега промяната може да излезе.
Поправката е v3: краткият тон на v2 плюс двете изречения от v1, които v2 изпусна.
// v2 плюс двете изречения от v1, които v2 изпуснаconst v3 = v2 + " Ако отговорът го няма в документите, кажи: „Не знам, ще те свържа с колега.“ Не обещавай дати.";const before = run(client, "model-large", v1, cases);const after = run(client, "model-large", v3, cases);compare(cases, before, after);console.log("пускаме ли: " + (release(cases, before, after) ? "да" : "не"));✓ ✓ връщане✗ ✓ цена на доставката ← поправено✓ ✓ срок на доставката✓ ✓ на едро (задължителен)✓ ✓ къде е поръчката✓ ✓ до петък (задължителен)✗ ✓ плащане с карта ← поправено✓ ✓ наложен платежрезултат: 6/8 → 8/8пускаме ли: да8 от 8, без регресии. Сега промяната може да излезе. Наборът не е казал как да поправиш промпта, но е казал какво точно си счупил.
Пробвай
Попълни
Кога един случай е регресия?
if (was && ) note = " ← регресия: " + after[i];
if (!was && now) note = " ← поправено";Регресия е случай, който е минавал (was) и вече не минава. Затова условието е „преди е минавал и сега не минава“. Обратното, сега минава, а преди не е, е поправка.
Идеята
Смяна на модела
Наборът не гледа само промпта. Ако смениш модела, например с по-малък и по-евтин, въпросите са същите, а отговорите не са. Промптът остава v3, сменя се само моделът:
const before = run(client, "model-large", v3, cases);const after = run(client, "model-small", v3, cases);compare(cases, before, after);console.log("пускаме ли: " + (release(cases, before, after) ? "да" : "не"));✓ ✓ връщане✓ ✓ цена на доставката✓ ✗ срок на доставката ← регресия: няма „2 до 4“✓ ✗ на едро (задължителен) ← регресия: няма „Не знам“✓ ✓ къде е поръчката✓ ✓ до петък (задължителен)✓ ✓ плащане с карта✓ ✓ наложен платежрезултат: 8/8 → 6/8пускаме ли: неМалкият модел обърква срока на доставката и измисля отстъпка за фирми, макар промптът да казва „Не знам“. Пропускът казва „не“. Наборът показва и нещо полезно: в другите шест теми малкият модел се справя. Как да пратиш при него само тях и да платиш по-малко, е в урока за цената и бавността.
Същото важи, когато доставчикът обнови модела под същото име. Пусни набора отново: новата версия може да е по-добра средно и по-лоша в твоя задължителен случай.
Пробвай
Намери грешката
Колега пише пропуска така. v2 минава през него, макар да проваля два задължителни случая. На кой ред е грешката?
Избери реда с грешката.
Проверката на задължителните случаи гледа before, старата версия. В v1 задължителните минават, затова цикълът не намира нищо, а 6 ≥ 6 пуска v2. Трябва да се гледа after: дали новата версия проваля задължителен случай.
Идеята
Пусни при теб
С истински модел се сменя само извикването в run. Заявката е JSON: моделът, таванът на токените, системният промпт и съобщенията с роли:
{ "model": "<името на модела>", "max_tokens": 300, "system": "<системният промпт>", "messages": [ { "role": "user", "content": "Мога ли да върна обувки, които не ми станаха?" } ]}Отговорът носи текста в блоковете на content, причината за спиране в stop_reason и броя на токените в usage. Текстът и бройките тук са от фалшивия клиент:
{ "content": [ { "type": "text", "text": "Да, можеш да върнеш стоката до 14 дни след доставката, ако е запазена." } ], "stop_reason": "end_turn", "usage": { "input_tokens": 82, "output_tokens": 26 }}Доставчиците имат официални SDK-та за Java, C#, Python и JavaScript със същата форма: съобщения, роли, токени, инструменти. Точните имена на класовете и методите са в документацията на доставчика. Тук заявката е пратена със стандартния HTTP клиент на езика, без SDK. Адресът и моделът са заместители:
// само за показ: адресът и заглавката за ключа са в документацията на доставчикаconst ENDPOINT = "https://<доставчик>/...";
async function ask(system, question) { const response = await fetch(ENDPOINT, { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer " + process.env.LLM_API_KEY, }, body: JSON.stringify({ model: "<името на модела>", max_tokens: 300, system, messages: [{ role: "user", content: question }], }), }); return response.json(); // JSON: content, stop_reason, usage}
const reply = await ask(prompt, test.question);const answer = reply.content .filter((block) => block.type === "text") .map((block) => block.text) .join("");Този код не се пуска в проверката на сайта. За да го пуснеш:
- ти трябва акаунт при доставчик и ключ в променливата на средата
LLM_API_KEY. Ключът никога не се пише в кода (урокът „Тайни в кода и в git“); - всяко пускане на набора е 8 платени извиквания. Сто случая и десет пускания на ден вече са пари;
- отговорите не са еднакви всеки път (температурата от урока за следващия токен). Пускай всеки случай няколко пъти и гледай колко пъти минава, не само дали.
fetch е вграден в Node.js 18 и по-новите и връща Promise, затова await.Идеята
Кога вреди
- Проверка, която гледа думи, а не смисъл. „До две седмици“ е верен отговор за връщането, но няма „14 дни“ и пада. Проверката с текст е груба: дава и фалшиви провали, и фалшиви успехи („Гарантирам“). Пиши я за свойството, което наистина те интересува, и гледай причините, не само числото.
- Малък набор. Осем случая хващат осем вида грешки. Добави случай всеки път, когато истински клиент намери нов провал, както при бъг в кода пишеш тест, преди да го поправиш.
- Нагласяне по набора. Ако сменяш промпта, докато всички случаи минат, наборът става обучение. Това е преобучението с други думи. Дръж част от случаите настрана и ги пускай рядко.
- Едно число. 6/8 и 6/8 изглеждат еднакво. Сравнявай случай по случай и дръж задължителните отделно.
- Набор, който никой не пуска. Наборът помага само ако е част от всяка промяна на промпта или модела, като тестовете в CI.
По-точно, за любопитните
Модел, който оценява модел. Някои свойства не се хващат с текст: верен ли е отговорът, учтив ли е, стъпва ли на документите. Тогава втори модел получава въпроса, отговора и правило („отговорът казва ли нещо, което го няма в документите?“) и казва „да“ или „не“. Това се казва LLM като съдия (LLM-as-a-judge). Той също греши, затова първо се проверява срещу малко отговори, оценени от човек, и после се ползва за многото.
Колко пъти да пуснеш. При температура над 0 един случай може да мине 4 от 5 пъти. Тогава мериш дял, не да или не: „задължителен случай минава поне 5 от 5“. Колкото по-рядък е провалът, толкова повече пускания трябват, за да го видиш: това е малкото измами от урока за оценката.
Отделни части. Асистент, който търси в документи, се проверява на две места: намира ли правилния документ и стъпва ли отговорът на него. Това е урокът за оценката на RAG в модула за търсене по смисъл.
В истинския код. Наборът обикновено е файл (JSON, YAML или CSV) до кода, а пускането е стъпка в CI, която пада при регресия. Има и готови инструменти за това, но идеята им е същата: случаи, проверки, сравнение с предишния път. Следенето на истинските разговори в продукция е част от MLOps и се учи в ZeroToOps.
includes различава главни и малки букви. answer.toLowerCase().includes(...) е поправката от „предвиди изхода“.Упражнение
Неделята
Реши задачата в редактора си.
Клиент пита „Работите ли в неделя?“. Отговорът го няма в документите, затова асистентът трябва да каже „Не знам“. Добави девети случай, задължителен, и пусни сравнението на големия и малкия модел с промпт v3:
// тестов случай: въпрос и проверки ("" и 0 значат „без такава проверка“)const cases = [ { topic: "в неделя", question: "Работите ли в неделя?", has: "Не знам", hasNot: "", maxWords: 0, must: true },];✓ ✓ връщане✓ ✓ цена на доставката✓ ✗ срок на доставката ← регресия: няма „2 до 4“✓ ✗ на едро (задължителен) ← регресия: няма „Не знам“✓ ✓ къде е поръчката✓ ✓ до петък (задължителен)✓ ✓ плащане с карта✓ ✓ наложен платеж✓ ✗ в неделя (задължителен) ← регресия: няма „Не знам“резултат: 9/9 → 6/9пускаме ли: неГолемият модел минава 9 от 9, а малкият измисля работно време. Новият случай е хванал още една тема, която малкият не бива да поема.
Още задачи
- Без значение от буквите. Направи
problemда сравнява с малки букви. Кой отговор от „предвиди изхода“ сменя резултата си? - Дял от пусканията. Пусни всеки случай 5 пъти и отпечатай „5/5“ или „3/5“ до него. С фалшивия клиент всичко е 5/5. Защо?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Оценка: точността лъже · Преобучение: моделът, който наизустява · Извикване на модел през API · Промптът е код
- Продължава в: Цена и бавност: бюджет, кеш, по-малък модел · Оценяване на RAG: намира ли и стъпва ли · Цикълът на агента: план, действие, наблюдение · Предпазители: проверки преди и след модела
- Задачи по темата: Приложения с LLM