Dayward AI
Week 3 · D17About 6 hours

Planner-Executor-Critic Plus a Shared Workspace: Workspace State, toolBudget, Parallel Fan-Out, a Review Loop

Build a Planner-Executor-Critic collaboration flow, pass intermediate state through a shared workspace, add a tool budget and parallel execution, then have the Critic check the result and drive a rewrite.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Implement a Planner that splits a task into subtasks and writes them into the shared workspace
  2. Implement an Executor that runs subtasks in parallel while respecting the toolBudget limit
  3. Implement the full Critic loop that checks the result and bounces it back for a rewrite

Yesterday's Supervisor solved "who takes this case," and it dispatches one person at a time. Today handles the other half: one thing split into several, several done at once, and somebody signing off afterwards.

Plain-Language Walkthrough

One pitch meeting: who splits, who writes, who reviews

A newspaper gets a lead: a brand has been exposed. The editor does not write the story themselves; they hold a pitch meeting first and split it into three angles — the timeline of events, the parties' responses, and the industry background. Three reporters each take an angle and go out to report, none waiting for the others. The copy comes back and an editor reads each piece: the timeline lacks the key dates, so back it goes; the other two pass. With all three in hand, they assemble tomorrow's page.

Those are today's three roles, one for one:

  • Planner is the editor: responsible only for splitting a one-sentence request into several subtasks that can run in parallel, doing no work itself.
  • Executor is the reporter: responsible only for finishing the one piece in hand, unconcerned with what anybody else is doing.
  • Critic is the copy editor: responsible only for judging whether the output passes, bouncing it back with a reason when it does not.

The contrast with yesterday is clear: a Supervisor is a fork where one of three roads is taken; today's graph is a fan-out, then a join, with a back edge in the middle. In D15's four features it has fan-out, join, and back edge simultaneously, the most complex graph in this course so far.

TextText
                  +--) executor(t-1) --+
START - planner --+--) executor(t-2) --+--) critic --) respond - END
                  +--) executor(t-3) --+        ^          |
                                                +----------+
                                            failed goes back for a redo (at most twice)

Where does the three reporters' copy go? Into a workspace state everybody can read and write — the workspace field fixed on D15. The editor writes three to-do items into it, each reporter changes their own item into a written version, the copy editor reads the whole block to judge, and a final node assembles it into a reply. Note the trap hidden in that sentence: three reporters put copy into the same basket at the same time.

Without a framework, how would you write it? Most likely a Promise.all firing all three at once, then splicing the returned array into a shared object. Everything is fine there, because you merge once after Promise.all. But once those three become three nodes in a graph scheduled by the framework, merging is no longer hand-written by you — it is handed to the field's own merge rule. And the rule D15 gave workspace happens to be wrong.

The basket's rules: with parallel writes to one field, the reducer decides

Dig out the trap D15 left. D15's workspace channel is configured to concat: whoever writes appends an item to the array. With only one handle node, its way of "updating" a subtask was actually appending a new version with the same id, muddling through by having downstream take the last one. Single-node sequential execution hides it.

Now three Executors write into it in parallel. Run the lab's first self-check and you see this:

TextText
planner   wrote [workspace] t-1-order:pending/0 calls t-2-shipping:pending/0 calls t-3-address:pending/0 calls
executor  wrote [workspace] t-1-order:done/1 call
executor  wrote [workspace] t-3-address:done/1 call
executor  wrote [workspace] t-2-shipping:done/2 calls
concurrency peak 2 (limit 2), and the first t-2-shipping read shows status pending
3 subtasks produced 6 records, with duplicate ids t-1-order t-2-shipping t-3-address

Three things, six records, two of every id. And worse, that last line: workspace.find looking up t-2-shipping by id gets back the stale pending version, because it comes first. That bug throws nothing, the logs look fine, and the final reply looks right too (because assembly takes the last one) — only somewhere reading state by id quietly gets the wrong value. The hardest bug in a multi-agent system is exactly this kind: the state is dirty and nothing reports an error.

Look at those three executor lines' order too: t-1, t-3, t-2. Run it a few more times and the order changes, because it depends on who finishes first — so "take the last one" was never a sound convention.

