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.
今日目标
- 能构建一个小规模 golden set,覆盖典型输入和期望输出
- 能实现一个 LLM-as-judge 评分脚本,对输出打分并给出理由
- 能接入 tracing,输出一份包含失败率和成本的可观测面板
昨天结尾那句话是今天的起点:这套系统会分诊、会拆任务并行做、会自我评审、会主动关怀而且知道克制,但它到底做得好不好,你手上只有「我试了几条感觉还行」。今天把这句话换成数字。读完回到页面顶部把三条目标勾掉。
小白版讲解
质检不能靠老师傅捏一把
产线刚开的时候,质检往往就是一位老师傅:拿起成品捏一捏、听一听,说这批可以。产量小时很好用,因为他确实懂;问题是这套办法没有记录、不可复现、换个人就换个标准——等客户投诉「上个月的货比这个月好」,没人能回答这话成不成立。
成熟的产线一定会长出三样东西,今天要给 Agent 装的正是这三样:标准样品(一小批批次固定的样件,只有同一批量出来的数才能互相比较)对应 golden set;自动质检仪(快、便宜、能全量跑,但仪器会漂移,得拿标准样品校准)对应 LLM-as-judge;流转卡(记着每件产品经过哪几道工序、谁做的、花了多久)对应链路追踪。
为什么普通后端靠单元测试就够,Agent 不够?因为同样的输入,Agent 不保证给同样的输出。传统测试的断言是「等于」,而 Agent 的产出没有唯一正确答案,只有「够不够好」。这一条差别连锁出三个后果。
第一,跑没跑通判断不了质量。 D17 那条评审回路跑通只意味着流程没抛错,产出有没有写明退款结论它一个字都没说。第二,改动的影响是弥散的。 改一个字的提示词可能只影响一类请求,人肉抽查五条恰好都不属于那一类,你会得出「没影响」然后上线。第三,多 Agent 又难一层:一次请求要走路由、拆分、并行执行、评审、汇总五道工序,任何一道歪了都表现成「最后那段话不太对」,不分开量你连该改哪块都不知道。
所以第一句结论是:评估不是测试,评估是给一个随机系统建立一条可比较的基线。 它的产物不是「通过」或「不通过」,而是一个能和上次比的数字。既然要比,样件就得固定——那么该怎么挑,挑多少?
标准样品:15 条精挑的,比 500 条随手抓的有用
先给结论:golden set 的价值不在数量,在稳定性。 判据只有一条——能不能在每次改完提示词后原样重跑,给出和上次可比的数字。500 条从线上日志随机捞的对话做不到(订单早已发货、优惠早已过期,结果每天在变),15 条依赖全部可控的样件做得到。
怎么挑?三层覆盖,缺一层等于没测:
第一层,每条路由都要有人走。 D16 那三个子 Agent 各至少两三条——缺一条,路由提示词改坏了你也发现不了。
第二层,每种失败模式各来一条。 最容易跳过也最值钱:置信度不足落兜底、路由名不在名单里落兜底(D16)、撞上工具预算或打满评审上限而降级、下游工具挂掉(D17)。这些样件量的不是「答得好不好」,是「坏得对不对」——坏得不对比答得不好危险得多。
第三层,历史上真出过事故的那几条。 每修一个线上问题就把触发它的那句话补进去,永远留着——这一层随时间自然增长,也最不该被删。
每条样件除了输入还要写两样期望:走哪条路由(含落哪种兜底),以及必备信息清单——回复里必须出现的那几件事实。清单是关键,它把「答得好吗」换成了「这几件事写没写」。实验里的样本集长这样:
{ "id": "g03", "input": "SO20260901 什么状态、物流到哪了,另外这单我要退款",
"expectRoute": "refund_draft",
"checklist": ["当前阶段", "预计送达", "退款结论"],
"tags": ["fanout", "review-loop"] }
{ "id": "g08", "input": "上次那个事后来怎么样了",
"expectRoute": "smalltalk", "expectFallback": "fallback:low-confidence",
"checklist": ["请补充"],
"tags": ["fallback", "incident"] }维护规矩只有一条,但它是硬的:只增不改。 改一条期望,之前所有分数全部作废——就像标准样品换了批次,历史数据再没法比。确实写错了就标注作废、另加一条,不要就地改。
自动质检仪本身要被校准
清单有了,谁来打分?15 条还能人工看,可改一次提示词就要重看一遍,一天改五次就是 75 条。所以用模型打分,也就是 LLM-as-judge:把「必备信息清单 + 这次的产出」交给模型,输出 1 到 5 分加一句理由,低于 3 分算失败。
调用只有一行,可信度全在这一行之外——下面三种失效模式讲不透,整套评估就是自欺欺人。
第一种,同源偏差。 judge 和被评估的 Agent 同模型时会偏向认可自己的输出——它对「什么算好答案」的偏好是一致的。D17 在 Critic 那里点过一次,今天推广:凡是「模型评模型」的位置都有这个问题。实验里量得很清楚:同一批被改坏的产出,同源 judge 给 14/15、异源只给 12/15,被多放过去的正是最该抓的两条边缘产出。
第二种,长度偏好。 judge 倾向给篇幅大的答案更高分。实验里把一条 33 字的正确回复灌上 141 字无关客套话,凭印象打分那套提示词就从 2 分涨到 4 分——内容一个字没变。
第三种,评分提示词漂移。 同一份产出,两套评分提示词一套给 2 分、一套给 5 分。所以有条硬纪律:分数只在同一套 judge 提示词内部可比。
解药:
- 固定 judge 提示词并版本化:每条评分记录带上 rubric 版本与 judge 模型,那是它的坐标。面板发现混了两套口径应当直接拒绝聚合,而不是算出一个没有含义的平均分。
- 用不同的模型当 judge,而且这该是默认值,不靠调用方每次记得传参。
- 留一小批人工标注做校准集:每次改评分提示词都拿它对一遍,比的是结论(过或不过)而不是分数差——差 1 分无所谓,结论翻了就是事故。实验里 5 条标注上异源一致 5/5、同源只有 3/5。
还有一条更根本的,上一节其实已经埋好了:用可核对的清单代替主观印象。它同时解掉长度偏好——照清单逐条数,灌水加不了分。那条灌水的回复在清单口径下前后都是 5 分,纹丝不动。
export const PASS_SCORE = 3 // 低于 3 分算失败
export const RUBRICS = {
// v1 是几乎所有人写的第一版:没有可核对的东西,只能凭「读起来怎么样」
v1: '给这段回复打 1 到 5 分,看它是否专业、完整、让用户满意。',
// v2 把主观判断换成客观核对,长度偏好在这一步就被挡掉了
v2: '逐条核对必备信息清单:全部命中 5 分,一条都没有 1 分。只看清单,不看文采与篇幅。',
}
const buildPrompt = (o) =>
[
`RUBRIC: ${o.rubricVersion}`, // 这三行不是给模型看的装饰,
`JUDGE_MODEL: ${o.judgeModel}`, // 是这条评分记录的坐标:
`TARGET_MODEL: ${o.targetModel}`, // 没有它们,一批分数说不清是不是同一把尺子量的
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 {
// 解析不出来一律算最低分:判不出好坏时宁可当它没通过,也不要静默放行
score: Number.isFinite(score) ? score : 1,
reason: raw.match(/REASON:\s*(.*)/)?.[1] ?? '',
rubricVersion: o.rubricVersion, // 口径必须跟着分数一起落库
judgeModel: o.judgeModel,
}
}import re
from dataclasses import dataclass
PASS_SCORE = 3 # 低于 3 分算失败
RUBRICS = {
# v1 是几乎所有人写的第一版:没有可核对的东西,只能凭「读起来怎么样」
"v1": "给这段回复打 1 到 5 分,看它是否专业、完整、让用户满意。",
# v2 把主观判断换成客观核对,长度偏好在这一步就被挡掉了
"v2": "逐条核对必备信息清单:全部命中 5 分,一条都没有 1 分。只看清单,不看文采与篇幅。",
}
@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 # 口径必须跟着分数一起落库,
judge_model: str # 否则两把尺子量出来的数会被平均到一起
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)
# 解析不出来一律算最低分:判不出好坏时宁可当它没通过,也不要静默放行
return Verdict(
int(score.group(1)) if score else 1,
reason.group(1) if reason else "",
r.rubric,
r.judge_model,
)// 依赖:JDK 17+ 的 record、文本块与 java.util.regex,无第三方库
static final int PASS_SCORE = 3; // 低于 3 分算失败
enum Rubric { V1, V2 }
record JudgeRequest(String output, List<String> checklist, Rubric rubric,
String judgeModel, String targetModel) {}
/** 口径跟着分数一起落库,否则两把尺子量出来的数会被平均到一起 */
record Verdict(int score, String reason, Rubric rubric, String judgeModel) {}
static final Map<Rubric, String> RUBRICS = Map.of(
// V1 是几乎所有人写的第一版:没有可核对的东西,只能凭「读起来怎么样」
Rubric.V1, "给这段回复打 1 到 5 分,看它是否专业、完整、让用户满意。",
// V2 把主观判断换成客观核对,长度偏好在这一步就被挡掉了
Rubric.V2, "逐条核对必备信息清单:全部命中 5 分,一条都没有 1 分。只看清单,不看文采与篇幅。");
// 文本块省掉手拼换行;前三行不是装饰,是这条评分记录的坐标
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);
// 解析不出来一律算最低分:判不出好坏时宁可当它没通过,也不要静默放行
return new Verdict(score.find() ? Integer.parseInt(score.group(1)) : 1,
reason.find() ? reason.group(1) : "", r.rubric(), r.judgeModel());
}let passScore = 3 // 低于 3 分算失败
enum Rubric: String { case v1, v2 }
struct JudgeRequest {
let output: String
let checklist: [String]
let rubric: Rubric
let judgeModel: String
let targetModel: String
}
/// 口径跟着分数一起落库,否则两把尺子量出来的数会被平均到一起
struct Verdict {
let score: Int
let reason: String
let rubric: Rubric
let judgeModel: String
}
let rubrics: [Rubric: String] = [
// v1 是几乎所有人写的第一版:没有可核对的东西,只能凭「读起来怎么样」
.v1: "给这段回复打 1 到 5 分,看它是否专业、完整、让用户满意。",
// v2 把主观判断换成客观核对,长度偏好在这一步就被挡掉了
.v2: "逐条核对必备信息清单:全部命中 5 分,一条都没有 1 分。只看清单,不看文采与篇幅。",
]
// 多行字符串字面量里直接插值,前三行是这条评分记录的坐标,不是装饰
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)
// Swift 5.7 起的正则字面量:捕获组的类型编译期就定好了,不用再手工下标取
let score = raw.firstMatch(of: /SCORE:\s*([1-5])/).flatMap { Int($0.1) }
let reason = raw.firstMatch(of: /REASON:\s*(.*)/).map { String($0.1) }
// 解析不出来一律算最低分:判不出好坏时宁可当它没通过,也不要静默放行
return Verdict(score: score ?? 1, reason: reason ?? "",
rubric: r.rubric, judgeModel: r.judgeModel)
}接上之后最值钱的一次实验是故意改坏一条提示词:把执行者提示词里「拿到评审意见要补一句结论」删掉,其余一字不动,通过率立刻从 15/15 掉到 12/15,并点名 g04、g05、g14 各缺哪几件事实。这就是评估体系存在的全部理由:它把「好像变差了」变成「掉了 3 条,是这 3 条,缺的是退款结论」。
流转卡:一次请求在多 Agent 里长成一棵树
分数告诉你结果好不好,不告诉你为什么。单 Agent 的一次调用是一条线:一次模型调用、几次工具、一段回复,按时间排开就看完。今天这套是一棵树:监督者路由到退款,规划者拆出三件活,三个执行者并行做(受并发上限约束实际是两批),评审者打回一件,重跑,汇总。没有这张卡,线上出问题你连「它当时走了哪条路」都答不上来——正好回指 D16 那个 routingReason:路由是模型做的决策,同一句话下次未必一样,当时不记下来那次判断就永远丢了。
一条 span 的字段少得出奇:id、父指针、名字、起止时刻,再加几个属性。父指针是全部关键——有它才是树,没它只是平铺列表:你知道发生过什么,却不知道谁在谁里面,也看不出哪两个执行者并行。实验跑出来的树长这样,‖ 是并行标记:
└─ request 58.3ms case=g03
├─ supervisor 4.7ms route=refund_draft routingReason=用户明确提出退款诉求
│ └─ 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 ‖并行 task=t-2-shipping outcome=done
├─ executor 14.4ms ‖并行 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=0这十来行直接回答四类问题:走了哪条路、拆成几件、并行真生效了吗(带 ‖ 的那两行同时证明并发上限在起作用)、为什么慢(那次打回多了一个执行者加一个评审者)。
父子关系怎么传?不要在每个函数上加一个 parentSpanId 参数——每加一个节点都要改签名,漏一处断一截。四门语言都有现成机制:JS 的 AsyncLocalStorage、Python 的 contextvars、Swift 的 TaskLocal,Java 稍特殊,下面代码里说。
import { AsyncLocalStorage } from 'node:async_hooks'
const parentCtx = new AsyncLocalStorage() // 存「当前所在的 span id」
export class Tracer {
spans = []
async span(name, attrs, fn) {
const span = {
id: `s${this.spans.length + 1}`,
parentId: parentCtx.getStore(), // 取不到父就是根:一次请求的入口
name,
attrs,
startMs: performance.now(),
endMs: 0,
}
this.spans.push(span)
try {
// 在这个 store 里跑:里面再开的 span 自动认它当父,节点函数不用多一个参数
return await parentCtx.run(span.id, () => fn(span))
} finally {
span.endMs = performance.now()
}
}
}
// 两个兄弟的时间区间有重叠,就是真的在并行——并发上限有没有生效,看这一行
export const overlaps = (a, b) => a.startMs < b.endMs && b.startMs < a.endMsimport contextvars, time
from contextlib import asynccontextmanager
from dataclasses import dataclass
# ContextVar 会跟着 await 一起传给下游,asyncio.gather 出去的每个任务各有一份拷贝
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
# 取不到父就是根:一次请求的入口
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) # token 保证嵌套退出时精确还原,不用自己存旧值
span.end_ms = time.perf_counter() * 1000
def overlaps(a: Span, b: Span) -> bool:
# 两个兄弟的时间区间有重叠,就是真的在并行
return a.start_ms < b.end_ms and b.start_ms < a.end_ms// 依赖:JDK 17+。JDK 21 的 ScopedValue 更贴这个场景,但它还在预览,
// 17 上的地道写法就是 ThreadLocal 加 try/finally 还原
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(); // 取不到父就是根:一次请求的入口
var startMs = System.nanoTime() / 1_000_000;
PARENT.set(id);
try {
return body.call();
} finally {
PARENT.set(parent); // 还原,否则同一个线程上的下一个 span 会认错父亲
SPANS.add(new Span(id, parent, name, attrs, startMs, System.nanoTime() / 1_000_000));
}
}
// 注意 ThreadLocal 不会跨线程:扇出到线程池的子任务读不到父线程这一份,
// 必须在提交任务时把 parentId 显式带进去。JS/Python/Swift 的上下文机制替你做掉了这件事。
static boolean overlaps(Span a, Span b) {
return a.startMs() < b.endMs() && b.startMs() < a.endMs();
}enum Trace {
/// TaskLocal 会跟着 async let 与 TaskGroup 一起传给子任务,
/// 所以扇出时不用手工把 parentId 塞进每一个子任务
@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 // 取不到父就是根:一次请求的入口
let startMs = Date().timeIntervalSince1970 * 1000
// withValue 只在这段闭包里改绑定,出去自动还原,不用手写 finally
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
}
}
// 两个兄弟的时间区间有重叠,就是真的在并行
func overlaps(_ a: Span, _ b: Span) -> Bool { a.startMs < b.endMs && b.startMs < a.endMs }面板:失败率与成本,而且要能被手算复核
面板不是另一套埋点,是 trace 的聚合——这是本节的地基。同一份数据横着看是树、竖着堆是面板;两套来源迟早对不上,然后没人相信任何一个。
面板要回答四个问题:错了多少(通过率、路由准确率、降级率、兜底率)、慢在哪(p50/p95)、花了多少、钱花在哪个角色上(按节点分摊)。最后一样是多 Agent 特有的,也最有用——实验里执行者占了成本三分之一强,一眼就知道压成本先压哪儿。
成本有一个必须说清的口径差别:多 Agent 一次用户请求可能产生 5 到 10 次模型调用。 实验里 15 次请求共 76 次调用,平均每请求 5.1 次。所以「每次调用多少钱」比真实单价小一个数量级,按请求算钱才反映真实成本——单 Agent 时代的账单直觉在这里会骗人:调用单价没涨,请求单价涨了五倍。
面板上的数字必须能用价目表手算复核,否则只是一个漂亮的数字。沿用本课价目表——openai/gpt-4o-mini 输入每百万 token 零点一五美元、输出零点六美元,字符按 1 比 1 保守高估。实验打出来的是这样一块,每行都能拿计算器验:
面板(15 次请求,评分口径 v2/anthropic/claude-3.5-haiku)
质量:通过率 100.0%|路由准确率 100.0%|降级率 20.0%|兜底率 13.3%
延迟:p50 30.5ms|p95 76.5ms
调用:76 次模型调用 ÷ 15 次请求 = 每请求 5.1 次
手算复核:
输入 8494 tokens × $0.15 / 1M = $0.001274
输出 2844 tokens × $0.6 / 1M = $0.001706
合计 $0.002980 ÷ 15 次请求 = $0.000199 / 请求
按调用摊则是 $0.002980 ÷ 76 次 = $0.000039 / 调用,差 5.1 倍
钱花在谁身上:compose 21 次 $0.001109|route 15 次 $0.000704|review 25 次 $0.000556…
评估本身的开销(不进生产成本):judge 52 次,$0.002950最后一行值得单独说:评估自己也要花钱,而且要和生产成本分开记。 judge 调用的花费和被评估的系统本身一个量级——跑一次全量评估多少钱,就是你决定「每次提交都跑」还是「每天跑一次」的依据。
const PRICE_IN = 0.15 / 1_000_000 // 每 token;字符按 1:1 保守高估
const PRICE_OUT = 0.6 / 1_000_000
export function buildPanel(records) {
// 分数只在同一套 judge 提示词内部可比,混着算出来的平均分没有含义
const rubrics = new Set(records.map((r) => `${r.rubricVersion}/${r.judgeModel}`))
if (rubrics.size > 1) return { ok: false, reason: `混了 ${rubrics.size} 套评分口径,拒绝合并` }
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,
// 一次用户请求会产生好几次调用,所以分母是请求数,不是调用数
costPerRequest: cost / records.length,
costPerCall: cost / calls.length,
}
}PRICE_IN = 0.15 / 1_000_000 # 每 token;字符按 1:1 保守高估
PRICE_OUT = 0.6 / 1_000_000
def build_panel(records: list[Record]) -> Panel | Rejected:
# 分数只在同一套 judge 提示词内部可比,混着算出来的平均分没有含义
rubrics = {(r.rubric_version, r.judge_model) for r in records}
if len(rubrics) > 1:
return Rejected(f"混了 {len(rubrics)} 套评分口径,拒绝合并")
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),
# 一次用户请求会产生好几次调用,所以分母是请求数,不是调用数
cost_per_request=cost / len(records),
cost_per_call=cost / len(calls),
)// 依赖:JDK 17+ 的 Stream API 与 sealed interface,无第三方库
static final double PRICE_IN = 0.15 / 1_000_000; // 每 token;字符按 1:1 保守高估
static final double PRICE_OUT = 0.6 / 1_000_000;
// 密封接口把「算得出来」和「拒绝合并」表达成两种结果,调用方必须两种都处理
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) {
// 分数只在同一套 judge 提示词内部可比,混着算出来的平均分没有含义
var rubrics = records.stream().map(r -> r.rubricVersion() + "/" + r.judgeModel())
.collect(Collectors.toSet());
if (rubrics.size() > 1) return new Rejected("混了 " + rubrics.size() + " 套评分口径,拒绝合并");
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, // 分母是请求数,不是调用数
cost / calls.size());
}let priceIn = 0.15 / 1_000_000 // 每 token;字符按 1:1 保守高估
let priceOut = 0.6 / 1_000_000
/// 带关联值的枚举:算得出来和拒绝合并是两种结果,调用方 switch 时编译器逼你都处理
enum PanelResult {
case panel(passRate: Double, degradedRate: Double, callsPerRequest: Double,
costPerRequest: Double, costPerCall: Double)
case rejected(reason: String)
}
func buildPanel(_ records: [Record]) -> PanelResult {
// 分数只在同一套 judge 提示词内部可比,混着算出来的平均分没有含义
let rubrics = Set(records.map { "\($0.rubricVersion)/\($0.judgeModel)" })
guard rubrics.count == 1 else {
return .rejected(reason: "混了 \(rubrics.count) 套评分口径,拒绝合并")
}
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, // 分母是请求数,不是调用数
costPerCall: cost / Double(calls.count))
}Pi 与 LangGraph:定位不同,以及什么时候两个都不用
D15 欠的一句话今天还上。这两样不是替代关系,五个维度对照最清楚——判断来自这一周两套都写过代码的体感,不是特性表。
上手成本。 D3 那天 Pi 的调用侧只有四步,120 行手写循环压到 40 行;LangGraph 要你先想清楚状态有哪些字段、怎么合并、边怎么连,D15 那张三节点直线图光状态定义就十几行。Pi 的默认值多,LangGraph 几乎没有默认值——快,也就是 D3 那两颗定时炸弹:模型是它替你挑的,人设是它替你塞的。
状态管理的显式程度。 Pi 的历史在会话内部你感知不到;LangGraph 的状态是你自己定义的字段表,每个字段自带合并规则。D17 那个修复——workspace 从拼接换成按 id 原地更新——在 Pi 里根本没有对应的位置可改。判据很干脆:多个角色并行写同一份状态就必须显式;不需要并行,显式就是纯负担。
调试与可观测。 Pi 给事件流,一次执行是一条时间线;LangGraph 给逐节点增量加检查点,一次执行是一棵能回放能分叉的树。线性问题看时间线更快,多角色问题必须看树。
什么时候两个都不需要。 回到 D15 那三条判据,一条不命中就别拆、也别引框架。一两个工具、循环最多两轮,一个 while 加一个 switch 就够,引框架是净亏。
具体的体感是这样的:写 Pi 那天最舒服的是不用管历史,最不舒服的是不知道它替我塞了什么系统提示词;写 LangGraph 这一周最舒服的是每个字段的合并规则写在一处,最不舒服的是一个扇出参数忘了序列化,恢复出来是空壳,而且不报错。两句话合起来就是选型的全部:你更怕看不见的默认值,还是更怕写不完的样板。
W3 复盘:一天逼出一天
七天不是七篇 LangGraph 文档,是一条被问题推着走的线。每天都在解决前一天留下的具体麻烦:
- D15 被 D14 那句「一个 Agent 干不完」逼出来。它先泼冷水(三条判据一条不命中就别拆),再立下贯穿全周的规矩:合并规则声明在字段上,不写在节点里。
- D16 被 D15 那张全是写死边的直线图逼出来。路由必须走结构化输出——自由文本的失败是静默的:模型判对了、正则匹配不上,日志里只剩一行兜底。
- D17 被 D16「一次只派一个人」逼出来,并行当场引爆了 D15 埋的雷:拼接 reducer 让三件事写出六条记录、按 id 读到过期版本,而且不报错。
- D18 被 D17 那条会打回会重做的链路逼出来。累加通道做不了减法,
messages因此多了替换指令;检查点要连待执行的扇出一起存,漏了会「看起来跑完了,其实一件活没派」。 - D19 被 D18 那句「它现在自己能跑完了」逼出来:那谁来调它。答案是带用户自己的护照过去——身份只能来自令牌,不能来自请求体。
- D20 被 D19「接口通了,还是用户说一句做一件」逼出来。机制 D13 就备齐了,这天补的是该不该发:三道闸装晚了,每条被拦下的消息你都已经付过一次钱。
- D21 被 D20 那句「它做得好不好你其实说不上来」逼出来,也就是今天。
如果只带走三句话,我建议这三句。
第一,每一次拆分都是把「在一个提示词里没法同时最优」的问题,换成一堆「需要显式协议」的问题。 那些协议就是这一周的全部内容:路由理由、合并规则、工具预算、检查点、用户级令牌、三道闸。拆分不产生价值,把协议定清楚才产生价值。
第二,每个新机制都要配一个「它失效时会怎样」的答案。 W2 留下的这句话在 W3 一次都没落空:路由静默兜底、reducer 脏读、摘要丢判据、检查点丢扇出、judge 认可自己。这几种失效有一个共同点——都不报错,所以只能被评估和链路追踪抓住,抓不住就等于没做。
第三,没有评估的多 Agent 系统,是你永远不敢改的那一种。 五个角色互相影响,改一处提示词的影响弥散到全局,凭感觉只会越改越保守,最后冻在一个谁也不满意的状态上。今天这套 15 条样件加一块面板,买的就是「敢改」两个字。
源码导读
动手实验
本实验没有 docker-compose.yml:评估、打分、追踪、面板全在进程内。被评估的图直接来自 D16 与 D17,一行机制都没改、只加了 span 埋点,src/shared/state.ts 仍逐字来自 D15。MOCK=1 下零外部服务,但假回复随输入变化——换产出、换评分提示词、换 judge 模型分数都会跟着变,否则今天那三种失效模式一个都看不见。卡住了先看 README 的「常见坑」。
- 先原样跑一次
MOCK=1 SELFTEST=1 pnpm start,那六条 ❌ 的文案就是待办清单。 - 练习 1,
runGoldenSet从只跑第一条改成整批跑,第 2 项从「1/1」变成真正的 15 条。 - 练习 2,去掉 judge 的随机扰动并把评分口径写进记录;练习 3,把默认 judge 换成异源模型并实现校准一致率——第 3、5 项变 ✅,能看到同源 14/15 与异源 12/15 的差距。
- 练习 4,给 span 接上父指针;练习 5,成本的分母从调用数换成请求数,第 6、7 项变 ✅。
面试题
今天 5 道题在下方题库区,前 4 道覆盖评估方法、LLM-as-judge 的局限、可观测维度与框架选型,最后一道是系统设计大题:设计一套多 Agent 的评估与可观测体系。那道大题的分析过程给的是完整作答框架,照着它把这一周讲一遍,比刷十道小题管用。
检查清单与明日预告
- 能构建一个小规模 golden set,覆盖典型输入和期望输出
- 能实现一个 LLM-as-judge 评分脚本,对输出打分并给出理由
- 能接入 tracing,输出一份包含失败率和成本的可观测面板
- 能说出 LLM-as-judge 的三种失效模式,并给出各自的解药
- 能解释为什么多 Agent 要按请求算钱而不是按调用算钱,并用价目表手算一遍
- 能不看讲义把 D15 到 D21 逐天串一句,说清每天被前一天什么问题逼出来
- 实验的 5 条验收标准全部通过(七项自检全 ✅)
- 5 道面试题至少答出 4 道,系统设计那道能讲满 20 分钟
明天(D22)进入第四周,第一件事是安全:prompt injection、工具最小权限、沙箱、密钥管理。为什么紧接在评估之后?因为安全问题的表现形式和今天的失效模式一模一样——都不报错。一条藏在工具返回结果里的注入指令,让 Agent 老实按攻击者说的做,日志里一切正常,只有 trace 和评估能看出它走了不该走的路。今天这套面板,明天就用来发现「有人在教你的 Agent 干别的事」。再往后 D23 讲 MCP 与 Skills,把工具接入换成一套标准协议。
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 favours 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-labelled 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-labelled 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 optimise.
- 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 judgement 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 standardises 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 behaviour
- 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 standardise 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 optimise, 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 favour 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-labelled 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 会被针对性优化、采样漏长尾——说不出失效模式等于只是读过
Comments
Sign in to join the discussion
No comments yet — be the first.