general state-space search

不定义角色,
也能走出未知的路

Cairn 是一个通用的问题求解引擎:给它一个起点和一个终点,它在未知的状态空间里搜索一条路径。 渗透测试只是它第一个被验证的领域。这份文档从零开始,拆开它的每一个设计决定—— 不是告诉你「它有什么」,而是告诉你「为什么必须这样」。

3 个原语 3 类任务 0 个预设角色 1 个协议写入者 只增不改 的事实图
one-minute version · 一分钟版本
  1. 把问题变成一张图。已知的写成 Fact,要探索的方向写成 Intent,人的判断写成 Hint。
  2. 图是唯一真相源。Server 只负责存图、维护一致性,不做任何推理。
  3. Dispatcher 是唯一的自动化写入者。读图 → 选任务 → 选 Worker → 执行 → 把结果写回图。
  4. 只有三类任务。bootstrap 直接试着做完,reason 看全局决定下一步,explore 走完一条边。
  5. Agent 没有角色。它只收到一段 prompt,只返回一个 JSON。任务是根据图的当前状态在运行时生成的。
  6. 图只增不改。完成 = 补一条指向 goal 的边;判错了就 reopen,再补一块「此路不通」的石头。
01 / THE SHAPE OF THE PROBLEM

问题的形状,决定了引擎的形状

在写第一行代码之前,Cairn 先做了一个判断:有一类问题长得一模一样,它们都可以被写成「已知起点、已知终点、路径未知」。

渗透测试的形态非常干净:你知道目标是什么(一个 IP、一个站点、一个二进制),你知道成功长什么样(一个 shell、一个 flag、一份权限证明),但你不知道中间该怎么走。这条路径不在任何文档里,它藏在目标的实现细节里,只能靠一次次试探把它逼出来。

要素渗透测试一般形式
起点目标 IP / 域名 / 样本已知的初始状态
终点shell / flag / 权限证明可判定的成功条件
路径侦察 → 打点 → 提权 → 横向未知,且分支极多

同样形状的问题还有一大把:漏洞研究(从「这段代码可疑」到「构造出崩溃」)、数学证明(从公理到目标命题)、CTF、甚至线上故障排查(从「服务 500」到「找到那行配置」)。它们的领域知识完全不同,但搜索结构是同构的。

1.1三种常见做法,三种失败

approach 01

角色编排

把任务拆给「侦察 Agent」「打点 Agent」「提权 Agent」。角色来自人的经验总结,但真实的关键路径经常跨阶段:一个备份文件同时是信息泄露、凭据来源和权限入口。角色会把它切成三段,每次交接都掉一层上下文。

approach 02

固定工作流

把流程画成 DAG:先扫描、再指纹、再打点。工作流的前提是「顺序已知」,而顺序恰恰是这道题要求你求出来的东西。一旦现实不按流程图走,系统要么卡住,要么空转。

approach 03

自由多 Agent 对话

让 Agent 互相发消息协商。通信成本随人数平方增长,每次转述都是一次有损压缩,最要命的是:没有一个地方持有「当前全局状态」。谁都不知道别人已经排除了什么。

Cairn 的答案

不预设角色,不预设流程。只维护一块共享的板子(图),让每个 Agent 读同一份全局状态、各自往前推进一步、把结果写回板上。任务不是分配好的岗位,而是从图的当前状态里当场生成的。

这就是 README 里那句 “It defines no roles, no workflows.” 的实际含义——它不是一句口号,而是一整套结构选择的起点。

这套思路并不新。1970 年代 CMU 的 Hearsay-II 语音识别系统就提出了黑板架构:多个专家围绕一块共享黑板工作,各自读取当前状态、各自贡献新知识,没有中央调度。Cairn 做的是把黑板重新形式化:用一张有向图当黑板,用 Intent 保留完整的因果链,于是这张图同时是知识库和推理过程的审计日志。

协调机制则对应蚁群的信息素(stigmergy):个体之间不直接通信,而是通过改变共享环境来间接协调。Agent 读图、写 Fact,就是在环境上留下信息素。

Cairn 是登山者在雾里垒的石堆:每个人看到前人的石堆,知道「有人从这走过」,于是继续往前垒一块。 —— 项目名的由来
02 / THREE PRIMITIVES

整个系统只有三个原语

知识、行动、判断。刚好三样,多一个都是负担。

fact · 已知

客观事实

一条被确认的发现,写进板上就永久保留。只增不改。状态会变,但「变了」本身也是一条新事实。

f001 · 80 端口是 nginx + PHP 7.3

intent · 待走

探索意图

一条声明的方向,在图上是边。它从若干 Fact 出发,指向一个还不存在的结论。声明了不等于做了。

i002 · 目录扫描(from f001)

hint · 判断

人类提示

图外的输入,不属于事实、不参与因果。它是「指挥官意图」,可以是策略建议,也可以是对当前态势的评估。任何时候都能写。

h003 · 优先看文件上传,SSH 已排除

三个原语的分工非常硬:Fact 回答「现在知道什么」,Intent 回答「接下来往哪走」,Hint 回答「我认为该注意什么」。没有一个原语是「计划」「阶段」「角色」或者「任务」——因为这些东西都可以由图的状态推导出来,一旦写进数据结构,就变成了需要维护的第二种真相。

2.1为什么 Fact 必须只增不改

想象多层网络渗透里的真实场景:

  1. f003:拿到 host-A 的 root shell。
  2. f007:通过 host-A 做跳板,发现内网 10.0.2.0/24。
  3. 十分钟后,shell 掉线了。

大多数系统会去「更新 f003 的状态」:把 alive 改成 dead。Cairn 不这么做,它直接追加 f025:“host-A 的 shell 已断开”。

这样做换来三件事:

不用回答「谁来判断」

失效是世界的属性,不是某条记录的属性。让 Agent 去改历史,就要处理「谁有权限改」「改到哪一版为准」。

并发安全

只追加意味着没有写写冲突。多个 Worker 同时写图,不需要行锁、不需要版本号。

时序即信息

f003 与 f025 的先后顺序本身就说明了状态变化。审计时能看到完整的因果,而不是被覆盖后的结果。

代价也很实在:图会膨胀,旧事实永远不会消失。所以协议里明确给了 Hint 一个额外用途——写态势评估。比如「SSH 和 SQL 注入方向均已排除,攻击面集中在文件上传」。随着图变大,这类 Hint 是降低每轮读图认知成本的唯一手段。

