逐日AI
第 3 周 · D18约 4 小时

hooks 与后台任务:生命周期钩子、确定性检查与不打断对话的通知

有些事不该交给模型判断:实现生命周期钩子,在工具执行前后插入确定性的检查与格式化,再实现后台任务,让长时间运行的命令不阻塞对话、结束时把结果通知回 REPL。

今日目标 0/3

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

今日目标

  1. 能设计钩子的触发点与输入输出协议,并说清它能阻断什么、不能阻断什么
  2. 能实现后台任务的启动、状态查询与结束通知,且不打断正在进行的对话
  3. 能判断哪些约束该交给钩子、哪些该交给提示词或权限规则

前十七天都在让模型做得更好,今天第一次反过来:不让它有机会做错。做完之后回到页面顶部把三条目标勾掉。

小白版讲解

车间的自动检查工位

一条流水线上,每道工序后面都有一个自动检查工位:工件出来先过一遍卡尺,尺寸不对当场停下。这个工位不需要聪明,它只会量一个数、和一个阈值比一比。它存在的理由恰恰是它不聪明——一个只会量卡尺的工位,量一万次的结果是一样的;一个会看会想的老师傅,第一万次的时候会累。

前十七天我们给 mca 加的全是「让它更聪明」的东西:更多工具、更贴的上下文、别人的经验、分头干活的帮手。这些东西都在把结果往好里推,但它们推的是概率。一条写在系统提示里的规矩——比如「改文件之前必须先读一遍原文」——模型大部分时候会遵守。问题就出在「大部分时候」这四个字上:它偏偏会在一个已经被别人动过的文件上凭记忆下手,然后精确替换替到了错的地方,而这类错误在代码评审里是看不出来的,因为它看起来完全正常。

今天要加的就是那个卡尺工位:不判断、不推理,只在工具执行之前和之后各拦一道。上一天是把一整个模型派出去干活,今天正相反——把一部分判断从模型手里收回来,交给代码。 讲到第十八天才讲这件事是有道理的:你得先见过模型能干多少活,才会明白哪些活不该让它干。

确定的规则不要交给模型判断

先把判据说死,因为后面所有设计都从它长出来:一条规则如果你能用一个 if 判断出来,它就不该交给模型去记。

提示词能让模型「通常」做对,钩子让它「总是」做对。这两个词之间的差距,就是这一整套机制存在的全部理由。代价也很清楚:钩子只能表达能被写成代码的东西,而「这段代码写得好不好」永远写不成代码。所以两者不是替代关系,是分工——提示词管判断,钩子管纪律。

这条分工在第十六天出现过一次,当时说的是「手艺卡管判断,代码管纪律」;今天是同一条判据的第二次落地。

还要跟第五天的权限规则划清界线,因为它们长得很像。权限规则回答「谁有权做这件事」,钩子回答「这件事此刻做得对不对」。 权限规则是一张按工具名与路径匹配的静态表,它表达得了「不许改 test 目录下的文件」,却表达不了「这个文件本轮读过没有」——后者依赖会话此刻的状态。凡是判据依赖状态或写入内容的,都只能是钩子。

四个触发点,只有一个能说不

一次会话里有边界的时刻不多,本课取了四个:

放行 否决 会话开始 session_start 用户说一句话 模型要调工具 工具执行前 pre_tool 工具真的执行 工具根本没跑回灌一条失败结果 工具执行后 post_tool 结果回灌给模型 会话结束 session_end
Mermaid 源码
mermaidmermaid
flowchart TD
  A[会话开始 session_start] --> B[用户说一句话]
  B --> C[模型要调工具]
  C --> D[工具执行前 pre_tool]
  D -->|放行| E[工具真的执行]
  D -->|否决| F[工具根本没跑<br/>回灌一条失败结果]
  E --> G[工具执行后 post_tool]
  G --> H[结果回灌给模型]
  F --> H
  H --> I[会话结束 session_end]

四个里只有 pre_tool 能否决,这一条必须记死。否决的意思是「这件事没有发生」,而工具跑完之后文件已经改了、命令已经执行了,那时候说「不行」是自欺。

