AGENT NETWORK · DOCS
概念与架构

可插拔

构建标签、符号数判据、减法与加法两个方向

可插拔是 ANet daemon 最独特的工程属性,也是唯一一条由构建产物而不是文档来兑现的承诺: 一个模块被拔掉,指的是二进制里没有它的代码,不是运行时把它关掉。

这条属性的判据是符号数,每次提交在 CI 上两个方向都验,并且下游拿到二进制之后 可以在自己的文件上重新数一遍。


命题:让"裁剪"从 git 操作变成构建配置

这句话是三代实现的教训综合,写在 ANet/docs/CONTRACTS-zh.md §0(2026-08-16 原文):

代症状代价
v1组织(org)功能长进 35 个 daemon 文件想拔的时候拔不出来
1.3.0-v3模块之间没有合同开源时只能用斧头,删掉 683,852 行
oss-v0.1减法产品成立,但减法用的是 fork内核逐字拷贝,两个月即漂移

三次失败的形状不同,缺的东西是同一件:代码没有地方可以挂,就会挂得到处都是。 所以 anet4 的唯一命题是:

让"裁剪"从 git 操作变成构建配置。一套代码,N 种发行版。

一套代码意味着不再有 fork,也就不再有漂移;N 种发行版意味着每一种都能说清自己不能做什么, 而这句话必须是可核对的,否则它和"我们关掉了那个功能"没有区别。

判据是符号数,不是"运行时禁用"

对任意一个减法模块 <m>,判据是这两条命令:

go build -o full ./cmd/anet
go tool nm full | grep -c module/<m>                  # 必须 > 0
 
go build -tags no_<m> -o lean ./cmd/anet
go tool nm lean | grep -c module/<m>                  # 必须 == 0

以 x402(付费模块)为例,可以原样复制运行:

go build -o full ./cmd/anet
go tool nm full | grep -c module/x402
 
go build -tags no_x402 -o lean ./cmd/anet
go tool nm lean | grep -c module/x402

两个方向都要验。 只验 lean == 0 会让一个从未被链接进任何构建的模块看起来"可插拔" —— 它在两种构建里都是 0,而检查只看其中一边,于是通过。 full > 0 这一半证明的是"检查确实看得见它要检查的代码"。

符号数与"配置里关掉"是两种不同性质的事实。no_x402 的构建里没有 facilitator 客户端、 没有兑付路径、也没有那个公开监听口,所以"这个节点不可能被诱导付款"是一句关于文件的陈述; 如果代码还在、只是没走到,那句话就退化成一句关于运行时状态的期望。

no_x402 拿走的约 1,600 行(module/x402 的六个源文件加 cmd/anet 里那行 blank import,不含测试) 里包含 x402 模块唯一的公开 HTTP 监听面(配了 voucher_addr 时才开)。 这也是当初把付费从内核拆出去的理由:在它还是内核里 838 行无 tag 代码的时候, 这个承诺根本无法成立。

十个由构建标签管辖的子系统

名字构建标签方向代码位置
p2pno_p2p减法(默认在)module/p2p
anetlinkno_anetlink减法module/anetlink、provider/anetlink
blackboardno_blackboard减法module/blackboard
orgno_org减法module/org
casno_cas减法module/cas
serviceno_service减法module/service
mcpno_mcp减法internal/mcpserv、cmd/anet/mcp.go
x402no_x402减法module/x402
taskboardno_taskboard减法module/taskboard
shellshell加法(默认不在)module/shell

默认构建里由 module.Register 登记的是其中八个,可以直接问二进制:

anet version
anet 0.1.7 (commit unknown, built unknown)
modules: anetlink,blackboard,cas,mcp,org,p2p,service,taskboard,x402

commit 与 built 在这里是 unknown,因为这是一次不带 -ldflags -X 的本地构建; 发布脚本会把 commit 与构建时间打进去,下载到的二进制这两项是具体值。第二行不受影响。