原语谁能写什么时候能写写错了怎么办
Fact由 Server 在创建项目、conclude、reopen 时写入项目为 active不能改、不能删。补一条新 Fact 说明错误
Intent任何消费者经 API(Dispatcher / UI / 人)项目为 active已结论的不可改;未结论的可 release 重来
Hint任何消费者active / stopped / completed 都可以不回滚,补一条新的
03 / GRAPH SEMANTICS

一张从起点长向终点的图

Fact 是节点,Intent 是边,goal 是唯一的汇点。完成不是「关掉项目」,而是给图补上最后一条边。

项目的创建就是图的初始化:Server 写入两个特殊 Fact,origin 和 goal。之后所有普通 Fact 由系统分配 id(f001、f002……),所有 Intent 也从 i001 开始。id 是项目内自增的,互相不干扰。

3.1Intent 是一条边,而且可以是超边

一条 Intent 的基本结构是 from[] → to。当 from 里有多个 Fact 时,它等价于一条超边:多个源节点共同支撑同一次探索,共享同一个 description、creator 和 worker。

f002 泄露的数据库备份 f004 后台凭据 i005 f005 登录后台成功

from=[f002, f004] → to=f005

为什么非要支持多源?因为真实推理经常是「A 和 B 合起来才能推出 C」。如果强行只留一个主节点,就会丢掉另一半上下文,后来的人(或 Agent)看到 f005 时无法还原它是怎么来的。超边把这个因果关系完整保留了下来。

3.2一条 Intent 的三种状态

状态判据worker 的含义谁能推进
未认领to = null 且 worker = null没人处理,等待接手任何消费者可以 heartbeat 认领
进行中to = null 且 worker 有值正在处理的人只有该 worker 能续约 / 结论 / 释放
已结论to 有值产出结论的人,永久保留不可修改
超时回收心跳超过 intent_timeout清空为 null谁都能重新认领

这里有个容易忽略的细节:creator 和 worker 是两件事。creator 是「谁看懂了局势、提出了这个方向」,创建时写入,永不可变;worker 是「谁在真正执行」,随认领、超时、结论而变。审计时你能分清「出主意的人」和「干活的人」。

另外,last_heartbeat_at 是永久保留的:即使 worker 因为超时被清空,时间戳也不清空。这样你还能看到「上一次有人碰它是什么时候」。

3.3完成,是图上的一条边

当某个消费者判断「这些 Fact 已经足够证明 goal 达成」,它调用 POST /projects/{id}/complete。Server 不会只把状态改成 completed,它会写一条 from=[...] → to=goal 的已结论 Intent,然后把项目状态置为 completed。

这个设计让「为什么算完成」变成了图的一部分:审计时能看到是 f007、f012、f015 这三块石头共同支撑了完成判断,而不是只看到一句「项目已结束」。

完成判断可能是错的

如果外部验证发现 flag 是假的,协议不提供「撤销完成」的图内操作,而是提供 reopen:删掉那条 goal 边,把纠错信息写成一条新 Fact,再从原来的 from 拉一条 description = "external_feedback" 的边指向新 Fact,项目回到 active。

历史中「曾经完成过一次」的痕迹被删掉了(因为那条边确实不该存在),但「为什么被推翻」被永久记录成一块新石头。这是这套协议里少数几个「允许删除」的地方,而且删的是判断,不是事实。

3.4同一张图,两种读法

Server 提供两种导出,互补而不是重复:

GET /export?format=yaml

结构视图。看拓扑:哪些 Fact 存在、哪些 Intent 连着谁、哪些还没结论。Agent 读的就是这份(以文件形式落进容器,见第 8 节)。不含 worker 心跳时间,也不含项目级 reason lease——因为那不属于事实图。

GET /export?format=timeline

时序视图。按时间排事件:项目创建、Hint、Intent 声明、Intent 结论、项目完成。它回答「先后顺序和因果链」,YAML 回答「现在长什么样」。

04 / ARCHITECTURE

四层,各管一件事

Server 只管一致性,Dispatcher 只管调度与写回,容器只管环境,Agent 只管思考。

Cairn Server Projects · Facts · Intents · Hints · Lease FastAPI + SQLite(WAL)· 不做推理,只维护一致性 read / write API 唯一自动化写入者 Dispatcher Scheduler · Container Lifecycle · Session · Timeout · Health 读图决定派什么任务、给谁、什么时候收尾 docker exec docker exec Project Container A cairn-dispatch-proj_001 claude / codex / pi × N Kali 工具链 · 一个项目一个环境 Project Container B cairn-dispatch-proj_002 claude / codex / pi × N 容器之间互不干扰 Agent 只收到 prompt,只返回 JSON —— 它不认识 HTTP,也不认领 Intent

4.1Server:真相源,但故意「不聪明」

Server 的职责被压到极小:存 Project / Fact / Intent / Hint,维护 claim、heartbeat、conclude 的状态机,做输入校验。它不做任何推理,不知道什么算完成,也不知道下一步该做什么。

有两个实现细节值得注意:

  • 超时是「读时语义」。没有后台定时器在扫全表,而是每次读取或认领时调用 expire_workers(),把心跳超时的 open intent 的 worker 清空。简单、没有额外进程,代价是「过期」只在有人来看的时候才发生。
  • 硬停止是立即生效的。项目被切到 stopped 时,Server 会当场把该项目所有未结论 Intent 的 worker 清成 null,同时清空 project.reason。这样恢复时不用等超时,所有人可以立刻重新认领。

4.2Dispatcher:控制面,也是唯一的自动化写者

Dispatcher 是这套系统真正的「工程」。它做五件事:拉项目列表 → 判断该项目现在该跑什么任务 → 选一个 Worker → 在容器里执行 → 把结构化结果写回 Server。

为什么不让 Agent 直接连 Server?三个理由,每一个都足够独立成立:

模型不该学协议

如果 Agent 要自己 claim、自己 heartbeat、自己 conclude,那 prompt 里就得塞进一套 HTTP 状态机。这是在用宝贵的上下文教模型做机械劳动。

并发会失控

多个 Agent 同时写图,claim 竞争、重复 conclude、越权 release 都会出现。把写入收敛到单点,这些竞态直接消失。

可替换

只要 Agent 的输入是 prompt、输出是 JSON,底层换成 claude、codex、pi 甚至 mock,上层一行不用改。

4.3容器:一个项目一个环境