所以 post_tool 的「阻断」是另一件事:它不撤销任何东西,它只是把这次调用判成失败,好让第六天那套自纠机制接手——模型看到的不是「成功了」,而是一份具体的失败报告,于是它自己回头收拾。实验里那个跑测试的钩子就是这么工作的:模型把守卫写在了错的条件上,文件确实被改了,但这次调用被判成失败,测试输出原样回灌,它下一步就改对了。把这两种「阻断」混为一谈,是设计钩子协议时最常见的一个错误,混了之后你会写出一个以为能撤销写入、实际什么也没撤销的机制。

协议:输入什么,失败了算谁的

钩子的输入里有一个细节值得说:传给它的参数是已经解析好的对象,不是原始的 JSON 文本。让每个钩子自己解析一遍,等于把第三天就处理过的「参数可能不是合法 JSON」复制到每一个钩子里,而它们的处理方式必然各不相同。

输出里有两个容易写混的位:钩子自己跑成功了没有,和要不要拦住这次调用,是两件事。 一个格式化钩子没找到可格式化的文件,它失败了,但那不该拦住任何东西。拦不拦由钩子声明的 blocking 决定,钩子本身只报告自己的结果。

还有两件事必须在执行层兜住,它们都只在钩子写坏的时候才暴露:

src/hooks/run.ts
async function runOne(hook: HookDef, input: HookInput) {
  const startedAt = Date.now()
  // 超时算失败而不是算通过:把跑不完的检查当成通过,
  // 等于在最需要它的时候(工作区大到检查跑不完)悄悄关掉了它
  const timeout = new Promise<HookOutcome>((resolve) => {
    setTimeout(() => resolve({ ok: false, note: '超时' }), hook.timeoutMs).unref()
  })
  try {
    return { outcome: await Promise.race([hook.run(input), timeout]), ms: Date.now() - startedAt }
  } catch (error) {
    // 钩子抛异常也只是「这个钩子失败了」,循环那边一行都不用改
    const message = error instanceof Error ? error.message : String(error)
    return { outcome: { ok: false, note: `钩子抛异常:${message}` }, ms: Date.now() - startedAt }
  }
}

一条要点:Promise.race 停不住任何东西。JavaScript 没法从外面掐断一个正在 await 的函数,那个超时只保证调用方不会一直等下去。真正能停住的是钩子自己传给子进程的那个超时——本课两个耗时钩子跑的都是子进程,所以它们真的停得下来。Python 那边 wait_for 会取消协程,干净一些,但如果协程里也是在等一个子进程,同样要给子进程自己一个超时。

兜住异常背后是同一条纪律:一个零件坏了,最坏的后果应该是少一个零件。 第十五天对掉线的服务端、第十六天对写坏的技能包,用的都是这句话。

钩子的输出要不要回灌

这是今天第二个要想清楚的判断题。钩子跑完有话说,这话是打给用户看,还是也塞进给模型的那条工具结果里?

判据只有一条:模型接下来的决策会不会因为知道它而不同。

格式化钩子清掉几个行尾空格,模型知道了也不会做别的事——但它一旦真的改动了文件内容就必须说,否则模型下一次精确替换会拿改之前的原文去匹配。测试红了它必须知道,否则会带着「已经修好了」的判断往下走。而「工作区看起来是对的」这种开场检查,说了纯属占地方。

回灌的实现有一个不用改协议的办法:把钩子的话接在工具结果的正文后面。ToolResult.content 本来就是「回灌给模型的文本」,钩子的输出天然属于那里。

src/hooks/wrap.ts
const result = await tool.run(args, ctx)
const post = await runHooks(selectHooks(state.hooks, 'post_tool', tool.name), {
  event: 'post_tool',
  tool: tool.name,
  args,
  result,
  cwd: ctx.cwd,
})
if (post.feed.length === 0) return result
return {
  ...result,
  // 阻断型钩子失败时把这次调用判成失败。它撤销不了任何东西,
  // 只是让模型知道「你刚才那一手没成」,好让它自己收拾
  ok: post.denied ? false : result.ok,
  content: `${result.content}\n\n钩子结果:\n${post.feed.join('\n')}`,
}

实验里能看到这条回灌真的改变了模型的行为:钩子替它跑完测试并回灌了「四个用例全绿,不需要你再跑一遍」,于是那一轮里它没有再调一次 run_command。少了这句话,你会看到工具清单上多出一次完全重复的测试。

