跳到主要内容

02 · 路由与发现(DHT + 拉取式发现)

本章是 dir 工程含量最高的部分。核心一句话:DHT 里只放“谁有这块内容(CID)”,不放“这块内容是什么(标签)”;标签靠对端把记录拉回来现抽、缓存到本地。理解这个反直觉的设计,后面全通。

1. 这章要解决的小问题

我们想做“按能力发现 agent”:别的 peer 发布了一条带 skills/AI/Machine Learning 标签的记录,我应该能 search --skill AI 找到它。

最朴素的做法:把标签直接塞进 DHT(DHT.PutValue("/skills/AI", CID))。但这会撞上 DHT 的硬限制——一个 key 只可靠地复制到离它最近的约 20 个 peer(k-closest)。网络一大,标签传播就不可靠、会丢、会过期。ROUTING.md 把这条旧设计明确标为 “❌ REMOVED”。

2. 思路 / 直觉:拉取式发现(pull-based discovery)

把问题拆成两层,各用最擅长的机制:

放什么用什么为什么
“谁有”CID 的 provider 宣告DHT Provide(CID)DHT 的 provider 系统是成熟可靠的,而且 provider 宣告能到达所有 peer,不受 k-closest 限制
“是什么”技能/领域/模块标签对端拉回内容现抽 + 本地缓存标签永远和内容一致(从源头抽),且按需拉取可扩展到上百 peer

直觉类比: DHT 像 BitTorrent 的 tracker,只告诉你“这些 peer 有这个文件”;你想知道文件里有什么标签?自己连过去把文件头拉回来看一眼,然后记在小本本上(本地缓存)。下次搜索只翻小本本,不再打网络。

这就解释了三个操作的分工:

  • Publish = 本地记一笔 + 向 DHT 宣告 Provide(CID)(+ 可选 GossipSub 广播标签走快路径)。
  • List = 只查本地“我自己发布了哪些记录”,永不打网络。
  • Search = 只查本地缓存的‘远端标签’(来自拉取式发现),OR 打分过阈值,永不实时打网络。

3. 核心数据结构:增强标签键(Enhanced Label Key)

标签在本地 KV 里用一种自描述的键存储,把所有信息编进 key 本身:

格式: /<namespace>/<label_path>/<cid>/<peer_id>

例如:/skills/AI/Machine Learning/baeabc123.../12D3KooWExample...

好处:不用解析 JSON value 就能从 key 里抠出 CID 和 PeerID——过滤“本地 vs 远端”只看末段 PeerID。value 里只剩极少的时间戳元数据(LabelMetadata{Timestamp, LastSeen})。构建/解析见 server/routing/label_utils.go:18(BuildEnhancedLabelKey)、:23(ParseEnhancedLabelKey)。标签命名空间只有四种:skills/domains/modules/locators,定义在 server/types/label.go:24

4. 主线一:Publish(发布)

route.Publish 的逻辑很短,但分工清晰——本地总是写,网络看情况:

// server/routing/routing.go:76-93(Publish 节选)
err := r.local.Publish(ctx, record) // 1. 本地永远写(归档 + 可查)
// ...
if r.hasPeersInRoutingTable() { // 2. 有 peer 才向网络宣告
err = r.remote.Publish(ctx, record)
}

hasPeersInRoutingTable 检查 DHT 路由表里有没有 peer(server/routing/routing.go:36)——孤立节点也能正常本地工作,这是“离线可用”的关键。

网络宣告做两件事(server/routing/routing_remote.go:253 routeRemote.Publish):

  1. r.server.DHT().Provide(ctx, decodedCID, true) —— 关键操作,失败就返回错误。
  2. 若启用 GossipSub,pubsubManager.PublishRecord 广播标签——尽力而为,失败只记 warning(DHT+Pull 兜底)。

5. 主线二:远端怎么发现并缓存标签

这是拉取式发现的心脏。远端节点收到“某 peer Provide 了某 CID”的通知后,走 handleCIDProviderNotification(server/routing/routing_remote.go:682):

下图怎么读:从上到下是收到通知后的决策,先查缓存有没有,没有才拉

收到 DHT 通知: peerX Provide 了 CID_A


是我自己宣告的? ──是──▶ 忽略
│否

本地已缓存 CID_A@peerX 的标签? ──是──▶ 只更新 lastSeen 时间戳(不拉)
│否(新记录)

service.Pull(peerX, CID_A) ← RPC 直连源 peer 把记录拉回


GetLabelsFromRecord(record) ← 从内容现抽 skills/domains/modules


对每个标签写本地 KV:
/skills/AI/CID_A/peerX → {Timestamp, LastSeen}

关键源码(拉取 + 抽标签 + 缓存):

// server/routing/routing_remote.go:719-739(节选)
record, err := r.service.Pull(ctx, notif.Peer.ID, notif.Ref) // RPC 拉回内容
// ...
labelList := types.GetLabelsFromRecord(adapter) // 从内容抽标签
for _, label := range labelList {
enhancedKey := BuildEnhancedLabelKey(label, notif.Ref.GetCid(), peerIDStr)
// ... 写入 r.dstore
}

