逐日AI
第 2 周 · D8约 6 小时

工作流引擎:把流水线做成可断点续跑的任务图

把顺序脚本换成有节点、有依赖、有状态的工作流引擎,让每步产物落盘、每步幂等,失败后能从断点继续而不是从头再烧一遍钱。

今日目标 0/3

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

今日目标

  1. 能实现一个最小的任务图执行器,按依赖调度节点并记录每个节点的状态
  2. 能让每个节点幂等:同样的输入不重复花钱,重跑只补做没做完的部分
  3. 能把每次运行的产物与花费记进台账,支持按运行标识追溯

昨天那份痛点报告上写着一个数字:第四个环节炸掉时,已经花掉的三块一毛二五会被原封不动再花一次。今天把它变成 0。读完回到页面顶部把三条目标勾掉。

小白版讲解

场记板:拍到哪一条了,重拍从哪接

片场里有一块小黑板,每拍一条就在上面写清楚:第几场、第几镜、第几次。它看起来只是给后期对素材用的,但它真正的作用是让「拍到哪儿了」这件事离开任何一个人的脑子。摄影指导中途换人、机器死机重启、第二天补拍,翻一下场记单就知道从哪接。

昨天那条流水线没有场记板。它把「进度」这个东西存在了进程的调用栈里——跑到第四步就是第四步,进程一死,前面三步做过什么、花了多少、产物在哪,全部随之消失。所以它只能从头再来。

要修好它,得先做一件更基础的事:把调度、状态、重试这三件事从业务代码里分出去

昨天的六个环节是六段依次 await 的代码,业务逻辑和执行顺序焊在一起。现在换一种写法:每个环节只声明自己是什么,不管什么时候跑

node-spec.js
// 节点只声明四件事:我依赖谁、我的输入是什么、我会产出哪些文件、我怎么跑。
// 「什么时候跑我」「我跑挂了怎么办」「我跑过没有」,一律不归它管。
export const clipsNode = {
  id: 'clips',
  title: '镜头视频',
  deps: ['frames'],
  version: 1, // 改了这个节点的实现就加一,旧产物立刻失效
  inputs: { model: 'video-model-id', shots: shots.map((s) => `${s.id}|${s.visual}|${s.durationSec}`) },
  outputs: shots.map((s) => `${s.id}.mp4`),
  async run(ctx) {
    for (const s of shots) {
      // 依赖的产物从目录里读,不从内存里传——这样节点才可能跑在别的进程上。
      const firstFrame = `${ctx.depDir('frames')}/${s.id}.png`
      const r = await video.generate({ prompt: s.visual, firstFrame, outPath: `${ctx.outDir}/${s.id}.mp4` })
      ctx.record({ nodeId: 'clips', kind: 'video', units: s.durationSec, costCny: r.costCny })
    }
    return { note: `${shots.length} 个镜头` }
  },
}

这一步的价值不在于代码变好看了,而在于节点从此可以被引擎任意摆布:可以跳过、可以重排、可以在别的进程上跑、可以在跑之前先问一句「这活儿是不是已经干过了」。而这最后一句,正是省钱的全部来源。

那么,引擎怎么才能知道「这活儿已经干过了」?这个问题的答案就是一个键,而这个键设计得对不对,决定了你的流水线是省一半的钱,还是安静地给你一集串了戏的片子。

幂等的关键是键

幂等(idempotent)这个词听着抽象,落到这条线上只有一句话:同样的输入,第二次不要再花钱。

实现它的办法是给每个节点算一个指纹,用指纹去查「这份活儿的产物在不在」。难点全在指纹里放什么。

先说必须放的四样,一样都不能少:

节点 id。 不同节点不能撞车,这个显然。

实现版本号。 你改了这个节点的提示词模板、编码参数、算法——输入没变,但产出应该变。如果版本号不在键里,你会拿着新代码读到旧产物,而且完全查不出来。这是四样里最容易漏的一样,也是最阴险的一样。

本节点的输入。 模型 id、提示词、时长、分辨率——所有会影响产物的东西。

