Evaluation and Observability: a Golden Set, LLM-as-Judge, Tracing, a Failure-Rate/Cost Dashboard; Pi vs. LangGraph Summary; Week Three Retrospective
Build an agent evaluation system: score with a golden set and LLM-as-judge, wire in tracing and a failure-rate/cost dashboard, and summarize when Pi and LangGraph each fit, wrapping up week three.
Today's Goals
- Build a small golden set covering typical inputs and expected outputs
- Implement an LLM-as-judge scoring script that scores output and gives a reason
- Wire in tracing and produce an observability dashboard that includes failure rate and cost
Yesterday's closing line is today's starting point: this system triages, splits tasks and runs them in parallel, reviews itself, and reaches out proactively with restraint, and all you have about how well it does any of it is "I tried a few and it seemed fine." Today that sentence becomes numbers.
Plain-Language Walkthrough
Quality control cannot rest on one veteran's hands
When a production line first opens, quality control is often one veteran: they pick up a piece, squeeze it, and say this batch will do. At low volume that works, because they genuinely know; the problem is that the method keeps no record, cannot be reproduced, and changes standard when the person changes — so when a customer complains that last month's goods were better, nobody can say whether that is true.
A mature line grows three things, and those three are what we fit to an agent today: reference samples (a small fixed batch, where only numbers from the same batch compare) are a golden set; an automatic gauge (fast, cheap, and drifting, so it needs calibrating against the samples) is LLM-as-judge; and a routing card (which stations a product passed, who worked on it, how long each took) is tracing.
Why do ordinary backends get by on unit tests while an agent does not? Because the same input does not guarantee the same output. A traditional test asserts equality, and an agent's output has no single correct answer, only good enough. That difference cascades into three consequences.
One, running successfully says nothing about quality. D17's review loop completing means only that the flow threw nothing; it says nothing about whether the output states the refund conclusion. Two, a change's impact is diffuse. A one-word prompt edit may affect one category, and five hand-picked samples that all miss it tell you there was no impact, and you ship. Three, multi-agent is harder again: one request passes routing, splitting, parallel execution, review, and assembly, and any station going crooked presents as "that last paragraph is a bit off," so without measuring them apart you cannot tell which piece to fix.
So the first conclusion is: evaluation is not testing, evaluation establishes a comparable baseline for a stochastic system. Its product is not pass or fail, it is a number comparable with last time. And to compare, the samples must be fixed — so how should they be chosen, and how many?
Reference samples: 15 carefully chosen beat 500 grabbed at random
The conclusion first: a golden set's value is not in quantity but in stability. One criterion — can it be rerun unchanged after every prompt edit and produce a number comparable with last time? Five hundred conversations scraped from production logs cannot (the orders shipped, the promotions expired, and the results change daily); fifteen samples with fully controlled dependencies can.
How to choose? Three layers of coverage, and missing one means not having tested:
Layer one, every route has somebody walking it. At least two or three each for D16's three subagents — miss one and a broken routing prompt goes unnoticed.
Layer two, one per failure mode. The most skipped and most valuable: falling back on low confidence, falling back on an out-of-list route name (D16), degrading on the tool budget or the review ceiling, a downstream tool going down (D17). Those samples measure not how well it answers but how correctly it breaks — breaking wrongly is far more dangerous than answering poorly.
Layer three, the ones that caused real incidents. Every time you fix a production problem, add the sentence that triggered it and keep it forever — this layer grows over time and is the last that should ever be deleted.
Besides the input, every sample records two expectations: which route (including which fallback), and a required-information checklist — the facts that must appear in the reply. The checklist is the crux, converting "is this a good answer" into "were these things stated." The lab's set looks like this:
{ "id": "g03", "input": "what is the status of SO20260901 and where is the shipment, and I also want a refund",
"expectRoute": "refund_draft",
"checklist": ["current stage", "estimated delivery", "refund conclusion"],
"tags": ["fanout", "review-loop"] }
{ "id": "g08", "input": "how did that thing from last time turn out",
"expectRoute": "smalltalk", "expectFallback": "fallback:low-confidence",
"checklist": ["please add"],
"tags": ["fallback", "incident"] }There is one maintenance rule, and it is hard: append only, never edit. Edit an expectation and every previous score is void — as when a reference batch changes, the historical data no longer compares. If one is genuinely wrong, mark it retired and add another rather than editing in place.
The automatic gauge itself needs calibrating
With a checklist, who scores? Fifteen can be read by hand, and one prompt edit means rereading them, so five edits a day is 75 readings. So a model scores — LLM-as-judge: hand it the checklist plus this run's output and get a 1-to-5 score with a reason, where below 3 counts as a failure.
The call is one line and all of its credibility lies outside that line — leave the following three failure modes unexplained and the whole evaluation is self-deception.
One, same-source bias. A judge using the same model as the agent under evaluation favors approving its own output, because its preferences about what counts as a good answer are consistent. D17 named it once at the Critic, and today generalizes it: anywhere a model scores a model has this problem. The lab measures it clearly: on the same batch of deliberately broken outputs, a same-source judge gives 14/15 and a different-source one gives 12/15, and the two extra passes are exactly the borderline outputs most in need of catching.
Two, a length preference. A judge tends to score longer answers higher. In the lab, padding a correct 33-word reply with 141 words of irrelevant pleasantries takes the impression-based rubric from 2 to 4 — with not one word of content changed.
Three, scoring-prompt drift. For one output, two scoring prompts give 2 and 5. So there is a hard discipline: scores compare only within one judge prompt.
The remedies:
- Fix and version the judge prompt: every scoring record carries its rubric version and judge model, which are its coordinates. A dashboard finding two rubrics mixed should refuse to aggregate rather than computing a meaningless average.
- Use a different model as judge, and make that the default rather than relying on a caller remembering an argument.
- Keep a small human-labeled calibration set: run it every time the scoring prompt changes, comparing conclusions (pass or fail) rather than score deltas — one point of difference does not matter, a flipped conclusion is an incident. In the lab, on 5 labeled samples a different-source judge agrees 5/5 and a same-source one only 3/5.
And one more fundamental remedy already planted in the previous section: replace subjective impression with a checkable list. It also dissolves the length preference — count the checklist item by item and padding earns nothing. That padded reply scores 5 before and after under the checklist rubric, unmoved.
export const PASS_SCORE = 3 // below 3 counts as a failure
export const RUBRICS = {
// v1 is the first version almost everybody writes: nothing checkable, only "how does it read"
v1: 'Score this reply 1 to 5 on whether it is professional, complete, and satisfying.',
// v2 replaces subjective judgment with objective checking, which also blocks the length preference
v2: 'Check the required-information checklist item by item: all hit is 5, none is 1. Look only at the checklist, not at style or length.',
}
const buildPrompt = (o) =>
[
`RUBRIC: ${o.rubricVersion}`, // these three lines are not decoration for the model,
`JUDGE_MODEL: ${o.judgeModel}`, // they are this scoring record's coordinates:
`TARGET_MODEL: ${o.targetModel}`, // without them a batch of scores cannot prove it used one ruler
RUBRICS[o.rubricVersion],
`CHECKLIST: ${o.checklist.join('|')}`,
`OUTPUT:\n${o.output}`,
].join('\n')
export async function judge(o) {
const raw = await callModel('judge', buildPrompt(o), o.judgeModel)
const score = Number(raw.match(/SCORE:\s*([1-5])/)?.[1])
return {
// Unparseable means the lowest score: when you cannot tell, treat it as failed rather
// than silently letting it through
score: Number.isFinite(score) ? score : 1,
reason: raw.match(/REASON:\s*(.*)/)?.[1] ?? '',
rubricVersion: o.rubricVersion, // the rubric is persisted alongside the score
judgeModel: o.judgeModel,
}
}import re
from dataclasses import dataclass
PASS_SCORE = 3 # below 3 counts as a failure
RUBRICS = {
# v1 is the first version almost everybody writes: nothing checkable
"v1": "Score this reply 1 to 5 on whether it is professional, complete, and satisfying.",
# v2 replaces subjective judgment with objective checking
"v2": "Check the required-information checklist item by item: all hit is 5, none is 1. Look only at the checklist, not at style or length.",
}
@dataclass(frozen=True)
class JudgeRequest:
output: str
checklist: list[str]
rubric: str
judge_model: str
target_model: str
@dataclass(frozen=True)
class Verdict:
score: int
reason: str
rubric: str # the rubric is persisted alongside the score,
judge_model: str # or numbers from two rulers get averaged together
def build_prompt(r: JudgeRequest) -> str:
return "\n".join(
[
f"RUBRIC: {r.rubric}",
f"JUDGE_MODEL: {r.judge_model}",
f"TARGET_MODEL: {r.target_model}",
RUBRICS[r.rubric],
f"CHECKLIST: {'|'.join(r.checklist)}",
f"OUTPUT:\n{r.output}",
]
)
async def judge(r: JudgeRequest) -> Verdict:
raw = await call_model("judge", build_prompt(r), model=r.judge_model)
score = re.search(r"SCORE:\s*([1-5])", raw)
reason = re.search(r"REASON:\s*(.*)", raw)
# Unparseable means the lowest score: when you cannot tell, treat it as failed
return Verdict(
int(score.group(1)) if score else 1,
reason.group(1) if reason else "",
r.rubric,
r.judge_model,
)// Dependencies: JDK 17+ records, text blocks, and java.util.regex, no third-party library
static final int PASS_SCORE = 3; // below 3 counts as a failure
enum Rubric { V1, V2 }
record JudgeRequest(String output, List<String> checklist, Rubric rubric,
String judgeModel, String targetModel) {}
/** The rubric is persisted with the score, or numbers from two rulers get averaged */
record Verdict(int score, String reason, Rubric rubric, String judgeModel) {}
static final Map<Rubric, String> RUBRICS = Map.of(
// V1 is the first version almost everybody writes: nothing checkable
Rubric.V1, "Score this reply 1 to 5 on whether it is professional, complete, and satisfying.",
// V2 replaces subjective judgment with objective checking
Rubric.V2, "Check the required-information checklist item by item: all hit is 5, none is 1. Look only at the checklist, not at style or length.");
// A text block spares hand-written newlines; the first three lines are coordinates
static String buildPrompt(JudgeRequest r) {
return """
RUBRIC: %s
JUDGE_MODEL: %s
TARGET_MODEL: %s
%s
CHECKLIST: %s
OUTPUT:
%s""".formatted(r.rubric(), r.judgeModel(), r.targetModel(),
RUBRICS.get(r.rubric()), String.join("|", r.checklist()), r.output());
}
static final Pattern SCORE = Pattern.compile("SCORE:\\s*([1-5])");
static final Pattern REASON = Pattern.compile("REASON:\\s*(.*)");
static Verdict judge(JudgeRequest r) throws Exception {
var raw = callModel("judge", buildPrompt(r), r.judgeModel());
var score = SCORE.matcher(raw);
var reason = REASON.matcher(raw);
// Unparseable means the lowest score: when you cannot tell, treat it as failed
return new Verdict(score.find() ? Integer.parseInt(score.group(1)) : 1,
reason.find() ? reason.group(1) : "", r.rubric(), r.judgeModel());
}let passScore = 3 // below 3 counts as a failure
enum Rubric: String { case v1, v2 }
struct JudgeRequest {
let output: String
let checklist: [String]
let rubric: Rubric
let judgeModel: String
let targetModel: String
}
/// The rubric is persisted with the score, or numbers from two rulers get averaged
struct Verdict {
let score: Int
let reason: String
let rubric: Rubric
let judgeModel: String
}
let rubrics: [Rubric: String] = [
// v1 is the first version almost everybody writes: nothing checkable
.v1: "Score this reply 1 to 5 on whether it is professional, complete, and satisfying.",
// v2 replaces subjective judgment with objective checking
.v2: "Check the required-information checklist item by item: all hit is 5, none is 1. Look only at the checklist, not at style or length.",
]
// Interpolating directly inside a multi-line literal; the first three lines are coordinates
func buildPrompt(_ r: JudgeRequest) -> String {
"""
RUBRIC: \(r.rubric.rawValue)
JUDGE_MODEL: \(r.judgeModel)
TARGET_MODEL: \(r.targetModel)
\(rubrics[r.rubric] ?? "")
CHECKLIST: \(r.checklist.joined(separator: "|"))
OUTPUT:
\(r.output)
"""
}
func judge(_ r: JudgeRequest) async throws -> Verdict {
let raw = try await callModel("judge", buildPrompt(r), model: r.judgeModel)
// Regex literals since Swift 5.7: capture-group types are fixed at compile time,
// so no manual subscripting
let score = raw.firstMatch(of: /SCORE:\s*([1-5])/).flatMap { Int($0.1) }
let reason = raw.firstMatch(of: /REASON:\s*(.*)/).map { String($0.1) }
// Unparseable means the lowest score: when you cannot tell, treat it as failed
return Verdict(score: score ?? 1, reason: reason ?? "",
rubric: r.rubric, judgeModel: r.judgeModel)
}Once wired up, the most valuable experiment is deliberately breaking one prompt: delete "add a conclusion when you receive a review note" from the executor's prompt with nothing else changed, and the pass rate drops from 15/15 to 12/15, naming g04, g05, and g14 and which facts each is missing. That is the entire reason an evaluation system exists: it turns "it seems worse" into "it dropped 3, these 3, and what is missing is the refund conclusion."
The routing card: one request grows into a tree in a multi-agent system
A score tells you whether the result is good, not why. A single agent's call is a line: one model call, a few tools, one reply. Today's system is a tree: the supervisor routes to refunds, the planner splits three items, three executors run in parallel, the critic bounces one, it reruns, assembly follows. Without this card, a production problem leaves you unable to say which road it took — which points back to D16's routingReason: routing is a model's decision, the same sentence may differ next time, and not recording it then loses that judgment forever.
A span's fields are remarkably few: id, parent pointer, name, start and end, plus a few attributes. The parent pointer is everything — with it you have a tree, without it only a flat list: you know what happened and not who was inside whom, and you cannot see which two executors ran in parallel. The lab's tree looks like this, where the double bar marks parallelism:
+- request 58.3ms case=g03
+- supervisor 4.7ms route=refund_draft routingReason=the user explicitly asked for a refund
| +- model:route 4.7ms intent=route
+- planner 4.6ms tasks=order+shipping+refund
| +- model:plan 4.6ms intent=plan
+- executor 9.4ms task=t-1-order outcome=done
+- executor 16.0ms || parallel task=t-2-shipping outcome=done
+- executor 14.4ms || parallel task=t-3-refund outcome=done
+- critic 4.8ms rejected=1 outcome=redo
+- executor 13.1ms task=t-3-refund outcome=done
+- critic 3.4ms rejected=0Those lines answer four questions directly: which road it took, how many items it split into, whether parallelism worked (the double-bar lines also prove the ceiling is in effect), and why it was slow (that bounce added an executor and a critic).
How is the parent relationship propagated? Do not add a parentSpanId parameter to every function — every new node changes a signature and one miss breaks the chain. All four languages have machinery for it: JS's AsyncLocalStorage, Python's contextvars, Swift's TaskLocal, and Java is a special case explained in the code below.
import { AsyncLocalStorage } from 'node:async_hooks'
const parentCtx = new AsyncLocalStorage() // holds the current span id
export class Tracer {
spans = []
async span(name, attrs, fn) {
const span = {
id: `s${this.spans.length + 1}`,
parentId: parentCtx.getStore(), // no parent means this is the root: a request's entrance
name,
attrs,
startMs: performance.now(),
endMs: 0,
}
this.spans.push(span)
try {
// Run inside this store: spans opened within automatically take it as their parent,
// so node functions need no extra argument
return await parentCtx.run(span.id, () => fn(span))
} finally {
span.endMs = performance.now()
}
}
}
// Two siblings whose intervals overlap really did run in parallel - this line is how you
// see whether the concurrency ceiling took effect
export const overlaps = (a, b) => a.startMs < b.endMs && b.startMs < a.endMsimport contextvars, time
from contextlib import asynccontextmanager
from dataclasses import dataclass
# A ContextVar propagates through await, and each task from asyncio.gather gets its own copy
parent_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar("parent", default=None)
@dataclass
class Span:
id: str
parent_id: str | None
name: str
attrs: dict[str, str]
start_ms: float
end_ms: float = 0.0
class Tracer:
def __init__(self) -> None:
self.spans: list[Span] = []
@asynccontextmanager
async def span(self, name: str, attrs: dict[str, str]):
now = time.perf_counter() * 1000
# No parent means this is the root: a request's entrance
span = Span(f"s{len(self.spans) + 1}", parent_ctx.get(), name, attrs, now)
self.spans.append(span)
token = parent_ctx.set(span.id)
try:
yield span
finally:
parent_ctx.reset(token) # the token restores precisely on nested exit
span.end_ms = time.perf_counter() * 1000
def overlaps(a: Span, b: Span) -> bool:
# Two siblings whose intervals overlap really did run in parallel
return a.start_ms < b.end_ms and b.start_ms < a.end_ms// Dependencies: JDK 17+. JDK 21's ScopedValue fits this better, and it is still in preview,
// so the idiomatic form on 17 is a ThreadLocal with try/finally restoration
static final ThreadLocal<String> PARENT = new ThreadLocal<>();
record Span(String id, String parentId, String name, Map<String, String> attrs,
long startMs, long endMs) {}
static final List<Span> SPANS = new CopyOnWriteArrayList<>();
static <T> T span(String name, Map<String, String> attrs, Callable<T> body) throws Exception {
var id = "s" + (SPANS.size() + 1);
var parent = PARENT.get(); // no parent means this is the root
var startMs = System.nanoTime() / 1_000_000;
PARENT.set(id);
try {
return body.call();
} finally {
PARENT.set(parent); // restore, or the next span on this thread takes the wrong parent
SPANS.add(new Span(id, parent, name, attrs, startMs, System.nanoTime() / 1_000_000));
}
}
// Note a ThreadLocal does not cross threads: a child task on a pool cannot read the
// parent thread's copy, so parentId must be passed in explicitly when submitting.
// JS, Python, and Swift's context mechanisms do that for you.
static boolean overlaps(Span a, Span b) {
return a.startMs() < b.endMs() && b.startMs() < a.endMs();
}enum Trace {
/// A TaskLocal propagates through async let and TaskGroup to child tasks, so a fan-out
/// needs no manual parentId in every child
@TaskLocal static var parentId: String?
}
struct Span {
let id: String
let parentId: String?
let name: String
let attrs: [String: String]
let startMs: Double
var endMs: Double = 0
}
final class Tracer {
private(set) var spans: [Span] = []
func span<T>(_ name: String, _ attrs: [String: String],
_ body: () async throws -> T) async throws -> T {
let id = "s\(spans.count + 1)"
let parent = Trace.parentId // no parent means this is the root
let startMs = Date().timeIntervalSince1970 * 1000
// withValue rebinds only within this closure and restores on exit, no finally needed
let result = try await Trace.$parentId.withValue(id) { try await body() }
spans.append(Span(id: id, parentId: parent, name: name, attrs: attrs,
startMs: startMs, endMs: Date().timeIntervalSince1970 * 1000))
return result
}
}
// Two siblings whose intervals overlap really did run in parallel
func overlaps(_ a: Span, _ b: Span) -> Bool { a.startMs < b.endMs && b.startMs < a.endMs }The dashboard: failure rate and cost, verifiable by hand
A dashboard is not a second set of instrumentation, it is an aggregation of the traces — that is this section's foundation. The same data read across is a tree and stacked down is a dashboard; two sources eventually disagree and then nobody trusts either.
The dashboard answers four questions: how much went wrong (pass rate, routing accuracy, degradation, fallback), where it is slow (p50/p95), how much it cost, and which role spent it (by node). The last is multi-agent-specific and most useful — in the lab the executors take over a third, so you know where to compress first.
Cost has one convention that must be stated: one multi-agent user request may produce 5 to 10 model calls. In the lab, 15 requests made 76 calls, 5.1 per request. So "cost per call" is an order of magnitude smaller than the true unit, and counting per request reflects real cost — a single-agent-era billing intuition misleads here: the per-call price did not rise, the per-request price rose fivefold.
The dashboard's numbers must be verifiable by hand against the price table, or they are just pretty numbers. Using this course's prices — openai/gpt-4o-mini at 0.15 dollars per million input tokens and 0.60 per million output, characters estimated 1:1 conservatively — the lab prints a block like this, every line checkable on a calculator:
dashboard (15 requests, rubric v2/anthropic/claude-3.5-haiku)
quality: pass rate 100.0% | routing accuracy 100.0% | degradation 20.0% | fallback 13.3%
latency: p50 30.5ms | p95 76.5ms
calls: 76 model calls / 15 requests = 5.1 per request
hand check:
input 8494 tokens x $0.15 / 1M = $0.001274
output 2844 tokens x $0.6 / 1M = $0.001706
total $0.002980 / 15 requests = $0.000199 per request
per call it is $0.002980 / 76 = $0.000039, a factor of 5.1 apart
who spent it: compose 21 calls $0.001109 | route 15 calls $0.000704 | review 25 calls $0.000556...
the evaluation's own spend (not production cost): judge 52 calls, $0.002950That last line deserves its own note: evaluation costs money too, and it must be recorded separately from production cost. The judge calls' spend is the same order as the system under evaluation — what one full evaluation run costs is exactly what decides whether you run it per commit or once a day.
const PRICE_IN = 0.15 / 1_000_000 // per token; characters estimated 1:1 conservatively
const PRICE_OUT = 0.6 / 1_000_000
export function buildPanel(records) {
// Scores compare only within one judge prompt; a mixed average is meaningless
const rubrics = new Set(records.map((r) => `${r.rubricVersion}/${r.judgeModel}`))
if (rubrics.size > 1) return { ok: false, reason: `${rubrics.size} rubrics mixed, refusing to aggregate` }
const calls = records.flatMap((r) => r.spans).filter((s) => s.kind === 'model')
const inTok = calls.reduce((n, s) => n + s.promptTokens, 0)
const outTok = calls.reduce((n, s) => n + s.completionTokens, 0)
const cost = inTok * PRICE_IN + outTok * PRICE_OUT
return {
ok: true,
passRate: records.filter((r) => r.pass).length / records.length,
degradedRate: records.filter((r) => r.degraded).length / records.length,
callsPerRequest: calls.length / records.length,
// One user request produces several calls, so the denominator is requests, not calls
costPerRequest: cost / records.length,
costPerCall: cost / calls.length,
}
}PRICE_IN = 0.15 / 1_000_000 # per token; characters estimated 1:1 conservatively
PRICE_OUT = 0.6 / 1_000_000
def build_panel(records: list[Record]) -> Panel | Rejected:
# Scores compare only within one judge prompt; a mixed average is meaningless
rubrics = {(r.rubric_version, r.judge_model) for r in records}
if len(rubrics) > 1:
return Rejected(f"{len(rubrics)} rubrics mixed, refusing to aggregate")
calls = [s for r in records for s in r.spans if s.kind == "model"]
in_tok = sum(s.prompt_tokens for s in calls)
out_tok = sum(s.completion_tokens for s in calls)
cost = in_tok * PRICE_IN + out_tok * PRICE_OUT
return Panel(
pass_rate=sum(r.passed for r in records) / len(records),
degraded_rate=sum(r.degraded for r in records) / len(records),
calls_per_request=len(calls) / len(records),
# One user request produces several calls, so the denominator is requests
cost_per_request=cost / len(records),
cost_per_call=cost / len(calls),
)// Dependencies: JDK 17+ Stream API and sealed interfaces, no third-party library
static final double PRICE_IN = 0.15 / 1_000_000; // per token; characters estimated 1:1
static final double PRICE_OUT = 0.6 / 1_000_000;
// A sealed interface expresses computable and refused as two outcomes the caller must handle
sealed interface PanelResult permits Panel, Rejected {}
record Rejected(String reason) implements PanelResult {}
record Panel(double passRate, double degradedRate, double callsPerRequest,
double costPerRequest, double costPerCall) implements PanelResult {}
static PanelResult buildPanel(List<Record0> records) {
// Scores compare only within one judge prompt; a mixed average is meaningless
var rubrics = records.stream().map(r -> r.rubricVersion() + "/" + r.judgeModel())
.collect(Collectors.toSet());
if (rubrics.size() > 1) return new Rejected(rubrics.size() + " rubrics mixed, refusing to aggregate");
var calls = records.stream().flatMap(r -> r.spans().stream())
.filter(s -> s.kind().equals("model")).toList();
long inTok = calls.stream().mapToLong(Span::promptTokens).sum();
long outTok = calls.stream().mapToLong(Span::completionTokens).sum();
double cost = inTok * PRICE_IN + outTok * PRICE_OUT;
double n = records.size();
return new Panel(
records.stream().filter(Record0::passed).count() / n,
records.stream().filter(Record0::degraded).count() / n,
calls.size() / n,
cost / n, // the denominator is requests, not calls
cost / calls.size());
}let priceIn = 0.15 / 1_000_000 // per token; characters estimated 1:1 conservatively
let priceOut = 0.6 / 1_000_000
/// An enum with associated values: computable and refused are two outcomes, and the
/// compiler makes a caller's switch handle both
enum PanelResult {
case panel(passRate: Double, degradedRate: Double, callsPerRequest: Double,
costPerRequest: Double, costPerCall: Double)
case rejected(reason: String)
}
func buildPanel(_ records: [Record]) -> PanelResult {
// Scores compare only within one judge prompt; a mixed average is meaningless
let rubrics = Set(records.map { "\($0.rubricVersion)/\($0.judgeModel)" })
guard rubrics.count == 1 else {
return .rejected(reason: "\(rubrics.count) rubrics mixed, refusing to aggregate")
}
let calls = records.flatMap(\.spans).filter { $0.kind == "model" }
let inTok = calls.reduce(0) { $0 + $1.promptTokens }
let outTok = calls.reduce(0) { $0 + $1.completionTokens }
let cost = Double(inTok) * priceIn + Double(outTok) * priceOut
let n = Double(records.count)
return .panel(
passRate: Double(records.filter(\.passed).count) / n,
degradedRate: Double(records.filter(\.degraded).count) / n,
callsPerRequest: Double(calls.count) / n,
costPerRequest: cost / n, // the denominator is requests, not calls
costPerCall: cost / Double(calls.count))
}Pi and LangGraph: differently positioned, and when to use neither
D15 owed a sentence and today pays it. These two are not substitutes, and five dimensions make it clearest — the judgment comes from having written code in both this week, not from a feature table.
Onboarding cost. On D3 Pi's calling side was four steps, 120 hand-written lines down to 40; LangGraph makes you decide the state's fields, their merge rules, and the edges first, and D15's straight line took a dozen lines of state definition alone. Pi has many defaults and LangGraph almost none — fast, and therefore D3's two time bombs: it chose the model and inserted the persona for you.
How explicit state is. Pi's history lives inside the session unfelt; LangGraph's state is a field table you define, each field with its merge rule. D17's fix — workspace from concat to update-by-id — has no place to change in Pi at all. The criterion: several roles writing one state in parallel forces explicitness; without parallelism it is pure burden.
Debugging and observability. Pi gives an event stream, and one execution is a timeline; LangGraph gives per-node deltas plus checkpoints, and one execution is a tree you can replay and fork. A linear problem is faster on a timeline, and a multi-role problem requires a tree.
When neither is needed. Back to D15's three criteria: if none hits, do not split and do not adopt a framework either. One or two tools and at most two rounds is a while plus a switch, and a framework is a net loss.
The felt experience: the nicest thing about Pi was not managing history, the worst was not knowing what system prompt it inserted; the nicest thing about LangGraph was every merge rule in one place, the worst was one unserialized fan-out argument restoring as an empty shell, with no error. Together: do you fear invisible defaults more, or endless boilerplate?
W3 retrospective: each day forced by the one before
Seven days was not seven LangGraph articles, it was a line pushed by problems. Every day solved a specific trouble the previous day left:
- D15 was forced by D14's line about one agent not being enough. It threw cold water first (three criteria, split only if one hits) and set the rule for the week: merge rules are declared on the field, not written in the node.
- D16 was forced by D15's straight line with every edge hard-wired. Routing must use structured output — free text's failure is silent: the model decided correctly, the regex missed, and the log holds one fallback line.
- D17 was forced by D16's one-at-a-time dispatch, and parallelism detonated D15's mine on the spot: the concat reducer made three items write six records and reads by id return stale versions, with no error.
- D18 was forced by D17's bouncing, redoing chain. An accumulating channel cannot subtract, so
messagesgained a replace instruction; a checkpoint must store the pending fan-out too, because omitting it leaves a run that "looks finished, with not one item actually dispatched." - D19 was forced by D18's "it can now finish on its own": so who calls it. The answer is arriving with the user's own passport — identity comes only from the token, never the body.
- D20 was forced by D19's "the endpoints work and it still does one thing per sentence." The machinery was ready on D13, and this day added whether it should send: install the three gates late and every stopped message has already been paid for.
- D21 was forced by D20's "you cannot actually say how well it does" — which is today.
If you take away only three sentences, I suggest these.
One, every split converts a problem that cannot be jointly optimized in one prompt into a pile of problems needing explicit protocols. Those protocols are this entire week: routing reasons, merge rules, tool budgets, checkpoints, user-level tokens, three gates. Splitting creates no value; defining the protocols clearly does.
Two, every new mechanism needs an answer to "what happens when it fails." W2's line did not miss once in W3: silent routing fallbacks, dirty reducer reads, summaries losing the criteria, checkpoints losing fan-outs, a judge approving itself. Those failures share one property — none of them errors, so only evaluation and tracing catch them, and not catching them is the same as not having done the work.
Three, a multi-agent system with no evaluation is the kind you never dare to change. Five roles influence each other, one prompt edit's impact diffuses globally, and going on feel only makes you more conservative until it freezes in a state nobody likes. Today's 15 samples and one dashboard buy exactly the willingness to change.
Source Reading
Hands-On Lab
This lab has no docker-compose.yml: evaluation, scoring, tracing, and the dashboard are in-process. The graph under evaluation comes from D16 and D17 with no mechanism changed and only span instrumentation added, and src/shared/state.ts is still D15's. Under MOCK=1 there are zero external services, and the fake replies vary with the input — change the output, the rubric, or the judge model and the score follows, or none of today's three failure modes would be visible.
- Run
MOCK=1 SELFTEST=1 pnpm startas-is first; the wording of those six failures is your to-do list. - Exercise 1, change
runGoldenSetfrom running only the first sample to running the batch, taking check 2 from 1/1 to a genuine 15. - Exercise 2, remove the judge's random jitter and write the rubric into the record; exercise 3, switch the default judge to a different model and implement the calibration agreement rate — checks 3 and 5 turn green, showing the gap between same-source 14/15 and different-source 12/15.
- Exercise 4, connect the spans' parent pointers; exercise 5, change cost's denominator from calls to requests, and checks 6 and 7 turn green.
Interview Questions
Today's five questions are in the bank below; the first four cover evaluation methodology, LLM-as-judge's limits, observability dimensions, and framework selection, and the last is a full system-design question: design an evaluation and observability system for a multi-agent setup. That question's analysis gives a complete answer framework, and walking through this week against it is worth more than ten smaller questions.
Checklist and Tomorrow
- Build a small golden set covering typical inputs and expected outputs
- Implement an LLM-as-judge scoring script that scores output and gives a reason
- Wire in tracing and produce an observability dashboard that includes failure rate and cost
- Name LLM-as-judge's three failure modes and the remedy for each
- Explain why multi-agent cost is counted per request rather than per call, and verify it by hand against the price table
- Without notes, give one sentence per day from D15 to D21, saying which problem from the previous day forced it
- All 5 acceptance criteria of the lab pass (all seven self-checks green)
- Answer at least 4 of the 5 interview questions, and talk for a full 20 minutes on the system-design one
Tomorrow (D22) begins week four, and the first subject is security: prompt injection, least privilege for tools, sandboxing, secret management. Why right after evaluation? Because security problems present exactly like today's failure modes — none of them errors. One injected instruction hidden inside a tool result has the agent dutifully doing what the attacker said, the logs look fine, and only a trace and an evaluation reveal it took a road it should not have. Today's dashboard is what you use tomorrow to notice somebody teaching your agent to do something else. After that, D23 covers MCP and Skills, replacing tool integration with a standard protocol.
Interview questions
How do you evaluate an agent's quality, and how does it differ from testing a conventional backend service?怎么评估一个 Agent 的效果?和传统后端服务的测试有什么不同?
Common in ChinaCommon overseasIntermediate#evaluation#testing#agent-qualityHow to reason about it · think before answering
- The hinge is differ. Answering build a test set and measure accuracy is the textbook ML answer and misses the point; the interviewer wants to know whether you can articulate what makes agents special here.
- The root difference is one sentence: the same input does not guarantee the same output. Conventional tests assert equality, but an agent's output has no single correct answer, only good enough. Once the assertion changes from equality to scoring, the whole methodology changes with it.
- That difference cascades into three consequences, and covering all three secures the question. First, whether it ran tells you nothing about quality — the flow not throwing does not mean the reply stated the refund conclusion. Second, the blast radius of a change is diffuse: a one-word prompt edit may affect only one class of request, and hand-checking five samples that happen to miss that class yields no impact, then you ship. Third, multi-agent adds a layer: one request passes routing, planning, parallel execution, review and aggregation, and any one of them going wrong surfaces as that last paragraph seems off — without measuring each stage you cannot tell which to fix.
- So frame it: evaluation is not testing. Evaluation establishes a comparable baseline for a stochastic system. Its output is not pass or fail but a number you can compare against last time — and to compare, the sample set must be frozen.
- Then get concrete: a small stable golden set (15 items here), each declaring its expected route and a checklist of facts the reply must contain; an LLM-as-judge scoring against that checklist; and evaluation results joined to tracing on one dashboard. The checklist is the key move — it converts is this a good answer, which cannot be verified, into were these facts stated, which can.
- Expect: do you still need unit tests? Yes, with a clean split — deterministic parts (tool functions, state transitions, reducers) keep asserting equality in unit tests, while evaluation covers only the model-generated segment. Merge the two and you get a suite that fails randomly, after which everyone starts ignoring CI.
分析过程 · 先想清楚再作答
- 题眼是「不同」。只答「建一个测试集跑准确率」拿不到分——那是机器学习的标准答案,面试官想看你能不能说清 Agent 这个场景特殊在哪。
- 根子上的差别只有一句:**同样的输入,Agent 不保证给同样的输出**。传统测试的断言是「等于」,而 Agent 的产出没有唯一正确答案,只有「够不够好」。断言从等值变成了判分,整套方法论跟着变。
- 这条差别连锁出三个后果,说全了这题就稳了:一是**跑没跑通判断不了质量**——流程没抛错,不等于回复里写明了退款结论;二是**改动的影响是弥散的**,改一个字的提示词可能只影响一类请求,人肉抽查五条恰好没覆盖到,你会得出「没影响」然后上线;三是**多 Agent 又难一层**,一次请求走路由、拆分、并行执行、评审、汇总五道工序,任何一道歪了都表现成「最后那段话不太对」,不分开量就不知道该改哪块。
- 所以给出定位:**评估不是测试,评估是给一个随机系统建立一条可比较的基线。** 它的产物不是「通过」或「不通过」,而是一个能和上一次比的数字。既然要比,样本集就必须固定。
- 然后落到具体做法:一个小而稳的 golden set(本课 15 条),每条写清期望走哪条路由和一份必备信息清单;用 LLM-as-judge 对照清单打分;把评估结果和链路追踪接到同一块面板上。**清单是关键**——它把「这答得好吗」这种没法验的问题,换成了「这几件事写没写」这种能验的问题。
- 可以预期的追问:那还需要单元测试吗?需要,而且分工很清楚——工具函数、状态迁移、reducer 这些确定性的部分照旧用单元测试断言等值,评估只负责模型产出那一段。把两者混成一套,你会得到一堆随机失败的测试,然后所有人开始无视 CI。
Key points
- The root difference: identical input does not guarantee identical output, so the assertion shifts from equality to good enough
- Three consequences: running is not quality, change impact is diffuse (sampling misses it), and in multi-agent any of five stages failing looks like the same symptom
- Framing: evaluation is not testing — it establishes a comparable baseline for a stochastic system, yielding a number rather than pass/fail
- Method: a small stable golden set, a required-facts checklist per item, an LLM-as-judge, and a dashboard sharing tracing's data source
- The checklist is the key move: it converts is this good into were these facts stated — unverifiable into verifiable
- Unit tests remain for deterministic parts; merging the two makes CI fail randomly until everyone ignores it
答题要点
- 根本差别:同样的输入 Agent 不保证同样的输出,断言从「等于」变成「够不够好」
- 三个后果:跑通不等于质量合格、改动影响弥散(抽查会漏)、多 Agent 里五道工序任一歪了都表现成同一个症状
- 定位:评估不是测试,是给随机系统建一条可比较的基线,产物是能和上次比的数字而不是通过与否
- 做法:小而稳的 golden set + 每条的必备信息清单 + LLM-as-judge 打分 + 与 tracing 同源的面板
- 清单是关键,它把「答得好吗」换成「这几件事写没写」,从没法验变成能验
- 单元测试仍然需要,负责确定性部分;两者混在一起会让 CI 随机变红,最后被所有人无视
What makes LLM-as-judge unreliable, and what do you do about it?用大模型给大模型的输出打分(LLM-as-judge),有哪些不可靠的地方?怎么办?
Common in ChinaCommon overseasDeep dive#evaluation#llm-as-judge#reliabilityHow to reason about it · think before answering
- This screens for whether you have actually used it. People who have can name specific failure shapes with magnitudes; people who have not just say it might be inaccurate.
- First, self-preference: when the judge and the evaluated agent share a model, it favors its own output — the same model has a consistent notion of what a good answer looks like, so asking it to review what it just wrote gets an approving verdict. Measured: on the same batch of deliberately degraded outputs, a same-model judge gave 14/15 while a different model gave 12/15, and the extra passes were exactly the borderline cases worth catching. This is not confined to judges — every model-grading-model position has it, and a Critic node is the same problem.
- Second, length bias: judges reward longer answers. Measured: padding a correct 33-character reply with 141 characters of irrelevant pleasantries moved an impression-based rubric from 2 to 4 without changing a word of substance.
- Third, rubric drift: scores shift wholesale when the judge prompt is tweaked. The same output scored 2 under one rubric and 5 under another. Hence the hard rule: scores are comparable only within one judge prompt, and cross-version comparison is meaningless.
- Match each remedy to its failure rather than saying run it a few more times. Freeze and version the judge prompt — every score record carries its rubric version and judge model, which are its coordinates, and a dashboard that finds two rubrics mixed should refuse to aggregate rather than emit a meaningless average. Default to a different model as judge, as a default and not an option. Keep a small human-labeled calibration set and re-run it whenever the rubric changes, comparing verdicts (pass or fail) rather than score deltas — one point of drift is fine, a flipped verdict is an incident.
- And one deeper fix: replace impressionistic criteria with a checkable list, which also dissolves length bias — counting items off a list gives padding nothing to earn. Measured, that padded reply scored 5 both before and after under the checklist rubric.
- Expect: is a judge cheaper than humans? The judge's cost is the same order as the system being evaluated, so what a full evaluation run costs decides whether you run it per commit or nightly. Human cost is not money but latency — it cannot give you feedback at the speed of one prompt edit, which is why humans belong on the calibration set only.
分析过程 · 先想清楚再作答
- 这题筛的是「你是真用过,还是听说过」。用过的人能报出具体的失效形态和量级,没用过的人只会说「可能不准」。
- 第一种,**同源偏差**:judge 和被评估的 Agent 用同一个模型时,它偏向认可自己的输出——同一个模型对「什么算好答案」的偏好是一致的,让它复核自己刚写的东西,它当然觉得没问题。实测数量级:同一批被改坏的产出,同源 judge 给 14/15,换个模型只给 12/15,被多放过去的正是最该抓的边缘产出。这个坑不止在 judge,**凡是「模型评模型」的位置都有**,Critic 节点是同一个问题。
- 第二种,**长度偏好**:judge 倾向给篇幅大的答案更高分。实测:一条 33 字的正确回复灌上 141 字无关客套话,凭印象打分的提示词就从 2 分涨到 4 分,内容一个字没变。
- 第三种,**评分提示词漂移**:judge 的评分随提示词微调整体移动。同一份产出,两套评分提示词一套给 2 分一套给 5 分。所以有条硬纪律——**分数只在同一套 judge 提示词内部可比**,跨版本比较是没有意义的。
- 解药要一一对应,别笼统说「多测几次」:固定 judge 提示词并版本化(每条评分记录带上 rubric 版本与 judge 模型,那是它的坐标;面板发现混了两套口径应当直接拒绝聚合,而不是算出一个没含义的平均分);默认用不同的模型当 judge,而且这该是默认值不是可选项;留一小批人工标注做校准集,每次改评分提示词拿它对一遍,**比的是结论(过或不过)而不是分数差**——差 1 分无所谓,结论翻了就是事故。
- 还有一条更根本的:**把评分标准从主观印象换成可核对的清单**,它同时解掉长度偏好——照清单逐条数,灌水加不了分。实测那条灌水回复在清单口径下前后都是 5 分,纹丝不动。
- 可以预期的追问:judge 便宜还是人工便宜?答:judge 的成本和被评估的系统本身一个量级,所以「跑一次全量评估多少钱」是你决定每次提交都跑还是每天跑一次的依据;而人工的成本不在钱在延迟——它给不了你改一次提示词就想看一次结果的反馈速度,所以人工只该用在校准集上。
Key points
- Self-preference: a same-model judge inflates scores (14/15 vs 12/15 cross-model), and it applies to every model-grading-model spot including Critic
- Length bias: 141 characters of padding moved an impression score from 2 to 4 with no substantive change
- Rubric drift: the same output scored 2 and 5 under two rubrics, so scores compare only within one judge prompt
- Remedies map one-to-one: version the rubric and store it alongside each record, refuse to aggregate mixed rubrics, default to a different judge model
- Keep a human-labeled calibration set and compare verdicts, not score deltas — a point of drift is fine, a flipped verdict is an incident
- The deeper fix is a checkable list instead of impressions, which also removes length bias (the padded reply scored 5 both ways)
答题要点
- 同源偏差:judge 与被评估 Agent 同模型会虚高(实测 14/15 vs 异源 12/15),且凡「模型评模型」的位置都有,Critic 同理
- 长度偏好:灌水 141 字能让印象分从 2 涨到 4,内容一字未变
- 评分提示词漂移:同一产出两套 rubric 一个 2 分一个 5 分,所以分数只在同一套提示词内部可比
- 解药一一对应:rubric 版本化并随记录存坐标、面板发现混口径直接拒绝聚合、默认换模型当 judge
- 留人工标注校准集,比结论(过/不过)而不是比分数差——差 1 分无所谓,结论翻了是事故
- 更根本的是把主观印象换成可核对的清单,同时解掉长度偏好(清单口径下灌水前后都是 5 分)
What does observability look like for a multi-agent system, and how does it differ from a single agent?多 Agent 系统的可观测性要看哪些东西?和单 Agent 有什么不一样?
Common in ChinaCommon overseasIntermediate#observability#tracing#distributed-systemsHow to reason about it · think before answering
- The hinge is differ. Saying add logs and metrics is a non-answer; name the structural difference.
- In one sentence: a single agent's call is a line, a multi-agent request is a tree. One request goes supervisor routing, planner splitting into three, three executors in parallel, a critic rejecting one, that one rerunning, then aggregation — flattened by time you cannot see nesting or which two ran concurrently.
- So spans must carry a parent pointer; that is the whole game. With it you have a tree, without it a flat list where you know what happened but not what triggered what. A span needs surprisingly few fields — id, parent, name, start and end, a few attributes — to reconstruct the entire tree.
- How the parent propagates is itself an interview point: do not thread a parentSpanId parameter through every function, because each new node then changes a signature and one omission breaks the chain. Use the language's implicit context — AsyncLocalStorage in JS, contextvars in Python, TaskLocal in Swift, and ScopedValue or ThreadLocal with explicit propagation across thread pools in Java.
- Then the four questions a dashboard must answer: how much is wrong (pass rate, routing accuracy, degradation rate, fallback rate), where is it slow (p50/p95), what did it cost, and which role spent the money (cost attributed per node). That last one is multi-agent specific and the most actionable — measured, executor nodes took over a third of spend, telling you immediately where to optimize.
- One foundational point: the dashboard is not a second instrumentation layer, it is an aggregation of traces. The same raw data read across is a tree and stacked up is a dashboard. Two separate sources will eventually disagree, after which nobody trusts either.
- Finally, tie back to routing: the routing decision is made by a model and the same sentence may route differently next time, so the routing rationale must be recorded — if you do not capture it then, that judgment is gone forever. It is the easiest thing to omit and the thing most needing post-hoc audit.
分析过程 · 先想清楚再作答
- 题眼在「不一样」。答「加日志加监控」等于没答,要说清结构上的差别。
- 结构差别一句话:**单 Agent 的一次调用是一条线,多 Agent 是一棵树。** 一次请求走监督者路由、规划者拆三件、三个执行者并行、评审者打回一件、那件重跑、最后汇总——按时间平铺看不出谁在谁里面,也看不出哪两个是并行的。
- 所以 span 必须带**父指针**,这是全部关键:有它才是树,没它只是一张平铺列表,你知道发生过什么,却不知道谁触发了谁。一条 span 的字段少得出奇——id、父指针、名字、起止时刻、几个属性,就够还原整棵树。
- 父子关系怎么传下去也是个考点:**不要在每个函数上加一个 parentSpanId 参数**,每加一个节点都要改签名、漏一处断一截。用语言自带的隐式上下文——JS 的 AsyncLocalStorage、Python 的 contextvars、Swift 的 TaskLocal,Java 用 ScopedValue 或 ThreadLocal 配合线程池的显式传播。
- 然后说面板要回答哪四个问题:错了多少(通过率、路由准确率、降级率、兜底率)、慢在哪(p50/p95)、花了多少、**钱花在哪个角色身上**(按节点分摊)。最后一样是多 Agent 特有的,也最有用——实测执行者节点占了成本三分之一强,一眼就知道压成本先压哪儿。
- 还有一条地基性的:**面板不是另一套埋点,是 trace 的聚合**。同一份原始数据横着看是树、竖着堆是面板。两套数据来源迟早会对不上,然后没有人相信任何一个。
- 最后回指路由:路由决策是模型做的,同一句话下次未必给同样的答案,所以必须把**路由理由**一起记下来——当时不记,那次判断就永远丢了。这是多 Agent 里最容易漏、又最需要事后审计的一条。
Key points
- Structural difference: a single agent call is a line, multi-agent is a tree (route, split, parallel execute, critic reject, rerun, aggregate)
- Spans need a parent pointer, or you have a flat list showing neither nesting nor parallelism
- Propagate parentage through implicit context (AsyncLocalStorage / contextvars / TaskLocal), not a parameter on every signature
- The dashboard answers four questions: how much is wrong, where it is slow, what it cost, and which role spent it — the last is multi-agent specific and most actionable
- The dashboard must be an aggregation of traces, not separate instrumentation; two sources will disagree
- Record the routing rationale: routing is a model decision, and uncaptured it is lost forever
答题要点
- 结构差别:单 Agent 一次调用是一条线,多 Agent 是一棵树(路由→拆分→并行执行→评审打回→重跑→汇总)
- span 必须带父指针,否则只是平铺列表,看不出嵌套关系也看不出并行
- 父子关系用语言自带的隐式上下文传(AsyncLocalStorage / contextvars / TaskLocal),不要在每个函数签名上加参数
- 面板回答四个问题:错了多少、慢在哪、花了多少、钱花在哪个角色身上(最后一个是多 Agent 特有且最有用)
- 面板必须是 trace 的聚合而不是另一套埋点,两套数据源迟早对不上
- 路由理由必须记下来:路由是模型做的决策,当时不记那次判断就永远丢了
When should you reach for an orchestration framework like LangGraph, and when should you not?什么时候该用 LangGraph 这类编排框架,什么时候不该用?
Common in ChinaCommon overseasIntermediate#architecture#framework-selection#langgraphHow to reason about it · think before answering
- The trap is answering with a feature matrix. The interviewer wants criteria, specifically criteria that can also say do not use it — people who can only argue for adoption usually have not been burned by a framework.
- Start with three criteria for splitting at all (if none holds, do not split and do not add a framework): the prompt contains mutually exclusive behavioural demands (rigorous and playful at once, where tuning one breaks the other); tools have grown numerous enough that selection error is visibly rising; or some step needs its own failure and retry semantics (an inventory lookup should retry, a refund draft should escalate to a human, and they cannot share one policy).
- Then the framework criterion, which is one sentence: if multiple roles write the same state concurrently, it must be explicit; if you do not need concurrency, explicitness is pure overhead. LangGraph's value is declaring merge rules on the field — with three executors writing one workspace, how those writes combine has to be declared somewhere. Conversely, with two tools and a loop that runs at most twice, a while and a switch suffice and a framework is a net loss.
- When comparing against a higher-level SDK like Pi, use dimensions rather than features: onboarding cost (Pi's defaults make it fast, at the price of it choosing your model and persona); explicitness of state (Pi keeps history inside the session, so when you want to change how one workspace merges there is no place to change it); and debugging shape (Pi gives an event stream, one timeline; LangGraph gives per-node deltas and checkpoints, a replayable and forkable tree — linear problems read faster as a timeline, multi-role problems require the tree).
- Cross-language deserves its own mention because it is routinely forgotten: neither Java nor Swift has LangGraph, so a polyglot team either standardizes on TS/Python or hand-writes the same structure. Pricing that in during selection is cheaper than discovering it after launch.
- Expect: so how do you choose? Give something actionable: do you fear invisible defaults more, or endless boilerplate more? Fear the former and pick the explicit framework; fear the latter and pick the high-level SDK. That sentence is more useful than any feature table.
分析过程 · 先想清楚再作答
- 这题最怕答成特性对比表。面试官想听的是判据,而且是能反过来说「不该用」的判据——只会说该用的人,通常是没被框架坑过的人。
- 先给三条该拆的判据(一条都不命中就别拆,也别引框架):**提示词里出现了互斥的行为要求**(既要严谨又要俏皮,调好一个另一个就坏);**工具多到选错率明显上升**;**某一步需要独立的失败与重试语义**(比如查库存失败该重试,拟退款方案失败该转人工,两者不能共用一套策略)。
- 然后给框架本身的判据,核心是一句:**要让多个角色并行写同一份状态,就必须显式;不需要并行,显式就是纯负担。** LangGraph 的价值是把合并规则声明在字段上——三个执行者并行写同一个工作区,谁的写入怎么合并,这件事必须有地方声明。反过来,一两个工具、循环最多两轮的场景,一个 while 加一个 switch 就够了,引入框架是净亏。
- 对比 Pi 这类高层 SDK 时,用维度而不是特性:上手成本(Pi 默认值多所以快,代价是模型和人设都是它替你挑的)、状态管理的显式程度(Pi 的历史在会话内部你感知不到,所以想改「同一个工作区怎么合并」时根本没有位置可改)、调试形态(Pi 给事件流是一条时间线,LangGraph 给逐节点增量和检查点是一棵可回放可分叉的树——**线性问题看时间线更快,多角色问题必须看树**)。
- 跨语言这条值得单独提,因为它常被忽略:**Java 和 Swift 都没有 LangGraph**,跨语言团队要么统一到 TS/Python,要么自己手写同一套结构。选框架的时候把这条算进去,比上线后再发现便宜。
- 可以预期的追问:那你怎么选?给一句可执行的:**你更怕看不见的默认值,还是更怕写不完的样板?** 怕前者选显式框架,怕后者选高层 SDK。这句话比任何特性表都实用。
Key points
- First decide whether to split at all: mutually exclusive prompt demands, rising tool-selection error, or a step needing its own retry semantics — none holding means no split and no framework
- The framework criterion in one line: concurrent writes to shared state require explicitness; without concurrency, explicitness is pure overhead
- LangGraph's value is declaring merge rules on the field; Pi keeps history inside the session, leaving nowhere to change merge behavior
- Different debugging shapes: an event stream is a timeline, per-node deltas plus checkpoints are a replayable forkable tree — timelines for linear problems, trees for multi-role ones
- Neither Java nor Swift has LangGraph, so polyglot teams standardize or hand-write the structure — price that in at selection time
- An actionable heuristic: fear invisible defaults, choose the explicit framework; fear endless boilerplate, choose the high-level SDK
答题要点
- 先答该不该拆:提示词有互斥的行为要求、工具多到选错率上升、某步需要独立的失败与重试语义——一条不命中就别拆也别引框架
- 框架判据一句话:多个角色并行写同一份状态就必须显式;不需要并行,显式就是纯负担
- LangGraph 的价值是把合并规则声明在字段上;Pi 的历史在会话内部,想改合并方式根本没有位置可改
- 调试形态不同:事件流是一条时间线,逐节点增量加检查点是一棵可回放可分叉的树;线性问题看时间线,多角色问题必须看树
- Java 和 Swift 都没有 LangGraph,跨语言团队要么统一栈要么手写同一套结构,选型时就要算进去
- 一句可执行的选型判据:更怕看不见的默认值就选显式框架,更怕写不完的样板就选高层 SDK
System design: a multi-agent support platform is live, the team edits prompts several times a week, nobody can say whether quality is improving, and cost is only known as a month-end total. Design its evaluation and observability system.系统设计:一个多 Agent 客服平台已经上线,团队每周改几次提示词,但没人说得清质量是变好还是变差,成本也只有一个月底的总数。请为它设计一套评估与可观测体系。
Common in ChinaCommon overseasDeep dive#system-design#evaluation#observability#costHow to reason about it · think before answering
- Do not draw an architecture diagram yet. The trap is that this sounds like build monitoring, so many candidates open with Prometheus and Grafana — that answers infrastructure, not this question. Spend three to five minutes on four things: how often prompts change and how they ship (weekly cadence, canary, rollback); how problems surface today (user complaints, or someone happening to notice); what history exists (how long conversations are retained, whether they can be replayed); and who consumes this (engineers debugging, or an executive watching spend). All four materially change the design, so asking them scores.
- Then the trunk, in one sentence: one dataset, two readings. Instrument once, as spans; read across for a single request's call tree (debugging) and stack them for a dashboard (trends and cost). This is the foundation — two data sources will eventually disagree and then nobody trusts either. Many candidates fork here into a monitoring system and an evaluation system, which is the source of every later problem.
- Then three layers. Layer one, offline regression: a small stable golden set (15 to 50), covering three things — every route exercised, one item per failure mode (low-confidence fallback, tool budget exhaustion, downstream outage), and the cases behind real past incidents. Each item declares its expected route and a checklist of required facts. The maintenance rule is add, never edit: changing an expectation voids all historical scores. Score with an LLM-as-judge using a different model, and version the rubric, storing that version on every record. This layer runs in CI on every prompt change and emits a number comparable to last time.
- Layer two, online observability: every request writes a span tree recording the routing rationale (a model decision, lost forever if not captured), per-node tokens and latency, and degradation and fallback events. The dashboard answers four questions: how much is wrong, where it is slow, what it cost, and which role spent it — that last one is multi-agent specific and the most actionable.
- Layer three, online sampled evaluation: fifteen offline cases cannot cover the real traffic distribution, so sample a fraction of live requests (say 1%) through the same judge to get a true quality curve. This layer bridges the other two: offline tells you whether you broke something known, online tells you what real users encountered.
- Bring numbers on cost, which is what separates levels. A multi-agent request can produce five to ten model calls, so per-call price is an order of magnitude below the real unit cost and you must price per request. Give the arithmetic: 10k DAU at three sessions each and five calls per session is 150k calls a day; at 2000 input and 500 output tokens, $0.15 and $0.60 per million, that is roughly $90 a day. That number implies two things: per-node attribution shows where to optimize, and evaluation's own cost must be tracked separately, since judge calls are the same order as the system itself and decide whether you evaluate per commit or nightly.
- Close on adoption, which many candidates omit: wire evaluation into the release process (block a deploy when pass rate drops below threshold), keep the rubric and golden set in the repository under code review, and pair every mechanism with a failure mode — judges favor same-family models, golden sets get gamed (someone tunes prompts to make it green, and at that moment it is worthless), sampling misses the long tail. A proposal with no stated failure modes reads as book knowledge.
- Expect, by frequency: which model judges (one tier above the system under test, and necessarily a different family); where the golden set comes from (start with human-labeled production samples, then append every incident); what happens when this system itself misbehaves (the dashboard refuses to aggregate mixed rubric versions rather than emitting a meaningless average); and how long to build (layer two in a week, layer one in two, layer three in a month since it depends on both).
分析过程 · 先想清楚再作答
- 先别画架构图。这道题的陷阱是它听起来像「搭一套监控」,于是很多人上来就报 Prometheus 加 Grafana——那答的是基础设施,不是这道题。花三到五分钟问清四件事:一是**改提示词的频率和发布方式**(每周几次、有没有灰度、能不能回滚);二是**现在出问题是怎么发现的**(用户投诉?还是有人偶然看到?);三是**有没有历史数据**(线上对话存了多久、能不能回放);四是**谁来看这套东西**(工程师排障,还是老板看成本)。这四个答案会实质改变设计,问它们本身就是分数。
- 然后给主干,一句话定形状:**一份数据、两种读法。** 埋点只做一套(span),横着读是一次请求的调用树(排障用),竖着堆是面板(趋势和成本用)。**这条是地基**——两套数据来源迟早对不上,然后没有人相信任何一个。很多候选人在这里就分叉成「监控系统」和「评估系统」两套,那是后面所有麻烦的源头。
- 接着按三层展开。**第一层,离线回归**:建一个小而稳的 golden set(15 到 50 条),三层覆盖——每条路由都有人走、每种失败模式各一条(置信度不足落兜底、工具预算耗尽降级、下游挂掉)、以及历史上真出过事故的那几条。每条写清期望路由和必备信息清单。维护规矩是**只增不改**:改一条期望,历史分数全部作废。用 LLM-as-judge 对照清单打分,**judge 换一个模型、rubric 版本化并随每条记录存下来**。这一层挂在 CI 上,每次改提示词跑一遍,产出一个能和上次比的数字。
- **第二层,在线观测**:每次请求落一棵 span 树,必须记路由理由(模型做的决策,当时不记就永远丢了)、每个节点的 token 与耗时、以及降级和兜底事件。面板回答四个问题:错了多少、慢在哪、花了多少、**钱花在哪个角色身上**。最后一个是多 Agent 特有的,也最有用。
- **第三层,在线采样评估**:离线的 15 条覆盖不了真实流量分布,所以按比例采样线上请求(比如 1%)跑同一套 judge,得到一条真实质量曲线。**这一层是前两层的桥**:离线告诉你有没有改坏已知的东西,在线告诉你真实用户遇到了什么。
- 成本这块要给数字感,这是区分层级的地方。**多 Agent 一次用户请求可能产生 5 到 10 次模型调用**,所以「每次调用多少钱」比真实单价小一个数量级,**必须按请求算钱**。给个算式:日活一万、人均三次会话、每次 5 次调用就是 15 万次调用;按输入 2000 输出 500 token、$0.15/$0.60 每百万算,一天约 90 美元。这个数立刻推出两件事:按节点分摊能定位省钱的地方,以及**评估本身的成本要单独记**——judge 调用和被评估系统一个量级,它决定你每次提交都跑还是每天跑一次。
- 最后收在「怎么让它真的被用起来」,这是很多人漏的一层:把评估结果接进发布流程(通过率跌破阈值就挡住发布)、把 rubric 和 golden set 放进代码仓库走 code review、以及**给每个机制配一句失效模式**——judge 会偏向同源模型、golden set 会被针对性优化(有人为了让它绿而调提示词,那一刻它就失去了意义)、采样会漏掉长尾。说不出失效模式的方案,面试官会认为你只是读过。
- 可以预期的追问,按频率排:judge 用什么模型(比被评估的强一档,且必须异源);golden set 从哪来(先从线上捞一批人工标注,再逐次把事故补进去);这套东西自己出问题怎么办(面板发现 rubric 混版直接拒绝聚合,而不是给一个没含义的平均分);多久能上线(第二层一周、第一层两周、第三层一个月,因为它依赖前两层)。
Key points
- Spend three to five minutes clarifying four things: prompt change cadence and release process, how problems surface today, what replayable history exists, and who the audience is
- The trunk is one dataset, two readings: instrument once as spans, read across for a call tree and stack for a dashboard; two sources will disagree
- Layer one, offline regression: a small stable golden set covering every route, every failure mode and past incidents, add-never-edit, wired into CI
- Layer two, online observability: span trees recording routing rationale, per-node tokens and latency, degradation events; the dashboard answers wrong/slow/cost/which-role
- Layer three, sampled online evaluation through the same judge, covering the real distribution the offline set cannot
- Price per request, not per call: five to ten calls per request, with arithmetic showing ~$90/day at 10k DAU; track evaluation's own cost separately
- Close on adoption: block releases when pass rate drops, keep rubric and golden set in the repo under review
- Pair every mechanism with a failure mode: judge self-preference, golden set gaming, sampling missing the tail — omitting these reads as book knowledge
答题要点
- 先用三到五分钟问清四件事:改提示词的频率与发布方式、现在问题怎么被发现、有无历史数据可回放、这套东西给谁看
- 主干是「一份数据、两种读法」:埋点只做一套 span,横着读是调用树、竖着堆是面板;两套数据源迟早对不上
- 第一层离线回归:小而稳的 golden set,三层覆盖(每条路由、每种失败模式、历史事故),只增不改,挂 CI
- 第二层在线观测:span 树记路由理由、每节点 token 与耗时、降级兜底事件;面板回答错了多少/慢在哪/花了多少/钱花在哪个角色
- 第三层在线采样评估:按比例采样线上请求跑同一套 judge,补上离线覆盖不到的真实分布
- 成本必须按请求算而非按调用:一次请求 5 到 10 次调用,给出日活一万约 90 美元一天的算式;评估自身成本单独记
- 收在落地:通过率跌破阈值挡发布、rubric 与 golden set 进仓库走 review
- 每个机制配失效模式:judge 偏向同源、golden set 会被针对性优化、采样漏长尾——说不出失效模式等于只是读过