否决那一支的失败信息也有讲究:里面必须写清「原样重试不会有不同结果」。模型在第六天学到的是「失败就再试一次」,而确定性规则重试一万次结果都一样。少这一句,你会看到它对着同一堵墙撞三次,然后被打转检测拦下。

挂在哪:三种做法选了最不起眼的那种

第一种是改循环,在执行工具前后各插一段。否决了:循环已经管着退避、上限、打转、审批四件事,再塞一件它就不是循环,而是那个什么都干的文件。第二种是给事件协议加两个新事件类型。也否决了:渲染层根本不需要知道钩子怎么跑,这和第五天审批没进协议是同一条口径。

第三种是装饰工具定义本身。钩子做的事就是「工具执行前后多做一点」,而工具定义里那个 run 正好就是「工具执行」这件事。选它之后全天的接线只有一行,还白捡一个好处:远端工具、技能附带的脚本、上一天那个派发子 Agent 的工具、以后新加的任何工具,一个字都不用改就自动带上了钩子——它们本来就都是同一个接口。

后台任务:进程、日志、状态查询

换一个题目。有些命令就是要跑很久——跑一整套端到端测试、装一遍依赖、等一次构建。前台跑它,整个对话就卡在那儿。

后台任务三件套缺一不可:进程要自成一个进程组,父进程不等它;日志必须落盘,因为没人看着它,输出可能有几万行、也可能半小时后才产生;状态要有一张能查的表——这件最容易漏,没有它,一个后台任务就是你启动完就再也找不到的东西,而「找不到」在用户那里表现成「它好像根本没干活」。

给模型的那个工具叫 run_background,它的描述里有两句是必须写的:返回的是受理回执不是执行结果,以及在系统告诉你它结束之前不许说它成功了。模型见过的工具几乎都是调用完就拿到答案,不明说的话,它会拿着一个回执告诉用户「测试已经跑完了」。

进度走另一条路:实验里开了一个只绑本机的小接收端(端口 3118),任务想报就往那儿报一次。为什么不直接解析它的标准输出——标准输出是给人看的日志,不是给程序读的协议,想从日志里解析进度就得约定前缀、写解析器,而任务打一行无关的话就能把它骗过去。还有一条口径:进度是拉的,完成是推的。 每秒报三次进度都推通知,终端会被刷爆。今天只讲进程与通知,两个任务会不会互相踩是上一天隔离那一节的题目。

通知怎么回到一个正在等输入的终端

这是今天第二难的地方。后台任务会在任意时刻说话,而终端是单线程的,它任何时刻只处于三种状态之一,能不能插话完全不同:

此刻的状态能插话吗为什么
正在流式输出绝对不能打字机是一个字一个字往同一行写的,插一行会把那段话撕成两半
正在等审批或等回答不能用户正盯着一个问题准备回答,冒一行「任务完成」会被当成问题的一部分
正在等用户输入有条件地能只有输入行还是空的时候才行,否则会冲掉他敲了一半的字

所以通知队列的默认动作是攒着,只在两个安全时刻放出来:一轮对话结束之后,以及「正在等输入且输入行为空」这一刻。

src/tasks/notify.ts
push(notice: Notice): void {
  this.forModel.push(notice)
  // 能插话就当场打,打完把提示符重画回来;不能就攒着
  if (this.sink?.canInterrupt()) {
    this.sink.out(`\n${notice.text}`)
    this.sink.redraw()
    return
  }
  this.pending.push(notice)
}

代码里那个 forModel 是第二条路,它不能省:给人看的通知和给模型看的通知要分开攒。 屏幕上的字不在消息数组里,模型看不见。少了它,用户会遇到一个很别扭的现象——明明看到了「任务完成」,一问模型却说「还在跑」。实现只是在下一轮开头把攒下的状态附成一条消息,三行代码。

这套东西的代价:它会拖慢每一次编辑

一个机制的成本要摆出来才算讲完。钩子的成本是明确的:它拖慢的是每一次工具调用,而这笔账要乘以调用次数。

实验里那个跑测试的钩子,在只有四个用例的沙盒仓库上是一百多毫秒(这个数不可复现,每台机器每次都不同,只看量级)。一次编辑多花这点时间不值一提,改二十次文件就是两三秒;换成跑全量测试要三分钟的真实仓库,同一个钩子会让这个 Agent 彻底没法用。

