Параллельные вопросы в одном запросе
Runs a 13-question regulatory briefing over the GDPR Wikipedia article, showing that batching every question into one TypeSafe call is 12.2x cheaper and 10.0x faster with no change in answers.
Выполнение нормативного брифинга из 13 вопросов по статье Википедии о GDPR, демонстрирующее, что объединение всех вопросов в один вызов TypeSafe обходится в 12.2 раза дешевле и выполняется в 10.0 раз быстрее без каких-либо изменений в ответах.
У вас есть один документ и N вопросов к нему. Вы можете отправить один запрос со всеми N вопросами или N запросов по одному вопросу в каждом. В случае с TypeSafe ответы в обоих случаях будут одинаковыми: каждый вопрос оценивается изолированно относительно документа, поэтому его ответ не зависит от того, что еще содержится в запросе.
Чтобы проверить это, в руководстве каждый вопрос задается несколько раз обоими способами — все N в одном запросе и по одному вопросу на запрос — и сравнивается стандартное отклонение (std dev) между запусками: насколько сильно ответ меняется от одного повторения к следующему. Какой бы шум ни был присущ вопросу, он проявляется при обеих стратегиях пакетирования. Само пакетирование шума не добавляет. Большинство ответов возвращались абсолютно одинаковыми во всех 5 повторениях в обоих случаях — одно и то же значение при каждом вызове, стандартное отклонение ровно 0.0.
Стоимость и скорость при этом существенно меняются. Документ составляет основную часть каждого запроса. N вызовов с отдельными вопросами оплачивают его N раз за N обращений (round trips); пакетный вызов оплачивает его лишь однажды. Чем больше документ, тем ближе эта экономия к полному коэффициенту Nx.
В качестве примера здесь рассматривается нормативный комплаенс-брифинг. Документ представляет собой статью Википедии о GDPR (~54 000 символов — нагрузка, где документ составляет подавляющую часть каждого запроса), а отдел комплаенса хочет проверить 13 пунктов: 8 вопросов Noul, 2 вопроса Choice и 3 вопроса Score.
Настройка
pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
затем задайте TYPESAFE_API_KEY.
import json
import os
import urllib.request
from pathlib import Path
from statistics import mean, stdev
from time import perf_counter
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, ChoiceAnswer, Noul, NoulAnswer, Score, TypeSafeClient
TYPESAFE_MODEL = "jev-1.12"
PRICE = (
0.042,
0.00,
) # $ per 1M tokens (input, output); TypeSafe jev-1.12 as of 2026-09, see README
RUNS = 5 # repeats per batching strategy, to estimate each answer's run-to-run std dev
client = TypeSafeClient(api_key=os.environ["TYPESAFE_API_KEY"], timeout=120.0)
json_cache = JsonCache(Path("json_cache.json"))
Документ: статья Википедии о GDPR
Загружается в виде простого текста из закрепленной версии статьи и кэшируется в json_cache.json рядом с вызовами API, благодаря чему документ и полученные цифры остаются неизменными даже при редактировании живой статьи.
WIKIPEDIA_REVISION = 1363040264 # "General Data Protection Regulation", as of 2026-07
@json_cache
def fetch_article(revision_id: int) -> str:
url = (
"https://en.wikipedia.org/w/api.php?action=query&format=json"
f"&prop=extracts&explaintext=1&revids={revision_id}"
)
request = urllib.request.Request(
url, headers={"User-Agent": "typesafe-cookbook/1.0"}
)
with urllib.request.urlopen(request) as response:
pages = json.loads(response.read())["query"]["pages"]
return next(iter(pages.values()))["extract"]
DOCUMENT = {
"source": f"https://en.wikipedia.org/?oldid={WIKIPEDIA_REVISION}",
"text": fetch_article(WIKIPEDIA_REVISION),
}
print(f"{len(DOCUMENT['text']):,} characters")
display(Markdown(f"📄 [Read the pinned Wikipedia revision]({DOCUMENT['source']})"))
53,777 characters
📄 Прочитать закрепленную версию Википедии
Вопросы: 8 noul + 2 choice + 3 score
Для каждого типа вопроса отслеживается одно числовое значение:
Noul: вероятность ответа «да» (yes).Choice: максимальная вероятность (max prob) — вероятность выбранной метки.criteriaсопоставляет каждую метку с ее описанием.Score: нормализованная оценка от 0 до 1 — балл, деленный на максимальный уровень шкалы.criteriaперечисляет описания уровней, начиная с уровня 0.
QUESTIONS = {
"breach_72h": Noul(
instructions="Must a personal data breach be reported to the supervisory authority within 72 hours?"
),
"applies_non_eu": Noul(
instructions="Does the regulation apply to organisations established outside the EU that offer goods or services to people in the EU?"
),
"dpo_all_orgs": Noul(
instructions="Must every organisation appoint a Data Protection Officer, regardless of what data it processes?"
),
"pre_ticked_consent": Noul(
instructions="Can valid consent be obtained through pre-ticked boxes or inactivity?"
),
"right_erasure": Noul(
instructions="Does the regulation grant individuals a right to erasure of their personal data?"
),
"data_portability": Noul(
instructions="Does the regulation include a right to data portability?"
),
"us_federal_law": Noul(instructions="Is the GDPR a United States federal law?"),
"criminal_penalties": Noul(
instructions="Does the GDPR itself impose criminal penalties such as imprisonment?"
),
"instrument_type": Choice(
instructions="What kind of EU legal instrument is the GDPR?",
criteria={
"Regulation": "Directly binding law in all member states, no national implementation needed.",
"Directive": "Sets goals that member states implement through national law.",
"Treaty": "An international treaty between states.",
"Recommendation": "Non-binding guidance.",
},
),
"max_fine": Choice(
instructions="What is the maximum administrative fine for the most serious infringements?",
criteria={
"TwentyM_or_4pct": "Up to EUR 20 million or 4% of annual worldwide turnover, whichever is greater.",
"TenM_or_2pct": "Up to EUR 10 million or 2% of annual worldwide turnover, whichever is greater.",
"FixedCap": "A fixed amount not tied to turnover.",
"NoFines": "The GDPR provides no administrative fines.",
},
),
"individual_rights": Score(
instructions="How strong are the rights the GDPR grants to individuals over their data?",
criteria=[
"None: individuals get no rights over their data.",
"Weak: a right to be informed, but little control.",
"Moderate: access and correction rights, but limited means to act on them.",
"Strong: access, erasure, portability, and objection rights, with enforcement behind them.",
],
),
"penalty_severity": Score(
instructions="How severe are the penalties the GDPR provides for non-compliance?",
criteria=[
"None: no penalties of any kind.",
"Symbolic: small fixed fines unlikely to change behavior.",
"Substantial: fines large enough to matter to most companies.",
"Severe: fines scaled to global revenue, material even to the largest companies.",
],
),
"compliance_burden": Score(
instructions="How heavy is the compliance burden the GDPR places on organisations?",
criteria=[
"Negligible: no meaningful obligations.",
"Light: a few notices and disclosures.",
"Moderate: documented processes and some dedicated roles for larger processors.",
"Heavy: records, impact assessments, officers, and breach procedures for many organisations.",
"Extreme: obligations so demanding that ordinary organisations cannot fully comply.",
],
),
}
N = len(QUESTIONS)
METRIC = { # question type -> the one number we track per answer
Noul: "p(yes)",
Choice: "max prob",
Score: "normalized score",
}
Запросы двумя способами, по 5 раз каждый
Функция ask() отправляет любое подмножество вопросов вместе с документом и сводит каждый ответ к одному отслеживаемому числу. Документ побайтово идентичен в каждом вызове.
Обе стратегии пакетирования выполняются RUNS = 5 раз, предоставляя по 5 ответов на каждый вопрос для каждой стратегии — этого достаточно для сравнения среднего значения (совпадают ли результаты?) и стандартного отклонения (добавляет ли пакетирование шум?). Вызовы кэшируются в json_cache.json, поставляемый с руководством, поэтому повторный рендеринг бесплатен; удалите его, чтобы выполнить запросы в реальном времени.
@json_cache
def ask(keys: tuple[str, ...], run: int):
"""One TypeSafe call -> ({key: tracked metric}, input_tokens, output_tokens, latency_s);
``run`` only forces a distinct live call per repeat."""
started = perf_counter()
response = client.system_one(
state={"article": DOCUMENT},
questions={key: QUESTIONS[key] for key in keys},
model=TYPESAFE_MODEL,
)
values = {}
for key in keys:
answer = response.answers[key]
if isinstance(answer, NoulAnswer):
values[key] = answer.noul
elif isinstance(answer, ChoiceAnswer):
values[key] = max(answer.probabilities.values())
else:
values[key] = answer.score / (len(QUESTIONS[key].criteria) - 1)
return (
values,
response.usage.input_tokens,
response.usage.output_tokens,
perf_counter() - started,
)
def priced(result):
"""({key: metric}, in_tokens, out_tokens, latency) -> ({key: metric}, cost_usd, latency)."""
values, input_tokens, output_tokens, latency = result
return values, input_tokens / 1e6 * PRICE[0] + output_tokens / 1e6 * PRICE[1], latency
# Price after cache retrieval, so a price change needs no new calls.
batched = [
priced(ask(tuple(QUESTIONS), run)) for run in range(RUNS)
] # all N in one call, x RUNS
singles = [
{key: priced(ask((key,), run)) for key in QUESTIONS} for run in range(RUNS)
] # N x 1, x RUNS
Пакетирование не меняет ответы
По каждому вопросу: среднее значение и стандартное отклонение отслеживаемого числа за 5 запусков для каждой стратегии пакетирования. Если бы пакетирование влияло на ответы, столбцы для пакетных запросов (batched) отличались бы от столбцов для одиночных (single). Смещение среднего значения указывало бы на систематическую ошибку (bias), а увеличение стандартного отклонения — на дополнительный шум.
print(
f"{'question':<22}{'metric':<18}{'batched mean':>13}{'single mean':>12}"
f"{'batched std':>13}{'single std':>12}"
)
for key, question in QUESTIONS.items():
batched_values = [values[key] for values, _cost, _latency in batched]
single_values = [singles[run][key][0][key] for run in range(RUNS)]
print(
f"{key:<22}{METRIC[type(question)]:<18}{mean(batched_values):>13.3f}"
f"{mean(single_values):>12.3f}{stdev(batched_values):>13.4f}{stdev(single_values):>12.4f}"
)
question metric batched mean single mean batched std single std
breach_72h p(yes) 0.804 0.814 0.0055 0.0055
applies_non_eu p(yes) 0.990 0.990 0.0000 0.0000
dpo_all_orgs p(yes) 0.030 0.030 0.0000 0.0000
pre_ticked_consent p(yes) 0.040 0.040 0.0000 0.0000
right_erasure p(yes) 0.990 0.990 0.0000 0.0000
data_portability p(yes) 0.990 0.990 0.0000 0.0000
us_federal_law p(yes) 0.010 0.010 0.0000 0.0000
criminal_penalties p(yes) 0.108 0.108 0.0045 0.0084
instrument_type max prob 1.000 1.000 0.0000 0.0000
max_fine max prob 1.000 1.000 0.0000 0.0000
individual_rights normalized score 1.000 1.000 0.0000 0.0000
penalty_severity normalized score 1.000 1.000 0.0000 0.0000
compliance_burden normalized score 0.750 0.750 0.0000 0.0000
Разбор таблицы по типам вопросов:
- Варианты Choice, Score и шесть из восьми Noul возвращаются полностью идентичными по всем 5 повторениям: стандартное отклонение составляет ровно 0.0 при обеих стратегиях, каждый пакетный и одиночный вызов возвращает одно и то же число. Один вызов с N вопросами дает точно такие же ответы, как и N вызовов по одному вопросу.
- Вопросы
breach_72hиcriminal_penaltiesсодержат небольшой шум сэмплирования от запуска к запуску, причем его величина одинакова при обеих стратегиях пакетирования, а средние значения согласуются в пределах этого шума. Шум является свойством самого вопроса, а не способа отправки: пакетирование не смещает ответ и не увеличивает дисперсию.
В любом случае эффект пакетирования отсутствует: ответ ни на один вопрос не зависит от остальных 12 вопросов, находящихся в том же запросе.
Единственная разница: стоимость и скорость
Те же ответы, но совершенно другой счет. Статья объемом ~54 000 символов составляет основную часть каждого запроса, поэтому:
- Стоимость: 13 одиночных вызовов отправляют статью 13 раз; пакетный вызов отправляет ее лишь однажды. Эта экономия сохраняется независимо от способа запуска вызовов.
- Скорость: показатель суммирует задержки 13 одиночных вызовов, предполагая их последовательное выполнение. При параллельном запуске разрыв сократится, однако 13-кратная переплата за токены останется.
Количество токенов и задержки кэшируются вместе с ответами; расчет стоимости применяется после, и оба показателя усредняются по 5 запускам.
batched_cost = mean(cost for _values, cost, _latency in batched)
batched_latency = mean(latency for _values, _cost, latency in batched)
singles_cost = mean(
sum(singles[run][key][1] for key in QUESTIONS) for run in range(RUNS)
)
singles_latency = mean(
sum(singles[run][key][2] for key in QUESTIONS) for run in range(RUNS)
)
print(f"{'batching':<24}{'calls':>6}{'cost':>12}{'total time':>12}")
print(
f"{f'one call, all {N}':<24}{1:>6}{'$' + format(batched_cost, '.6f'):>12}{format(batched_latency, '.2f') + 's':>12}"
)
print(
f"{f'{N} calls, one each':<24}{N:>6}{'$' + format(singles_cost, '.6f'):>12}{format(singles_latency, '.2f') + 's':>12}"
)
print(
f"\nbatching: {singles_cost / batched_cost:.1f}x cheaper, {singles_latency / batched_latency:.1f}x faster"
)
batching calls cost total time
one call, all 13 1 $0.000497 0.27s
13 calls, one each 13 $0.006090 2.71s
batching: 12.2x cheaper, 10.0x faster
Открыть в песочнице TypeSafe
Та же статья и те же 13 вопросов, упакованные в ссылку. Откройте ее, чтобы перезапустить брифинг в реальном времени: вернутся те же самые цифры.
playground_link = make_playground_link(
{"article": DOCUMENT}, QUESTIONS, models=[TYPESAFE_MODEL]
)
display(
Markdown(
f"🔗 [Open this article + questions in the TypeSafe playground]({playground_link})"
)
)