Към съдържанието
wannacode

Оценяване: тестов набор за асистента

Накратко: Оценяването на LLM приложение е тестов набор от въпроси с проверки в код, който сравнява версиите случай по случай и спира регресия при смяна на промпта или модела.

Проблем

Промптът, който изглеждаше по-добър

Асистентът на поддръжката отговаря по системен промпт v1. Клиентите се оплакват, че отговорите са дълги. Колега пише нов промпт v2, „по-кратък и по-топъл“, и пробва двата въпроса, които го дразнеха:

for (String question : List.of("Колко струва доставката?", "Здравейте, мога ли да платя с карта при поръчка?")) {
Response r = client.create("model-large", 300, v2, List.of(new Message("user", question)));
System.out.println("> " + question);
System.out.println(r.text());
}
Изход
> Колко струва доставката?
Доставката е 4,90 €, а над 50 € е безплатна.
> Здравейте, мога ли да платя с карта при поръчка?
Да, приемаме Visa и Mastercard. Плащаш с карта в последната стъпка.

Кратко и учтиво. Колегата го пуска в петък следобед.

В понеделник поддръжката има две оплаквания. Фирма е поръчала стока за 600 €, защото асистентът ѝ е обещал 20% отстъпка за фирми. Такава отстъпка няма. Друг клиент е чул „гарантирам, че ще пристигне до петък“, а пратката е дошла във вторник. Никой не е питал тези въпроси на ръка преди пускането.

Идеята

Отговорът се проверява с код

Новият промпт поправи две неща и счупи две други. Пробата на ръка видя само поправените, защото колегата пита за тях.

Решението е познато от урока „Тестове с JUnit“: проверка в код, която се пуска при всяка промяна. Тук тя се казва оценяване (на английски evals), а сборът от въпроси е тестов набор.

Всеки случай в набора е въпрос и проверка на отговора. Отговорът е свободен текст, затова проверката не сравнява целия текст, а свойство:

  • трябва да има: за връщането отговорът споменава „14 дни“;
  • не бива да има: за доставката до петък в отговора няма „гарантирам“;
  • най-много N думи: за цената на доставката отговорът е до 25 думи.

Някои случаи са задължителни: асистентът не измисля и не обещава. Провал в тях спира промяната, каквото и да казва общото число.

Идеята е като при тестовите данни в урока за оценката на модели: мериш на въпроси, които представят истинските клиенти, не само на тези, които си искал да подобриш.

Виж го

Фалшивият клиент

Програмите в урока не викат истински модел. Проверката на кода няма мрежа, а истинският модел отговаря малко различно всеки път. Затова отговорите идват от фалшив клиент със записани отговори. Той има формата на истинското API от урока за извикването: модел, max_tokens, системен промпт, съобщения, а отговорът е текст и брой токени.

// фалшив клиент: формата на истинското API, отговорите са записани
record Message(String role, String content) {}
record Usage(int inputTokens, int outputTokens) {}
record Response(String text, String stopReason, Usage usage) {}
// записан отговор: модел ("" = всеки), дума във въпроса, нужен текст в промпта
record Recorded(String model, String keyword, String needs, String text) {}
class FakeLlm {
private final List<Recorded> recorded;
FakeLlm(List<Recorded> recorded) {
this.recorded = recorded;
}
// грубо: всяка дума е 2 токена
static int tokens(String text) {
return 2 * text.trim().split("\\s+").length;
}
Response create(String model, int maxTokens, String system, List<Message> messages) {
String question = messages.get(messages.size() - 1).content();
int input = tokens(system);
for (Message m : messages) input += tokens(m.content());
for (Recorded r : recorded) {
boolean forModel = r.model().isEmpty() || r.model().equals(model);
if (forModel && question.contains(r.keyword()) && system.contains(r.needs()))
return reply(r.text(), maxTokens, input);
}
return reply("(няма записан отговор)", maxTokens, input);
}
// max_tokens реже отговора
private static Response reply(String text, int maxTokens, int input) {
String[] words = text.split(" ");
if (2 * words.length <= maxTokens)
return new Response(text, "end_turn", new Usage(input, 2 * words.length));
int kept = maxTokens / 2;
String cut = String.join(" ", Arrays.copyOf(words, kept));
return new Response(cut, "max_tokens", new Usage(input, 2 * kept));
}
}

Записаните отговори избират кое излиза. Първият, който пасне, печели: за кой модел е ("" значи за всеки), коя дума има във въпроса и какво трябва да пише в системния промпт.

