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

Промптът е код

Накратко: Промптът е текстът, който дава на модела роля, правила и данни; шаблонът с примери и проверки на отговора живее в кода, в git и в тестовете като всеки друг код.

Проблем

Отговорът, който звучи добре

Клиент пише в чата на магазина: „Кога ще пристигне поръчка 1042?“. Асистентът е свързан с модел като в урока за извикването на API, а системният промпт е един ред:

var client = new FakeClient();
var order = new Order("1042", "изпратена", "03.11");
List<Message> question = [new Message("user", "Кога ще пристигне поръчка 1042?")];
var reply = client.Create("Ти си асистент на магазин.", question);
Console.WriteLine($"промпт: ~{reply.InputTokens} токена");
Console.WriteLine($"отговор: {reply.Text}");
foreach (var line in Check(reply.Text, order))
{
Console.WriteLine($" {line}");
}
Изход
промпт: ~14 токена
отговор: Здравейте! Благодаря, че се свързахте с нас. Обикновено доставката отнема от 2 до 5 работни дни според куриера и адреса. Поръчка 1042 най-вероятно ще пристигне до петък. Ако имате още въпроси, с удоволствие ще помогна!
✗ до 30 думи (35)
✗ датата от данните (03.11)
✓ номерът на поръчката (1042)

Отговорът е учтив, но е 35 думи, когато в чата се събират две изречения. Срокът „от 2 до 5 работни дни“ е общ, а „до петък“ е измислен: в базата поръчката пристига на 03.11. Клиентът ще чака в петък, а после ще пише пак, ядосан.

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

Идеята

Промптът е спецификация

Промптът казва на модела какво да направи, както задачата казва на нов колега. Добрият промпт има пет части:

  1. Роля: кой е моделът („асистент на поддръжката на магазина“).
  2. Правила: колко дълго, на какъв тон, какво не бива.
  3. Данни: всичко, което моделът трябва да знае, сложено от програмата. Тук това е поръчката от базата.
  4. Граница на данните: тагове като <order> и </order> показват къде свършват инструкциите и къде започват данните.
  5. Какво прави, когато не знае: „ако данните не стигат, предай на колега“. Без това правило моделът запълва празното с измислица.

Промптът се сглобява от функция с параметри, а не се пише на ръка за всеки въпрос. Тази функция е шаблон на промпта (prompt template). Писането и изпитването на такива промпти е prompt engineering: промптът е код, който моделът изпълнява.

Виж го

Шаблонът

Шаблонът взима поръчката и връща системния промпт:

static string SupportPrompt(Order order) => $"""
Ти си асистент на поддръжката на магазина.
Отговаряй на „Вие“, с най-много две изречения.
Ползвай само данните между <order> и </order>.
Ако данните не стигат, предай на колега.
Не обещавай срокове извън данните.
<order>
номер: {order.Id}
статус: {order.Status}
очаквана доставка: {order.Eta}
</order>
""";
var order = new Order("1042", "изпратена", "03.11");
var system = SupportPrompt(order);
Console.WriteLine(system);
Console.WriteLine($"~{FakeClient.Tokens(system)} токена");
Изход
Ти си асистент на поддръжката на магазина.
Отговаряй на „Вие“, с най-много две изречения.
Ползвай само данните между <order> и </order>.
Ако данните не стигат, предай на колега.
Не обещавай срокове извън данните.
<order>
номер: 1042
статус: изпратена
очаквана доставка: 03.11
</order>
~55 токена

Данните не идват от модела, а от програмата: статусът и датата са прочетени от базата. Ако поръчката утре смени статуса си, промптът се сменя с нея.

Цената: неясният промпт беше около 7 токена, този е около 55. Всеки въпрос в чата плаща тези токени наново, защото API-то няма памет и промптът тръгва с всяка заявка. Какво е токен и защо цената е в токени, видя в урока за токените.

Виж го

Един въпрос, два промпта

