JevCode / 생태계 사례

병렬 질문

GDPR 위키백과 문서를 대상으로 13개 질문의 규제 브리핑을 실행한 결과, 모든 질문을 한 번의 TypeSafe 호출로 배치 처리할 때 답변의 변화 없이 비용은 12.2배 저렴하고 속도는 10.0배 빨라짐을 확인했습니다.

이 문서는 en에서 기계 번역되었으며 교정되지 않았습니다. 빠른 참고용으로만 사용하세요.

소스: docs.typesafe.ai/cookbooks/parallel_questionscookbookrecipe
One request fanning out into four questions

당신은 하나의 문서와 그에 대한 N개의 질문을 가지고 있습니다. N개의 질문을 모두 포함한 하나의 요청을 보내거나, 각 질문 하나씩을 담은 N개의 요청을 보낼 수 있습니다. TypeSafe에서는 두 방식 모두 답변이 동일하게 나옵니다: 각 질문은 문서와 독립적으로 평가되므로, 요청 내 다른 내용과 무관하게 답변이 생성됩니다.

이를 확인하기 위해, 쿡북은 각 질문을 양방향으로 여러 번 묻습니다. 모든 N을 한 번의 요청에 담는 방식과, 요청마다 한 질문씩만 묻는 방식입니다. 그리고 실행 간 표준편차를 비교합니다. 즉, 반복할 때마다 답변이 얼마나 달라지는지를 측정합니다. 질문이 가진 노이즈는 두 배치 전략 모두에서 동일하게 존재합니다. 배치는 추가적인 노이즈를 더하지 않습니다. 대부분의 답변은 두 방식 모두에서 5번의 반복 동안 완전히 동일하게 돌아왔으며, 호출할 때마다 같은 값을 보였고 표준편차는 정확히 0.0였습니다.

비용과 속도는 변한다. 문서는 모든 요청을 지배한다. N개의 단일 질문 호출은 N번의 라운드 트립에서 이를 N번 지불하지만, 배치 호출은 한 번만 지불한다. 문서가 클수록 그 절약 효과는 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개의 nouls + 2개의 choices + 3개의 scores

답변 유형별로 답변당 하나의 숫자를 추적함:

  • Noul: “yes”의 확률.
  • Choice: 최대 확률, 선택된 라벨의 확률. 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회 실행 동안 추적된 수치의 평균과 표준편차. 배치가 답변을 변경한 경우, 배치된 열은 단일 열과 다를 것이다. 평균의 편차는 편향이다. 더 큰 표준편차는 노이즈이다.

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

질문 유형별 표 읽기:

  • Choices, scores, 그리고 여덟 개의 nouls 중 여섯 개는 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})"
    )
)

TypeSafe 플레이그라운드에서 이 기사 + 질문 열기 →