所以它在真实项目里必然要改形状:要么只跑受影响的那几个文件,要么挪到提交前那个触发点上——改二十次只检查一次。这是选触发点时就该算清楚的账:越靠近每一次工具调用,检查越及时,成本也越高。正因如此,每个钩子跑了多久必须打在屏幕上——不打的话用户只会觉得「这东西今天怎么这么慢」。看不见的成本等于没人管的成本。

源码导读

动手实验

🧪 D18 实验:一套生命周期钩子与后台任务机制

代码位置:labs/my-coding-agent-21days/day-18-hooks

实验里挂了六个钩子覆盖四个触发点:一对「先读后改」的记录者与守卫、格式化、跑测试、开场环境检查、收摊提醒。后台那一摊有任务表、日志、进度接收端与两条斜杠命令。五个练习点全是「不写也能跑、写了才靠谱」的地方:超时与兜异常、否决语义、回灌、日志落盘、通知排队。起点代码原样跑是十四项里过五项。

  1. 给钩子加上超时与兜异常,看第二、三项从红变绿——注意超时要算失败,不能算通过。
  2. 把算出来却没人理的否决接上,看模型凭记忆改文件那一手被拦下,而且文件一个字节都没变。
  3. 把钩子的输出回灌进工具结果,并在阻断时把这次调用判成失败,看模型自己回头改对。
  4. 把后台任务的输出接到日志文件上,看 /tasks 带上任务号能读到它的输出。
  5. 把通知从「来一条打一条」改成排队,跑 MOCK=1 SELFTEST=1 pnpm start 看到十四项全过,再用 README 里那几条管道命令看三个现象。

验收看五条勾:自检十四项全过;没读过就改被拦下且文件没变;改错之后那次调用被判成失败并回灌了测试输出;后台任务的进度经 3118 报到三分之三、日志可回看;端到端那一轮的三次工具调用里没有 run_command——测试是钩子替它跑的。

面试题

今天三道题,考的是确定性与概率性的分工,以及异步机制的收尾,不是「什么是 hook」:

  1. 哪些约束该用钩子实现,哪些该写进提示词?判据是什么?
  2. 钩子怎么设计才能既能否决又不至于把 Agent 卡死?
  3. 长时间运行的命令放到后台,状态与结果怎么回到会话里?

完整题干、分析过程与答题要点见本课面试题库的第十八天。第二题最有区分度——多数人只答得出「加超时」,能说出「超时要算失败」「阻断要有默认姿态」「失败信息里必须写重试没用」这三层的人很少。

检查清单与明日预告

  • 能说清「能用一个 if 判断出来的规矩就不该交给模型去记」这条判据,以及钩子与第五天权限规则的分界
  • 能说清四个触发点里为什么只有工具执行前那个能否决
  • 能解释工具执行后的「阻断」到底是什么意思,它撤销不了什么
  • 能说出否决为什么要走「返回一条失败结果」而不是抛异常,以及失败信息里必须写什么
  • 能说清钩子的输出该不该回灌给模型的那条判据,并各举一个例子
  • 能说出钩子超时为什么要算失败,以及 race 其实停不住什么
  • 能解释为什么钩子选择装饰工具定义,而不是改循环或扩事件协议
  • 能说出后台任务三件套,以及漏掉状态查询那一件会怎样
  • 能说清终端的三种状态,以及通知为什么只能在其中一种里插话
  • 能解释为什么给人看的通知与给模型看的通知要分开攒
  • 能算出钩子的成本账,并说出触发点越靠前成本为什么越高

明天是 D19《多模态输入:粘贴截图、图片校验与照图改代码》。今天让 mca 多了一层不讲道理的纪律,明天让它多一个感官——而那条路上第一个坑是:不支持看图的模型不会报错,它会编一个答案。

