研究:把"可插拔"变成可验证的属性
为什么"运行时禁用"不算,以及符号数判据的代价与收益
摘要
"这个构建不含 X" 是几乎所有项目都在口头承诺、而没有人能核对的属性。本文报告 ANet 把这条承诺 改造成一个可测量判据的过程与结果。
判据不是"运行时把它关掉",而是符号数:一个模块在完整构建里的符号数必须大于 0,在
-tags no_<name> 构建里必须等于 0。两个方向都验,每次提交在 CI 上跑,发布的二进制保留符号表,
使下游能在自己下载到的文件上重做同一次测量。
实测结果与直觉相反:按体积分发行版是站不住的。去掉全部九个减法模块只从 14,320 K 降到
12,096 K(15%),其中 no_mcp 一项占了可省总量的 85%;其余八个模块里最大的 no_x402 是 124 K,
其余七个都在百 K 以内。分档的真实维度是攻击面、可被要求做什么、运维复杂度,与体积无关。
方法本身有明确边界:符号数只能证明代码不在,不能证明剩下的行为等价;它对跨语言组件不适用; 并且它目前只在 ANet 一个仓里被完整执行,另外两个仓的裁剪只被证明了"去掉后还能编译、还能启动"。
背景
问题的形状
一个软件项目说"这个版本不带远程执行能力"或"这个版本不含付费代码",读者能做的通常只有相信。 两种常见做法都不产生可核对的证据:
- 运行时开关。 代码在二进制里,一个配置项让它不生效。这句承诺的真值取决于运行时状态 —— 取决于配置有没有被改、有没有别的路径能到达那段代码、有没有一个 bug 让开关失效。
- 文档声明。 一份写着"精简版不含 X"的说明,与二进制之间没有任何机械关系。
两者的共同点是:读者无法在自己手上的那个文件上验证这句话。 这不是诚信问题,是形式问题 —— 一句关于运行时状态的期望,与一句关于文件内容的陈述,是两种不同强度的命题,而通常只有前者 被真的做到、后者被真的说出口。
三代教训
ANet 之前有三代实现,三次在同一件事上失败,形状各不相同。这段记录写在
ANet/docs/CONTRACTS-zh.md §0(2026-08-16 原文),此处照其结论整理:
| 代 | 症状 | 代价 |
|---|---|---|
| v1 | 组织(org)功能长进 35 个 daemon 文件 | 想拔的时候拔不出来 |
| 1.3.0-v3 | 模块之间没有合同 | 开源时只能用斧头,删掉 683,852 行 |
| oss-v0.1 | 减法产品成立,但减法用的是 fork | 内核逐字拷贝,两个月即漂移 |
三次的共同缺失不是纪律,是接缝:代码没有地方可以挂,就会挂得到处都是。v1 的 org 并不是 被谁批准写进 35 个文件的,它只是每次多写一处,而每一处单看都合理。
值得单独说明的是第三代。oss-v0.1 证明了"减法产品"这个商业形态成立 —— 确实有人只想要一个精简 的东西。但它的减法用的是 fork:把内核逐字拷贝一份,在拷贝上删。两个月后两份内核开始漂移, 于是修一个缺陷要修两遍,而且两遍会慢慢变得不一样。这一代的教训不是"减法不可行",是"减法的 实现方式决定它能不能长期维持"。
由此得到 anet4 的唯一命题:
让"裁剪"从 git 操作变成构建配置。一套代码,N 种发行版。
"一套代码"消除漂移;"N 种发行版"要求每一种都能说清自己不能做什么 —— 而这句话必须可核对, 否则它退回成上一节说的那种承诺。本文研究的就是后半句怎么变成可测量的。
方法
判据定义
对任意一个减法模块 <m>,判据是四条命令与两个断言:
go tool nm 列出二进制符号表里的符号,grep -c 数出包路径匹配的行数。这测量的是链接器最终
留下了什么,而不是源码里写了什么、也不是运行时会不会执行到。
对加法模块 shell,断言方向相反:默认构建必须等于 0,-tags shell 构建必须大于 0。
为什么两个方向都要验
只验 lean == 0 是这套方法最容易犯的错,而且它会静默通过:
一个从未被链接进任何构建的模块 —— 比如它的 import 那行被误删、或者包名写错、或者它压根还没
被接线 —— 在完整构建和精简构建里符号数都是 0。一个只看 lean == 0 的检查会给它开绿灯,报告
"可插拔",而实际情况是这个模块在两种构建里都不存在。
full > 0 这一半证明的不是模块的价值,是检查本身看得见它要检查的代码。少了这一半,整个
判据可以因为错误的理由通过。
同一条道理在发布脚本里还出现了一次,形式不同:deploy/release/build-release.sh 在数符号之前
先断言符号表存在 ——
一个被 -s 剥掉符号表的二进制,go tool nm | grep -c 同样返回 0。所以"没有符号表"被定义为
失败条件,而不是让检查空转着通过。
匹配 pattern 的一处特判
CI 用的 pattern 是 provider/$mod|module/$mod,唯独 mcp 特判为 internal/mcpserv —— MCP 北向
不在 module/ 下,它是 internal/mcpserv 加 cmd/anet/mcp.go。这一行特判的性质与上一节相同:
一个用错路径的 pattern 会数出 0 并报告通过,而它数的是一个不存在的路径。
判据在哪些地方被执行
| 位置 | 做什么 | 覆盖 |
|---|---|---|
.github/workflows/ci.yml 的 pluggable job | 18 行矩阵,每行 build + test + 启动 + 符号数两个方向 | 九个减法 tag 各一行,若干递增组合,四种发行版组合,全部九个一起 |
同文件的 optin job | gofmt,以及带 tag 的 vet 与 test 各跑一遍;两个二进制都要能启动;再反向验符号数 | 加法 tag shell |
build.sh --check | 开发者推送前的本地入口,含默认构建里 module/shell 必须为 0 的断言 | 加法 tag |
deploy/release/build-release.sh | 对当前平台的两个发布变体验一次 | 发布产物 |
optin 是单独一个 job 而不是矩阵里多加一行,理由在 CI 注释里写着:加法 tag 下的代码
go test ./... 根本看不见。module/shell/shell_test.go 自己带 //go:build shell,所以一条
不写 tag 的测试命令永远不会编译、更不会运行这个模块的测试 —— 而这是套件里唯一执行命令的模块。
因此必须另有一条 go test -tags shell ./...,否则这个模块的测试哪儿都不会跑。
度量条件
本文所有体积与依赖数字的测量条件:
| 项 | 值 |
|---|---|
| 日期 | 2026-09-03 |
| Go | 1.26.6(go.mod 的 go 指令) |
| CGO | CGO_ENABLED=0 |
| 构建 | go build -trimpath -ldflags "-s -w" |
| 版本 | ANet 0.1.7 · ANetCore v0.13.1 |
| 依赖数 | go list -deps ./cmd/anet 的包计数 |
表中的体积是剥掉符号表(-s -w)的数字。实际发布的二进制只用 -w,保留符号表,因此比表里
大约 1.4 MB(14,320 K → 15,760 K)。为什么付这个代价,见下文"讨论"。
这一批数字是那天那次构建的读数,此后的代码改动没有重测。要取当前值,跑下文"可复现步骤 D"。
数据
单模块的体积与依赖贡献
完整构建 14,320 K。去掉全部九个减法模块 12,096 K,省下 2,224 K,占 15%。逐个去掉:
| tag | 省下 | 占可省总量 |
|---|---|---|
no_mcp | 1,900 K | 85% |
no_x402 | 124 K | 5% |
no_cas | 60 K | 2% |
no_blackboard | 44 K | 1% |
no_org | 32 K | 1% |
no_p2p | 28 K | 1% |
no_taskboard | 24 K | 1% |
no_anetlink | 20 K | 不到 1% |
no_service | 16 K | 不到 1% |
加法方向反过来算:-tags shell 加 28 K、加 1 个依赖包。
依赖数:默认构建链接 307 个包;no_mcp 降到 273(减 34,是 MCP SDK 及其 jsonschema 依赖);
九个减法 tag 全用上降到 264(减 43)。
一次线性性检验。 九项单独测量之和是 2,248 K,而一次去掉九个的实测是 2,224 K,差 24 K, 约 1%。这说明模块之间共享的依赖很少,单独测量的数字可以近似相加 —— 但只是近似,不能当成 可加的。
结论:除 MCP 外,没有一个模块对体积有实质影响。 剩下的 12 MB 是 Go 运行时、net/http、
加密库和 SQLite 驱动,与模块无关。
六档发行版与三个 shell 孪生版的实测
下表照 ANet/docs/DISTRIBUTIONS-zh.md 的实测表转录,保留它当时的口径与数字。
本节前面的每模块数字同样来自那份源表。站内的规范数字不是这一组 ——
发行版总览 后来在 go 1.26.7 上重测了一遍,
依赖数改成含 main 包本身(因此各档比这里大 1),六档的 tag 串改用 ci.yml 的
pluggable 矩阵(因此 agent / p2p 两档带着 taskboard)。
要引用数字请引那一页;这里保留源表是为了让"两次测量差多少"看得见。
| 形态 | 体积 | 依赖包 | 二进制里实际有的模块 | 公开端口 |
|---|---|---|---|---|
| min † | 12,132 K | 265 | (无) | 无 |
| standard | 12,144 K | 266 | service | 无 |
| paid | 12,264 K | 267 | service x402 | 可选(配了 voucher_addr 才开) |
| agent | 14,044 K | 300 | service mcp | 无(MCP 走 stdio) |
| p2p | 12,172 K | 267 | service p2p | 有 |
| full | 14,320 K | 307 | 全部减法模块 | 可选 + 有 |
| standard+shell | 12,192 K | 267 | service shell | 无 |
| agent+shell | 14,072 K | 301 | service mcp shell | 无 |
| full+shell | 14,348 K | 308 | 全部 | 可选 + 有 |
† min 的 tag 组合就是九个减法 tag 全写上,与上一节"去掉全部九个减法模块"是同一个构建
(anet version 报 modules: (none))。但源表这一行记 12,132 K / 265 个依赖,而上一节记
12,096 K / 264 个依赖。同一个构建不会有两个体积,这条差异尚未复核,min 的两个数都应当
当作近似值。本页不替它选一个,因为选哪个都是猜。
min 到 paid 之间按上表只差 132 K 和 2 个依赖。一个用户在这两档之间做的选择,如果理由是
体积,那是一个百 K 量级的选择;而它实际的差别是"这个节点能不能收钱、会不会打开一个公开
监听口"。
攻击面:分档的实际维度
既然体积不成立,这张表是分档真正依据的东西:
| 模块 | 打开的入口 | 出站行为 |
|---|---|---|
x402 | 公开 HTTP 监听口(配了 voucher_addr 时) | 向 hub 发结算、拉发放链 |
p2p | 入站对等连接 | 主动连对等节点 |
mcp | stdio 服务(由本机进程启动) | 无 |
service | 无 | 向本机 HTTP 服务发请求 |
anetlink | 无 | 连 ANetLink 的 unix socket |
cas / blackboard / org | 无 | 无 |
shell | 无(请求经 hub 或 p2p 到达) | 在宿主机上执行命令 |
只有 x402 和 p2p 会让节点接受外部连接。shell 不开端口,但它是唯一一个把网络请求变成
本机命令执行的模块,因此单独占一条线,也是唯一用加法 tag 的模块。
符号数的实测值
在 v0.1.7 的发布产物上实测(判据是 0 与非 0,不是具体数字):
| 二进制 | module/shell | module/x402 |
|---|---|---|
anet-linux-amd64(默认) | 0 | 81 |
anet-shell-linux-amd64 | 23 | 81 |
module/taskboard 在这条判据下的历史值是 22 → 0(H-6b 完成时记录)。这些数字会随实现变化,
把它们当成断言只会得到一堆脆弱的检查。
讨论
减法与加法的代价不对称
设计稿(2026-08-16)写的是加法 tag:anet_<name>,加了才有。实现选了减法:no_<name>,
默认在、去掉才没有。唯一的例外是 shell。
这不是审美偏好,是两种写错的代价不一样:
| 写错的方式 | 后果 |
|---|---|
| 减法 tag 漏了(该去掉的没去掉) | 某人想去掉的模块留在了他的构建里 |
| 加法 tag 漏了(该不在的进去了) | 命令执行能力进了所有没要过它的构建 |
对绝大多数模块,前者代价小 —— 一个不需要共脑的节点里多了共脑代码,损失是几十 K 和一点攻击面。 对"在宿主机上执行命令"这一个,后者代价大到必须反过来:需要靠"记得去掉"来保护的人,恰好是 从来没听说过这个 tag 的人。
由此得到一条可以直接套用的规则:
默认构建应当是完整的,除非某个能力"进错构建"的后果比"缺失"更重。
这条规则的代价是:加法 tag 会制造一个测试盲区(见方法一节),必须用一条额外的
go test -tags <name> ./... 补回来;并且模块的名字要在不含它的构建里也存在,否则操作者会去
找 no_shell 这个从来不存在的 tag。后者的做法是 cmd/anet/optin_tags.go —— 一个没有构建标签
的文件,里面只有一行 module.DeclareOptIn("shell")。于是当有人在配置里写了 modules.shell 而
构建里没有它时,错误信息说的是"它需要 -tags shell,而默认构建不用这个 tag"。
保留符号表的取舍
到这一步为止,所有测量都发生在我们自己的机器上。要让"可插拔"对下游成立,下游必须能在自己 下载到的文件上重做同一次测量 —— 否则这条属性只是一次内部质检,与读者手上那个文件没有机械关系。
所以 deploy/release/build-release.sh 用 -ldflags '-w' 而不是常见的 -s -w:-w 只去掉 DWARF
调试信息,-s 会连符号表一起去掉。代价是每个二进制大约多 1.4 MB(14,320 K → 15,760 K),
约 10%。
这个取舍值得单独说,因为它是一次把"可验证"当作产品属性、而不是开发者内部细节的决定。
常规判断会说 1.4 MB 是纯粹的浪费:符号表对运行没有任何作用,绝大多数用户永远不会跑
go tool nm。这个判断在"用户"只指最终使用者时是对的。但一条承诺的读者不只是使用者 ——
还包括决定要不要在自己机器上装这个东西的人、复核这个决定的人、以及以后回过头来问"当时装的
到底是哪个变体"的人。对这三类读者,1.4 MB 换来的是这句话可以不被相信而被检查。
不付这 1.4 MB,"这个二进制不能在你的机器上执行命令"就从一条关于文件的陈述,退回成一句关于 我们的承诺。
对没有 Go 工具链的机器,还有一条更弱但可用的替代:
默认版回答 0,shell 版回答 1。它比数符号弱(字符串常量不等于代码),但不需要装任何东西。
一个反例:会说谎的 tag 字符串
方法一节说判据要测"链接器留下了什么"。这里给一个具体反例,说明测别的东西会怎样失败。
internal/version 里有一个 Tags 变量,记录构建时给的 tag 字符串,由 -ldflags -X 在链接时
写入。把它当成"这个二进制有哪些模块"的答案,看上去顺理成章 —— 它就是为这件事存在的。
它会说谎。go build -tags shell 而不加 -X 的 ldflag,产出的是一个含有 shell 模块、
却报告"没有 tag"的二进制。一个能把"可以在宿主机上执行命令"的构建说成"默认版"的字段,比没有
这个字段更糟:它会让一次本来会引起怀疑的检查通过。
所以 anet version 报模块时读的是编译期注册表(module.Compiled()),不是这个字符串:
注册表不会说错。一个名字出现在这一行里,是因为它所在文件的 init() 跑了;init() 跑了,是因为
链接器留下了它。这与符号数测的是同一件事,只是换了一个不需要工具链的读法。
同一个二进制在别的档上答什么:-tags shell 的构建这一行多一个 shell(名字按字母序排,不在末尾);
min 档(九个减法 tag 全写上)答的是 modules: (none)。
两点需要说清楚:
- 这一行现在是 9 个名字,含
mcp,而在此之前是 8 个 —— 漏的就是mcp。它确实不走module.Register:MCP 北向是internal/mcpserv加cmd/anet/mcp.go,由no_mcp管辖,不是module.Module。补法不是把它塞进注册表,而是cmd/anet/module_mcp_declare.go—— 一个带//go:build !no_mcp的文件,里面一行module.DeclareCompiled("mcp")。声明与子系统共用 同一个 tag,所以声明不会比链接器留下的东西活得更久。一条自称"这个二进制里有什么"却漏报一部分 的行,比没有这行更糟,因为文档正是让人靠读它来认自己装的是哪一档。no_mcp的构建里anet mcp这条命令仍然存在,回答的是"这个构建是用-tags no_mcp编的",而不是"未知命令"。 Tags字段并没有被删掉,它回答的是另一个问题(同一 commit 的两个二进制是不是同一个东西)。 被拒绝的是拿它回答"有哪些模块"。
anet version 这一行与 go tool nm 的关系是:前者不需要工具链、但要求你信任这个二进制自己的
输出;后者不要求信任它,但要求你装 Go。两条路径答的是同一个事实,给的是两种不同强度的证据。
局限
以下是这套方法不覆盖的部分。写清楚这一节,和写清楚方法本身同样重要 —— 一个被高估适用范围 的判据,会在它答不了的问题上给出一个看起来像答案的东西。
符号数只证明代码不在,不证明行为等价
full > 0 / lean == 0 证明链接器去掉了这个包,不证明去掉之后剩下的系统行为是正确的。一个
no_x402 的构建可以既没有付费代码,又在遇到标价能力时挂着不答,而不是回答 UNAVAILABLE ——
两个方向的符号数都会通过,因为它测的是代码在不在,不是剩下的代码答什么。
这类缺陷归单元测试与联调路径(scripts/joint.sh、joint-shell.sh、scenario.sh、
prodtest.sh)管。判据与测试是两件事,不能互相替代。
这不是假想的形状,套件里出过一条:
- 位置:
ANet/internal/daemon/capability.go的tryCapabilityPaid。 - 行为(旧):能力解析不出时返回 false,委派落到自动回复;未配自动回复的节点因此对这次 委派永不作答。
- 影响:请求方拿到的是超时,与"节点宕了"无法区分 —— 而"不知道"和"知道对方不提供"是两种 状态,合并它们正是诚实效果状态这条规则要防的事。
- 发现方式:
scripts/joint-shell.sh的第 7 项检查(请求一个未开启的shell.exec), 不是符号数检查。 - 现状:已修。没有匹配的提供方且没有配自动回复时,节点直接答
UNAVAILABLE, message 是this node does not serve <capability>;配了自动回复的节点仍按原样把任务交给 agent 处理。
跨语言组件不适用
这条判据的前提是单一 Go 二进制加一个能读它的符号表工具。它对以下形态无效:
- 打包进二进制的浏览器侧资源。 ANetHub 的 webui 是 HTML/JS,内嵌在 hub 二进制里。
go tool nm数不出它。它的等价保证是另一件事:ANetHub/scripts/build.sh在 docker 容器里 重建前端,并断言嵌入的副本与刚构建的一致,不一致就报错。 - 受管子进程与外部运行时。 套件目前的分发运行时是纯 Go(
CGO_ENABLED=0),所以这个问题 暂时不出现;但架构约定允许"确需跨语言的组件自带打包、作为受管子进程运行",一旦真的出现 这样的组件,它的"在不在"就不能用符号数回答。 - 配置与数据。 一个模块的配置字段、错误字符串、线协字段名可能因为在内核里被引用而留下, 即使模块本身被去掉。判据不覆盖这类残留。
判据在三个仓里执行得不均
这是本文写作过程中核实出的一条缺口,按位置、行为、影响、发现方式记录:
- 位置:
ANetHub/.github/workflows/ci.yml的unplugjob;ANetLink/.github/workflows/ci.yml的unplug与pluginjob。 - 行为:hub 侧只跑
go build -tags "<tags>";ANetLink 的unplug跑go build加一次go run ... --demo启动,plugin另外跑带 tag 的vet与test。三个 job 都不数符号。 - 影响:hub 的
no_taskboard/no_federation与 ANetLink 的 15 个构建标签(11 个减法、 4 个加法),目前只被证明了"去掉之后还能编译、还能启动",没有被证明"代码不在二进制里"。 这两个仓的裁剪承诺,强度低于 ANet 的同名承诺。 - 发现方式:
grep -rn "go tool nm"在三个仓里只在 ANet 命中。
需要说明的是,daemon 是终端用户直接下载安装的那个二进制,hub 与 anetlinkd 通常由部署方自己构建 运行,所以优先级排序是合理的;但"两个方向的符号数"这条判据没有在三个仓里一致执行,是事实。
组合空间没有被穷举
九个减法 tag 有 512 种组合,CI 矩阵跑 18 行:九个单独、若干递增组合、四种发行版组合、以及 全部九个一起。没被列进矩阵的组合可能编译不过 —— 这不是理论风险,矩阵里为发行版专门列出的 四行,理由正是"一个设计文档提出而没有人构建的组合,会在没有人注意的情况下停止编译"。
矩阵的组合行还有一处方法上的弱点:符号检查只对第一个 tag 数符号
(mod="${tags%%,*}")。这不影响覆盖 —— 九个模块各自都有独立一行、单独被数过 —— 但组合行
本身并不验证"这九个一起去掉之后九个都不在"。
自建裁剪版本的人,应当把自己的 tag 组合加进自己的 CI。
剥符号会让核对失效
我们发的二进制保留符号表,但任何人重新打包、再剥一次 -s,下游就数不出来了 —— 而且数出来的
是 0,看起来像通过。拿到二进制的第一步应当是确认符号表存在,这正是发布脚本自己也在做的事。
内核不可裁
身份、委派生命周期、证据链、hub 客户端没有构建标签。"不带委派的 daemon"是做不到的,这是有意 的设计决定:委派生命周期是 C2 的一半,拔掉之后节点既不能求助也不能被求助。判据只适用于模块, 不适用于内核,而内核有多大是一个设计决定,不是这套方法能回答的问题。
结论
- "可插拔"可以被定义成一个可测量的属性。 定义是符号数,两个方向都验,证据是一条任何人 都能在自己文件上重跑的命令。
- 判据必须双向。 只验
lean == 0会让一个从未被链接的模块看起来可插拔,而且是静默通过。 同理,"符号表不存在"必须被定义为失败而不是通过。 - 测量对象要选对。 测"构建时刻打进去的 tag 字符串"会说谎;测"编译期注册表"和测符号数 不会,因为它们读的是链接器实际留下的东西。
- 按体积分发行版站不住。 除 MCP 外没有模块有实质体积影响(九个模块全去掉只省 15%,其中 MCP 占 85%)。真实的分档维度是攻击面、可被要求做什么、运维复杂度。
- 减法与加法的代价不对称,方向要按代价选。 默认构建应当是完整的,除非某个能力进错构建的
后果比缺失更重 —— 目前只有
shell满足这个条件。 - 可验证是一个产品属性,值得为它付体积。 1.4 MB 的符号表换的是"这句话可以被检查而不是 被相信"。
- 方法有边界。 它不证明行为等价,不适用于跨语言组件,目前也没有在三个仓里一致执行。 这些缺口写在上一节,不写等于高估了这条判据。
可复现步骤
A · 从源码复现符号数判据(需要 Go 工具链与仓库):
第一条应当大于 0,第二条应当等于 0。换成 no_p2p / no_cas / no_org / no_blackboard /
no_service / no_taskboard / no_anetlink 同理;mcp 的路径是 internal/mcpserv。
加法方向:
一次跑完本地全部检查:
B · 在下载到的二进制上复现(不需要仓库,需要 Go 工具链):
期望:第一个符号数 0,第二个大于 0(实测 81)。第二条是上文说的"检查看得见它要检查的东西" 那一半。
C · 不需要工具链的弱化版:
anet version 的 modules: 一行读自编译期注册表;strings 那条默认版回答 0、shell 版回答 1。
两者都比数符号弱,但不需要装任何东西。
D · 复现体积与依赖数字:
数字会随 Go 版本与依赖更新变化。本文的表测于 2026-09-03、go 1.26.6、CGO_ENABLED=0、
ANet 0.1.7。