.NET 10 多智能體工作流:建立信任前要寫的 5 項測試
5 Tests I Write Before I Trust a Multi-Agent Workflow in .NET 10
作者以 Microsoft Agent Framework 1.24.0 和 .NET 10 建立支援工單分流工作流,並先為它編寫五項失敗路徑測試。測試涵蓋回覆防護、敏感工單轉交人工處理、格式錯誤輸出、模型故障及 12 張工單並行處理;使用確定性離線模型後,測試無需 API key 或網絡即可執行。整套測試共 58 項,包括 51 項單元測試和 7 項整合測試。
Every multi-agent demo I've seen shows the happy path. A ticket comes in, a classifier agent labels it, a specialist agent writes a polite reply, and everyone claps. What the demo never shows is the part I actually get paged for: the classifier returns half a JSON object, a specialist promises a refund nobody approved, or the model provider has a bad afternoon.
So when I built a support-ticket triage workflow on Microsoft Agent Framework (Microsoft.Agents.AI.Workflows 1.24.0 on .NET 10), I wrote the unhappy-path tests first. The workflow has one classifier agent, two specialist agents (billing and technical), and plain C# steps for intake, routing, escalation, human review and a reply guard. The full suite is 58 tests (51 unit, 7 integration), and all of them run with no API key and no network.
This post isn't a tour of the framework. It's the five tests I'd write first for any agent workflow, the bug each one catches, and the shape of the code behind it. The complete project is in Tech Skill Builder.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.
Before the tests: a fake model you can steer
You can't test failure paths against a real LLM. It won't fail when you ask it to, and it will fail when you don't. So the first thing I wrote was OfflineTriageChatClient, a deterministic IChatClient that every agent shares.
The trick that makes one fake serve three agents: ChatClientAgent passes the agent's instructions through ChatOptions.Instructions, so the fake can tell who is calling and answer in that role:
var instructions = options?.Instructions ?? string.Empty; // simplified; the real fake also checks system messages
var text = instructions switch
{
_ when instructions.Contains(TriageInstructions.Classifier, StringComparison.Ordinal) => Classify(input),
_ when instructions.Contains(TriageInstructions.BillingSpecialist, StringComparison.Ordinal) => BillingReply(input),
_ when instructions.Contains(TriageInstructions.TechnicalSpecialist, StringComparison.Ordinal) => TechnicalReply(input),
_ => "I am an offline model and I do not know this role.",
};
// Classify() returns cut-off JSON when the ticket contains "#malformed-json".
// TechnicalReply() throws HttpRequestException when it contains "#model-outage".
// Call counting, keyword rules and reply templates: full fake in the complete project.
Those two trigger strings are public constants (MalformedJsonTrigger and ThrowTrigger), so a test can ask for a specific failure by name. The fake also exposes a CallCount, which turns out to be one of the most useful assertions in the whole suite.
Test 1: The agent that says too much
The bug: a specialist writes something no human would approve. My fake billing specialist is deliberately over-eager, the way real models often are. Ask it for "$480 for everything" and it replies with "I have requested a refund of $480 ... We guarantee this will be resolved today."
The defense: specialists never have the last word. Both of them feed a single reply-guard step, which runs every draft through a deterministic ReplyPolicyGuard. It checks refund amounts against MaxAutoRefund, "guarantee" language, foreign email addresses, card-like digit runs and length.
The test:
[Fact]
public async Task Large_refund_is_blocked_by_guard_and_keeps_draft()
{
var (result, _) = await RunAsync("Refund", "Refund $480 for everything please.");
Assert.Equal(OutcomeStatus.NeedsHumanReview, result.Outcome.Status);
Assert.Contains(result.Outcome.Reasons, r => r.StartsWith("RefundAboveLimit", StringComparison.Ordinal));
Assert.Contains(result.Outcome.Reasons, r => r.StartsWith("UnapprovedPromise", StringComparison.Ordinal));
Assert.NotNull(result.Outcome.ReplyDraft);
}
Two details I care about. I assert on the reason prefix, not the full message, so rewording an error doesn't break the test. And I assert that the draft is kept. A reviewer would much rather edit a draft than start from an empty box. An integration test also lowers Triage:MaxAutoRefund to 10, and then the same kind of $29 refund that normally passes gets blocked. That proves the limit really comes from configuration.
Test 2: The path that must not call a model
The bug: a "my account was hacked" ticket gets a cheerful, model-written reply. On a security ticket, you don't want a draft at all. You want a person.
The defense: the routing rules live in a plain TriageRoutingPolicy class, and security or critical urgency goes straight to the escalate step, even at low confidence.
The test: the outcome alone isn't enough here. I count how many times the model was called:
var (result, model) = await RunAsync("Account hacked", "Someone changed my email, I think I was hacked.");
Assert.Equal(OutcomeStatus.Escalated, result.Outcome.Status);
Assert.Null(result.Outcome.ReplyDraft);
Assert.Equal(1, model.CallCount); // the classifier, and nobody else
// audit-trail assertion (intake -> classify -> escalate): in the complete project
The same idea works on the happy path: the small-refund test asserts CallCount == 2 (classifier plus billing specialist). If someone later adds a "quick sanity check" agent call in the middle of the graph, this test is the one that notices the extra cost.
Because routing is a pure function, most of its coverage doesn't need a workflow at all. It's a [Theory] table:
[Theory]
[InlineData(TicketCategory.Billing, TicketUrgency.Normal, 0.9, true, TriageRoute.Billing)]
[InlineData(TicketCategory.Billing, TicketUrgency.Normal, 0.59, true, TriageRoute.HumanReview)]
[InlineData(TicketCategory.Security, TicketUrgency.Normal, 0.2, true, TriageRoute.Escalate)]
// ...five more rows (invalid output, unknown category, critical urgency) in the complete project
public void Routes_decisions(TicketCategory category, TicketUrgency urgency, double confidence, bool valid, TriageRoute expected)
{
var decision = new TriageDecision("TCK-1", category, urgency, confidence, "r", valid);
Assert.Equal(expected, _policy.Route(decision));
}
Test 3: Half a JSON object
The bug: the classifier hits a token limit mid-object and returns something like { "category": "billing", "confidence":. If that crashes the run, or worse, gets read as "billing with confidence 0", the ticket ends up in the wrong place.
The defense: the classify step calls the agent with RunAsync<ClassifierVerdict>(). Reading .Result on broken output throws JsonException, and the step catches it and returns an invalid decision (ClassifierOutputValid = false). The routing policy sends every invalid decision to a human. That's what "fail closed" means in practice.
The test:
[Fact]
public async Task Malformed_classifier_json_fails_closed_to_human_review()
{
var (result, _) = await RunAsync("Invoice", $"My invoice is wrong {OfflineTriageChatClient.MalformedJsonTrigger}");
Assert.Equal(OutcomeStatus.NeedsHumanReview, result.Outcome.Status);
Assert.Contains("could not be parsed", Assert.Single(result.Outcome.Reasons), StringComparison.Ordinal);
}
A related habit: the verdict contract uses strings, not enums. Models drift between "Billing", "billing " and "payments", so a VerdictMapper normalizes them in one place and clamps confidence to 0..1. Strict enums at the edge of a model just give you more deserialization failures to test.
Test 4: The model outage that looks like silence
The bug: this is the one that surprised me. When the technical specialist throws, InProcessExecution.RunAsync does not throw. It returns normally, and the exception shows up as an ExecutorFailedEvent (plus a WorkflowErrorEvent) in the run's events. If your code only looks for output events, a failed run looks exactly like "the workflow had nothing to say."
The defense: the runner treats anything other than exactly one terminal outcome as a failure:
if (failure is not null || outcomes.Count != 1)
{
// log it, add a "runner:" line to the audit trail,
// and return TriageOutcome.Failed(ticketId, reason)
}
// the loop over run.OutgoingEvents that fills `failure` and `outcomes`: full runner in the complete project
The test:
[Fact]
public async Task Executor_exception_becomes_failed_outcome_not_a_throw()
{
var (result, _) = await RunAsync("App crash", $"Error 500 on save {OfflineTriageChatClient.ThrowTrigger}");
Assert.Equal(OutcomeStatus.Failed, result.Outcome.Status);
Assert.Contains("technical-specialist", Assert.Single(result.Outcome.Reasons), StringComparison.Ordinal);
}
The reason names the step that failed, which is the first thing you want during an incident. At the HTTP level, a matching integration test checks that the API returns 502 for a Failed outcome. DraftReady, NeedsHumanReview and Escalated all return 200. A ticket waiting for a person is a normal result, not a server problem.
Test 5: Twelve tickets at once
The bug: my first API version kept a single Workflow instance as a singleton. One request at a time, it worked fine. Two at once, and the second one failed with InvalidOperationException, because in Agent Framework 1.24 a workflow instance belongs to one runner at a time.
The defense: a TriageWorkflowFactory whose Create() builds a fresh graph for every run. It's a handful of small objects, so it costs nothing you'd notice.
The tests: I keep two, on purpose. One proves the trap is real, so nobody "optimizes" the factory back into a singleton:
var workflow = provider.GetRequiredService<TriageWorkflowFactory>().Create();
await using var first = await InProcessExecution.RunAsync(workflow, new TicketSubmission("a@example.com", "Hi", "dark mode?"));
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await InProcessExecution.RunAsync(workflow, new TicketSubmission("b@example.com", "Hi", "dark mode?")));
The other runs 12 tickets in parallel through the real runner and asserts that all 12 come back DraftReady with 12 distinct ticket IDs.
Bonus: swap the model, keep the graph
One integration test removes the registered IChatClient and replaces it with a stub that labels everything "general". The same request that normally produces a billing draft now goes to human review. Nothing in the workflow changes. That's the payoff of keeping the decision logic in typed C# instead of in prompts: you can switch models, and the tests tell you exactly what moved.
What these tests don't prove
The fake model proves the workflow is right: the routing, the guard, the failure handling and concurrency. It says nothing about whether a real model classifies your tickets well or writes good replies. For that you need evaluations against real outputs, and I treat that as a separate job. The regex guard is also a first line of defense, not a complete safety system. It's cheap, explainable and immune to prompt injection, but you may still want a moderation model behind it.
Get the complete project
The snippets above show the shape of each test. The complete, working version is in Tech Skill Builder: the Core library (domain records, routing policy, reply guard, options and validation), the Agent Framework workflow with all its steps and the runner, the offline model, a minimal API, a CLI demo and all 58 tests, plus a step-by-step PDF guide. It runs with no API key, so you can run dotnet test first and plug in your own model after.
Tech Skill Builder gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. 👉 Join Tech Skill Builder
How do you test the unhappy paths in your own agent workflows: a scripted fake like this, recorded responses, or live calls in a nightly job?
來源:DEV Community · dev.to