九个名字里有两个要单独说清:

  • mcp 不走模块注册表。它是一个 northbound(anet mcp,stdio 上的 MCP 服务), 代码在 internal/mcpserv,由 no_mcp 管辖。它出现在这一行里,是因为 cmd/anet/module_mcp_declare.go 带着同一个 //go:build !no_mcp,里面一句 module.DeclareCompiled("mcp") —— 声明文件与子系统受同一个 tag 管辖, 所以这个名字不可能比链接器留下的东西活得更久。no_mcp 的构建里名字消失, anet mcp 这条命令仍然存在,但会回答"这个构建是用 -tags no_mcp 编的" —— 比"未知命令"更能把操作者送到正确的地方。
  • shell 默认就不在。带上 -tags shell 之后它会出现在同一行里。

这一行读的是注册表,不是构建时打进去的字符串。理由写在 cmd/anet/main.go 里: go build -tags shell 而不加 -X 会产出一个含有 shell 模块、却报告"没有 tag"的二进制, 一个能把"可以在宿主机执行命令"的构建说成"默认版"的字段,比没有这个字段更糟。 注册表不会说错 —— 一个模块出现在列表里,是因为它的 init() 跑了,而 init() 跑了, 是因为链接器留下了它。

daemon 启动时也会把同一份清单写进日志(internal/daemon/modules.go):

anet: modules compiled in: anetlink,blackboard,cas,mcp,org,p2p,service,taskboard,x402 (started: N)

N 是占位,实际日志里是个数字。compiled in 与 started 是两个数:前者是二进制里有什么, 后者是本机配置里启用了几个。

两个方向:减法与加法

九个减法 tag 默认在、-tags no_<name> 去掉;一个加法 tag shell 默认不在、-tags shell 才有。

方向不对称是有意的,理由不是审美,是两种写错的代价不一样:

写错的方式后果
减法 tag 漏了(该去掉的没去掉)某人想去掉的模块留在了他的构建里
加法 tag 漏了(该不在的进去了)命令执行能力进了所有没要过它的构建

shell 是套件里唯一把一次网络请求变成宿主机上一条命令的模块。需要靠"记得去掉" 来保护的人,恰好是没听说过这个 tag 的人 —— 所以默认必须是安全的那一边,由发布方 主动 opt in。规则可以写成一句:

默认构建应当是完整的,除非某个能力进错构建的后果比缺失更重。

加法方向的判据同样是符号数,只是断言反过来:

go build -o a ./cmd/anet
go tool nm a | grep -c module/shell                   # 必须 == 0
 
go build -tags shell -o b ./cmd/anet
go tool nm b | grep -c module/shell                   # 必须 > 0

加法 tag 还有一个容易被忽略的连带后果:go test ./... 根本看不见 tag 后面的代码。 module/shell/shell_test.go 自己也带 //go:build shell,所以一条不写 tag 的测试命令 永远不会编译、更不会运行这个模块的测试 —— 而这是唯一一个执行命令的模块,是最不该没有覆盖的那个。 因此 CI 与本地脚本都必须另有一条 go test -tags shell ./...。

"编进去"不等于"开着"。带 shell 的构建在没有 modules.shell 配置块时不注册任何能力; 配了但调用方名单(allow 或 allow_file)为空时拒绝所有远程调用。三道闸门分别由发布方、 机器运营者、运营者写的调用方名单把守,详见 shell 发行变体。

shell 这个名字在不含它的构建里也要存在,否则一个操作者会去找 no_shell 这个从来 不存在的 tag。做法是 cmd/anet/optin_tags.go —— 一个没有构建标签的文件, 里面只有 module.DeclareOptIn("shell") 一行。于是当有人在配置里写了 modules.shell 而构建里没有它时,错误信息说的是"它需要 -tags shell,而默认构建不用这个 tag", 不是"是不是用 no_shell 编的"。

顺带说明另一条相关行为:配置了却没编进来的模块是错误,不是空操作。 module.Build 遇到这种情况直接失败,而不是让 daemon 读到 "p2p": {...} 之后 默默忽略、留下一个操作者对着"为什么没有在对等"发呆。

接缝长什么样

可插拔不是九个 tag 的集合,是一个接缝。子系统只能挂在 module.Host 上,而 Host 是一个接口,不是 *Daemon:

Host 给出的能力说明
AID()本节点身份锚
Providers()C1 能力注册表 —— 模块对外提供能力的唯一途径
RecordEvidence()往本节点自己的证据链追加。模块不另开私有账本
ResolveKEL()读本节点已验证过的对端密钥历史(共脑合并前需要知道"是谁签的")
PaymentSeam()以本节点身份签一笔付款所需的最小面,无 hub 时不存在
HubSeam()以本节点身份对自己的 hub 发起请求所需的最小面(签名 + hub 地址)

这张表就是"一个子系统被允许想要什么"的清单。想要更多的模块,是在说明它要么属于内核、 要么根本不该存在 —— 而说这句话的是编译器,在越界被写下的那一刻,不是两年以后。

PaymentSeam 与 HubSeam 都是签名授权,所以它们是这张表里最大的两项,也因此被 分成了两个大小不同的口子:一个只需要向自己的 hub 认证的模块拿 HubSeam(两个方法), 不必顺带拿到 hub 的密钥历史和本节点的证据。

内核是"拔掉就不是 anet daemon"的部分,没有构建标签:身份与多身份、能力注册表、 委派生命周期、委派验签、收据验证、第三方验证、证据链、中继客户端、传输列表、 对端 KEL 缓存、回环控制面、CLI、自动回复、本地控制台。

CI 怎么验

.github/workflows/ci.yml 里有两个专门的 job。

pluggable —— 18 行矩阵,逐个减法 tag 加若干组合。九个减法 tag 每一个都有独立一行, 另有覆盖多个 tag 的组合行,以及 DISTRIBUTIONS-zh.md 提出的四种发行版组合。 每一行做四件事:

go build -tags "<tags>" ./...
go test  -tags "<tags>" ./...
go build -tags "<tags>" -o /tmp/anet ./cmd/anet && /tmp/anet --help    # 还能启动
# 然后是符号数:full > 0 且 lean == 0

符号匹配的 pattern 是 provider/<mod>|module/<mod>,mcp 单独特判为 internal/mcpserv —— 一个看不见自己要检查的代码的检查,会因为错误的理由通过。组合行只对第一个 tag 数符号, 这一点不影响覆盖:九个模块各自都有独立一行,单独被数过。

矩阵里为发行版专门列的组合,理由写在文件注释里:一个设计文档提出而没有人构建的组合, 会在没有人注意的情况下停止编译。

optin —— 加法 tag 的反向验证,单独一个 job 而不是矩阵里多加一行:

gofmt -l .                       # 空
go vet  -tags shell ./...
go test -tags shell ./...        # 否则这个模块的测试哪儿都不会跑
 
go build -o /tmp/anet-default ./cmd/anet
go build -tags shell -o /tmp/anet-optin ./cmd/anet
/tmp/anet-default --help && /tmp/anet-optin --help
 
go tool nm /tmp/anet-default | grep -c module/shell    # 必须 0
go tool nm /tmp/anet-optin   | grep -c module/shell    # 必须 > 0

此外还有 test(gofmt / vet / go test ./...)、race(go test -race ./...)、 scenario(一个 hub 加三个节点的真实多进程联调)三个 job。

开发者本地做同一件事的入口是:

./build.sh --check

它会跑 gofmt、go vet ./...、go vet -tags shell ./...、go test ./...、 go test -tags shell ./...,然后构建一次默认二进制并断言 module/shell 的符号数为 0。 这个断言放在本地而不只放在 CI,是因为这是开发者推送之前跑的脚本,而"它默认不在" 是整个加法 tag 所依赖的全部内容。

在你自己下载到的二进制上核对

上面所有检查都发生在我们的机器上。要让"可插拔"对下游成立,下游必须能在自己下载到的文件 上重做一遍,所以 release 二进制保留符号表:

deploy/release/build-release.sh 用的是 -ldflags '-w',不是常见的 -s -w。 -w 只去掉 DWARF 调试信息;-s 会连符号表一起去掉,而那样 go tool nm 会回答 "没有符号"—— 读起来像检查通过,实际什么也没有证明。

