Промптът е код
Накратко: Промптът е текстът, който дава на модела роля, правила и данни; шаблонът с примери и проверки на отговора живее в кода, в git и в тестовете като всеки друг код.
Проблем
Отговорът, който звучи добре
Клиент пише в чата на магазина: „Кога ще пристигне поръчка 1042?“. Асистентът е свързан с модел като в урока за извикването на API, а системният промпт е един ред:
FakeClient client = new FakeClient();Order order = new Order("1042", "изпратена", "03.11");List<Message> question = List.of(new Message("user", "Кога ще пристигне поръчка 1042?"));Reply reply = client.create("Ти си асистент на магазин.", question);System.out.println("промпт: ~" + reply.inputTokens() + " токена");System.out.println("отговор: " + reply.text());for (String line : check(reply.text(), order)) { System.out.println(" " + line);}промпт: ~14 токенаотговор: Здравейте! Благодаря, че се свързахте с нас. Обикновено доставката отнема от 2 до 5 работни дни според куриера и адреса. Поръчка 1042 най-вероятно ще пристигне до петък. Ако имате още въпроси, с удоволствие ще помогна! ✗ до 30 думи (35) ✗ датата от данните (03.11) ✓ номерът на поръчката (1042)Отговорът е учтив, но е 35 думи, когато в чата се събират две изречения. Срокът „от 2 до 5 работни дни“ е общ, а „до петък“ е измислен: в базата поръчката пристига на 03.11. Клиентът ще чака в петък, а после ще пише пак, ядосан.
Моделът не е сбъркал нищо, което може. Той не знае нищо за поръчка 1042 и дописва най-правдоподобния текст, както в урока за границите на модела. Проблемът е в промпта.
Идеята
Промптът е спецификация
Промптът казва на модела какво да направи, както задачата казва на нов колега. Добрият промпт има пет части:
- Роля: кой е моделът („асистент на поддръжката на магазина“).
- Правила: колко дълго, на какъв тон, какво не бива.
- Данни: всичко, което моделът трябва да знае, сложено от програмата. Тук това е поръчката от базата.
- Граница на данните: тагове като
<order>и</order>показват къде свършват инструкциите и къде започват данните. - Какво прави, когато не знае: „ако данните не стигат, предай на колега“. Без това правило моделът запълва празното с измислица.
Промптът се сглобява от функция с параметри, а не се пише на ръка за всеки въпрос. Тази функция е шаблон на промпта (prompt template). Писането и изпитването на такива промпти е prompt engineering: промптът е код, който моделът изпълнява.
Виж го
Шаблонът
Шаблонът взима поръчката и връща системния промпт:
static String supportPrompt(Order order) { return """ Ти си асистент на поддръжката на магазина. Отговаряй на „Вие“, с най-много две изречения. Ползвай само данните между <order> и </order>. Ако данните не стигат, предай на колега. Не обещавай срокове извън данните.
<order> номер: %s статус: %s очаквана доставка: %s </order>""" .formatted(order.id(), order.status(), order.eta());}Order order = new Order("1042", "изпратена", "03.11");String system = supportPrompt(order);System.out.println(system);System.out.println("~" + FakeClient.tokens(system) + " токена");Ти си асистент на поддръжката на магазина.Отговаряй на „Вие“, с най-много две изречения.Ползвай само данните между <order> и </order>.Ако данните не стигат, предай на колега.Не обещавай срокове извън данните.
<order>номер: 1042статус: изпратенаочаквана доставка: 03.11</order>~55 токенаДанните не идват от модела, а от програмата: статусът и датата са прочетени от базата. Ако поръчката утре смени статуса си, промптът се сменя с нея.
Цената: неясният промпт беше около 7 токена, този е около 55. Всеки въпрос в чата плаща тези токени наново, защото API-то няма памет и промптът тръгва с всяка заявка. Какво е токен и защо цената е в токени, видя в урока за токените.
Виж го
Един въпрос, два промпта
String vague = "Ти си асистент на магазин.";List<String> prompts = List.of(vague, supportPrompt(order));for (String system : prompts) { Reply reply = client.create(system, question); System.out.println("~" + reply.inputTokens() + " токена"); System.out.println(reply.text()); for (String line : check(reply.text(), order)) { System.out.println(" " + line); }}static String supportPrompt(Order order) { return """ Ти си асистент на поддръжката на магазина. Отговаряй на „Вие“, с най-много две изречения. Ползвай само данните между <order> и </order>. Ако данните не стигат, предай на колега. Не обещавай срокове извън данните.
<order> номер: %s статус: %s очаквана доставка: %s </order>""" .formatted(order.id(), order.status(), order.eta());}Стъпка по стъпка, с думи
- Един въпрос от клиент: „Кога ще пристигне поръчка 1042?“ Първо неясният промпт: един ред, без данни, ~14 токена.
- Отговорът е учтив и дълъг. „До петък“ е измислено: в промпта няма дата, от която моделът да вземе срока.
- Програмата проверява отговора: 35 думи, няма датата от данните. Минава 1 от 3.
- Шаблонът: кой е моделът, колко дълго да отговаря, какво може да ползва и какво да прави, когато данните не стигат.
- Данните на поръчката идват от програмата, между <order> и </order>. Промптът вече е ~62 токена вместо ~14.
- Същият въпрос, новият промпт: едно изречение с датата 03.11.
- Неясният промпт минава 1 проверка от 3, ясният 3 от 3. Ясният промпт е по-дълъг, но отговорът му може да се провери.
Програмата праща един и същ въпрос с двата промпта и проверява всеки отговор с три правила: дължина, датата от данните и номерът на поръчката:
static List<String> check(String answer, Order order) { int words = answer.strip().split("\\s+").length; return List.of( mark(words <= 30) + " до 30 думи (" + words + ")", mark(answer.contains(order.eta())) + " датата от данните (" + order.eta() + ")", mark(answer.contains(order.id())) + " номерът на поръчката (" + order.id() + ")");}
static String mark(boolean ok) { return ok ? "✓" : "✗";}FakeClient client = new FakeClient();Order order = new Order("1042", "изпратена", "03.11");List<Message> question = List.of(new Message("user", "Кога ще пристигне поръчка 1042?"));String vague = "Ти си асистент на магазин.";List<String> prompts = List.of(vague, supportPrompt(order));for (String system : prompts) { Reply reply = client.create(system, question); System.out.println("~" + reply.inputTokens() + " токена"); System.out.println(reply.text()); for (String line : check(reply.text(), order)) { System.out.println(" " + line); }}~14 токенаЗдравейте! Благодаря, че се свързахте с нас. Обикновено доставката отнема от 2 до 5 работни дни според куриера и адреса. Поръчка 1042 най-вероятно ще пристигне до петък. Ако имате още въпроси, с удоволствие ще помогна! ✗ до 30 думи (35) ✗ датата от данните (03.11) ✓ номерът на поръчката (1042)~62 токенаПоръчка 1042 е изпратена и се очаква да пристигне на 03.11. ✓ до 30 думи (11) ✓ датата от данните (03.11) ✓ номерът на поръчката (1042)Неясният промпт минава 1 проверка от 3, ясният 3 от 3. Проверките са код: те не казват дали отговорът е хубав, а дали спазва правилата, които можеш да провериш. Как такива проверки стават тестов набор за цялото приложение, е в урока за оценяването.
Честно за модела в програмите. Тук моделът е фалшив клиент: същата форма като истинското API (системен промпт, съобщения, отговор и токени), но отговорите са записани. Правилото му е грубо: ако в промпта има данни за поръчка, отговаря от тях; ако няма, връща типичния общ отговор. Така програмата върви без мрежа и дава едно и също всеки път. Истински модел ще даде други думи при всяко пускане, но разликата между двата промпта е същата. Кодът на фалшивия клиент е в „По-точно“ най-долу.
Пробвай
Намери грешката
Колега преправя шаблона за въпроса „къде са ми поръчките?“ и подава всички поръчки от базата. Тестовете минават: отговорите са кратки и с правилните дати. На кой ред е грешката?
Избери реда с грешката.
Цикълът минава през поръчките на всички клиенти, а customer изобщо не се ползва. Каквото е в промпта, моделът може да го каже: клиентът пита „а другата ми поръчка?“ и чува номера и статуса на чужда. Поправката е в кода, не в правило „не казвай чужди данни“: в цикъла влизат само поръчките на този клиент. Колкото по-малко данни има в промпта, толкова по-малко може да изтече, а и промптът е по-евтин.
Идеята
Примери вместо обяснения
Понякога отговорът не отива при клиент, а в кода. Писмата в пощата на поддръжката трябва да се разпределят по тема: доставка, връщане, плащане или друго. Програмата чака точно една от четирите думи, за да сложи писмото в правилната опашка.
Можеш да обясниш формата с думи („отговори само с категорията“). По-сигурно е да го покажеш: няколко съобщения от клиенти, всяко с отговора, който очакваш. Те стоят в историята на разговора преди истинското писмо, като предишни реплики на модела.
Това се казва примери в промпта (few-shot). Моделът продължава текста по образеца, който вижда, затова примерите държат формата по-добре от обясненията. От урока за следващия токен знаеш защо: моделът дава вероятност на продължението, а примерите правят точния формат най-вероятен.
Виж го
Три примера, четири писма
Инструкцията и разрешените категории:
static final String SYSTEM = "Определи темата на съобщението от клиента. " + "Категории: доставка, връщане, плащане, друго. " + "Отговори само с категорията.";static final List<String> LABELS = List.of("доставка", "връщане", "плащане", "друго");Трите примера са двойки съобщения: клиентът (user) и отговорът, който искаме (assistant). Последното съобщение е истинското писмо:
static List<Message> withExamples(String message) { return List.of( new Message("user", "Пратката ми още не е дошла, а мина седмица."), new Message("assistant", "доставка"), new Message("user", "Искам да върна обувките, малки са."), new Message("assistant", "връщане"), new Message("user", "Картата ми е таксувана два пъти."), new Message("assistant", "плащане"), new Message("user", message));}Програмата приема само точна категория. Позволява малко шум: интервали, главна буква и точка накрая:
static String parseLabel(String answer) { String label = answer.strip().toLowerCase(Locale.ROOT); if (label.endsWith(".")) { label = label.substring(0, label.length() - 1); } return LABELS.contains(label) ? label : null;}Същите четири писма без примери и с примери:
FakeClient client = new FakeClient();String[] inbox = { "Куриерът не ме намери на адреса.", "Как да върна якето, ако съм махнал етикета?", "Плащането с карта не минава.", "Имате ли магазин в Бургас?",};for (boolean examples : new boolean[] {false, true}) { System.out.println(examples ? "с примери:" : "без примери:"); int parsed = 0; for (String message : inbox) { List<Message> messages = examples ? withExamples(message) : List.of(new Message("user", message)); String answer = client.create(SYSTEM, messages).text(); String label = parseLabel(answer); if (label != null) { parsed++; } System.out.println(" \"" + answer + "\" → " + (label == null ? "?" : label)); } System.out.println(" разпознати: " + parsed + " от " + inbox.length);}без примери: "Категория: доставка" → ? "Връщане на стока" → ? "Плащане." → плащане "Това не е от категориите, бих казал „друго“." → ? разпознати: 1 от 4с примери: "доставка" → доставка "връщане" → връщане "плащане" → плащане "друго" → друго разпознати: 4 от 4Без примери моделът разбира темата, но я обвива в думи и програмата разчита само „Плащане.“. С три примера отговорите са точно в нужния формат: 4 от 4.
Пробвай
Предвиди изхода
Какво печата parseLabel за тези три отговора?
for (String answer : new String[] {" Доставка.", "Категория: доставка", "връщане"}) { System.out.println(parseLabel(answer));}„ Доставка.“ минава: интервалите се махат, буквите стават малки, точката накрая се реже. „Категория: доставка“ съдържа категорията, но не е категория, затова е null. Програмата не гадае какво е искал да каже моделът. Отговор, който не се разчита, отива при човек.
Пробвай
Попълни
Примерът е двойка съобщения. Каква е ролята на втория ред, отговорът, който искаме да видим?
new Message("user", "Пратката ми още не е дошла, а мина седмица."),
new Message("", "доставка"),assistant. Примерът се преструва на предишен отговор на модела. Ако и двата реда са user, моделът вижда два въпроса от клиента и нищо, което да копира.
Идеята
Промптът е код
Една дума в промпта сменя поведението на цялото приложение, точно като ред в кода. Затова и с него се отнасяш като с код:
- В кода, не в настройките. Шаблонът е функция или константа в проекта, а не текст, който някой пипа на сървъра. Така е в 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-ът на заявката отгоре.// Точната заглавка за ключа е в документацията на доставчика.HttpClient http = HttpClient.newHttpClient();HttpRequest request = HttpRequest.newBuilder(URI.create("https://<доставчик>/messages")) .header("Authorization", "Bearer " + System.getenv("LLM_API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(requestJson)) .build();HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());System.out.println(response.body());Разликата от урока за извикването е само в system: вместо ред текст е резултатът от шаблона.
Фалшивият клиент отвътре. Той пази формата на заявката и на отговора. Токените брои грубо: думите × 1,3, закръглено нагоре. Истинският брой зависи от токенизатора и е различен за български и английски.
// Стои на мястото на истинския модел: връща записан отговор,// без мрежа и всеки път един и същ.class FakeClient { static final String VAGUE = "Здравейте! Благодаря, че се свързахте с нас. Обикновено доставката отнема от 2 до 5 работни дни според куриера и адреса. Поръчка 1042 най-вероятно ще пристигне до петък. Ако имате още въпроси, с удоволствие ще помогна!"; static final Map<String, String> VERBOSE = Map.of( "доставка", "Категория: доставка", "връщане", "Връщане на стока", "плащане", "Плащане.", "друго", "Това не е от категориите, бих казал „друго“.");
Reply create(String system, List<Message> messages) { String text = answer(system, messages); int input = tokens(system); for (Message m : messages) { input += tokens(m.content()); } return new Reply(text, "end_turn", input, tokens(text)); }
// Грубо: думите × 1,3, закръглено нагоре. static int tokens(String text) { String t = text.strip(); int words = t.isEmpty() ? 0 : t.split("\\s+").length; return (words * 13 + 9) / 10; }
private String answer(String system, List<Message> messages) { String last = messages.get(messages.size() - 1).content(); if (system.contains("Категории:")) { String label = labelOf(last); return messages.size() > 1 ? label : VERBOSE.get(label); } String eta = field(system, "очаквана доставка: "); if (eta == null) { return VAGUE; } return "Поръчка " + field(system, "номер: ") + " е " + field(system, "статус: ") + " и се очаква да пристигне на " + eta + "."; }
private static String labelOf(String message) { String m = message.toLowerCase(Locale.ROOT); if (m.contains("куриер") || m.contains("пратка")) return "доставка"; if (m.contains("върна")) return "връщане"; if (m.contains("карта") || m.contains("плащане")) return "плащане"; return "друго"; }
private static String field(String system, String name) { for (String line : system.split("\n")) { if (line.startsWith(name)) { return line.substring(name.length()); } } return null; }}Защо тагове. <order> и </order> не са специален синтаксис. Моделът е виждал много текст с такива тагове и разбира къде започва и къде свършва блокът. Със същия успех работят ### или кавички, стига да са едни и същи навсякъде.
„Мисли стъпка по стъпка“. За задачи с няколко стъпки (сметка, сравнение) помага моделът първо да напише разсъжденията си и после отговора. Това струва още токени и време, а отговорът трябва да се отдели от разсъжденията, преди да стигне до програмата. Структурираният изход от следващия урок прави това отделяне надеждно.
Текстовият блок""" в Java маха общия отстъп на редовете, а formatted попълва %s по ред. Ако шаблонът расте, именувани полета ({order}) с replace се четат по-добре от десет %s.Упражнение
Шаблон за връщане
Реши задачата в редактора си.
Напиши шаблон returnPrompt(order) за въпросите за връщане. Поръчката има номер, дата на доставка (или няма, ако още не е доставена) и сума в евро. Шаблонът има три правила и данните между <order> и </order>. Ако датата я няма, в данните пише „още не е доставена“: кодът решава какво да каже, а не моделът. Пусни го за двете поръчки и отпечатай --- след всеки промпт.
Ти си асистент на поддръжката на онлайн магазин.Клиентът може да върне поръчката до 14 дни след доставката.Ползвай само данните между <order> и </order>.
<order>номер: 1187доставена на: 21.10сума: 89.90 €</order>---Ти си асистент на поддръжката на онлайн магазин.Клиентът може да върне поръчката до 14 дни след доставката.Ползвай само данните между <order> и </order>.
<order>номер: 1203доставена на: още не е доставенасума: 34.50 €</order>---Още задачи
- Срокът в кода. Добави в данните ред „може да се върне до:“ с датата 14 дни след доставката, сметната в програмата.
- Още една проверка. Добави в
checkправило „без обещание за ден от седмицата“: отговорът не съдържа „понеделник“, „вторник“ и т.н. до „неделя“. Колко проверки минава тогава неясният отговор?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Предсказване на следващия токен · Прозорецът на контекста · Границите на модела · Извикване на модел през API
- Продължава в: Структуриран изход: JSON по схема · Инструменти: моделът пита, програмата прави · Оценяване: тестов набор за асистента · RAG: отговор от документите
- Задачи по темата: Приложения с LLM