There is one fix: change the merge rule. A reducer is declared on the field rather than written in the node — this is the moment D15's line about a field knowing how to merge cashes out. Change workspace from append to update-by-id in place, and with the same node code and the same graph the workspace becomes three items, zero duplicates, all current.

reducers.js
import { Annotation } from '@langchain/langgraph'
 
// D15's version: new writes are appended as-is. Under a parallel fan-out the same id
// leaves both a pending and a done copy
const concatWorkspace = (old, next) => old.concat(next)
 
// Today's replacement: update by id in place, appending only ids never seen
const upsertWorkspace = (old, next) => {
  const merged = old.slice() // a reducer must be pure: copy before changing, never touch the old value
  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
}
 
// The merge rule hangs off the field, not the node - so adding a node needs no thought
// about merging with anybody else's writes
export const AgentAnnotation = Annotation.Root({
  workspace: Annotation({ reducer: upsertWorkspace, default: () => [] }),
  reviewRounds: Annotation({ reducer: (_old, next) => next, default: () => 0 }),
})

But update-by-id has a premise: each subtask has exactly one writer. If two nodes write different fields of the same record, what you need is field-level merging rather than whole-record replacement, or the later write wipes out the earlier one. The test is simple: ask how many people write this field in one round, and whether they write the same record; the answer determines the reducer's shape.

A reporting budget per story: toolBudget

Sending a reporter out costs money, so the paper sets a budget per story. The agent equivalent is toolBudget: the maximum tool calls one subtask may make. This course takes 5.

Why is that number mandatory? Because a stuck subtask's typical form is not an error but querying, being unsatisfied, and querying again — a model never complains of fatigue, it spends the budget to the last unit. W2 covered a cost ceiling for a whole round, which is the outer gate; toolBudget is the inner one, granular to one item, so an overrun tells you precisely which item lost control.

Two details matter more than the number.

One, the budget is per subtask, not per execution. A Critic bounce is billed too. Otherwise two bounces triple the effective budget and the guard is decorative. In the lab the refund subtask uses 4 across two rounds, leaving 1 — one more bounce and it hits the budget before the retry ceiling. Whichever ceiling arrives first takes the same degradation exit.

Two, an exhausted budget must never throw. Throwing escalates "half done" into "the whole request failed," and the user loses even the shipping information already retrieved. The correct move writes the part already obtained into the result, marks the status failed, and sets a degraded flag, letting the layer above decide how to word it. The lab's third self-check is that scene: invoicing needs seven steps, hits the budget at the fifth, and returns what it found plus a degradation note, while the shipping subtask in the same round completes normally.

executor.js
const TOOL_BUDGET = 5 // per subtask: a bounce is billed too, or a graph with a loop has no ceiling
 
