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

Извикване на модел през API

Накратко: Извикването на езиков модел през API е заявка с модел, системен промпт и съобщения с роли; отговорът носи текста, причината за спиране и броя токени.

Проблем

Асистентът излиза пред клиентите

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

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

Как изглежда една заявка, какво връща моделът и какво правиш, когато отговорът е грешка?

Идеята

Заявка и отговор

Извикването на модел е HTTP заявка с JSON. Заявката носи:

  • model: кой модел;
  • max_tokens: най-много колко токена да е отговорът;
  • system: системният промпт, инструкцията за ролята и тона;
  • messages: съобщенията с ролите им, user (клиентът) и assistant (предишните отговори на модела).

Отговорът носи:

  • content: списък от блокове с type; текстът е в блок text;
  • stop_reason: защо моделът спря, end_turn (свърши) или max_tokens (стигна тавана);
  • usage: колко токена влязоха (input_tokens) и колко излязоха (output_tokens). По тях се плаща.

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

Виж го

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

Програмите в урока не викат истински модел: проверката им няма мрежа, а истинският отговор е различен всеки път (урокът за границите). Затова вместо клиента на доставчика има заместител със същата форма: взима model, max_tokens, system и messages и връща content, stop_reason и usage. Отговорите са записани предварително и се избират по дума в последното съобщение. Токените са преброени грубо, 4 на 3 думи.

