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):
r.server.DHT().Provide(ctx, decodedCID, true)—— 关键操作,失败就返回错误。- 若启用 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):
QueryAllNamespaces扫四个标签命名空间(:49)。- 从 key 抠出
(label, cid, peerID),peerID == 本地的跳过(Search 只看远端)。 - 对每个远端 CID 算匹配分:每个查询命中任一标签就 +1(OR 逻辑),见
calculateMatchScore(:413)。 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,全在newRemote的WithCustomDHTOpts(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.go | BuildEnhancedLabelKey / ParseEnhancedLabelKey |
| 标签命名空间类型 | server/types/label.go | LabelType / GetLabelsFromRecord |
| DHT/超时常量 | server/routing/constants.go | RecordTTL / RefreshInterval / DefaultMinMatchScore |
| 设计文档(必读) | server/routing/ROUTING.md | — |