Dynamic Routing With a Supervisor: Structured-Output Routing, Override, routingReason
Implement a supervisor node that uses structured output to dynamically decide which subagent to route to, and support human override plus recording the reason behind each routing decision.
Today's Goals
- Implement a supervisor node that outputs a routing decision as structured output
- Add a routingReason to the routing result to make debugging and retrospectives easier
- Implement an override mechanism for a human to correct the model when it routes incorrectly
Yesterday's graph contained one obvious fiction: all three edges hard-wired, with the same handler always following the entrance. Today one of those edges gets its destination decided by the model at runtime, and we think through "what if it decides wrong" once and for all.
Plain-Language Walkthrough
A dispatch desk does not work the case, it decides whose case it is
When you call an emergency line, the person answering does not chase the thief or fight the fire. They do one thing: hear your first two sentences, decide whether this is police, traffic, or fire, and pass the ticket to the matching department. The whole call may last twenty seconds, and those twenty seconds decide everybody else's workload — send it to the wrong department and the responders discover the mistake on arrival, it gets transferred again, and all the time went into travel.
That is the Supervisor pattern: one node dedicated to deciding who should take this case, handing it off and doing no work itself. In our e-commerce support scenario there are three takers, and their names do not change this week: order_lookup for order status and shipping progress, refund_draft to draft a refund proposal by the rules without executing a refund, and smalltalk for chat and as the catch-all.
Why make the decision its own node rather than letting the order-lookup agent judge for itself? Because deciding and executing are two different kinds of work. Deciding needs the whole picture, speed, and cheapness, usually in a few dozen tokens; executing needs detail, tools, and a pile of rules. Mixed together you can neither swap a smaller model in for the decision alone nor measure triage accuracy separately — and that metric happens to be the most worthwhile one to watch in a multi-agent system.
Technically the Supervisor rests on a conditional edge: this edge's destination is unknown at compile time and is decided at runtime by a selector function. Yesterday's three unconditional edges hard-wired the order; today's edge hands "where next" to a field in the state.
import { StateGraph, START, END } from '@langchain/langgraph'
// The supervisor writes two fields only: who takes it (route) and why (routingReason).
// It does not answer the user's question
const supervisor = async (state) => decide(lastText(state))
// The selector reads state and calls no model: the decision was already made inside
// supervisor, and this only translates it into a node name
const selectRoute = (state) => state.route ?? 'smalltalk'
export const graph = new StateGraph(AgentAnnotation)
.addNode('supervisor', supervisor)
.addNode('order_lookup', orderLookup)
.addNode('refund_draft', refundDraft)
.addNode('smalltalk', smalltalk)
.addEdge(START, 'supervisor')
// The third argument maps the selector's return value to a node name. Write it out in
// full: returning an unregistered name is a compile-time error, and a registered node
// nobody points at makes compile() throw UnreachableNodeError
.addConditionalEdges('supervisor', selectRoute, {
order_lookup: 'order_lookup',
refund_draft: 'refund_draft',
smalltalk: 'smalltalk',
})
.addEdge('order_lookup', END)
.addEdge('refund_draft', END)
.addEdge('smalltalk', END)
.compile()from langgraph.graph import StateGraph, START, END
# The supervisor writes two fields only: who takes it (route) and why (routing_reason)
async def supervisor(state: AgentState) -> dict:
return decide(last_text(state))
# The selector reads state and calls no model: it only translates into a node name
def select_route(state: AgentState) -> str:
return state.get("route") or "smalltalk"
graph = (
StateGraph(AgentState)
.add_node("supervisor", supervisor)
.add_node("order_lookup", order_lookup)
.add_node("refund_draft", refund_draft)
.add_node("smalltalk", smalltalk)
.add_edge(START, "supervisor")
# The third argument maps the selector's return value to a node name; write it in full
.add_conditional_edges(
"supervisor",
select_route,
{"order_lookup": "order_lookup", "refund_draft": "refund_draft", "smalltalk": "smalltalk"},
)
.add_edge("order_lookup", END)
.add_edge("refund_draft", END)
.add_edge("smalltalk", END)
.compile()
)// Java has no LangGraph, so this hand-writes the same graph idiomatically for JDK 17+:
// a node is a function and a conditional edge is one table lookup.
// Dependencies: JDK 17+, no third-party library
enum Route { ORDER_LOOKUP, REFUND_DRAFT, SMALLTALK }
// An EnumMap's key space is the enum itself, and with the self-check below a missing
// branch prevents startup rather than crashing halfway through
static final Map<Route, UnaryOperator<AgentState>> AGENTS = new EnumMap<>(Map.of(
Route.ORDER_LOOKUP, Graph::orderLookup,
Route.REFUND_DRAFT, Graph::refundDraft,
Route.SMALLTALK, Graph::smalltalk));
static {
for (Route r : Route.values())
if (!AGENTS.containsKey(r)) throw new IllegalStateException("route " + r + " has no subagent");
}
static AgentState run(AgentState input) {
AgentState routed = supervisor(input); // decides who takes it, answers nothing
return AGENTS.get(routed.route()).apply(routed); // this one lookup is the conditional edge
}// Swift has no LangGraph either. This hand-writes the same graph with an enum and a switch:
// a switch over an enum is exhaustive, so a missing branch **does not compile** - the
// biggest benefit of the Swift version of a conditional edge
enum Route: String {
case orderLookup = "order_lookup"
case refundDraft = "refund_draft"
case smalltalk
}
func nextAgent(for route: Route) -> (AgentState) async throws -> AgentState {
switch route {
case .orderLookup: return orderLookup
case .refundDraft: return refundDraft
case .smalltalk: return smalltalk
}
}
func run(_ input: AgentState) async throws -> AgentState {
let routed = try await supervisor(input) // decides who takes it, answers nothing
return try await nextAgent(for: routed.route)(routed)
}All four teach one thing: turn "where next" from a hard-wired order in the code into one runtime table lookup. The mechanics are now covered, and everything remaining is the genuinely hard question — what goes inside decide. Can you just have the model say "I think the order-lookup colleague should see this" and parse that sentence?
Do not let the model tell you the route in plain language
The conclusion first: no, and its failure mode is particularly nasty — it is silent.
Picture the lazy version: let the model answer freely who should take this case, and scrape a subagent's name out of the reply with a regular expression. What happens? The model replies "I think the order-lookup colleague could take a look at this." It decided correctly, entirely correctly. But it said it in prose rather than as an id, the regex misses, your code falls into the catch-all, and the log holds one line: route=smalltalk.
You cannot tell from the log that it actually decided correctly. That is the deadly part. If the model had decided wrong you could see it in the transcript; here the model is right and the parsing is wrong, and both present identically. By the time you notice triage accuracy is only sixty percent and spend two days tuning the routing prompt, you discover the prompt was never the problem — those three lines of regex were.
Free-text routing has three holes, each more insidious:
One, the output drifts. Today the model says "order lookup," tomorrow "check the order," the day after "let me look at this shipment first." Your regex can never keep up and only grows branches. A vendor quietly ships a minor version and your routing accuracy drops a notch with nothing changed on your side.
Two, there is no confidence. Free text carries no "how sure am I." The model is genuinely unsure about a vague question like "how did that thing turn out," and its tone reads exactly as it does when certain. All you receive is a sentence, with no way to separate "confident" from "guessing."
Three, a misspelled route name only explodes at runtime. The model invents a department called complaint_handler that has no node in your graph. With free text you have no allowlist gate at all, and that name flows into the graph and jumps to a node that does not exist.
Structured output solves all three at once: hand the model a schema requiring the output to be an object with route (one of three enum values only), confidence (a number from zero to one), and routingReason (one human-readable sentence). The model's decoding is constrained by the schema and what comes back is already a validatable structure.
import { z } from 'zod'
// One declaration does two jobs: constraining the request (the model generates against it)
// and validating the response (we parse against it), so the two cannot drift
export const RouteDecision = z.object({
route: z.enum(['order_lookup', 'refund_draft', 'smalltalk']),
confidence: z.number().min(0).max(1),
routingReason: z.string().min(1).max(120),
})
const body = {
model: 'openai/gpt-4o-mini',
messages: [{ role: 'system', content: ROUTER_PROMPT }, { role: 'user', content: text }],
response_format: {
type: 'json_schema',
json_schema: { name: 'route_decision', strict: true, schema: z.toJSONSchema(RouteDecision) },
},
}
// What comes back is still only text: the model promises JSON, not correct JSON, so this
// step cannot be skipped
const parsed = RouteDecision.safeParse(JSON.parse(await postJson(body)))
if (!parsed.success) return fallback('unknown-route')from typing import Literal
from pydantic import BaseModel, Field, ValidationError
# A pydantic model is likewise one declaration used twice: model_json_schema() goes to the
# model and model_validate_json() checks what comes back
class RouteDecision(BaseModel):
route: Literal["order_lookup", "refund_draft", "smalltalk"]
confidence: float = Field(ge=0, le=1)
routing_reason: str = Field(min_length=1, max_length=120)
body = {
"model": "openai/gpt-4o-mini",
"messages": [{"role": "system", "content": ROUTER_PROMPT}, {"role": "user", "content": text}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "route_decision",
"strict": True,
"schema": RouteDecision.model_json_schema(),
},
},
}
try:
decision = RouteDecision.model_validate_json(post_json(body))
except ValidationError:
return fallback("unknown-route")// Dependencies: Jackson. Java's gate is the enum itself: given complaint_handler,
// readValue throws InvalidFormatException and business code never has to judge.
// The same Route as above, with annotations so it participates in decoding
enum Route {
@JsonProperty("order_lookup") ORDER_LOOKUP,
@JsonProperty("refund_draft") REFUND_DRAFT,
@JsonProperty("smalltalk") SMALLTALK
}
record RouteDecision(Route route, double confidence, String routingReason) {}
static final ObjectMapper MAPPER = new ObjectMapper();
static RouteDecision parse(String json) {
try {
return MAPPER.readValue(json, RouteDecision.class);
} catch (JsonProcessingException e) {
// A name outside the enum, a missing field, or not JSON at all all land here;
// the next section separates the three causes
return fallback("unknown-route");
}
}// Swift's gate is a RawRepresentable enum: a rawValue outside the list makes JSONDecoder
// fail decoding, the same gate as zod's enum and Jackson's enum written differently.
// The same Route as above, plus Codable so it participates in decoding
enum Route: String, Codable {
case orderLookup = "order_lookup"
case refundDraft = "refund_draft"
case smalltalk
}
struct RouteDecision: Decodable {
let route: Route
let confidence: Double
let routingReason: String
}
func parse(_ data: Data) -> RouteDecision? {
// try? is appropriate here: there is only one way to handle a decode failure, the
// fallback, with no need to know which field broke
try? JSONDecoder().decode(RouteDecision.self, from: data)
}The JSON Schema zod generates is the constraint sent to the model, and it looks like this, with the enum line the most valuable thing in it:
{
"type": "object",
"properties": {
"route": { "type": "string", "enum": ["order_lookup", "refund_draft", "smalltalk"] },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
"routingReason": { "type": "string", "minLength": 1, "maxLength": 120 }
},
"required": ["route", "confidence", "routingReason"],
"additionalProperties": false
}There is a bill too: routing's input is usually the system prompt plus the user's last sentence or two, and its output is three fields, so by this course's price list (openai/gpt-4o-mini at 0.15 dollars per million input tokens and 0.60 per million output) one routing call is about 300 tokens in and 50 out, under a ten-thousandth of a dollar, an order of magnitude cheaper than a full answer. Which is why you can comfortably give the Supervisor its own cheaper small model — it only answers multiple choice.
routingReason: you have to be able to say why it decided that way
The emergency ticket has, besides "transfer to traffic," a field saying why it was classified as traffic. That field is not for the dispatcher, it is for whoever reviews it three days later.
routingReason is that field, and it is the only auditable part of this whole dynamic-routing arrangement. Consider: the routing decision was made by a model, the same model may not give the same answer to the same sentence next time, and you cannot rerun it to see how it was thinking. Unless the reason was recorded at the time, that judgment is gone forever.
So routingReason is not a decorative log field, it has three concrete uses:
One, separating "decided wrong" from "parsed wrong." With a reason you see at a glance whether the model erred or was caught by the catch-all. In the previous section's example, a log reading fallback:unknown-route(complaint_handler) tells you instantly that the model wanted a department that does not exist; with only route=smalltalk you spend two days.
Two, accumulating material for the next prompt. Group a week of catch-all requests by reason and a few clusters appear: all of them asking whether they can change the delivery address means the triage prompt lacks a description for that category. Far more effective than guessing at prompt edits.
Three, it is an input to D21's evaluation. A golden set evaluates not only whether the final answer is good but whether triage was accurate. And triage accuracy is only assessable if both the decision and its reason were recorded.
A decent routing log looks like this, with the three lines being correct, caught, and manually reassigned:
2026-09-16T10:02:11Z run=r-8831 route=order_lookup conf=0.88 reason="asking about one order's shipping progress, so order lookup"
2026-09-16T10:04:37Z run=r-8832 route=smalltalk conf=0.42 reason="fallback:low-confidence(0.42) | model said order_lookup: mentions that thing without saying which order"
2026-09-16T10:07:52Z run=r-8833 route=refund_draft conf=1.00 reason="override:refund_draft | set by a human, model said order_lookup(0.88): asking about shipping progress"Note the second and third lines both preserve the model's original decision. That is the writing rule most worth remembering here: neither a fallback nor an override should erase what the model decided, or a week later nobody can say whether this case was misrouted by the model or reassigned by a person. Twenty extra characters save one investigation through the whole codebase.
override: a duty manager may reassign, and reassignment leaves a trace
After the desk decides, a duty manager can still reassign. That entry point must exist in a real system, because a model will sometimes decide badly and you cannot rescue one specific complaint by editing a prompt and shipping a release.
Override is one sentence to implement: it outranks the model's decision and must not erase the trace of it. In code, after obtaining the model's conclusion, if this execution carries an override, overwrite route with the override's target while splicing what the model decided and with what confidence into routingReason verbatim. The third log line above came from exactly that.
Two details are easy to miss. One, overrides go through enum validation too — people misspell as well, and a misspelled override jumps the graph to a node that does not exist, which is harder to diagnose than a model error. Two, do not write the override into graph state; pass it through runtime configuration. Because it describes "this execution was intervened in by a person" rather than the agent's own state; mixed into state, D18's checkpointing treats that one-off human decision as history to restore, so replaying from a checkpoint mysteriously reassigns it again.
So when should you override? Three criteria, and never otherwise:
- The case burning right now. The user has already complained and cannot wait for a prompt edit and a release, so route this one by hand.
- Piloting a new subagent. A new subagent handles address changes, so force a little traffic to it with an override to see how it does before writing it into the triage prompt.
- Reproducing a bug. "What would have happened if this same sentence had gone to refunds" — an override is the cheapest reproduction there is.
Conversely, an override must not become a long-term patch. If you find some category needs reassigning by hand every day, the triage prompt or the subagent boundaries themselves are wrong and that is what to fix. Production deserves an alert: an override rate above one percent means somebody should go and look at what broke in triage. That metric is itself an interview bonus — it shows you treat human intervention as a signal rather than a solution.
When it cannot tell, say one thing less rather than answering wrong with confidence
The last piece is the fallback. First, why it matters so much: routing to the wrong subagent is far worse than routing failing.
A routing failure at least tells you it failed, so you can ask "did you want to check an order or request a refund?" A misroute leaves the receiving subagent entirely unaware it took the wrong case — the refund agent will earnestly reply, by the refund rules, that this order qualifies for a no-questions return, in a confident tone and a complete format with no visible flaw, while the user was asking about an invoice. The user will not be suspicious, they will act on that wrong answer. A confident wrong answer is a hundred times more expensive than one "I did not quite catch that."
So the rule is hard: a confidence below 0.6, or a route name outside the three subagents' list, falls to smalltalk with a fallback: plus cause marker in routingReason. The smalltalk persona says "when information is insufficient, ask for the missing key detail first and do not guess" — the point of a fallback is not finding somebody to take it, it is handing the uncertainty back to the user so they add one sentence.
const CONFIDENCE_FLOOR = 0.6 // when unsure, ask one more question: a confident wrong answer costs more
export function normalizeDecision(raw) {
const parsed = RouteDecision.safeParse(raw)
if (!parsed.success) {
// Extract the name the model wanted, so afterwards you can see at a glance that it
// wanted to go somewhere nobody is
const attempted = typeof raw?.route === 'string' ? `unknown-route(${raw.route})` : 'invalid-shape'
return { route: 'smalltalk', confidence: 0, routingReason: `fallback:${attempted}` }
}
const d = parsed.data
if (d.confidence < CONFIDENCE_FLOOR) {
// A fallback keeps the model's decision too, or a review cannot tell a wrong decision
// from an unsure one
return {
route: 'smalltalk',
confidence: d.confidence,
routingReason: `fallback:low-confidence(${d.confidence}) | model said ${d.route}: ${d.routingReason}`,
}
}
return d
}CONFIDENCE_FLOOR = 0.6 # when unsure, ask one more question
def normalize_decision(raw: dict) -> Routed:
try:
d = RouteDecision.model_validate(raw)
except ValidationError:
# Extract the name the model wanted: afterwards you can see it aimed at nobody
attempted = raw.get("route")
cause = f"unknown-route({attempted})" if isinstance(attempted, str) else "invalid-shape"
return Routed("smalltalk", 0.0, f"fallback:{cause}")
if d.confidence < CONFIDENCE_FLOOR:
# A fallback keeps the model's decision too
return Routed(
"smalltalk",
d.confidence,
f"fallback:low-confidence({d.confidence}) | model said {d.route}: {d.routing_reason}",
)
return Routed(d.route, d.confidence, d.routing_reason)// Dependencies: Jackson. Java puts both gates in one place: parse failure in the catch,
// low confidence in the if
static final double CONFIDENCE_FLOOR = 0.6;
static Routed normalize(String json) {
RouteDecision d;
try {
d = MAPPER.readValue(json, RouteDecision.class);
} catch (InvalidFormatException e) {
// The raw value the enum rejected is on the exception, so write it into the reason
return new Routed(Route.SMALLTALK, 0, "fallback:unknown-route(" + e.getValue() + ")");
} catch (JsonProcessingException e) {
return new Routed(Route.SMALLTALK, 0, "fallback:invalid-shape");
}
if (d.confidence() < CONFIDENCE_FLOOR) {
// A fallback keeps the model's decision too
return new Routed(Route.SMALLTALK, d.confidence(),
"fallback:low-confidence(%s) | model said %s: %s".formatted(d.confidence(), d.route(), d.routingReason()));
}
return new Routed(d.route(), d.confidence(), d.routingReason());
}let confidenceFloor = 0.6 // when unsure, ask one more question
func normalize(_ data: Data) -> Routed {
do {
let d = try JSONDecoder().decode(RouteDecision.self, from: data)
// guard is Swift's standard "bail out when a condition fails", rather than nested ifs
guard d.confidence >= confidenceFloor else {
// A fallback keeps the model's decision too
let reason = "fallback:low-confidence(\(d.confidence)) | model said \(d.route.rawValue): \(d.routingReason)"
return Routed(route: .smalltalk, confidence: d.confidence, routingReason: reason)
}
return Routed(route: d.route, confidence: d.confidence, routingReason: d.routingReason)
} catch DecodingError.dataCorrupted(let ctx) where ctx.codingPath.last?.stringValue == "route" {
// A rawValue outside the enum makes JSONDecoder throw exactly dataCorrupted with
// codingPath pointing at route, so "where the model wanted to go" survives in Swift too
return Routed(route: .smalltalk, confidence: 0, routingReason: "fallback:unknown-route")
} catch {
return Routed(route: .smalltalk, confidence: 0, routingReason: "fallback:invalid-shape")
}
}A threshold of 0.6 is an engineering judgment, not a truth: raise it and more requests fall to the catch-all with users asked one more question, lower it and more ambiguous requests are forced somewhere. How to set it depends on which of the two errors is more expensive. In support, one extra question costs a little impatience and a misroute may cost a wrong refund promise, so be conservative. For an internal tooling agent, one extra question costs more, so the threshold can go lower. Asked in an interview how to set the threshold, reaching this level shows you are not reciting a number.
One inoculation in advance: confidence is self-reported by the model and it is not a probability. A model saying 0.9 does not mean it is right nine times out of ten. It is a relatively usable ranking signal — with the same model and the same prompt, the 0.9 batch really is more accurate than the 0.4 batch. So do not compute expected values with it, use it only as a gate. Real accuracy has to be measured with D21's golden set.
Source Reading
Hands-On Lab
Today has no infrastructure dependencies, so this lab has no docker-compose.yml: the supervisor, the three subagents, and the conditional edge are all in-process. The only network egress is the model call, and under MOCK=1 it returns a routing decision that varies with the input — a shipping question gets 0.88 confidence, "that thing from last time" gets 0.42, and "I want to complain" invents a department that does not exist, all four phenomena having genuinely happened in production. src/shared/state.ts is word for word D15's, with no field added today, only the first real writes into route and routingReason. Do the four exercises in order; the later self-checks will not turn green until the earlier ones are done.
- Run
MOCK=1 SELFTEST=1 pnpm startas-is first; the wording of the five failures is your to-do list, and pay attention to check 1 — how free-text routing silently discards a correct decision. - Exercise 1, replace the supervisor's free text plus regex with
routeStructuredand a zod schema, and check 1's three routes start hitting individually. - Exercise 2, add enum validation and the 0.6 confidence gate to
normalizeDecision, and checks 2 and 3's fallback reasons carry thefallback:prefix. - Exercise 3, teach
resolveRouteabout overrides and splice the model's decision into the reason, and check 4's case is forced to the refund draft. - Exercise 4, change
selectRouteto genuinely readroutefrom state, taking check 5 from "all six ran order_lookup" to "ran exactly what was decided."
Interview Questions
Today's four questions are in the bank below, weighted toward routing design, structured output, and intent fallback, with the last specifically about what debug information is worth in production — how well you answer that one exposes directly whether you have investigated a live problem. Expand a question and read the analysis before the key points; practicing the derivation beats memorizing the answer. Each is tagged for the China-domestic or overseas market so you can prioritize by where you are applying.
Checklist and Tomorrow
- Implement a supervisor node that outputs a routing decision as structured output
- Add a routingReason to the routing result to make debugging and retrospectives easier
- Implement an override mechanism for a human to correct the model when it routes incorrectly
- State free-text routing's three holes: it drifts, it has no confidence, and a misspelled name only explodes at runtime
- Explain why routing to the wrong subagent is worse than routing failing, and how the 0.6 threshold should be set
- All 5 acceptance criteria of the lab pass (all five self-checks green)
- Answer at least 3 of the 4 interview questions without looking at the key points
Tomorrow (D17) covers Planner-Executor-Critic and a shared workspace. Why does it follow today? Because a Supervisor solves only "who takes it" — it dispatches one person at a time and considers the job done. Real cases are often "look up the order, then check it against the refund rules, then have somebody review whether that proposal may be sent": one thing split into several, several runnable in parallel, and a sign-off after. Tomorrow assembles task splitting, parallel execution, and a review loop into one chain, and answers a question today dodged: when several nodes write the same state field at once, who overwrites whom.
Interview questions
How is the routing decision usually implemented in a supervisor pattern? What should that node do, and what should it not do?Supervisor 模式里的路由决策一般怎么实现?请说说这个节点该做什么、不该做什么。
Common in ChinaCommon overseasBasic#multi-agent#routing#langgraphHow to reason about it · think before answering
- This is a warm-up question, and warm-ups are where people lose points by restating the prompt: an agent decides who goes next. The discriminator is the second half — can you state the node's responsibility boundary?
- Start with the mechanics: the supervisor is an ordinary node. It reads state, makes one model call, and writes exactly two fields — the route and the reason for it. The actual branching happens on the conditional edge after it, whose selector function maps the route to the next node name.
- Then draw the boundary, which is where the points are: the supervisor never answers the user, never calls business tools, and produces no side effects. It only takes a multiple-choice test, so it can run on a cheaper small model with a short input.
- One boundary people miss: do not call the model inside the selector function. The judgment was already made and stored in state; the selector only translates. Calling a model there makes the same state jump to different nodes across runs, which destroys reproducibility and breaks checkpoint replay and evaluation later.
- Close with one-at-a-time: a supervisor answers who takes this, not how to split a task and who reviews the output. That second problem belongs to planner-executor-critic. Drawing that line yourself signals you have seen a real system.
- Expect: where does the list of sub-agents live, and how many places change when you add one? Answer that the list should be a single source of truth — the enum, the schema, and the edge mapping all derive from it, so adding an agent is one edit and everything else fails at compile time.
分析过程 · 先想清楚再作答
- 这是一道送分题,但送分题最容易答成「让一个 Agent 决定下一步找谁」这种复述题面的话。区分度在后半句:你能不能说清这个节点的职责边界。
- 先给机械原理:Supervisor 是图里的一个普通节点,它读状态、调一次模型、只写两个字段——交给谁(route)和为什么这么判(routingReason);真正的分叉发生在它后面那条条件边上,边上挂一个选择函数,把 route 翻译成下一个节点名。
- 再划边界,这是拿分的地方:Supervisor 不回答用户的问题、不调业务工具、不产生副作用。它只做选择题,所以可以配一个更便宜的小模型,输入通常只有系统提示词加最后一两句话。
- 还有一条边界更容易被忽略:**选择函数里不要再调模型**。判断已经在 Supervisor 节点里做完并落进状态了,选择函数只做翻译。把模型调用塞进选择函数,同一份状态每次可能跳到不同的节点,图就不可复现,后面做检查点重放和评估都会失真。
- 最后补一句「一次只派一个人」:Supervisor 解决的是「交给谁」,不解决「一件事要拆成几件、还得有人验收」。后者是 Planner-Executor-Critic 的活。能主动划出这条线,面试官会认为你见过真实系统的边界。
- 可以预期的追问:那三个子 Agent 的名单从哪来、加一个新的要改几处?答案是名单应该是单一真相来源——枚举定义、schema、条件边的映射表都从它生成,加一个子 Agent 只改一处,其余地方编译期报错提醒你。
Key points
- The supervisor is an ordinary node: read state, one model call, write only the route and the routing reason
- Branching lives on the conditional edge after it — a selector maps the route to a node name, and the mapping table must be exhaustive
- Boundary: it never answers the user, calls no business tools, has no side effects, so it can run on a cheaper small model
- Never call a model inside the selector, or the same state jumps to different nodes across runs and replay and evaluation both break
- A supervisor dispatches one agent at a time and only answers who takes this; splitting and reviewing belong to planner-executor-critic
答题要点
- Supervisor 是图里的一个普通节点:读状态、调一次模型、只写 route 与 routingReason 两个字段
- 真正的分叉在它后面的条件边上:选择函数把 route 翻译成下一个节点名,映射表要写全
- 职责边界:不回答用户、不调业务工具、不产生副作用,因此可以单独配一个更便宜的小模型
- 选择函数里不能调模型,否则同一份状态每次跳的节点不同,图不可复现,检查点重放与评估都会失真
- Supervisor 一次只派一个人,只解决「交给谁」;拆任务与验收是 Planner-Executor-Critic 的职责
Why use structured output rather than natural language for routing? What exactly goes wrong with free text?为什么要让模型输出 structured output 而不是自然语言来做路由?自然语言到底差在哪?
Common in ChinaCommon overseasIntermediate#structured-output#routing#reliabilityHow to reason about it · think before answering
- The trap is answering structured output is cleaner and easier to parse. Those are adjectives, not reasons. The interviewer wants a concrete failure you have actually debugged.
- Lead with the sharpest point: free-text routing fails silently. The model replies I think the order desk should look at this — it judged correctly, but it spoke prose, not an id. Your regex misses, you fall through to the default, and the log shows only smalltalk. A correct model with a broken parser looks exactly like a wrong model, so you spend two days tuning a prompt that was never the problem.
- Then list three holes and map each to what structured output fixes: wording drifts across versions so regexes never catch up; there is no confidence signal, so you cannot tell certainty from guessing; and an invented route name only explodes at runtime, whereas an enum is a gate that exists before the request is even sent.
- Explain the mechanism rather than stopping at zod is nicer: send the schema in the request (response_format with a json_schema), so decoding is constrained by the enum, then validate the response with the same declaration. One declaration used twice means request and validation cannot drift apart.
- The counterintuitive point that separates candidates: structured output does not remove the need to validate. Not every gateway or model enforces the schema strictly, and a fallback model may not at all. Your parse function should return something-to-be-validated, not an already-typed decision.
- Expect: what if the model does not support json_schema? Fall back to few-shot plus a strict prompt plus your own validation. The real gate was never the model's discipline; it is your parsing layer.
分析过程 · 先想清楚再作答
- 这题最容易答成「结构化更规范、更好解析」——这是形容词,不是理由。面试官想听的是一个具体的失败场景,最好是你真的调过的那种。
- 把最锋利的一刀先亮出来:**自然语言路由的失败是静默的**。模型回「我觉得这个可以让查订单的同事看一下」,它其实判对了,但说的是人话不是 id,正则匹配不上就落进兜底,日志里只留下一个 smalltalk。模型是对的、解析是错的,而它和「模型判错了」在日志里长得一模一样。你会去调提示词,调两天才发现问题在那三行正则。
- 然后给三个漏洞,一条一条对上结构化输出解决了什么:输出会漂移(今天回「订单查询」明天回「查订单」,正则永远追不上,模型小版本升级你就掉准确率);没有置信度(自然语言里没有「我有多大把握」这个信息,你没法区分它很确定还是在猜);拼错或自造的路由名要到运行时才炸(枚举是一道编译期就存在的闸门)。
- 接着说清机制,别停在「用 zod 更规范」:把 schema 发进请求(response_format 里的 json_schema),模型的解码过程被枚举约束;回来之后**用同一份声明再校验一遍**。一份声明两用,请求与校验不会漂移。
- 关键的反直觉点,答到这里就拉开差距了:**结构化输出不等于不用校验**。不是所有网关、所有模型都严格执行 schema,降级到备用模型时更说不准。所以解析函数的返回类型应该是「一段待校验的东西」,而不是「已经是 RouteDecision」。
- 可以预期的追问:那不支持 json_schema 的模型怎么办?答案是退回「few-shot 加严格提示词加自己校验」,闸门仍然在你的枚举校验那一步——真正兜底的从来不是模型的自觉,是你的解析层。
Key points
- Free-text routing fails silently: a correct judgment in prose misses your regex and falls through, looking identical to a wrong judgment in the logs
- Three holes: wording drifts, there is no confidence signal, and invented route names only fail at runtime
- One declaration used twice: the schema constrains decoding in the request and validates the response, so the two cannot drift
- An enum is a gate that exists before the call, turning a misspelled route from an incident into a parse failure
- Structured output does not remove validation — gateways and fallback models may not enforce the schema, so parsing must return an unvalidated value
答题要点
- 自然语言路由的失败是静默的:模型判对了但说的是人话,正则匹配不上就落兜底,和判错在日志里完全一样
- 三个漏洞:措辞会漂移(正则追不上)、没有置信度(分不清确定与猜)、自造的路由名要到运行时才炸
- 机制是一份声明两用:schema 随请求发出去约束解码,回来后用同一份声明校验,请求与校验不会漂移
- 枚举是编译期就存在的闸门,把「拼错的路由名」从线上事故降级成一次解析失败
- 结构化输出不等于不用校验:网关和降级模型未必严格执行 schema,解析函数的返回类型应该是「待校验」而不是「已经是」
How should the system handle an uncertain or wrong routing decision, and how do you pick the threshold?路由不确定或者路由错误时,系统应该怎么兜底?阈值该怎么定?
Common in ChinaCommon overseasDeep dive#routing#fallback#reliabilityHow to reason about it · think before answering
- The hinge is that uncertain and wrong are two different failures. Most candidates answer retry or escalate to a human, collapsing both into one. The discriminator is stating a value judgment before giving a policy.
- The claim first: routing to the wrong sub-agent is far worse than failing to route. A failure announces itself and lets you ask a clarifying question. A wrong route does not — the receiving agent has no idea it got the wrong job and will produce a confident, well-formatted, wrong answer that the user will act on. A confident wrong answer costs a hundred times more than I did not catch that.
- Then give a concrete policy with real numbers: if the model's confidence is below 0.6, or the route name is not in the allowed list, fall back to the small-talk agent and stamp the reason with a fallback prefix plus a cause code (low confidence, unknown route, invalid shape). The fallback agent's job is to ask for the one missing detail rather than guess — falling back means handing the uncertainty back to the user.
- The threshold question is the real test, so do not recite a number: it depends on which error is more expensive. In customer support one extra question costs mild annoyance while a misroute can become a wrong refund promise, so stay conservative. For an internal tool the extra question is the bigger cost, so lower it. Then give a method: sweep thresholds over a golden set, plot misroute rate against clarification rate, and pick the knee.
- Name the trap: the confidence number is self-reported and is not a probability. Nine tenths does not mean nine in ten are right. It is a usable ranking signal within one model and one prompt — good as a gate, useless for expected-value math. Real accuracy comes from offline evaluation.
- Expect: does falling back just hide the problem? Not if you record cause codes. Group a week of fallbacks by cause and you can see exactly which intent the routing prompt fails to describe. The fallback stops the bleeding; the cause code is what fixes it.
分析过程 · 先想清楚再作答
- 题眼在「不确定」和「错误」是两件事。多数人只答重试或人工接管,那是把两个问题揉成一个。区分度在于你能不能先给出一条价值判断,再给策略。
- 先立论:**路由到错的子 Agent,比路由失败糟糕得多**。失败你至少知道自己失败了,可以追问一句;错了,接手的子 Agent 完全不知道自己接错了活,会用笃定的语气给出一个格式完整的错误答案,用户不会怀疑,会照着去操作。一个自信的错误答案比一句「我没听清」贵一百倍。
- 再给可执行的策略,数字要具体:模型给的置信度低于 0.6,或者路由名不在合法名单里,一律落到兜底的 smalltalk,并在 routingReason 里打上 fallback 前缀加原因码(低置信度、未知路由、结构非法各一种)。兜底那位的人设是「信息不足先追问一句缺的关键信息,不要猜」——兜底的本质是把不确定性还给用户。
- 阈值怎么定这一问是重点,别背数字:**取决于两类错误哪一类更贵**。客服场景里多问一句只是用户小小的不耐烦,派错可能变成一条错误的退款承诺,所以宁可保守取 0.6;内部工具型 Agent 里多问一句反而更烦人,阈值就该放低。再补一句可落地的定法:拿标准样本集扫一遍,画出不同阈值下的误派率与追问率,选拐点。
- 必须点破的一个坑:**置信度是模型自己报的,它不是概率**。模型说 0.9 不代表有九成对。它只是同一模型、同一提示词下相对可用的排序信号,只能当闸门用,不能拿去算期望值。真正的准确率要靠离线评估去量。
- 可以预期的追问:兜底会不会把问题掩盖掉?答案是不会,前提是你记了原因码——把一周内落进兜底的请求按原因分组,能直接看出分诊提示词缺了哪一类描述。兜底是止血,原因码才是治本的输入。
Key points
- Separate the two: a failed route can ask a clarifying question, a wrong route produces a confident wrong answer, and the second is far costlier
- Policy: confidence below 0.6 or a route outside the allowed list falls back to small talk, stamped with a fallback prefix and a cause code
- Falling back is not picking someone at random — the fallback agent asks for the missing detail instead of guessing
- The threshold depends on which error costs more; sweep it over a golden set and pick the knee between misroutes and clarifications
- Self-reported confidence is not a probability — use it as a gate only, and measure real accuracy offline
- Record cause codes on every fallback; grouping them shows which intent the routing prompt fails to describe
答题要点
- 先分清两件事:路由失败可以追问,路由错误会让子 Agent 自信地给出错误答案,后者贵得多
- 策略:置信度低于 0.6 或路由名不在名单里,一律落兜底的 smalltalk,并在 routingReason 打上 fallback 前缀加原因码
- 兜底不是随便找个人接,而是把不确定性还给用户——兜底那位应当追问缺失的关键信息而不是猜
- 阈值取决于两类错误哪一类更贵:客服场景多问一句便宜、派错很贵,所以保守;定法是拿标准样本集扫阈值找拐点
- 置信度是模型自报的,不是概率,只能当闸门用;真正的准确率要靠离线评估量
- 落兜底时记原因码,按原因分组就能看出分诊提示词缺了哪一类描述
What is a field like routingReason actually worth in production? Is it just logging?routingReason 这类调试信息在生产系统里有什么价值?只是打日志而已吗?
Common in ChinaCommon overseasIntermediate#observability#routing#debuggingHow to reason about it · think before answering
- This looks like a throwaway question but it screens for whether you have ever been on call. Anyone who stops at it helps with debugging has not.
- Start with the fact you cannot design around: the routing decision is made by a model, and models are not reproducible. The same sentence may be judged differently next time, so you cannot re-run to see what it was thinking. The reason must be captured at decision time or it is gone forever — that is what turns this field from a log line into the only audit evidence you have.
- Then give three concrete uses. One, it separates a wrong model judgment from a parsing or fallback problem, provided the prefix carries a cause code. Two, it is raw material for the next prompt revision: group a week of fallbacks by cause and the missing intent descriptions jump out. Three, it feeds offline evaluation — a golden set should score routing accuracy, not just the final answer, and that is only scorable if the decision and its reason were recorded.
- Mention the shape: a structured prefix wrapping a human sentence. The prefix (fallback plus cause, override plus target) is what you aggregate on; the sentence is what you read for one specific case. Making the whole field prose puts you right back in the failure mode this chapter argues against.
- Add the detail people skip: neither a fallback nor a human override should erase the model's original judgment — carry it into the reason. Otherwise nobody can later tell whether the model got it wrong or a human redirected it. Twenty extra characters save an afternoon of archaeology.
- Expect: do these fields create privacy or cost problems? Yes, so record the basis for the decision rather than the user's raw text, cap the length, and reuse the same run identifier as your tracing instead of inventing a parallel one.
分析过程 · 先想清楚再作答
- 这题看着像水题,其实在筛「有没有真的排查过线上问题」。答「方便调试」就结束的人,基本没值过班。
- 先给一条不可回避的事实:**路由决策是模型做的,而模型不可复现**。同一句话下次未必给同样的判断,你没法重跑一遍去看「当时是怎么想的」。所以理由必须在当时就写下来,否则那次判断永远丢了。这一条把 routingReason 从「日志」抬到了「唯一的审计证据」。
- 然后给三个具体用途,每个都要能落地:一是把「模型判错了」和「解析或兜底出错了」分开,前缀写成 fallback 加原因码,一眼就能分辨;二是攒下一版提示词的素材,把一周内落进兜底的请求按原因分组,会看到集中的几类意图缺描述;三是它是离线评估的输入——标准样本集要评的不只是最终回答,还有分诊准不准,而这件事只有当时记了判断和理由才评得了。
- 写法上有个细节值得主动说:**结构化的壳加自然语言的芯**。前缀(fallback 加原因、override 加目标)用来聚合统计,后面那句人话用来看具体这一单。整条都写成自然语言,就退回成本章批判的那种东西了。
- 再补一条容易被忽略的:兜底和人工改派都不要擦掉模型的原判,原样拼进理由里。否则一周后没人说得清这一单是模型判错了还是本来就被人改过——多写二十个字符,省掉一次翻遍代码的排查。
- 可以预期的追问:这些字段会不会带来隐私或成本问题?答案是会,所以理由里只写判断依据不写用户原文,长度设上限(比如 120 字),并且和链路追踪共用同一个 run 标识,别另起一套。
Key points
- The decision comes from a model and is not reproducible, so the reason must be captured at decision time — it is the only audit evidence you get
- Use one: it separates a wrong model judgment from a parsing or fallback failure, via a cause code in the prefix
- Use two: grouping a week of fallbacks by cause tells you exactly what the next routing prompt is missing
- Use three: it feeds offline evaluation, since routing accuracy can only be scored if the decision and reason were recorded
- Shape it as a structured prefix around a human sentence: aggregate on the prefix, read the sentence for one case
- Keep the model's original judgment through fallbacks and overrides; store the basis rather than raw user text, cap the length, and reuse the tracing run id
答题要点
- 路由决策由模型做出且不可复现,理由必须在当时写下来,否则那次判断永远丢了——它是唯一的审计证据
- 用途一:把「模型判错」和「解析或兜底出错」分开,靠 fallback 加原因码一眼分辨
- 用途二:把一周内落进兜底的请求按原因分组,直接得到下一版分诊提示词该补什么
- 用途三:它是离线评估的输入,分诊准确率这个指标只有记了当时的判断与理由才评得了
- 写法是结构化的壳加自然语言的芯:前缀用于聚合统计,人话用于看具体这一单
- 兜底与人工改派都要保留模型原判;理由只写判断依据不写用户原文,长度设上限,并复用链路追踪的 run 标识