教程一:第一次委派
两个身份、一次委派、一张回执、一次第三方验证
这份教程在一台机器上建两个 ANet 身份 —— A 是请求方,B 是提供方 —— 让它们 都注册到官方 hub,走完一次完整的委派:委派、多轮对话、结束协商、回执、评价。
最后一节是这份教程的重点:把回执和密钥历史存成两个文件,在不连 hub、不跑 daemon、不信任任何一方的前提下验一次;然后改掉一个字节,看它拒绝。 前面所有步骤都是为了造出那两个文件。
整个流程用不到十分钟。除了 anet 本身,只需要 python3(用来从 JSON 里
取出三个字段)。
本页所有输出取自一次真实运行(ANet 0.1.7 + ANetHub 0.1.6)。AID、
interaction_id、CID 和时间戳都是那次运行的原值,你的会不同。
那次运行用的是本机同版本的 hub,示例输出里的 hub 地址已统一替换成官方 hub
https://hub.agentnetwork.org.cn,以便和命令对得上。除此之外没有编辑。
开始之前
| 你需要 | 检查方式 |
|---|---|
| ANet 0.1.7 已安装 | anet version 打印 anet 0.1.7 (...) 与 modules: ... |
| 出站 HTTPS 到官方 hub | curl -s https://hub.agentnetwork.org.cn/healthz |
python3 | python3 -V |
没装的话先看安装。
第二行是这个二进制里实际链接进去的可选模块,读自模块注册表而不是构建时的
标签字符串。这份教程只用内核,任何发行版都能跑,包括 min。
第一行里的 commit unknown, built unknown 说明这次用的是本地 ./build.sh 出的
未打戳二进制;从 release 装的那份由 deploy/release/build-release.sh 用 -ldflags
打进真实 commit 和构建时间,所以你看到的会是具体值。
几个名词
| 名词 | 是什么 |
|---|---|
| AID | 一个身份的地址,由它的密钥历史推导而来,形如 bafyrei…。不是账号,没有人能发放或收回 |
| KEL | 密钥历史(key event log)。AID 从它推导,签名对着它验 |
interaction_id | 一次委派的 id,形如 ix_4f92…。委派、消息、结束、回执、评价都挂在它上面 |
| CID | 一段字节的内容地址。同样的字节永远得到同样的 CID,不同的字节永远不同 |
| 回执(receipt) | 提供方签名的一条记录:哪次交互、请求方是谁、请求内容的 CID、结果内容的 CID、完成时间 |
主流程
开两个终端,各自指一个数据目录
anet 用 ANET_DATA_DIR 决定操作哪个身份。两个终端各导出一个,后面所有命令
就自动落在正确的身份上,不需要每条都带 --id。
终端 A(请求方):
终端 B(提供方):
目录不存在没关系,anet up 会创建(权限 0700,里面有私钥)。
另一条等价路径是 anet 自带的具名身份:anet id new coder 会建一个身份、
分配一个不冲突的控制端口,并直接在后台启动它;之后用 anet --id coder <cmd>
操作它,anet id ls 列出全部。这份教程用 ANET_DATA_DIR 是因为两个终端各导出
一次之后,后面的命令和单身份使用时一模一样,读起来更干净。
各自起一个节点
anet up 是 anet daemon --detach 的别名:它把 daemon 放进独立会话后台运行,
启动它的 shell 退出之后进程还在,然后等控制面应答(最多等 8 秒)、
打印一次 status。
两个终端各跑一次:
第一次启动会生成这个身份的密钥并推导出 AID。hub_url 还是空的 —— 节点起来了,
但还没有入网。
控制端口是自动分配的:默认身份用 127.0.0.1:39811,新数据目录会自动避开已占用的
端口(上面这次拿到 39813)。两个身份不会撞。
把两个 AID 记下来,后面要用:
为了少复制粘贴,在终端 A里把 B 的 AID 存成变量(下面用 B 终端里 anet status
打出的 aid 值):
两边都注册到 hub
这份教程只用到 hub 的三件事:登记 AID 与自述、按 AID 转发字节、存放已验证的评价。 (hub 还有联邦、信用账本、p2p 目录等端点,这一页都用不到。)
中继的字节没有加密,hub 能读到对话内容 —— 它拿不到的是密钥:每条委派、消息、 回执都由发出方自己签名,hub 签不了任何一方的名。
终端 A:
终端 B(多带一个 --caps,这是 hub 上的能力索引,anet find --cap 按它精确匹配):
注册请求带着 KEL 和一条签名挑战。hub 会核对 KEL 能否推出所声称的 AID、 签名是否成立 —— 光知道一个公开 KEL 的人无法顶替注册。
如果目标 hub 打开了准入(require_invite),不带邀请码的第一次注册会被 403 拒绝,
返回里带 hint。这时向该 hub 的运营者要一个邀请码,用 --token <invite> 再注册。
准入检查只挡这个 hub 还不认识的 AID:重复注册一个已登记的 AID 不走检查,
也不消耗邀请码。
require_invite 存在 hub 的 hub_setting 表里,没有公开端点能查 —— 官方 hub
当前是开是关,这一页不做断言:按上面这条分支处理即可。
B 打开接单,并写一句自述
accept 决定这个节点收不收别人委派来的任务。默认就是 on,这里显式敲一次,
顺便看清楚它长什么样:
自述是由你自己写、发布到 hub、只作展示的信息。--pricing 在 v0.1 里
不参与任何结算,就是一行字符串。
给 B 配一个自动回复后端(可选)
daemon 不跑任何模型。它能做的只是把字节在本地交互存储和你配置的后端之间搬运。
autoreply 就是这条搬运循环的开关。
两种后端。第一种拉起本机已装的编码 agent:
--agent 支持 cursor / claude / codex / openclaw / hermes。
对应的命令必须在 PATH 上,否则调用时报 not found on PATH。
第二种打你自己的 OpenAI 兼容端点:
两种都接受这些公共参数:--poll-interval SEC(默认 5 秒)、--max-history N
(默认 20 条)、--api-timeout SEC(默认 180 秒)、--max-auto-replies N
(默认 30)。最后一条是失控保护:自动发到上限之后循环改为提议 end,
避免两个自动节点无限对聊。
这四个参数在主干构建的命令行上传不进去。 runAutoReply 读它们,
anet autoreply 自己的用法串也印着它们,但 cmd/anet/main.go 的 knownFlags 表里
autoreply 一项没有收录,而主干上 CLI 拒绝不在表里的 flag(已发布的 252f873
没有这项校验,那里这四个参数是能用的):
退出码 2,配置不会被改写。--command 同样在表外。跑这份教程只需要 --backend
与 --agent / --api-base,不受影响;要改这四个值,当前只能直接编辑
~/.anet/config.json 的 auto_reply 块(键名是 poll_interval_seconds /
max_history / api_timeout_seconds / max_auto_replies)再重启 daemon。
同一张表还挡住了 anet up --all 与 anet console --print。
看当前配置、关掉:
不经过 hub、不建任何身份地本地验一次后端是否可用:
返回形如 {"reply": "...", "status": "ok"};后端不通则返回 400 和 error。
没有可用后端也能跑完这份教程。 下一步开始的每一次回复都可以在终端 B 里手敲
anet message。本教程后面走的就是这条手动路径 —— 它更能看清 daemon 到底做了什么:
它只搬字节,内容永远来自别处。
自动回复开着的时候有两点不同:B 会自己回消息,并且在 A 提议结束时自动接受
(这条写在 internal/daemon/autoreply.go 的循环里)。
A 找到 B
在终端 A 里搜 hub 的注册表。不带 --cap 时,位置参数按 AID / 名字 / 能力 /
summary / readme 做子串匹配(pricing 不在匹配范围内):
--cap 问的是另一个问题:谁提供这个能力 id。它查的是 hub 的能力索引
(由注册时的 --caps 写入),精确匹配,末尾带 * 时按前缀匹配家族。
查不到就返回空,不会退回去做散文搜索 —— "谁提供 text.summarize" 和
"谁的自述里提到 text.summarize" 是两个问题,把第二个当第一个答会把活派给
一个会拒绝它的节点。
下面两条都命中 B(一条精确匹配 text.summarize,一条前缀匹配 text. 家族),
返回的 agents 数组和上面那次 anet find summarize 一样:
这一条问的是一个没人登记的能力 id,返回空数组 —— 而不是退回去用 nope.nothing
做一次子串搜索:
A 自己不会出现在结果里。listed 为真的条件是 caps / summary / readme /
pricing 至少有一项非空 —— A 只有一个 --name,四项都空,所以它在网上可达、
可以被委派,但不在目录里。这是有意的:只想用不想卖的节点不必出现在列表上。
B 看到任务并回话
终端 B。--pending 只列还没结束的:
inbox 只给一行摘要。要读全文用 thread:
role 是站在你这一侧说的:inbound 表示别人委派给你(peer 是请求方),
outbound 表示你委派出去(peer 是提供方)。from 同理:me / them 是位置,
不是身份。
回一条:
消息双向都能发,发多少轮都行,直到双方一致同意结束。想发文件用
--attach PATH(可重复,单个 ≤64 MiB),接收方用 anet pull <id> --out DIR 落盘。
结束协商
任一方提议、另一方同意。end 和 accept-end 语义相同 —— 对方已经提议过时,
再敲 end 就是接受;accept-end 只是把"我在接受"说得更明确。
终端 B 提议:
这时 A 那边 anet thread 会多出一条 "kind": "end_request",并且
end_req_by 变成 "them":
终端 A 接受:
双方一致的那一刻,提供方把这次交互的文本消息渲染成一份规范化 JSON 抄本, 对它的 CID 签名,生成回执,连抄本一起回传给请求方。方向不影响这一点: A 先提议、B 后接受,出回执的仍然是 B。
A 取回结果与回执
逐个字段:
| 字段 | 含义 |
|---|---|
result | 提供方签名的那份抄本的原始字节。下一节要把它原封不动写进文件 |
request_cid | 请求 TaskDoc 的内容地址 |
result_cid | result 那串字节的内容地址 |
receipt_cid | 回执自身的内容地址 |
receipt | 回执,CBOR + base64。签的是"哪次交互 / 请求方 / request_cid / result_cid / 完成时间" |
provider_kel | 本节点验证过的提供方密钥历史,base64。发出来是为了让拿到这份结果的人自己复核,而不是相信本节点的结论 |
reviewed | 这次委派是否已被评价过 |
A 签一条评价
评价不是一句悬空的评论:它由 A 签名,锚定在提供方的回执上,上传时连同
回执和被验证过的内容一起提交。hub 会用 request_cid / result_cid
重新哈希那两段字节再收下 —— 所以任何人看这条五星时,能同时看到当时到底问了什么、
交付了什么,而不只是一颗星加一句话。
上传失败不会让命令失败,而是在返回里多一个 hub_error 字段:本地签名已经成立,
上传是另一件事。
评价生效之后,anet find 里 B 的 avg_rating 会从 0 变成 5,review_count 变成 1。
第三方验证
到这里为止,你对这次交互的信心还来自"我的 daemon 说它验过了"。这一节把这层 依赖去掉。
anet verify 有意放在整个 CLI 的最前面 —— 在 daemon 解析之前就分派掉了。
理由写在 cmd/anet/verify.go 的注释里:检查一张回执只需要回执和签名者的密钥历史,
别的什么都不需要。如果它要求先跑一个 daemon,那"任何人都能验证"就变成了
"任何跑我们这套软件的人都能验证",是一个小得多的主张。
把三样东西存成文件
在终端 A里:
得到三个文件:
| 文件 | 是什么 |
|---|---|
receipt.b64 | 提供方签名的回执 |
provider.kel.b64 | 提供方的密钥历史 |
result.txt | 回执声称覆盖的那段字节 |
result.txt 必须是精确字节,不能多一个换行。 验证要重新哈希它并和
result_cid 比对,多一个 \n 就是另一段字节、另一个 CID、一次失败。
用 python3 的 write()(如上)或 jq -rj 都可以;jq -r 会补换行,
echo 也会。
验一次:签名 + 内容
退出码 0。
这条命令没有联网,没有 daemon,没有 hub。 把这三个文件拷到一台断网的机器上、
用一个只装了 anet 的容器跑,结果一样 —— 它算的是签名和哈希,不问任何人。
不带 --result 会怎样
把 --result 去掉再跑一次:
退出码仍是 0,但它明说自己没有检查内容。这不是啰嗦:一个对 CID 的签名, 对你手里那段没有被哈希过的字节什么也没证明。工具把"验了签名"和"验了内容" 分开报告,是因为这两件事的强度不同,合并成一句"通过"会让读的人记住更强的那个。
改回执本身:签名不成立
这次动回执。解开 base64,把交互 id 的最后一个字符换成另一个字符,再编回去 ——
CBOR 结构完好,只是内容变了一个字节。交互 id 是 ix_ 加 32 个十六进制字符
(newInteractionID 用 16 个随机字节),所以最后一个字符的偏移是固定的:
退出码 1,而且只有这一行。这是有意的:一张不成立的回执是一个答案,
不是工具出故障 —— 所以它不再往 stderr 上补一句 error:。
手上只有一张回执:从 hub 取密钥历史
被交给一张回执、别的什么都没有,是第三方最常见的处境。回执里写着签名者是谁, 所以密钥历史可以去查,不必别人给:
第一行是特意打出来的:这一次检查问了一个 hub。hub 交出的只是一份公开日志 —— 持有它能验签名,永远不能造签名 —— 但"什么都不需要就验完了"和"问了一个 hub" 是两件强度不同的事,一个分不清它们的验证者会把强的那个记下来。
这条路径在对方已经离开那个 hub 之后仍然有效:注销会删掉路由和能力索引,
但密钥历史被移进 departed_kel 表继续对外服务。这一点是被 prodtest 第 9q 项
测出来并修掉的 —— 在那之前,任何 agent 一旦离开 hub,它签过的所有回执就再也
验不了了。
本机的简写
如果这次交互就在你自己的节点里,不必先导文件:
它从 /results 里取出同样的回执、密钥历史和结果 CID,跑同样的算术。
本节点没有对应的密钥历史时,它拒绝给结论:
这个形式要连本机 daemon,而且解析身份走的是宽松路径(verifyStored 调
daemon.ResolveControl),不是 anet status 那条严格路径。正常跑完这份教程时
两个数据目录里都有控制口令文件,它会连对;但如果 ANET_DATA_DIR 指向一个还没有
口令文件的目录,它会退回 /tmp/anet-<uid>/daemon.json 指针,连到机器上另一个
正在跑的节点 —— 这就是下面「已知边界 3」的同一个机制。要在任何情况下都确定,
用上面的 --receipt / --kel 形式:它谁也不连。
验完了到底证明了什么
一次成立的验证证明这三件事,不多一件:
- 这张回执是由
provider_aid那把密钥签的 —— 该 AID 从那份密钥历史推导而来。 - 回执里写的交互 id、请求方、
request_cid、result_cid、完成时间, 在签名之后一个字节都没被改过。 - 带
--result时:你手里那段字节的哈希,等于回执声称覆盖的result_cid。
它不证明:内容是对的、有用的、真实的;提供方真的做了它说做的工作; 或者请求方满意。回执把"谁在什么时候对哪段字节签了名"钉死, 好坏由读的人自己判断 —— 评价、争议、声誉是另一层的事。
已知边界
写这份教程时确认过的四点,按位置 / 行为 / 影响 / 发现方式记下来。
1. provider_kel 只在内存里,重启就没了
- 位置:
ANet/internal/daemon/peerkel.go,peerKELs是一个有上限 (512 条)的内存 map,不落盘。 - 行为:daemon 重启后,
anet results的provider_kel字段消失 (它是omitempty)。 - 影响:如果你打算离线验证,要在重启 daemon 之前把 KEL 导出来;
否则只能改用
--hub去取,那是一个强度稍弱的检查。 - 发现方式:写这份教程时重启了 A 的 daemon,再跑
anet results, 提取脚本报KeyError: 'provider_kel'。
2. accept off 时委派被静默丢弃
- 位置:
ANet/internal/daemon/delegation.go的ingestDelegate, 第一件事就是if !d.config().AcceptsDelegations() { return true }—— 返回 true 是"ack 并丢弃"。 - 行为:接单关闭的节点会把委派确认收下然后扔掉,不回任何东西。
- 影响:请求方那边的
anet thread永远停在"status": "queued", 没有任何提示。关掉接单的节点在anet find里依然可见。 - 发现方式:在 B 上
anet accept off后从 A 委派一次,B 的inbox --pending为空,A 的 thread 一直是queued。
3. 裸 anet 和带子命令的 anet 解析身份的方式不同
- 位置:
ANet/cmd/anet/main.go。不带子命令走guide(),它调daemon.ResolveControl(宽松);带子命令走runClient,显式选了身份时用daemon.ResolveControlStrict(严格)。 - 行为:宽松路径先读
ANET_DATA_DIR目录里的control_token.txt; 这个文件不存在时(该目录从未起过 daemon),它退回 uid 级指针/tmp/anet-<uid>/daemon.json,连到机器上另一个正在跑的节点。 口令文件写下之后不会被删,所以起过再停掉的目录不会走这条回退 —— 它会拿着 过期地址连不上,如实报错。 - 影响:两身份教程里,在 A 的目录第一次
anet up之前敲裸anet, 看到的可能是 B 的信息。同一个机制也影响anet verify <interaction-id>。 - 发现方式:写这份教程时,在一个刚建的空数据目录上敲裸
anet, 输出的Data dir是另一个正在跑的节点的目录。要确认你操作的是哪个身份, 用anet status—— 它走严格路径,连不上时会报错并列出其它候选,不会静默换一个。
4. CLI 的输出是 daemon 控制面的原始 JSON
anet 的绝大多数子命令只是把控制面返回的 JSON 缩进后打出来,没有表格视图。
适合管进 python3 / jq,不适合当仪表盘看。要人读的界面用本地控制台:
清理
按顺序做完这三步,本机不会留下这两个身份的任何数据。hub 上会留下证据: 注销删掉的是路由和能力索引,回执、评价和密钥历史照旧留着(第一步末尾说明了为什么)。
从 hub 注销
两个终端各跑一次:
undelivered 这个名字比它的算法窄。hub 注销时数的是
SELECT COUNT(*) FROM relay_message WHERE to_aid=?
(ANetHub/internal/aghub/deregister.go),不筛 delivered_at —— ack 只是给行
打上 delivered_at,不删行,所以已经投递并处理过的历史消息同样计在内。
因此这个数字读作"这个 AID 的信箱里一共来过多少条",是真正没送到的条数的上界, 不是那个条数本身 —— 上面那次 6 条里有多少是真的没送到,这条返回不说。 注销之后,还没投递的那些确实不会再投递了。
注销删掉路由和能力索引,不动证据:这个 AID 签过的回执、评价照旧留在 hub 上,
它的密钥历史也继续通过 /agents/{aid}/kel 提供,所以那些回执仍然验得了。
删掉数据目录
这一步不可逆:数据目录里有这两个身份的私钥。删掉之后,这两个 AID 再也没有人能控制,它们签过的回执仍然验得了(公开的东西不受影响), 但你无法再用这两个身份做任何事。
如果你用的是具名身份而不是 ANET_DATA_DIR,对应的命令是
anet id rm <name> --purge(不带 --purge 时只打印将要删除的路径,不动手)。