Container 模式下,每个项目对应一个 Docker 容器,命名固定为 cairn-dispatch-<project_id>。容器起来后只做一件事:sleep infinity。真正的工作是 Dispatcher 用 docker exec 把 CLI 命令送进去执行。

几个工程细节:

  • 每条命令外面套一层 timeout -k 5s <N>s,Dispatcher 侧再套一层 communicate(timeout=N+15)。双保险:先礼后兵,超时后 5 秒强杀。
  • 图快照不会直接塞进 prompt,而是写到容器里的 /tmp/cairn-prompts/<phase>-<uuid>/graph.yaml,prompt 里只给一句「去读这个文件」。这是为了控制 prompt 体积——图会越跑越大,把它全文塞进每次调用既贵又容易截断。
  • 项目 completed 之后,容器按 container.completed_action 处理:stop 保留现场(可进去复盘),remove 直接删掉。清理在独立的线程池里跑,不阻塞主调度循环。
  • 领域知识放在容器镜像里,不放在引擎里。仓库的 container/AGENTS.md 就是给某次比赛写的环境说明(Kali、题目分级、反弹 shell 的出口 IP),引擎本身完全不知道「渗透测试」四个字。

4.4Local 模式:不要 Docker,也不要 API Key

把 runtime.execution 设成 local,Worker 就不进容器了,直接在 Dispatcher 宿主机上以子进程运行,复用本机已经登录好的 claude / codex / pi CLI。启动时 Dispatcher 会对每个配置到的 CLI 跑一次 --help 探测,一个都找不到就直接报错退出。

每个项目会在 local.workspace_root 下分到一个隔离子目录当工作目录。注意:这种方式没有沙箱,Agent 以你的用户权限跑,所以官方文档明确说不要把它放进 Docker 里再跑一遍。

一个反直觉的结论

Server 越「笨」,系统越可控。所有智能都在 prompt 和模型里,所有一致性都在协议里。一旦让 Server 开始「判断这个 Fact 重不重要」「自动合并相似 Intent」,它就从真相源退化成了第二个会犯错的推理者。

05 / THREE TASK TYPES

只有三类任务,构成一个闭环

观察全局、决定方向、执行一步。OODA 循环的最小闭合,加上一个「跳过规划直接干」的快捷方式。

任务什么时候触发它看到什么它能写什么失败怎么办
bootstrap
初始态
项目 active;facts 只有 origin + goal;intents 为空或只有保留的 bootstrap intent;bootstrap_enabled=true 且存在支持它的 Worker origin、goal、hints 主阶段:fact + complete 必须同时返回
收尾阶段:只能返回 fact
超时 → 同 session 收尾;两阶段都失败 → release,下一轮仍按新项目重试
reason
全局判断
项目 active;没人持有 reason lease;出现「新态势」(facts↑ / hints↑ / open intents 从有到无) 整图 YAML、合法 fact id 列表、所有未结论 intent、max_intents complete 或 intents[](≤ max_intents)或空 {} 超时 / 非法输出 → 直接作废,不写图,释放 lease
explore
执行一条边
项目 active;存在一条未认领、未结论、非 bootstrap 的 Intent 整图 YAML、这条 intent 的 id 与描述 一条 Fact 的 description(客观结论) 超时 / 解析失败 → 双阶段收尾;单阶段 Worker 直接 release

5.1三个输出契约

Agent 的输出永远是一个 JSON 对象,最外层固定是 {accepted, data}。三种任务的数据形状不同:

reason · 判定完成
{"accepted": true,
 "data": {"complete": {
   "from": ["f007"],
   "description": "flag{...} 满足 goal 要求"
 }}}
reason · 提出新方向
{"accepted": true,
 "data": {"intents": [
   {"from": ["f001"], "description": "目录扫描"},
   {"from": ["f002", "f004"], "description": "用凭据登录后台"}
 ]}}
explore · 一条结论
{"accepted": true,
 "data": {"description":
   "对 /search 测试常见 SQLi payload,未发现注入迹象"}}
bootstrap · 主阶段
{"accepted": true,
 "data": {
   "fact": {"description": "拿到 flag 与 root shell"},
   "complete": {"description": "关键结果已验证,goal 达成"}
 }}
契约不是建议,是校验

explore 的 description 必须非空;reason 的 complete 与 intents 不能同时出现,而且当 open_intents 为空时必须提出新 intent(否则图就死了);bootstrap 主阶段如果只返回 fact 而不返回 complete,会被判为非法,直接掉进收尾流程。

5.2为什么是这三类

把 OODA 拆开看:Observe + Orient + Decide 合起来是 reason——读全图、理解局势、决定是收工还是开新方向;Act 是 explore——只走一条边,只产出一个结论。多个 Agent 各自跑这个循环,通过共享图间接同步,就得到了任务式指挥(Mission Command)的结构:上级只给目标,各单位根据态势自主决策。

bootstrap 是个刻意的例外。有些问题很小,或者至少第一个突破口很近。如果连这种题都要走「reason 提 intent → explore 执行 → reason 再判断」的三轮往返,光调度开销就比解题本身贵。于是协议允许在项目最初阶段直接推进一次——但有两个约束:

  • 它仍然通过一个保留 Intent 落图(from=["origin"]、description="bootstrap"、creator="dispatcher.bootstrap"),所以这次尝试在图上可见、可审计,不会因为「直接完成」而绕开事实图。
  • 主阶段只有已经解决问题时才允许返回,而且必须 fact 与 complete 同时给出。这就堵死了「模型觉得差不多了就宣布完成」这条路:要么拿出证据并宣布完成,要么继续干到被收尾。
文档与代码的差异(读源码时要注意)

docs/specs 写的是 reason 返回单个 intent,但当前代码支持一次返回 intents[] 数组,并新增了 tasks.reason.max_intents(默认 3,示例配置里设成 2);旧的单个 intent 仍然兼容。

另外三处小漂移:pi 已经是第三种正式 backend(README 有写,spec 的校验清单只列了 claudecode/codex/mock);mock 的 bootstrap 支持 complete 结果;spec 说 Dispatcher 会清理找不到项目的 orphan 容器,ContainerManager.cleanup_orphan() 也实现了,但当前调度循环里没有调用点。

06 / SCHEDULER

调度器每一轮在想什么

它不聪明,但很守规矩:先照顾已经在跑的项目,再看有没有空位接新项目。