代价是每个二进制大约多 1.4 MB(14,320 K → 15,760 K)。这个代价是有意付的: 不付,一条可核对的性质就退回成一句承诺。

发布脚本自己也把这条当成失败条件 —— 如果产物没有符号表,它直接报错说"下面的检查会 因为错误的理由通过",而不是让检查空转着通过。

下面这段可以在任意一台装了 Go 工具链的机器上跑:

curl -fsSL -O https://agentnetwork.org.cn/dl/anet-linux-amd64.gz
curl -fsSL -O https://agentnetwork.org.cn/dl/checksums.txt
gunzip anet-linux-amd64.gz
 
# 1. 你手上的文件确实是我们发的那个
sha256sum anet-linux-amd64
grep -E '  anet-linux-amd64$' checksums.txt
 
# 2. 符号表在,否则第 3 步会因为错误的理由通过
go tool nm anet-linux-amd64 > /dev/null || echo "没有符号表:下面的检查无意义"
 
# 3. 这个二进制不能在你的机器上执行命令
go tool nm anet-linux-amd64 | grep -c module/shell     # 期望 0
 
# 4. 这个二进制确实带付费模块(证明检查看得见它要看的东西)
go tool nm anet-linux-amd64 | grep -c module/x402      # 期望 > 0

在 v0.1.7 的 anet-linux-amd64 上实测:第 3 步 0,第 4 步 81; anet-shell-linux-amd64 的第 3 步是 23。

判据是 0 与非 0,不是具体数字。81 与 23 会随实现变化而变,拿它们当断言只会得到 一堆脆弱的检查。

一行安装脚本的 --shell 拉取的是另一个文件,不是同一个二进制的一个开关:

curl -fsSL https://agentnetwork.org.cn/install.sh | sh                    # 默认版
curl -fsSL https://agentnetwork.org.cn/install.sh | sh -s -- --shell      # shell 变体

所以一台从来不打算执行远程命令的机器,拿到的是一个做不到这件事的二进制, 而不是一个做得到但被配置拦住的二进制。

裁出来的形态

体积不是分发行版的理由:去掉全部九个减法模块只从 14,320 K 降到 12,096 K(15%), 其中 no_mcp 一项就占了可省总量的 85%(1,900 K),其余八个加起来 348 K、单个最多 124 K(no_x402); -tags shell 加进去只有 28 K 和 1 个依赖包。

真正值得分档的维度是三个:攻击面(这个构建会打开什么、接受什么)、 可被要求做什么(没有付费模块的节点不可能被诱导付款)、运维复杂度。 六档减法形态加一个正交的加法开关,以及每一档的实测数据,在发行版章节里展开。

已知边界

写清楚这条属性不覆盖什么,和写清楚它覆盖什么同样重要。

  • 符号数只回答"代码在不在",不回答"它做了什么"。 full > 0 / lean == 0 证明链接器 去掉了这个包,不证明这个包的行为是正确的;那是单元测试与 scripts/joint.sh、 scripts/joint-shell.sh、scripts/scenario.sh、scripts/prodtest.sh 这几条联调路径的事。
  • -s 剥符号会让核对失效。 我们发的二进制保留符号表,但任何人再打包、再剥一次, 下游就数不出来了。拿到二进制先跑一次"符号表在不在"的检查。
  • 没有"发行版名字"这个字段。 anet version 报的是模块列表,不报"这是 standard 档"。 想知道手上是哪一档,要自己对着模块列表比。anet status 里的 "profile" 指的是 agent 的自述资料,不是发行版档位。
  • 组合矩阵不是全集。 九个减法 tag 有 512 种组合,CI 跑的是 18 行 —— 九个单独、 若干递增组合、四种发行版组合、以及全部九个一起。没被列进矩阵的组合可能编译不过。 自建裁剪版本时,把你的 tag 组合加进你自己的 CI。
  • 内核不可裁。 身份、委派、证据链、hub 客户端没有 tag。想要一个"不带委派的 daemon" 是做不到的,这是有意的设计决定(见 五条契约)。

On this page