var vague = "Ти си асистент на магазин.";
string[] prompts = [vague, SupportPrompt(order)];
foreach (var system in prompts)
{
var reply = client.Create(system, question);
Console.WriteLine($"~{reply.InputTokens} токена");
Console.WriteLine(reply.Text);
foreach (var line in Check(reply.Text, order))
{
Console.WriteLine($" {line}");
}
}
Стъпка по стъпка, с думи
  1. Един въпрос от клиент: „Кога ще пристигне поръчка 1042?“ Първо неясният промпт: един ред, без данни, ~14 токена.
  2. Отговорът е учтив и дълъг. „До петък“ е измислено: в промпта няма дата, от която моделът да вземе срока.
  3. Програмата проверява отговора: 35 думи, няма датата от данните. Минава 1 от 3.
  4. Шаблонът: кой е моделът, колко дълго да отговаря, какво може да ползва и какво да прави, когато данните не стигат.
  5. Данните на поръчката идват от програмата, между <order> и </order>. Промптът вече е ~62 токена вместо ~14.
  6. Същият въпрос, новият промпт: едно изречение с датата 03.11.
  7. Неясният промпт минава 1 проверка от 3, ясният 3 от 3. Ясният промпт е по-дълъг, но отговорът му може да се провери.

Програмата праща един и същ въпрос с двата промпта и проверява всеки отговор с три правила: дължина, датата от данните и номерът на поръчката:

static List<string> Check(string answer, Order order)
{
var words = answer.Split((char[])null, StringSplitOptions.RemoveEmptyEntries).Length;
return
[
$"{Mark(words <= 30)} до 30 думи ({words})",
$"{Mark(answer.Contains(order.Eta))} датата от данните ({order.Eta})",
$"{Mark(answer.Contains(order.Id))} номерът на поръчката ({order.Id})",
];
}
static string Mark(bool ok) => ok ? "✓" : "✗";
var client = new FakeClient();
var order = new Order("1042", "изпратена", "03.11");
List<Message> question = [new Message("user", "Кога ще пристигне поръчка 1042?")];
var vague = "Ти си асистент на магазин.";
string[] prompts = [vague, SupportPrompt(order)];
foreach (var system in prompts)
{
var reply = client.Create(system, question);
Console.WriteLine($"~{reply.InputTokens} токена");
Console.WriteLine(reply.Text);
foreach (var line in Check(reply.Text, order))
{
Console.WriteLine($" {line}");
}
}
Изход
~14 токена
Здравейте! Благодаря, че се свързахте с нас. Обикновено доставката отнема от 2 до 5 работни дни според куриера и адреса. Поръчка 1042 най-вероятно ще пристигне до петък. Ако имате още въпроси, с удоволствие ще помогна!
✗ до 30 думи (35)
✗ датата от данните (03.11)
✓ номерът на поръчката (1042)
~62 токена
Поръчка 1042 е изпратена и се очаква да пристигне на 03.11.
✓ до 30 думи (11)
✓ датата от данните (03.11)
✓ номерът на поръчката (1042)

Неясният промпт минава 1 проверка от 3, ясният 3 от 3. Проверките са код: те не казват дали отговорът е хубав, а дали спазва правилата, които можеш да провериш. Как такива проверки стават тестов набор за цялото приложение, е в урока за оценяването.

Честно за модела в програмите. Тук моделът е фалшив клиент: същата форма като истинското API (системен промпт, съобщения, отговор и токени), но отговорите са записани. Правилото му е грубо: ако в промпта има данни за поръчка, отговаря от тях; ако няма, връща типичния общ отговор. Така програмата върви без мрежа и дава едно и също всеки път. Истински модел ще даде други думи при всяко пускане, но разликата между двата промпта е същата. Кодът на фалшивия клиент е в „По-точно“ най-долу.

Пробвай

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

Колега преправя шаблона за въпроса „къде са ми поръчките?“ и подава всички поръчки от базата. Тестовете минават: отговорите са кратки и с правилните дати. На кой ред е грешката?

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

Идеята

Примери вместо обяснения

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

Можеш да обясниш формата с думи („отговори само с категорията“). По-сигурно е да го покажеш: няколко съобщения от клиенти, всяко с отговора, който очакваш. Те стоят в историята на разговора преди истинското писмо, като предишни реплики на модела.

