逐日AI
第 3 周 · D17约 6 小时

Planner–Executor–Critic + 共享工作区:workspace state、toolBudget、并行 fan-out、review 回路

搭一套 Planner-Executor-Critic 协作流程,用共享工作区传递中间状态,加上工具预算和并行执行,最后让 Critic 检查并驱动重写。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能实现 Planner 把任务拆成多个子任务并写入共享工作区
  2. 能实现 Executor 并行执行子任务,并遵守 toolBudget 限制
  3. 能实现 Critic 检查结果、打回重写的完整回路

昨天的监督者(Supervisor)解决了「这一单该谁接」,但它一次只派一个人。今天要处理的是另一半问题:一件事要拆成好几件、几件同时做、做完还得有人验收。读完回到页面顶部把三条目标勾掉。

小白版讲解

一次选题会:谁拆、谁写、谁审

一家报社接到线索:某品牌被曝光了。主编不会自己动手写这条新闻,他先开一个选题会,把它拆成三个角度——事件时间线、当事人回应、行业背景。三个记者各领一个角度出去采写,谁也不等谁。稿子交回来,编辑逐篇看:时间线那篇没写清关键日期,退回去补;另外两篇合格,通过。三篇齐了,拼成明天见报的那个版面。

这就是今天要搭的三个角色,一一对应:

  • **Planner(规划者)**是主编:只负责把一句话的请求拆成几件可以并行做的子任务,自己不做事。
  • **Executor(执行者)**是记者:只负责把手上这一件做完,不关心别人在做什么。
  • **Critic(评审者)**是编辑:只负责判断产出合不合格,不合格就带着理由打回。

和昨天的对比很清楚:Supervisor 是一个岔路口,三条路里选一条走;今天这张图是先扇出再汇合,中间还有一条回边。用 D15 那四个特征说,它同时有扇出、汇合和回边,是本课到目前为止最复杂的一张图。

TextText
                  ┌──→ executor(t-1) ──┐
START → planner ──┼──→ executor(t-2) ──┼──→ critic ──→ respond → END
                  └──→ executor(t-3) ──┘        ↑          │
                                                └──────────┘
                                            不合格就打回重做(最多 2 次)

三个记者写的稿子放在哪?放在一块所有人都能读写的工作区状态(workspace state)里——就是 D15 定好的 workspace 字段。主编往里写三条待办,记者各自把自己那条改成写完的版本,编辑读整块判断,最后汇总节点把它拼成回复。请注意这句话里藏着今天最大的坑:三个记者是同时往同一个筐里放稿子的。

不用框架你会怎么写?大概率是一个 Promise.all 把三件事一起发出去,然后把返回的数组拼进一个共享对象。写到这里一切正常,因为你是在 Promise.all 之后一次性合并的。但当你把这三件事变成图里的三个节点、让框架去调度它们的时候,合并这一步就不再由你手写了——它交给了字段自己的合并规则。而 D15 给 workspace 配的那条规则,恰好是错的。

稿件筐的规矩:并行写同一个字段,reducer 说了算

先把 D15 留下的那个坑挖出来看。D15 的 workspace 通道配的是拼接(concat):谁写就往数组后面接一条。当时只有一个 handle 节点,它「更新」一条子任务的做法其实是追加一条同 id 的新版本,靠下游取最后一条来蒙混过关。单节点顺序执行时你察觉不到。

现在三个 Executor 并行往里写,跑一次实验的自检第一项,你会看到这样的现象:

TextText
planner   写入 [workspace] t-1-order:pending/0次 t-2-shipping:pending/0次 t-3-address:pending/0次
executor  写入 [workspace] t-1-order:done/1次
executor  写入 [workspace] t-3-address:done/1次
executor  写入 [workspace] t-2-shipping:done/2次
并发峰值 2(上限 2),第一条 t-2-shipping 读到的状态是 pending
3 件子任务写出 6 条记录,重复 id t-1-order t-2-shipping t-3-address

三件事,六条记录,每个 id 两份。更要命的是最后那句:workspace.find 按 id 去查 t-2-shipping,拿回来的是那条过期的 pending 版本——因为它排在前面。这个 bug 不抛异常、日志一切正常、最终回复看起来也对(因为汇总时用的是「取最后一条」),只有当某一处按 id 读状态时才悄悄给你错值。多 Agent 系统里最难查的 bug 就是这一类:状态是脏的,但没有任何一处报错。

再看那三条 executor 的顺序:t-1t-3t-2。多跑几次这个顺序会变,因为它取决于谁先跑完——所以「取最后一条」这种约定本身就不牢靠。

修法只有一条:把合并规则换掉。 合并规则(reducer)是声明在字段上的,不是写在节点里的——这是 D15 那句「新增节点时你不需要考虑我该怎么和别人的写入合并,字段自己知道」的真正兑现时刻。把 workspace 从「往后接」换成「按 id 原地更新」,同一份节点代码、同一张图,工作区立刻变成三条、零重复、全是最新版本。

