Slack不在航班上,而在交接之间:图数据库如何驯服连锁延误

把航班连锁延误建成图模型,用边上的缓冲与零行不变量让智能体安全地解释和验证调度决策。
文章从一个反直觉的建模决策出发:航班延误的主要来源是连锁传播而非单点故障,而缓冲时间(slack)属于两个航班之间的交接边,不属于航班节点本身。作者用 `NEXT_LEG` 和 `CREW_NEXT` 两种边刻画飞机与机组的物理依赖,使延误传播可以用一条 Cypher 查询反复松弛到不动点。由此派生三个能力:用最短路径直接回答"这趟航班为什么晚点",溯源信息随遍历免费获得;用四条"必须返回零行"的不变量在提交前验证任何取消、换机或换组方案;用 `GRAPH.COPY` 分叉在沙箱中评估计划,只有通过验证的 `decision_id` 才能提交。核心立场是"图做决定,模型只说话,人类来确认",并用一个图离线即全部失败的测试守住这一主张。
当航班延误时,我们的第一反应往往是“这趟航班出了问题”。但欧洲空管组织Eurocontrol的CODA Digest数据显示,2023年第二季度欧洲44.6%的延误分钟属于“连锁延误”(knock-on delay),而非原发性延误。美国交通部2025年9月的《航空旅行消费者报告》也将“晚到飞机”列为延误的最大单一原因。
连锁延误不是正在发生的新问题,而是旧问题“抵达”了。一架飞机每天要飞4到6个航段,同一机组也要执飞其中一部分。第二段航班在等第一段,这个依赖关系是物理性的、真实的,而且天然构成一张图。