全部依赖的指纹。 这一条是让上游变更能连锁失效的唯一机制。只哈希自己的输入,上游换了剧本你还在用旧的镜头文件,程序不报错,成片就是不对。

再说绝对不能放的:运行标识、时间戳、随机数、绝对路径。放进去等于每次都是新键,缓存永远不命中,而你会以为是缓存写坏了。「缓存不命中」和「缓存串戏」这两种病的根子都在键上,只是方向相反。

fingerprint.js
import { createHash } from 'node:crypto'
 
// 四个成分缺一不可。少了 version,改了实现读到旧产物;
// 少了 deps,上游变更传不下来;多了 runId,缓存永远不命中。
export function fingerprint(node, depFingerprints) {
  const payload = JSON.stringify({
    id: node.id,
    version: node.version,
    inputs: node.inputs,
    deps: depFingerprints,
  })
  return createHash('sha256').update(payload).digest('hex').slice(0, 16)
}

顺便说一句边界:D3 讲资产去重时说过「同样的描述不重复生成」,那是这套机制在图像这一环的具体应用;缓存到底省下多少钱、命中率低的时候怎么降级,是 D12 的话题。今天只管把键设计对。

产物落在哪:目录名就是指纹

有了指纹,产物就该按指纹落盘,这叫内容寻址(content addressing)——目录名由内容决定,而不是由「第几次运行」决定

于是磁盘上分成清清楚楚的两层:

TextText
work/
├── cache/<nodeId>/<fingerprint>/     产物。同样的输入永远写到同一个目录
│   ├── clips/a276b932372bc7c6/s01.mp4
│   └── voice/182a684769cb79e8/s01.mp3
└── runs/<runId>/
    ├── state.json                    这一次运行里每个节点的状态、耗时、花费、指纹
    ├── run.json                      成本台账
    └── output/                       从 compose 的缓存目录取出来的成片、字幕、封面

缓存是全局的,状态是每次运行一份。 这个划分值得记住,因为它对应着两种完全不同的能力:幂等来自缓存——换一个运行标识重跑,照样一分钱不花;断点续跑来自状态——知道上次跑到哪儿、哪个节点是失败而不是没轮到。

两层分开还有一个好处:运行目录变得非常轻,一次运行就是一份状态加一份台账加几个最终产物,几十次实验的运行目录加起来也没多大,而重的素材在缓存里按内容去重,同样的输入天然只有一份。

断点续跑:重跑变成一次差集

前面两节铺完,续跑这件事就没有什么神秘的了。引擎的主循环按拓扑序走一遍,每个节点问三个问题:上游有没有失败?我的产物齐不齐?跑完之后把结果落盘了吗?

engine.js
export async function runWorkflow(specs, opts) {
  const state = await loadState(opts.statePath, opts.runId)
  const fingerprints = new Map()
  const dirs = new Map()
  let failedAt
 
  for (const node of topoSort(specs)) {
    const fp = fingerprint(node, node.deps.map((d) => fingerprints.get(d) ?? ''))
    fingerprints.set(node.id, fp)
    const outDir = `${opts.cacheDir}/${node.id}/${fp}`
    dirs.set(node.id, outDir)
 
    // 上游没跑完,下游拿不到完整输入,跑了只会又一次白花钱。
    if (failedAt) {
      state.nodes[node.id] = { fingerprint: fp, status: 'blocked', ms: 0, calls: 0, costCny: 0 }
      continue
    }
 
    // 幂等判定:只看磁盘上的产物齐不齐,不看状态文件里写了什么。
    if (await allExist(outDir, node.outputs)) {
      state.nodes[node.id] = { fingerprint: fp, status: 'done', source: 'cache', ms: 0, calls: 0, costCny: 0 }
      await saveState(opts.statePath, state) // 每一步都落盘,进程被杀也只丢正在跑的那一个
      continue
    }
 
    const started = Date.now()
    const callsBefore = opts.callCount()
    try {
      const { note } = await node.run({ outDir, depDir: (id) => dirs.get(id), record: opts.onCost })
      state.nodes[node.id] = { fingerprint: fp, status: 'done', source: 'executed', ms: Date.now() - started, calls: opts.callCount() - callsBefore, note }
    } catch (err) {
      // 失败节点也要落盘:它已经花掉的钱是真的,残产物留着不删。
      state.nodes[node.id] = { fingerprint: fp, status: 'failed', ms: Date.now() - started, calls: opts.callCount() - callsBefore, error: String(err) }
      failedAt = node.id
    }
    await saveState(opts.statePath, state)
  }
  return { state, dirs, failedAt }
}