主循环的骨架非常短。真正的复杂度全在「某个项目现在该不该派任务、派哪一种」这一个判断里:

dispatcher/scheduler/loop.py · 主循环
while True:
    reap_futures()              # 回收已完成任务,更新 reason checkpoint / 冷却窗口
    summaries = list_projects() # 一次拿到所有项目的元信息与计数
    init_reason_checkpoints()   # 给"有 open intent 但还没基线"的项目建基线
    refresh_runtime_projects()  # 已退出 active 的项目释放 admission 名额
    cancel_inactive_tasks()     # stopped / deleted → 立刻取消本地任务
    queue_container_cleanups()  # completed / stopped → 异步清理容器
    dispatch_available()        # 真正的派发
    sleep(interval)

6.1全局顺序:已运行的项目优先

派发顺序(简化)
# 1. 先把已经在跑的项目喂饱(轮转起点每轮后移,避免饿死)
for project in running_projects_round_robin:
    if has_dispatchable_bootstrap(project): dispatch_bootstrap; continue
    if has_dispatchable_reason(project):    dispatch_reason;    continue
    if has_dispatchable_explore(project):   dispatch_explore;   continue

# 2. 所有在跑项目都暂时没活干,且还有 admission 名额,才接一个新项目
if running_project_count < max_running_projects:
    maybe_start_one_new_project()

这个顺序有一个直接后果:一个项目即使暂时没有可派发的任务,也继续占用 max_running_projects 的名额,直到它真的退出 active。也就是说,admission 是「接手」语义,不是「正在执行」语义。

6.2项目内判断:五道门

#判断成立就做什么不成立去哪
1容器正在清理?单项目任务数已到 max_project_workers?跳过本项目继续
2项目还是 active 吗?否 → 跳过(下一轮会被取消/清理)继续
3处于初始态?(只有 origin+goal,且无普通 intent)Bootstrap 可用 → 派 bootstrap;否则派 reason(trigger=initial)继续
4没人持有 reason lease,且出现了新态势?派 reason继续
5存在未认领、未结论、非 bootstrap 的 Intent?派 explore(当前实现挑 created_at 最新的那条)本轮什么都不做

注意第 4 道门在第 5 道之前:只要态势变了,reason 可以先于 explore 跑,哪怕图上还有没被认领的 Intent。这和 spec 里「reason 要求当前没有未认领 intent」的表述看起来矛盾,其实是同一件事的两面——那句约束描述的是首次触发;一旦有了基线,新 Fact / 新 Hint 就足以让 reason 再跑一次,否则新信息会被积压。

6.3reason 的去重:什么才算「新态势」

这是整个调度里最容易写错的地方。如果只要图上有一点点变化就触发 reason,系统会自激:reason 创建了一条 intent → 图变了 → 再触发 reason → 又创建一条……永远停不下来。

Cairn 的做法是给每个项目记一个 checkpoint:上次成功跑完 reason 时的 facts 数量、hints 数量、以及当时有没有 open intents。之后只有三种情况算新态势:

Fact 变多

新的探索有了结果,全局判断值得重做。

Hint 变多

人给了新判断,或者别的 Agent 写了态势评估。

open intents 归零

从「还有事在做」变成「没人做事了」,必须有人决定下一步。

不算新态势的:Intent 总数变多(那通常只是上一次 reason 自己的产物)、某次 explore 失败后 release(Intent 还是 open,只是换了个人接手)、心跳丢失。

还有一个容易被忽略的启动细节:Dispatcher 启动时,会给「当前有 open intent、但还没有 checkpoint」的 active 项目补一个基线。否则它会把运行过程中新增的第一条 Fact 当成「从来没见过的变化」而误触发,或者反过来把真正的第一次变化吞掉。

6.4选哪个 Worker

规则是可预测的六步过滤,没有玄学:

  1. 按任务类型筛:这个 Worker 的 task_types 里有没有当前任务。
  2. 按并发筛:当前运行数是否已经到 max_running。
  3. 按健康筛:是否处于「刚检查失败」的 5 秒冷却窗口里。
  4. 按拒绝筛:是否在这个项目 + 这个任务类型上刚拒绝过(同样 5 秒冷却,防止同一个模型反复撞墙)。
  5. 按 priority 升序:数字越小越优先。
  6. 同优先级里选当前运行数最少的;再相同就随机。

最后一步(随机)不是偷懒,而是为了避免多个 Worker 在完全等价时总是打到同一个上,形成隐性偏好。

6.5四层并发,各管一段

配置限制的是什么典型用途
runtime.max_workersDispatcher 同时运行的任务总数保护本机 CPU / 内存 / 网络
runtime.max_running_projects已接手且仍 active 的项目数(admission)防止一次接太多项目,每个都半死不活
runtime.max_project_workers单项目内同时运行的任务数(bootstrap + reason + explore 都算)防止一个项目吃满整个 Dispatcher
workers[].max_running单个 Worker 的并发上限对应一个独立的 LLM 配额单元

关于 Worker 的建模有个明确约定:一个 Worker 应该对应一个独立的 API key / 配额单元,不要把同一个 key 拆成多个 Worker 来「提高并发」——那只是把限流问题藏起来。同一个模型想跑两份,就用两个不同的 key,配成两个 Worker。

6.6硬停止与取消

项目被切到 stopped 后,Dispatcher 把它当硬停止处理,顺序很严格:不再派新任务 → 对本地还在跑的 bootstrap / explore / reason 立即发取消 → 已取消的任务不再进入收尾流程(不会再多写一条 Fact)→ 后续轮次停掉容器、杀掉容器里还在跑的 Agent 进程。

「不再收尾」这个细节很重要。收尾(conclude)本来是为了「超时了也别白跑」,但如果是人主动喊停,再让模型总结一遍就违背了停止的意图——而且此时项目已经不是 active,写回也会被 403 拒绝。

6.7日志:只在状态变化时说话

调度器每 3 秒转一圈,如果每圈都打印「没有可派发的任务」,日志会变成噪音。所以实现里有一个 _log_changed(scope, ...):同一个 scope 下,只有日志内容和参数真的变了才输出。

必须可见的是:容器创建、任务派发、容器内新进程启动、健康检查失败、超时、收尾、释放 Intent、Worker 进入冷却窗口。稳定轮询、正常心跳、重复 skip 则原则上不刷屏。

07 / ONE FULL RUN

走完一整轮:图是怎么长出来的