印度监管机构在IndiGo 2025年12月运营崩溃后的表述让这个问题变得清晰:2,507架次取消、1,852架次延误、超过30万旅客滞留三天,罚款22.2亿卢比。印度新闻局的调查结论是,排班“最大化了机组利用率”,削减了排班缓冲,增加了对搭便机、换尾号和最短恢复时间的依赖。最大化利用率意味着最小化冗余。
建模决策:Slack属于边,而非节点
作者最初的本能是给每个航班节点加一个slack属性,但这是错误的。Slack不属于航班,而属于两个航班之间的“间隙”:
slack_min = scheduled_ground_time − minimum_legal_turnaround
航班本身并不脆弱,脆弱的是“交接”环节。因此模型用两种边类型承担了全部工作:
| 边类型 | 含义 | 属性 |
|---|---|---|
NEXT_LEG | 同一飞机的紧邻下一航段 | min_turn, sched_ground, slack_min |
CREW_NEXT | 同一机组的紧邻下一航段 | min_rest, sched_ground, slack_min |
一旦Slack被放到边上,连锁传播就变成一条可写下的查询。延误从航班a溢出到航班b,条件正是前者超过了两者之间的缓冲:
MATCH (a:Flight)-[e:NEXT_LEG|CREW_NEXT]->(b:Flight)
WHERE a.status = 'SCHEDULED' AND b.status = 'SCHEDULED'
AND (a.delay_min - e.slack_min) > b.delay_min
WITH b, max(a.delay_min - e.slack_min) AS new_delay
SET b.delay_min = new_delay,
b.proj_dep = b.sched_dep + new_delay,
b.proj_arr = b.sched_arr + new_delay
RETURN count(b) AS changed
反复执行直到changed = 0,这就是在数据库内完成的Bellman-Ford松弛到不动点。max()聚合至关重要:一个航班有两个入向依赖(飞机和机组),必须取更坏的那个,因为它要同时等两者。一次聚合,规则只表达一次。
Bellman-Ford 松弛:Bellman-Ford 是经典的单源最短路径算法,核心操作是"松弛"(relaxation)——反复检查每条边,若经由该边能得到更短(或更优)的距离,就更新目标顶点的值。由于每次迭代至少让一个顶点收敛,最多执行 |V|−1 轮即可得到稳定解。文中借用了这一思想:把"延误"当作路径代价,把每条 NEXT_LEG/CREW_NEXT 边上的 slack_min 当作该边的"容量/阈值",每轮对所有边做一次传播,直到没有任何航班的值再变化(changed = 0)为止,即"松弛到不动点"。与经典算法不同的是,这里的更新不是取最小,而是用 max() 聚合多个前驱——因为一个航班要同时等待飞机和机组两个依赖,必须取更坏的那个。这也解释了为什么传播天然是单调的(延误只增不减),后文提到的"分叉必须重置"正是这一单调性的直接后果。
最小过站时间(minimum legal turnaround)与最小休息时间(min_rest):这两个参数是航空排班中的硬性运行约束,决定了两个航班之间"合法的"最短间隔。最小过站时间指飞机落地后到下一次起飞之间必须完成的下客、清洁、加油、配餐、检查、登机等地面作业所需的最短时间,通常因机型、机场和是否国际航班而异。最小休息时间则针对机组,指同一机组执行相邻两个任务之间依法必须保证的休息时长,往往远长于过站时间,且受飞行值勤时间限制(FTL/FDTL)法规约束。二者都不等于"计划地面时间"(scheduled_ground_time)——后者是排班时实际留出的间隔。slack_min = scheduled_ground_time − minimum_legal_turnaround 因此衡量的是计划相对法规下限还剩多少缓冲;缓冲越薄,上游延误越容易溢出到下游。文中提到的"把交接收紧到 min_turn,而 min_rest 才是更长要求"的 bug,正是因为飞机约束与机组约束必须分别检查、取更严格者。
为什么“这趟航班为什么晚点”才是真正的功能
作者认为最值得展示的查询,是用一条路径回答“为什么晚点”:
MATCH p = (root:Flight {id: $root_id})-[:NEXT_LEG|CREW_NEXT*1..8]->(target:Flight {id: $target_id})
RETURN [n IN nodes(p) | n.id] AS chain,
[r IN relationships(p) | type(r)] AS hop_types,
[r IN relationships(p) | r.slack_min] AS slack_at_each_hop,
length(p) AS hops
ORDER BY hops ASC
LIMIT 1
它用一条路径作答:6E101因雾霾受阻,将飞机交给6E117时只有5分钟余量,后者又通过8分钟余量把延误传给6E131。没有模型写下这句话,这是一次遍历。如果解释错了,那就是图错了,可以回去检查图。
作者认为图数据库在智能体系统中最被低估的用途正在于此:大家总把它当作检索层,取些上下文塞进提示词。但遍历结果本身在结构上就是解释,溯源信息免费获得,因为答案就是你走过的路径。
用“零行”证明安全
智能体可以提议动作:取消一对航班、换备用飞机、换备份机组。任何动作提交前,四条不变量必须成立,每条都是一个“必须返回空集”的查询:
- INV-1:没有机组的值勤窗口超过限制
- INV-2:没有过站时间短于最小值
- INV-3:尾号连续性——飞机到达机场等于下一段出发机场
- INV-4:每个航班都有终态
INV-3尤其值得展开,因为初版设计漏掉了它。取消一个延误航班是最直观的遏制动作,但往往错得离谱:取消一个中段航段,会让飞机当天剩余时间停留在错误的城市。飞机无法瞬移。因此取消必须以“往返成对”方式进行,或搭配明确的调机。
作者是通过写下不变量、看着计划失败才发现这一点的。这正是把安全表达为“模式的不存在”而非应用逻辑的理由:匹配路径的不存在就是安全的证明,而一个返回零行的查询,远比一个你忘记写的if语句更难自欺。
在触碰世界之前先分叉
每个计划都在GRAPH.COPY分叉上评估:
fork → 应用计划 → 重新传播连锁 → 检查4条不变量 → 打分 → 删除分叉
FalkorDB的GRAPH.COPY会克隆节点、关系、属性、索引和约束,源图保持可读写。分叉在finally块中删除,包括内部抛异常时。
作者还坦白了一个bug:松弛是单调的,只会抬高延误。第一版评估循环在分叉上应用取消后又重跑松弛,结果每个计划得分都一样。原因显而易见:取消一个航班无法降低已经传播到下游的延误。分叉必须重置到已发布时刻表,并在动作应用之后从扰动点重新传播。事后看很显眼,分数平淡无奇之前却完全隐形。
GRAPH.COPY 与写时复制式沙箱:GRAPH.COPY 是 FalkorDB 提供的图级克隆命令,会为目标图复制源图的全部节点、关系、属性,以及索引和约束定义。关键在于源图在克隆后仍保持可读写,二者相互独立,因此可以把它当作一次"世界状态快照"。在本文的规划流程中,每个候选计划都在一个独立的克隆图上应用动作、重新传播连锁延误、跑四条不变量检查并打分,无论成功失败都删除该分叉。这与数据库事务的"回滚"或函数式编程中的"不可变数据结构 + 派生新版本"思路一致:真实世界(已发布时刻表)不被试探性操作污染,只有通过验证并拿到 decision_id 的计划才被提交回主图。需要注意的是,克隆带来的是空间与时间开销,图规模大时频繁 fork 会成为瓶颈,这也是为什么每个分叉都在 finally 中及时清理。
塑造架构的那条约束
作者借用黑客松的规则给自己定了一条铁律:
图做决定。模型只说话。人类来确认。
在MCP服务器中,九个工具里有八个是只读的。第九个是commit_plan,它只接受一个decision_id,从不接受计划正文。这个签名就是全部的安全论证:模型无法凭空发明动作,因为没有任何参数可以传递动作。它只能指向图已经验证并记录的东西。每个Decision都盖有验证时所依据的图版本号;如果实时图已经变化,提交会被拒绝。针对一种世界状态证明安全的计划,不构成对另一种状态的安全证明。
MCP(Model Context Protocol):MCP 是 Anthropic 提出的一种开放协议,用于让大语言模型应用以标准化方式连接外部工具与数据源。它采用客户端-服务器结构:模型所在的应用作为客户端,通过 JSON-RPC 与 MCP 服务器通信,服务器把一组"工具"(tools)、"资源"(resources)和"提示"(prompts)暴露给模型。本文的 MCP 服务器就是这样一个工具提供方,暴露出九个工具供智能体调用。MCP 的意义在于把"模型能做什么"与"系统允许做什么"解耦:工具的参数 schema 本身就是一道权限边界。文中的 commit_plan 只接受 decision_id,正是利用这一层——模型可以调用工具,但无法通过参数注入未经图验证的动作,接口签名成了可审计的安全契约。
只能保留一个测试的话,就保留这个
tests/graph-off.test.ts把产品指向一个死掉的数据库,断言所有路径都失败。没有缓存、没有默认值、没有降级模式、没有内存回退。它还会grep源码中的禁用数据存储导入。如果拿掉图产品还能工作,那图就只是装饰。
这个测试也让作者能在加入MongoDB做用户账户时不削弱核心主张:账户是应用外壳;航班、边、连锁、计划、决策和回执才是产品。packages/graph被禁止导入mongoose,由linter而非良好意愿来执行。
如果重来,我会告诉自己的事
先写预言机。 在连上数据库之前,作者先写了一个纯TypeScript检查器,用与Cypher相同的四条不变量校验生成的时刻表。它立刻抓到三个真实bug:负的机组休息余量(把交接收紧到了min_turn,而min_rest才是更长要求)、轮转越过23:00日界、以及过度切分机组。同一规则的独立实现,比再写一个同实现的测试更有价值。
把所有查询放在一个文件里。 所有Cypher都在queries.ts中,不内联在路由、脚本或测试里。这让README中“本产品依赖的查询”一节可以复制粘贴,也让查询作为一个集合可被审查。
给关系命名。 FalkorDB只对未命名关系做存在性检查。MATCH (a)-[e:NEXT_LEG]->(b)中实际使用e时行为正确;丢掉别名,计数可能悄悄出错。
这是什么,不是什么
这是一个基于合成数据的决策支持原型。每趟航班、尾号、机组和旅客数都是生成的,只有机场代码和坐标是真实的。
它明确不是运营求解器的替代品。汉莎航空的OPSD是成熟部署系统;INFORMS报道调度员接受了其26%的建议,约二十名开发者花了四年,大部分时间用在数据准备上。26%是已发表数字,至于为什么是26%,那是作者的假设而非发现——但这个假设正是项目的立足点:运营调度员对决策负有个人责任,而一个你无法质询的建议,就是一个你只能凭信念接受的建议。
所以立场不是“更好的计划”,而是:看见整条链,把每条建议解释为一条路径,并在任何人行动前用硬约束测试它。遏制方案选择是一个带声明权重的评分启发式,不是最优性证明。不变量只覆盖已建模的部分——糟糕的输入数据仍可能生成通过验证的计划。duty_limit_min = 780是占位参数,不是监管数字;阅读真实的DGCA FDTL CAR仍在待办清单上。
相关推荐

uGAP:统一100+非洲地理数据集的AI平台
uGAP是开普敦大学团队开发的非洲地理空间AI平台,统一120余个非洲数据集并提供Python SDK,支持数据发现、处理与模型训练一站式工作流,已用于气旋洪水制图等实战场景。

Cursor能控制你的电脑了:AI代理可驱动桌面应用测试
Cursor 新增桌面控制能力,AI 代理可驱动 iOS 模拟器等桌面应用测试自己构建的功能。本文分析这一更新对开发工作流的影响、技术实现可能路径及可靠性考量。

Vibecoding自研推理引擎:理想丰满,现实骨感
Reddit开发者分享Vibecoding自研推理引擎的真实经历:用WebGL2和GLSL在浏览器跑GGUF Q4模型,却陷入tokenizer调试地狱。本文分析浏览器端LLM推理的工程难点,以及为何直接clone llama.cpp是更理性的选择。