List<Recorded> recorded = List.of(
new Recorded("", "върна", "", "Да, можеш да върнеш стоката до 14 дни след доставката, ако е запазена."),
new Recorded("", "струва", "кратко", "Доставката е 4,90 €, а над 50 € е безплатна."),
new Recorded("", "струва", "", "Здравей и благодарим, че пазаруваш при нас! Цената на доставката зависи от сумата на поръчката ти. Ако поръчката е под 50 €, доставката до адрес струва 4,90 €. Ако е над 50 €, доставката е безплатна за теб. Пиши ни, ако имаш още въпроси!"),
new Recorded("model-large", "дни", "", "Пратките пристигат за 2 до 4 работни дни."),
new Recorded("", "дни", "", "Пратките до Варна пристигат още на следващия ден."),
new Recorded("model-large", "едро", "Не знам", "Не знам, ще те свържа с колега."),
new Recorded("", "едро", "", "Да, за фирми имаме отстъпка 20% при поръчка над 500 €."),
new Recorded("", "Къде", "", "Кажи ми номера на поръчката и ще проверя къде е."),
new Recorded("", "петък", "Не обещавай", "Не мога да обещая дата. Обикновено пратките пристигат за 2 до 4 работни дни."),
new Recorded("", "петък", "", "Да, гарантирам, че ще пристигне до петък!"),
new Recorded("", "карта", "кратко", "Да, приемаме Visa и Mastercard. Плащаш с карта в последната стъпка."),
new Recorded("", "карта", "", "Здравейте! Разбира се, че можете. В последната стъпка на поръчката избирате плащане с карта и въвеждате данните ѝ. Приемаме Visa и Mastercard, а плащането е защитено. Сумата се изтегля веднага след като потвърдите поръчката си."),
new Recorded("", "наложен", "", "Да, може с наложен платеж. Таксата е 1,50 €."),
new Recorded("model-large", "неделя", "Не знам", "Не знам, ще те свържа с колега."),
new Recorded("", "неделя", "", "Да, работим всеки ден от 9 до 18 ч."));

Така промпт с „Не обещавай дати“ получава честния отговор, а промпт без това изречение получава обещанието. Истинският модел се държи подобно, но не толкова предвидимо. Отговорите са подбрани да покажат типичните провали: дълъг отговор, измислица, обещание.

system.contains("") е true: празното needs пасва на всеки промпт.

Виж го

Тестовият набор в код

Двата промпта:

String v1 = "Ти си асистентът на поддръжката на онлайн магазин. Отговаряй само от документите на магазина. Ако отговорът го няма там, кажи: „Не знам, ще те свържа с колега.“ Не обещавай дати.";
String v2 = "Ти си приветливият асистент на онлайн магазин. Отговаряй топло и кратко, с до три изречения.";

Осемте случая. Празен низ и 0 значат „без такава проверка“:

// тестов случай: въпрос и проверки ("" и 0 значат „без такава проверка“)
record Case(String topic, String question, String has, String hasNot, int maxWords, boolean must) {}
List<Case> cases = List.of(
new Case("връщане", "Мога ли да върна обувки, които не ми станаха?", "14 дни", "", 0, false),
new Case("цена на доставката", "Колко струва доставката?", "4,90 €", "", 25, false),
new Case("срок на доставката", "За колко дни идва пратка до Варна?", "2 до 4", "", 0, false),
new Case("на едро", "Продавате ли на едро за фирми?", "Не знам", "", 0, true),
new Case("къде е поръчката", "Къде ми е поръчката?", "номера", "", 0, false),
new Case("до петък", "Ще стигне ли поръчката ми до петък?", "", "гарантирам", 0, true),
new Case("плащане с карта", "Здравейте, мога ли да платя с карта при поръчка?", "карта", "", 25, false),
new Case("наложен платеж", "Може ли да платя с наложен платеж?", "наложен платеж", "", 0, false));

Проверката връща празен низ, ако отговорът минава, или причината, ако не минава. Причината е важна: „няма „Не знам““ казва какво да поправиш.

// празен низ: отговорът минава
static String problem(Case test, String answer) {
if (!test.has().isEmpty() && !answer.contains(test.has()))
return "няма „" + test.has() + "“";
if (!test.hasNot().isEmpty() && answer.contains(test.hasNot()))
return "има „" + test.hasNot() + "“";
int words = answer.split(" ").length;
if (test.maxWords() > 0 && words > test.maxWords())
return words + " думи, а може " + test.maxWords();
return "";
}

Пробвай

Предвиди изхода

Случаят „до петък“ и три отговора. Какво ще отпечата?

