跳到主要内容

上下文即文件系统:构建与导航

30 秒导读: nao 的核心赌注是——别把整个数据仓库的 schema 一股脑塞进模型的上下文窗口。 它先用 CLI(nao sync)把每张表的列、预览、画像写成磁盘上一堆 .md 文件,按 type=/database=/schema=/table= 的目录层层组织;然后给 agent 一套文件系统工具 (searchgrepreadlist),让它像人翻代码库一样,用到哪张表才去读哪张表。 文件夹的层级结构,天然就是"渐进式披露"。

本章讲清楚这条主线:磁盘上的上下文怎么被构建出来(CLI 侧),长什么样(布局约定), 以及 agent 怎么导航它(工具带 + 沙箱)。至于 agent 的主循环见 02-agent-loop.md,SQL 执行内部见 03-tools.md, 把这些文件装配进系统提示见 04-system-prompt.md


1. 这是什么(零基础也能懂)

先说要解决的痛

假设你在给一支数据团队做一个"用大白话问数据"的 AI agent。用户问:"上个月复购率多少?" agent 得先知道:有哪些表、每张表有哪些列、列是什么类型、哪张表常和哪张表 join…… 这堆信息叫 schema / 元数据(metadata,描述数据的数据)

最粗暴的做法是:把整个仓库所有表的 schema 全拼成一大段文字,每次对话都塞进模型的 上下文窗口(context window,模型一次能"看见"的文本上限)。表一多就爆:几千张表的 DDL 既超长又烧钱,而且模型 90% 的内容根本用不上。

nao 的答案:把上下文当文件系统

nao 换了个思路——把元数据落到磁盘,做成一个"上下文文件夹",让 agent 按需去翻

  • 一句话定义: nao 是一个"分析型 agent"框架;它把数据源的元数据同步成磁盘上的一棵 markdown 文件树,agent 用文件系统工具逐层发现,而不是被一次性灌满 schema。
  • 给谁用: 数据团队搭 agent,业务同学用自然语言问数。
  • 一句话直觉/类比: 把它想成给数据仓库生成一份"代码库"。就像你不会把整个 repo 粘进 聊天框、而是让编码 agent 自己 grep、自己 read 那样——nao 让数据 agent 也这么干。 上下文窗口是内存,磁盘上的上下文文件夹是硬盘。

用起来什么样

三条 CLI 命令就把这套东西立起来(README 的 Quickstart):

nao init # 脚手架:建好 databases/ queries/ docs/ ... 空目录 + RULES.md + .naoignore
nao sync # 把数据源同步进上下文:为每张表写出 columns.md / preview.md / ...
nao chat # 起聊天 UI;agent 用文件系统工具翻这些文件来回答问题

nao sync 跑完,磁盘上会长出这样一棵树(这是 nao 自带 example 项目的真实产物):

databases/
└── type=duckdb/
└── database=jaffle_shop/
└── schema=main/
└── table=customers/
├── columns.md # 列名 + 类型
├── preview.md # 前 N 行样例数据
├── description.md # 表说明
└── profiling.md # 每列的空值率/去重数/分布画像

columns.md 里就是给模型看的、精炼过的一小段:

# customers

**Dataset:** `main`

## Columns (7)

- customer_id (int32)
- first_name (string)
- ...

关键:agent 不需要一开始就看到这些。它先看到"有个 customers 表",要用了再 read 它的 columns.md。这就是本章反复要讲的那句话——文件系统 = 渐进式披露


2. 顶层全景(它大概怎么转)

这套机制横跨两个进程:Python 写的 CLI(负责构建磁盘上的上下文)和 TypeScript 写的 backend agent(负责导航这些文件)。它们不直接对话,只通过磁盘上的文件夹交接—— CLI 写文件,agent 读文件。

怎么读这张图:从左到右是数据流,中间那个文件夹是唯一的交接点