reducers.js
import { Annotation } from '@langchain/langgraph'
 
// D15 的写法:新写入原样接在后面。并行扇出时同一个 id 会留下 pending 与 done 两份
const concatWorkspace = (old, next) => old.concat(next)
 
// 今天换上的:按 id 原地更新,没见过的 id 才追加
const upsertWorkspace = (old, next) => {
  const merged = old.slice() // reducer 必须是纯函数:先拷再改,别动传进来的旧值
  for (const task of next) {
    const at = merged.findIndex((t) => t.id === task.id)
    if (at === -1) merged.push(task)
    else merged[at] = task
  }
  return merged
}
 
// 合并规则挂在字段上,不写在节点里——所以加节点时不用操心怎么跟别人的写入合并
export const AgentAnnotation = Annotation.Root({
  workspace: Annotation({ reducer: upsertWorkspace, default: () => [] }),
  reviewRounds: Annotation({ reducer: (_old, next) => next, default: () => 0 }),
})

但按 id 原地更新也有前提:每条子任务只有一个写者。如果两个节点要写同一条记录的不同字段,你需要的就不是整条替换而是字段级合并,否则后写的会把前一份抹掉。判断口径很简单:先问「这个字段同一轮会被几个人写、写的是不是同一条记录」,答案决定了 reducer 该长什么样。

给每篇稿子一个采访预算:toolBudget

记者出差采访是要花钱的,报社会给每篇稿子定一个额度。Agent 里对应的就是 toolBudget每个子任务最多允许多少次工具调用。 本课取 5 次。

为什么必须有这个数?因为子任务卡住的典型形态不是报错,而是反复查、反复不满意、再查——模型不会喊累,它会把额度花光为止。W2 讲过整轮对话的成本封顶,那是外层的闸;toolBudget 是内层的闸,粒度细到单件事,超支时你能精确知道是哪一件失控了。

有两个细节比这个数字本身重要。

第一,预算是子任务级的,不是单次执行级的。 被 Critic 打回重做也照样计费。否则只要打回两次,实际额度就翻了三倍,防超支形同虚设。实验里退款那条子任务两轮一共用掉 4 次,预算只剩 1 次——再被打回一次,它会先撞上预算而不是撞上重试上限。两个上限哪个先到,都走同一条降级出口。

第二,预算耗尽绝不能抛错。 抛错等于把「这件事只做了一半」升级成「整个请求失败」,用户连已经查到的物流信息都拿不到。正确做法是把已经拿到的部分写进结果、状态标成失败、置上 degraded 标记,让上层决定这句话怎么说。实验自检第三项就是这个现场:开票这件事需要七步,走到第五步撞上预算,返回的是「已查到店铺支持电子普通发票、抬头、税号」加一句降级说明,同一轮的物流子任务照常完成。

executor.js
const TOOL_BUDGET = 5 // 子任务级:被打回重做也照样计费,否则有回路的图等于没设上限
 
export async function runTask(task) {
  const observations = []
  let used = task.toolCalls
  for (const tool of toolsFor(task)) {
    // 预算耗尽不抛错:抛错会把「做了一半」升级成「整个请求失败」
    if (used >= TOOL_BUDGET) return degrade(task, used, observations, `预算耗尽,停在 ${tool}`)
    observations.push(await callTool(tool, task))
    used += 1
  }
  return { ...task, toolCalls: used, result: await compose(task, observations), status: 'done' }
}
 
function degrade(task, used, observations, why) {
  const partial = observations.length > 0 ? observations.join(';') : '什么都没查到'
  return { ...task, toolCalls: used, result: `(降级)${why}。已有:${partial}`, status: 'failed' }
}

同时派三个记者出去,你要多付三笔账

扇出(fan-out)在图里就是一句话:Planner 之后那条边,运行时拆出几件就派几个 Executor。LangGraph 里由 Send 表达——条件边返回的不是节点名,而是一串「去执行这个节点,参数是这件活」的指令;子任务条数直到运行时才知道,所以这条边必须是动态的。

好处很直接:三件事各自两秒,串行六秒,并行两秒。但并行不是免费的,你要多付三笔账。

第一笔:并发数必须有上限。 拆出三件看不出问题,等哪天模型帮你拆出二十件,二十个请求会同一瞬间打向模型服务商,然后你收到一片 429。上限该设多少不看你机器多快,看你的配额和下游能扛多少并发——这是一条业务约束,不是性能调优。 实验里定成 2,自检里能看到并发峰值确实是 2 而不是 3。