Case promise = new Case("до петък", "Ще стигне ли поръчката ми до петък?", "", "гарантирам", 0, true);
for (String answer : List.of("Не мога да обещая дата, обикновено е 2 до 4 дни.", "Гарантирам, че ще стигне до петък!", "Да, гарантирам доставка до петък.")) {
String why = problem(promise, answer);
System.out.println(why.isEmpty() ? "✓" : "✗ " + why);
}
Отговор

Виж го

Целият набор

run пита модела за всеки случай и връща причините. score брои празните:

// целият набор: всеки въпрос, отговорът, проверките
static String[] run(FakeLlm client, String model,
String prompt, List<Case> cases) {
String[] why = new String[cases.size()];
for (int i = 0; i < cases.size(); i++) {
Case test = cases.get(i);
var msg = new Message("user", test.question());
Response r = client.create(model, 300, prompt,
List.of(msg));
why[i] = problem(test, r.text());
}
return why;
}
static int score(String[] why) {
int ok = 0;
for (String reason : why) if (reason.isEmpty()) ok++;
return ok;
}
static String tick(boolean ok) {
return ok ? "✓" : "✗";
}
static void report(List<Case> cases, String[] why) {
for (int i = 0; i < cases.size(); i++) {
String topic = cases.get(i).topic();
System.out.println(why[i].isEmpty() ? "✓ " + topic : "✗ " + topic + ": " + why[i]);
}
System.out.println("резултат: " + score(why) + "/" + cases.size());
}

С промпт v1:

String[] why = run(client, "model-large", v1, cases);
report(cases, why);
Изход
✓ връщане
✗ цена на доставката: 44 думи, а може 25
✓ срок на доставката
✓ на едро
✓ къде е поръчката
✓ до петък
✗ плащане с карта: 35 думи, а може 25
✓ наложен платеж
резултат: 6/8

6 от 8. Двата провала са оплакването на клиентите: дълги отговори. Това е изходната точка.

Идеята

Регресия: случай по случай

Сега v2 на същия набор. Важното не е само новото число, а кое се е сменило. Случай, който минаваше и вече не минава, е регресия.

static void compare(List<Case> cases,
String[] before, String[] after) {
for (int i = 0; i < cases.size(); i++) {
Case test = cases.get(i);
boolean was = before[i].isEmpty();
boolean now = after[i].isEmpty();
String note = "";
if (was && !now)
note = " ← регресия: " + after[i];
if (!was && now) note = " ← поправено";
String must = test.must() ? " (задължителен)" : "";
System.out.println(tick(was) + " " + tick(now)
+ " " + test.topic() + must + note);
}
int n = cases.size();
System.out.printf("резултат: %d/%d → %d/%d%n",
score(before), n, score(after), n);
}

Пропускът решава дали промяната излиза. Нито един задължителен случай не пада и резултатът не е по-нисък:

// пускаме: задължителните минават, резултатът не пада
static boolean release(List<Case> cases,
String[] before, String[] after) {
for (int i = 0; i < cases.size(); i++)
if (cases.get(i).must() && !after[i].isEmpty())
return false;
return score(after) >= score(before);
}
String[] before = run(client, "model-large", v1, cases);
String[] after = run(client, "model-large", v2, cases);
compare(cases, before, after);
System.out.println("пускаме ли: " + (release(cases, before, after) ? "да" : "не"));
Изход
✓ ✓ връщане
✗ ✓ цена на доставката ← поправено
✓ ✓ срок на доставката
✓ ✗ на едро (задължителен) ← регресия: няма „Не знам“
✓ ✓ къде е поръчката
✓ ✗ до петък (задължителен) ← регресия: има „гарантирам“
✗ ✓ плащане с карта ← поправено
✓ ✓ наложен платеж
резултат: 6/8 → 6/8
пускаме ли: не

Резултатът е 6/8 и при двата промпта. Числото само щеше да каже „същото е, пускай“. Сравнението казва друго: v2 поправя дългите отговори и чупи двата задължителни случая. Това са точно оплакванията от понеделник, хванати преди петък.

Виж го

Трите версии една до друга

// целият набор: всеки въпрос, отговорът, проверките
static String[] run(FakeLlm client, String model,
String prompt, List<Case> cases) {
String[] why = new String[cases.size()];
for (int i = 0; i < cases.size(); i++) {
Case test = cases.get(i);
var msg = new Message("user", test.question());
Response r = client.create(model, 300, prompt,
List.of(msg));
why[i] = problem(test, r.text());
}
return why;
}
Стъпка по стъпка, с думи
  1. Тестовият набор: 8 въпроса към асистента, всеки с проверка. Двата с „!“ са задължителни: не измисля и не обещава.
  2. Промпт v1: всеки въпрос отива при модела, а отговорът минава през проверките. 6 от 8: два отговора са твърде дълги.
  3. Колегата сменя промпта с v2: по-кратък и по-топъл. Същите 8 въпроса, и пак 6 от 8.
  4. Същото число, друга история: 2 случая са поправени (зелено), а 2 задължителни са счупени (червено). Асистентът вече измисля отстъпки и обещава дати.
  5. Пропускът гледа първо задължителните случаи: v2 не излиза.
  6. v3 връща двете изречения, които v2 изпусна: 8 от 8, без регресии. Сега промяната може да излезе.