两条快慢路径并存(hybrid)。 如果对端开了 GossipSub,标签会通过广播到达(handleRecordPublishEvent,:832),DHT 通知到的时候发现“已缓存”,就只更新时间戳、省掉一次拉取。代码注释给了经验值:~90% 情况 GossipSub 先到(~15ms),~10% 情况 DHT 先到(~80ms)走拉取兜底(:677-679)。GossipSub 用 msg.ReceivedFrom密码学已验证 peer ID写缓存,防止恶意 peer 投毒标签缓存(:826-828)。

6. 主线三:Search(发现)与 OR 打分

Search 只查本地缓存的远端标签,不打网络(server/routing/routing.go:114 注释:“Search is always remote-only ... queries locally cached remote announcements”)。

算法在 searchRemoteRecords(server/routing/routing_remote.go:341):

  1. QueryAllNamespaces 扫四个标签命名空间(:49)。
  2. 从 key 抠出 (label, cid, peerID),peerID == 本地 的跳过(Search 只看远端)。
  3. 对每个远端 CID 算匹配分:每个查询命中任一标签就 +1(OR 逻辑),见 calculateMatchScore(:413)。
  4. score >= minMatchScore 才收进结果。

OR 逻辑 + 最小阈值让一个 API 同时表达“宽松”和“严格”:

你想要怎么调效果
AI Python(任一即可)--skill AI --skill Python --min-score 1命中其一就返回
AI Python(都要)--skill AI --skill Python --min-score 2两个都命中才返回

生产防御: min-score=0 会被强制抬到 DefaultMinMatchScore=1(server/routing/constants.go:52),避免“匹配 0 个查询”导致全表扫描;查询会服务端去重(deduplicateQueries,:311)防止客户端重复传同一查询把分数刷高。

标签匹配规则(ROUTING.md “Query Types and Matching”):skills/domains/modules 是层级前缀匹配(查 AI 命中 AI/ML),locators 是精确匹配

7. 后台维护:让缓存不腐烂

拉取式发现的代价是缓存会过期,所以有两个后台任务(newRemote 里起的 goroutine,server/routing/routing_remote.go:229-233):

  • 周期重发布(StartLabelRepublishTask)——自己发布的记录要定期重新 Provide,否则 DHT 记录过期(RecordTTL = 48h,server/routing/constants.go:14)。proto 注释也提醒“Items need to be periodically republished (eg. 24h)”(proto/agntcy/dir/routing/v1/routing_service.proto:25)。
  • 远端标签清理(StartRemoteLabelCleanupTask)——lastSeen 太旧的远端标签缓存被清掉,避免堆积陈旧数据。

这也解释了 LabelMetadata 里为什么有 LastSeen:每次重新收到宣告就刷新它(updateRemoteRecordLastSeen,:909),清理任务靠它判断新鲜度。

8. 关键细节 / 坑

  • List 和 Search 看的是同一份 KV、用 PeerID 区分。 同一个增强键库里,末段是本地 PeerID 的是“我发布的”(List 看这些),是别人 PeerID 的是“缓存的远端记录”(Search 看这些)。一份存储,两种视角。
  • Search 不返回内容,只返回 {CID, peer, 匹配查询, 分数} 拿到 CID 后要 pull 才有字节(proto/.../routing_service.proto:39-41 注释:“Results from the search can be used as an input to Pull”)。
  • GetProviderCount = 流行度信号。 数本地路由库里某 CID 被多少不同 PeerID 宣告,给 reconciler 当“provider_count”流行度用——注意它是本地视角的点态估计,不是实时全网查询(server/routing/routing.go:175 + proto :145-150)。
  • DHT 自定义参数。 协议前缀 "dir"、汇合点 "dir/connect"ModeServer、对标签命名空间挂了自定义校验器、MaxRecordAge=48h,全在 newRemoteWithCustomDHTOpts(server/routing/routing_remote.go:149-174)。

9. 小结与下一步

记住三句:DHT 放“谁有”,标签靠拉内容现抽缓存到本地;List 查本地自有、Search 查本地缓存的远端;OR 打分 + min-score 一个 API 覆盖宽松/严格。

下一章看怎么给这些记录加上“可验证的身份” → 03 · 可信:签名与命名

10. 本章代码地图

主题文件符号
Publish 顶层分工(本地+网络)server/routing/routing.go(*route).Publish
有没有 peer 决定是否上网络server/routing/routing.go(*route).hasPeersInRoutingTable
网络宣告(DHT Provide + GossipSub)server/routing/routing_remote.go(*routeRemote).Publish
拉取式标签发现(核心)server/routing/routing_remote.go(*routeRemote).handleCIDProviderNotification
GossipSub 快路径缓存server/routing/routing_remote.go(*routeRemote).handleRecordPublishEvent
Search + OR 打分server/routing/routing_remote.go(*routeRemote).searchRemoteRecords / calculateMatchScore
增强标签键构建/解析server/routing/label_utils.goBuildEnhancedLabelKey / ParseEnhancedLabelKey
标签命名空间类型server/types/label.goLabelType / GetLabelsFromRecord
DHT/超时常量server/routing/constants.goRecordTTL / RefreshInterval / DefaultMinMatchScore
设计文档(必读)server/routing/ROUTING.md