第二笔:一个子任务失败不该让整个请求失败。 这一点在框架下尤其容易踩:扇出的每个 Executor 都是图里的一个节点,任何一个节点抛异常,整张图直接 reject,同一轮里已经做完的另外两件活跟着一起丢。所以异常必须在节点边界被翻译成状态:接住它、把这条子任务标成失败、置 degraded,其余的照常交付。实验自检第五项就是这个:理赔服务返回 503,理赔那条降级、物流那条照样交付。

第三笔,就是上一节讲的 reducer。 并行写共享状态必须有合并规则——这三笔账里它最隐蔽,因为前两笔至少会报错或者变慢,它连声都不吭。

fanout.js
const CONCURRENCY = 2 // 上限由配额决定,不是由机器性能决定
 
// Promise.all 会一次把 20 个请求全打出去,所以自己开固定数量的「工人」去排队领活
export async function runAll(tasks) {
  const queue = [...tasks]
  const results = []
  const workers = Array.from({ length: CONCURRENCY }, async () => {
    for (let task = queue.shift(); task; task = queue.shift()) {
      try {
        results.push(await runTask(task))
      } catch (error) {
        // 失败关在这一件事里:其余的照常交付
        results.push(degrade(task, task.toolCalls, [], String(error)))
      }
    }
  })
  await Promise.all(workers)
  return results
}

编辑怎么退稿才有用,以及编辑自己也会看走眼

Critic 是今天三个角色里最容易写砸的一个,因为它看起来最简单:让模型判个「行不行」而已。它有三种典型的失效方式,三种都要防。

第一种:无限打回。 稿子改一版编辑挑一个新毛病,改到天亮也发不了版。所以必须有硬上限——本课取最多打回 2 次,加上第一次执行一共 3 次。上限的作用不是「省钱」,是保证这个流程一定会结束

第二种:打回但不说人话。 编辑只回一句「不合格」,记者拿不到任何可执行的信息,第二稿只能原样再交一遍,于是必然打满上限、白烧三倍的钱。打回必须带具体理由,写成「产出里没有写明退款结论,请补上」这种能直接照着改的句子,然后把这句理由塞回子任务的目标里带给执行者。实验里退款方案第一版只罗列了查到的事实、没给结论,被打回一次、补上结论就通过了——这条回路真的在起作用,而不是走了个过场。

第三种最危险,也最少被提到:Critic 和 Executor 用同一个模型、同一套提示词时,它倾向于认可自己的输出。 同一个模型对「什么算好答案」的偏好是一致的,让它复核自己刚写的东西,它当然觉得没问题。表现是通过率高得离谱,你以为质量很好,其实这道工序等于没有。三条缓解手段,按性价比排:给 Critic 一份可核对的验收要求(本课的做法:产出里必须写明某件事,这是客观判据,不是主观感受);让 Critic 用不同的模型,哪怕是更便宜的那个;把评审做成逐条打分而不是一句结论,分项越具体越难糊弄过去。D21 讲评估时会把这个问题彻底展开,那里它有个名字叫同源偏差。

critic.js
const MAX_REVIEW_ROUNDS = 2 // 最多打回 2 次,加首次执行共 3 次
 
export async function criticNode(state) {
  const tasks = latestById(state.workspace)
  const reviews = await reviewTasks(tasks.filter((t) => t.status === 'done'))
  const rejected = reviews.filter((r) => !r.ok)
  if (rejected.length === 0) return {} // 全过:什么都不写,条件边会把流程送去汇总
 
  const byId = new Map(tasks.map((t) => [t.id, t]))
  if (state.reviewRounds >= MAX_REVIEW_ROUNDS) {
    // 上限到了:不再打回,降级返回已有结果。无限打回的代价是用户永远等不到回复
    return {
      degraded: true,
      workspace: rejected.map((r) => ({ ...byId.get(r.id), status: 'failed' })),
    }
  }
  // 打回时把理由写进目标:只说「不合格」,执行者第二次会原样再交一遍
  return {
    reviewRounds: state.reviewRounds + 1,
    workspace: rejected.map((r) => ({
      ...byId.get(r.id),
      status: 'pending',
      goal: `${baseGoal(byId.get(r.id).goal)} | 评审意见:${r.reason}`,
    })),
  }
}

截稿时间:退稿两次就得发版

报纸有截稿时间。到点了稿子还不完美,编辑的选择不是无限期停印,而是带着现有的稿子发版,并在文末加一句「本报将持续跟进」。这句话就是 degraded 标记。

所以今天所有的上限——单件事 5 次工具调用、最多打回 2 次——耗尽之后走的是同一条出口:降级返回已有结果,不抛错。 它反直觉:程序员的本能是做不到就抛异常,但在 Agent 里抛异常意味着用户等了六秒只看到一句「服务异常」,而他其实只是没拿到三件事里的一件。给出两件半的答案并说明哪半件没做成,比给一个错误页有用得多。

