Supervisor 动态路由:structured output 路由、override、routingReason
实现一个 Supervisor 节点,用 structured output 动态决定路由到哪个子 Agent,并支持人工 override 与记录路由理由。
今日目标
- 能实现一个用 structured output 输出路由决策的 Supervisor 节点
- 能给路由结果加上 routingReason,方便调试和复盘
- 能实现一个 override 机制,在模型路由错误时人工纠正
昨天那张图有个明显的假:三条边全是写死的,入口之后永远是同一个处理节点。今天让其中一条边由模型在运行时决定走向,并且把「判错了怎么办」一次性想清楚。读完回到页面顶部把三条目标勾掉。
小白版讲解
接警台不办案,只负责判断这是谁的活
打 110 的时候,接你电话的那个人不会去抓小偷,也不会去救火。他只做一件事:听你说完前两句,判断这是治安、交通还是火警,然后把工单转给对应的部门。整个通话可能只有二十秒,但这二十秒决定了后面所有人的忙闲——转错了部门,出警的人到了现场才发现不对,再转一次,时间全耗在路上。
这就是监督者(Supervisor)模式:一个节点专门负责判断「这一单该谁接」,判完就交出去,自己不干活。 沿用我们的电商客服场景,接单的有三位,名字这一周都不会变:order_lookup 查订单状态与物流进度,refund_draft 按规则拟一份退款方案但不执行退款,smalltalk 负责闲聊与兜底。
为什么把「判断」单独设成一个节点,而不是让查订单那位自己看着办?因为判断和执行是两种完全不同的活。判断要看全局、要快、要便宜,通常几十个 token 就够;执行要看细节、要调工具、要遵守一堆规则。混在一起,你既没法给判断单独换个小模型,也没法单独统计「分诊准确率」——而这个指标恰恰是多 Agent 系统里最值得盯的一个。
技术上,Supervisor 靠的是条件边(conditional edge):这条边的终点在编译期是未知的,运行时才由一个选择函数决定。昨天那三条无条件边写死了顺序,今天这条边把「下一步去哪」交给了状态里的一个字段。
import { StateGraph, START, END } from '@langchain/langgraph'
// Supervisor 只写两个字段:交给谁(route)、为什么这么判(routingReason)。它不回答用户的问题
const supervisor = async (state) => decide(lastText(state))
// 选择函数只读状态、不调模型:判断已经在 supervisor 里做完了,这里只负责翻译成节点名
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')
// 第三个参数是「选择函数的返回值 → 节点名」的映射表。写全它:返回一个没登记的名字
// 编译期就报错,而且登记了却没人指向的节点会让 compile() 直接抛 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
# Supervisor 只写两个字段:交给谁(route)、为什么这么判(routing_reason)。它不回答用户的问题
async def supervisor(state: AgentState) -> dict:
return decide(last_text(state))
# 选择函数只读状态、不调模型:判断已经在 supervisor 里做完了,这里只负责翻译成节点名
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")
# 第三个参数是「选择函数的返回值 → 节点名」的映射表,写全它,图才画得出来
.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 没有 LangGraph,这里用 JDK 17+ 的惯用法手写同一张图:
// 节点是一个函数,条件边就是「一次查表」。依赖:JDK 17+,无第三方库
enum Route { ORDER_LOOKUP, REFUND_DRAFT, SMALLTALK }
// EnumMap 的键空间就是枚举本身,配合下面的自检,漏配一个分支起不来,而不是跑到一半才崩
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("路由 " + r + " 没有对应的子 Agent");
}
static AgentState run(AgentState input) {
AgentState routed = supervisor(input); // 只判断交给谁,不回答问题
return AGENTS.get(routed.route()).apply(routed); // 这一次查表就是条件边
}// Swift 也没有 LangGraph。这里用 enum + switch 手写同一张图:
// switch 对 enum 是穷尽的,漏一个分支**编译不过**——这是 Swift 版条件边最大的好处
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) // 只判断交给谁,不回答问题
return try await nextAgent(for: routed.route)(routed)
}四份代码教的是同一件事:把「下一步去哪」从代码里的写死顺序,变成一次运行时查表。 到这里机械原理其实已经讲完了,剩下的全是那个真正难的问题——decide 里面该怎么写。让模型直接说一句「我觉得该找查订单的同事」,然后你去解析这句话,行不行?
别让模型用人话告诉你该走哪条路
先说结论:不行,而且它的失败方式特别恶心——是静默的。
设想最省事的写法:让模型自由回答「这一单该找谁」,你拿正则去它的回复里捞子 Agent 的名字。跑起来会发生什么?模型回一句「我觉得这个可以让查订单的同事看一下」。它判对了,判得非常对。但它说的是人话不是 id,正则匹配不上,于是你的代码落进兜底,日志里只留下一行 route=smalltalk。
你在日志里看不出它其实判对了。 这才是要命的地方。如果模型判错了,你还能从对话里看出来;而现在模型是对的、解析是错的,两者的表现却一模一样。等你发现分诊准确率只有六成、花两天去调路由提示词,最后才明白提示词根本没问题,问题在那三行正则。
自由文本路由一共有三个漏洞,一个比一个隐蔽:
第一,输出会漂移。 今天模型回「订单查询」,明天同一个问题回「查订单」,后天回「先看看这单的物流」。你的正则永远追不上,只能不停加分支。模型服务商悄悄升级一个小版本,你的路由准确率就掉一截,而你什么都没改。
第二,没有置信度。 自由文本里没有「我有多大把握」这个信息。模型对「上次那个事怎么样了」这种模糊问句其实心里没底,但它的语气和确定的时候一模一样。你收到的只是一句话,没法区分「它很确定」和「它在猜」。
第三,拼错的路由名要到运行时才炸。 模型自己造一个叫 complaint_handler 的部门名,你的图里根本没有这个节点。用自由文本你压根没有一道「合法名单」的闸门,这个名字会一路流到图里,跳到一个不存在的节点。
结构化输出(structured output)把这三件事一次解决:你交给模型一份 schema,规定输出必须是一个对象,里面有 route(只能是三个枚举值之一)、confidence(零到一的小数)、routingReason(一句给人看的话)。模型的解码过程被这份 schema 约束,回来的东西直接就是可校验的结构。
import { z } from 'zod'
// 一份声明干两件事:约束请求(模型照着它生成)+ 校验响应(我们照着它解析),两边不会漂移
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) },
},
}
// 回来的仍然只是一段文本:模型只承诺给一段 JSON,不承诺给对的 JSON,所以这一步不能省
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
# pydantic 的模型同样一份声明两用:model_json_schema() 发给模型,model_validate_json() 校验回来的
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")// 依赖:Jackson(com.fasterxml.jackson.databind)。Java 这边的闸门是枚举本身:
// 模型给了 complaint_handler,readValue 直接抛 InvalidFormatException,轮不到业务代码去判
// 还是上一段那个 Route,加上 Jackson 的注解就能直接参与解码
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) {
// 名字不在枚举里、字段缺失、根本不是 JSON,现在都走这一条;下一节把三种原因分开
return fallback("unknown-route");
}
}// Swift 的闸门是 RawRepresentable 的枚举:rawValue 不在名单里,JSONDecoder 直接判解码失败,
// 和 zod 的 enum、Jackson 的 enum 是同一道闸门,只是各自语言里的写法不同。
// 还是上一段那个 Route,补一个 Codable 就能直接参与解码
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? 在这里是合适的:解码失败的处理方式只有一种——落 fallback,不需要区分是哪个字段坏了
try? JSONDecoder().decode(RouteDecision.self, from: data)
}zod 生成的 JSON Schema 就是发给模型的那份约束,它长这样,enum 那一行是整段里最值钱的东西:
{
"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
}工程上还有一笔账:路由这一步的输入通常只有系统提示词加用户最后一两句,输出是三个字段,按本课的价目表(openai/gpt-4o-mini,输入每百万 token 零点一五美元、输出每百万 token 零点六美元),一次路由大约三百 token 进、五十 token 出,一次不到万分之一美元,比一次完整回答便宜一个数量级。这也是为什么可以放心给 Supervisor 单独配一个更便宜的小模型——它只做选择题。
routingReason:出了事你得答得上来「当时为什么这么判」
110 的工单上除了「转交通队」,还有一栏写着「为什么判成交通」。这一栏不是给接警员自己看的,是给三天后复盘的人看的。
routingReason 就是那一栏,而且它是这整套动态路由里唯一能被事后审计的部分。想想看:路由决策是模型做的,同一个模型对同一句话下次未必给同样的答案,你没法重跑一遍来「再看看当时是怎么想的」。除非当时就把理由记下来,否则那次判断永远丢了。
所以 routingReason 不是装饰性的日志字段,它有三个具体用途:
第一,把「判错了」和「解析错了」分开。 有了理由你一眼能看出模型是判错还是被兜底了。上一节那个例子,日志里如果是「fallback:unknown-route(complaint_handler)」,你立刻知道模型想去一个不存在的部门;如果只有 route=smalltalk,你要查两天。
第二,攒出下一版提示词的素材。 把一周内所有落进兜底的请求按理由分组,你会看到集中的几类:全是问「我这单能不能改地址」的,说明分诊提示词里缺了这一类的描述。这比拍脑袋改提示词有效得多。
第三,它是 D21 评估的输入。 标准样本集(golden set)要评的不只是「最终回答好不好」,还有「分诊准不准」。而分诊准不准这件事,只有把当时的判断和理由都记下来才评得了。
一条合格的路由日志长这样,三行分别是判对、兜底、被人工改派:
2026-09-16T10:02:11Z run=r-8831 route=order_lookup conf=0.88 reason="用户在问某一单的物流进度,属于订单查询"
2026-09-16T10:04:37Z run=r-8832 route=smalltalk conf=0.42 reason="fallback:low-confidence(0.42)|模型原判 order_lookup:用户提到那个事但没说是哪一单"
2026-09-16T10:07:52Z run=r-8833 route=refund_draft conf=1.00 reason="override:refund_draft|人工指定,模型原判 order_lookup(0.88):用户在问物流进度"注意第二、三行都保留了模型的原判。这是本节最该记住的一条写法:兜底和改派都不要把模型原来的判断擦掉,否则一周之后没人说得清「这一单是模型判错了,还是本来就被人改过」。多写二十个字符,省掉一次翻遍代码找答案的排查。
override:值班领导可以改派,但改派要留痕
接警台判完之后,值班领导还能改派。这个入口在真实系统里必须留着,因为模型一定会有判不准的时候,而你不可能每次都靠改提示词、重新发版来救一单具体的投诉。
override 的实现只有一句话:它的优先级高于模型判断,但不能覆盖掉模型判断的痕迹。 具体到代码,就是拿到模型那一侧的结论之后,如果这次执行带了 override,就用 override 的目标覆盖 route,同时把模型原来判的是什么、置信度多少,原样拼进 routingReason。上一节那三行日志的第三行就是这么来的。
有两个细节容易漏。第一,override 也要过枚举校验——人一样会拼错,一个拼错的 override 会让图跳到不存在的节点,比模型判错更难查。第二,override 不要写进图状态,走运行时配置传进去。理由是它描述的是「这一次执行被人干预了」,不是 Agent 自己的状态;混进状态里,D18 存检查点时会把这个一次性的人工决定当成历史一起恢复,下次从检查点重放就莫名其妙又被改派了一次。
那什么时候该 override?给三条判据,其余情况都别用:
- 线上正在烧的那一单。 用户已经投诉了,等不了改提示词发版,先手动把这一单派对。
- 灰度一个新的子 Agent。 新加了一个专门处理改地址的子 Agent,先用 override 把少量流量强制打过去看效果,再决定要不要写进分诊提示词。
- 复现一个 bug。 「同样这句话,如果当时派给了退款那位会怎么样」——override 是最便宜的复现手段。
反过来,override 不能拿来当补丁长期用。如果你发现某一类请求天天要人工改派,那是分诊提示词或者子 Agent 划分本身有问题,改那里才对。生产上值得加一条告警:override 的比例超过百分之一,就该有人去看看分诊哪里坏了。这条指标本身也是面试里的加分点——它说明你把人工干预当成了信号,而不是当成了解法。
判不准的时候,宁可少答一句,也不要自信地答错
最后一块拼图是兜底。先说清楚为什么它这么重要:路由到错的子 Agent,比路由失败要糟糕得多。
路由失败你至少知道自己失败了,可以追问一句「您是想查订单还是想退款?」。而路由错了,接手的那位子 Agent 完全不知道自己接错了活——退款那位会认真地按退款规则给一个「您这单符合七天无理由」的答复,语气笃定、格式完整、看起来毫无破绽,而用户问的其实是发票。用户不会怀疑,他会照着这个错误答案去操作。一个自信的错误答案,比一句「我没听清」贵一百倍。
所以规则很硬:模型给出的 confidence 低于 0.6,或者路由名不在三个子 Agent 的名单里,一律落到 smalltalk,并且在 routingReason 里打上 fallback: 加原因的标记。smalltalk 那位的人设里写着「信息不足时先追问一句缺的关键信息,不要猜」——兜底的本质不是随便找个人接,是把不确定性还给用户,让他补一句话。
const CONFIDENCE_FLOOR = 0.6 // 判不准就宁可多问一句:自信的错答案比一句追问贵得多
export function normalizeDecision(raw) {
const parsed = RouteDecision.safeParse(raw)
if (!parsed.success) {
// 把模型想去的那个名字单独摘出来:事后要能一眼看出「它想去哪,但那里没人」
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) {
// 兜底也要留下模型原判,否则复盘时分不清是模型判错了还是它自己没把握
return {
route: 'smalltalk',
confidence: d.confidence,
routingReason: `fallback:low-confidence(${d.confidence})|模型原判 ${d.route}:${d.routingReason}`,
}
}
return d
}CONFIDENCE_FLOOR = 0.6 # 判不准就宁可多问一句:自信的错答案比一句追问贵得多
def normalize_decision(raw: dict) -> Routed:
try:
d = RouteDecision.model_validate(raw)
except ValidationError:
# 把模型想去的那个名字单独摘出来:事后要能一眼看出「它想去哪,但那里没人」
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:
# 兜底也要留下模型原判,否则复盘时分不清是模型判错了还是它自己没把握
return Routed(
"smalltalk",
d.confidence,
f"fallback:low-confidence({d.confidence})|模型原判 {d.route}:{d.routing_reason}",
)
return Routed(d.route, d.confidence, d.routing_reason)// 依赖:Jackson。Java 把两道闸门合在一处:解析失败走 catch,置信度不够走 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) {
// 枚举挡下的那个原值就在异常对象里,取出来写进理由:事后要能一眼看出「它想去哪,但那里没人」
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) {
// 兜底也要留下模型原判,否则复盘时分不清是模型判错了还是它自己没把握
return new Routed(Route.SMALLTALK, d.confidence(),
"fallback:low-confidence(%s)|模型原判 %s:%s".formatted(d.confidence(), d.route(), d.routingReason()));
}
return new Routed(d.route(), d.confidence(), d.routingReason());
}let confidenceFloor = 0.6 // 判不准就宁可多问一句:自信的错答案比一句追问贵得多
func normalize(_ data: Data) -> Routed {
do {
let d = try JSONDecoder().decode(RouteDecision.self, from: data)
// guard 是 Swift 处理「条件不满足就早退」的标准写法,不要写成层层嵌套的 if
guard d.confidence >= confidenceFloor else {
// 兜底也要留下模型原判,否则复盘时分不清是模型判错了还是它自己没把握
let reason = "fallback:low-confidence(\(d.confidence))|模型原判 \(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" {
// rawValue 不在枚举里时 JSONDecoder 抛的正是 dataCorrupted,codingPath 指着 route 那一层,
// 所以「模型想去哪」这条信息在 Swift 里也没丢
return Routed(route: .smalltalk, confidence: 0, routingReason: "fallback:unknown-route")
} catch {
return Routed(route: .smalltalk, confidence: 0, routingReason: "fallback:invalid-shape")
}
}阈值取 0.6 是个工程判断,不是真理:调高会让更多请求落进兜底、用户被多问一句,调低会让更多模糊请求被硬派出去。它该怎么定,取决于两类错误哪一类更贵。 客服场景里,多问一句的代价是用户小小的不耐烦,派错的代价可能是一条错误的退款承诺,所以宁可保守。换成一个内部工具型 Agent,多问一句的代价反而更高,阈值就可以放低。面试时被问到「阈值怎么定」,能把话说到这一层,就说明你不是在背数字。
还有一件事要提前打预防针:置信度是模型自己报的,它不是概率。模型说 0.9 不代表它有九成对的时候。它只是一个相对可用的排序信号——同一个模型、同一份提示词下,0.9 的那批确实比 0.4 的那批准。所以别拿它去算什么期望值,只拿它当闸门用。真正的准确率要靠 D21 的标准样本集去量。
源码导读
动手实验
今天没有基础设施依赖,所以本实验没有 docker-compose.yml:Supervisor、三个子 Agent、条件边全在进程内。唯一的网络出口是模型调用,MOCK=1 下返回随输入变化的路由决策——问物流给零点八八的把握,说「上次那个事」给零点四二,说「我要投诉」会自造一个不存在的部门名,四种现象都是线上真实发生过的。src/shared/state.ts 与 D15 逐字相同,今天一个字段都没加,只是第一次真的往 route 和 routingReason 里写值。四个练习按顺序做,前一个没做完后面的自检也绿不了。
- 先原样跑一次
MOCK=1 SELFTEST=1 pnpm start,五条叉的文案就是你的待办清单;重点看第 1 项——自由文本路由是怎么把判对的结果静默丢掉的。 - 练习 1,把 Supervisor 从「自由文本加正则」换成
routeStructured加 zod schema,第 1 项的三条路由开始各自命中。 - 练习 2,在
normalizeDecision里加上枚举校验与 0.6 的置信度闸门,第 2、3 项的兜底理由带上fallback:前缀。 - 练习 3,让
resolveRoute认识 override,并把模型原判拼进理由里,第 4 项的那一单被强制改派到退款方案。 - 练习 4,把
selectRoute改成真的读状态里的route,第 5 项从「六次全跑 order_lookup」变成「跑的正是判的那个」。
面试题
今天 4 道题在下方题库区,侧重路由设计、结构化输出与意图 fallback,最后一道专门考「调试信息在生产里值多少钱」——这题答得好不好,直接暴露你有没有真的排查过线上问题。展开后先看「分析过程」再看要点,照着推导练,比背要点管用。标注「国内高频 / 海外高频」方便按目标市场取舍。
检查清单与明日预告
- 能实现一个用 structured output 输出路由决策的 Supervisor 节点
- 能给路由结果加上 routingReason,方便调试和复盘
- 能实现一个 override 机制,在模型路由错误时人工纠正
- 能说清自由文本路由的三个漏洞:会漂移、没有置信度、拼错的名字要到运行时才炸
- 能解释为什么「路由到错的子 Agent」比「路由失败」更糟,以及 0.6 这个阈值该怎么定
- 实验的 5 条验收标准全部通过(五项自检全部通过)
- 4 道面试题不看要点也能答出至少 3 道
明天(D17)讲 Planner-Executor-Critic 与共享工作区。为什么是它接在今天后面?因为 Supervisor 只解决了「交给谁」——它一次只派一个人,派出去这件事就算完了。可现实里的一单常常是「先查订单、再核对退款规则、最后还得有人复核这份方案能不能发出去」:一件事要拆成几件、几件可以并行做、做完还要有人验收。明天会把任务拆分、并行执行和评审回路拼成一条完整链路,顺便回答一个今天绕开的问题:多个节点同时往同一个状态字段里写东西,谁覆盖谁。
面试题库
Supervisor 模式里的路由决策一般怎么实现?请说说这个节点该做什么、不该做什么。How is the routing decision usually implemented in a supervisor pattern? What should that node do, and what should it not do?
国内高频海外高频基础#multi-agent#routing#langgraph分析过程 · 先想清楚再作答
- 这是一道送分题,但送分题最容易答成「让一个 Agent 决定下一步找谁」这种复述题面的话。区分度在后半句:你能不能说清这个节点的职责边界。
- 先给机械原理:Supervisor 是图里的一个普通节点,它读状态、调一次模型、只写两个字段——交给谁(route)和为什么这么判(routingReason);真正的分叉发生在它后面那条条件边上,边上挂一个选择函数,把 route 翻译成下一个节点名。
- 再划边界,这是拿分的地方:Supervisor 不回答用户的问题、不调业务工具、不产生副作用。它只做选择题,所以可以配一个更便宜的小模型,输入通常只有系统提示词加最后一两句话。
- 还有一条边界更容易被忽略:**选择函数里不要再调模型**。判断已经在 Supervisor 节点里做完并落进状态了,选择函数只做翻译。把模型调用塞进选择函数,同一份状态每次可能跳到不同的节点,图就不可复现,后面做检查点重放和评估都会失真。
- 最后补一句「一次只派一个人」:Supervisor 解决的是「交给谁」,不解决「一件事要拆成几件、还得有人验收」。后者是 Planner-Executor-Critic 的活。能主动划出这条线,面试官会认为你见过真实系统的边界。
- 可以预期的追问:那三个子 Agent 的名单从哪来、加一个新的要改几处?答案是名单应该是单一真相来源——枚举定义、schema、条件边的映射表都从它生成,加一个子 Agent 只改一处,其余地方编译期报错提醒你。
How 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 judgement 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.
答题要点
- Supervisor 是图里的一个普通节点:读状态、调一次模型、只写 route 与 routingReason 两个字段
- 真正的分叉在它后面的条件边上:选择函数把 route 翻译成下一个节点名,映射表要写全
- 职责边界:不回答用户、不调业务工具、不产生副作用,因此可以单独配一个更便宜的小模型
- 选择函数里不能调模型,否则同一份状态每次跳的节点不同,图不可复现,检查点重放与评估都会失真
- Supervisor 一次只派一个人,只解决「交给谁」;拆任务与验收是 Planner-Executor-Critic 的职责
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
为什么要让模型输出 structured output 而不是自然语言来做路由?自然语言到底差在哪?Why use structured output rather than natural language for routing? What exactly goes wrong with free text?
国内高频海外高频进阶#structured-output#routing#reliability分析过程 · 先想清楚再作答
- 这题最容易答成「结构化更规范、更好解析」——这是形容词,不是理由。面试官想听的是一个具体的失败场景,最好是你真的调过的那种。
- 把最锋利的一刀先亮出来:**自然语言路由的失败是静默的**。模型回「我觉得这个可以让查订单的同事看一下」,它其实判对了,但说的是人话不是 id,正则匹配不上就落进兜底,日志里只留下一个 smalltalk。模型是对的、解析是错的,而它和「模型判错了」在日志里长得一模一样。你会去调提示词,调两天才发现问题在那三行正则。
- 然后给三个漏洞,一条一条对上结构化输出解决了什么:输出会漂移(今天回「订单查询」明天回「查订单」,正则永远追不上,模型小版本升级你就掉准确率);没有置信度(自然语言里没有「我有多大把握」这个信息,你没法区分它很确定还是在猜);拼错或自造的路由名要到运行时才炸(枚举是一道编译期就存在的闸门)。
- 接着说清机制,别停在「用 zod 更规范」:把 schema 发进请求(response_format 里的 json_schema),模型的解码过程被枚举约束;回来之后**用同一份声明再校验一遍**。一份声明两用,请求与校验不会漂移。
- 关键的反直觉点,答到这里就拉开差距了:**结构化输出不等于不用校验**。不是所有网关、所有模型都严格执行 schema,降级到备用模型时更说不准。所以解析函数的返回类型应该是「一段待校验的东西」,而不是「已经是 RouteDecision」。
- 可以预期的追问:那不支持 json_schema 的模型怎么办?答案是退回「few-shot 加严格提示词加自己校验」,闸门仍然在你的枚举校验那一步——真正兜底的从来不是模型的自觉,是你的解析层。
How 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.
答题要点
- 自然语言路由的失败是静默的:模型判对了但说的是人话,正则匹配不上就落兜底,和判错在日志里完全一样
- 三个漏洞:措辞会漂移(正则追不上)、没有置信度(分不清确定与猜)、自造的路由名要到运行时才炸
- 机制是一份声明两用:schema 随请求发出去约束解码,回来后用同一份声明校验,请求与校验不会漂移
- 枚举是编译期就存在的闸门,把「拼错的路由名」从线上事故降级成一次解析失败
- 结构化输出不等于不用校验:网关和降级模型未必严格执行 schema,解析函数的返回类型应该是「待校验」而不是「已经是」
Key points
- Free-text routing fails silently: a correct judgement in prose misses your regex and falls through, looking identical to a wrong judgement 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
路由不确定或者路由错误时,系统应该怎么兜底?阈值该怎么定?How should the system handle an uncertain or wrong routing decision, and how do you pick the threshold?
国内高频海外高频深入#routing#fallback#reliability分析过程 · 先想清楚再作答
- 题眼在「不确定」和「错误」是两件事。多数人只答重试或人工接管,那是把两个问题揉成一个。区分度在于你能不能先给出一条价值判断,再给策略。
- 先立论:**路由到错的子 Agent,比路由失败糟糕得多**。失败你至少知道自己失败了,可以追问一句;错了,接手的子 Agent 完全不知道自己接错了活,会用笃定的语气给出一个格式完整的错误答案,用户不会怀疑,会照着去操作。一个自信的错误答案比一句「我没听清」贵一百倍。
- 再给可执行的策略,数字要具体:模型给的置信度低于 0.6,或者路由名不在合法名单里,一律落到兜底的 smalltalk,并在 routingReason 里打上 fallback 前缀加原因码(低置信度、未知路由、结构非法各一种)。兜底那位的人设是「信息不足先追问一句缺的关键信息,不要猜」——兜底的本质是把不确定性还给用户。
- 阈值怎么定这一问是重点,别背数字:**取决于两类错误哪一类更贵**。客服场景里多问一句只是用户小小的不耐烦,派错可能变成一条错误的退款承诺,所以宁可保守取 0.6;内部工具型 Agent 里多问一句反而更烦人,阈值就该放低。再补一句可落地的定法:拿标准样本集扫一遍,画出不同阈值下的误派率与追问率,选拐点。
- 必须点破的一个坑:**置信度是模型自己报的,它不是概率**。模型说 0.9 不代表有九成对。它只是同一模型、同一提示词下相对可用的排序信号,只能当闸门用,不能拿去算期望值。真正的准确率要靠离线评估去量。
- 可以预期的追问:兜底会不会把问题掩盖掉?答案是不会,前提是你记了原因码——把一周内落进兜底的请求按原因分组,能直接看出分诊提示词缺了哪一类描述。兜底是止血,原因码才是治本的输入。
How 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 judgement 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 自信地给出错误答案,后者贵得多
- 策略:置信度低于 0.6 或路由名不在名单里,一律落兜底的 smalltalk,并在 routingReason 打上 fallback 前缀加原因码
- 兜底不是随便找个人接,而是把不确定性还给用户——兜底那位应当追问缺失的关键信息而不是猜
- 阈值取决于两类错误哪一类更贵:客服场景多问一句便宜、派错很贵,所以保守;定法是拿标准样本集扫阈值找拐点
- 置信度是模型自报的,不是概率,只能当闸门用;真正的准确率要靠离线评估量
- 落兜底时记原因码,按原因分组就能看出分诊提示词缺了哪一类描述
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
routingReason 这类调试信息在生产系统里有什么价值?只是打日志而已吗?What is a field like routingReason actually worth in production? Is it just logging?
国内高频海外高频进阶#observability#routing#debugging分析过程 · 先想清楚再作答
- 这题看着像水题,其实在筛「有没有真的排查过线上问题」。答「方便调试」就结束的人,基本没值过班。
- 先给一条不可回避的事实:**路由决策是模型做的,而模型不可复现**。同一句话下次未必给同样的判断,你没法重跑一遍去看「当时是怎么想的」。所以理由必须在当时就写下来,否则那次判断永远丢了。这一条把 routingReason 从「日志」抬到了「唯一的审计证据」。
- 然后给三个具体用途,每个都要能落地:一是把「模型判错了」和「解析或兜底出错了」分开,前缀写成 fallback 加原因码,一眼就能分辨;二是攒下一版提示词的素材,把一周内落进兜底的请求按原因分组,会看到集中的几类意图缺描述;三是它是离线评估的输入——标准样本集要评的不只是最终回答,还有分诊准不准,而这件事只有当时记了判断和理由才评得了。
- 写法上有个细节值得主动说:**结构化的壳加自然语言的芯**。前缀(fallback 加原因、override 加目标)用来聚合统计,后面那句人话用来看具体这一单。整条都写成自然语言,就退回成本章批判的那种东西了。
- 再补一条容易被忽略的:兜底和人工改派都不要擦掉模型的原判,原样拼进理由里。否则一周后没人说得清这一单是模型判错了还是本来就被人改过——多写二十个字符,省掉一次翻遍代码的排查。
- 可以预期的追问:这些字段会不会带来隐私或成本问题?答案是会,所以理由里只写判断依据不写用户原文,长度设上限(比如 120 字),并且和链路追踪共用同一个 run 标识,别另起一套。
How 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 judgement 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 judgement — 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.
答题要点
- 路由决策由模型做出且不可复现,理由必须在当时写下来,否则那次判断永远丢了——它是唯一的审计证据
- 用途一:把「模型判错」和「解析或兜底出错」分开,靠 fallback 加原因码一眼分辨
- 用途二:把一周内落进兜底的请求按原因分组,直接得到下一版分诊提示词该补什么
- 用途三:它是离线评估的输入,分诊准确率这个指标只有记了当时的判断与理由才评得了
- 写法是结构化的壳加自然语言的芯:前缀用于聚合统计,人话用于看具体这一单
- 兜底与人工改派都要保留模型原判;理由只写判断依据不写用户原文,长度设上限,并复用链路追踪的 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 judgement 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 judgement through fallbacks and overrides; store the basis rather than raw user text, cap the length, and reuse the tracing run id
评论
登录后即可参与讨论
还没有评论,来说第一句。