@dataclass
class Block:
type: str
text: str
@dataclass
class Usage:
input_tokens: int
output_tokens: int
@dataclass
class Response:
content: list
stop_reason: str
usage: Usage
class ApiError(Exception):
def __init__(self, status, message):
super().__init__(message)
self.status = status
# заместител на истинския клиент: записани отговори, без мрежа
class FakeClient:
def __init__(self, failures=()):
self.failures = list(failures)
def create(self, model, max_tokens, system, messages):
if self.failures:
status = self.failures.pop(0)
raise ApiError(status, reason(status))
text = answer(messages)
stop_reason = "end_turn"
if tokens(text) > max_tokens:
# max_tokens: отговорът спира по средата
text = " ".join(text.split()[: max_tokens * 3 // 4])
stop_reason = "max_tokens"
input_tokens = tokens(system)
for message in messages:
input_tokens += tokens(message["content"])
usage = Usage(input_tokens, tokens(text))
return Response([Block("text", text)], stop_reason, usage)
# записаният отговор: избира се по дума в последното съобщение
def answer(messages):
last = messages[-1]["content"]
has_order = any("1042" in m["content"] for m in messages)
if "върн" in last:
return "Можете да върнете стоката до 14 дни от получаването."
if "пристиг" in last:
return "Поръчка 1042 е при куриера и пристига утре до 18 ч." if has_order else "Кой е номерът на поръчката ви?"
return "С какво да помогна: статус на поръчка, доставка или връщане?"
# текстът на грешката по кода ѝ
def reason(status):
reasons = {
400: "невалидна заявка",
401: "невалиден ключ",
429: "твърде много заявки",
}
return reasons.get(status, "грешка на сървъра")
# грубо: 4 токена на 3 думи (истинския брой дава токенизаторът)
def tokens(text):
words = len(text.split())
return (words * 4 + 2) // 3

Едно извикване, после същото с max_tokens 8:

# текстовите блокове, после защо спря и колко токена
def show(response):
for block in response.content:
if block.type == "text":
print(block.text)
u = response.usage
print(f"stop_reason: {response.stop_reason}, input_tokens: {u.input_tokens}, output_tokens: {u.output_tokens}")
client = FakeClient()
system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко."
messages = [{"role": "user", "content": "Кога ще пристигне поръчка 1042?"}]
response = client.create(model=MODEL, max_tokens=1024, system=system, messages=messages)
show(response)
response = client.create(model=MODEL, max_tokens=8, system=system, messages=messages)
show(response)
Изход
Поръчка 1042 е при куриера и пристига утре до 18 ч.
stop_reason: end_turn, input_tokens: 21, output_tokens: 15
Поръчка 1042 е при куриера и
stop_reason: max_tokens, input_tokens: 21, output_tokens: 8

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

Виж го

Пусни при теб: истинското извикване

Същото извикване към истински модел. То не е част от проверените програми и тук не се пуска: трябва ти профил при доставчик, ключ в средата, а всяко извикване струва пари.

По мрежата отива JSON. Заявката:

заявка
{
"model": "<името на модела>",
"max_tokens": 1024,
"system": "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.",
"messages": [
{
"role": "user",
"content": "Кога ще пристигне поръчка 1042?"
}
]
}

И отговорът (числата тук са от фалшивия клиент):

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

Със стандартния HTTP клиент на езика това е една POST заявка. Адресът, името на модела и заглавката с ключа са заместители: истинските са в документацията на доставчика. Ключът се чете от променливата LLM_API_KEY.

import json
import os
import urllib.request
body = {
"model": "<името на модела>",
"max_tokens": 1024,
"system": "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко.",
"messages": [{"role": "user", "content": "Кога ще пристигне поръчка 1042?"}],
}
request = urllib.request.Request(
"https://<доставчик>/messages",
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": "Bearer " + os.environ["LLM_API_KEY"],
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(request) as response:
reply = json.load(response)
for block in reply["content"]:
if block["type"] == "text":
print(block["text"])

Доставчиците имат и официални библиотеки (SDK) за Java, C#, Python и JavaScript със същата форма: съобщения с роли, токени, инструменти. Точните имена на класовете и методите са в документацията им.

Идеята

Ключът е в средата, не в кода

Ключът за API-то е като парола за сметката: който го има, харчи от нея. В кода той стига до git, до всеки колега и понякога до публично хранилище. Затова се чете от променлива на средата, тук LLM_API_KEY.

key = os.environ.get("LLM_API_KEY", "")
if not key.strip():
print("Няма ключ: задай LLM_API_KEY в средата.")
else:
print("Ключът е зареден от средата. Не го печатай и не го пиши в кода.")
Изход
Няма ключ: задай LLM_API_KEY в средата.

Програмата казва дали ключът го има, но никога не го печата. Файлът с локалните тайни (например .env) е в .gitignore.

Как се пазят тайните в истински проект, е в урока за тайните.

Виж го

API без памет

ask добавя въпроса към историята, праща цялата история и пази отговора с ролята assistant. Накрая същият въпрос, пратен сам:

# добавя въпроса, праща цялата история и пази отговора
def ask(client, system, history, text):
history.append({"role": "user", "content": text})
r = client.create(model=MODEL, max_tokens=1024, system=system, messages=history)
reply = r.content[0].text
history.append({"role": "assistant", "content": reply})
return reply
client = FakeClient()
system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко."
history = []
for text in ["Здравейте, имам поръчка 1042.", "Кога ще пристигне?"]:
print(f"клиент: {text}")
print(f"асистент: {ask(client, system, history, text)}")
alone = client.create(model=MODEL, max_tokens=1024, system=system,
messages=[{"role": "user", "content": "Кога ще пристигне?"}])
print(f"само въпросът, без историята: {alone.content[0].text}")
Изход
клиент: Здравейте, имам поръчка 1042.
асистент: С какво да помогна: статус на поръчка, доставка или връщане?
клиент: Кога ще пристигне?
асистент: Поръчка 1042 е при куриера и пристига утре до 18 ч.
само въпросът, без историята: Кой е номерът на поръчката ви?

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

Пробвай

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

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

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

Пробвай

Попълни

С каква роля влиза в историята отговорът на модела?

def ask(client, system, history, text):
    history.append({"role": "user", "content": text})
    r = client.create(model=MODEL, max_tokens=1024, system=system, messages=history)
    reply = r.content[0].text
    history.append({"role": , "content": reply})
    return reply

Идеята

Грешките на API-то

Мрежата и сървърът на доставчика падат, както всяка външна услуга. Грешките идват с HTTP код и се делят на две:

  • Временни: 429 (твърде много заявки, достигнат лимит), 500 и останалите 5xx (сървърът има проблем или е претоварен), 408 и 409. След малко същата заявка може да мине. Тях си струва да повториш.
  • Постоянни: 400 (грешна заявка), 401 (невалиден ключ), 404. Повторението дава същото. Тях програмата казва веднага.

Повторението чака между опитите, а паузата се удвоява: 1, 2, 4 секунди. Така кратък проблем минава бързо, а претовареният сървър не получава поток от нови заявки. Това е експоненциалното чакане (exponential backoff). След определен брой опити програмата се отказва.

Виж го

Повторение с удвояваща се пауза

# повтаря временните грешки, паузата се удвоява
def create_with_retry(client, system, messages):
max_attempts = 4
delay = 1
for attempt in range(1, max_attempts + 1):
try:
return client.create(
model=MODEL, max_tokens=1024,
system=system, messages=messages)
except ApiError as e:
last = attempt == max_attempts
if last or not retryable(e.status):
raise
print(f" опит {attempt}: {e.status},",
f"чакам {delay} с")
# истинската програма чака тук:
# time.sleep(delay)
delay *= 2
# временни: 408, 409, 429 и 5xx
def retryable(status):
return (status == 408 or status == 409
or status == 429 or status >= 500)
Стъпка по стъпка, с думи
  1. Заявката: моделът, системният промпт и съобщенията с ролите им. Тук е едно, от клиента (user).
  2. Първият опит връща 429: „твърде много заявки“. Временна грешка.
  3. 429 е от временните, затова програмата чака 1 с и опитва пак.
  4. Вторият опит: 500, „грешка на сървъра“. Също временна.
  5. Паузата се удвоява: 2 с. Претовареният сървър получава въздух.
  6. Третият опит минава.
  7. Отговорът: текстов блок, stop_reason end_turn и usage: 21 входни и 15 изходни токена.
  8. 401 и 400 не се повтарят: грешният ключ не се оправя с чакане. След 4 опита програмата се отказва.
# повтаря временните грешки, паузата се удвоява
def create_with_retry(client, system, messages):
max_attempts = 4
delay = 1
for attempt in range(1, max_attempts + 1):
try:
return client.create(
model=MODEL, max_tokens=1024,
system=system, messages=messages)
except ApiError as e:
last = attempt == max_attempts
if last or not retryable(e.status):
raise
print(f" опит {attempt}: {e.status},",
f"чакам {delay} с")
# истинската програма чака тук:
# time.sleep(delay)
delay *= 2
# временни: 408, 409, 429 и 5xx
def retryable(status):
return (status == 408 or status == 409
or status == 429 or status >= 500)

Три сценария: 429 и 500, после отговор; 401, без повторение; четири пъти 429, отказ след четвъртия опит. Паузата само се печата, за да върви програмата веднага.

system = "Ти си асистент на поддръжката на онлайн магазин. Отговаряй кратко."
messages = [{"role": "user", "content": "Кога ще пристигне поръчка 1042?"}]
scenarios = [[429, 500], [401], [429, 429, 429, 429]]
for failures in scenarios:
print(f"сценарий {failures}:")
try:
r = create_with_retry(FakeClient(failures), system, messages)
print(f" отговор: {r.content[0].text}")
except ApiError as e:
print(f" отказ: {e.status} {e}")
Изход
сценарий [429, 500]:
опит 1: 429, чакам 1 с
опит 2: 500, чакам 2 с
отговор: Поръчка 1042 е при куриера и пристига утре до 18 ч.
сценарий [401]:
отказ: 401 невалиден ключ
сценарий [429, 429, 429, 429]:
опит 1: 429, чакам 1 с
опит 2: 429, чакам 2 с
опит 3: 429, чакам 4 с
отказ: 429 твърде много заявки

Официалните SDK на доставчиците обикновено правят това вътре: сами повтарят временните грешки с нарастваща пауза. Провери в документацията и не слагай свой цикъл отгоре: опитите се умножават. Свой цикъл пишеш, когато викаш API-то направо по HTTP, както в блока „пусни при теб“.

Timeout, повторение с jitter и circuit breaker са в урока за устойчивостта.

Пробвай

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

Фалшивият клиент връща първо 429, после 400. system и messages са както по-горе. Какво ще отпечата програмата?

client = FakeClient([429, 400])
try:
r = create_with_retry(client, system, messages)
print(f"отговор: {r.content[0].text}")
except ApiError as e:
print(f"отказ: {e.status}")
Отговор

Идеята

Какво вижда клиентът

Повторенията не помагат винаги. Когато и последният опит падне, клиентът не трябва да вижда „ApiException: 503“. Програмата хваща грешката и казва нещо вежливо и вярно: „В момента не мога да отговоря. Ще ви свържа с колега.“ Грешката се записва в лога за екипа.

По-точно, за любопитните

Jitter. Когато хиляди програми паднат заедно и чакат точно 1, 2, 4 секунди, те опитват пак заедно. Към паузата се добавя случайна част (jitter), за да се разпръснат.

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

Лимитите. 429 значи, че си стигнал лимита на заявки или токени за минута. Повторението помага при пик. Ако го стигаш постоянно, решението е по-малко заявки, по-къси заявки или по-висок лимит, не повече повторения.

Какво следва. Системният промпт е първата стъпка към промпта като код. Отговор, който програмата чете, а не само показва, е структурираният изход, а модел, който сам иска данни от програмата, е използването на инструменти. Текст от потребител или документ, който се опитва да смени инструкциите (prompt injection), е в модула за агенти по-нататък в курса.

Упражнение

Отговор или вежлив отказ

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

Напиши askSupport(client, system, question): вика createWithRetry, връща текста на отговора, а ако и последният опит падне (или грешката е постоянна), връща „В момента не мога да отговоря. Ще ви свържа с колега.“. Пусни я с въпроса „Мога ли да върна якето?“ и четири сценария на фалшивия клиент: без грешки; четири пъти 503; 401; един 429.

Изход
сценарий []:
Можете да върнете стоката до 14 дни от получаването.
сценарий [503, 503, 503, 503]:
опит 1: 503, чакам 1 с
опит 2: 503, чакам 2 с
опит 3: 503, чакам 4 с
В момента не мога да отговоря. Ще ви свържа с колега.
сценарий [401]:
В момента не мога да отговоря. Ще ви свържа с колега.
сценарий [429]:
опит 1: 429, чакам 1 с
Можете да върнете стоката до 14 дни от получаването.

Още задачи

  • Лог за екипа. При отказ отпечатай отделен ред с кода и текста на грешката, преди вежливия отговор. Клиентът вижда само вежливия.
  • Отрязаният отговор. Добави параметър за max_tokens към createWithRetry. Ако stop_reason е max_tokens, върни отговора с „…“ накрая и запиши предупреждение. Пробвай с 8.

Тест

Провери се

1. Клиентът пише „Здравейте, имам поръчка 1042.“, а после „Кога ще пристигне?“. Програмата праща на втората заявка само новия въпрос. Какво става?
2. Отговорът идва със stop_reason: max_tokens. Какво значи?
3. Кои грешки си струва да повториш?
4. Защо паузата между опитите се удвоява (1, 2, 4 с)?
5. Къде е мястото на ключа за API-то?

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