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

Прозорецът на контекста

Накратко: Прозорецът на контекста е колко токена моделът вижда в една заявка; моделът няма памет, затова всяка заявка носи системния промпт и историята, а когато прозорецът се напълни, най-старите съобщения отпадат.

Проблем

Асистентът забрави номера

Асистентът на поддръжката води разговор с клиент. В първото съобщение клиентът казва номера на поръчката: 1042. После пита за офис на куриера, за код за отстъпка, за смяна на яке. На петия въпрос („кога точно ще дойде пратката?“) асистентът отговаря: „Кой е номерът на поръчката ви?“

Клиентът се ядосва: той го каза. А в края на месеца сметката за модела е по-голяма, отколкото броят разговори обяснява.

Двата проблема имат една причина. Тя не е в модела, а в това какво програмата му праща.

Идеята

Моделът няма памет

Езиковият модел не помни нищо между две заявки. Той получава текст, пише продължение и забравя. Затова „паметта“ на разговора е в твоята програма: на всеки ход тя праща наново системния промпт и цялата история, всички съобщения досега, плюс новия въпрос.

Моделът вижда само това, което е в заявката, и то до определен брой токени. Тази граница се казва прозорец на контекста (context window). В него влиза всичко: системният промпт, историята, новият въпрос и отговорът, който моделът тепърва пише.

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

Виж го

Разговорът в код

Съобщението е речник с роля (user е клиентът, assistant е моделът) и текст, точно както го искат истинските SDK:

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

# грубо: 4 токена на 3 думи (истинския брой дава токенизаторът)
def tokens(text):
words = len(text.split())
return (words * 4 + 2) // 3
# заявката: системният промпт и цялата история
def request_tokens(system, history):
total = tokens(system)
for message in history:
total += tokens(message["content"])
return total

Разговорът от проблема: пет въпроса на клиента и четири отговора.

system = "Ти си асистентът на поддръжката на онлайн магазина. Отговаряй кратко и учтиво."
customer = [
"Здравейте, поръчка 1042 още не е пристигнала. Поръчах я преди пет дни.",
"Може ли да я оставите в офис на куриера вместо на адреса?",
"Имам и код за отстъпка, който не сработи при плащането. Какво да направя?",
"Благодаря. А якето от същата поръчка ми е малко. Мога ли да го сменя?",
"Добре. И последно: кога точно ще дойде пратката?",
]
assistant = [
"Съжалявам за забавянето. Поръчка 1042 е при куриера и пристига утре до 18 ч.",
"Да, пратката ще чака в офиса на куриера на бул. Витоша 12.",
"Кодът важи над 50 евро. Ще ви върнем разликата до 5 работни дни.",
"Да, смяната е безплатна до 14 дни от получаването. Пишете ни, когато пристигне.",
]

На всеки ход въпросът влиза в историята и цялата история се праща. Цената е примерна: 3 € за милион входни токена.

history = []
total = 0
print("ход заявка общо")
for i, text in enumerate(customer):
history.append({"role": "user", "content": text})
sent = request_tokens(system, history)
total += sent
print(f"{i + 1:3d} {sent:7d} {total:6d}")
if i < len(assistant):
history.append({"role": "assistant", "content": assistant[i]})
price = 3.0 # € за милион входни токена (примерна цена)
one = total * price / 1_000_000
print(f"един разговор: {one:.4f} €, 20000 разговора: {one * 20000:.2f} €")
Изход
ход заявка общо
1 32 32
2 67 99
3 101 200
4 138 338
5 167 505
един разговор: 0.0015 €, 20000 разговора: 30.30 €

Петата заявка е 167 токена, макар че новият въпрос е само 11. Целият разговор е 167 токена, а пратените са 505: първото съобщение е пратено пет пъти. 0,0015 € на разговор не е много. Но цената расте с всеки ход, не само с всеки нов разговор, а 20 000 разговора в месеца вече са 30,30 €.

Идеята

Когато прозорецът се напълни

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