面试题库

  • 哪些约束该用钩子实现,哪些该写进提示词?判据是什么?Which constraints belong in hooks and which belong in the prompt? What is the criterion?
    国内高频海外高频基础#hooks#prompt-engineering#agent-design

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

    1. 这题在考「你知不知道提示词的可靠性上限」。没实现过的人会答「重要的写提示词,非常重要的写代码」,这是同义反复;实现过的人手里有一条能当场判的判据。
    2. 怎么拆:先给判据,再说两边各自的代价,最后补一条容易被混进来的第三方(权限规则)。
    3. 判据:一条规则如果你能用一个 if 判断出来,它就不该交给模型去记。提示词让模型「通常」做对,钩子让它「总是」做对,这两个词之间的差距就是钩子存在的全部理由。
    4. 两边的代价要都说:钩子只能表达能被写成代码的东西,「这段代码写得好不好」永远写不成代码,所以提示词不可能被替代;而钩子多了会拖慢每一次工具调用,这笔账要乘以调用次数。
    5. 还要跟权限规则划界,它们最容易混:权限规则回答「谁有权做这件事」,是一张按工具名与路径匹配的静态表;钩子回答「这件事此刻做得对不对」,判据可以依赖会话状态与写入内容。「这个文件本轮读过没有」就是权限规则表达不了的。
    6. 举一个具体的对照最能说明问题:「改文件之前先读一遍原文」写在提示词里是大部分时候遵守,做成钩子是百分之百,而实现代价只有十几行。
    7. 可预期的追问:一条规则同时能写成两者时怎么选;钩子写错了怎么办(要有总开关);提示词里已经写过的规矩做成钩子之后要不要从提示词里删掉。

    How to reason about it · think before answering

    1. This tests whether you know the reliability ceiling of a prompt. People who have not built one answer "important things go in the prompt, very important things go in code", which is circular; people who have built one carry a criterion they can apply on the spot.
    2. How to break it down - state the criterion, then the cost on each side, then separate out a third mechanism people tend to conflate with hooks.
    3. The criterion - if a rule can be decided by a single if statement, it should not be left to the model to remember. A prompt makes the model usually right; a hook makes it always right, and the gap between those two words is the entire reason hooks exist.
    4. Name the cost on both sides. A hook can only express what can be written as code, and "is this code any good" never can, so prompts cannot be replaced. Meanwhile every hook slows down every tool call, and that bill is multiplied by the number of calls.
    5. Separate hooks from permission rules, which are the easiest thing to confuse them with. Permission rules answer who is allowed to do this and are a static table matched on tool name and path; hooks answer whether this is the right thing to do right now, and may key off session state or the content being written. Whether this file has been read in this session is something a permission table cannot express.
    6. A concrete contrast carries the point - read the file before editing it is usually obeyed when it lives in the prompt, and always obeyed when it is a hook, at a cost of a dozen lines.
    7. Likely follow-ups - how to choose when a rule could be either; what happens when a hook itself is wrong (there must be a master switch); whether to delete a rule from the prompt once it becomes a hook.

    答题要点

    • 判据:能用一个 if 判断出来的规则就不该交给模型去记;提示词给「通常」,钩子给「总是」
    • 钩子的表达力有限,凡是需要判断好坏的仍然只能靠提示词,两者是分工不是替代
    • 钩子与权限规则的分界:权限管「谁有权做」,是静态表;钩子管「此刻做得对不对」,判据可依赖状态与内容
    • 钩子的代价是拖慢每一次工具调用,而且乘以调用次数
    • 做成钩子之后提示词里那句通常仍要留着,让模型知道有这条规矩,免得它撞上去才知道

    Key points

    • The criterion - a rule decidable by one if statement should not be left to the model; prompts give you usually, hooks give you always
    • Hooks have limited expressive power; anything requiring a judgment of quality still needs the prompt - they divide work rather than replace each other
    • Hooks versus permission rules - permissions answer who may act and are a static table; hooks answer whether the act is right now and may key off state and content
    • The cost of a hook is that it slows every tool call, multiplied by the number of calls
    • Usually keep the sentence in the prompt too, so the model knows the rule exists instead of discovering it by being blocked
  • 钩子怎么设计才能既能否决又不至于把 Agent 卡死?How do you design hooks so they can veto a call without deadlocking the agent?
    国内高频海外高频深入#hooks#reliability#failure-handling

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

    1. 这题在考「你有没有被自己写的钩子坑过」。没实现过的人答「加个超时」就没了;实现过的人会分三层答:否决语义、失败姿态、执行层的兜底。
    2. 怎么拆:先说清否决只在哪个触发点成立,再说否决怎么表达,最后说执行层必须兜住的两件事。
    3. 第一层,否决只在工具执行之前成立。执行之后文件已经改了、命令已经跑了,那时候说「不行」是自欺——执行后钩子的「阻断」只能是「把这次调用判成失败」,让模型自己回头收拾,它撤销不了任何东西。把这两种阻断混为一谈是设计这套协议最常见的错误。
    4. 第二层,否决要表达成一条失败的工具结果而不是抛异常。抛异常会被注册表兜成一句「执行失败」,模型看不出发生了什么就会原样重试;返回一条写清原因与下一步的结果,自纠机制就自动接手了。失败信息里必须写「原样重试不会有不同结果」——模型学到的默认动作就是重试一次。
    5. 第三层是防卡死的两件事:每个钩子必须有超时,而且超时要算失败不算通过(算通过等于在最需要检查的时候悄悄关掉检查);钩子抛的异常必须兜在钩子这一层,一个写坏的钩子最坏的后果应该是少一个检查。还要知道 race 之类的写法停不住正在跑的东西,真正能停的是传给子进程的那个超时。
    6. 最后一层是姿态问题:默认不该是阻断。一个动不动就阻断的钩子会让 Agent 变成走不动路的东西,而那种故障最难查——用户看到的只是「它今天什么都不肯做」。所以还要有一个总开关,让人当场关掉再继续干活。
    7. 可预期的追问:多个钩子是串行还是并行;阻断型钩子失败之后后面的钩子还跑不跑;钩子自己需要调模型时怎么办。

    How to reason about it · think before answering

    1. This tests whether your own hooks have ever bitten you. People who have not built them stop at "add a timeout"; people who have answer in three layers - veto semantics, failure posture, and what the execution layer must catch.
    2. How to break it down - say where a veto is even possible, then how a veto should be expressed, then the two things the execution layer must guarantee.
    3. Layer one - a veto is only possible before the tool runs. Afterwards the file is already changed and the command already executed, so saying no is self-deception. A post-tool hook's block can only mean marking the call as failed so the model cleans up after itself; it undoes nothing. Conflating the two is the most common mistake in this protocol.
    4. Layer two - express a veto as a failed tool result, not a thrown exception. A throw gets flattened into "execution failed" by the registry, the model cannot see what happened, and it retries verbatim. Returning a result that states the reason and the next step lets the self-correction loop take over. That message must say that retrying identically changes nothing, because retry is exactly the default behavior the model has learned.
    5. Layer three - two guarantees against deadlock. Every hook needs a timeout, and a timeout must count as failure rather than success; counting it as success silently disables the check precisely when it matters most. And exceptions must be caught inside the hook layer, so a broken hook costs you one check rather than the whole agent. Also know that a race-style timeout does not stop the work in flight - only the timeout handed to the child process does.
    6. The last layer is posture - blocking should not be the default. A hook that blocks readily turns the agent into something that refuses to move, and that failure is the hardest to diagnose because all the user sees is that it will not do anything today. So there must also be a master switch to turn hooks off and keep working.
    7. Likely follow-ups - serial or parallel execution; whether later hooks still run after a blocking one fails; what to do when a hook itself needs to call a model.

    答题要点

    • 否决只在工具执行前成立;执行后的「阻断」只是把这次调用判成失败,撤销不了任何东西
    • 否决表达成一条 ok 为 false 的工具结果,不要抛异常,让自纠机制接手
    • 失败信息里必须写清「原样重试不会有不同结果」,否则模型会照默认动作重试
    • 每个钩子必须有超时,超时算失败不算通过;异常兜在钩子这一层,坏一个钩子只损失一个检查
    • 默认姿态是只警告不阻断,并且要留一个总开关,钩子写错时能当场关掉

    Key points

    • A veto is only possible before execution; blocking afterwards only marks the call failed and undoes nothing
    • Express a veto as a tool result with ok false rather than a throw, so the self-correction loop takes over
    • The failure message must say that an identical retry changes nothing, or the model will retry by default
    • Every hook needs a timeout that counts as failure, and exceptions must be caught at the hook layer so one broken hook costs one check
    • Default to warning rather than blocking, and keep a master switch so a bad hook can be turned off on the spot
  • 长时间运行的命令放到后台,状态与结果怎么回到会话里?When a long-running command goes to the background, how do its status and result get back into the session?
    国内高频海外高频进阶#background-tasks#async#terminal-ux

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

    1. 这题在考「你有没有真的做过异步收尾」。没做过的人答「跑完打印一行」,做过的人知道难点全在「什么时候打印」和「打给谁看」。
    2. 怎么拆:先说后台任务三件套,再说通知的时机,最后说给人看与给模型看是两条路。
    3. 三件套是进程、日志、状态查询。进程要自成进程组,父进程不等它;日志必须落盘,因为没人看着它,输出可能有几万行也可能半小时后才产生;状态要有一张能查的表——这件最容易漏,没有它,一个后台任务就是启动完就再也找不到的东西。
    4. 工具返回值要说清:模型拿到的是受理回执不是执行结果,而且这句话必须写进工具描述里。模型见过的工具几乎都是调用完就拿到答案,不明说它会拿着回执告诉用户「已经跑完了」。
    5. 通知时机是真正的难点。终端在任何时刻只有三种状态:正在流式输出(绝对不能插,会把打字机撕成两半)、正在等审批或等回答(不能插,用户会当成问题的一部分)、正在等用户输入(能插,但只有输入行为空时才行,否则会冲掉他敲了一半的字)。所以默认动作是攒着,只在安全时刻倒出来。
    6. 还有一条最容易漏的:给人看的通知和给模型看的通知要分开攒。屏幕上的字不在消息数组里,模型看不见——少了这一条,用户明明看到「任务完成」,一问模型却说「还在跑」。
    7. 进度与完成的路子也不一样:进度是拉的(用户主动查),完成是推的(只发生一次,必须送到眼前)。每秒推三次进度会把终端刷爆。
    8. 可预期的追问:任务失败了要不要打断当前对话;退出时还在跑的任务怎么办;多个任务并行时怎么隔离(那是另一天的题目)。

    How to reason about it · think before answering

    1. This tests whether you have actually handled async completion. People who have not answer "print a line when it finishes"; people who have know the hard parts are when to print and who to print it for.
    2. How to break it down - the three pieces a background task needs, then the timing of the notification, then the fact that humans and the model are two separate channels.
    3. The three pieces are process, log, and status. The process gets its own process group and the parent does not wait on it. The log must go to disk, because nobody is watching - output may run to tens of thousands of lines or arrive half an hour later. And there must be a queryable status table; this is the piece people forget, and without it a background task is something you can start and never find again.
    4. Be explicit about the return value - the model gets an acknowledgment, not a result, and that sentence must be in the tool description. Almost every tool the model has seen returns an answer on call, so without saying so it will wave the receipt around and tell the user the tests are done.
    5. Notification timing is the real difficulty. A terminal is only ever in one of three states - streaming output, where interrupting tears the typewriter in half; waiting on an approval or a question, where a stray line reads as part of the question; and waiting for input, where interrupting is fine only if the input line is still empty, or you wipe out what the user half typed. So the default is to queue and drain at a safe moment.
    6. The most commonly missed piece - queue notifications for the human and for the model separately. Text on screen is not in the message array, so the model cannot see it. Without that, the user sees the task complete and then hears the model say it is still running.
    7. Progress and completion travel differently - progress is pulled, because the user asks for it, while completion is pushed exactly once and must reach the user. Pushing progress three times a second floods the terminal.
    8. Likely follow-ups - whether a failed task should interrupt the current turn; what to do with tasks still running at exit; how to isolate parallel tasks (a different day's topic).

    答题要点

    • 三件套:自成进程组的进程、落盘的日志、可查询的状态表,缺状态那件任务就找不回来
    • 工具返回的是受理回执不是结果,这句话必须写进工具描述,否则模型会拿回执当结论
    • 通知的默认动作是攒着;终端只有「正在等输入且输入行为空」这一种状态允许插话
    • 给人看的通知与给模型看的通知分两条路攒,否则模型会说「还在跑」
    • 进度是拉的、完成是推的;每次进度都推通知会把终端刷爆

    Key points

    • Three pieces - a process in its own group, a log on disk, and a queryable status table; without status the task cannot be found again
    • The tool returns an acknowledgment, not a result, and the description must say so or the model treats the receipt as the outcome
    • Queue notifications by default; the only terminal state that permits interrupting is waiting for input with an empty input line
    • Queue separately for the human and for the model, or the model will insist the task is still running
    • Progress is pulled and completion is pushed; pushing every progress update floods the terminal

评论