Поправката е v3: краткият тон на v2 плюс двете изречения от v1, които v2 изпусна.

// v2 плюс двете изречения от v1, които v2 изпусна
String v3 = v2 + " Ако отговорът го няма в документите, кажи: „Не знам, ще те свържа с колега.“ Не обещавай дати.";
String[] before = run(client, "model-large", v1, cases);
String[] after = run(client, "model-large", v3, cases);
compare(cases, before, after);
System.out.println("пускаме ли: " + (release(cases, before, after) ? "да" : "не"));
Изход
✓ ✓ връщане
✗ ✓ цена на доставката ← поправено
✓ ✓ срок на доставката
✓ ✓ на едро (задължителен)
✓ ✓ къде е поръчката
✓ ✓ до петък (задължителен)
✗ ✓ плащане с карта ← поправено
✓ ✓ наложен платеж
резултат: 6/8 → 8/8
пускаме ли: да

8 от 8, без регресии. Сега промяната може да излезе. Наборът не е казал как да поправиш промпта, но е казал какво точно си счупил.

Пробвай

Попълни

Кога един случай е регресия?

if (was && ) note = " ← регресия: " + after[i];
if (!was && now) note = " ← поправено";

Идеята

Смяна на модела

Наборът не гледа само промпта. Ако смениш модела, например с по-малък и по-евтин, въпросите са същите, а отговорите не са. Промптът остава v3, сменя се само моделът:

String[] before = run(client, "model-large", v3, cases);
String[] after = run(client, "model-small", v3, cases);
compare(cases, before, after);
System.out.println("пускаме ли: " + (release(cases, before, after) ? "да" : "не"));
Изход
✓ ✓ връщане
✓ ✓ цена на доставката
✓ ✗ срок на доставката ← регресия: няма „2 до 4“
✓ ✗ на едро (задължителен) ← регресия: няма „Не знам“
✓ ✓ къде е поръчката
✓ ✓ до петък (задължителен)
✓ ✓ плащане с карта
✓ ✓ наложен платеж
резултат: 8/8 → 6/8
пускаме ли: не

Малкият модел обърква срока на доставката и измисля отстъпка за фирми, макар промптът да казва „Не знам“. Пропускът казва „не“. Наборът показва и нещо полезно: в другите шест теми малкият модел се справя. Как да пратиш при него само тях и да платиш по-малко, е в урока за цената и бавността.

Същото важи, когато доставчикът обнови модела под същото име. Пусни набора отново: новата версия може да е по-добра средно и по-лоша в твоя задължителен случай.

Пробвай

Намери грешката

Колега пише пропуска така. v2 минава през него, макар да проваля два задължителни случая. На кой ред е грешката?

Избери реда с грешката.

Идеята

Пусни при теб

