Requires Go SDK v0.18.0 or later. The Go SDK saves run state in its own format, so start and resume a run with the Go SDK.
How workflow evaluations work
A workflow evaluation has three stages:- Define an evaluation: Build an
eval.WorkflowEvalwith a task, scorers, and a store, and attach your Braintrust client withbraintrust.NewWorkflowEval. It works likeeval.Eval, except that the task or scorers can send their work to a provider’s asynchronous API. - Start the run: Call
Startwith your dataset to submit the requests. It returns a run ID right away, without waiting for the provider. - Resume until completion: Pass the run ID to
Pollon a schedule, or pass a provider’s completion event toProcessSubmissionResultfrom a webhook handler. Each call collects the results that are ready and scores them. The run is done when its status iseval.WorkflowRunCompleted.
Example: OpenAI Batch API
This example sends questions about capital cities through the OpenAI Batch API and scores each answer against the expected city. Here’s what the program does:- Submits the cases to OpenAI together, in one batch.
- Polls OpenAI until the batch finishes, then collects each answer.
- Scores each answer with
exactMatch, an ordinary scorer, and logs the results to Braintrust.
BRAINTRUST_API_KEY and OPENAI_API_KEY environment variables:
evaluation.go. They live in their own file so the Redis and webhook programs later on this page can reuse them:
evaluation.go
main.go:
main.go
go run evaluation.go main.go. OpenAI batches can take minutes or hours, so the program can run for a while. After each Poll, it prints the run’s status and how many batches are still pending. When it finishes, the Braintrust experiment contains two rows, each with an exact_match score.
The rest of this section walks through the example’s three stages.
Define an evaluation
First,newEvaluation in evaluation.go defines the evaluation. In outline, the definition looks like this:
braintrust.NewWorkflowEval.
Project, experiment, and client
Project, experiment, and client
ProjectName sets the Braintrust project, "capital-cities-eval" in the example. Braintrust creates the project if it doesn’t exist. Without ProjectName, the evaluation uses the project configured on the client. Each run logs to a new experiment named after Name, unless you set Experiment in eval.WorkflowStartOpts.braintrust.NewWorkflowEval attaches your Braintrust client, so runs create an experiment and log results. Every process that starts or resumes a run must use a definition attached this way. To run without Braintrust, set NoSendLogs in eval.WorkflowStartOpts. The run state is still saved to the store.Name also identifies the run’s records in the store, so keep it, the scorer names, and their order unchanged while runs are active. A process that loads a run with a different definition returns an error.Data
Data
The dataset goes to
Start in eval.WorkflowStartOpts, not in the definition. It accepts the same eval.Dataset values as eval.Eval, such as one built with eval.NewDataset. The SDK saves every case to the store when the run starts, so later calls don’t need the dataset.Each case’s ID becomes its case ID in the experiment rows. When a case has no ID, the SDK generates one when the run starts. Cases can also set Metadata, Tags, and TrialCount. Inputs, expected values, and metadata must be JSON-serializable, so the store can save them.Task
Task
The
Task field accepts an ordinary task function built with eval.T, an eval.WorkflowBatchTask, or an eval.WorkflowTask. Use an eval.WorkflowBatchTask when the provider accepts many requests at once and returns their results later, as batch APIs do. The example’s task has these fields:Batching:MaxSizesets the most items in one batch.Startsubmits every case and trial right away, in batches of up toMaxSizeitems.Submit: Sends one batch to the provider. Receives a slice ofeval.WorkflowBatchItemvalues, one for each case and trial, each with the case’s fields inItemand aCustomIDthat identifies the item. Use theCustomIDas the provider’s request ID, such as OpenAI’scustom_id. Returns ajson.RawMessagehandle, such as the provider’s batch ID. The SDK saves it and passes it to theCompletionandCollectcallbacks.Completion: How the SDK learns the batch is done: by polling with a function such as the example’spollBatch, or from a webhook. Set exactly one of itsPollandGetExternalIDfields. Seeeval.WorkflowCompletion.Collect: Fetches the finished batch’s results and callsyieldonce for each item with aneval.WorkflowBatchResult, which has theCustomIDand either aResultwith the task’sOutputor anError. Return right away whenyieldreturns an error. The SDK records an item that has an error or no result as failed: its row shows the error, and its scorers don’t run.
{"batch_id": "..."}.Every callback also receives an eval.WorkflowSubmissionContext with the run ID and a SubmissionID. The SubmissionID stays the same if the SDK calls Submit again for the same work, so use it as the provider’s idempotency key if the provider supports one. The example stores both IDs in the batch’s metadata.To send each case as its own request instead, use an eval.WorkflowTask. See Submit one request per case.Scorers
Scorers
Ordinary scorers, like the example’s Then add
exactMatch, go in Scorers, and classifiers go in Classifiers. They run in your process as soon as a case’s output is collected. Asynchronous scorers go in WorkflowScorers, which accepts eval.WorkflowBatchScorer and eval.WorkflowScorer values. Use an eval.WorkflowBatchScorer when scoring needs requests that finish later, such as LLM-as-a-judge calls through a batch API.An eval.WorkflowBatchScorer takes the same fields as an eval.WorkflowBatchTask, plus a Name. Its items are eval.WorkflowScorerItem values, which include the task’s Output along with the case’s fields, and each result’s Result is an eval.Scores. A failed item omits that score and records the error in the row’s scorer_errors metadata. See eval.WorkflowBatchScorer.An eval.WorkflowBatchScorer submits a batch once MaxSize items have task outputs, or once no more items can get one. To submit partial batches sooner, set Batching.MaxWait. The SDK submits a partial batch that has waited that long on the next Start, Poll, or ProcessSubmissionResult call.Example: Score with a batch requestThe following newLLMJudge scorer asks a model to grade each answer through the OpenAI Batch API. It reuses the example’s submitBatch, pollBatch, and collectText. To try it, add it to the end of evaluation.go:WorkflowScorers: []eval.WorkflowScorerDefinition[string, string]{newLLMJudge(client)} to the definition in newEvaluation and run the program again. Your exactMatch scorer runs as soon as the answers arrive. The same Poll submits one grading batch for both answers, and a later Poll collects the grades.Store
Store
The
Store field saves the run’s progress between calls:eval.NewWorkflowEvalMemoryStore(): Keeps progress in memory. Use it when the whole run happens in one process, as in the example.eval.NewWorkflowEvalRedisStore(): Keeps progress in Redis, through a function that runs Redis commands with your Redis client. Use it when the run must survive process exits or be resumed by another process. Records expire after seven days by default.
Start the run
Next,main in main.go starts a run by calling Start, which creates the experiment, submits the first batches, and returns an *eval.WorkflowResult with the RunID, without waiting for the provider. With an ordinary task function, Start runs the task and the ordinary scorers for every case before it returns.
Save the RunID. You pass it to Poll and Status. If a callback fails after the run is saved, Start returns the run ID along with the error, so you can still resume the run.
Resume until completion
Finally, the example callsPoll in a loop until every case is scored. A waiting run can resume in either of two ways, or both:
- Polling: Call
evaluation.Poll(ctx, runID)on a schedule. - Webhooks: Call
evaluation.ProcessSubmissionResult()when the provider sends a completion event.
Poll to collect the scorer results.
Serialize the calls that advance a run, including across processes. The store’s operations are atomic, but the SDK doesn’t lock a run while it advances it. Different runs can advance at the same time.
Check progress. Each call returns a result whose Status stays eval.WorkflowRunWaiting until the run completes, and whose PendingPoll and PendingWebhook fields count the batches not yet collected. To check without advancing the run, call evaluation.Status(ctx, runID).
Get the results. When the status is eval.WorkflowRunCompleted, the result’s Scores has the average of each score, Failed counts the cases with a failed request or scorer, and Permalink links to the experiment.
Resume with polling
Resume with polling
The main example runs in one process that waits until the run finishes, and it keeps progress in memory.For runs that take hours, an alternative is a separate program, such as the following To use it:
resume.go, that runs in place of main.go. Each time it runs, it does one step and exits, and it saves progress in Redis so the next run continues where the last one stopped. It reuses newEvaluation from evaluation.go. It requires the go-redis client (go get github.com/redis/go-redis/v9) and a REDIS_URL environment variable, such as redis://localhost:6379:resume.go
- Running
go run evaluation.go resume.gowith no arguments submits the requests and prints the run ID. - Running
go run evaluation.go resume.go <run-id>advances the run once. A scheduled job can run it periodically until it printscompleted.
Resume from provider webhooks
Resume from provider webhooks
Use webhook completion when your provider sends an event when a request finishes. For OpenAI, configure a webhook endpoint for Set
batch.completed, batch.failed, batch.expired, and batch.cancelled events, and use a shared Redis store. The following webhook.go reuses newEvaluation from evaluation.go, with webhook completion:webhook.go
OPENAI_WEBHOOK_SECRET to the endpoint’s signing secret from OpenAI. Start a run with go run evaluation.go webhook.go start, then serve the endpoint with go run evaluation.go webhook.go. The handler returns an error status when ProcessSubmissionResult fails, so OpenAI redelivers the event.Keep in mind:- Failed batches: The example passes
batch.failed,batch.expired, andbatch.cancelledevents toProcessSubmissionResultalong withbatch.completed. The SDK records each request without a result as failed, so the run still completes. - Run ID: The example passes only the batch ID as
ExternalID. That works when each batch ID belongs to one run of the evaluation, as OpenAI batch IDs do. If several runs can share an external ID, also setRunID. - Security:
ProcessSubmissionResultdoesn’t check that the event came from the provider. In the example,Unwrapdoes. - Repeated events: The same event can arrive more than once. The SDK ignores a repeated event for a submission it already collected, but your
Collectcallbacks can run more than once, so keep them free of side effects that can’t repeat.
eval.WorkflowSubmissionResult.Handle failures and concurrency
Handle failures and concurrency
- Concurrency:
MaxConcurrencylimits how many of your callbacks the SDK runs at once during eachStart,Poll, orProcessSubmissionResultcall. It defaults to10. For a batch task or scorer, eachSubmitcall sends one batch. - Provider failures: When your polling callback returns
eval.WorkflowFailed,Pollreturns an error. The SDK checks the batch again on the nextPoll, so a permanent failure makes every laterPollreturn an error, and the run never completes. Start a new run to recover. The example’spollBatchavoids this by reportingeval.WorkflowCompletefor failed, expired, and cancelled batches, soCollectrecords the requests without results as failed. The SDK doesn’t resubmit or cancel requests that the provider accepted, so retries and cancellation are up to your integration. - Errors in your callbacks: For polling, the SDK calls your polling callback and your
Collectcallback again on the nextPoll. For webhooks,Collectruns again only whenProcessSubmissionResultis called again, such as when the provider redelivers the event. If yourSubmitcallback returns an error, the nextPollcalls it again with the same items andSubmissionID, for both polling and webhook completion. A crash after the provider accepts a request but before the SDK saves the handle can also callSubmitagain, so use theSubmissionIDas an idempotency key if your provider supports one. - Task output: Task outputs must be JSON-serializable. An ordinary task function that returns
eval.TaskOutputwithUserDatafails, because the store can’t save it. Use metadata instead.
Submit one request per case
Aneval.WorkflowTask or eval.WorkflowScorer submits one request for each case and trial instead of batching them. Use one when your provider’s asynchronous API takes one request per job, or when each case’s scorers should start as soon as that case’s output is ready.
The following task sends each case to OpenAI as its own single-request batch. To try it, replace the eval.WorkflowBatchTask in newEvaluation with this task, and add "errors" to the imports in evaluation.go:
eval.WorkflowBatchTask in these ways:
- Items:
Submitreceives oneeval.WorkflowTaskItem, without aCustomID. TheSubmissionIDis unique to each case and trial, so the example uses it as OpenAI’scustom_id. - Results:
Collectreturns one result, aneval.WorkflowTaskResultfor aneval.WorkflowTaskor aneval.Scoresfor aneval.WorkflowScorer, instead of callingyield. - Failures: If
Collectreturns an error, the SDK calls it again on the nextPoll, so a request that can’t succeed, such as one in an expired batch, keeps the run waiting. Start a new run to recover. - Scorers: An
eval.WorkflowScorersubmits each case’s request as soon as that case’s task output is ready.
eval.WorkflowTask and eval.WorkflowScorer.
Next steps
- Review
braintrust.NewWorkflowEvaland theWorkflowEvalmethods. - Learn how to run experiments in code.
- Add custom scorers to measure task quality.