A/B test agent deployments
Run two versions of an agent side by side, route a percentage of traffic to each, and compare results from the workflow DAG.
Run two versions of the same agent at the same time. They share one node_id, register with different version values, and the control plane splits traffic when callers use the normal execution endpoint.
The only SDK-level setting that distinguishes the variants is the agent's version and tags. Traffic splitting and routing live entirely in the control plane (see the REST calls below).
# agent_v1.py -- control
from agentfield import Agent
app = Agent(
node_id="summarizer",
version="2.0.0",
tags=["summarizer", "variant:control"],
)
@app.reasoner()
async def summarize(text: str) -> dict:
result = await app.ai(system="Summarize concisely.", user=text)
return {"variant": "control", "summary": str(result)}
app.run(port=9200)# agent_v2.py -- treatment
from agentfield import Agent
app = Agent(
node_id="summarizer",
version="3.0.0",
tags=["summarizer", "variant:treatment"],
)
@app.reasoner()
async def summarize(text: str) -> dict:
result = await app.ai(
system="Summarize concisely. Lead with the headline finding.",
user=text,
)
return {"variant": "treatment", "summary": str(result)}
app.run(port=9201)// agent_v1.ts -- control
import { Agent } from "@agentfield/sdk";
const app = new Agent({
nodeId: "summarizer",
version: "2.0.0",
tags: ["summarizer", "variant:control"],
});
app.reasoner("summarize", async (ctx) => {
const summary = await ctx.ai(ctx.input.text, { system: "Summarize concisely." });
return { variant: "control", summary };
});
app.serve();// agent_v2.ts -- treatment
import { Agent } from "@agentfield/sdk";
const app = new Agent({
nodeId: "summarizer",
version: "3.0.0",
tags: ["summarizer", "variant:treatment"],
});
app.reasoner("summarize", async (ctx) => {
const summary = await ctx.ai(ctx.input.text, {
system: "Summarize concisely. Lead with the headline finding.",
});
return { variant: "treatment", summary };
});
app.serve();// agent_v1.go -- control
package main
import (
"context"
"fmt"
"log"
"github.com/Agent-Field/agentfield/sdk/go/agent"
"github.com/Agent-Field/agentfield/sdk/go/ai"
)
func main() {
a, err := agent.New(agent.Config{
NodeID: "summarizer",
Version: "2.0.0",
Tags: []string{"summarizer", "variant:control"},
AgentFieldURL: "http://localhost:8080",
AIConfig: &ai.Config{Model: "anthropic/claude-sonnet-4-20250514"},
})
if err != nil {
log.Fatal(err)
}
a.RegisterReasoner("summarize", func(ctx context.Context, input map[string]any) (any, error) {
text := fmt.Sprintf("%v", input["text"])
resp, err := a.AI(ctx, text, ai.WithSystem("Summarize concisely."))
if err != nil {
return nil, err
}
return map[string]any{"variant": "control", "summary": resp.Text()}, nil
})
if err := a.Serve(context.Background()); err != nil {
log.Fatal(err)
}
}The treatment build is identical except for Version: "3.0.0", Tags: []string{"summarizer", "variant:treatment"}, and a system prompt of "Summarize concisely. Lead with the headline finding.".
Call the shared target. The control plane chooses a healthy registered version and returns the selected version in the response headers.
curl -i -X POST http://localhost:8080/api/v1/execute/summarizer.summarize \
-H "Content-Type: application/json" \
-d '{"input": {"text": "AgentField gives agents backend-shaped deployment controls."}}'
# Look for:
# X-Routed-Version: 3.0.0Adjust the rollout with traffic weights:
# Send roughly 90% to v2.0.0 and 10% to v3.0.0.
curl -X PUT http://localhost:8080/api/v1/connector/reasoners/summarizer/versions/2.0.0/weight \
-H "X-Connector-Token: $AGENTFIELD_CONNECTOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"weight": 90}'
curl -X PUT http://localhost:8080/api/v1/connector/reasoners/summarizer/versions/3.0.0/weight \
-H "X-Connector-Token: $AGENTFIELD_CONNECTOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"weight": 10}'For rollback, set the treatment weight to 0 or stop the candidate agent. The execution API keeps the same target, so callers do not need a deploy-time switch.
If the choice needs request-specific logic, keep the split inside a reasoner instead:
# router.py -- choose variants with custom logic
import hashlib
from agentfield import Agent
app = Agent(node_id="summarizer-router")
@app.reasoner()
async def route_summarize(request_id: str, text: str) -> dict:
# Discover both variants -- health-aware so dead agents drop out automatically.
candidates = app.discover(tags=["summarizer"], health_status="active")
# Stable bucketing on request_id -- same request always hits the same variant.
bucket = int(hashlib.sha256(request_id.encode()).hexdigest(), 16) % 100
target_tag = "variant:treatment" if bucket < 10 else "variant:control"
chosen = next(
cap for cap in candidates.json.capabilities
if target_tag in (cap.tags or [])
)
target = f"{chosen.agent_id}.summarize"
result = await app.call(target, text=text)
app.note(f"routed {request_id} to {target}", tags=["ab-test", target_tag])
return result
app.run()// router.ts -- choose variants with custom logic
import { Agent } from "@agentfield/sdk";
import { createHash } from "crypto";
const app = new Agent({ nodeId: "summarizer-router" });
app.reasoner("routeSummarize", async (ctx) => {
const { requestId, text } = ctx.input;
// Discover both variants -- health-aware so dead agents drop out automatically.
const candidates = await app.discover({ tags: ["summarizer"], healthStatus: "active" });
// Stable bucketing on requestId -- same request always hits the same variant.
const hash = createHash("sha256").update(requestId).digest("hex");
const bucket = Number(BigInt("0x" + hash) % 100n);
const targetTag = bucket < 10 ? "variant:treatment" : "variant:control";
// Variant tags are carried on each agent's summarize reasoner.
const chosen = candidates.json?.capabilities.find((cap) =>
cap.reasoners.some((r) => r.id === "summarize" && r.tags.includes(targetTag))
);
const target = `${chosen?.agentId}.summarize`;
const result = await app.call(target, { text });
app.note(`routed ${requestId} to ${target}`, ["ab-test", targetTag]);
return result;
});
app.serve();// router.go -- choose variants with custom logic
package main
import (
"context"
"crypto/sha256"
"fmt"
"log"
"math/big"
"github.com/Agent-Field/agentfield/sdk/go/agent"
)
func main() {
a, err := agent.New(agent.Config{
NodeID: "summarizer-router",
Version: "1.0.0",
AgentFieldURL: "http://localhost:8080",
})
if err != nil {
log.Fatal(err)
}
a.RegisterReasoner("route_summarize", func(ctx context.Context, input map[string]any) (any, error) {
requestID := fmt.Sprintf("%v", input["requestId"])
text := fmt.Sprintf("%v", input["text"])
// Discover both variants -- health-aware so dead agents drop out automatically.
candidates, err := a.Discover(ctx,
agent.WithTags([]string{"summarizer"}),
agent.WithHealthStatus("active"),
)
if err != nil {
return nil, err
}
// Stable bucketing on requestID -- same request always hits the same variant.
sum := sha256.Sum256([]byte(requestID))
bucket := new(big.Int).Mod(new(big.Int).SetBytes(sum[:]), big.NewInt(100)).Int64()
targetTag := "variant:control"
if bucket < 10 {
targetTag = "variant:treatment"
}
// Variant tags are carried on each agent's summarize reasoner.
var target string
for _, cap := range candidates.JSON.Capabilities {
for _, r := range cap.Reasoners {
if r.ID == "summarize" && contains(r.Tags, targetTag) {
target = fmt.Sprintf("%s.summarize", cap.AgentID)
}
}
}
result, err := a.Call(ctx, target, map[string]any{"text": text})
if err != nil {
return nil, err
}
a.Note(ctx, fmt.Sprintf("routed %s to %s", requestID, target), "ab-test", targetTag)
return result, nil
})
if err := a.Serve(context.Background()); err != nil {
log.Fatal(err)
}
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}After running both for a day, compare cost, latency, errors, and output quality by routed version or variant tag. Both registered versions show up side by side in the Agent nodes page with their tags, version strings, and live health:
What this gives you
- Native control-plane routing for canaries and A/B tests without changing callers.
- Traffic weights you can adjust through REST as the rollout progresses.
- A programmable reasoner pattern when routing depends on request content, account rules, or an LLM decision.
Next