Lesson 08 / 26
Batch and Asynchronous Calls
Processing a hundred and twenty fictional requests at seven different batch sizes, and batch size moving three separate numbers in opposite directions: input tokens fall from 5854 to 1784, calls from 120 to 10, and rounds at four-way concurrency from 30 to 3. The load other than the repeated directive is exactly 1414 tokens in all seven rows; the whole saving comes from the repeated directive. In return, the number of requests affected by a single failure equals the batch size and rises from 1 to 12; at size 18 the prompt overflows the context window, six of seven calls drop and accuracy collapses to 0.0667. The movement of accuracy with batch size does not exceed two run differences, that is, it counts as unmeasured. If the output limit is not enlarged with the batch size, 26 requests go unanswered at size 12 and accuracy falls to 0.5333.
Published Updated
In this lesson
- Writes with the standard library a batch interface that sends several requests in one call and a decoder that splits its response into rows.
- Measures the effect of batch size on the number of calls, the input tokens and the waiting rounds.
- Counts that the number of requests affected by a single failure equals the batch size and where the context window collapses.
- Compares the movement in accuracy with two run differences and shows that the output limit has to be enlarged with the batch size.
Contents
Up to the previous lesson every measure was written under the assumption of one call per request: a hundred and twenty requests, a hundred and twenty calls. This assumption has an invisible cost. The same directive is placed at the head of every call and is paid for again each time. When a nightly batch job has thousands of records instead of a hundred and twenty, this repetition alone makes up most of the bill.
A batch call removes it: the directive is written once, numbered requests are lined up behind it and the response is read in one go. This lesson treats batch size as a setting and prints three numbers side by side: calls and tokens, waiting, and the number of requests affected by a single failure. The first two fall as batch size grows, the third grows.
- AU63. The code is a simulator; no real endpoint is called. The requests and all the failure rules are fictional, seed 20260218, second run 20260219.
- AU64. Failure is at call level. Whatever the reason a call is rejected, all the requests inside it are rejected. This is the definition of a batch interface and it is what this lesson actually measures.
- AU65. There are three sources of rejection: the context window (240 tokens), the rate limit (one call in thirty-seven) and malformed output. The last is a share of one and a half percent per request; if one request spoils it, the whole response drops.
- AU66. That the malformed-output share is per request is the key assumption of this lesson. The underlying defect rate is independent of batch size; the only thing that changes is how many requests one defect takes with it.
- AU67. Waiting is written in rounds. Four calls are sent concurrently; the number of rounds is the number of calls divided by four, rounded up. Raw time is not written; the round count does not change from run to run.
- AU68. By default the output limit grows with the batch size (three tokens per request and a margin of two shares). What happens when it is not enlarged is measured separately.
- AU69. The response is split into rows by a decoder: a number, then a label. If a row cannot be read, that request counts as unanswered, even if the call succeeded.
- AU70. The procedure for queues, backoff and asynchronous job management was measured in the Caching, Queues and Asynchronous Processing course of the Backend Development curriculum; it is not repeated. This lesson adds only what batch size does to the three numbers.
How Many Requests Fit in One Call
The prompt of a batch call is a single piece: the directive once, then the numbered requests. The response is also a single piece, and to be read it has to be split into rows. The block below builds these two ends and shows what happens at three batch sizes.
# SIMULATOR -- FICTIONAL. No real model endpoint is called. The requests and # all the failure rules are fictional. import math SEED, M32 = 20260218, 0xFFFFFFFF WINDOW, CONCURRENCY = 240, 4 # context window , number of concurrent calls def rng(t): x = ((t ^ (t >> 16)) * 2246822507) & M32 x = ((x ^ (x >> 13)) * 3266489909) & M32 s = [(x ^ (x >> 16)) & M32] def next_value(): s[0] = (s[0] * 1664525 + 1013904223) & M32 return s[0] / 4294967296 return next_value def pick(u, w): t = 0.0 for i, x in enumerate(w): t += x if u < t: return i return len(w) - 1 def tokenize(m): # FICTIONAL tokenizer, at most five characters per piece b = [] for s in m.lower().replace(",", " , ").split(): while len(s) > 5: b.append(s[:5]) s = s[5:] b.append(s) return b def hash_text(m): h = 2166136261 for c in m: h = ((h ^ ord(c)) * 16777619) & M32 return h TOPICS = ["fatura", "ariza", "sayac", "tarife", "kesinti"] PATTERNS = [("bu ayki fatura tutari gecen aya gore cok yuksek geldi", "fatura"), ("faturami odedim ama sistemde borc gorunuyor", "fatura"), ("fatura ayrintisini nereden gorebilirim", "fatura"), ("evde su akmiyor bir ariza var galiba", "ariza"), ("baglanti bozuk oldu ve hic calismiyor", "ariza"), ("ariza kaydi actim ama ekip gelmedi", "ariza"), ("sayac okumasi yanlis girilmis endeks tutmuyor", "sayac"), ("sayac degisimi icin randevu istiyorum", "sayac"), ("sayac uzerindeki endeks ile fatura ayni degil", "sayac"), ("hangi tarife basamagindayim ogrenmek istiyorum", "tarife"), ("tarife degisikligi icin ne yapmam gerekiyor", "tarife"), ("birim ucret abone tipine gore degisiyor mu", "tarife"), ("mahallede kesinti var ne zaman gelecek", "kesinti"), ("planli kesinti duyurusu nereden takip edilir", "kesinti"), ("aksamdan beri kesinti yasiyoruz", "kesinti")] REQUESTS = [] for i in range(120): # 120 FICTIONAL support requests r = rng(SEED + i) k = TOPICS[pick(r(), [0.24, 0.22, 0.18, 0.20, 0.16])] REQUESTS.append({"id": 4001 + i, "topic": k, "text": [x for x in PATTERNS if x[1] == k][int(r() * 3)][0]}) CLUES = {"fatur": "fatura", "tutar": "fatura", "ayrin": "fatura", "ariza": "ariza", "bozuk": "ariza", "calis": "ariza", "akmiy": "ariza", "sayac": "sayac", "endek": "sayac", "okuma": "sayac", "tarif": "tarife", "birim": "tarife", "abone": "tarife", "basam": "tarife", "kesin": "kesinti", "duyur": "kesinti", "mahal": "kesinti"} PREFIX = ("Her talebi su etiketlerden birine ata: fatura , ariza , sayac , tarife ," " kesinti . Her satir icin once numara sonra tek etiket yaz . Talepler: ") PREFIX5 = {k[:5]: k for k in TOPICS} CALL_COUNT = [0] def batch_call(batch, seed=SEED, max_length=None, temperature=0.6): """Sends k requests in a single call. Failure is at call level: whichever rejection occurs, the whole batch is rejected.""" CALL_COUNT[0] += 1 k = len(batch) prompt = PREFIX + " ".join(f"{i + 1} {x['text']} ;" for i, x in enumerate(batch)) toks = tokenize(prompt) max_length = 3 * k + 2 if max_length is None else max_length empty = {"input": len(toks), "output": 0, "response": []} if len(toks) > WINDOW: return dict(empty, status="context_overflow") if CALL_COUNT[0] % 37 == 0: return dict(empty, status="rate_limit") r = rng(seed ^ hash_text(prompt)) for _ in batch: # each request carries its own malformed-output share if r() < 0.015: # if one request spoils it, the whole response drops return dict(empty, status="malformed") cb = [] for i, x in enumerate(batch): p = {c: 0.15 for c in TOPICS} for t in tokenize(x["text"]): if CLUES.get(t) in p: p[CLUES[t]] += 1.0 names = sorted(p) w = [math.exp(p[a] / temperature) for a in names] cb += [str(i + 1)] + tokenize(names[pick(r(), [q / sum(w) for q in w])]) cut = len(cb) > max_length return {"status": "done", "input": len(toks), "output": min(len(cb), max_length), "response": cb[:max_length], "cut": cut} def decode(cb, k): """Reads one label per row from the batch response. A row that cannot be read stays None.""" out, i = [None] * k, 0 while i < len(cb): if cb[i].isdigit(): n, j = int(cb[i]), i + 1 piece = [] while j < len(cb) and not cb[j].isdigit(): piece.append(cb[j]) j += 1 e = "".join(piece) if 1 <= n <= k and e[:5] in PREFIX5: out[n - 1] = PREFIX5[e[:5]] i = j else: i += 1 return out for k in (1, 6, 18): s = batch_call(REQUESTS[:k]) print(f"batch size {k:>2} : input {s['input']:>3} , output {s['output']:>2} ," f" status {s['status']}") s = batch_call(REQUESTS[:6]) print("\nresponse of a single six-request call:", " ".join(s["response"])) print("decoded:", decode(s["response"], 6)) print("actual :", [x["topic"] for x in REQUESTS[:6]]) d = batch_call(REQUESTS[:6], max_length=8) # if the output limit is not enlarged with the batch size print(f"\nsame call with max_length 8: output {d['output']} , cut {d['cut']} ," f" decoded {sum(e is not None for e in decode(d['response'], 6))} / 6")
batch size 1 : input 50 , output 3 , status done batch size 6 : input 107 , output 16 , status done batch size 18 : input 247 , output 0 , status context_overflow response of a single six-request call: 1 kesin ti 2 kesin ti 3 kesin ti 4 ariza 5 sayac 6 fatur a decoded: ['kesinti', 'kesinti', 'kesinti', 'ariza', 'sayac', 'fatura'] actual : ['kesinti', 'fatura', 'ariza', 'ariza', 'sayac', 'tarife'] same call with max_length 8: output 8 , cut True , decoded 3 / 6
The first three lines show the ceiling of batch size. The directive is thirty-seven tokens; a call with a single request comes to fifty tokens, that is, three quarters of the input is directive. In the six-request call the input rises to 107, but the share per request falls to eighteen. At eighteen requests the prompt reaches 247 tokens and exceeds the 240-token window: the call is rejected without ever being made. The upper limit of batch size is set by the context window, not by the rate limit.
The decoder shows the second face of a batch response. The response is a single sequence of tokens, and the rows can be separated only by their numbers. All six rows are read, three correctly. The last block shows what happens when the output limit is not enlarged with the batch size: a limit of eight tokens cuts the response in the middle, the call returns successfully and only three of the six requests are decoded. A cut does not show in the call status; it becomes visible only if the rows the decoder leaves empty are counted.
The Three Numbers of Batch Size
A hundred and twenty requests are processed from start to finish at seven different batch sizes. Each row prints the number of calls, the input and output tokens, the failed calls, the number of requests affected by those failures, the delivered accuracy, the second run accuracy and the number of rounds at four-way concurrency.
def run(k, seed=SEED, max_length=None): CALL_COUNT[0] = 0 calls = t_in = t_out = failed = affected = correct = unanswered = 0 for i in range(0, 120, k): batch = REQUESTS[i:i + k] s = batch_call(batch, seed, max_length) calls, t_in, t_out = calls + 1, t_in + s["input"], t_out + s["output"] if s["status"] != "done": failed, affected = failed + 1, affected + len(batch) continue for x, e in zip(batch, decode(s["response"], len(batch))): correct, unanswered = correct + (e == x["topic"]), unanswered + (e is None) return calls, t_in, t_out, failed, affected, correct / 120, -(-calls // CONCURRENCY), unanswered print(" size calls input output failed affected accuracy second run rounds") for k in (1, 2, 3, 6, 12, 18, 24): a, b = run(k), run(k, SEED + 1) print(f"{k:>5} {a[0]:>6} {a[1]:>6} {a[2]:>6} {a[3]:>10} {a[4]:>10}" f" {a[5]:>8.4f} {b[5]:>13.4f} {a[6]:>6}") for k in (6, 12): a = run(k, max_length=24) print(f"output limit fixed at 24 , size {k:>2} : unanswered requests {a[7]:>2} ," f" accuracy {a[5]:.4f}")
size calls input output failed affected accuracy second run rounds
1 120 5854 300 3 3 0.8250 0.7083 30
2 60 3634 300 3 6 0.7667 0.7250 15
3 40 2894 291 3 9 0.7167 0.6667 10
6 20 2154 299 1 6 0.7833 0.7583 5
12 10 1784 284 1 12 0.7083 0.6833 3
18 7 1673 30 6 108 0.0667 0.0750 2
24 5 1599 0 5 120 0.0000 0.0000 2
output limit fixed at 24 , size 6 : unanswered requests 0 , accuracy 0.7833
output limit fixed at 24 , size 12 : unanswered requests 26 , accuracy 0.5333
The whole drop in the input column comes from a single item, and it can be separated out exactly. The directive is thirty-seven tokens; it is paid for 120 times when sending one by one and 10 times when sending in batches of twelve, that is, it falls from 4440 tokens to 370. The remaining load is exactly 1414 tokens in all seven rows: the requests themselves, their numbers and their separators. A batch call removes not the load of the request but the repetition of the directive. The output column confirms this reading: three tokens per request and about three hundred in total, independent of batch size. A batch call does not save on the output.
The rounds column is the measure of waiting. When a hundred and twenty separate calls are sent four at a time it takes thirty rounds; with batches of twelve, three rounds. The latency class moves from the single call to the batch call and the difference between them is a factor of ten. Raw time is not written, but the round count does not change from run to run and is enough for the comparison.
The Input That Wins, the Radius That Grows
The affected requests column is the real finding of this lesson and it reads in a single line: the number of affected requests is the number of failed calls times the batch size. When sending one by one, three failures take three requests. In batches of six a single failure takes six requests, in batches of twelve it takes twelve. The underlying defect rate did not change; every request carries the same share of one and a half percent. The only thing that changes is the radius of one defect.
That the number of failed calls falls as batch size grows can hide this reading. It goes from three to one and on its own sounds like good news. Yet part of this fall is only because the number of calls has decreased: the rate limit kicks in once in thirty-seven calls and never kicks in for a setup that makes ten calls. Doing the same work with fewer calls does genuinely dilute the rate limit, but it does not dilute the malformed output; its share is per request, and the number of requests is fixed.
The rows for eighteen and twenty-four are beyond a threshold. At eighteen the prompt does not fit the window; six of the seven calls drop without ever being made and 108 requests go unanswered. The one call left standing is the last twelve requests, and accuracy falls to 0.0667. At twenty-four no call is made at all, and accuracy is zero. The added mechanism making the work worse is in this table not a curve but a cliff: raising the batch size from twelve to eighteen gains only 111 tokens of input and in return takes the whole job away.
Reading the accuracy column calls for a yardstick. In the first five rows accuracy wanders between 0.7083 and 0.8250; the difference between them is 0.1167. The second run column has, in the same rows, a largest difference of 0.1167 (0.8250 and 0.7083 when sending one by one). The effect of batch size on accuracy does not exceed the run-to-run movement, that is, it counts as unmeasured. This is not surprising: the label decision is made per request and sending in a batch does not change that decision. Batch size is not a quality setting, it is a cost and risk setting. Defending it on quality grounds, or blaming it on quality grounds, goes against the table.
The last two rows measure the case where the output limit is forgotten. When the limit is held fixed at twenty-four, batches of six are not affected, because six rows fit into twenty tokens. In batches of twelve, however, the tail of the response is cut and 26 requests go unanswered; accuracy falls from 0.7083 to 0.5333. This loss does not show in the failed calls column: the calls returned successfully. Enlarging the batch size makes it mandatory to enlarge the output limit with it.
Summary
- A batch call removes the repetition of the directive: input tokens fall from 5854 to 1784, because the 37-token directive is paid for 10 times instead of 120. The remaining load is exactly 1414 tokens in all seven rows and the output tokens are independent of batch size.
- Waiting is written in rounds: at four-way concurrency it falls from 30 rounds to 3.
- The number of requests affected by a single failure equals the batch size; the same defect rate goes from taking 3 requests to taking 12.
- The context window sets the upper limit of batch size: at size 18 the prompt rises to 247 tokens, six of seven calls drop without ever being made and accuracy falls to 0.0667, at size 24 to 0.0000.
- The 0.1167 wobble of accuracy in the first five rows stays within two run differences: batch size is not a quality setting but a cost and risk setting.
- If the output limit is not enlarged with the batch size, 26 requests go unanswered at size 12 and accuracy falls to 0.5333; this loss never shows in the failed calls column.
Next Step
In eight lessons, what the message-based interface offers was measured one by one: the cost of conversation state, what response streaming changes, the validity rate of tool calling, interface-level schema enforcement, the token bill of the input format, the trade-off of embedding dimension, the fields lost by the common interface and the three numbers of batch size. Under all eight lay the same assumption: the call succeeds.
In this lesson it was counted that the assumption does not hold, but no answer was given. Rejected calls were not retried, the batch that exceeded the window was not split, the twenty-six unanswered requests were not rescued, the repeated directive was not cached. They were counted and left there. The next topic starts exactly here and in its first lesson builds the second claim of this course: retrying a request can rescue it, but it also multiplies the tokens and strains the rate limit further. Adding resilience is not free, and sometimes it spoils the very number it set out to fix.
To keep your progress and take notes, Log in
My notes
Log in to take notes.