Async & Batch Evaluations
Submit fire-and-forget async evaluations, poll job IDs for results, and run 50+ evals in parallel with ThreadPoolExecutor using the Evaluator SDK.
Submit fire-and-forget async evaluations, poll for results, and run 50+ evals in parallel using the Evaluator SDK with ThreadPoolExecutor.
| Time | Difficulty | Package |
|---|---|---|
| 15 min | Intermediate | ai-evaluation |
- Future AGI account → app.futureagi.com
- API keys:
FI_API_KEYandFI_SECRET_KEY(see Get your API keys) - Python 3.11+
Install
pip install ai-evaluation
export FI_API_KEY="your-api-key"
export FI_SECRET_KEY="your-secret-key"
Tutorial
This cookbook covers client-side async and parallel patterns for custom pipelines, as opposed to dataset-level batch evaluation (uploading a CSV and running evals across every row server-side).
Run a synchronous eval as a baseline
A single synchronous call blocks until the result is ready.
from fi.evals import evaluate
result = evaluate(
"groundedness",
output="The Eiffel Tower is in Paris, France.",
context="The Eiffel Tower is a wrought-iron lattice tower on the Champ de Mars in Paris.",
model="turing_small",
)
print(f"Score: {result.score} Passed: {result.passed}")
print(f"Reason: {result.reason}")You should see:
Score: 1.0 Passed: True
Reason: The output is fully supported by the provided context.This is fine for single items. For 50+ items it becomes slow because each call waits for the server response before the next one starts.
Submit an async evaluation (fire and forget)
Use Evaluator.evaluate() with is_async=True. The call returns immediately with an eval_id you can poll later.
import os
from fi.evals import Evaluator
evaluator = Evaluator(
fi_api_key=os.environ["FI_API_KEY"],
fi_secret_key=os.environ["FI_SECRET_KEY"],
)
result = evaluator.evaluate(
eval_templates="groundedness",
inputs={
"output": "The Eiffel Tower is in Paris, France.",
"context": "The Eiffel Tower is a wrought-iron lattice tower on the Champ de Mars in Paris.",
},
model_name="turing_small",
is_async=True,
)
eval_id = result.eval_results[0].eval_id
print(f"Submitted async eval (eval_id: {eval_id})")You should see:
Submitted async eval (eval_id: abc123-def456-...)Note
is_async=True is only available on Evaluator.evaluate(), not on the standalone evaluate() function.
Poll for results with get_eval_result()
Use get_eval_result(eval_id) to retrieve the result once processing completes.
import time
for attempt in range(15):
poll_result = evaluator.get_eval_result(eval_id)
inner = poll_result.get("result", {})
if isinstance(inner, dict) and inner.get("eval_status") == "completed":
eval_data = inner["result"]
print("Evaluation complete")
print(f" Metric: {eval_data['name']}")
print(f" Value: {eval_data['value']}")
print(f" Runtime: {eval_data['runtime'] / 1000:.1f}s")
print(f" Reason: {eval_data['reason'][:120]}...")
break
print(f" Attempt {attempt + 1}/15: still processing...")
time.sleep(5)
else:
print("Timed out waiting for result")You should see:
Attempt 1/15: still processing...
Attempt 2/15: still processing...
Evaluation complete
Metric: groundedness
Value: Passed
Runtime: 24.2s
Reason: The output is fully supported by the provided context. The Eiffel Tower being in Paris, France is...The runtime, reason text, and attempt count above are illustrative and will vary with your account tier and network conditions.
eval_status moves from pending to completed once the server finishes scoring; polling on a short interval is what turns the fire-and-forget submission from step 2 into a usable result.
Note
get_eval_result() returns the raw, unparsed status payload. ai-evaluation also ships a higher-level handle API (handle = evaluator.submit(...) followed by handle.wait()) that does this polling for you; see evaluator.get_execution() if you want a parsed result without hand-rolling the loop above.
Evaluate 50+ items in parallel
Warning
This step and step 5 each fire 50 real turing_small eval runs against your account (100 total). If you’re testing on a small quota, lower range(50) before running them.
Use concurrent.futures.ThreadPoolExecutor to submit many evaluations concurrently. Each thread calls Evaluator.evaluate() independently.
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from fi.evals import Evaluator
evaluator = Evaluator(
fi_api_key=os.environ["FI_API_KEY"],
fi_secret_key=os.environ["FI_SECRET_KEY"],
)
# Sample dataset, 50 items
test_cases = [
{
"output": f"Response {i}: The capital of France is Paris.",
"context": "Paris is the capital and most populous city of France.",
"input": f"Question {i}: What is the capital of France?",
}
for i in range(50)
]
def evaluate_one(index, test_case):
result = evaluator.evaluate(
eval_templates="groundedness",
inputs=test_case,
model_name="turing_small",
)
return index, result
results = [None] * len(test_cases)
completed = 0
failed = 0
start = time.time()
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {
executor.submit(evaluate_one, i, tc): i
for i, tc in enumerate(test_cases)
}
for future in as_completed(futures):
idx = futures[future]
try:
idx, result = future.result(timeout=60)
results[idx] = result
completed += 1
except Exception as exc:
# One slow or errored item shouldn't sink the whole batch.
print(f" Item {idx} failed: {exc}")
failed += 1
if (completed + failed) % 10 == 0:
elapsed = time.time() - start
print(f"Progress: {completed + failed}/{len(test_cases)} ({elapsed:.1f}s)")
elapsed = time.time() - start
print(f"Done in {elapsed:.1f}s. Succeeded: {completed}, Failed: {failed}")
scored = sum(
1 for r in results
if r and r.eval_results and r.eval_results[0].output is not None
)
print(f"Scored: {scored}/{len(test_cases)}")You should see:
Progress: 10/50 (3.2s)
Progress: 20/50 (5.8s)
Item 27 failed: TimeoutError
Progress: 30/50 (8.1s)
Progress: 40/50 (10.5s)
Progress: 50/50 (12.9s)
Done in 12.9s. Succeeded: 49, Failed: 1
Scored: 49/50Timings, the failing item, and the succeeded/failed split are illustrative and vary with your account tier and network conditions. The batch completes even when individual items time out or error, because each future’s exception is caught and counted rather than left to crash the loop.
Combine async submission with batch polling
For maximum throughput, submit every item with is_async=True first, then poll each returned eval_id in a loop until all complete, instead of waiting on each one in turn.
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from fi.evals import Evaluator
evaluator = Evaluator(
fi_api_key=os.environ["FI_API_KEY"],
fi_secret_key=os.environ["FI_SECRET_KEY"],
)
test_cases = [
{
"output": f"Response {i}: Python is a programming language.",
"context": "Python is a high-level, general-purpose programming language.",
}
for i in range(50)
]
def submit_async(index, test_case):
result = evaluator.evaluate(
eval_templates="groundedness",
inputs=test_case,
model_name="turing_small",
is_async=True,
)
eval_id = result.eval_results[0].eval_id
return index, eval_id
eval_ids = {}
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {
executor.submit(submit_async, i, tc): i
for i, tc in enumerate(test_cases)
}
for future in as_completed(futures):
idx, eval_id = future.result()
eval_ids[idx] = eval_id
print(f"Submitted {len(eval_ids)} async evaluations")
results = {}
max_polls = 15
for poll_round in range(max_polls):
still_pending = {
idx: eid for idx, eid in eval_ids.items() if idx not in results
}
if not still_pending:
break
for idx, eid in still_pending.items():
poll_result = evaluator.get_eval_result(eid)
inner = poll_result.get("result", {})
if isinstance(inner, dict) and inner.get("eval_status") == "completed":
results[idx] = poll_result
print(f" Poll {poll_round + 1}: {len(results)}/{len(eval_ids)} completed")
if len(results) < len(eval_ids):
time.sleep(3)
if len(results) < len(eval_ids):
print(f"Poll budget exhausted: {len(results)}/{len(eval_ids)} evaluations completed, {len(eval_ids) - len(results)} still pending")
else:
print(f"Completed {len(results)}/{len(eval_ids)} evaluations")You should see:
Submitted 50 async evaluations
Poll 1: 12/50 completed
Poll 2: 34/50 completed
Poll 3: 50/50 completed
Completed 50/50 evaluationsThe poll counts and round timing above are illustrative and vary with your account tier and network conditions.
Unlike step 4, submission here never blocks on a response: every item is submitted before any result is awaited, so the polling loop is the only place this step waits.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
The standalone evaluate() call blocks and returns a score, no eval_id to poll | evaluate() ends in **inputs, so is_async=True is silently absorbed as an eval input and ignored rather than raising an error | Use Evaluator().evaluate(..., is_async=True) instead; the standalone evaluate() has no async mode |
IndexError on result.eval_results[0] | eval_templates name is misspelled, or the request returned zero results | Check the eval name against the supported templates and confirm model_name is a valid model |
Loop prints Timed out waiting for result | The eval is still processing after 15 poll attempts, usually under turing_large on a busy account | Raise the attempt count or time.sleep interval, or switch to turing_small/turing_flash for faster turnaround |
401 or 403 from any SDK call | FI_API_KEY or FI_SECRET_KEY is missing, unexported, or expired | Re-export both keys in the current shell and confirm them under app.futureagi.com |
Frequent 429 errors during the parallel step | max_workers is too high for your account’s rate limit | Lower ThreadPoolExecutor(max_workers=...) or chunk large batches with a short sleep between chunks |
Loop never sees eval_status == "completed" and always times out | The status key was read as evalStatus instead of eval_status in older copies of this code, or the eval_id being polled doesn’t match what was submitted, or the polling call uses different FI_API_KEY/FI_SECRET_KEY values | Confirm the loop checks inner.get("eval_status"), print and confirm the eval_id returned at submission, and poll with the same credentials used to submit |
For dataset-level batch evaluation over a CSV, see Dataset SDK: Batch Evaluation.
Questions & Discussion