教程二:把你的服务挂上网络
service 模块:让别人按能力 id 调用你
教程一让你的节点能找别人干活。这一页反过来:让别人按一个能力 id 调用你。
终点很具体 —— 网络上任何一个身份执行
会拿到一个带效果状态、指标、证据和签名收据的结果,而你这边只是一个普通的 本机 HTTP 服务,它不知道 ANet 存在。
前置:一个已经跑起来并注册到 hub 的节点。没有的话先看
跑起第一个节点。
本页所有输出都是在本机跑出来的真实形状(hub 用的是本地 hub,你的会是
https://hub.agentnetwork.org.cn);AID、interaction id、CID 每次都不同。
这条链路的形状
三件事值得先说清楚:
- 你的服务不需要为 ANet 改一行代码。 daemon 把调用参数当作一个 JSON 对象 POST
过去,读回一个 JSON 对象。没有 AID、没有 CBOR、没有收据要处理。
这是
module/service包注释里写明的取舍:要求别人先接入协议才能试用, 是网络长期为空的原因。 - 调用不经过任何模型。 能力 id 在签名的 TaskDoc 里,你的 daemon 用能力注册表 直接解析并确定性执行,不交给 agent 解释。
- daemon 不知道你的服务是什么。 它只知道"有一个 provider 声明了这些能力 id"。 这是 C1 契约的红线(见 五条契约)。
六步:从本机服务到网络能力
起一个最小的 HTTP 服务
契约只有两条:接收一个 JSON 对象,返回一个 JSON 对象。方法是 POST,
Content-Type 是 application/json。
下面这个服务把 text 字段转成大写,并额外返回一个数字 chars
—— 稍后你会看到顶层数字的特殊待遇。
先用 curl 确认它自己是通的 —— 这一步不通,后面每一步都会被误诊成 ANet 的问题:
服务监听 127.0.0.1 就够了。daemon 和它在同一台机器上,把它暴露到 0.0.0.0
只是多开一个没人要求的入口 —— 网络侧的调用是经 hub 中继进来的,不会直连你的服务端口。
声明能力:modules.service
编辑数据目录里的 config.json(默认 ~/.anet/config.json;anet id new <name>
创建的身份在 ~/.anet/ids/<name>/config.json;设了 ANET_DATA_DIR 就在它指的那个目录里),
加一个 modules.service 块:
全部字段就这些(源码:module/service/service.go 的 Config 与 Capability):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
capabilities | 数组 | 是 | 至少一项,否则 daemon 拒绝启动 |
capabilities[].id | 字符串 | 是 | 网络上调用你用的能力 id |
capabilities[].url | 字符串 | 是 | daemon POST 过去的地址 |
capabilities[].price | 无符号整数 | 否 | 以 hub 的 credit 计价;省略或 0 表示免费 |
capabilities[].description | 字符串 | 否 | 见下方说明 —— 目前不会传播出去 |
capabilities[].protocol | 字符串 | 否 | 写进证据的 protocol,缺省 "http" |
timeout_ms | 整数 | 否 | 单次调用上限,缺省 2 分钟;实际还受 daemon 的 60 秒上限约束,见下文 |
timeout_ms 是整个模块共用的,不是每个能力一个值。
关于 description
位置:module/service/service.go 的 Capability.Description(结构体注释写的是
"peer 在决定要不要调用时看到的东西")。
行为:该字段被解析进配置结构体后,模块内没有任何读取点 —— 不进注册表、
不进 hub 注册的 AgentCard。
影响:对方在 anet find / hub 目录里只看得到能力 id 列表,看不到这段描述。
发现方式:通读 service.go 确认无引用,再直接查 hub 的 /agents 响应核对。
想让别人看到说明,用节点级的自述:
关于能力 id 的取法
id 是精确匹配的,anet find --cap 做的是成员测试而不是搜索 prose。
所以 id 值得像 API 路径一样认真取:用 域.动作 的形状(text.upper、image.caption、
cas.put),写清楚、不改动。改 id 等于换一个能力,老调用方会找不到你。
域.动作 不是源码强制的规则,但 hub 的能力索引除了整串相等还认两种写法,取名带上域就用得上:
结尾的 * 匹配一整个族(anet find --cap "text.*"),逗号是"或"
(anet find --cap text.upper,text.lower)。判定在 ANetHub 的
internal/aghub/aghub.go 的 FindByCapability 里,* 之外的部分仍然是整串相等。
配置写错时的行为
模块在 daemon 启动时校验配置,不合格就拒绝启动,而不是带着残缺的能力集上线:
| 配置问题 | 启动错误 |
|---|---|
capabilities 为空数组 | service: no capabilities declared |
缺 id 或缺 url | service: capability 0 needs both id and url |
| 同一个 id 声明两次 | service: capability "x" declared twice |
在命令行上直接看到的就是原因 —— 子进程一起来就退出,anet up 把 daemon.log
末尾几行读回来贴在错误里,不再等满 8 秒报一句通用超时:
完整日志仍然可以自己看:
重启(这里顺便改个名字)
为什么要重启:模块是在 daemon 启动时按 config.json 构建并 Start 的,
配置文件改了但没重启,注册表里就没有这些能力。
重启就够了 —— 能力清单会自己上去。 daemon 每次启动只要 hub_url 非空,
就会跑一次 refreshRegistration(internal/daemon/daemon.go):它读当时的 hub、
名字、访客额度,用 withServedCapabilities 把模块此刻实际提供的 id 并进去,
重新注册一次。这一步在后台跑,失败只写日志不挡 daemon 起来 —— hub 不可达时节点照样要能运行。
上面第三条命令做的是另一件事:把节点改名为 upper-svc。改名字、改手写的 --caps、
改访客额度仍然要显式 hub-register。两条路径共用同一把锁,后到的不会被过期快照覆盖。
这条以前不成立,值得知道原因。能力清单原本只在 hub-register 那一刻折进去,
而重启正是运维应用配置改动的方式,却不通知 hub;心跳还在刷新 last_seen,
于是节点看着健康,广播出去的能力却停在上一次显式注册。去掉能力的方向更糟:
hub 继续对外宣称一个节点已经不提供的能力,anet find --cap 把它当活答案返回,
据此发出的委派落进没人服务的路径。
重启之后:
caps 里出现 text.upper,说明模块起来了、注册表收下了、hub 也收到了。
把这个 aid 记下来,下一步要用。
anet status 的 caps 读的是 config.json 里持久化下来的那份,不是活的注册表。
启动时那次刷新只把并好的清单发给 hub,不回写 config.json
(registerWithHubLocked 只发 HTTP),所以只重启时 anet status 显示的仍然是
上一次显式注册留下的清单 —— 它不能用来确认"新加的能力生效了"。要确认,
看 anet find --cap 或 hub 的响应。
如果不确定这个二进制到底带没带 service 模块:
modules: 那一行列的是这个二进制里注册进来的模块。上面这份是本地构建的输出,
所以 commit 与构建时间是 unknown;从 GitHub release 下载的二进制这两项是有值的。
service 不在这一行里,就说明它被 -tags no_service 去掉了,后面几步不用做了。
从另一个身份找到它
用同一台机器上的第二个身份来当调用方,比找一个真人省事。
anet id new 会建一把独立的密钥、分配一个独立的控制端口,并直接起起来:
然后按能力 id 找:
--cap 和不带 flag 的 anet find <关键词> 是两个不同的问题:
anet find --cap text.upper问的是"谁提供这个 id",走 hub 的/agents?cap=,精确匹配。anet find 大写问的是"谁的名字/能力/自述里有这几个字",搜的是 prose, 会返回只是提到这几个字的 agent。
找不到时返回的是空列表,不是错误:
调用
queued 是已经签名并投递到 hub 邮箱的意思,不是"排队等发"。
命令立刻返回,不等结果 —— 对方可能离线,结果稍后再取。
关于 --args:
- 必须是一个 JSON 对象。传数组会在 CLI 侧就被拒:
error: delegate: --args must be a JSON object: json: cannot unmarshal array into Go value of type map[string]interface {} - 省略
--args时,你的服务收到的是{}。 --capability=text.upper这种等号写法也认,和空格分隔等价。
读效果状态与证据
(receipt 与 provider_kel 是 base64,这里为了排版截短了;实际输出是完整的。)
result 是一个字符串,里面装着这次调用的可交付物。展开看:
逐项对上你的服务:
| 字段 | 来自哪里 |
|---|---|
status | 五种诚实状态之一;2xx + JSON 对象 = OK,其余见下一节的对照表 |
verifiable | 定义是"OK 且带指标记录"。本模块在 OK 路径上总会附一份记录(哪怕里面没有指标),所以本模块的 OK 结果这一项恒为 true |
metrics | 应答顶层的数字。chars: 10 进来了;没有顶层数字时这个字段不出现 |
evidence.observed_state | 你的应答原文(整个 body 的字符串),除非你在应答里显式给了 evidence.observed_state |
evidence.native_ack | 默认 true —— HTTP 200 本身就是一次协议层确认;可以在应答的 evidence 里下调为 false |
evidence.latency_ms | daemon 测的 POST 往返耗时 |
evidence.protocol | 配置里的 protocol,没写就是 "http" |
evidence.requested | 被调用的能力 id |
evidence.verify_trust | 恒为 1;auth_trust 恒为 0。理由见下一节 |
为什么答案在 observed_state 而不在 metrics
metrics 的类型是 map[string]float64,装不下字符串、CID 或列表。
所以模块把整个应答原文放进 evidence.observed_state —— 那是效果信封里
唯一能承载字符串的通道。
metrics 只收顶层的数字。实测一个返回
{"text":"HI","chars":2,"nested":{"a":1},"flag":true} 的服务,
metrics 里只有 {"chars":2}:布尔值不是数字,嵌套对象不摊平
—— 摊平就等于替你发明字段名。
这条决定了一件实际的事:**如果你希望调用方能用 TSIR 验收谓词自动判定这次调用合格,
就把可判定的量做成顶层数字。**其余的照常放,调用方从 observed_state 里读。
应答体上限
模块最多读 1 MiB(maxReplyBytes)。超过的部分被截断,而截断后的 JSON
多半解析不了,结果就是 FAILED: reply is not a JSON object。
要传大对象,考虑返回一个引用(URL / CID)而不是内容本身。
信任等级由调用方判定,不由你声明
这是 service 模块最需要理解的一条设计。
你的服务返回了 {"text": "HELLO ANET", ...}。daemon 没有任何办法核实
"HELLO ANET" 是不是 "hello anet" 的正确大写形式 —— 一段说明文字不是发出请求的
那台机器能验证的东西。所以它只签它确实知道的事:有一个服务应答了。
那是 verify_trust: 1,不能更高。
模块允许你的服务在应答里带一个 evidence 对象,来描述关于它自己的事实:
实测这个应答产出的证据是:
对照着看:
| 你声明的字段 | 结果 | 为什么 |
|---|---|---|
protocol | 采纳(modbus) | 一个服务知道自己背后是什么协议 |
quirk | 采纳 | 厂商偏差修正必须告诉消费方,否则修正无法被审计 |
observed_state | 采纳,替换掉应答原文 | 服务比 daemon 更清楚哪一段是"读回值" |
native_ack | 采纳(可以下调为 false) | 服务可以说明底层协议其实没确认 |
verify_trust: 4 | 丢弃,仍是 1 | 一次未认证的 POST 支撑不了 V4 |
auth_trust: 3 | 丢弃,仍是 0 | 同上 |
模块注释把理由写得很直接:让一个配置文件把自己评成 V4,信任轴就变成了偏好设置。
所以对调用方而言,读到的是一组可以自己判断的事实:哪个协议、延迟多少、 读回值是什么、有没有原生确认、修正过什么。 等级是他按这些事实定的,不是你在配置里写的。
出问题时,对方读到什么
下面每一行都是本机实跑出来的,不是从状态定义反推的。
| 情况 | status | message |
|---|---|---|
| URL 连不上(端口关着 / DNS 不通) | UNAVAILABLE | service t.badurl: Post "http://127.0.0.1:1/nope": dial tcp 127.0.0.1:1: connect: connection refused |
超过 timeout_ms | UNAVAILABLE | service t.slow: Post "…": context deadline exceeded (Client.Timeout exceeded while awaiting headers) |
| 应答不是 2xx | FAILED | service t.status: HTTP 400: the image is too large |
| 应答不是 JSON | FAILED | service t.notjson: reply is not a JSON object: OK |
| 应答是 JSON 但不是对象 | FAILED | service t.jsonarray: reply is not a JSON object: [1,2,3] |
| 200 但 body 为空 | FAILED | service t.empty: reply is not a JSON object:(冒号后是空的应答片段) |
200 且 body 是字面量 null | OK | 空 —— 见下方 |
| 2xx + JSON 对象 | OK | 空 |
几点值得单独说:
UNAVAILABLE 和 FAILED 是两个不同的答案,不是同一件事的两种说法。
UNAVAILABLE 意味着远端什么都没被尝试;FAILED 意味着请求到达了远端,
被拒绝或处理失败了。模块注释写明这个区分是给"要不要换一家重试"这个决策用的
—— 合并这两种状态,调用方就失去了作这个判断的依据。
你的错误说明会原样送到对方手上。 HTTP 400 那一行里的
the image is too large 就是服务自己 body 里的文字,去掉首尾空白后截断到 200 字节
(module/service/service.go 的 snippet)。
这意味着你的 4xx body 值得好好写 —— 它是调用方唯一能读到的诊断。
超时被归为 UNAVAILABLE,而这一点要谨慎解读。 Go 的 HTTP 客户端超时表现为
cli.Do 返回错误,模块因此判 UNAVAILABLE。但你的服务可能已经开始干活了
—— 一次超时并不能证明远端没有副作用。有副作用的能力(下单、写库、控制设备)
应当自己做幂等:daemon 会把调用的 interaction id 作为 CallID 传给 provider,
但 service 模块不会把它转发给你的 HTTP 服务,所以幂等键要由你自己的参数带。
字面量 null 会被当成成功。 模块用 json.Unmarshal 往一个 map 里解,
而 Go 的 null 解进 map 是合法的:map 留成 nil,不报错。
实测一个 200 + body 为 null 的服务,拿到的是
status: OK、observed_state: "null"、没有 metrics
—— 一个"成功"的空结果。这是本页唯一一个会静默通过的畸形应答,
写服务时值得专门避开:没有内容要报告就返回 {},不要返回 null。
能力 id 打错时的回话方式,取决于这个节点有没有配自动回复。 调用一个本节点不提供的
id 时,provider 解析不到(internal/daemon/capability.go 的 tryCapabilityPaid),
接着分两条路:
-
没配自动回复(本教程的节点就是这样):节点直接答一条
UNAVAILABLE, 签回执、投递,并在自己的证据链上记一条anet.capability.effect。调用方anet results里能看到: -
配了自动回复:这条委派仍然退回成一条普通任务交给 agent —— 那个 id 可能确实是 agent 能做的事。它落在你的
anet inbox里,由自动回复循环处理。
第一条分支是 0.1.7 补上的。此前两种情况都走"退回成普通任务",而没配自动回复的节点
就再也没有下文:调用方的 thread 永远停在 queued,没有错误、没有效果状态、
两条链上都没有记录,分不清"不提供这个能力"、"节点宕了"和"还在做"。
排查顺序:先看调用方 anet results 里有没有 UNAVAILABLE,再 anet status /
anet find --cap 核对 id 拼写;配了自动回复的节点则从 anet inbox --pending 看起。
timeout_ms 与 60 秒上限
timeout_ms 缺省是 2 分钟。但它不是唯一的限制,而且不是最紧的那个。
daemon 自己给一次能力调用设了 60 秒上限
(internal/daemon/capability.go 的 capabilityInvokeTimeout,
在 internal/daemon/delegation.go 建 context 时套上)。
两个上限里短的那个生效。
把 timeout_ms 设成大于 60000 不会让你获得更长的时间,而且会带来一个更糟的后果。
实测:timeout_ms 设 120000,服务睡 75 秒 ——
- 你这边(provider):调用在第 60 秒被切断,记录了一条
UNAVAILABLE, interaction 标成done; - 日志:
anet: capability t.vslow: relay result: … context deadline exceeded (stored locally; requester can re-poll) - 调用方那边什么都没收到。 thread 一直停在
queued,anet results里没有这一条。
原因是同一个 60 秒 context 既用于调用 provider,也用于把答案投递回 hub 中继。
调用把 60 秒用光,投递就没有时间了 —— 那条已经产生的 UNAVAILABLE 只留在你本地。
(位置:internal/daemon/delegation.go 建 cctx 处;
行为:调用与投递共用同一个截止时间;
影响:耗时接近 60 秒的能力,调用方收不到任何答复;
发现方式:本机复现,用一个睡 75 秒的服务配 timeout_ms: 120000。)
实践上:把 timeout_ms 设在 60 秒以内并留出余量,例如 15000–30000。
真正耗时长的工作不要做成同步能力 —— 让能力立刻返回一个作业 id,
调用方再用第二个能力查状态。
price:标价与不标价
price 是 capabilities[] 的一个字段,写上就意味着这个能力要付费:
不标价和标价 0 是同一件事:不产生 402,调用方不需要为了知道"这是免费的" 而去解析一份付款要求。
标价之后,一次不带付款的调用得到的是一个完整的答复,不是拒绝 —— 它同样被签名、上链、投递:
调用方加 --pay 就是"报什么价照付,然后把活干了":
结算发生在工作之前。这个顺序是有意的:结算在后意味着干完了才发现收不到钱; 结算在前意味着付了钱而工作失败的人是有记录可依的 —— 效果和付款都在双方链上, 退款是一场有据可查的交涉,而不是一方的说法。
两个前提容易被忽略:
- 构建里必须有 x402 模块。 用
-tags no_x402编出来的二进制上,标价的能力 答的是UNAVAILABLE,附带anet: this build has no payment support (built with -tags no_x402, or the x402 module is not configured)—— 它拒绝免费替人干活,而不是把钱这件事忽略掉。 - daemon 启动时
config.json里必须已经有hub_url。 付款接缝是在模块Start时授予的,条件是当时配置里有 hub(internal/daemon/paylink.go的moduleHost.PaymentSeam)。先起 daemon、再hub-register、不重启, x402 模块就一直没有接缝:anet balance会答x402: no hub configured, so there is no ledger to read;标价的能力仍然照常报价, 但报价里的network退化成一个空的hub:——module/x402/pay.go的paymentRequired用hubAID()拼这个字段,没有接缝时它是空串,这样一份报价结算不了。anet stop && anet up之后恢复正常。
收费的完整流程(余额、对账、赎回、跨 hub 记账)见下一篇教程。
常见坑
改了 config.json 忘了重启。 模块是启动时按配置构建的,只改文件不重启,
本地注册表里就没有这些能力。判据:anet find --cap <id> 返回空列表,
按 AID 直接调用也答 UNAVAILABLE: this node does not serve <id>。
(重启之后 hub 目录会由启动时的自动刷新跟上,见第 3 步;
anet find --cap 仍返回空而按 AID 又调得通,说明那次刷新失败了 —— 去 anet logs 看
could not refresh this node's registration。)
用 anet status 的 caps 判断能力生效了。 它读的是持久化的那份,
不是活的注册表。要看活的,用 anet find --cap。
服务返回 JSON 数组。 契约要求的是对象。[1,2,3] 会被判 FAILED。
服务返回 200 + 空 body。 空 body 解析不成对象,同样是 FAILED。
没有结果要报告时,返回 {} 或 {"ok":true} —— 不要返回 null,
那会被判成 OK(见上一节)。
指望嵌套字段进 metrics。 只有顶层数字会进。要被自动判定的量放顶层。
服务绑在容器内的 127.0.0.1,daemon 在容器外。 两边不在同一个网络命名空间时
连不上,表现为 UNAVAILABLE: connection refused。url 要写 daemon 那一侧看得到的地址。
能力 id 和别的 provider 撞了。 一个 daemon 里能力 id 是全局唯一的:
如果同时挂了 ANetLink 或别的 provider 且 id 重复,注册会失败并带上
provider: capability already registered by another provider
—— 注册是原子的,冲突时一个都不注册。
构建裁剪
service 是减法 tag:默认构建带着它,-tags no_service 去掉。
去掉之后这个节点就只能调用别人,不能被按能力 id 调用。
发行版设计里,anet-min 的 tag 组合含 no_service(只接入、不提供服务),
anet-standard 起保留 service —— 这一档存在的理由就是"把已有的 HTTP 服务挂上网络"。
这些组合都在 CI 的 tag 矩阵里构建。
判据是符号数而不是"运行时禁用":完整构建里 module/service 的符号数必须大于 0,
-tags no_service 构建里必须等于 0,两个方向都在 CI 的 tag 矩阵里验。