degraded 这个标记的价值也在这里:它让「降级」变成一个可观测、可统计的事实,而不是藏在日志里的一句话。上层拿到它可以决定要不要转人工;监控拿到它可以画出降级率;D21 做评估时,降级率本身就是一条核心指标——一个降级率百分之三十的系统和一个百分之三的系统,平均分可能一样,但完全不是一个东西。

还有一层保险丝要认识:LangGraph 自带一个递归上限,图在节点之间打转超过一定步数会直接抛异常。它是最后一道防线,但不能拿它当业务上限——第一,它是全图的,你不知道是哪条回路失控了;第二,它触发时抛的是异常,你连已有结果都拿不到,恰好违背了上面那条口径。实验的练习 3 特意留了这个现场:如果你实现了打回却忘了写上限,自检会把这个异常打出来并告诉你它是什么。

最后把今天这张图的全貌串一遍,也回答「不用框架你会怎么写」:你要自己维护一个待办列表、自己写并发闸门、自己在每次合并时决定覆盖还是追加、自己数打回了几轮、自己保证异常不会掀翻整批。这五件事框架各给了一个位置——Send、并发上限配置、reducer、状态字段、节点边界。框架的价值不是让你少写代码,是让这五件事各有各的地方放,不至于全挤在一个函数里互相打架。

源码导读

动手实验

🧪 D17 实验:任务拆分并行 + 评审重写循环

代码位置:labs/agent-30days/day-17-planner-executor-critic

验收标准:

  1. MOCK=1 SELFTEST=1 pnpm start 五项自检全部 ✅、退出码 0(starter/ 原样跑是 1 ✅ 4 ❌,每个 ❌ 点名对应哪个练习)。
  2. 第 1、2 项是同一组对照:配 concat reducer 时 3 件子任务留下 6 条记录、3 个重复 id,按 id 查到的第一条是过期的 pending 版本;同一张图只换成按 id 原地更新,就变成 3 条、0 个重复、全部 done。同时能看到并发峰值是 2 而不是 3。
  3. 第 3 项:开票子任务需要 7 次工具调用,撞上 5 次预算后降级——toolCalls 停在 5、状态 faileddegraded 为 true、结果里带着已经查到的部分,进程没有抛错;同一轮的物流子任务照常 done
  4. 第 4 项:退款方案被打回 1 次后补上结论通过(reviewRounds 为 1、degraded 为 false,同一轮的物流子任务没有被连坐重跑);投诉工单永远给不出工单号,被打回 2 次、共执行 3 次后停下,返回已有结果并置 degraded 为 true。
  5. 第 5 项:理赔服务 503 时,理赔子任务 failed、物流子任务照样 done、整张图没有 reject。

今天依旧没有基础设施依赖,所以本实验没有 docker-compose.yml:扇出、合并、评审回路全在进程内。src/shared/state.ts 原样来自 D15,字段一个没加。唯一的网络出口是模型调用,MOCK=1 下三种用途的假回复都随输入变化——换订单号会换出不同的金额与轨迹。先原样跑一次,那四条 ❌ 就是你的待办清单。

  1. 先原样跑 MOCK=1 SELFTEST=1 pnpm start,重点看第 1 项打印的那六条记录和三个重复 id——这是今天要修的现象。
  2. 练习 1,把 upsertWorkspace 写成按 id 原地更新,第 2 项从 6 条重复变成 3 条干净记录。
  3. 练习 2,在 runTask 里加预算检查,第 3 项的开票子任务从调满 7 次变成停在 5 次并降级。
  4. 练习 3,让 Critic 真的评审、带理由打回,并自己设上限,第 4 项能看到退款打回一次后通过、投诉打满两次后停下。
  5. 练习 4,在 Executor 节点边界接住工具异常,第 5 项从「整张图 reject」变成「只有理赔那条失败」。

面试题

今天 4 道题在下方题库区,侧重反思纠错、预算控制与死循环防护,其中第二道「并行写共享状态怎么避免冲突」是本章最值钱的一道,也是简历上写了多 Agent 就一定会被追的一道。展开后先看「分析过程」再看要点,照着推导练,比背要点管用。标注「国内高频 / 海外高频」方便按目标市场取舍。

检查清单与明日预告

  • 能实现 Planner 把任务拆成多个子任务并写入共享工作区
  • 能实现 Executor 并行执行子任务,并遵守 toolBudget 限制
  • 能实现 Critic 检查结果、打回重写的完整回路
  • 能说清不配 reducer 时并行写同一个字段会出现什么现象,以及为什么它不报错
  • 能背出并行 fan-out 的三笔账,并说清预算或重试耗尽时为什么要降级而不是抛错
  • 实验的 5 条验收标准全部通过(五项自检全 ✅)
  • 4 道面试题不看要点也能答出至少 3 道