Това се казва примери в промпта (few-shot). Моделът продължава текста по образеца, който вижда, затова примерите държат формата по-добре от обясненията. От урока за следващия токен знаеш защо: моделът дава вероятност на продължението, а примерите правят точния формат най-вероятен.

Виж го

Три примера, четири писма

Инструкцията и разрешените категории:

static class Triage
{
public const string Prompt = "Определи темата на съобщението от клиента. "
+ "Категории: доставка, връщане, плащане, друго. "
+ "Отговори само с категорията.";
public static readonly string[] Labels = ["доставка", "връщане", "плащане", "друго"];
}

Трите примера са двойки съобщения: клиентът (user) и отговорът, който искаме (assistant). Последното съобщение е истинското писмо:

static List<Message> WithExamples(string message) =>
[
new("user", "Пратката ми още не е дошла, а мина седмица."),
new("assistant", "доставка"),
new("user", "Искам да върна обувките, малки са."),
new("assistant", "връщане"),
new("user", "Картата ми е таксувана два пъти."),
new("assistant", "плащане"),
new("user", message),
];

Програмата приема само точна категория. Позволява малко шум: интервали, главна буква и точка накрая:

static string ParseLabel(string answer)
{
var label = answer.Trim().ToLowerInvariant().TrimEnd('.');
return Triage.Labels.Contains(label) ? label : null;
}

Същите четири писма без примери и с примери:

var client = new FakeClient();
string[] inbox =
[
"Куриерът не ме намери на адреса.",
"Как да върна якето, ако съм махнал етикета?",
"Плащането с карта не минава.",
"Имате ли магазин в Бургас?",
];
foreach (var examples in new[] { false, true })
{
Console.WriteLine(examples ? "с примери:" : "без примери:");
var parsed = 0;
foreach (var message in inbox)
{
List<Message> messages = examples ? WithExamples(message) : [new Message("user", message)];
var answer = client.Create(Triage.Prompt, messages).Text;
var label = ParseLabel(answer);
if (label != null)
{
parsed++;
}
Console.WriteLine($" \"{answer}\" → {label ?? "?"}");
}
Console.WriteLine($" разпознати: {parsed} от {inbox.Length}");
}
Изход
без примери:
"Категория: доставка" → ?
"Връщане на стока" → ?
"Плащане." → плащане
"Това не е от категориите, бих казал „друго“." → ?
разпознати: 1 от 4
с примери:
"доставка" → доставка
"връщане" → връщане
"плащане" → плащане
"друго" → друго
разпознати: 4 от 4

Без примери моделът разбира темата, но я обвива в думи и програмата разчита само „Плащане.“. С три примера отговорите са точно в нужния формат: 4 от 4.

Пробвай

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

Какво печата ParseLabel за тези три отговора?

foreach (var answer in new[] { " Доставка.", "Категория: доставка", "връщане" })
{
Console.WriteLine(ParseLabel(answer) ?? "null");
}
Отговор

Пробвай

Попълни

Примерът е двойка съобщения. Каква е ролята на втория ред, отговорът, който искаме да видим?

new("user", "Пратката ми още не е дошла, а мина седмица."),
new("", "доставка"),

Идеята

Промптът е код

Една дума в промпта сменя поведението на цялото приложение, точно като ред в кода. Затова и с него се отнасяш като с код:

  • В кода, не в настройките. Шаблонът е функция или константа в проекта, а не текст, който някой пипа на сървъра. Така е в git: виждаш кой какво е сменил и можеш да върнеш старата версия.
  • Данните отделно от инструкциите. Правилата са в системния промпт, данните между тагове, а въпросът на клиента е отделно съобщение с роля user. Текстът на клиента никога не се лепи в инструкциите.
  • Проверка преди пускане. Смяната на промпта, която оправя един отговор, може да развали друг. Набор от въпроси с проверки, който пускаш при всяка промяна, е в урока за оценяването.
  • Каквото може кодът, го прави кодът. Датата на доставка, сумата и срокът за връщане се смятат в програмата и влизат готови в промпта. Моделът ги преразказва, не ги смята.

