<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Anthony Wang — Writing</title><description>Anthony Wang — Principal Architect, Google Cloud APAC. Writing on enterprise AI architecture, agentic systems, and taking AI from demo to production across APAC&apos;s largest enterprises.</description><link>https://anthonywang.cc/</link><language>en</language><item><title>Evaluating multi-agent systems: a practical loop with Google&apos;s ADK</title><link>https://anthonywang.cc/writing/evaluating-multi-agent-systems-adk/</link><guid isPermaLink="true">https://anthonywang.cc/writing/evaluating-multi-agent-systems-adk/</guid><description>A working evaluation loop for multi-agent systems built on Google&apos;s Agent Development Kit — datasets, deterministic checks, LLM judges, and the failure modes that actually show up in production.</description><pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every enterprise agent program reaches the same moment: the demo works, leadership is convinced, and then someone changes a prompt. Is the system better now, or worse? Most teams cannot answer that question with evidence. That inability — not model quality — is what keeps agentic AI stuck between pilot and production.&lt;/p&gt;
&lt;p&gt;This piece describes an evaluation loop that works in practice for multi-agent systems built on Google’s Agent Development Kit (ADK). It is deliberately minimal: a case format, a runner, deterministic checks first, LLM judges only where they earn their keep.&lt;/p&gt;
&lt;h2 id=&quot;why-evaluation-is-the-hard-part&quot;&gt;Why evaluation is the hard part&lt;/h2&gt;
&lt;p&gt;Traditional software has tests because behavior is deterministic. Classic ML has benchmarks because tasks are fixed. Agent systems have neither property: the same input can produce different tool-call sequences, and the “task” is an open-ended conversation with side effects. Meanwhile the things that change most often — prompts, tool descriptions, model versions, agent topology — are exactly the things with no compiler and no type checker.&lt;/p&gt;
&lt;p&gt;The result is a system where regressions are silent. A prompt tweak that improves refund handling quietly breaks escalation behavior. A model upgrade that lifts reasoning quality doubles tool-call latency. Without a repeatable loop, every change is a bet, and the organization’s appetite for bets runs out long before the backlog does.&lt;/p&gt;
&lt;h2 id=&quot;the-shape-of-the-problem&quot;&gt;The shape of the problem&lt;/h2&gt;
&lt;p&gt;“Is the agent good?” decomposes into four questions that need separate instruments:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Task success&lt;/strong&gt; — did the user get the right outcome?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool-call correctness&lt;/strong&gt; — did the agent call the right tools, with the right arguments, and &lt;em&gt;only&lt;/em&gt; those tools?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Coordination quality&lt;/strong&gt; — in multi-agent setups: did handoffs occur at the right moments, did agents share state instead of re-deriving it, did any agent do redundant work?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operational cost&lt;/strong&gt; — tokens, latency, and retries per completed task.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Tool-call correctness and cost are deterministic and cheap to check. Task success and coordination quality usually need judgment. Order matters: teams that start with an LLM judge for everything drown in noise; teams that start with deterministic checks catch most regressions for free.&lt;/p&gt;
&lt;h2 id=&quot;building-an-eval-dataset&quot;&gt;Building an eval dataset&lt;/h2&gt;
&lt;p&gt;The dataset is the asset with the longest half-life — models and prompts will change; good cases won’t. Three sources, in order of value:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Failures from real traffic.&lt;/strong&gt; Every escalation, thumbs-down, and support ticket involving the agent is a case. These carry the distributional quirks synthetic data never has: ambiguous phrasing, mixed intents, wrong assumptions stated confidently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Boundary cases from policy.&lt;/strong&gt; Anything with an approval threshold, a compliance rule, or an irreversible side effect gets a case pair: one just inside the boundary, one just outside. This is where the &lt;code&gt;must_not_call_tools&lt;/code&gt; assertion does most of its work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Synthetic broadening.&lt;/strong&gt; Generate variations of the above — paraphrases, locale changes, adversarial phrasings — but review them by hand. Fifty curated cases beat five hundred generated ones; a case whose expectation is wrong is worse than no case.&lt;/p&gt;
&lt;p&gt;Expectations should describe &lt;em&gt;observable behavior&lt;/em&gt;, not exact wording:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &quot;id&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;refund-over-limit-001&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &quot;input&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;I need a refund of $4,200 for order 88231&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &quot;expectations&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &quot;must_call_tools&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;lookup_order&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;check_refund_policy&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &quot;must_not_call_tools&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;issue_refund&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &quot;outcome&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;escalates to a human approver because the amount exceeds the auto-approval limit&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;wiring-up-adk-for-eval-runs&quot;&gt;Wiring up ADK for eval runs&lt;/h2&gt;
&lt;p&gt;ADK’s event stream is what makes this tractable: every tool call, agent transfer, and final response is observable as the run executes. The harness runs each case in a fresh session, collects the events, and applies checks.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;async&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; for&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; event &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; runner.run_async(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#E36209;--shiki-dark:#FFAB70&quot;&gt;    user_id&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;eval-user&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#E36209;--shiki-dark:#FFAB70&quot;&gt;session_id&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;session.id, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#E36209;--shiki-dark:#FFAB70&quot;&gt;new_message&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;case[&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;input&quot;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    for&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; call &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; event.get_function_calls() &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;or&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; []:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        result.tool_calls.append(call.name)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; event.is_final_response() &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;and&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; event.content:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        result.final_response &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; event.content.parts[&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;].text&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details carry most of the value. First, &lt;strong&gt;capture everything, judge later&lt;/strong&gt;: persist the full event trace per case as JSONL, so a scoring change never requires re-running the agent. Second, &lt;strong&gt;fresh state per case&lt;/strong&gt;: shared sessions leak context between cases and make failures irreproducible.&lt;/p&gt;
&lt;p&gt;The full harness — case loading, deterministic checks, result reporting — is about sixty lines; a copy-ready version is published as &lt;a href=&quot;/resources/adk-eval-harness-starter&quot;&gt;the ADK eval harness starter&lt;/a&gt; on the resources tab.&lt;/p&gt;
&lt;h2 id=&quot;llm-as-judge-carefully&quot;&gt;LLM-as-judge, carefully&lt;/h2&gt;
&lt;p&gt;Deterministic checks can’t score the &lt;code&gt;outcome&lt;/code&gt; expectation, so a judge model compares the agent’s final response and trace against the expected outcome. Three rules keep judges honest:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rubrics, not vibes.&lt;/strong&gt; The judge prompt asks for a binary decision against a specific criterion (“does the response escalate rather than commit to the refund?”), never a 1–10 quality score. Scalar scores drift; binary criteria are auditable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Calibrate before trusting.&lt;/strong&gt; Label thirty cases by hand, run the judge, and measure agreement. Below roughly 90% agreement, fix the rubric — or the expectation — before wiring the judge into any decision.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Judge the trace, not just the text.&lt;/strong&gt; A response that &lt;em&gt;says&lt;/em&gt; “I’ve escalated this” while the trace shows an &lt;code&gt;issue_refund&lt;/code&gt; call is a failure. Feeding the judge the tool-call sequence alongside the final response catches exactly the class of failure that text-only judging misses.&lt;/p&gt;
&lt;h2 id=&quot;the-failure-modes-that-actually-show-up&quot;&gt;The failure modes that actually show up&lt;/h2&gt;
&lt;p&gt;Running this loop across real multi-agent systems, the recurring findings are rarely “the model got the answer wrong”:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;State re-derivation.&lt;/strong&gt; A task agent re-queries what the root agent already fetched because the handoff didn’t carry state — invisible in demos, doubled latency and cost in traces.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Premature handoff.&lt;/strong&gt; The router transfers to a specialist before the case is qualified, so the specialist bounces it back — visible as transfer→transfer→transfer chains in the event stream.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retries masking broken tools.&lt;/strong&gt; A tool failing 30% of the time hides behind agent retry behavior; success rates look fine while cost and latency degrade. Only per-case tool-error counts expose it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instruction bleed.&lt;/strong&gt; A prompt change to one agent shifts another agent’s behavior through shared context — the reason eval runs must cover the whole system, not the changed agent in isolation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are model problems. All of them are architecture problems that only a trace-level evaluation loop makes visible.&lt;/p&gt;
&lt;h2 id=&quot;closing-the-loop&quot;&gt;Closing the loop&lt;/h2&gt;
&lt;p&gt;The harness pays for itself the day it gates a change. Wire it into CI so that prompt, tool, and topology changes run the case set and fail the build when pass-rate drops. Track pass-rate per category (tool correctness, boundaries, coordination) rather than a single number — a flat aggregate hides a collapsing category.&lt;/p&gt;
&lt;p&gt;Two disciplines keep the loop trustworthy over time. Add a case for every production incident — the dataset should grow the way regression suites grow. And resist tuning prompts against the eval set until it stops failing; when pass-rate hits 100% and stays there, the set has stopped measuring, and it’s time to harvest new failures from traffic.&lt;/p&gt;
&lt;p&gt;Evaluation is not a phase after building the agent. It is the mechanism by which an agent system becomes buildable at all.&lt;/p&gt;</content:encoded><category>adk</category><category>agents</category><category>evaluation</category></item></channel></rss>