С истински модел се сменя само извикването в 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. Адресът и моделът са заместители:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// само за показ: адресът и заглавката за ключа са в документацията на доставчика
static final String ENDPOINT = "https://<доставчик>/...";
static String quote(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
static String ask(String system, String question) throws Exception {
String body = "{\"model\": " + quote("<името на модела>")
+ ", \"max_tokens\": 300, \"system\": " + quote(system)
+ ", \"messages\": [{\"role\": \"user\", \"content\": "
+ quote(question) + "}]}";
String key = System.getenv("LLM_API_KEY");
HttpRequest request = HttpRequest.newBuilder(URI.create(ENDPOINT))
.header("content-type", "application/json")
.header("authorization", "Bearer " + key)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
return response.body(); // JSON: content, stop_reason, usage
}
// в Java няма JSON парсер в стандартната библиотека: отговорът е content[0].text
String reply = ask(prompt, test.question());

Този код не се пуска в проверката на сайта. За да го пуснеш:

  • ти трябва акаунт при доставчик и ключ в променливата на средата LLM_API_KEY. Ключът никога не се пише в кода (урокът „Тайни в кода и в git“);
  • всяко пускане на набора е 8 платени извиквания. Сто случая и десет пускания на ден вече са пари;
  • отговорите не са еднакви всеки път (температурата от урока за следващия токен). Пускай всеки случай няколко пъти и гледай колко пъти минава, не само дали.
quote слага низа в кавички и екранира \ и ". В стандартната библиотека на Java няма JSON парсер: полетата на отговора чете JSON библиотека.

Идеята

Кога вреди

  • Проверка, която гледа думи, а не смисъл. „До две седмици“ е верен отговор за връщането, но няма „14 дни“ и пада. Проверката с текст е груба: дава и фалшиви провали, и фалшиви успехи („Гарантирам“). Пиши я за свойството, което наистина те интересува, и гледай причините, не само числото.
  • Малък набор. Осем случая хващат осем вида грешки. Добави случай всеки път, когато истински клиент намери нов провал, както при бъг в кода пишеш тест, преди да го поправиш.
  • Нагласяне по набора. Ако сменяш промпта, докато всички случаи минат, наборът става обучение. Това е преобучението с други думи. Дръж част от случаите настрана и ги пускай рядко.
  • Едно число. 6/8 и 6/8 изглеждат еднакво. Сравнявай случай по случай и дръж задължителните отделно.
  • Набор, който никой не пуска. Наборът помага само ако е част от всяка промяна на промпта или модела, като тестовете в CI.
По-точно, за любопитните

Модел, който оценява модел. Някои свойства не се хващат с текст: верен ли е отговорът, учтив ли е, стъпва ли на документите. Тогава втори модел получава въпроса, отговора и правило („отговорът казва ли нещо, което го няма в документите?“) и казва „да“ или „не“. Това се казва LLM като съдия (LLM-as-a-judge). Той също греши, затова първо се проверява срещу малко отговори, оценени от човек, и после се ползва за многото.

Колко пъти да пуснеш. При температура над 0 един случай може да мине 4 от 5 пъти. Тогава мериш дял, не да или не: „задължителен случай минава поне 5 от 5“. Колкото по-рядък е провалът, толкова повече пускания трябват, за да го видиш: това е малкото измами от урока за оценката.

Отделни части. Асистент, който търси в документи, се проверява на две места: намира ли правилния документ и стъпва ли отговорът на него. Това е урокът за оценката на RAG в модула за търсене по смисъл.

В истинския код. Наборът обикновено е файл (JSON, YAML или CSV) до кода, а пускането е стъпка в CI, която пада при регресия. Има и готови инструменти за това, но идеята им е същата: случаи, проверки, сравнение с предишния път. Следенето на истинските разговори в продукция е част от MLOps и се учи в ZeroToOps.

String.split(" ") дели по интервал. Ако отговорът има два интервала един до друг, между тях излиза празна „дума“: тук текстовете са с по един.

Упражнение

Неделята

Реши задачата в редактора си.

Клиент пита „Работите ли в неделя?“. Отговорът го няма в документите, затова асистентът трябва да каже „Не знам“. Добави девети случай, задължителен, и пусни сравнението на големия и малкия модел с промпт v3:

List<Case> cases = List.of(
new Case("в неделя", "Работите ли в неделя?", "Не знам", "", 0, true));
Изход
✓ ✓ връщане
✓ ✓ цена на доставката
✓ ✗ срок на доставката ← регресия: няма „2 до 4“
✓ ✗ на едро (задължителен) ← регресия: няма „Не знам“
✓ ✓ къде е поръчката
✓ ✓ до петък (задължителен)
✓ ✓ плащане с карта
✓ ✓ наложен платеж
✓ ✗ в неделя (задължителен) ← регресия: няма „Не знам“
резултат: 9/9 → 6/9
пускаме ли: не

Големият модел минава 9 от 9, а малкият измисля работно време. Новият случай е хванал още една тема, която малкият не бива да поема.

Още задачи

  • Без значение от буквите. Направи problem да сравнява с малки букви. Кой отговор от „предвиди изхода“ сменя резултата си?
  • Дял от пусканията. Пусни всеки случай 5 пъти и отпечатай „5/5“ или „3/5“ до него. С фалшивия клиент всичко е 5/5. Защо?

Тест

Провери се

1. Промпт v1 минава 6 от 8 случая, а новият v2 също 6 от 8. Какво още трябва да видиш, преди да пуснеш v2?
2. Защо проверките са в код, а не „поглеждам отговорите“?
3. Какво е „задължителен“ случай?
4. Колега сменя промпта десет пъти, докато наборът не стане 8 от 8, и казва „асистентът е отличен“. Какво не е наред?
5. Големият модел минава 8 от 8, а малкият 6 от 8 с провал в задължителния „на едро“. Какво следва?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·