结果动笔前,我们翻了翻仓库——这家伙刚改了名。
6 月 20 日,官方把 enrichment_agent 这个包改名成了 reference_agent(PR#129),commit message 写得很直白:"Reframes the OKF proof-of-concept agent as the reference agent to disambiguate it from other agents"(把它重新定位成"参考 agent",好跟其它 agent 区分开)。
改名波及面不小:Python import、ADK 标识、CLI 命令、PyPI 包名、User-Agent、prompt 文件名、README 行文——全改了。但 SPEC.md 一个字没动。
这是我们的解读,可以拿出来讨论:Google 在刻意淡化"这个 agent 就是 OKF 的官方/唯一工具"的印象。README 里反复说它是 "proof of concept, demonstrating one way to produce OKF bundles"——格式才是主角,这个 agent 只是"演示其中一种打法"的配角。
所以这篇文章,我们就钻进这个"配角"的源码,看看 Google 团队到底怎么用 LLM 自动织出一份知识图谱。读完你会发现:它最有意思的地方,不是 prompt 写得多花哨,而是把约束焊进了工具本身。
一、先看全貌:不是"一个 agent",是"两个串起来"
很多人第一反应会以为,这种"读元数据+爬文档+写知识库"的活,是一个无所不能的 agent 一把梭。翻开源码你会发现——它是两个职责分明的 agent 串成的。
编排的核心在 runner.py 的 enrich_all(),逻辑干净得像伪代码:
一句话解读:三段流水线——先给每个概念写初稿,再爬外部文档做增强,最后扫一遍目录生成导航。
关键在 agent.py 里,这两趟是两个独立构建的 ADK agent:
一句话解读:两个 agent 工具集几乎一样,只差一个 fetch_url。但注意,BQ agent 有 sample_rows(能采样真实数据),web agent 没有——第二趟只许读已有文档和抓网页,绝不许碰原始数据采样。这个差别后面会用到。
还有一处容易被忽略的设计:两个 agent 共享一个 Source 抽象层(sources/base.py),list_concepts() 返回的是 ConceptRef,路径就是它的身份。BigQuery 只是"当前插上的那一种数据源"。换句话说,你要接 Dataplex、Unity Catalog、Collibra,或干脆扫自己的数据库,都是再写一个 Source 子类的事——这个口子,Google 留着了。
二、第一趟 BQ pass:一个 agent 写一个概念,五步强制工作流
第一趟的逻辑简单粗暴:遍历所有概念,每个概念单独起一次 agent 调用,让它写一篇文档。
它的工作流不是"自由发挥",而是 prompt(reference_instruction.md)里钉死的五步:
一句话解读:每一步都对应一个工具调用,prompt 把顺序焊死,最后一步是收口。
文档的 body 也规定了固定分节:先一段 prose 描述(grain、时间范围、采样口径),然后 # Schema、# Common query patterns(1-3 段 SQL)、# Citations。
prompt 里还有几条很"诚实"的约束,值得点名:
"Do not invent fields, partitions, or shard counts that are not in the raw metadata"——不许编字段、编分区、编分片数; description 要求"one sentence...used verbatim in auto-generated index.md,so keep it tight"——必须一句话,因为它会被原样塞进自动生成的 index 里。
这种"prompt 当代码写、把流程钉死"的风格,是第一趟的全部逻辑。它解决的问题很明确:用确定性的元数据,产出一份确定性的、不掺幻觉的初稿。
三、第二趟 web pass:把 LLM 当爬虫,但只许"增强"不许"覆盖"
第二趟是整套设计的重头戏。它干的事听起来很自由:给 agent 一堆种子 URL,让它自己决定爬哪些链接、抓到的网页该怎么处理。但读完它的 prompt,你会发现——自由是假的,到处是闸门。
对每一个抓到的网页,agent 只能三选一:
- (a) 增强已有概念
:这个网页讲的东西,某篇已有文档覆盖了(比如某张表的 schema 参考),那就去读旧文档、增强它; - (b) 新建一篇
references/文档:这个网页定义了某个"可被名字引用的东西",值得单独立一篇; - (c) 跳过
:无关、低信号、已覆盖,直接过。
最值得拆的是 (b)——新建 reference 文档得过四道门,全部满足才行:
| Topic shape | ||
| 非 bundle 级 meta | ||
| Citation test | ||
| Reuse test |
prompt 原话说得很狠:"A bundle with zero references/ docs is fine; a bundle full of references/overview and references/getting_started is noise."(零篇 reference 没关系,一堆 overview/getting_started 那就是垃圾。)
四道门之外,还有一条特殊豁免,我们觉得是整个 prompt 最聪明的笔:指标和 join 路径,可以绕过四道门,直接进 references。 原话:"These structured extractions bypass the four-gate reference test"。
为什么?因为这两样东西天生就是"概念形状"的、天生可复用:
- 聚合指标
→ 一个指标一个文件,放进 references/metrics/<slug>.md,而且必须带具体 SQL 表达式(比如COUNT(DISTINCT user_pseudo_id)),光写定义不够; - join 路径
→ 一对表一个文件,放进 references/joins/<a>__<b>.md(表名字母序、双下划线),而且必须带具体的 ON 子句。
一句话解读:prose 类内容层层设防怕变垃圾,结构化高信号内容直接开绿灯——这个区分很见功力。
但真正让第二趟"增强不覆盖"成立的,不只是 prompt。我们翻到 bundle_tools.py 时,看到了整套源码最惊艳的一笔。
四、全文高潮:护栏不在 prompt 里,焊在工具里
prompt 里写了无数遍"不许覆盖、只能增强"。但做过 LLM 应用的人都懂——prompt 是建议,不是保证。模型该偷懒重写时,照样重写。
Google 团队显然也懂。所以他们在 write_concept_doc 这个工具里,直接加了一道代码级的校验:
一句话解读:web pass 期间,只要新文档的 schema 字段比旧文档少一个,工具直接返回 error、拒绝落盘。
引用也一样防:
一句话解读:引用条数只能增不能减——连"BQ 表的 resource URL"这种被 prompt 要求必须保留的引用,都用代码兜了底。
而且这个 error 不是冷冰冰甩一句"失败",它手把手教 agent 纠错:
"Re-call read_existing_doc to see the current schema, then re-call write_concept_doc with the full field list preserved."
这是我们的解读,也是这篇文章最想传递给你的一点:做 agent 时,约束该沉的就要沉到工具层。 prompt 里写"不许覆盖"是 80 分的保险,工具里加一道 augmentation guard 是 99 分的保险。这跟软件工程里"用类型系统、用编译器去保证约束,而不是靠注释"是一个道理。Google 团队在这套 reference agent 里,把这条原则演示得明明白白。
五、爬虫护栏:agent 凭空编的 URL,工具直接拒收
第二趟还有一个工具值得单独说:fetch_url。它负责让 agent 自己当爬虫,但爬取的所有边界,全焊在工具内部。
web_tools.py 里,这个工具在真正抓取前,要顺序过六道关:
任何一关不过,返回 {"error": ...}。默认配置是:max_pages=100、max_depth=2、allowed_hosts 默认就是种子 URL 的域名(同域名才放行)。
但最妙的设计,是对"凭空编 URL"的防御。工具内部维护一个 url_depth 字典,记录每个 URL 在爬取图里的深度;抓到一页后,把它外链的 depth 记为"父 depth + 1":
一句话解读:agent 自己编一个 URL 丢进来,工具发现它不在爬取图里,直接拒收。 因为只有两种 URL 有 depth:种子(depth=0),和"被某页外链出来"的(depth>0)。凭空冒出来的,depth 查不到——挡掉。
被拒之后,prompt 里的指令是"Treat that as a signal to stop or pick a different URL; do not retry the same URL"——把它当信号,换个 URL,别重试。
这套设计解决的是 agent 的经典翻车场景:爬虫跑飞。LLM 可能会越爬越远、重复抓、甚至编 URL 去抓。Google 的答案不是"在 prompt 里嘱咐它别乱来",而是用爬取图 + 预算 + 域名白名单,从工具层让它根本没机会乱来。
六、后处理:index.md 自动生成,渐进式披露的最后一公里
两趟 pass 跑完,会生成一堆 concept 文档。但 OKF 规范有个讲究:每个目录要有 index.md,让 agent 和人能一层层往下翻,而不是一次性把整个 bundle 灌进 context。这叫"渐进式披露"。这个 index 是怎么来的? bundle/index.py 的 regenerate_indexes() 给出了答案——它扫整棵目录树,自底向上生成:
一句话解读:先排叶子目录、再排父目录,这样父目录生成时,子目录的描述已经算好了——典型的后序遍历。
更省 token 的是描述的生成策略:如果一个目录只有一个子文档,描述直接透传那个文档的,不调 LLM;只有当目录下有多个子项、需要"概括"时,才烧一次 LLM 调用。根目录则干脆不合成描述。这种"只在必要处调模型"的克制,对想省成本的团队是很实在的参考。
七、真实产物长啥样 + 一个 Google 自己也踩过的坑
讲了半天源码,落到真实产物上是什么样?我们翻了官方 bundle 里 GA4 电商数据集的 tables/events_.md,这是 reference agent 实际生成的一篇概念文档:
一句话解读:
frontmatter 带 type/resource/tags/timestamp;
# Metrics 每条都链到对应的 references/metrics/ 文档;
# Schema 细到嵌套 RECORD 的子字段;
# Citations 是真实抓到的官方 URL。
这就是把"元数据 + 文档"织成图的样子。
但注意看那些链接——../references/metrics/event_count.md、../references/joins/events___ads_clickstats.md,全是文件相对路径。
这其实暴露了 Google 自己踩过的一个坑。6 月 13 日的 PR#45(commit message 写得很详细)揭示:最初 agent 把每个交叉链接写成 bundle 根绝对路径/tables/users.md 这种形式。在本地或可视化器里没事,但 bundle 一传到 GitHub 上,那个开头的 / 被当成 repo 根,于是每个链接全 404。
修复的动静不小:改了 bundle/index.py(写相对路径)、viewer/generator.py(解析回 concept id)、两个 prompt 的 Cross-linking 段全部重写,还重新生成了 40 个 bundle 文件里的 152 处链接。现在 prompt 里明写着:"Use file-relative paths only. Never start a link with / (that breaks GitHub rendering)"。
这个细节我们觉得挺有价值——它说明一件事:markdown 看着简单,真要在"多平台都能正确渲染"这件事上扛住,坑比想象的多。/foo.md 在你的 Obsidian 里是好用的,在 GitHub 上就废了。OKF 选择文件相对路径,是在"跨工具可移植"和"实现复杂度"之间做了取舍。
八、能抄什么,该眯眼什么
拆到这儿,落到两个问题。
能抄的,很多。 这套 reference agent 是个相当完整的"用 LLM 生产结构化知识"的工程范本:
- 分 pass 编排
把"确定性元数据"和"不确定性网页爬取"拆成两个 agent,各自职责清晰; - 工具即护栏
augmentation guard、fetch_url 的爬取图、index 的自底向上——约束尽量沉到代码层,不靠 prompt 兜底; - 结构化内容开绿灯
指标和 join 这种高信号、可复用的东西,绕过防垃圾四道门直接落库; - Source 抽象层
换数据源是写子类的事,不是改 agent。
这些范式,哪怕你不用 OKF、不用 Google ADK,都能直接搬到自己的 context 工程里。这是我们最想说的"值得抄"的部分。
但该眯眼的,和上一篇一样,没变。 翻开 pyproject.toml,核心依赖第一条还是:
这套护栏设计得再漂亮,默认实现还是绑在 Google 自家的 ADK 和 Gemini 上。write_concept_doc 的 augmentation guard 是好东西——但你想用,要么接受 google-adk 这条依赖链,要么照着这个思路自己实现一个。Google 给的口子(Source 抽象、格式中立)是真的,但它把你引向自家生态的默认实现,也是真的。
一句话总结这篇:范式值得抄,实现要清醒。 护栏设计是工程上的真功夫,绑不绑 Google 是选型时要算的账。两件事,可以同时成立。
收尾
上一篇我们说,OKF 这种东西,是 AI 时代的"造船工具"——让 Agent 这种"机器工人"能读懂你家公司的图纸。
这一篇拆完 reference_agent 的源码,我们想补一句:OKF 这种造船的图纸怎么画、怎么织,也是有门道的。 Google 这套"两趟 pass + 工具焊护栏"的打法,就是其中一张值得收藏的图纸。
不管你最终用不用 OKF、用不用 reference_agent,有两件事现在就能做:
- 去翻一遍源码
—— reference_agent目录就那么几个文件,bundle_tools.py的 augmentation guard 和web_tools.py的 fetch_url,半小时就能读完,是了解"工具即护栏"最现成的教材; - 留意"prompt 约束 vs 工具约束"这道分界线
——下次你给 Agent 造上下文时,问自己一句:这条规则,是写在 prompt 里当建议,还是焊进工具里当保证?
OKF 格式是真好。范式也真好。该清醒的地方,照样清醒。三件事,可以同时是真的。
这是 OKF 系列第二篇。没看过第一篇的,建议先读👉《OKF:Google 新开源的知识格式,专治 AI Agent 答不准》——它讲"OKF 是什么、设计哲学、Google 的策略";这一篇钻进源码看"它怎么把元数据织成图谱"。
下一篇预告:手写一个不绑 Google 的 OKF producer(Python 50 行)——把这篇"批判 google-adk 绑定"的钩子兑现成你自己的工具。
👉 觉得"工具焊护栏"这个点讲透了?点「在看」,让更多造 agent 的人看到。💬 你写 agent 时,约束习惯放 prompt 还是工具里?评论区聊聊。⭐ 收藏这套源码拆解,做 context 工程时翻出来对照。
关注不迷路,一起造船。⚓️
#OKF #AIAgent #AI #开源 #效率工具
夜雨聆风