明天(D18)讲两件被今天逼出来的事。第一,评审回路跑上两三轮,messages 已经很长了——多 Agent 场景下的历史压缩比单 Agent 难,因为摘要一旦丢掉「是谁在哪一步说的」,Critic 就没法判断了,这叫历史保真。第二,今天这一切都在内存里,进程一挂全部重来,九次模型调用的钱白花——所以要有检查点(checkpoint)。为什么排在协作模式之后?因为只有真的跑出一条会打回、会重做的链路,才会切身感到「存不下来」有多贵。

面试题库

  • Planner-Executor-Critic 这种结构解决了什么问题?它和 Supervisor 路由的区别在哪?What problem does the Planner-Executor-Critic structure solve, and how is it different from Supervisor routing?
    国内高频海外高频基础#multi-agent#orchestration#architecture

    分析过程 · 先想清楚再作答

    1. 题眼在后半句。只答「拆解、执行、评审」是在背名词,面试官想确认的是你能不能用图的形状把两种模式分开,而不是靠记忆背模式表。
    2. 先用形状拆:Supervisor 是一个岔路口,运行时在几条路里选一条走,一次只交给一个人,图上只有分叉;Planner-Executor-Critic 是先扇出、再汇合、中间还有一条回边。分叉解决「交给谁」,扇出解决「一件事要拆成几件」,回边解决「谁来验收」。
    3. 再给适用判据:一次只需要一个专家、难点在判断该找谁,用 Supervisor;一件事必须拆成几件且几件之间没有先后依赖,才值得扇出;产出的对错有明确判据、且错了重做比错了发出去便宜,才值得加 Critic。三条判据都不命中就别上这套结构。
    4. 结论要落到代价,这是区分「读过文档」和「上线过」的地方:拆出三件事意味着模型调用次数从一次变成七次起步(拆解一次、三次执行、三次评审),有一轮打回就是九次;延迟被最慢的那件事决定而不是平均值,而且并行只省延迟不省钱。
    5. 可以预期的追问:Critic 一定要单独一个节点吗?答案是不一定——如果验收判据是可以用代码判的(比如 JSON schema 校验、必填字段检查),就别花一次模型调用,代码判更快更准也更便宜。只有判据本身需要理解语义时,Critic 才值得是一次模型调用。

    How to reason about it · think before answering

    1. The hinge is the second half. Reciting plan, execute, review is naming shapes from memory; the interviewer wants to see you separate the two patterns by graph shape.
    2. Separate by shape: a Supervisor is a fork — at runtime it picks one of several paths and hands the work to exactly one agent, so the graph only branches. Planner-Executor-Critic fans out, joins, and adds a back edge. Branching answers who takes this, fan-out answers this must be split into several pieces, the back edge answers who signs it off.
    3. Then give the criteria: use a Supervisor when only one specialist is needed per request and the hard part is picking them; only fan out when a request genuinely splits into independent pieces with no ordering between them; only add a Critic when correctness has an explicit rubric and redoing is cheaper than shipping something wrong. If none of these hold, do not build this.
    4. Land on cost, which is where shipped-it separates from read-the-docs: three subtasks turn one model call into seven (one plan, three executions, three reviews) and nine after a single rejection round; latency is set by the slowest branch rather than the average, and parallelism buys latency, never money.
    5. Expect: does the Critic have to be its own node? Not necessarily — if the rubric is checkable in code (schema validation, required fields), check it in code: faster, cheaper, and more reliable. A Critic earns a model call only when the rubric requires understanding meaning.

    答题要点

    • Supervisor 是分叉(一次派一个人),Planner-Executor-Critic 是扇出加汇合加回边(拆成几件并行做,做完有人验收)
    • 三条适用判据:一次只需一个专家用路由;能拆成互不依赖的几件才扇出;对错有明确判据且重做便宜才加评审
    • 拆解的代价是模型调用从一次涨到七到九次、延迟由最慢的分支决定,而并行只省延迟不省成本
    • 评审判据能用代码判就别用模型判,Critic 只在需要理解语义时才值一次模型调用

    Key points

    • A Supervisor branches (one agent per request); Planner-Executor-Critic fans out, joins, and loops back (split, run in parallel, then sign off)
    • Three criteria: route when one specialist suffices; fan out only for genuinely independent pieces; add review only when the rubric is explicit and redoing beats shipping wrong
    • The cost is seven to nine model calls instead of one, with latency set by the slowest branch — parallelism buys latency, not money
    • If the rubric is checkable in code, check it in code; a Critic deserves a model call only when semantics must be understood
  • 多个子任务并行执行、都要写同一份共享状态时,怎么设计才不会互相覆盖?When several subtasks run in parallel and all write the same shared state, how do you design it so they do not clobber each other?
    国内高频海外高频深入#multi-agent#state-management#concurrency

    分析过程 · 先想清楚再作答

    1. 这题的区分度极高,因为大多数人会答成「加锁」或者「用不可变数据结构」——都是从多线程经验迁移过来的答案,但图的执行模型里根本没有并发写内存这回事,写入是被收集起来统一合并的。答错方向比答不全更致命。
    2. 先把机制说对:并行节点各自返回一份增量,框架把同一轮里所有增量按字段收集,再逐字段调用这个字段的合并规则(reducer)算出新值。所以问题不是「怎么加锁」,而是**这个字段的合并规则写得对不对**。
    3. 然后给一条可复用的判断链:先问这个字段同一轮会被几个人写;再问他们写的是不是同一条记录。只有一个写者,默认的后写覆盖就够;多个写者写不同记录,数组追加就够;多个写者写同一条记录的同一份数据,要按主键原地更新;多个写者写同一条记录的不同字段,要做字段级合并。四种情况四种 reducer,这条链能直接迁移到任何框架。
    4. 结论落在最容易踩的那一格:把「更新一条记录」写成「往数组里追加一条同 id 的新版本」。它的症状不是报错,是同一个 id 在状态里有两份、而且哪份在前取决于谁先跑完——下游任何按 id 查的地方都可能拿到过期版本,日志干净、结果偶尔错。
    5. 补一句权衡:也可以不动 reducer,改成每处读状态前先按 id 去重。但读取点有三四处,漏一处就是一个偶发脏读;reducer 只写一次,之后所有读取点自动干净。在字段上解决一次,还是在每个读取点解决 N 次,这是同一个问题的两种成本。
    6. 可以预期的追问:那顺序不确定要不要紧?答:合并规则最好对顺序不敏感(可交换),做不到就必须保证每条记录只有一个写者。本课的按 id 原地更新属于后者——它是最后写入者获胜,靠「一轮里一条记录只有一个执行者」这个前提才安全。

    How to reason about it · think before answering

    1. This one separates people fast, because most candidates answer locks or immutable data structures — instincts carried over from threads. A graph runtime has no concurrent memory writes at all: updates are collected and merged. Answering in the wrong frame is worse than answering incompletely.
    2. Get the mechanism right first: parallel nodes each return a delta, the runtime groups all deltas from the same step by field, then calls that field's reducer to compute the new value. So the question is not how to lock, it is whether that field's reducer is correct.
    3. Then give a reusable chain: how many writers touch this field in one step, and do they write the same record? One writer — last-write-wins is fine. Several writers on different records — appending to a list is fine. Several writers on the same record — upsert by key. Several writers on different fields of the same record — merge per field. Four cases, four reducers, and the chain transfers to any framework.
    4. Land on the common mistake: implementing update this record as append a new version with the same id. The symptom is not an error — the same id exists twice and which one comes first depends on who finished first, so any lookup by id may return the stale version. Clean logs, occasionally wrong results.
    5. Add the trade-off: you can leave the reducer alone and dedupe by id at every read instead. But there are three or four read sites, and missing one is an intermittent stale read; a reducer is written once and every read is clean afterwards. Solve it once on the field, or N times at the read sites.
    6. Expect: does nondeterministic ordering matter? Ideally the reducer is order-insensitive (commutative); if it is not, you must guarantee one writer per record. Upsert-by-id is the latter — it is last-write-wins and is safe only because each record has exactly one executor per round.

    答题要点

    • 图的执行模型里没有并发写内存:节点各返回增量,框架按字段收集后调用该字段的 reducer 合并,所以问题是 reducer 写得对不对,不是加不加锁
    • 判断链:同一轮几个写者、写的是不是同一条记录——单写者用覆盖、多写者写不同记录用追加、多写者写同一条记录用按主键原地更新、写同一条记录的不同字段要字段级合并
    • 最常见的错是把「更新」写成「追加同 id 的新版本」,症状是同 id 两份、顺序取决于谁先跑完、按 id 查会拿到过期版本,而且全程不报错
    • 另一条路是每处读取前手动去重,但读取点有好几处,漏一处就是偶发脏读;reducer 只写一次就一劳永逸
    • 合并规则最好对顺序不敏感;做不到就必须保证一轮里一条记录只有一个写者

    Key points

    • A graph runtime has no concurrent memory writes: nodes return deltas, the runtime groups them per field and calls that field's reducer — so the answer is a correct reducer, not a lock
    • Decision chain: how many writers per step, and same record or not — overwrite, append, upsert by key, or per-field merge
    • The classic bug is implementing update as append-a-new-version-with-the-same-id: two entries per id, order depends on who finished first, lookups return stale data, and nothing ever errors
    • The alternative is deduping at every read site, but there are several and missing one gives an intermittent stale read; a reducer is written once
    • Prefer an order-insensitive reducer; if it is not, guarantee exactly one writer per record per step
  • 为什么要给每个子任务设 toolBudget 这样的预算?超了预算之后你会怎么处理?Why give each subtask a tool-call budget, and what do you do when it runs out?
    国内高频海外高频进阶#cost-control#reliability#agent-design

    分析过程 · 先想清楚再作答

    1. 这题的题眼在后半句。前半句几乎人人会答「防止成本失控」,真正拉开差距的是超限之后的动作——答「抛异常」的人基本没做过面向用户的 Agent。
    2. 先把「为什么」说具体。子任务卡住的典型形态不是报错,而是反复查、反复不满意、再查——模型不会喊累,它会把额度花光为止。整轮对话的成本封顶是外层的闸,子任务预算是内层的闸;粒度细到单件事的好处是超支时你能精确指出是哪一件失控了,而不是只看到这次对话贵了。
    3. 再点一个容易被忽略的设计点:预算必须是子任务级的,不是单次执行级的。有评审回路时,被打回重做也得计费,否则打回两次实际额度就翻三倍,这道闸等于没设。
    4. 结论是超限的处理:降级返回已有结果并打上标记,不抛错。理由要说透——抛错等于把「这件事只做了一半」升级成「整个请求失败」,用户等了几秒最后看到一句服务异常,可他其实只是没拿到三件事里的一件。给出两件半的答案并说明哪半件没做成,永远比一个错误页有用。
    5. 降级标记本身也要说:它让降级变成可观测、可统计的事实,而不是日志里的一句话。上层据此决定要不要转人工,监控据此画降级率——两个平均分一样的系统,降级率百分之三十和百分之三完全不是一回事。
    6. 可以预期的追问:预算该设多少?答案是从「这件事正常需要几次工具调用」反推再留一点余量,不是拍脑袋取整数;同时要有第二个维度的闸(挂钟时间或 token 数),因为一次超长的工具调用同样能拖垮请求,而它只算一次。

    How to reason about it · think before answering

    1. The hinge is the second half. Everyone can say it controls cost; what separates people is what happens when the budget runs out. Answering throw an exception usually means you have never shipped a user-facing agent.
    2. Make the why concrete: a stuck subtask rarely errors — it queries, dislikes the result, and queries again. The model never gets tired; it will spend whatever you allow. A per-conversation cap is the outer gate, a per-subtask budget is the inner one, and the finer grain tells you which piece went out of control instead of only that the conversation was expensive.
    3. Add the design point people miss: the budget must be per subtask, not per execution. With a review loop, retries have to draw on the same budget, or two rejections triple the real allowance and the gate is meaningless.
    4. The conclusion is the exhaustion path: degrade — return what you already have with a flag — rather than throw. Explain why: throwing upgrades this piece is half done into the whole request failed. The user waited several seconds and gets an error page, when in reality only one of three pieces is missing. Two and a half answers plus a clear note beats an error page every time.
    5. Say something about the flag too: it turns degradation into an observable, countable fact instead of a log line. The layer above decides whether to escalate to a human, and monitoring plots a degradation rate — two systems with the same average score but 30 percent versus 3 percent degradation are not the same system.
    6. Expect: how big should the budget be? Derive it from how many tool calls the task normally needs plus margin, not a round number pulled from the air. And pair it with a second dimension — wall-clock or tokens — because one very slow tool call can ruin a request while counting as a single call.

    答题要点

    • 子任务卡住的典型形态是反复查而不是报错,模型会把额度花光为止;整轮封顶是外层闸,子任务预算是内层闸,细粒度让你能定位到是哪一件失控
    • 预算必须是子任务级而不是单次执行级,否则被评审打回两次实际额度就翻三倍
    • 超限必须降级返回已有结果并标记,不能抛错——抛错把「做了一半」升级成「整个请求失败」,用户连已经查到的部分都拿不到
    • 降级标记让降级率变成可统计指标,上层据此决定转人工,评估据此区分两个平均分相同的系统
    • 预算大小从这件事正常需要几次工具调用反推并留余量,同时配一个时间或 token 维度的闸

    Key points

    • A stuck subtask loops rather than errors, and the model will spend whatever you allow; a conversation cap is the outer gate, a subtask budget the inner one that localises the blowup
    • The budget must be per subtask, not per execution, or two review rejections triple the real allowance
    • On exhaustion, degrade and flag rather than throw — throwing upgrades half done into whole request failed and discards what was already retrieved
    • The degradation flag makes the degradation rate a real metric for escalation and evaluation
    • Size the budget from the task's normal tool-call count plus margin, and pair it with a wall-clock or token gate
  • Critic 的评审回路怎么防止陷入死循环?除了次数上限还有什么要防的?How do you keep a Critic review loop from spinning forever, and what else needs guarding besides a retry cap?
    国内高频海外高频进阶#reflection#loop-guard#reliability

    分析过程 · 先想清楚再作答

    1. 问「除了次数上限还有什么」,说明面试官已经预设你会答上限,真正在考的是你有没有真的跑过这条回路。只答上限的人拿基础分,能说出另外两种失效方式的才算过。
    2. 第一种就是无限打回:每改一版评审者挑一个新毛病,永远收敛不了。上限的作用不是省钱,是**保证流程一定会结束**。本课取最多打回 2 次、共 3 次执行,这个量级的取法是「一次有效的修改通常在第二次就完成,第三次还不行说明判据本身有问题」。
    3. 第二种是打回不说人话:评审者只回一句「不合格」,执行者拿不到可执行信息,第二稿原样再交一遍,于是必然打满上限、白烧三倍的钱。所以打回必须带具体理由,而且理由要回写进子任务的目标里带给执行者——「缺了退款结论,请补上」才是可执行的,「质量不佳」不是。
    4. 第三种最危险也最少被提到:评审者和执行者用同一个模型、同一套提示词时,它倾向于认可自己的输出。同一个模型对「什么算好答案」的偏好是一致的,让它复核自己刚写的东西,通过率会高得离谱,这道工序等于没有。缓解手段按性价比排:给评审者一份可核对的客观验收要求;换一个不同的模型来评审,哪怕更便宜;把评审做成逐条打分而不是一句结论。
    5. 还要点一句框架的兜底与业务上限的区别:编排框架通常自带一个递归步数上限,但那是最后一道保险丝,不能当业务上限用——它是全图的,你不知道是哪条回路失控;而且它触发时抛异常,你连已有结果都拿不到,正好违背「降级返回」的原则。
    6. 可以预期的追问:上限用完了返回什么?答:返回已有结果并标记降级,同时把最后一次的评审意见一起带出去,让上层能判断要不要转人工——这条回路的价值不只是修好,还包括「修不好时说清楚差在哪」。

    How to reason about it · think before answering

    1. Asking what else besides a cap tells you the interviewer already expects the cap. What is really being tested is whether you have run this loop for real. The cap earns baseline credit; naming the other two failure modes is what passes.
    2. Failure one is infinite rejection: every revision draws a new complaint and nothing converges. The cap exists to guarantee termination, not to save money. Two rejections and three executions is a reasonable default, because an effective fix usually lands on the second attempt — if the third still fails, the rubric itself is the problem.
    3. Failure two is a rejection with no actionable content. If the reviewer only says not good enough, the executor has nothing to act on and resubmits the same thing, burning the full cap. Rejections must carry a specific reason, and that reason must be written back into the subtask goal. Missing the refund conclusion, please add it is actionable; poor quality is not.
    4. Failure three is the dangerous one people rarely mention: when reviewer and executor share a model and a prompt, the reviewer tends to approve its own output. A single model has consistent preferences about what a good answer looks like, so pass rates go implausibly high and the review step becomes theatre. Mitigations by value: give the reviewer an objective, checkable rubric; use a different model even a cheaper one; score item by item rather than emitting one verdict.
    5. Also distinguish the framework's safety net from your business cap: orchestration frameworks usually ship a recursion limit, but that is a last-resort fuse — it is graph-wide so you cannot tell which loop ran away, and it throws, which means you lose the partial results you were supposed to degrade to.
    6. Expect: what do you return once the cap is used up? Return what you have, flag it as degraded, and carry the last review comment out with it so the layer above can decide whether to escalate. The loop's value is not only fixing things — it is stating precisely what could not be fixed.

    答题要点

    • 次数上限的作用是保证流程一定会结束,不是省钱;本课取最多打回 2 次、共 3 次执行
    • 打回必须带具体、可执行的理由并回写进子任务目标,只说「不合格」会让执行者原样重交、必然打满上限
    • 最危险的是评审者与执行者同模型同提示词,它倾向于认可自己的输出,通过率虚高、这道工序等于没有
    • 缓解手段:给客观可核对的验收要求、换一个模型来评审、逐条打分而不是一句结论
    • 框架自带的递归上限只是保险丝,不能当业务上限:它是全图的、触发时抛异常,连已有结果都拿不到
    • 上限用完要返回已有结果加降级标记,并把最后一次评审意见带出去,供上层决定是否转人工

    Key points

    • A retry cap exists to guarantee termination, not to save money — two rejections, three executions total
    • Rejections must carry specific, actionable reasons written back into the subtask goal; not good enough guarantees an identical resubmission and a maxed-out cap
    • The most dangerous failure is a reviewer sharing model and prompt with the executor: it approves its own output, pass rates inflate, and the step becomes theatre
    • Mitigate with an objective checkable rubric, a different model for review, and item-by-item scoring instead of a single verdict
    • The framework's recursion limit is a fuse, not a business cap: it is graph-wide and it throws, so you lose the partial results you meant to degrade to
    • When the cap is spent, return what you have with a degraded flag plus the last review comment so the layer above can escalate

评论

登录后即可参与讨论

还没有评论,来说第一句。