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

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

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

Проблем

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

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

foreach (var question in new[] { "Колко струва доставката?", "Здравейте, мога ли да платя с карта при поръчка?" })
{
var r = client.Create("model-large", 300, v2, [new Message("user", question)]);
Console.WriteLine("> " + question);
Console.WriteLine(r.Text);
}
Изход
> Колко струва доставката?
Доставката е 4,90 €, а над 50 € е безплатна.
> Здравейте, мога ли да платя с карта при поръчка?
Да, приемаме Visa и Mastercard. Плащаш с карта в последната стъпка.

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

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

Идеята

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

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

Решението е познато от урока „Тестове с xUnit“: проверка в код, която се пуска при всяка промяна. Тук тя се казва оценяване (на английски 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(List<Recorded> recorded)
{
// грубо: всяка дума е 2 токена
public static int Tokens(string text) =>
2 * text.Split([' ', '\n'], StringSplitOptions.RemoveEmptyEntries).Length;
public Response Create(string model, int maxTokens, string system, List<Message> messages)
{
var question = messages[^1].Content;
var input = Tokens(system) + messages.Sum(m => Tokens(m.Content));
foreach (var r in recorded)
{
var forModel = r.Model == "" || r.Model == model;
if (forModel && question.Contains(r.Keyword) && system.Contains(r.Needs))
return Reply(r.Text, maxTokens, input);
}
return Reply("(няма записан отговор)", maxTokens, input);
}
// max_tokens реже отговора
static Response Reply(string text, int maxTokens, int input)
{
var words = text.Split(' ');
if (2 * words.Length <= maxTokens)
return new Response(text, "end_turn", new Usage(input, 2 * words.Length));
var kept = maxTokens / 2;
var cut = string.Join(' ', words[..kept]);
return new Response(cut, "max_tokens", new Usage(input, 2 * kept));
}
}

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

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

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

messages[^1] е последният елемент, а words[..kept] са първите kept думи.

Виж го

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

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

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

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

// тестов случай: въпрос и проверки ("" и 0 значат „без такава проверка“)
record Case(string Topic, string Question, string Has, string HasNot, int MaxWords, bool Must);
List<Case> cases =
[
new("връщане", "Мога ли да върна обувки, които не ми станаха?", "14 дни", "", 0, false),
new("цена на доставката", "Колко струва доставката?", "4,90 €", "", 25, false),
new("срок на доставката", "За колко дни идва пратка до Варна?", "2 до 4", "", 0, false),
new("на едро", "Продавате ли на едро за фирми?", "Не знам", "", 0, true),
new("къде е поръчката", "Къде ми е поръчката?", "номера", "", 0, false),
new("до петък", "Ще стигне ли поръчката ми до петък?", "", "гарантирам", 0, true),
new("плащане с карта", "Здравейте, мога ли да платя с карта при поръчка?", "карта", "", 25, false),
new("наложен платеж", "Може ли да платя с наложен платеж?", "наложен платеж", "", 0, false),
];

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

// празен низ: отговорът минава
static string Problem(Case test, string answer)
{
if (test.Has != "" && !answer.Contains(test.Has))
return $"няма „{test.Has}“";
if (test.HasNot != "" && answer.Contains(test.HasNot))
return $"има „{test.HasNot}“";
var words = answer.Split(' ').Length;
if (test.MaxWords > 0 && words > test.MaxWords)
return $"{words} думи, а може {test.MaxWords}";
return "";
}

Пробвай

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

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

var promise = new Case("до петък", "Ще стигне ли поръчката ми до петък?", "", "гарантирам", 0, true);
foreach (var answer in new[] { "Не мога да обещая дата, обикновено е 2 до 4 дни.", "Гарантирам, че ще стигне до петък!", "Да, гарантирам доставка до петък." })
{
var why = Problem(promise, answer);
Console.WriteLine(why == "" ? "✓" : "✗ " + why);
}
Отговор

Виж го

Целият набор

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

// целият набор: всеки въпрос, отговорът, проверките
static string[] Run(FakeLlm client, string model,
string prompt, List<Case> cases)
{
var why = new string[cases.Count];
for (var i = 0; i < cases.Count; i++)
{
var test = cases[i];
var msg = new Message("user", test.Question);
var r = client.Create(model, 300, prompt, [msg]);
why[i] = Problem(test, r.Text);
}
return why;
}
static int Score(string[] why) => why.Count(reason => reason == "");
static string Tick(bool ok) => ok ? "✓" : "✗";
static void Report(List<Case> cases, string[] why)
{
for (var i = 0; i < cases.Count; i++)
Console.WriteLine(why[i] == "" ? $"✓ {cases[i].Topic}" : $"✗ {cases[i].Topic}: {why[i]}");
Console.WriteLine($"резултат: {Score(why)}/{cases.Count}");
}

С промпт v1:

var 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 (var i = 0; i < cases.Count; i++)
{
var test = cases[i];
bool was = before[i] == "", now = after[i] == "";
var note = "";
if (was && !now)
note = " ← регресия: " + after[i];
if (!was && now) note = " ← поправено";
var must = test.Must ? " (задължителен)" : "";
var line = $"{Tick(was)} {Tick(now)} {test.Topic}";
Console.WriteLine(line + must + note);
}
int n = cases.Count, a = Score(before);
var b = Score(after);
Console.WriteLine($"резултат: {a}/{n} → {b}/{n}");
}

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