┌─────────── 构建侧 (Python CLI) ───────────┐ ┌───── 导航侧 (TS backend agent) ─────┐
│ │ │ │
数据源 ─▶ ① nao sync ─▶ ② 渲染 Jinja 模板 ─▶ ╎ ╎ ─▶ ⑤ 文件系统工具 ─▶ agent 主循环
(仓库/ (每表每模板一个 .md) ╎ ╎ search/grep/read/list │
Notion/ ▼ ╎ ▲ │
repos) ┌──────────────────────┐ ╎ │
│ ③ 上下文文件夹 │◀───┘ ④ 按需读取 │
│ databases/type=.../ │ (渐进式披露) │
│ queries/ docs/ ... │ │
└──────────────────────┘ │
└───────────────────────────────────────────┘ └─────────────────────────────────┘

自托管时:LocalContextProvider(卷挂载)或 GitContextProvider(拉 git)喂这个文件夹

部件一句话职责

部件干什么在哪(相对 clone 根)
nao init脚手架:建空目录树 + RULES.md + .naoignorecli/nao_core/commands/init.py
nao sync 编排逐个跑 provider,再渲染用户模板cli/nao_core/commands/sync/__init__.py
数据库 provider连库、遍历 schema/表、每表渲染模板落盘cli/nao_core/commands/sync/providers/databases/provider.py
DatabaseContext模板能调用的取数方法(列、预览、画像)cli/nao_core/config/databases/context.py
上下文 provider容器启动时把上下文喂到磁盘(本地卷 / git)cli/nao_core/context/{local,git}.py
文件系统工具agent 的 search/grep/read/listapps/backend/src/agents/tools/
工具 schema上面四个工具的 zod 输入/输出契约apps/shared/src/tools/
沙箱工具函数虚拟路径转换 + 越界拦截 + .naoignore 过滤apps/backend/src/utils/tools.ts
结构说明写进系统提示、告诉 agent 目录约定apps/backend/src/components/ai/nao-context-structure.tsx

主线走一遍(高层)

  1. nao init 立起空的目录骨架。
  2. nao sync 连上你的数据源,把每张表 × 每个模板渲染成一个 .md 文件,按 hive 风格目录落盘。
  3. 部署时,agent 进程能看到这个文件夹(本地就是挂载,云上可以从 git 拉)。
  4. 用户提问,agent 不预读全部,而是先 list/search 看有什么,再 read 具体表的 columns.md
  5. 拿到刚好够用的上下文,再去执行 SQL、画图(下几章)。

3. 核心原理(逐个机制,由浅入深)

3.1 为什么是"文件系统"= 渐进式披露

它要解决的小问题: 上下文窗口装不下整库 schema,而且大部分表和当前问题无关。

思路/直觉: 把"选择性加载"的活儿外包给文件系统本身。目录层级 = 一棵天然的索引树: 先看顶层(有哪些库),再下钻(有哪些 schema、哪些表),最后才读叶子(某张表的列)。 每一步都只花"读一层目录"的成本,而不是"读整库"的成本。这正是 agent 领域说的 progressive disclosure(渐进式披露)——信息按需、分层暴露,而非一次性倾倒

nao 把这条哲学直接写进了给模型的系统提示里(注意它明确说"别直接查库,先搜文件"):

