不定义角色,
也能走出未知的路
Cairn 是一个通用的问题求解引擎:给它一个起点和一个终点,它在未知的状态空间里搜索一条路径。 渗透测试只是它第一个被验证的领域。这份文档从零开始,拆开它的每一个设计决定—— 不是告诉你「它有什么」,而是告诉你「为什么必须这样」。
- 把问题变成一张图。已知的写成 Fact,要探索的方向写成 Intent,人的判断写成 Hint。
- 图是唯一真相源。Server 只负责存图、维护一致性,不做任何推理。
- Dispatcher 是唯一的自动化写入者。读图 → 选任务 → 选 Worker → 执行 → 把结果写回图。
- 只有三类任务。
bootstrap直接试着做完,reason看全局决定下一步,explore走完一条边。 - Agent 没有角色。它只收到一段 prompt,只返回一个 JSON。任务是根据图的当前状态在运行时生成的。
- 图只增不改。完成 = 补一条指向
goal的边;判错了就reopen,再补一块「此路不通」的石头。
问题的形状,决定了引擎的形状
在写第一行代码之前,Cairn 先做了一个判断:有一类问题长得一模一样,它们都可以被写成「已知起点、已知终点、路径未知」。
渗透测试的形态非常干净:你知道目标是什么(一个 IP、一个站点、一个二进制),你知道成功长什么样(一个 shell、一个 flag、一份权限证明),但你不知道中间该怎么走。这条路径不在任何文档里,它藏在目标的实现细节里,只能靠一次次试探把它逼出来。
| 要素 | 渗透测试 | 一般形式 |
|---|---|---|
| 起点 | 目标 IP / 域名 / 样本 | 已知的初始状态 |
| 终点 | shell / flag / 权限证明 | 可判定的成功条件 |
| 路径 | 侦察 → 打点 → 提权 → 横向 | 未知,且分支极多 |
同样形状的问题还有一大把:漏洞研究(从「这段代码可疑」到「构造出崩溃」)、数学证明(从公理到目标命题)、CTF、甚至线上故障排查(从「服务 500」到「找到那行配置」)。它们的领域知识完全不同,但搜索结构是同构的。
1.1三种常见做法,三种失败
角色编排
把任务拆给「侦察 Agent」「打点 Agent」「提权 Agent」。角色来自人的经验总结,但真实的关键路径经常跨阶段:一个备份文件同时是信息泄露、凭据来源和权限入口。角色会把它切成三段,每次交接都掉一层上下文。
固定工作流
把流程画成 DAG:先扫描、再指纹、再打点。工作流的前提是「顺序已知」,而顺序恰恰是这道题要求你求出来的东西。一旦现实不按流程图走,系统要么卡住,要么空转。
自由多 Agent 对话
让 Agent 互相发消息协商。通信成本随人数平方增长,每次转述都是一次有损压缩,最要命的是:没有一个地方持有「当前全局状态」。谁都不知道别人已经排除了什么。
不预设角色,不预设流程。只维护一块共享的板子(图),让每个 Agent 读同一份全局状态、各自往前推进一步、把结果写回板上。任务不是分配好的岗位,而是从图的当前状态里当场生成的。
这就是 README 里那句 “It defines no roles, no workflows.” 的实际含义——它不是一句口号,而是一整套结构选择的起点。
这套思路并不新。1970 年代 CMU 的 Hearsay-II 语音识别系统就提出了黑板架构:多个专家围绕一块共享黑板工作,各自读取当前状态、各自贡献新知识,没有中央调度。Cairn 做的是把黑板重新形式化:用一张有向图当黑板,用 Intent 保留完整的因果链,于是这张图同时是知识库和推理过程的审计日志。
协调机制则对应蚁群的信息素(stigmergy):个体之间不直接通信,而是通过改变共享环境来间接协调。Agent 读图、写 Fact,就是在环境上留下信息素。
整个系统只有三个原语
知识、行动、判断。刚好三样,多一个都是负担。
客观事实
一条被确认的发现,写进板上就永久保留。只增不改。状态会变,但「变了」本身也是一条新事实。
f001 · 80 端口是 nginx + PHP 7.3
探索意图
一条声明的方向,在图上是边。它从若干 Fact 出发,指向一个还不存在的结论。声明了不等于做了。
i002 · 目录扫描(from f001)
人类提示
图外的输入,不属于事实、不参与因果。它是「指挥官意图」,可以是策略建议,也可以是对当前态势的评估。任何时候都能写。
h003 · 优先看文件上传,SSH 已排除
三个原语的分工非常硬:Fact 回答「现在知道什么」,Intent 回答「接下来往哪走」,Hint 回答「我认为该注意什么」。没有一个原语是「计划」「阶段」「角色」或者「任务」——因为这些东西都可以由图的状态推导出来,一旦写进数据结构,就变成了需要维护的第二种真相。
2.1为什么 Fact 必须只增不改
想象多层网络渗透里的真实场景:
f003:拿到 host-A 的 root shell。f007:通过 host-A 做跳板,发现内网 10.0.2.0/24。- 十分钟后,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 都可以 | 不回滚,补一条新的 |
一张从起点长向终点的图
Fact 是节点,Intent 是边,goal 是唯一的汇点。完成不是「关掉项目」,而是给图补上最后一条边。
项目的创建就是图的初始化:Server 写入两个特殊 Fact,origin 和 goal。之后所有普通 Fact 由系统分配 id(f001、f002……),所有 Intent 也从 i001 开始。id 是项目内自增的,互相不干扰。
3.1Intent 是一条边,而且可以是超边
一条 Intent 的基本结构是 from[] → to。当 from 里有多个 Fact 时,它等价于一条超边:多个源节点共同支撑同一次探索,共享同一个 description、creator 和 worker。
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 回答「现在长什么样」。
四层,各管一件事
Server 只管一致性,Dispatcher 只管调度与写回,容器只管环境,Agent 只管思考。
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」,它就从真相源退化成了第二个会犯错的推理者。
只有三类任务,构成一个闭环
观察全局、决定方向、执行一步。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}。三种任务的数据形状不同:
{"accepted": true,
"data": {"complete": {
"from": ["f007"],
"description": "flag{...} 满足 goal 要求"
}}}
{"accepted": true,
"data": {"intents": [
{"from": ["f001"], "description": "目录扫描"},
{"from": ["f002", "f004"], "description": "用凭据登录后台"}
]}}
{"accepted": true,
"data": {"description":
"对 /search 测试常见 SQLi payload,未发现注入迹象"}}
{"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() 也实现了,但当前调度循环里没有调用点。
调度器每一轮在想什么
它不聪明,但很守规矩:先照顾已经在跑的项目,再看有没有空位接新项目。
主循环的骨架非常短。真正的复杂度全在「某个项目现在该不该派任务、派哪一种」这一个判断里:
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
规则是可预测的六步过滤,没有玄学:
- 按任务类型筛:这个 Worker 的
task_types里有没有当前任务。 - 按并发筛:当前运行数是否已经到
max_running。 - 按健康筛:是否处于「刚检查失败」的 5 秒冷却窗口里。
- 按拒绝筛:是否在这个项目 + 这个任务类型上刚拒绝过(同样 5 秒冷却,防止同一个模型反复撞墙)。
- 按
priority升序:数字越小越优先。 - 同优先级里选当前运行数最少的;再相同就随机。
最后一步(随机)不是偷懒,而是为了避免多个 Worker 在完全等价时总是打到同一个上,形成隐性偏好。
6.5四层并发,各管一段
| 配置 | 限制的是什么 | 典型用途 |
|---|---|---|
runtime.max_workers | Dispatcher 同时运行的任务总数 | 保护本机 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 则原则上不刷屏。
走完一整轮:图是怎么长出来的
点「下一步」,看着一颗 flag 从零被搜出来。左边是图,右边是每一步的任务、协议调用和理由。
下面这个例子是虚构的,但每一步都对应真实的协议调用和调度判断。目标是一台 Web 服务器,成功条件是拿到 flag。注意观察三件事:每一轮的任务类型如何切换、每次任务留下了什么、以及「没找到」为什么也必须写进图里。
7.1看完之后,三个值得记住的点
任务类型是状态推出来的
同一个 Worker 池,前 3 步在跑 bootstrap,中间在 reason 和 explore 之间切换。没有人给它们分配「阶段」,是图的状态决定了这一轮该干什么。
失败也留下资产
f003 是「备份目录没有其他泄露」。它没有推进攻击,但它排除了一个方向,让后面的 reason 不必重复提议。负结论同样是 Fact。
完成是一条边
最后一步不是把状态改成 completed 就完事,而是在图上补了一条 f007 → goal 的已结论 Intent。谁、基于什么、什么时候判断完成,全在图里。
reason 不会因为「explore 刚跑完一条」就触发,它只认 facts / hints 的变化。所以在这个例子里,i002 和 i003 是并行提出的两条方向,但它们各自的结论都会让 facts 增加,从而在下一轮触发新的 reason。图的生长是批次的,不是每走一步就重新规划一次。
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 被写进容器的临时目录:
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 从哪来 |
|---|---|---|---|
claudecode | claude --session-id {uuid} -p -- {prompt} | claude -r {uuid} -p -- {prompt} | Dispatcher 预生成 uuid |
codex | codex exec ... -- {prompt} | codex exec resume {id} ... | 从 stderr 里正则提取 session id: |
pi | pi --session {id} -p {prompt} | 同一 session-dir 恢复 | 从 pi 输出的 session 事件里提取 id |
收尾 prompt 的措辞非常硬:不要继续探索,不要等待未完成的任务,只总结截至目前已经确认的事实。它不追求「再努力一下」,只追求「别白跑」。所以收尾产出的 fact 往往是半成品——但这半成品比没有强,而且它同样要经过严格的 JSON 校验。
因为 bootstrap 的唯一目的就是「一次做完」。如果允许它随时交一个 fact 就退场,它就会退化成一次昂贵的 explore,还占着初始态的调度位。要么带着 complete 回来,要么被超时打断、由收尾阶段留下一块石头。
8.4失败不是一种,是七种
「任务失败」这句话在实现里没有意义,因为不同失败对应完全不同的处理。这是整份代码里最值得逐行读的部分:
| 现象 | 判定 | 动作 | 写图 |
|---|---|---|---|
| reason 超时 | 作废 | 释放 reason lease,本轮不写 | 否 |
| explore 超时(双阶段) | 可收尾 | 杀进程 → 保留 session → 跑 conclude | 看 conclude |
| explore 超时(单阶段) | 失败 | release intent | 否 |
| 输出非 JSON / 缺字段 | 解析失败 | 双阶段进收尾;单阶段 release | 看收尾 |
accepted: false | rejected | release,该 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 探测。
十三个设计决定,以及它们的账单
每一个「优雅」背后都有代价。看懂代价,才算真的看懂设计。
| 决定 | 换来什么 | 付出什么 |
|---|---|---|
| 不定义角色 | 任务从图的状态生成,关键路径不会被阶段切碎 | 模型得自己从图里推断「我该干什么」,弱模型容易空转 |
| 黑板而非消息 | 全局状态唯一,读一次就是全量;没有信息孤岛 | 没有「协商」能力,冲突只能靠 claim 解决 |
| Fact 只增不改 | 并发无写冲突、审计完整、时序即状态 | 图会膨胀,必须靠 Hint 写态势评估来压缩认知成本 |
| reason lease 不入图 | 协调状态不污染知识,图保持纯因果 | 多一份需要维护的字段、超时和释放逻辑 |
| 单 Dispatcher | admission、冷却、容器清理都在一个进程里,简单可控 | 吞吐有上限;多实例不支持,因为本地状态不跨进程协调 |
| 写入收敛到 Dispatcher | Agent 不可能越权、不会竞态;换模型零成本 | 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。
从零跑起来:三条路径
先别急着接真模型。用 mock 跑一遍调度,看一小时日志,比读十遍文档有用。
10.1前置条件
- macOS 或 Linux(Windows 建议 WSL2)
- Python ≥ 3.12
- Docker(只有容器模式需要;local 模式不需要)
- 至少一个可用的 LLM 端点:Anthropic 兼容 / OpenAI 兼容 / 自建网关都行
10.2路径 A:Docker Compose(推荐)
# 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:手动起两个进程
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)
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、拒绝)。想看调度器在各种失败下怎么反应,这是成本最低的方式:
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 interval | Server 的超时必须大于 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、冷却、容器清理都是进程内状态,两个实例会互相打架 |
代码地图与阅读顺序
九千行 Python,真正需要读懂的大约两千行。按这个顺序读,不会迷路。
11.1建议的阅读顺序
docs/specs/server-protocol.md的「核心概念」一节——先建立 Fact / Intent / Hint 的语义直觉。server/db.py的表结构——你会发现整套协议其实只有五张表。server/routers/intents.py——看 conclude 怎么原子地「建 Fact + 落边」。dispatcher/config.py——先知道有哪些旋钮,再看它们怎么被用。dispatcher/scheduler/loop.py的_try_dispatch_project——五道门全在这一个函数里。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 |
| 选哪个 Worker | DispatcherLoop._select_worker + choose_worker |
| 超时后怎么收尾 | tasks/explore.py::_try_conclude_fallback |
| 输出怎么校验 | contracts.py::validate_*_payload |
| 怎么从噪音里捞 JSON | output_parser.py::extract_json_object |
| 心跳失联怎么杀进程 | runtime/heartbeat.py::HeartbeatLease._fail |
| 完成/推翻完成的图操作 | routers/projects.py::complete_project / reopen_project |
六道题,检验你是不是真的懂了
都是协议层面的判断题。点选项看解析。
Q1一条未结论 Intent 的 worker 心跳超时了,会发生什么?
Q2reason 刚创建了两条新 Intent,它会立刻被再次触发吗?
Q3explore 超时,Worker 支持双阶段。什么情况下最终会写图?
Q4bootstrap 主阶段只返回了 fact,没返回 complete,会怎样?
Q5项目标记完成后,外部发现 flag 是假的。正确操作是?
Q6为什么不让 Agent 直接调用 Server API、自己认领 Intent?
速查表
上手时需要反复查的东西,都放这一页。
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}/status | active ↔ stopped | completed 不能走这里;切 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 | 声明一条 Intent | worker 要么 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;或项目已被删除 |
400 | from 里含 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 换成真模型,你会对每一个日志行都有直觉。