// пускаме: задължителните минават, резултатът не пада
static bool Release(List<Case> cases,
string[] before, string[] after)
{
for (var i = 0; i < cases.Count; i++)
if (cases[i].Must && after[i] != "")
return false;
return Score(after) >= Score(before);
}
var before = Run(client, "model-large", v1, cases);
var after = Run(client, "model-large", v2, cases);
Compare(cases, before, after);
Console.WriteLine("пускаме ли: " + (Release(cases, before, after) ? "да" : "не"));
Изход
✓ ✓ връщане
✗ ✓ цена на доставката ← поправено
✓ ✓ срок на доставката
✓ ✗ на едро (задължителен) ← регресия: няма „Не знам“
✓ ✓ къде е поръчката
✓ ✗ до петък (задължителен) ← регресия: има „гарантирам“
✗ ✓ плащане с карта ← поправено
✓ ✓ наложен платеж
резултат: 6/8 → 6/8
пускаме ли: не

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

Виж го

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

// целият набор: всеки въпрос, отговорът, проверките
static string[] Run(FakeLlm client, string model,
string prompt, List<Case> cases)
{
var why = new string[cases.Count];
for (var i = 0; i < cases.Count; i++)
{
var test = cases[i];
var msg = new Message("user", test.Question);
var r = client.Create(model, 300, prompt, [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 изпусна
var v3 = v2 + " Ако отговорът го няма в документите, кажи: „Не знам, ще те свържа с колега.“ Не обещавай дати.";
var before = Run(client, "model-large", v1, cases);
var after = Run(client, "model-large", v3, cases);
Compare(cases, before, after);
Console.WriteLine("пускаме ли: " + (Release(cases, before, after) ? "да" : "не"));
Изход
✓ ✓ връщане
✗ ✓ цена на доставката ← поправено
✓ ✓ срок на доставката
✓ ✓ на едро (задължителен)
✓ ✓ къде е поръчката
✓ ✓ до петък (задължителен)
✗ ✓ плащане с карта ← поправено
✓ ✓ наложен платеж
резултат: 6/8 → 8/8
пускаме ли: да

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

Пробвай

Попълни

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

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

Идеята

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

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

var before = Run(client, "model-large", v3, cases);
var after = Run(client, "model-small", v3, cases);
Compare(cases, before, after);
Console.WriteLine("пускаме ли: " + (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. Адресът и моделът са заместители:

using System.Text;
using System.Text.Json;
// само за показ: адресът и заглавката за ключа са в документацията на доставчика
const string Endpoint = "https://<доставчик>/...";
var http = new HttpClient();
async Task<JsonElement> Ask(string system, string question)
{
var body = JsonSerializer.Serialize(new
{
model = "<името на модела>",
max_tokens = 300,
system,
messages = new[] { new { role = "user", content = question } },
});
var request = new HttpRequestMessage(HttpMethod.Post, Endpoint)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
var key = Environment.GetEnvironmentVariable("LLM_API_KEY");
request.Headers.Add("authorization", "Bearer " + key);
var response = await http.SendAsync(request);
var json = await response.Content.ReadAsStringAsync();
return JsonDocument.Parse(json).RootElement; // JSON: content, stop_reason, usage
}
var reply = await Ask(prompt, test.Question);
var answer = reply.GetProperty("content")[0].GetProperty("text").GetString();

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

  • ти трябва акаунт при доставчик и ключ в променливата на средата LLM_API_KEY. Ключът никога не се пише в кода (урокът „Тайни в кода и в git“);
  • всяко пускане на набора е 8 платени извиквания. Сто случая и десет пускания на ден вече са пари;
  • отговорите не са еднакви всеки път (температурата от урока за следващия токен). Пускай всеки случай няколко пъти и гледай колко пъти минава, не само дали.
JsonSerializer и JsonDocument са от System.Text.Json, а SendAsync е асинхронен, затова await.

Идеята

Кога вреди

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

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

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

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

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

why.Count(reason => reason == "") е LINQ: брои елементите, за които условието е вярно.

Упражнение

Неделята

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

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

List<Case> cases =
[
new("в неделя", "Работите ли в неделя?", "Не знам", "", 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 за достъпно обучение по програмиране ·