点「下一步」,看着一颗 flag 从零被搜出来。左边是图,右边是每一步的任务、协议调用和理由。

下面这个例子是虚构的,但每一步都对应真实的协议调用和调度判断。目标是一台 Web 服务器,成功条件是拿到 flag。注意观察三件事:每一轮的任务类型如何切换、每次任务留下了什么、以及「没找到」为什么也必须写进图里。

proj_001 · target http://10.0.0.5 · goal: 拿到 flag 01 / 13
已确认 Fact 已结论 Intent 未落定方向 将出现的落点
起点:两块石头
STEP 01 · PROJECT CREATED

7.1看完之后,三个值得记住的点

任务类型是状态推出来的

同一个 Worker 池,前 3 步在跑 bootstrap,中间在 reason 和 explore 之间切换。没有人给它们分配「阶段」,是图的状态决定了这一轮该干什么。

失败也留下资产

f003 是「备份目录没有其他泄露」。它没有推进攻击,但它排除了一个方向,让后面的 reason 不必重复提议。负结论同样是 Fact。

完成是一条边

最后一步不是把状态改成 completed 就完事,而是在图上补了一条 f007 → goal 的已结论 Intent。谁、基于什么、什么时候判断完成,全在图里。

一个容易被忽略的节奏

reason 不会因为「explore 刚跑完一条」就触发,它只认 facts / hints 的变化。所以在这个例子里,i002 和 i003 是并行提出的两条方向,但它们各自的结论都会让 facts 增加,从而在下一轮触发新的 reason。图的生长是批次的,不是每走一步就重新规划一次。

08 / AGENT CONTRACT

Agent 那一侧:只收一段话,只回一个 JSON

把协议从模型手里拿走之后,Agent 变成了一个纯函数。难的是怎么让它稳定地吐出合法 JSON。

8.1Prompt 是怎么拼出来的

Prompt 是仓库里的 markdown 文件,按 runtime.prompt_group 选目录(default 或 mock)。Dispatcher 用字符串替换把占位符填上:{graph_yaml}、{fact_ids}、{open_intents}、{intent_id}、{origin}、{goal}、{hints}、{max_intents}。

其中 {graph_yaml} 不是图本身,而是一段「去读文件」的指令。真正的 YAML 被写进容器的临时目录:

写入 prompt 的图引用
The graph YAML snapshot is stored in this file inside the current container:

/tmp/cairn-prompts/explore_execute-3f9a1c2b7d4e/graph.yaml

Before using the graph, read the entire file and treat its contents as
the YAML snapshot for this Graph section.

这个设计解决的是上下文成本问题:图会随着探索越来越大,而每次调用都把它全文塞进 prompt,既浪费 token 也容易被截断。写进文件后,模型可以自己决定读多少、怎么读——而它本来就是一个会读文件的 CLI。

8.2输出解析:先容错,再严格