// apps/backend/src/components/ai/system-prompt.tsx:50 附近,SystemPrompt()
You have access to user context defined as files and directories in the project folder.
Databases content is defined as files in the project folder so you can easily search
for information about the database instead of querying the database directly
(it's faster and avoids leaking sensitive information).

NaoContextStructure() 组件(apps/backend/src/components/ai/nao-context-structure.tsx:4) 则把目录命名约定(database= / schema= / table=)讲给模型听,让它知道该往哪翻。 这个组件同时被主系统提示和"上下文推荐"提示复用(见 04-system-prompt.md)。

为什么用文件而不是数据库表来存元数据? 三个附带好处,都写在提示里或代码注释里:

好处原因
读一个 .md 文件比往仓库发 metadata 查询快得多
安全不必为拿元数据就去连生产库,减少敏感信息泄露面
可评审上下文是纯文本文件,能进 git、能在 PR 里 diff(见 05)

3.2 构建侧:nao sync 如何把一张表变成一叠 markdown

它要解决的小问题: 把"活的"数据库元数据,固化成"死的"、可版本化的文本。

思路/直觉: 对每个数据源跑一个 provider(同步提供器);provider 遍历 schema → 表,对每张表套用一组 Jinja 模板(columns.md.j2preview.md.j2 等), 模板通过一个 DatabaseContext 对象回调数据库取真实的列/行/画像,渲染成 markdown 写盘。

顶层编排在 sync()(cli/nao_core/commands/sync/__init__.py:26):它按顺序跑每个 provider, 每个 provider 声明自己的 default_output_dir——数据库 provider 就是字符串 "databases" (providers/databases/provider.py:369),这决定了落盘的顶层文件夹名。

真正干活的是 sync_database()(providers/databases/provider.py:158)。它的路径构造三行, 就是磁盘布局的"事实来源":

# providers/databases/provider.py —— 逐层拼出 hive 风格目录(示意,取自真实源码)
db_path = base_path / f"type={db_config.type}" / db_folder # :187 type=duckdb/database=jaffle_shop
schema_path = db_path / f"schema={schema}" # :240 schema=main
table_path = schema_path / f"table={table}" # :253 table=customers
# ... 对每个模板渲染后:
output_file.write_text(content) # :310 写出 columns.md 等

渲染循环里,每张表会遍历配置启用的模板列表,逐个 engine.render(...) 出内容 (provider.py:284),把 db=ctx(那个 DatabaseContext)、表名、schema 传进模板。

这一层的教学要点:CLI 干的是"把动态查询的结果,快照成静态文本"。快照之后,agent 就再也 不用为"了解一张表"去连库了。

模板 ↔ 上下文对象的分工

模板本身很薄,只管排版;取数逻辑全在 DatabaseContext(config/databases/context.py:13)。 比如 columns.md.j2 就一句 {% set columns = db.columns() %} 然后循环打印:

{# cli/nao_core/templates/defaults/databases/columns.md.j2(节选)#}
{% set columns = db.columns() %}
# {{ table_name }}
## Columns ({{ columns | length }})
{% for col in columns %}
- {{ col.name }} ({{ col.type }}{% if col.description %}, "{{ col.description }}"{% endif %})
{% endfor %}

db.columns()(context.py:77)才是真去问 ibis 后端拿 schema、并缓存结果的地方; db.preview()(context.py:99)拿前 10 行做样例。这种"模板负责样子、context 负责取数"的 切分,让加一种新文档(比如新的 .md.j2)不用碰数据库连接代码。

一个巧妙细节: provider 还会顺手抓查询历史做 join 分析——compute_table_usage() (providers/databases/query_history.py:161)用 sqlglot 解析历史 SQL,统计每张表最常和谁 join、最常见的查询,喂给 how_to_use 模板。于是 agent 不只知道"表有什么列",还知道"这表 一般怎么用"。SQL 解析细节属于 03-tools.md 的范围,这里只点到为止。

3.3 磁盘布局约定:为什么是 key=value 目录

它要解决的小问题: 目录名既要给人看,又要能被机器无歧义地反解回结构。

思路/直觉: 借用大数据里的 hive 风格分区路径(key=value/)。每一段目录都自带"这是什么" 的标签,所以一条路径 type=duckdb/database=jaffle_shop/schema=main/table=customers 本身就是一条 完全限定名(FQDN),不依赖位置约定,拆开即得结构。

导航侧的 readDatabaseObjectsFromDisk()(apps/backend/src/agents/user-rules.ts:75)正是靠这个约定 纯从目录名重建整棵数据库对象树——它逐层 readDirEntries(dir, 'type=')'database=''schema=''table=',拼出 fqdn:

// apps/backend/src/agents/user-rules.ts:75 readDatabaseObjectsFromDisk(示意,取自真实源码)
readDirEntries(databasesPath, 'type=').flatMap(({ name: type, path: typePath }) =>
readDirEntries(typePath, 'database=').flatMap(({ name: database, path: dbPath }) =>
readDirEntries(dbPath, 'schema=').flatMap(({ name: schema, path: schemaPath }) =>
readDirEntries(schemaPath, 'table=').map(({ name: table }) => ({
type, database, schema, table,
fqdn: `${database}.${schema}.${table}`, // 目录名 → 结构,零解析歧义
})),
),
),
)

配套还有 getConnections()(user-rules.ts:27,只列到 database 层)和 getTableColumnsContent(fqdn)(user-rules.ts:101,给一个 FQDN 直接取回它的 columns.md)。 后者被 @ 提及功能用:用户在聊天里打 @customers,前端就能直接把那张表的列贴给模型。

一个性能细节: 目录扫描不是每次都做。getDatabaseObjects()(user-rules.ts:57)带一个 5 分钟 TTL 缓存(DATABASE_OBJECTS_TTL_MS = 5 * 60 * 1000,user-rules.ts:54):

// user-rules.ts:57 getDatabaseObjects(示意)—— 目录树扫描结果缓存 5 分钟
const cached = databaseObjectsCache.get(projectFolder);
if (cached && Date.now() < cached.expiresAt) return cached.objects; // 命中缓存,不再扫盘
const objects = readDatabaseObjectsFromDisk(projectFolder);
databaseObjectsCache.set(projectFolder, { objects, expiresAt: Date.now() + DATABASE_OBJECTS_TTL_MS });

因为上下文文件夹在一次会话里基本不变,重复扫盘纯属浪费——缓存把"列出全部表"这种高频操作变廉价。

3.4 导航侧:四把文件系统工具

它要解决的小问题: 给 agent 一套跟人翻代码库同构的动作,让它自己决定读什么。

nao 给 agent 的四把工具,注册在 apps/backend/src/agents/tools/index.ts:19tools 表里, 每把工具的输入/输出用 zod schema 定在 apps/shared/src/tools/(前后端共享同一份契约):

工具干什么后端实现schema
list列一个目录下有什么(文件/子目录 + 条目数)agents/tools/list.ts:9shared/src/tools/list.ts
search按 glob 找文件路径(如 **/columns.md)agents/tools/search.ts:10shared/src/tools/search.ts
grep用 ripgrep 在文件内容里搜正则agents/tools/grep.ts:50shared/src/tools/grep.ts
read读一个文件的全文 + 行数agents/tools/read.ts:8shared/src/tools/read.ts

这四把是故意做成通用文件系统原语,而不是 getTableSchema(table) 这种领域专用 API。 好处:agent 已经在预训练里见过无数 ls/grep/cat,迁移成本几乎为零;而且上下文文件夹里 不止有数据库——还有 docs/queries/semantics/(见 init.pyFOLDERS,init.py:158)—— 通用工具一套通吃。

grep 值得多看一眼:它 spawn 真正的 ripgrep 二进制(grep.ts 里的 getRipgrepPath()), 用 --json 拿结构化输出,支持 glob 过滤、上下文行、max_results 截断(默认 100)。等于把 "在几千个 markdown 里全文检索"这种重活,交给了业界最快的工具。

一个典型的导航序列(agent 自己编排,示意):

list("/databases") → 看到 type=duckdb/
list("/databases/type=duckdb/...") → 下钻到 schema=main/,看到 table=customers/ table=orders/
search("**/table=customers/columns.md") → 定位到目标文件
read("/databases/.../table=customers/columns.md") → 只把这一张表的列读进上下文

每一步只付一层的代价——这就是渐进式披露落到工具调用上的样子。

3.5 沙箱:虚拟路径 + .naoignore + 越界拦截

它要解决的小问题: agent 能自由翻文件系统,但绝不能翻出项目文件夹,也不该看到某些内部文件。

nao 的沙箱是纯路径层的,三件套都在 apps/backend/src/utils/tools.ts:

(1) 虚拟路径。 对 agent 来说,/ 就是项目根。它看到和给出的都是 /databases/... 这种 虚拟路径,永远不知道真实的绝对路径。两个转换函数负责翻译:

  • toRealPath(virtualPath, projectFolder)(utils/tools.ts:215):虚拟 → 真实,并在此处做安全检查
  • toVirtualPath(realPath, projectFolder)(utils/tools.ts:249):真实 → 虚拟,返回给 agent 前把绝对路径抹掉。

(2) 越界拦截。 toRealPath 解析完路径后,校验它必须落在项目根之内,否则直接抛错——这挡住了 ../../etc/passwd 这类路径穿越:

// utils/tools.ts:215 toRealPath(示意)—— 解析后必须仍在项目根内
const resolvedPath = path.resolve(normalizedFolder, relativePath); // path.resolve 会吃掉 ..
const withinFolder = resolvedPath === normalizedFolder
|| resolvedPath.startsWith(normalizedFolder + path.sep);
if (!withinFolder) throw new Error(`Access denied: path '${virtualPath}' is outside the project folder`);

search/grep 拿到 glob/结果后,还会再用 isWithinProjectFolder()(utils/tools.ts:192)兜一道底; search 更是直接拒绝绝对路径和含 .. 的 pattern(search.ts:18)。双重校验是刻意的: 构造路径时挡一次,拿到结果时再挡一次。

(3) .naoignore 过滤。.gitignore 一样,项目根放一个 .naoignore,列出对 agent 隐藏的 文件/目录。loadNaoignorePatterns()(utils/tools.ts:59)读它,并带一个按 mtime+size 指纹失效 的缓存——文件没变就不重复读。命中忽略规则的路径,list 会跳过、read 会被 toRealPath 拒绝。 另外有一组硬编码的 EXCLUDED_DIRS = ['.meta'](utils/tools.ts:27)永远不可见。

nao init 默认写出的 .naoignore 内容很说明问题(init.py:175):

templates/
*.j2
tests/

即:模板源文件和测试对 agent 隐藏——agent 只该看到模板渲染后的产物(columns.md), 不该看到模板本身(columns.md.j2)或评测用例。渲染前的 .j2 是"给 CLI 的",渲染后的 .md 才是"给 agent 的"——这条边界正好由 .naoignore 划出。

3.6 上下文从哪来:Local vs Git 两种 provider

它要解决的小问题: 部署时,那个上下文文件夹怎么出现在 agent 进程能看到的地方?

抽象基类 ContextProvider(cli/nao_core/context/base.py)定义三个动作:init() / refresh() / is_initialized()get_context_provider() 工厂(cli/nao_core/context/__init__.py:12)按环境变量 NAO_CONTEXT_SOURCE 选实现:

场景provider行为
本地 / 卷挂载(默认)LocalContextProvider(context/local.py:8)上下文已在磁盘;init() 只校验路径和 nao_config.yaml 存在,refresh() 是 no-op
云上 / 无卷挂载GitContextProvider(context/git.py:13)启动时 git clone --depth 1 一个 repo;refresh()git fetch + 硬 reset 拉最新

GitContextProvider 有两处值得记:_clone()(git.py:76)用浅克隆(--depth 1)加速启动; refresh()(git.py:118)先 git diff HEAD..FETCH_HEAD --stat 判断有没有变化,有才 reset, 没变就不动——这样"刷新上下文"变成低成本轮询。私有 repo 的 token 会注入 URL,报错时又被 替换成 *** 避免泄露。

这一节的意义:上下文文件夹是唯一的"部署产物"。你在开发机上 nao sync 生成它,提交进 git, 云上的 agent 容器再把它拉下来。构建和运行彻底解耦。自托管全景见 05-reliability-deploy.md


4. 巧妙之处(可借鉴的技术)

  • 把"选择性加载"外包给文件系统。 不发明一套 schema 检索 DSL,直接用目录层级当索引、用 grep/read 当检索——agent 天生会用。妙在零学习成本 + 一套工具通吃所有类型的上下文 (数据库、docs、queries、semantics)。(agents/tools/index.ts:19)

  • hive 风格 key=value 路径 = 自描述的 FQDN。 一条路径拆开即得 type/database/schema/table, 反解零歧义,还能纯靠目录名重建对象树。(user-rules.ts:75;布局来自 provider.py:187)

  • 模板产物给 agent、模板源码藏起来。 默认 .naoignore*.j2templates/ 挡在 agent 视野外——渲染前是"给 CLI 的",渲染后才是"给 agent 的"。一条 ignore 规则划清两侧。 (init.py:175 + utils/tools.ts:59)

  • 双层路径沙箱。 构造真实路径时 path.resolve 吃掉 .. 并校验前缀(toRealPath,utils/tools.ts:215), 拿到搜索结果时再 isWithinProjectFolder 兜底(utils/tools.ts:192)。虚拟路径顺带把绝对路径对模型隐藏。

  • 两处缓存都按"内容会不会变"来设计。 目录树扫描用 5 分钟 TTL(user-rules.ts:54,会话内基本不变), .naoignore 用 mtime+size 指纹失效(utils/tools.ts:59,变了才重读)。缓存策略贴着数据的变更频率走。

  • git refresh 先 diff 再 reset。 没变化就不动磁盘(git.py:118),把"同步上下文"变成廉价轮询。


5. 边界与局限(诚实)

  • 快照会过时。 .mdnao sync 那一刻的元数据快照;库结构变了得重新 sync,否则 agent 看到 的是旧 schema。刷新靠人跑 sync 或 git provider 定时拉,没有实时订阅数据库变更

  • preview.md 会把真实数据行写进磁盘。 db.preview() 取前 10 行落盘(context.py:99)。 敏感表要靠配置的 exclude_columns(context.py 里的列过滤)或不启用该模板来控制,默认会带样例数据

  • 沙箱是路径层的,不是内核层的。 越界防护全靠 toRealPath/isWithinProjectFolder 的字符串/ path.resolve 校验(utils/tools.ts),不是 OS 级 chroot。符号链接等边界要靠这些函数覆盖到位; 真正的代码执行隔离是另一套沙箱,见 03-tools.md

  • get_context_provider 的 git 逻辑作者自己标了 TODO(context/__init__.py:11 上方注释 # TODO: redo the git retrieval logic),说明这块还在演进。


6. 横向对比

同属 ai-agent-reference 货架的其它数据/编码 agent,多数走 RAG 检索函数式 schema API (getTableSchema(name))来喂元数据。nao 的取舍不同:用文件系统当中间层——先把元数据物化成 文本文件,再让 agent 用通用 search/grep/read/list 导航。代价是快照会过时,收益是上下文可版本化、 可评审、可离线,且工具面对 agent 极其熟悉。这与"编码 agent 让模型自己翻 repo"的范式是同一个思想, 只是把对象从代码库换成了数据库。


7. 代码地图(导航索引)

主题文件路径(相对 clone 根)符号名
上下文 provider 工厂(local/git 选择)cli/nao_core/context/__init__.pyget_context_provider
provider 抽象基类cli/nao_core/context/base.pyContextProvider
本地卷挂载 providercli/nao_core/context/local.pyLocalContextProvider
git 拉取 provider(浅克隆 + diff-then-reset)cli/nao_core/context/git.pyGitContextProvider._clone / .refresh
sync 顶层编排cli/nao_core/commands/sync/__init__.pysync
数据库 provider(遍历表、落盘)cli/nao_core/commands/sync/providers/databases/provider.pysync_database / DatabaseSyncProvider
hive 路径构造cli/nao_core/commands/sync/providers/databases/provider.py:187-253db_path / schema_path / table_path
模板取数上下文对象cli/nao_core/config/databases/context.pyDatabaseContext.columns / .preview
查询历史 → join 分析cli/nao_core/commands/sync/providers/databases/query_history.pycompute_table_usage
列文档模板cli/nao_core/templates/defaults/databases/columns.md.j2(Jinja 模板)
脚手架 + 默认 .naoignorecli/nao_core/commands/init.pycreate_empty_structure
文件系统工具:找文件apps/backend/src/agents/tools/search.ts(default createTool)
文件系统工具:内容检索apps/backend/src/agents/tools/grep.tsgetRipgrepPath / addContextToMatches
文件系统工具:读/列apps/backend/src/agents/tools/{read,list}.ts(default createTool)
工具输入/输出 zod 契约apps/shared/src/tools/{search,grep,read,list}.tsInputSchema / OutputSchema
沙箱:虚拟路径 + 越界拦截apps/backend/src/utils/tools.tstoRealPath / toVirtualPath / isWithinProjectFolder
沙箱:.naoignore 加载 + 缓存apps/backend/src/utils/tools.tsloadNaoignorePatterns / EXCLUDED_DIRS
目录树重建 + 5 分钟缓存apps/backend/src/agents/user-rules.tsreadDatabaseObjectsFromDisk / getDatabaseObjects / getTableColumnsContent
目录约定写进系统提示apps/backend/src/components/ai/nao-context-structure.tsxNaoContextStructure