Отговорът на истински модел се мени от пускане на пускане. Затова проверките гледат правила (дължина, дата, формат), а не точни думи.

Идеята

Кога вреди

  • Дългият промпт се плаща всеки път. 55 токена по 10 000 въпроса на ден са 550 000 токена само за промпта. Сложи в промпта това, което този въпрос иска, а не цялото ръководство на магазина.
  • Много правила се бият. „Отговаряй кратко“ и „обяснявай подробно условията“ в един промпт дават случаен избор между двете. Когато правилата станат двайсет, моделът спазва част от тях.
  • Примерите дърпат. Ако и трите примера са за доставка, моделът по-често казва „доставка“. Примерите трябва да покриват всички случаи и да не са всички еднакви.
  • Промптът не е ключалка. „Никога не казвай чужди данни“ е молба, не защита. Каквото е в промпта, може да излезе навън, затова в него влиза само това, което този човек може да види. Текстът от клиента е недоверен вход; Как се пази приложение от инструкции, скрити в него, е урокът за prompt injection в модула „Агенти“.
  • Промптът не поправя лоши данни. Ако в базата датата е грешна, най-добрият промпт ще я повтори точно.
По-точно, за любопитните

Истинското извикване (пусни при теб). Тази програма не е в проверката на урока: трябва ти профил при доставчик на модели, ключ и пари за всяко извикване. Ключът е в променливата на средата LLM_API_KEY, никога в кода. Защо, е в урока за тайните извън кода. Доставчиците имат официални SDK-та за Java, C#, Python и JavaScript със същата форма (съобщения, роли, токени, инструменти); точните имена на класовете и методите са в документацията на доставчика. Ето заявката като JSON, с общите имена на полетата и заместител на името на модела:

{
"model": "<името на модела>",
"max_tokens": 1024,
"system": "Ти си асистент на поддръжката на магазина.\nОтговаряй на „Вие“, с най-много две изречения.\nПолзвай само данните между <order> и </order>.\nАко данните не стигат, предай на колега.\nНе обещавай срокове извън данните.\n\n<order>\nномер: 1042\nстатус: изпратена\nочаквана доставка: 03.11\n</order>",
"messages": [
{
"role": "user",
"content": "Кога ще пристигне поръчка 1042?"
}
]
}

Примерен отговор: блоковете в content, stop_reason и токените в usage:

{
"content": [
{
"type": "text",
"text": "Поръчка 1042 е изпратена и се очаква да пристигне на 03.11."
}
],
"stop_reason": "end_turn",
"usage": {"input_tokens": 62, "output_tokens": 15}
}

Без SDK заявката се праща със стандартния HTTP клиент на езика. Адресът е заместител:

// requestJson е JSON-ът на заявката отгоре.
// Точната заглавка за ключа е в документацията на доставчика.
using var http = new HttpClient();
var key = Environment.GetEnvironmentVariable("LLM_API_KEY");
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + key);
var content = new StringContent(requestJson, Encoding.UTF8, "application/json");
var response = await http.PostAsync("https://<доставчик>/messages", content);
Console.WriteLine(await response.Content.ReadAsStringAsync());

Разликата от урока за извикването е само в system: вместо ред текст е резултатът от шаблона.

Фалшивият клиент отвътре. Той пази формата на заявката и на отговора. Токените брои грубо: думите × 1,3, закръглено нагоре. Истинският брой зависи от токенизатора и е различен за български и английски.