Най-честото правило: пази системния промпт винаги и най-новите съобщения, колкото се побират. Най-старите отпадат. Функцията върви от най-новото съобщение назад и спира при първото, което не се събира:

# от най-новото назад, докато се побира
def fit_window(history, budget):
kept = []
used = 0
for message in reversed(history):
t = tokens(message["content"])
if used + t > budget:
break
kept.insert(0, message)
used += t
return kept

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

history = []
for i, text in enumerate(customer):
history.append({"role": "user", "content": text})
if i < len(assistant):
history.append({"role": "assistant", "content": assistant[i]})
limit = 150
print(f"заявката: {request_tokens(system, history)} токена, прозорецът: {limit}")
window = fit_window(history, limit - tokens(system))
used = request_tokens(system, window)
seen = any("1042" in m["content"] for m in window)
print(f"без бележка: {len(window)} от {len(history)} съобщения, {used} токена")
print(f" 1042 в прозореца: {'да' if seen else 'не'}")
note = "Бележка: поръчка 1042."
window = fit_window(history, limit - tokens(system) - tokens(note))
used = request_tokens(system, window) + tokens(note)
seen = "1042" in note or any("1042" in m["content"] for m in window)
print(f"с бележка: {len(window)} от {len(history)} съобщения, {used} токена")
print(f" 1042 в прозореца: {'да' if seen else 'не'}")
Изход
заявката: 167 токена, прозорецът: 150
без бележка: 7 от 9 съобщения, 132 токена
1042 в прозореца: не
с бележка: 7 от 9 съобщения, 136 токена
1042 в прозореца: да

Без бележка отпадат двете най-стари съобщения, а с тях и номерът 1042. Моделът не може да ползва текст, който не вижда, затова пита наново. Бележката е 4 токена и го връща.

Виж го

Прозорецът се пълни

# от най-новото назад, докато се побира
def fit_window(history, budget):
kept = []
used = 0
for message in reversed(history):
t = tokens(message["content"])
if used + t > budget:
break
kept.insert(0, message)
used += t
return kept
Стъпка по стъпка, с думи
  1. Прозорецът побира 150 токена. Системният промпт е винаги пръв: 16 токена.
  2. Ход 1: клиентът дава номера на поръчката, 1042. Заявката е 32 токена.
  3. Всяка заявка носи цялата история: 67, 101, 138 токена. Пратени досега: 338.
  4. Ход 5: заявката е 167 токена, а прозорецът е 150. Не се побира.
  5. fitWindow тръгва от най-новото назад и спира при първото, което не се побира. Двете най-стари отпадат: остават 132 токена.
  6. Номерът 1042 беше в тях. Моделът вече не го вижда и ще пита за него наново.
  7. Поправката: програмата пази бележка „поръчка 1042“ до системния промпт. 136 токена и номерът се вижда.
  8. Разговорът е 167 токена, а са пратени 505: историята се плаща наново при всеки ход.

Отпадат цели съобщения, от най-старото. Системният промпт и бележката не отпадат никога: програмата ги слага отделно, преди историята.

Пробвай

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

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

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

Пробвай

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

Четири къси съобщения и бюджет от 25 токена. Броенето е същото: 4 токена на 3 думи, закръглено нагоре. Какво ще отпечата програмата?

history = [
{"role": "user", "content": "Поръчах две якета и една шапка онлайн миналия петък сутринта."},
{"role": "user", "content": "Пристигна само едното яке."},
{"role": "assistant", "content": "Шапката и второто яке са в отделна пратка от склада."},
{"role": "user", "content": "Кога идва?"},
]
window = fit_window(history, 25)
print(len(window), window[0]["content"])
Отговор

Пробвай

Попълни

Всяка заявка носи системния промпт. Довърши броенето.

def request_tokens(system, history):
    total = 
    for message in history:
        total += tokens(message["content"])
    return total

Идеята

Какво да пазиш и кога дългият контекст вреди