export async function runTask(task) {
  const observations = []
  let used = task.toolCalls
  for (const tool of toolsFor(task)) {
    // An exhausted budget does not throw: throwing escalates "half done" into total failure
    if (used >= TOOL_BUDGET) return degrade(task, used, observations, `budget exhausted, stopped at ${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('; ') : 'nothing found'
  return { ...task, toolCalls: used, result: `(degraded) ${why}. So far: ${partial}`, status: 'failed' }
}

Send three reporters out at once and you pay three extra bills

Fan-out is one sentence in the graph: the edge after the Planner dispatches as many Executors as there are items at runtime. LangGraph expresses it with Send — the conditional edge returns not a node name but a run of "go execute this node with this piece of work" instructions; the subtask count is unknown until runtime, so that edge must be dynamic.

The benefit is direct: three items at two seconds each is six serial and two parallel. But parallelism is not free and you pay three extra bills.

Bill one: concurrency needs a ceiling. Three items reveal nothing, and the day the model splits twenty for you, twenty requests hit the vendor in the same instant and you collect a wave of 429s. What the ceiling should be depends not on how fast your machine is but on your quota and how much concurrency the downstream tolerates — a business constraint, not performance tuning. The lab sets 2, and the self-check shows the concurrency peak really is 2 rather than 3.

Bill two: one failed subtask must not fail the whole request. This is especially easy to trip under a framework: every fanned-out Executor is a node in the graph, any node throwing rejects the whole graph, and the two items already finished in the same round go down with it. So exceptions must be translated into state at the node boundary: catch it, mark that subtask failed, set degraded, and deliver the rest as usual. The lab's fifth self-check is that: the claims service returns 503, the claims item degrades, and the shipping item delivers normally.

Bill three is the previous section's reducer. Parallel writes to shared state need a merge rule — the most insidious of the three, because the first two at least error or slow down, while this one makes no sound.

fanout.js
const CONCURRENCY = 2 // the ceiling comes from your quota, not your machine
 
// Promise.all fires all 20 requests at once, so run a fixed number of workers pulling
// from a queue instead
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) {
        // The failure is confined to this one item: the rest deliver as usual
        results.push(degrade(task, task.toolCalls, [], String(error)))
      }
    }
  })
  await Promise.all(workers)
  return results
}

How to reject copy usefully, and how the editor also misjudges

The Critic is the easiest of today's three roles to botch, because it looks simplest: have the model judge pass or fail. It has three typical failure modes and all three need guarding.

One: bouncing forever. Every revision earns a new complaint and the page never ships. So a hard ceiling is mandatory — this course takes at most 2 bounces, so 3 executions with the first. The ceiling's purpose is not saving money, it is guaranteeing this flow terminates.

Two: bouncing without saying anything useful. The editor replies only "not good enough," the reporter gains no actionable information, and the second draft is the first resubmitted, so the ceiling is inevitably reached and triple the money burned. A bounce must carry a specific reason, written as something directly actionable such as "the output does not state the refund conclusion, please add it," and then that reason goes back into the subtask's goal for the executor. In the lab the refund proposal's first version only listed the facts found with no conclusion, was bounced once, and passed with the conclusion added — the loop genuinely works rather than going through the motions.

Three, the most dangerous and least discussed: when Critic and Executor share a model and a prompt, it tends to approve its own output. One model's preferences about what counts as a good answer are consistent, so asking it to review what it just wrote gets approval. The symptom is an absurdly high pass rate, and you believe quality is high while the step is effectively absent. Three mitigations, best value first: give the Critic checkable acceptance requirements (this course's approach: the output must state a specific thing, an objective criterion rather than a feeling); have the Critic use a different model, even a cheaper one; and make the review item-by-item scoring rather than one verdict, since the more specific the items the harder it is to fudge. D21's evaluation chapter expands this fully, where it has a name: same-source bias.

critic.js
const MAX_REVIEW_ROUNDS = 2 // at most 2 bounces, 3 executions with the first
 
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 {} // all passed: write nothing, the edge sends it to assembly
 
  const byId = new Map(tasks.map((t) => [t.id, t]))
  if (state.reviewRounds >= MAX_REVIEW_ROUNDS) {
    // Ceiling reached: stop bouncing and return what exists. Bouncing forever means the
    // user never gets a reply
    return {
      degraded: true,
      workspace: rejected.map((r) => ({ ...byId.get(r.id), status: 'failed' })),
    }
  }
  // A bounce writes the reason into the goal: given only "not good enough", the executor
  // resubmits the same thing
  return {
    reviewRounds: state.reviewRounds + 1,
    workspace: rejected.map((r) => ({
      ...byId.get(r.id),
      status: 'pending',
      goal: `${baseGoal(byId.get(r.id).goal)} | review note: ${r.reason}`,
    })),
  }
}

The deadline: after two rejections the page ships

A newspaper has a deadline. If the copy is still imperfect when it arrives, the editor does not halt the presses indefinitely, they ship with what they have and add a line saying the paper will follow up. That line is the degraded flag.

So every ceiling today — 5 tool calls per item, at most 2 bounces — takes the same exit when exhausted: return what exists, degraded, without throwing. It is counterintuitive: a programmer's instinct is to throw when something cannot be done, and inside an agent throwing means the user waited six seconds for "service error" when in truth they only missed one of three items. Two and a half items with a note about the missing half is far more useful than an error page.

The degraded flag's value lies here too: it makes degradation an observable, countable fact rather than a sentence buried in a log. The layer above can decide whether to escalate to a human; monitoring can plot a degradation rate; and when D21 evaluates, the degradation rate is itself a core metric — a system degrading thirty percent of the time and one degrading three percent may average the same score and are not the same thing at all.

One more fuse to know about: LangGraph carries a recursion limit and throws when the graph bounces between nodes past a certain step count. It is the last line of defense, and do not use it as your business ceiling — first, it is graph-wide and does not tell you which loop lost control; second, it throws when triggered so you lose even the results you had, exactly contrary to the rule above. The lab's exercise 3 deliberately preserves that scene: implement the bounce but forget the ceiling and the self-check prints that exception and tells you what it is.

Finally, string today's graph together and answer "how would you write it without a framework": you would maintain a to-do list yourself, write the concurrency gate yourself, decide overwrite-versus-append at every merge yourself, count bounces yourself, and ensure an exception cannot capsize the batch yourself. The framework gives each of those five a place — Send, a concurrency setting, a reducer, a state field, and the node boundary. A framework's value is not writing less code, it is giving those five things separate homes rather than crowding into one function to fight.

Source Reading

Hands-On Lab

🧪 D17 lab: task splitting in parallel plus a review-and-rewrite loop

Code location: labs/agent-30days/day-17-planner-executor-critic

Acceptance criteria:

  1. All five self-checks under MOCK=1 SELFTEST=1 pnpm start pass with exit code 0 (starter/ as-is is 1 pass and 4 failures, each naming its exercise).
  2. Checks 1 and 2 are one comparison: with the concat reducer, 3 subtasks leave 6 records and 3 duplicate ids, and the first record found by id is the stale pending version; switching the same graph to update-by-id gives 3 records, 0 duplicates, all done. The concurrency peak also shows as 2 rather than 3.
  3. Check 3: the invoicing subtask needs 7 tool calls and degrades on hitting the budget of 5 — toolCalls stops at 5, status is failed, degraded is true, the result carries the part already found, and the process throws nothing; the shipping subtask in the same round completes as done.
  4. Check 4: the refund proposal is bounced once, adds a conclusion, and passes (reviewRounds 1, degraded false, and the shipping subtask in the same round is not rerun by association); the complaint ticket can never produce a ticket number, is bounced twice for 3 executions total, stops, and returns what exists with degraded true.
  5. Check 5: when the claims service returns 503, the claims subtask is failed, the shipping subtask is still done, and the whole graph does not reject.

Today still has no infrastructure dependencies, so this lab has no docker-compose.yml: the fan-out, the merging, and the review loop are all in-process. src/shared/state.ts comes from D15 unchanged with no field added. The only network egress is the model call, and under MOCK=1 all three kinds of fake reply vary with the input — a different order number yields different amounts and tracking events. Run it as-is first; those four failures are your to-do list.

  1. Run MOCK=1 SELFTEST=1 pnpm start as-is and study the six records and three duplicate ids check 1 prints — that is today's symptom to fix.
  2. Exercise 1, write upsertWorkspace as update-by-id, taking check 2 from 6 duplicated records to 3 clean ones.
  3. Exercise 2, add the budget check to runTask, taking check 3's invoicing subtask from a full 7 calls to stopping at 5 and degrading.
  4. Exercise 3, make the Critic genuinely review, bounce with a reason, and set its own ceiling, so check 4 shows the refund passing after one bounce and the complaint stopping after two.
  5. Exercise 4, catch tool exceptions at the Executor node boundary, taking check 5 from "the whole graph rejects" to "only the claims item fails."

Interview Questions

Today's four questions are in the bank below, weighted toward reflective self-correction, budget control, and infinite-loop guards, and the second, on avoiding conflicts when writing shared state in parallel, is this chapter's most valuable and the one you will certainly be pressed on if multi-agent appears on your resume. Expand a question and read the analysis before the key points; practicing the derivation beats memorizing the answer. Each is tagged for the China-domestic or overseas market so you can prioritize by where you are applying.

Checklist and Tomorrow

  • Implement a Planner that splits a task into subtasks and writes them into the shared workspace
  • Implement an Executor that runs subtasks in parallel while respecting the toolBudget limit
  • Implement the full Critic loop that checks the result and bounces it back for a rewrite
  • Say what happens when parallel writes hit one field with no reducer, and why it does not error
  • Recite the three bills of a parallel fan-out, and say why an exhausted budget or retry ceiling degrades rather than throws
  • All 5 acceptance criteria of the lab pass (all five self-checks green)
  • Answer at least 3 of the 4 interview questions without looking at the key points

Tomorrow (D18) covers two things today forced. First, two or three rounds of the review loop leave messages already long — history compression is harder in a multi-agent setting than a single-agent one, because a summary losing "who said it at which step" leaves the Critic unable to judge, and that is called history fidelity. Second, all of this lives in memory, so a crashed process redoes everything and nine model calls' worth of money is wasted — hence checkpoints. Why after the collaboration patterns? Because only after genuinely running a chain that bounces and redoes do you feel how expensive "it cannot be saved" is.

Interview questions

  • What problem does the Planner-Executor-Critic structure solve, and how is it different from Supervisor routing?Planner-Executor-Critic 这种结构解决了什么问题?它和 Supervisor 路由的区别在哪?
    Common in ChinaCommon overseasBasic#multi-agent#orchestration#architecture

    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.

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

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

    答题要点

    • Supervisor 是分叉(一次派一个人),Planner-Executor-Critic 是扇出加汇合加回边(拆成几件并行做,做完有人验收)
    • 三条适用判据:一次只需一个专家用路由;能拆成互不依赖的几件才扇出;对错有明确判据且重做便宜才加评审
    • 拆解的代价是模型调用从一次涨到七到九次、延迟由最慢的分支决定,而并行只省延迟不省成本
    • 评审判据能用代码判就别用模型判,Critic 只在需要理解语义时才值一次模型调用
  • 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?多个子任务并行执行、都要写同一份共享状态时,怎么设计才不会互相覆盖?
    Common in ChinaCommon overseasDeep dive#multi-agent#state-management#concurrency

    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.

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

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

    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

    答题要点

    • 图的执行模型里没有并发写内存:节点各返回增量,框架按字段收集后调用该字段的 reducer 合并,所以问题是 reducer 写得对不对,不是加不加锁
    • 判断链:同一轮几个写者、写的是不是同一条记录——单写者用覆盖、多写者写不同记录用追加、多写者写同一条记录用按主键原地更新、写同一条记录的不同字段要字段级合并
    • 最常见的错是把「更新」写成「追加同 id 的新版本」,症状是同 id 两份、顺序取决于谁先跑完、按 id 查会拿到过期版本,而且全程不报错
    • 另一条路是每处读取前手动去重,但读取点有好几处,漏一处就是偶发脏读;reducer 只写一次就一劳永逸
    • 合并规则最好对顺序不敏感;做不到就必须保证一轮里一条记录只有一个写者
  • Why give each subtask a tool-call budget, and what do you do when it runs out?为什么要给每个子任务设 toolBudget 这样的预算?超了预算之后你会怎么处理?
    Common in ChinaCommon overseasIntermediate#cost-control#reliability#agent-design

    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.

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

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

    答题要点

    • 子任务卡住的典型形态是反复查而不是报错,模型会把额度花光为止;整轮封顶是外层闸,子任务预算是内层闸,细粒度让你能定位到是哪一件失控
    • 预算必须是子任务级而不是单次执行级,否则被评审打回两次实际额度就翻三倍
    • 超限必须降级返回已有结果并标记,不能抛错——抛错把「做了一半」升级成「整个请求失败」,用户连已经查到的部分都拿不到
    • 降级标记让降级率变成可统计指标,上层据此决定转人工,评估据此区分两个平均分相同的系统
    • 预算大小从这件事正常需要几次工具调用反推并留余量,同时配一个时间或 token 维度的闸
  • How do you keep a Critic review loop from spinning forever, and what else needs guarding besides a retry cap?Critic 的评审回路怎么防止陷入死循环?除了次数上限还有什么要防的?
    Common in ChinaCommon overseasIntermediate#reflection#loop-guard#reliability

    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 theater. 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.

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

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

    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 theater
    • 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

    答题要点

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

Comments