// Стои на мястото на истинския модел: връща записан отговор,
// без мрежа и всеки път един и същ.
class FakeClient
{
const string Vague = "Здравейте! Благодаря, че се свързахте с нас. Обикновено доставката отнема от 2 до 5 работни дни според куриера и адреса. Поръчка 1042 най-вероятно ще пристигне до петък. Ако имате още въпроси, с удоволствие ще помогна!";
static readonly Dictionary<string, string> Verbose = new()
{
["доставка"] = "Категория: доставка",
["връщане"] = "Връщане на стока",
["плащане"] = "Плащане.",
["друго"] = "Това не е от категориите, бих казал „друго“.",
};
public Reply Create(string system, List<Message> messages)
{
var text = Answer(system, messages);
var input = Tokens(system) + messages.Sum(m => Tokens(m.Content));
return new Reply(text, "end_turn", input, Tokens(text));
}
// Грубо: думите × 1,3, закръглено нагоре.
public static int Tokens(string text)
{
var words = text.Split((char[])null, StringSplitOptions.RemoveEmptyEntries).Length;
return (words * 13 + 9) / 10;
}
string Answer(string system, List<Message> messages)
{
var last = messages[^1].Content;
if (system.Contains("Категории:"))
{
var label = LabelOf(last);
return messages.Count > 1 ? label : Verbose[label];
}
var eta = Field(system, "очаквана доставка: ");
if (eta == null)
{
return Vague;
}
return $"Поръчка {Field(system, "номер: ")} е "
+ $"{Field(system, "статус: ")} и се очаква да пристигне на {eta}.";
}
static string LabelOf(string message)
{
var m = message.ToLowerInvariant();
if (m.Contains("куриер") || m.Contains("пратка")) return "доставка";
if (m.Contains("върна")) return "връщане";
if (m.Contains("карта") || m.Contains("плащане")) return "плащане";
return "друго";
}
static string Field(string system, string name) =>
system.Split('\n').Where(line => line.StartsWith(name))
.Select(line => line[name.Length..]).FirstOrDefault();
}

Защо тагове. <order> и </order> не са специален синтаксис. Моделът е виждал много текст с такива тагове и разбира къде започва и къде свършва блокът. Със същия успех работят ### или кавички, стига да са едни и същи навсякъде.

„Мисли стъпка по стъпка“. За задачи с няколко стъпки (сметка, сравнение) помага моделът първо да напише разсъжденията си и после отговора. Това струва още токени и време, а отговорът трябва да се отдели от разсъжденията, преди да стигне до програмата. Структурираният изход от следващия урок прави това отделяне надеждно.

Суровият низ $""" в C# маха отстъпа до затварящите кавички, а {order.Id} се попълва на място. Затварящите """ на отделен ред значи, че текстът не завършва с нов ред.

Упражнение

Шаблон за връщане

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

Напиши шаблон ReturnPrompt(order) за въпросите за връщане. Поръчката има номер, дата на доставка (или няма, ако още не е доставена) и сума в евро. Шаблонът има три правила и данните между <order> и </order>. Ако датата я няма, в данните пише „още не е доставена“: кодът решава какво да каже, а не моделът. Пусни го за двете поръчки и отпечатай --- след всеки промпт.

Изход
Ти си асистент на поддръжката на онлайн магазин.
Клиентът може да върне поръчката до 14 дни след доставката.
Ползвай само данните между <order> и </order>.
<order>
номер: 1187
доставена на: 21.10
сума: 89.90 €
</order>
---
Ти си асистент на поддръжката на онлайн магазин.
Клиентът може да върне поръчката до 14 дни след доставката.
Ползвай само данните между <order> и </order>.
<order>
номер: 1203
доставена на: още не е доставена
сума: 34.50 €
</order>
---

Още задачи

  • Срокът в кода. Добави в данните ред „може да се върне до:“ с датата 14 дни след доставката, сметната в програмата.
  • Още една проверка. Добави в Check правило „без обещание за ден от седмицата“: отговорът не съдържа „понеделник“, „вторник“ и т.н. до „неделя“. Колко проверки минава тогава неясният отговор?

Тест

Провери се

1. Асистентът обещава „до петък“, а в базата датата е 03.11. Коя поправка е правилната?
2. Програмата чака една от четирите категории, а моделът връща „Категория: доставка“. Какво помага най-много?
3. Шаблонът слага в промпта поръчките на всички клиенти, за да е „по-пълен“. Какъв е проблемът?
4. Защо промптът се пази в кода, а не се пише на ръка в настройките на сървъра?

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