跑起来是这样的。第一次,六个节点全执行;第二次,六个节点全命中缓存,付费调用 0 次:

TextText
  ⏭ script    命中缓存 e5eba27d2715034e
  ⏭ assets    命中缓存 3d4e77e25b7af76f
  ⏭ frames    命中缓存 6a17ff8efdb8e2b8
  ⏭ clips     命中缓存 a276b932372bc7c6
  ⏭ voice     命中缓存 182a684769cb79e8
  ⏭ compose   命中缓存 be81c7a6875a8931

在第四个节点注入失败再续跑,就是昨天那个痛点的正面回答:前三个节点命中缓存、0 次调用、¥0,从第四个往后才真正执行。昨天白烧的三块一毛二五,今天是 0。

注意主循环里有一处很容易写漏:状态要在每个节点跑完之后立刻落盘,而不是等整个流程结束再写一次。 写在最后的话,进程被强杀、机器掉电、容器被驱逐,状态就全丢了——而这几件事在跑十几分钟的视频任务时并不罕见。

还有一个必须诚实说清的边界:本课的幂等粒度是节点,不是镜头。 四个镜头在同一个节点里,第三镜失败时四镜会一起重做,前两镜的钱白花了。把粒度拆到「一镜一个节点」当然更省,代价是任务图会大很多、调度也更复杂。这是并行粒度与失败半径的经典取舍,D9 会正面展开。

台账:每个节点花了谁的钱

有了节点状态,成本台账就是顺手的事——每个节点记下调用了谁、调了几次、花了多少、产出了什么。跑完打一张表:

TextText
台账(按节点)
  节点       状态  来源    耗时   付费调用   折算花费   指纹
  script   ✅    缓存     0ms        0   ¥  0.0000   e5eba27d2715034e
  assets   ✅    缓存     0ms        0   ¥  0.0000   dd68d1cf67dfdb0b
  frames   ✅    缓存     0ms        0   ¥  0.0000   6fccd700817dfc5c
  clips    ✅    执行  2666ms        3   ¥ 10.4950   14741e30ae0d75c0
  voice    ✅    执行   258ms        5   ¥  0.0193   9fa591ead4503587
  compose  ✅    执行  2730ms        0   ¥  0.0000   e4a2658619e63c9e
  合计                 5654ms        8   ¥ 10.5143
  本次真实调用明细:video ¥10.4950,tts ¥0.0193

这张表比昨天那张多了两列,而这两列正是今天的全部成果:来源告诉你这个节点是真跑了还是命中了缓存,指纹让任何一份产物都能被追溯回「哪次运行、哪个版本、哪份输入」。

顺便说一件读表的规矩:这张表里有些列可复现,有些不可复现,别把两类混着抄。 耗时那一列取决于你的机器,跑十次有十个数;而指纹、来源、付费调用次数是确定的——同样的输入必然算出同样的指纹,第二次运行的调用次数必然是 0。所以验收要盯的是后面这几列,把耗时当参考。分不清哪个数字该复现、哪个不该,是读别人的性能报告时最常见的翻车方式。

其中「付费调用」这一列有个细节值得强调:它必须是被计数出来的,不能是推断出来的。 「命中缓存所以应该是 0 次」是一句推断,而推断会因为一次疏忽的代码改动而变成谎言。正确做法是在四个 provider 外面套一层计数,每调用一次真实加一。一个证明不了任何事情的指标,比没有指标更糟。