Начините да се събере разговор в прозореца:

  • Последните n съобщения (плъзгащ прозорец). Най-простото и най-честото. Губи началото.
  • Бележка с фактите, които програмата пази сама: номер на поръчка, адрес, какво вече е обещано. Евтино и точно, но трябва да знаеш кои факти са важни.
  • Резюме на старите ходове, писано от модела. Пази повече, но струва още една заявка и може да изпусне детайл.
  • Търсене вместо всичко. Документите на магазина не се слагат целите в заявката: програмата намира парчетата, които отговарят на въпроса. Това е търсенето по смисъл, основата на RAG, и има свой модул по-нататък в курса.

Кога дългият контекст вреди:

  • Цената и бавността. Всеки токен в заявката се плаща и се чете при всеки ход. Двойно по-дълга история е двойно по-скъпа заявка и по-бавен отговор.
  • Изгубеното в средата. Моделите ползват по-добре началото и края на дълъг контекст, отколкото средата. Факт, заровен по средата на 50 страници, може да бъде пропуснат.
  • Прозорецът не е база данни. Каквото трябва да се помни утре (поръчки, предпочитания), се пази в базата на магазина. В заявката влиза само каквото трябва за този отговор.
  • Чужд текст в контекста. Всичко в прозореца влияе на отговора, включително текст, който не си писал ти. Защо това е опасно, е урокът за prompt injection в модула за агентите.
По-точно, за любопитните

Защо пратеното расте толкова бързо. Нека системният промпт е S токена, а всеки ход добавя m токена история. Заявката на ход k е около S + k·m. Сборът за n хода е n·S + m·(1 + 2 + … + n) = n·S + m·n·(n + 1) / 2. С думи: двойно по-дълъг разговор струва около четири пъти повече пратени токени. Затова дългите чатове се режат или резюмират.

Отговорът също е в прозореца. Ако прозорецът е 200 000 токена (пример), а заявката е 199 000, за отговора остават 1 000. Затова в заявката има граница на отговора (max_tokens, в урока за API) и тя трябва да се събира с историята.

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

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

Кеш на промпта. Доставчиците могат да пазят началото на заявката, което се повтаря (системния промпт, документите), и да го таксуват по-евтино. Как се ползва, е в урока за цената и бавността.

Истинските броячи. Тук токените са думи × 4 / 3. Истинският токенизатор на английски дава около 4 знака на токен, а на български често два или три пъти повече токени за същия смисъл. Отговорът на API-то казва точния брой (usage.input_tokens).

Упражнение

Дълъг разговор

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

Асистентът води разговор от 20 хода. Системният промпт е 200 токена, всеки въпрос на клиента е 32 токена, всеки отговор е 45. Напиши програма, която смята заявката на всеки ход (системният промпт, всички минали въпроси и отговори и новият въпрос) и пратеното общо. Печата всеки пети ход и накрая цената при 3 € за милион входни токена (примерна) за един разговор и за 10 000.

Изход
ход 5: заявка 540, общо 1930
ход 10: заявка 925, общо 5785
ход 15: заявка 1310, общо 11565
ход 20: заявка 1695, общо 19270
един разговор: 0.0578 €, 10000 разговора: 578.10 €

Двадесетата заявка е 1 695 токена, а общо са пратени 19 270, повече от единадесет пъти колкото последната.

Още задачи

  • Прозорец от 1 000 токена. Когато заявката мине 1 000, махай най-стария ход (въпрос и отговор). Колко са пратени общо сега?
  • Бележка вместо история. Вместо цялата история пращай системния промпт, бележка от 60 токена и последните 2 хода. Сравни цената.

Тест

Провери се

1. Клиентът пише втори въпрос на асистента. Какво праща програмата на модела?
2. Какво е прозорецът на контекста?
3. Разговорът е 5 хода, а последната заявка е 167 токена. Защо пратените общо са 505?
4. fitWindow реже историята. Защо тръгва от най-новото съобщение назад?
5. Кое НЕ помага, когато важен факт от началото на разговора отпада от прозореца?

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