数据截至 (上游 commit 136cd875b154)
工具带:SQL、图表、故事与沙箱
30 秒导读: 上一章讲的 Agent 主循环 是"引擎";这一章讲引擎驱动的那组手脚——nao 给模型的每一个工具。核心一句话:每个工具都是同一套四段式——先校验入参、再把动作落到真实目标(数据库/图表/文档/沙箱)、再把结果回写进一次运行的共享
ToolContext、最后把结果渲染成模型能读的 Markdown。全章以execute_sql这条最长的链为主线,顺带把其余工具一次讲清。
本章不讲循环怎么把这些工具串起来跑(那是 02-agent-loop.md);只讲每一只手脚自己怎么实现。上下文即文件系统的那套 read/grep/list 在 01-context-filesystem.md 已讲,这里不重复。
1. 这是什么(零基础也能懂)
一句话定义: 工具(tool)就是模型被允许调用的一个函数——它有名字、有参数 schema、有一段真实执行的代码。模型只会"说"要调用哪个工具、传什么参数;真正干 活的是 nao 后端里对应的那段 TypeScript / Python。
给谁用、解决什么问题: 假设一个业务同事在聊天框里问"上个季度北美区营收环比怎么样,画个图"。模型自己不会连数据库、不会画图、更不会跑 Python。它能做的只有:调 execute_sql 拿数、调 display_chart 出图、必要时调 execute_sandboxed_code 跑一段 pandas。工具带就是把"模型的话"翻译成"对真实系统的真实操作"的那一层。
nao 的工具清单(内置那批):
| 工具名 | 干什么 |
|---|---|
execute_sql | 对连上的仓库跑一条(默认只读)SQL,返回行数据 |
read_query_result | 翻页读上一条 execute_sql 的缓存结果,不重新跑 SQL |
display_chart | 把某条查询结果渲染成图表 |
story | 创建/改写一份可交互文档(story),内嵌图表与表格 |
execute_python | 在一个纯 TS 的 Python 解释器里跑一小段计算(无联网) |
execute_sandboxed_code | 在一个真沙箱 VM 里跑 Python / shell(可联网、可装包) |
clarification | 反问用户一个澄清问题,然后停下等回复 |
suggest_follow_ups | 一轮结束时给出用户可能想追问的下一步 |
read / grep / list / search | 读上下文文件系统(见 01 章) |
web_search / web_fetch | 走各大模型厂商自带的联网检索 |
| MCP 工具 | 外部 MCP server 暴露进来的第三方工具 |
一句 话直觉: 把模型想成一个只会说话、动不了手的分析师;工具带就是它的手、它的键盘、它的画笔。而这套手脚最讲究的一件事是:不能让分析师随手把数据库写坏——所以最重的那只手 execute_sql,门口站着一道"只读闸"。
2. 顶层全景(一个工具长什么样)
2.1 所有工具的共同骨架:createTool
nao 的每个内置工具都由一个薄封装 createTool 生成。它做的唯一一件事,是让工具的执行函数能拿到一份类型化的运行上下文 ToolContext——Vercel AI SDK 原生只透传一个 experimental_context,createTool 把它转成有类型的 ToolContext 交给你。
真实实现只有十几行(apps/backend/src/utils/tools.ts:22 createTool):
// 示意,非源码:createTool 的核心
export const createTool = (opts) =>
tool({
...opts,
// AI SDK 把上下文塞在 experimental_context 里,这里转成有类型的 ToolContext
execute: (input, { experimental_context }) =>
opts.execute(input, experimental_context as ToolContext),
});
一个工具定义因此长成固定的四件套:
| 字段 | 作用 |
|---|---|
description | 给模型看的用途说明(常常写得很长,是隐性的"工具级 prompt") |
inputSchema / outputSchema | zod schema,既约束模型出参,也约束返回值 |
execute | 真正干活的函数,拿到 input 和 ToolContext |
toModelOutput | 把执行结果渲染成模型能读的文本(见 2.3) |
2.2 ToolContext:一次运行里所有工具的公用黑板
execute 拿到的第二个参数 ToolContext,是同一次 agent 运行内所有工具共享的可变状态(apps/backend/src/types/tools.ts:16 ToolContext)。它是本章的暗线——工具之间不直接说话,而是通过往这块黑板上写、从这块黑板上读来协作:
| 字段 | 谁写 | 谁读 |
|---|---|---|
queryResults (Map) | execute_sql 把结果按 query_id 塞进去 | display_chart / read_query_result / 沙箱按 id 取回 |
generatedArtifacts.charts | display_chart 推入图表配置 | 循环结束后前端渲染 |
generatedArtifacts.stories | story 推入 story 元信息 | 同上 |
projectFolder | 循环启动时注入 | 所有需要读文件/连库的工具 |
agentSettings | 循环启动时注入 | execute_sql 读写权限开关等 |
azureAccessToken | EE 版 Microsoft 登录注入,OSS 恒为 null | execute_sql 的联邦鉴权 |
queryResults 就是"图表工具从不重新跑 SQL"的秘诀:数据只查一次,后续所有引用都走这个 id → 缓存(apps/backend/src/types/tools.ts:36)。
2.3 toModelOutput:结果不是塞 JSON,而是渲染成 Markdown
一个容易忽略但很关键的设计:工具执行完得到的是结构化对象,但喂回给模型的不是这个对象的 JSON,而是先用一组 JSX 组件渲染成 Markdown。桥梁是 renderToModelOutput(apps/backend/src/components/tool-outputs/index.ts:18):
// 示意,非源码
export function renderToModelOutput(node, fallback) {
const markdown = renderToMarkdown(node); // 把 JSX 组件树渲染成 markdown
return { type: 'text', value: markdown || JSON.stringify(fallback) };
}
于是每个工具的 toModelOutput 都是 renderToModelOutput(SomeOutput({ output }), output) 这种形状——用一个专门的组件(ExecuteSqlOutput、DisplayChartOutput、StoryOutput…)决定"这条工具结果讲给模型听时长什么样"。这一步是控制模型注意力的地方:比如把整表 40 行截断、把"结果可能被 LIMIT 截断了"的告警写进去(见 3.5)。
2.4 工具怎么被注册进循环
getTools 组装最终交给模型的工具集(apps/backend/src/agents/tools/index.ts:54)。它不是简单地全给,而是按设置和平台能力做门控:
getTools(agentSettings, ...)
│
├─ 基础工具:story / execute_sql / read_query_result / grep / list / read / search
├─ + suggest_follow_ups (除非 excludeFollowUps)
├─ + clarification (除非 testMode)
├─ + MCP 工具 (除非 mcpEnabled=false)
├─ + execute_python (仅当 experimental.pythonSandboxing 且平台支持)
├─ + execute_sandboxed_code (仅当 experimental.sandboxes 且平台支持)
└─ + extraTools
注意最后两个是双重门控:既要用户在设置里开开关,又要对应的原生二进制在本机能加载——execute_python / execute_sandboxed_code 若原生绑定加载失败,整个工具在 index.ts 里干脆是 null,...(x && { ... }) 直接不注册它(apps/backend/src/agents/tools/index.ts:110-114)。
3. 主线:execute_sql 的端到端路径
这是整条工具带工程含量最高的一支。难点从来不是"跑一条 SQL",而是"让 一个语言模型对生产仓库跑 SQL,还不能把库写坏、还得支持十几种方言、还得把结果精准回喂"。跟着一次调用从头走到尾。
3.1 全景图:一次 execute_sql 穿过几层
怎么读这张图:从上到下是一次调用的时间顺序;跨过中间那条虚线就从 TypeScript 世界进了 Python 世界。
模型说:execute_sql({ sql_query, database_id? })
│
▼ [TS] apps/backend/src/agents/tools/execute-sql.ts
① 只读闸:isReadOnlySqlQuery(sql) ← 除非管理员开了 dangerous write
② HTTP POST → http://localhost:{FASTAPI_PORT}/execute_sql
──────────────────────────────────────────────── TS │ Python 分界
│
▼ [PY] apps/backend/fastapi/main.py :execute_sql
③ NaoConfig.try_load(项目目录) ← 载入 nao_config.yaml
④ 选库:1 个直接用 / 多个按 database_id 挑
⑤ 看 auth_mode:
azure_entra_id → execute_sql_with_token(sql, token)
其它 → execute_sql(sql)
│
▼ [PY] cli/nao_core/config/databases/<方言>.py
⑥ 13 种方言适配器之一:connect() 建 ibis 连接 → raw_sql(sql)
⑦ 游标 → pandas DataFrame
│
▼ 回到 TS
⑧ df → JSON → context.queryResults.set(id, ...) ← 落到黑板
⑨ detectQueryRowLimit(sql) 算截断告警
⑩ toModelOutput:渲染成 markdown 表 + 告警,喂回模型
3.2 第 ① 步:只读闸(这是 SQL 工具的灵魂)
在把 SQL 发出去之前,TS 侧先做一道纯正则的静态判定:这条语句是不是只读的?若不是,且管理员没显式打开"危险写权限",直接抛错拒绝(apps/backend/src/agents/tools/execute-sql.ts:23):
// 示意,非源码:execute-sql.ts 的开头
const writePermEnabled = context.agentSettings?.sql?.dangerouslyWritePermEnabled ?? false;
if (!writePermEnabled && !(await isReadOnlySqlQuery(sql_query))) {
throw new Error('Write SQL operations are disabled. Only SELECT queries are allowed. ...');
}
判定逻辑在 isReadOnlySqlQuery(apps/backend/src/utils/sql-filter.ts:6),它比"看开头是不是 SELECT"讲究得多:
- 先剥注释、再按分号拆多条语句,要求每一条都只读(
stripComments+splitStatements,sql-filter.ts:124/:131),拆分时还得尊重引号内的分号。 - 每条语句过一个写操作黑名单正则
WRITE_STATEMENT_RE(sql-filter.ts:1):INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|REPLACE|MERGE|GRANT|REVOKE|RENAME|CALL|EXEC|…命中即判写。 SELECT直接放行;WITH(CTE)要特别处理:跳过所有 CTE 定义的括号、看主操作是不是SELECT(getWithMainKeyword,sql-filter.ts:168)——防止WITH x AS (...) DELETE ...这种把写操作藏在 CTE 后面的绕过。
关键细节: 这是一道保守的白名单闸——拿不准就判为"不是只读"从而拒绝(空语句列表也返回
false,sql-filter.ts:9)。它是正则启发式,不是 SQL 解析器;设计上宁可错杀,不放过写操作。
3.3 第 ② 步:TS 通过 HTTP 调 Python
TS 侧不自己连数据库——它把 SQL POST 给同机的 FastAPI 服务(apps/backend/src/agents/tools/execute-sql.ts:54)。请求体带上 SQL、项目目 录、可选的 database_id、环境变量,以及(EE 版才有的)azure_access_token:
// 示意,非源码
const response = await fetch(`http://localhost:${env.FASTAPI_PORT}/execute_sql`, {
method: 'POST',
body: JSON.stringify({
sql: sql_query,
nao_project_folder: naoProjectFolder,
...(database_id && { database_id }),
...(context.azureAccessToken && { azure_access_token: context.azureAccessToken }),
}),
});
为什么要跨语言? 因为数据库连接、方言适配、pandas 全在 Python 的 nao-core CLI 里(那也 是 nao sync 抽取元数据用的同一套代码)。TS 负责 agent 编排,Python 负责数据平面,两边用一个本地 HTTP 端点缝合。
3.4 第 ③–⑤ 步:Python 侧选库 + 按鉴权模式分流
FastAPI 的 execute_sql 处理函数(apps/backend/fastapi/main.py:223)是数据平面的入口:
- 载入配置:
NaoConfig.try_load(project_path, raise_on_error=True, ...)(main.py:234),把项目里的nao_config.yaml读成配置对象。 - 选库(
main.py:247-271):只配了 1 个库就直接用;配了多个则必须靠database_id精确匹配db.name,匹配不到就把可用库名列出来报 400——这也是为什么工具 schema 里database_id在多库时是必填。 - 按
auth_mode分流(main.py:273):
# 示意,非源码
auth_mode_value = getattr(getattr(db_config, "auth_mode", None), "value", None)
if auth_mode_value == "azure_entra_id":
if not request.azure_access_token:
raise HTTPException(400, "azure_access_token is required ...")
df = db_config.execute_sql_with_token(request.sql, request.azure_access_token)
else:
df = db_config.execute_sql(request.sql)
精华设计: 当库配成
azure_entra_id(Azure Entra ID 联邦)时,运行时查询必须带最终用户自己的 access token,绝不用配置里那份 user/password——那份只给nao sync抽元数据用。这样仓库上的每查一次都保留发起人的身份,而不是所有人共用一个服务账号。Redshift 甚至在execute_sql里硬拦:一旦auth_mode=azure_entra_id还走无 token 的路径,直接RuntimeError(cli/nao_core/config/databases/redshift.py:346)。
3.5 第 ⑥–⑦ 步:13 种方言适配器
拿到 db_config 后,真正连库执行的是方言适配器。所有适配器都是抽象基类 DatabaseConfig 的子类(cli/nao_core/config/databases/base.py:92),通过一个判别联合按 type 字段选出正确的类(cli/nao_core/config/databases/__init__.py:25 AnyDatabaseConfig,Discriminator("type"))。
注册在案的方言有 13 种(base.py:18 DatabaseType 枚举):
| 分类 | 方言 |
|---|---|
| 云数仓 | snowflake bigquery redshift databricks fabric |
| 通用 SQL | postgres mysql mssql duckdb |
| 大数据 / 湖仓 | athena trino starrocks clickhouse |
基类给了一个默认 execute_sql,把"连库 → 跑 SQL → 转 DataFrame"这套流程模板化了(base.py:164)。核心是走 ibis 的 raw_sql,再用一串 hasattr 兼容各驱动千奇百怪的游标(base.py:170):
# 示意,非源码:base.py execute_sql 的取数部分
cursor = conn.raw_sql(sql)
if hasattr(cursor, "fetchdf"): return cursor.fetchdf() # duckdb
if hasattr(cursor, "to_dataframe"): return cursor.to_dataframe() # bigquery 系
if hasattr(cursor, "to_pandas"): return cursor.to_pandas()
# ClickHouse: result_rows + column_names;其它:description + fetchall
子类各自只覆写自己独有的部分,这就是"适配器"的精髓——共性上提到基类,差异下沉到子类:
| 子类覆写点 | 例子 |
|---|---|
connect() 怎么建连接 | DuckDB 用 ibis.duckdb.connect(...)(duckdb.py:78);:memory: 库才可写,文件库强制只读 |
execute_sql_with_token() | 只有需要联邦鉴权的库才实现,如 Redshift 用 JWT 走 redshift_connector(redshift.py:360) |
auth_mode 枚举 | Redshift(RedshiftAuthMode,redshift.py:20)、Fabric(FabricAuthMode)各自定义 |
| 方言相关的类型转换 | DuckDB 把复杂类型 CAST(... AS VARCHAR)(duckdb.py:54) |
3.6 第 ⑧–⑩ 步:回到 TS,落黑板 + 渲染
Python 把 DataFrame 转成 { data, row_count, columns, dialect } 回给 TS(main.py:291,df.to_dict(orient="records"))。TS 侧收尾做三件事(apps/backend/src/agents/tools/execute-sql.ts:73):
// 示意,非源码
const data = await response.json();
const id = `query_${crypto.randomUUID().slice(0, 8)}`;
context.queryResults.set(id, { columns: data.columns, data: data.data }); // ⑧ 落黑板
const appliedLimit = detectQueryRowLimit(sql_query); // ⑨ 算 截断
return { _version: '1', ...data, id, ...(appliedLimit !== null && { applied_limit: appliedLimit }) };
⑧ 落黑板:结果按新生成的 query_id 存进 context.queryResults。这个 id 就是后续 display_chart / read_query_result / 沙箱引用同一份数据的钥匙。
⑨ 截断告警:detectQueryRowLimit(apps/backend/src/utils/sql-filter.ts:41)是另一段精巧启发式——它先把字符串字面量和括号内内容都用空格抹掉(maskQuotedStrings + extractTopLevelSql),只在最外层找 LIMIT / FETCH FIRST / TOP,故意忽略子查询和 CTE 里的 limit。目的是判断"这次结果是不是被 SQL 自己的 LIMIT 卡到了上限"。
⑩ 渲染给模型:ExecuteSqlOutput 组件(apps/backend/src/components/tool-outputs/execute-sql.tsx:20)负责把结果讲给模型听,这里藏着两个体贴的细节:
- 行数上限 40:超过就截断,并附一句"还有 N 行,调
read_query_result带 offset 看更多"(execute-sql.tsx:46)。 - LIMIT 命中告警:当
row_count >= applied_limit时,插入一段醒目警告,明确告诉模型"这个行数是 LIMIT 撞出来的,不是总数,别当成 exact count 汇报,要真总数请另跑 COUNT(*)"(execute-sql.tsx:18-42)。这是在主动防止模型把截断结果误读成全量统计。
3.7 配套:read_query_result 翻页
execute_sql 只给模型看 40 行,但数据全缓存着。read_query_result 就是翻页器——不重跑 SQL,而是按 query_id 从缓存切片(apps/backend/src/agents/tools/read-query-result.ts:15) 。
取数走 getQueryResult(apps/backend/src/services/query-result.service.ts:10),它有一层回退:先查本次运行的 queryResults 内存 Map;没有(比如是上一轮对话跑的查询)就去聊天消息历史里捞,捞到再缓存回内存。所以"翻看几轮前的某条查询结果"也能工作。
4. 其余工具:各司其职
4.1 display_chart:把查询结果变成图
图表工具不碰数据库,它只吃一个 query_id 和一份图表配置。execute(apps/backend/src/agents/tools/display-chart.ts:27)做的就两件事:一串入参校验,然后把配置推进黑板。
校验规则很具体(display-chart.ts:15-36):
| 规则 | 原因 |
|---|---|
直角/极坐标图必须有 x_axis_key | bar/line/area/scatter/radar 没有横轴键画不出来 |
| pie 图只能有 1 个 series | 饼图就一维 |
| stacked_bar / stacked_area 至少 2 个 series | 只有一层堆叠没意义,提示模型去 pivot 数据 |
| series 不能为空 | 没有数据列 |
支持的图型在共享 schema 里枚举(apps/shared/src/tools/display-chart.ts:5-19 BUILTIN_CHART_TYPES/ChartTypeEnum:bar/stacked_bar/stacked_bar_100/line/area/stacked_area/stacked_area_100/mixed/pie/donut/kpi_card/scatter/radar,另允许小写命名的自定义图型)。校验通过后 context.generatedArtifacts.charts.push(input)(display-chart.ts:40)——真正的绘制在前端,靠 apps/shared 的 chart-builder.tsx / chart-block.ts 把配置 + queryResults 渲染成组件。
4.2 story:版本化的可交互文档
story 是"报告 / canvas / artifact"工具——一份混排 Markdown 文本和图表的可交互文档。它有三个动作,共同点是每次修改都产生一个新版本(apps/backend/src/agents/tools/story.ts:66,storyQueries.createStoryVersion):
| action | 语义 | 关键实现 |
|---|---|---|
create | 新建一份 story | 先查重(同 chat+slug 已存在则拒绝),入库并挂到用户私有根目录 |
update | 局部搜索替换,出新版本 | code.indexOf(search) 精确定位后拼接替换(story.ts:85);搜不到就报错 |
replace | 整篇覆盖,出新版本 | 直接用新 code 建新版本 |
update 用的是字符串精确 search-replace(story.ts:81-92):找到 search 首次出现的位置,把它换成 replace。这跟编码 agent 的"编辑应用"是同一种思路——比让模 型重发整篇省 token,也更不容易改乱无关部分。搜索串找不到就直接失败并把现有版本原样带回(fail(..., existing)),保证工具结果永远反映最新版(含用户手改)。
文档里的图表/表格靠自定义标签内嵌(见工具 description,story.ts:15-17):
<chart query_id="..." chart_type="..." x_axis_key="..." series='[...]' title="..." />
<table query_id="..." title="..." />
<grid cols="2"> ...两个图并排... </grid>
这些标签由 apps/shared/src/chart-block.ts:37 的 buildStoryChartBlock 生成(注意属性值都过 escapeDoubleQuotedStoryAttr 转义)。前端编辑器实时跑 validateStoryCode(apps/shared/src/story-validation.ts:38,在 story-code-view.tsx:48 调用),用正则逐个检查 <chart>/<table>/<grid>:标签是否自闭合、series JSON 是否合法、<grid> 是否有闭合标签(story-validation.ts:120 起),把作者错误当场标红。
4.3 execute_python vs execute_sandboxed_code:两种"跑代码"
两个都能跑 Python,但沙箱边界完全不同,用途也不同:
| 维度 | execute_python | execute_sandboxed_code |
|---|---|---|
| 引擎 | @pydantic/monty——纯 TS 的 Python 解释器 | @boxlite-ai/boxlite——真 microVM 沙箱 |
| 联网 | ❌ 无 | ✅ networkEnabled: true |
| 装包 | ❌ | ✅ box.installPackages(...) |
| 资源 | 硬上限:30s / 64MB / 递归 500(execute-python.ts:23) | 按 vm_size 选规格,5 分钟 TTL 复用 |
| 文件访问 | 把项目文件读成内存 virtualFS,通过外部函数 read_file 暴露 | 把项目目录、聊天图片、query_id 导出的 CSV 拷进 VM |
| 门控标志 | isPythonAvailable(execute-python.ts:164) | isSandboxAvailable(execute-sandboxed-code.ts:286) |
execute_python(monty) 走的是"暂停/恢复"模型:Python 里调 read_file 这类外部函数时,解释器返回一个 MontySnapshot,TS 侧执行该函数、再 resume 回去(execute-python.ts:68 的 while 循环)。可用的外部函数在 apps/shared/src/tools/execute-python.ts:37 EXTERNAL_FUNCTIONS 里定义(如 read_file)。这套适合无副作用的纯计算。
execute_sandboxed_code(boxlite) 是重装备:维护一个沙箱池 sandboxPool(execute-sandboxed-code.ts:29),按 sandbox_id 复用同一个 VM(带 5 分钟 TTL 续期),这样多次调用能延续状态。它把 data_files 里指定的 query_id 结果转成 CSV 拷进 VM(queryResultToCsv,execute-sandboxed-code.ts:60),Python 就能 pd.read_csv 分析真实查询结果。
踩过的坑(源码注释直说): boxlite v0.3.0 停止一个 box 会腐蚀运行时,导致后续所有 box 创建失败——所以
evictSandbox故意不调box.stop(),靠 GC 回收(execute-sandboxed-code.ts:38)。这是文档级的"别照直觉来"警告。
4.4 循环控制工具:clarification 与 suggest_follow_ups
这两个工具几乎不干活,它们的价值在于"被调用"这个信号本身——用来控制循环节奏(循环怎么响应见 02-agent-loop.md)。
clarification(apps/backend/src/agents/tools/clarification.ts:4):execute只返回 success,toModelOutput硬编码一句"澄清问题已发给用户,停下等回复"。description 里明确约束:一次只问一个问题、可给 2–5 个互斥选项、绝不和suggest_follow_ups同时调。suggest_follow_ups(apps/backend/src/agents/tools/suggest-follow-ups.ts:4):同样是空执行,约定为"一轮里最后调用、只调一次",调用即意味着这一轮收尾。
它们是用工具调用来表达"我要暂停 / 我结束了"这种控制流意图——比让模型用自然语言表达更可被程序捕获。
4.5 上下文诊断工具:record-recommendation 与 propose-context-fix
这一对是给"上下文体检"类专门运行用的(不在默认工具集里,由专门流程注入)。它们靠闭包收集器工作,而不是往数据库直写:
createRecommendationCollector(apps/backend/src/agents/tools/record-recommendation.ts:94)返回record/resolve两个工具,分别把"发现的问题"和"已修复的指纹"推进闭包里的数组,供流程结束后统一处理。createContextFixCollector(apps/backend/src/agents/tools/propose-context-fix.ts:82)返回edit_file/propose_manual_fix。edit_file(:94)把对人写上下文文件的 search-replace 累积成完整的前后内容(供 UI 出 diff、PR builder 逐字写),并拒绝编辑自动生成的文件(databases/**会被nao sync覆盖)——这类只能走propose_manual_fix(:128),产出给用户自己的 编码 LLM 用的现成 prompt。
4.6 web_search:直接借用模型厂商的联网能力
nao 不自己实现搜索引擎,而是转接各家模型自带的检索工具(apps/backend/src/agents/tools/web-search.ts:22 createWebSearchTools):
| provider | web_search | web_fetch |
|---|---|---|
| OpenAI | tools.webSearch({ searchContextSize: 'medium' }) | — |
| Anthropic | tools.webSearch_20250305({ maxUses: 5 }) | tools.webFetch_20250910({ maxUses: 3 }) |
tools.googleSearch({}) | — |
换 provider 时,能不能联网、怎么联网就跟着变——WEB_SEARCH_PROVIDERS(web-search.ts:20)标记了哪些 provider 支持。
4.7 MCP 工具:把第三方工具接进来
除了内置工具,nao 还能挂载外部 MCP(Model Context Protocol)server 暴露的工具。早期版本按 server 名把工具逐个收集、加 servername__toolname 前缀(prefixToolName 仍保留在 apps/backend/src/utils/tools.ts:402);当前版本改为两个元工具:getTools 在配置了 server 时给模型挂 mcp_call 与 mcp_connect(apps/backend/src/agents/tools/index.ts:81-90,来自 ./mcp-call / ./mcp-connect),模型运行期自己指定 server/tool 去调,实际执行落到 mcpService.callTool(apps/backend/src/services/mcp.ts:265)。同时保留了 schema 消毒:
- schema 消毒:
sanitizeTools(utils/tools.ts:360)修正 MCP schema 常见毛病(数组缺items、required: null等),好让它们能被 AI SDK 接受。
反方向,nao 也能把自己的 agent 工具反过来暴露成 MCP 工具(apps/backend/src/mcp/tools/register-mcp-tool.ts:34 registerMcpTool),让别的系统调用 nao。
5. 巧妙之处(可带走的技术)
- 只读闸是"每条语句都得干净"的白名单,而非"看开头"。 剥注释 → 按引号感知拆分号 → 每条过写操作黑名单 → CTE 追主操作,专门堵
WITH ... DELETE这类绕过(apps/backend/src/utils/sql-filter.ts:6)。宁可错杀。 - 截断告警是主动喂给模型的"别误读"提示。
detectQueryRowLimit抹掉子查询只看最外层 LIMIT,再由ExecuteSqlOutput在命中上限时插入"这不是总数"警告(execute-sql.tsx:18-42)——把一个常见的模型幻觉在渲染层就掐掉。 - 数据只查一次,
query_id全程流通。 结果落queryResults黑板后,画图、翻页、沙箱分析全走 id 引用,零重复查询(types/tools.ts:36);跨轮还能从消息历史回捞(query-result.service.ts:10)。 - 方言适配用"基类模板 + 判别联合"。 共性(连库→raw_sql→DataFrame)在
base.py:164,差异(connect、token 鉴权、类型转换)下沉子类,新增一种库只写一个子类 + 进AnyDatabaseConfig(__init__.py:24)。 - 联邦鉴权保留最终用户身份。
azure_entra_id模式运行时强制用用户 token,配置里的密码只给 sync 用,Redshift 甚至在代码里硬拦无 token 的执行(redshift.py:346)。 - 控制流用工具调用来表达。
clarification/suggest_follow_ups几乎空实现,靠"被调用"这一事实驱动循环暂停/收尾——比解析自然语言意图可靠。 - 诚实的坑注释。 boxlite 不
stop()的那段注释(execute-sandboxed-code.ts:38)把上游 bug 直接写进代码,避免后人"好心"加回 stop。
6. 边界与局限
- 只读闸是正则,不是解析器。 它靠关键字黑名单和括号平衡的启发式(
sql-filter.ts),理论上存在被畸形 SQL 绕过的可能;它的立场是保守拒绝,不是形式化证明。真要写库得管理员显式开dangerouslyWritePermEnabled。 execute_python能力受限且看平台。 monty 无联网、无第三方包、有硬资源上限;@pydantic/monty/@boxlite-ai/boxlite原生绑定在某些平台(如 Linux ARM64)加载失败时,对应工具直接不注册(execute-python.ts:16、execute-sandboxed-code.ts:12)。execute_sql_with_token不是每种库都实现。 基类没有默认实现;只有需要联邦鉴权的库(如 Redshift)覆写了它——对未实现的库启用azure_entra_id会失败。azureAccessToken在 OSS 版恒为 null。 联邦鉴权那条路径属于 EE 版 Microsoft 集成(types/tools.ts:29注释直说)。- 截断检测是启发式。
detectQueryRowLimit靠正则,复杂 SQL(奇特的 TOP/FETCH 变体)可能漏检或误检;它只是"告警",不改变实际返回。
7. 横向对比
同 shelf 的编码类 / computer-use / browser agent,都要解决"把模型的话精确落到真实目标"这个共同难题,只是目标不同:
| 目标落到 | 代表 | 对应到 nao |
|---|---|---|
| 代码文件 | 编码 agent | story 的 search-replace、edit_file 的唯一性校验(同一种精确编辑思路) |
| 整台电脑 GUI | computer-use | — |
| 浏览器 / 网页 | browser agents | web_search/web_fetch(但 nao 是转接厂商能力,不自己驱动浏览器) |
| 数据库 / 表格 | data agents | execute_sql + 方言适配器 + 只读闸(nao 的主战场) |
nao 的取舍很鲜明:把数据平面做深(13 种方言、联邦鉴权、只读闸、截断告警),把执行平面要么下沉到沙箱、要么转接给模型厂商。相比通用编码 agent 更专、更重"数据正确性"。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 工具通用封装 | apps/backend/src/utils/tools.ts | createTool |
| 结果渲染成 Markdown | apps/backend/src/components/tool-outputs/index.ts | renderToModelOutput |
| 工具注册 + 门控 | apps/backend/src/agents/tools/index.ts | tools, getTools |
| 共享运行上下文 | apps/backend/src/types/tools.ts | ToolContext, QueryResult, GeneratedArtifacts |
| execute_sql (TS 侧) | apps/backend/src/agents/tools/execute-sql.ts | executeQuery |
| 只读校验 / 截断检测 | apps/backend/src/utils/sql-filter.ts | isReadOnlySqlQuery, detectQueryRowLimit, WRITE_STATEMENT_RE, getWithMainKeyword |
| execute_sql (Python 入口) | apps/backend/fastapi/main.py | execute_sql |
| 方言基类 | cli/nao_core/config/databases/base.py | DatabaseConfig, DatabaseType, execute_sql |
| 方言判别联合 | cli/nao_core/config/databases/__init__.py | AnyDatabaseConfig, parse_database_config |
| 方言子类样例 | cli/nao_core/config/databases/{duckdb,redshift,snowflake}.py | DuckDBConfig.connect, RedshiftConfig.execute_sql_with_token, RedshiftAuthMode |
| SQL 结果渲染 | apps/backend/src/components/tool-outputs/execute-sql.tsx | ExecuteSqlOutput |
| 结果翻页 | apps/backend/src/agents/tools/read-query-result.ts · services/query-result.service.ts | getQueryResult |
| 图表工具 | apps/backend/src/agents/tools/display-chart.ts · apps/shared/src/tools/display-chart.ts | ChartTypeEnum, SeriesConfigSchema |
| 图表构建/内嵌 | apps/shared/src/chart-builder.tsx · apps/shared/src/chart-block.ts | buildStoryChartBlock |
| 故事工具 | apps/backend/src/agents/tools/story.ts | story (create/update/replace) |
| 故事校验 | apps/shared/src/story-validation.ts | validateStoryCode, validateChartBlocks |
| Python 解释器 | apps/backend/src/agents/tools/execute-python.ts · apps/shared/src/tools/execute-python.ts | isPythonAvailable, EXTERNAL_FUNCTIONS |
| 真沙箱 | apps/backend/src/agents/tools/execute-sandboxed-code.ts | isSandboxAvailable, getOrCreateSandbox, sandboxPool |
| 循环控制工具 | apps/backend/src/agents/tools/{clarification,suggest-follow-ups}.ts | clarification, suggestFollowUps |
| 上下文诊断 | apps/backend/src/agents/tools/{record-recommendation,propose-context-fix}.ts | createRecommendationCollector, createContextFixCollector |
| 联网检索 | apps/backend/src/agents/tools/web-search.ts | createWebSearchTools, WEB_SEARCH_PROVIDERS |
| MCP 集成 | apps/backend/src/services/mcp.ts · apps/backend/src/mcp/tools/register-mcp-tool.ts | getMcpTools, prefixToolName, registerMcpTool |