模型很啰嗦,经常在 JSON 前后加一段解释,或者用 markdown 代码块包起来。所以解析分两层:

  • 提取层(output_parser.py):先试整段文本,再试所有 ``` 代码块,最后从每一个 { 开始尝试 raw_decode,直到捞出一个 dict。只要输出里有一个合法 JSON 对象,就能拿到。
  • 校验层(contracts.py):拿到 JSON 之后不再宽容。字段必须齐、类型必须对、语义必须自洽(比如 complete 和 intents 不能共存)。任何一条不满足,就按「解析失败」处理。

容错是为了兼容不同 CLI 的包装格式,严格是为了不让半成品污染图。宽容只发生在提取阶段,验证阶段一寸不让。

8.3双阶段:超时之后,别丢掉现场

一个 explore 跑了 300 秒,超时被杀。如果这时直接重来,模型之前读过的图、试过的命令、排除的方向全丢了。所以 Cairn 用「同一个会话再来一次」的方式收尾:

后端第一阶段(execute)第二阶段(conclude)session 从哪来
claudecodeclaude --session-id {uuid} -p -- {prompt}claude -r {uuid} -p -- {prompt}Dispatcher 预生成 uuid
codexcodex exec ... -- {prompt}codex exec resume {id} ...从 stderr 里正则提取 session id:
pipi --session {id} -p {prompt}同一 session-dir 恢复从 pi 输出的 session 事件里提取 id

收尾 prompt 的措辞非常硬:不要继续探索,不要等待未完成的任务,只总结截至目前已经确认的事实。它不追求「再努力一下」,只追求「别白跑」。所以收尾产出的 fact 往往是半成品——但这半成品比没有强,而且它同样要经过严格的 JSON 校验。

为什么 bootstrap 的主阶段不允许只返回 fact

因为 bootstrap 的唯一目的就是「一次做完」。如果允许它随时交一个 fact 就退场,它就会退化成一次昂贵的 explore,还占着初始态的调度位。要么带着 complete 回来,要么被超时打断、由收尾阶段留下一块石头。

8.4失败不是一种,是七种

「任务失败」这句话在实现里没有意义,因为不同失败对应完全不同的处理。这是整份代码里最值得逐行读的部分:

现象判定动作写图
reason 超时作废释放 reason lease,本轮不写否
explore 超时(双阶段)可收尾杀进程 → 保留 session → 跑 conclude看 conclude
explore 超时(单阶段)失败release intent否
输出非 JSON / 缺字段解析失败双阶段进收尾;单阶段 release看收尾
accepted: falserejectedrelease,该 Worker 在该项目+任务上冷却 5s否
命令退出码非 0失败release否
心跳 403(项目不再 active)硬停止立即杀进程,不进收尾否
心跳 409(lease 丢了)失去资格立即杀进程否
写回 403项目已停作废,不重试否
写回 409竞争失败作废 / 跳过否
健康检查失败Worker 不可用本次作废,Worker 冷却 5s否

两个贯穿始终的原则:不做立即重试(失败就把状态留给下一轮,让图决定要不要再来一次),以及失败绝不写半条数据(conclude 是原子的,要么 fact + 边一起落定,要么什么都没有)。

8.5健康检查:验证的不是「容器活着」

Dispatcher 会在启动时(以及可选的每次任务前)对每个 Worker 发一次极小的 LLM 请求:claudecode 打 {base}/v1/messages,codex 打 {base}/responses,pi 按 PI_PROVIDER_API 选 /chat/completions 或 /responses 或 /v1/messages。它只看 HTTP 状态码:2xx 即健康。

这一步验证的是「base URL 可达 + key 有效 + model 可调用」——也就是这个 Worker 的 LLM 配置真的能用,而不是容器有没有在跑。请求还会带上 Worker 环境里的 http_proxy / https_proxy,保证健康检查和真实出网走同一条路。

Local 模式不打 API(本机 CLI 自带鉴权),改成启动时对每个 CLI 跑一次 --help 探测。

09 / TRADE-OFFS

十三个设计决定,以及它们的账单

每一个「优雅」背后都有代价。看懂代价,才算真的看懂设计。

决定换来什么付出什么
不定义角色任务从图的状态生成,关键路径不会被阶段切碎模型得自己从图里推断「我该干什么」,弱模型容易空转
黑板而非消息全局状态唯一,读一次就是全量;没有信息孤岛没有「协商」能力,冲突只能靠 claim 解决
Fact 只增不改并发无写冲突、审计完整、时序即状态图会膨胀,必须靠 Hint 写态势评估来压缩认知成本
reason lease 不入图协调状态不污染知识,图保持纯因果多一份需要维护的字段、超时和释放逻辑
单 Dispatcheradmission、冷却、容器清理都在一个进程里,简单可控吞吐有上限;多实例不支持,因为本地状态不跨进程协调
写入收敛到 DispatcherAgent 不可能越权、不会竞态;换模型零成本Dispatcher 成为单点,它挂了整个自动化就停
interval 复用主循环节拍 = 心跳周期,少一个参数调大 interval 心跳变粗(更容易超时),调小则空转变多
bootstrap 强制 fact+complete不允许无证据宣布完成模型必须一次给出两样,失败率更高,容易掉进收尾
三种超时策略按任务风险定价:reason 便宜且不能乱写,explore 贵但必须留东西参数要调;配错会出现「每次都在收尾」或「永远跑不完」
stdout 全文 + 容错解析兼容各种 CLI 的啰嗦输出与包装格式解析失败只能靠收尾兜底,日志里会有一堆 preview
一个项目一个容器环境隔离、现场可保留、工具链完整资源开销大,冷启动慢;需要 Docker
不做立即重试逻辑简单,不会因为一个坏 Worker 雪崩失败要等下一轮 reason 重新提议,恢复慢半拍
领域知识放容器引擎完全通用,换领域不用改一行协议代码换领域要自己做镜像、写 AGENTS.md、准备工具链
三条不变量

一、任何时候,图都是当时已知事实的完整表达——不存在「图外的隐藏状态」。

二、任何写入都经过校验和认领,不会出现两个消费者同时改同一条边。

三、任何失败都不会污染图:要么整体成功,要么什么都没发生。

9.1它凭什么可信

README 里给了一个相当硬的证据:腾讯云黑客松 AI 渗透挑战赛第二届,610 支队伍、1345 人,Cairn 是唯一 AK(54/54 全部解出)的队伍,总排名第 3。更关键的是赛后说明——系统在比赛前从未跑过,完整链路是比赛当天凌晨 4 点第一次上线,没有训练、没有调参、没有领域专用工具,零 MCP、零 RAG、零预设 Agent 角色。

这不是「通用性」的证明,一个领域的成绩不能推出所有领域都行。但它至少说明了一件事:这套结构本身足以承载一次真实的、对抗性的搜索。领域的部分(Kali 工具链、比赛平台 API)全部放在容器镜像和 AGENTS.md 里,引擎确实不知道自己在打 CTF。

10 / GETTING STARTED

从零跑起来:三条路径

先别急着接真模型。用 mock 跑一遍调度,看一小时日志,比读十遍文档有用。

10.1前置条件

  • macOS 或 Linux(Windows 建议 WSL2)
  • Python ≥ 3.12
  • Docker(只有容器模式需要;local 模式不需要)
  • 至少一个可用的 LLM 端点:Anthropic 兼容 / OpenAI 兼容 / 自建网关都行

10.2路径 A:Docker Compose(推荐)

terminal
# Worker 容器镜像(比赛用的 Kali 环境,引擎本身不依赖它)
docker pull --platform=linux/amd64 ghcr.io/oritera/cairn-worker-container:latest

# 构建 Cairn 用的基础镜像
docker pull ghcr.io/astral-sh/uv:python3.13-trixie

cp dispatch.example.yaml dispatch.yaml
# 填好 server、workers[].env 里的 base_url / model / key

docker compose up --build

起来之后 cairn-server 监听 8000,健康检查通过后 cairn-dispatcher 启动。数据落在 ./datas/cairn/,浏览器打开 http://127.0.0.1:8000 就是控制台。

10.3路径 B:手动起两个进程

terminal
uv run --project cairn cairn serve

# 另开一个终端
uv run --project cairn cairn dispatch --config dispatch.yaml

# 只跑启动健康检查,确认 key 和模型可用
uv run --project cairn cairn dispatch --config dispatch.yaml --startup-healthcheck-only

# 只跑一轮调度(调试用)
uv run --project cairn cairn dispatch --config dispatch.yaml --once

# 回归测试(不需要 Docker / 不需要模型)
uv run --project cairn --group dev pytest

10.4路径 C:Local 模式(无 Docker、无 Key)

terminal
cp dispatch.local.example.yaml dispatch.yaml
uv run --project cairn cairn serve
uv run --project cairn cairn dispatch --config dispatch.yaml

runtime.execution: local 时,Worker 直接以子进程跑在宿主机上,复用你已经登录好的 CLI。每个项目在 local.workspace_root 下分到一个子目录。没有沙箱,用你的权限跑,所以别把它塞进 Docker 里。

10.5第一步其实应该是 mock

dispatch_mock.yaml 配了两个纯模拟的 Worker,不调用任何 LLM,只按概率和延时返回各种结果(包括超时、非法 JSON、拒绝)。想看调度器在各种失败下怎么反应,这是成本最低的方式:

terminal
cp dispatch_mock.yaml dispatch.yaml
uv run --project cairn cairn serve
uv run --project cairn cairn dispatch --config dispatch.yaml --log-level DEBUG

mock 的每个 phase 都能配延迟区间和结果分布,甚至能用 rules 按 fact_ids_gte / open_intents_empty 强制某个结果——也就是说,你可以写一个完全确定的剧本,验证「第一次 reason 提 2 条 intent、第二次提 1 条、第三次完成」这种时序。

10.6该看什么

控制台(8000)

左侧是图(Cytoscape,可切 dagre / klay / elk 布局),右侧三个页签:Detail(选中节点/边的详情)、Hints(注入人的判断)、Log。右上角能导出 YAML / Timeline,也有 Replay 按时间回放图的生长。

Dispatcher 日志

关注四类行:dispatched <task>(派了什么)、intent concluded(写回了什么)、reason created intent(新方向)、worker marked unhealthy / rejected(谁被冷落)。

10.7七个容易踩的坑

现象原因
启动直接 RuntimeError:intent_timeout=… must be greater than dispatcher intervalServer 的超时必须大于 Dispatcher 的心跳周期,否则认领还没续约就过期了。改 /settings 或调小 runtime.interval
接口一直 403不是权限问题,是项目不在 active。这是协议里统一的「别再写了」信号
接口 409要么这条 Intent 已被别人认领,要么已经结论。都是正常的竞争结果,不是 bug
初始态项目反复重跑 bootstrap两阶段都失败了就会 release,下一轮仍按新项目处理。想关掉就把 bootstrap_enabled 设 false
改 prompt 后启动报「missing placeholders」加载时会校验每个 prompt 必须包含的占位符,删了 {graph_yaml} 之类就起不来
max_project_workers 配得比 max_workers 大配置校验直接拒绝:单项目上限不可能超过全局上限
起了两个 Dispatcher不支持。admission、冷却、容器清理都是进程内状态,两个实例会互相打架
11 / CODE MAP

代码地图与阅读顺序

九千行 Python,真正需要读懂的大约两千行。按这个顺序读,不会迷路。

cairn/src/cairn/ ├── cli.py # 两个入口:serve / dispatch(含 --once、--startup-healthcheck-only) ├── server/ │ ├── db.py # SQLite schema:projects / facts / intents / intent_sources / hints / counters │ ├── models.py # 请求/响应模型,文本字段统一 strip 后非空校验 │ ├── services.py # 懒过期、id 分配、claim 校验、lease 清理 │ ├── routers/projects.py # 生命周期、reason lease、complete、reopen │ ├── routers/intents.py # declare / claim / release / conclude │ ├── routers/export.py # yaml(结构)/ timeline(时序) │ └── static/index.html # Alpine + Cytoscape 的单页控制台(3400 行,可选读) └── dispatcher/ ├── config.py # dispatch.yaml 的 pydantic 模型 + prompt 资源校验 + mock 概率校验 ├── scheduler/loop.py # 主循环、五道门、worker 选择、取消、容器清理(核心) ├── scheduler/worker_select.py # priority → 运行数 → 随机 ├── tasks/ │ ├── bootstrap.py # 主阶段 + 收尾 + fact&complete 写回 │ ├── reason.py # 读图 → complete / intents / {};lease 释放 │ ├── explore.py # claim → 执行 → conclude;超时走 session 收尾 │ └── common.py # 进程执行、心跳绑定、conclude 写回、图快照引用 ├── contracts.py # 三种任务的输出 schema 校验(严格) ├── output_parser.py # 从自由文本里捞 JSON(宽容) ├── prompting.py # 加载 markdown、占位符替换、JSON 格式化 ├── workers/ │ ├── base.py # Driver 接口:health / session / execute / conclude │ ├── health.py # 进程内 HTTP ping,带 worker 代理 │ └── adapters/ # claudecode / codex / pi / mock └── runtime/ ├── containers.py # 每项目一容器、docker exec、tar 写文件 ├── local_backend.py # 无容器模式:子进程 + 每项目工作目录 ├── heartbeat.py # 带 grace 的租约;403/409 立即失败并杀进程 ├── cancellation.py # 停止信号 → 杀进程 └── process.py # 进程封装:timeout / kill / 输出收集

11.1建议的阅读顺序

  1. docs/specs/server-protocol.md 的「核心概念」一节——先建立 Fact / Intent / Hint 的语义直觉。
  2. server/db.py 的表结构——你会发现整套协议其实只有五张表。
  3. server/routers/intents.py——看 conclude 怎么原子地「建 Fact + 落边」。
  4. dispatcher/config.py——先知道有哪些旋钮,再看它们怎么被用。
  5. dispatcher/scheduler/loop.py 的 _try_dispatch_project——五道门全在这一个函数里。
  6. dispatcher/tasks/explore.py——最典型的任务:claim → 执行 → 解析 → 收尾 → 写回。

读完这六处,剩下的都是变体:bootstrap 是「多一个 complete」的 explore,reason 是「不执行、只判断」的 explore,其他 driver 只是换命令行。

11.2想扩展它,改哪里

接一个新模型

在 workers/adapters/ 下实现一个 Driver:check_health、prepare_session、build_execute、build_conclude、extract_session、extract_response_text、supports_conclude。再在 config.py 注册它需要的环境变量、在 registry 登记类型。协议层一行都不用改。

加一种任务类型

不便宜。要同时改 config(任务配置)、prompting(占位符校验)、contracts(输出校验)、scheduler(派发门)、以及写回逻辑。三类任务是刻意的约束——它把「调度器需要理解的东西」压到了最小。

换一个领域

引擎不动。准备一个新镜像(工具链)、一份 AGENTS.md(环境说明)、一套 prompt 措辞(把「渗透测试」换成你的领域),再把 origin / goal 写清楚。领域知识应该待在容器里,不该渗进协议。

接一个可视化 / 新消费者

只依赖 HTTP 协议即可:轮询 GET /projects、claim、heartbeat、conclude。注意它不是「只能由 Dispatcher 用」——协议本身对人类和 UI 同样开放,Dispatcher 只是最复杂的那一个消费者。

11.3关键函数索引

想看什么去哪
项目现在该派什么任务DispatcherLoop._try_dispatch_project
什么算「新态势」DispatcherLoop._reason_trigger / _initialize_reason_checkpoints
选哪个 WorkerDispatcherLoop._select_worker + choose_worker
超时后怎么收尾tasks/explore.py::_try_conclude_fallback
输出怎么校验contracts.py::validate_*_payload
怎么从噪音里捞 JSONoutput_parser.py::extract_json_object
心跳失联怎么杀进程runtime/heartbeat.py::HeartbeatLease._fail
完成/推翻完成的图操作routers/projects.py::complete_project / reopen_project
12 / CHECK YOURSELF

六道题,检验你是不是真的懂了

都是协议层面的判断题。点选项看解析。

Q1一条未结论 Intent 的 worker 心跳超时了,会发生什么?

超时只回收「谁在干」这个 claim,不销毁方向本身。已结论的 Intent 不参与超时,worker 永久保留为产出结论的人。

Q2reason 刚创建了两条新 Intent,它会立刻被再次触发吗?

新态势只有三种:Fact 变多、Hint 变多、open intents 从有到无。把「Intent 变多」也当触发条件会让 reason 自激——它刚提的方向又成了下一次触发的理由。

Q3explore 超时,Worker 支持双阶段。什么情况下最终会写图?

收尾是一串前置条件的交集:driver 支持、session 存在、心跳未失、项目未停、收尾在超时内返回合法 JSON、conclude 写回成功。任何一环断了,整次探索作废且不写图。

Q4bootstrap 主阶段只返回了 fact,没返回 complete,会怎样?

主阶段的契约是「要么 fact+complete 一起给,要么不给」。缺 complete 就按解析失败处理,掉进收尾;收尾只允许 fact,绝不允许 complete。

Q5项目标记完成后,外部发现 flag 是假的。正确操作是?

completed 项目不能通过 status 接口改状态。/reopen 是唯一出口:它会删掉那条完成边(因为那个判断确实不成立),把纠错信息写成新 Fact,并从原来的 from 拉一条 external_feedback 边指向它。

Q6为什么不让 Agent 直接调用 Server API、自己认领 Intent?

两个独立理由:模型不该在上下文里学一套 HTTP 状态机;多写者会带来 claim 竞争、重复 conclude、越权 release。让 Agent 只做「prompt → JSON」,它就成了可替换的纯函数。
13 / CHEAT SHEET

速查表

上手时需要反复查的东西,都放这一页。

13.1API

方法 / 路径作用备注
GET /settings · PUT /settings读写 intent_timeout / reason_timeout最小值 5 秒;必须大于 Dispatcher 的 interval
GET /projects项目列表 + 统计摘要读取时会顺手做过期清理
POST /projects创建项目,写入 origin / goal可带 bootstrap_enabled 与初始 hints
GET /projects/{id}完整图数据facts / intents / hints / reason
PUT /projects/{id}/statusactive ↔ stoppedcompleted 不能走这里;切 stopped 会立刻清空所有 claim
POST /projects/{id}/reason/claim认领项目级 reason lease已被别人占用且未超时 → 409
POST /projects/{id}/reason/heartbeat续约只有持有者能续
POST /projects/{id}/reason/release主动释放幂等
POST /projects/{id}/hints补充 Hint任何状态都可以写
POST /projects/{id}/intents声明一条 Intentworker 要么 null,要么等于 creator
POST .../intents/{iid}/heartbeat认领 / 续约未认领 → 认领;被别人占 → 409
POST .../intents/{iid}/release放弃,回到未认领只有当前 worker 能释放
POST .../intents/{iid}/conclude原子地「写 Fact + 落边」已结论 → 409
POST /projects/{id}/complete写 from[] → goal 完成边creator 与 worker 都是调用者
POST /projects/{id}/reopen推翻完成态只对 completed 有效
GET /projects/{id}/export?format=yaml结构快照不含心跳与 reason lease
GET /projects/{id}/export?format=timeline事件时间线纯文本,按时间排序

13.2状态码到底在说什么

码含义该怎么办
403项目不在 active(stopped / completed)停止写入,取消本地任务;这不是权限错误
409已被别的 worker 认领 / 已经结论 / reason 被占放弃本次尝试,下一轮再说
404项目、Fact 或 Intent 不存在检查 id;或项目已被删除
400from 里含 goal,或 worker ≠ creator修请求
422文本字段 trim 后为空修请求

13.3配置字段

字段必填说明
server是Cairn Server 地址
runtime.max_workers是全局同时运行任务数
runtime.max_running_projects是已接手且 active 的项目上限
runtime.max_project_workers是单项目并发上限(≤ max_workers)
runtime.interval是主循环节拍 = 心跳周期(秒)
runtime.healthcheck_timeout是健康检查超时
runtime.worker_healthcheck否startup_and_task / startup_only(默认)/ disabled
runtime.execution否container(默认)/ local
runtime.prompt_group是default / mock,对应 prompts 子目录
tasks.bootstrap.timeout / conclude_timeout是主阶段 / 收尾
tasks.reason.timeout / max_intents是 / 否判断超时;单次最多提几条方向(默认 3)
tasks.explore.timeout / conclude_timeout是主阶段 / 收尾
container.image / network_mode / completed_action容器模式必填动作可选 stop(留现场)/ remove
container.cap_add否需要抓包 / 改网络时才加,例如 NET_RAW
local.workspace_root / completed_action否local 模式的工作目录根;完成动作 keep / remove
common_env否先并入每个 worker,再被 worker.env 覆盖
workers[].name / type / task_types / max_running / priority / env是type 支持 claudecode / codex / pi / mock

13.4术语

Origin / Goal
项目创建时写入的两个特殊 Fact,图的起点与汇点,id 固定为 origin / goal。
Claim
通过 heartbeat 把一条 open Intent(或项目级 reason)占为己有,防止并发执行。
Lease
带超时的认领。心跳停止超过阈值,lease 自动失效,别人可以接手。
Conclude
探索结束的原子操作:创建一个 Fact,同时把 Intent 的 to 指向它。
Bootstrap Intent
固定三元组 description="bootstrap" + creator="dispatcher.bootstrap" + from=["origin"] 的保留 Intent,用来标记「初始阶段的一次直接推进」。
Checkpoint
Dispatcher 本地记的「上次 reason 时的 facts / hints / open intents 数量」,用来判断什么算新态势。
Stigmergy
个体之间不直接通信,通过改变共享环境间接协调。这里是「读图 / 写 Fact」。
Admission
Dispatcher 接手一个项目的动作。接手后即使暂时没活干,也继续占用 max_running_projects 名额。
如果你只记住一句话

这套系统的全部聪明之处,不在于某个 Agent 有多强,而在于它把「未知路径」变成了一个可以逐块累积、可以并发推进、可以事后审计的图;然后让每一条新事实自动成为下一次决策的输入。

下一步可以做什么

读完这份文档,最有效的验证方式不是继续读,而是用 dispatch_mock.yaml 起一套环境,自己改 MOCK_* 的概率和 rules,造一个「第二次 reason 必定完成」的剧本,观察 checkpoint 和 intent 数量怎么变。跑通之后再把 worker 换成真模型,你会对每一个日志行都有直觉。