台账里的金额在离线模式下是折算值——按公开单价把用量换算了一遍,语音那一档是由资源包折算的近似值。估算可以,但必须标明是估算。 至于怎么用这张台账去省钱——草稿档与成片档、模型路由、预算熔断——那是 D12 的事,今天只负责把账记清楚。

什么时候该换成现成的

最后必须说一句反话:今天你写的这个引擎,不是用来当通用编排系统的。

它总共不到三百行,只做四件事:拓扑排序、指纹、缓存命中、状态落盘。它没有分布式调度、没有重试策略、没有定时触发、没有可视化、没有多租户、没有权限。这些一样都没有是故意的,因为你现在需要的恰恰就是这四件事,而自己写这四件事的收益是:你彻底搞懂了幂等键为什么要包含依赖指纹、状态为什么必须每步落盘。这些理解你换成任何一个现成引擎都用得上,而只会点按钮的人换个框架就抓瞎。

那什么时候该换?三个信号,出现任何一个就该认真评估:

第一,你开始需要跨机器调度。 单机的进程内调度撑不住了,节点要分发到多台机器上跑。自己实现分布式调度的复杂度是指数级上升的,这时候不换就是在造轮子。

第二,你开始需要人工介入节点。 比如「生成完等人审一下再继续」——这要求流程能挂起几个小时甚至几天,状态必须外置到数据库,而不是一个 JSON 文件。D10 的审核台会让你第一次感受到这个需求。

第三,你开始需要给非工程师看。 运营要知道这一集卡在哪一步、要能自己点重试。这时候你需要的不是引擎,是一个带界面的产品。

三个信号都没出现,就继续用你自己这三百行。过早引入一个重型编排框架,代价是你的每一个业务改动都要先绕过它的抽象,而收益要等到规模上来才兑现。

源码导读

动手实验

🧪 D8 实验:支持断点续跑与成本台账的最小工作流引擎

代码位置:labs/ai-drama-pipeline/day-08-workflow-engine

验收标准:

  1. 第一次运行六个节点全部执行,末尾打印本次付费接口调用 15 次。
  2. 紧接着再跑同一条命令,六个节点全部命中缓存,耗时 0、折算花费 0、付费接口调用 0 次。
  3. 在第四个节点注入失败:它记为失败且台账里有花费,下游两个节点记为未跑,进程非 0 退出。
  4. 去掉注入再跑同一条命令:前三个节点命中缓存、0 次调用,从第四个节点往后执行,成片落到本次运行目录。
  5. 换一句话并换一个运行标识再跑:六个节点的指纹全部与之前不同,全部重新执行。

动手之前先把昨天那份痛点报告翻出来,把「已经花掉多少」那个数字记在手边——今天的验收就是看着它变成 0。实验的状态用一个 JSON 文件落盘,不需要数据库,也不需要密钥。

  1. 连跑两次同一条命令,比较两次的台账,确认第二次的付费调用次数是 0 而不只是「没报错」。
  2. 手动删掉缓存里某一个节点的一个产物文件再跑,确认这个节点重做而其他节点仍然命中。
  3. 在第四个节点注入失败,读台账里失败那一行的花费与下游两行的状态,再续跑一次确认只从第四个节点往后执行。
  4. 把某个节点的实现版本号加一再跑,确认它和它的下游全部失效,而它的上游不受影响。
  5. 换一句话并换一个运行标识跑一次,把两次的指纹列并排比较,说出为什么六个全变了。

面试题

今天 3 道题在下方题库区,侧重调度与业务的分离、幂等键设计、以及断点续跑要持久化什么。展开后先看分析过程再看要点——照着推导练,比背要点管用。标注国内高频与海外高频,方便按目标市场取舍。

检查清单与明日预告

  • 能实现一个最小的任务图执行器,按依赖调度节点并记录每个节点的状态
  • 能让每个节点幂等:同样的输入不重复花钱,重跑只补做没做完的部分
  • 能把每次运行的产物与花费记进台账,支持按运行标识追溯
  • 能说出幂等键必须包含的四样与绝对不能包含的四样
  • 能说清缓存与状态这两层各自提供了什么能力,以及为什么要分开
  • 实验的 5 条验收标准全部通过
  • 3 道面试题不看要点也能答出至少 2 道

明天(D9)我们让多集同时开机。今天解决的是「同一件事不要做两遍」,明天解决的是「不同的事怎么同时做,还不能把厂商的额度打爆」——语音接口对充值用户是每分钟二十次请求,而一集四十个分镜就是四十次合成,不做闸门必然撞上限流。为什么先做幂等再做并发?因为并发只会让你更快地烧钱,在每一次失败都要从头重来的前提下开并发,等于把昨天那个痛点乘以集数。

面试题库

  • 怎么让一个会调用付费接口的生成节点是幂等的?缓存键里该放什么、不该放什么?How do you make a node that calls a paid generation API idempotent? What belongs in the cache key and what does not?
    国内高频海外高频进阶#idempotency#caching#workflow-engine

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

    1. 这题的区分度全在「不该放什么」那一半。只答「把输入哈希一下」的人,通常没在真实项目里被缓存坑过——缓存的两种病方向相反,一种是永远不命中,一种是命中了不该命中的。
    2. 先给判据:键里应该出现的,是所有会改变产物的东西;不该出现的,是所有每次都会变但不影响产物的东西。这一条能直接推出下面两张清单。
    3. 该放的四样:节点标识、实现版本号、本节点的输入(模型 id、提示词、时长、分辨率)、以及全部依赖的指纹。版本号和依赖指纹是最容易漏的两样——漏了版本号,改完代码读到旧产物;漏了依赖指纹,上游换了剧本你还在用旧的镜头。
    4. 不该放的:运行标识、时间戳、随机数、绝对路径、以及任何带机器名或临时目录的东西。放进去等于每次都是新键,你会以为缓存写坏了,其实是键设计错了。
    5. 还有两条落地细节值得主动说:判断「做没做完」要看磁盘上产物齐不齐,不能只信状态文件,因为文件可能被手删;以及幂等的粒度要想清楚,一个节点里跑四个镜头,第三镜失败就是四镜全重做,粒度更细更省钱但任务图会大很多。
    6. 可预期的追问是「依赖指纹会不会失效得太狠」。答:会。上游只是文案改了、产物其实一样,下游也会跟着重做。更省的做法是对依赖的产物内容做哈希而不是对它的键做哈希,代价是每次都要把产物读一遍——小文件划算,大视频不划算,这是要自己量的一笔账。

    How to reason about it · think before answering

    1. The discriminator is the second half: what must not go in. People who only say 'hash the inputs' have usually never been burned by a cache. The two failure modes point in opposite directions: never hitting, and hitting when it should not.
    2. State the criterion first: include everything that changes the artifact, exclude everything that changes every run without affecting the artifact. Both lists fall out of that.
    3. Include four things: node id, implementation version, this node's own inputs (model id, prompt, duration, resolution), and the fingerprints of all dependencies. The version and the dependency fingerprints are the two people forget — miss the version and new code reads old artifacts; miss the dependencies and an upstream script change never propagates.
    4. Exclude: run id, timestamps, random values, absolute paths, and anything carrying a hostname or temp directory. Any of those makes every key new, and you will blame the cache instead of the key.
    5. Two implementation details worth volunteering: decide 'is it done' by checking the artifacts on disk, not the state file, because files get deleted by hand; and think about granularity — four shots in one node means one failed shot redoes all four, while finer granularity saves money at the cost of a much larger graph.
    6. Expect the follow-up 'does hashing dependency keys over-invalidate'. Yes. An upstream wording change that produces an identical artifact still invalidates downstream. Hashing the dependency's artifact content instead is tighter but requires reading the artifact every time — worth it for small files, not for large videos.

    答题要点

    • 判据一句话:会改变产物的进键,每次都变但不影响产物的不进键。
    • 必放四样:节点标识、实现版本号、本节点输入、全部依赖的指纹。
    • 禁放:运行标识、时间戳、随机数、绝对路径与机器相关信息。
    • 命中判定看磁盘上产物是否齐全,不能只信状态文件。
    • 幂等粒度要显式选择:节点粒度实现简单,镜头粒度更省钱但图更大。

    Key points

    • One criterion: include what changes the artifact, exclude what changes every run without affecting it.
    • Must include: node id, implementation version, the node's own inputs, and all dependency fingerprints.
    • Must exclude: run id, timestamps, random values, absolute paths and host-specific data.
    • Decide cache hits by checking artifacts on disk, not by trusting the state file.
    • Choose the idempotency granularity explicitly: per node is simpler, per shot saves more but grows the graph.
  • 要支持断点续跑,你需要持久化哪些状态?只存每个节点的完成状态够不够?What state must you persist to support resuming a workflow? Is per-node completion status enough?
    国内高频海外高频深入#workflow-engine#state-persistence#resume

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

    1. 题眼在「够不够」三个字,它在暗示你答案是不够。只存完成状态的系统,重跑时只知道「这个节点做过」,却答不出「做的是哪一版」——于是改完代码重跑,它照样跳过。
    2. 拆的角度是:续跑要回答三个问题。哪些节点做完了?它们做的是不是我现在要的那一版?它们的产物还在不在?三个问题分别对应三样要持久化的东西。
    3. 所以除了状态,还要存指纹和产物位置。指纹回答「是不是同一版」,产物位置回答「东西还在不在」。本课的做法是把产物按指纹落进内容寻址的目录,这样第三个问题退化成一次文件存在性检查,连记都不用记。
    4. 还要区分两层:产物缓存是全局的,跨运行共享,它提供的是幂等;节点状态是每次运行一份,它提供的是断点续跑。混成一层的话,换个运行标识就得重花一次钱。
    5. 落盘时机也是这题的一部分:状态必须在每个节点跑完之后立刻写,而不是整个流程结束再写一次。进程被强杀、机器掉电、容器被驱逐,在跑十几分钟的视频任务时并不罕见。
    6. 可预期的追问是「失败节点的残产物要不要删」。答:不删。留着它,下一次跑到这里判断产物齐不齐就直接得到结论;但判定必须是「outputs 里每个文件都在」才算命中,缺一个就重做,否则残产物会被当成成功的。

    How to reason about it · think before answering

    1. The words 'is it enough' hint that it is not. A system storing only completion status knows a node ran, but not which version ran, so it happily skips after you change the code.
    2. Frame it as three questions a resume must answer: which nodes are done, are they the version I want now, and are their artifacts still there? Each maps to something you must persist.
    3. So beyond status you need the fingerprint and the artifact location. The fingerprint answers 'same version?', the location answers 'still there?'. Storing artifacts in a content-addressed directory named by the fingerprint collapses the third question into a file-existence check.
    4. Also separate two layers: the artifact cache is global and shared across runs, providing idempotency; node state is per run, providing resume. Collapse them and a new run id costs you full price again.
    5. Write timing is part of the answer: persist state right after each node completes, not once at the end. Hard kills, power loss and container eviction are not rare during ten-minute video jobs.
    6. Expect the follow-up 'do you delete a failed node's partial artifacts'. No. Keep them, and make the hit condition 'every declared output exists'. Missing one means redo, so partials are never mistaken for success.

    答题要点

    • 只存完成状态不够,还要存指纹和产物位置,分别回答「哪一版」和「还在不在」。
    • 产物按指纹落进内容寻址目录后,「还在不在」退化成一次文件存在性检查。
    • 两层分开:缓存全局共享提供幂等,节点状态每次运行一份提供断点续跑。
    • 状态要在每个节点跑完后立刻落盘,不能等整个流程结束再写。
    • 失败节点的残产物保留,但命中判定必须是全部产物齐全才算数。

    Key points

    • Completion status alone is not enough: persist the fingerprint and artifact location to answer 'which version' and 'still present'.
    • Content-addressed artifact directories reduce 'still present' to a file-existence check.
    • Keep two layers: a global cache for idempotency, per-run node state for resume.
    • Persist state immediately after each node, not once at the end of the run.
    • Keep failed nodes' partial artifacts, but only count a hit when every declared output exists.
  • 什么时候该自己写调度,什么时候该直接上现成的工作流引擎?When should you write your own scheduler, and when should you adopt an off-the-shelf workflow engine?
    国内高频海外高频基础#architecture#build-vs-buy#workflow-engine

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

    1. 这题考的是技术选型的成熟度。两个极端都会被扣分:什么都自己写显得不懂杠杆,什么都上框架显得没判断力。面试官想听的是你的切换信号是什么。
    2. 先给一条通用判据:自己写的收益是理解和贴合,框架的收益是省掉你还没遇到的那些问题。所以决策取决于「你现在需要的功能有多少落在框架的核心能力上」。
    3. 自己写划算的情形:单机、节点数是个位数、路径是你定死的、需要的只是拓扑排序加幂等加状态落盘这几件事。这时候自己写不到三百行,而且换来的理解是通用的——你会彻底搞懂幂等键为什么要包含依赖指纹、状态为什么必须每步落盘。
    4. 该换的三个信号:一是开始需要跨机器调度,自己实现分布式调度的复杂度是指数级上升的;二是开始需要人工介入节点,流程要挂起几小时甚至几天,状态必须外置到数据库而不是一个 JSON 文件;三是开始需要给非工程师看和操作,那你需要的其实是一个带界面的产品。
    5. 反过来说,过早引入重型框架的代价很具体:每一个业务改动都要先绕过它的抽象,而它的收益要等规模上来才兑现。这是典型的成本前置、收益后置。
    6. 可预期的追问是「自己写的那一套能不能平滑迁走」。答:能,前提是你从一开始就把节点定义成纯声明(依赖、输入、产物、执行体),调度和状态不侵入业务。这样迁移时改的是引擎,不是六个节点。

    How to reason about it · think before answering

    1. This tests selection maturity. Both extremes lose points: building everything yourself shows no sense of leverage, adopting a framework for everything shows no judgment. The interviewer wants your switching signals.
    2. Give a general criterion: writing it yourself buys understanding and fit; a framework buys you past problems you have not hit yet. So the decision hinges on how much of what you need overlaps with the framework's core.
    3. Writing your own pays off when: single machine, a handful of nodes, a path you fixed yourself, and you only need topological ordering plus idempotency plus state persistence. That is under three hundred lines, and the understanding transfers to any engine you adopt later.
    4. Three signals to switch: you need cross-machine scheduling, where rolling your own scales in complexity exponentially; you need human-in-the-loop nodes, so runs suspend for hours or days and state must live in a database rather than a JSON file; or non-engineers need to see and operate it, in which case you need a product with a UI, not an engine.
    5. Conversely, adopting a heavy framework too early has a concrete cost: every business change must route around its abstractions, while its benefits only land at scale. Cost up front, payoff deferred.
    6. Expect the follow-up 'can you migrate off your own version cleanly'. Yes, if nodes were declarative from the start — dependencies, inputs, outputs, body — with scheduling and state kept out of the business code. Then migration replaces the engine, not the nodes.

    答题要点

    • 判据是你需要的功能与框架核心能力的重叠度,不是「自研还是选型」的立场。
    • 自己写划算:单机、节点数少、路径固定,只需要拓扑排序加幂等加状态落盘。
    • 该换的三个信号:跨机器调度、人工介入导致流程长时间挂起、非工程师要操作。
    • 过早上重型框架的代价是每次业务改动都要绕过它的抽象,收益却要等规模。
    • 把节点写成纯声明,调度与状态不侵入业务,将来迁移改的是引擎而不是节点。

    Key points

    • Decide by how much your needs overlap the framework's core, not by a build-versus-buy stance.
    • Rolling your own wins on a single machine with few nodes and a fixed path, needing only topo order, idempotency and state persistence.
    • Three switching signals: cross-machine scheduling, human-in-the-loop suspension, and non-engineers needing to operate it.
    • Adopting a heavy framework early costs a detour around its abstractions on every change, with benefits deferred to scale.
    • Keep nodes declarative and scheduling non-invasive so a later migration replaces the engine, not the nodes.

评论

登录后即可参与讨论

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