从数据库客户端到 AI Agent:NovaDB 的第二条命
上回说到
上一篇聊了 NovaDB 怎么用 Rust + Tauri 把三种数据库驱动编译进二进制,彻底告别手动装驱动。那篇解决的是"连接"的问题——怎么连上数据库、怎么执行 SQL、怎么看结果。
但用了一段时间之后,我发现一个更深层的痛点:
连上数据库之后呢?大部分时间我其实在"想",而不是在"写"。
设计一张表要考虑字段类型、索引策略、外键关系。设计一个完整的游戏数据库系统?那更是几十张表的盘根错节。这个过程 DBeaver 帮不了你,它只是个执行器,不是思考伙伴。
于是 NovaDB 有了第二条命 ——
内置 AI Agent,能跟你对话、能执行 SQL、能自动建库,还能画 ER 图。
Agent 的核心:一个递归函数
很多应用号称"集成了 AI",其实就是在侧边栏放个聊天框,调一下 OpenAI API 完事。NovaDB 的 AI 不是这样的——它是一个真正的 Agent,能自己决定下一步做什么。
关键在 client.rs 里的 stream_chat_completion 函数。这个函数是递归的:
pub async fn stream_chat_completion(
config: &AiConfig,
messages: &mut Vec<ChatMessage>,
app: &AppHandle,
chat_id: &str,
connection_id: &str,
abort_flag: &AbortFlag,
) -> AppResult<()> {
// 1. 发起 SSE 流式请求
// 2. 解析每个 delta:文本内容 → 实时推送到前端
// 3. 遇到 tool_call → 执行工具,拿到结果
// 4. 把工具结果追加到 messages 里
// 5. 递归调用自己,把更新后的对话发回给 LLM
}当 LLM 返回 finish_reason: "tool_calls" 时,不是结束,而是继续。执行完工具,拿到结果,再递归调回自己。LLM 看到工具结果后,可以决定继续调工具,也可以决定给用户一个总结。
这意味着一次用户输入可能触发多轮工具调用,LLM 自己决定什么时候停。
这就是 Agent 和"聊天机器人"的本质区别。你告诉它"帮我设计一个 RPG 游戏的数据库,然后建出来",它会:
- 调
get_schema看看当前数据库里有什么 - 调
run_sql执行CREATE TABLE语句 - 再调
run_sql创建索引、外键 - 再调
get_schema确认建好了 - 最后给你一份完整的设计文档
整个过程你不需要干预,它自己规划、自己执行、自己验证。
五个工具:Agent 的手和眼
Agent 不能光靠"想",得有工具。NovaDB 给 AI 暴露了五个工具:
// tools.rs — 工具定义
pub fn tool_definitions() -> Vec<ToolDefinition> {
vec![
// 执行任意 SQL
tool("run_sql", "Execute SQL query", vec![("sql", "string", true)]),
// 获取完整 schema 树
tool("get_schema", "Load full database schema", vec![]),
// 获取单张表的详细信息
tool("get_table_info", "Get table details", vec![("qualified_name", "string", true)]),
// 获取数据库级别的信息
tool("get_database_info", "Get database info", vec![("database_name", "string", true)]),
// 获取数据库版本
tool("get_server_version", "Get server version", vec![]),
]
}工具不多,但覆盖了 Agent 需要的两个核心能力:
- 感知:
get_schema、get_table_info、get_database_info、get_server_version让 Agent 能"看到"数据库的当前状态 - 行动:
run_sql让 Agent 能执行任意 SQL——建表、插入数据、创建索引,无所不能
这五个工具的设计哲学是:给 Agent 最大的自由度,而不是限制它只能做什么。
run_sql 接受任意 SQL 字符串,不是只允许 SELECT。这意味着 Agent 可以 CREATE TABLE、ALTER TABLE、INSERT,甚至 DROP。当然,system prompt 里明确要求了"破坏性操作要先问用户确认"。
System Prompt 的秘密:注入活的 Schema
这是整个设计里我最得意的部分。
每次用户发消息,service.rs 里的 build_system_prompt 会动态组装 system prompt:
fn build_system_prompt(config: &AiConfig, profile: &ConnectionProfile,
version: &str, nodes: &[SchemaNode]) -> String {
let mut prompt = config.system_prompt.clone();
// 注入当前连接信息
prompt.push_str(&format!(
"\n\nCurrent connection: {} ({}), Database: {}",
profile.name, profile.db_type, profile.database
));
// 注入服务器版本
prompt.push_str(&format!("\nServer version: {}", version));
// 注入 compact schema 摘要
prompt.push_str("\n\nCurrent database schema:\n");
prompt.push_str(&build_schema_summary(nodes, 0));
prompt
}build_schema_summary 把整个 schema 树递归格式化成一种紧凑的文本表示:
TABLE public.users (int8 PK, varchar, varchar, timestamptz)
TABLE public.orders (int8 PK, int8 FK→users.id, numeric, timestamptz)
TABLE public.order_items (int8 PK, int8 FK→orders.id, int8 FK→products.id, int4, numeric)
VIEW public.user_order_summary (int8, varchar, int8, numeric)
注意几个设计决策:
- PK 列用
*前缀标记,让 LLM 一眼看出主键 - FK 用
FK→table.column标记,让 LLM 理解表间关系 - 紧凑格式而不是完整 JSON,节省 token 预算
- 每次请求都重新获取,保证 Agent 看到的是最新的 schema
这等于给 Agent 装了一双"眼睛",它不需要调
get_schema就已经知道当前数据库长什么样。
所以当你问"帮我看看 users 表有哪些列",Agent 不需要先调工具,直接从 system prompt 里就能回答。只有在需要执行操作(建表、查询数据)的时候才调工具。
前端:让工具调用"看得见"
Agent 调工具的过程不是黑盒。前端通过 Tauri 的事件系统,实时展示每一步:
| 事件 | 触发时机 | 前端表现 |
|---|---|---|
ai:tool-start |
工具开始执行 | 显示工具卡片,加载动画 |
ai:tool-arguments |
参数流式到达 | 实时显示参数内容 |
ai:tool-result |
工具执行完成 | 显示结果,计时器停止 |
ToolCallDisplay.tsx 里的每个工具卡片都有:
- 工具专属图标:
run_sql用播放图标,get_schema用数据库图标 - 实时计时器:用
useLiveTimerhook,每 100ms 更新一次 - 可展开详情:点开看参数和返回结果的 JSON
function useLiveTimer(startedAt?: number, stoppedAt?: number): string {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!startedAt || stoppedAt) return;
const id = setInterval(() => {
setElapsed(Date.now() - startedAt);
}, 100);
return () => clearInterval(id);
}, [startedAt, stoppedAt]);
// 格式化为 "Xms" / "X.Xs" / "Xm Ys"
}这个小细节很重要。用户看到 Agent 在调工具、在等结果、在思考,比看到一个转圈圈的 loading 动画安心多了。
ER 图:Agent 的"画板"
Agent 执行完建表操作后,用户可能想看看最终的表结构长什么样。NovaDB 的 ER 图功能就是为此设计的。
ErDiagram.tsx 用 React Flow 做渲染,Dagre 做自动布局。但有意思的是它的布局策略:
// 有外键关系的表 → Dagre 分层布局
const connectedLayout = new dagre.graphlib.Graph();
connectedLayout.setDefaultEdgeLabel(() => ({}));
connectedLayout.setGraph({ rankdir: "LR", nodesep: 80, ranksep: 160 });
// 没有外键关系的表 → 自适应网格
const disconnectedCols = Math.min(Math.ceil(Math.sqrt(disconnected.length)), 5);分两种情况:
- 有外键关系的表:用 Dagre 的分层算法,从左到右排列,自动计算层次
- 孤立的表:放在右边的网格里,不会干扰主图的布局
这个设计有个实际考量:游戏数据库经常有一些"配置表"(比如 item_types、skill_levels)不跟核心业务表关联,但也是系统的一部分。把它们单独放在右边,图不会乱。
每个表节点是一个自定义组件,展示表名、列名、数据类型,主键列还有个钥匙图标。外键边用贝塞尔曲线连接,标签显示关联的列名。
ER 图不只是"好看",它是 Agent 设计结果的可视化验证。 用户看到图,一眼就能判断表关系对不对。
实战:三分钟建一个 RPG 游戏数据库
说了这么多架构,来个实际的例子。
在 NovaDB 的 AI 聊天框里输入:
帮我设计一个 RPG 游戏的数据库系统,需要支持:玩家角色、背包物品、技能系统、任务系统。
Agent 的执行过程(你可以从工具调用卡片里实时看到):
第一步:获取当前 schema
Agent 调用 get_schema,发现是空数据库,确认可以从零开始。
第二步:设计并建表
Agent 会依次调用 run_sql 执行一系列 DDL:
-- 角色表
CREATE TABLE characters (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
class VARCHAR(20) NOT NULL,
level INTEGER DEFAULT 1,
experience BIGINT DEFAULT 0,
hp INTEGER NOT NULL,
mp INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 物品表
CREATE TABLE items (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(30) NOT NULL,
rarity VARCHAR(20) DEFAULT 'common',
description TEXT,
stats JSONB
);
-- 背包(多对多关系)
CREATE TABLE character_inventory (
id BIGSERIAL PRIMARY KEY,
character_id BIGINT REFERENCES characters(id) ON DELETE CASCADE,
item_id BIGINT REFERENCES items(id),
quantity INTEGER DEFAULT 1,
equipped BOOLEAN DEFAULT FALSE,
acquired_at TIMESTAMPTZ DEFAULT NOW()
);
-- 技能表
CREATE TABLE skills (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL,
mana_cost INTEGER DEFAULT 0,
cooldown_seconds REAL DEFAULT 0,
description TEXT
);
-- 角色技能(多对多)
CREATE TABLE character_skills (
character_id BIGINT REFERENCES characters(id) ON DELETE CASCADE,
skill_id BIGINT REFERENCES skills(id),
level INTEGER DEFAULT 1,
PRIMARY KEY (character_id, skill_id)
);
-- 任务表
CREATE TABLE quests (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
required_level INTEGER DEFAULT 1,
reward_xp BIGINT DEFAULT 0,
reward_items JSONB
);
-- 角色任务进度
CREATE TABLE character_quests (
character_id BIGINT REFERENCES characters(id) ON DELETE CASCADE,
quest_id BIGINT REFERENCES quests(id),
status VARCHAR(20) DEFAULT 'active',
progress JSONB DEFAULT '{}',
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
PRIMARY KEY (character_id, quest_id)
);
-- 索引
CREATE INDEX idx_inventory_character ON character_inventory(character_id);
CREATE INDEX idx_inventory_item ON character_inventory(item_id);
CREATE INDEX idx_char_skills_character ON character_skills(character_id);
CREATE INDEX idx_char_quests_character ON character_quests(character_id);
CREATE INDEX idx_char_quests_status ON character_quests(status);第三步:验证
Agent 调用 get_schema 确认所有表都建好了,然后给你一份设计文档,解释每张表的设计意图、索引策略、以及后续可以扩展的方向(比如公会系统、装备强化系统怎么加)。
第四步:你去看 ER 图
点击 ER 图按钮,27张表的关系一目了然:characters 在中心,character_inventory、character_skills、character_quests 三个关联表围绕着它,items、skills、quests 三个独立实体表在另一侧。
从"我想做个 RPG 游戏数据库"到"库建好了、图也画好了",三分钟。 不是三分钟写 SQL,是三分钟从零到有。
工具调用的上下文窗口保卫战
Agent 循环调工具有个隐患:如果 run_sql 返回了 10 万行数据,全塞进 messages 里,上下文窗口直接爆掉。
我的做法是截断:
// tools.rs
let result = query_service::run_query(app, connection_id, sql).await?;
// 截断到 100 行
let truncated = result.rows.len() > 100;
let rows = if truncated {
result.rows[..100].to_vec()
} else {
result.rows
};
let response = ToolResponse {
columns: result.columns,
rows,
row_count: result.row_count,
truncated,
note: if truncated {
Some("Results truncated to 100 rows".to_string())
} else {
None
},
// ...
};100 行是经验值——足够让 Agent 理解数据结构和内容,又不会撑爆上下文。如果 Agent 需要看更多数据,它可以自己写 WHERE 条件缩小范围。
这不是限制 Agent 的能力,是教它像一个有经验的开发者一样工作——先看样本,再做判断。
Mermaid 图:Agent 的另一种表达
除了 ER 图,Agent 还能画 Mermaid 图。默认 system prompt 里详细定义了 8 种图表类型的支持:流程图、时序图、类图、ER 图、状态图、甘特图、饼图、思维导图。
前端用 MermaidDiagram.tsx 渲染,支持缩放和工具栏控制。这个能力让 Agent 不只是"执行 SQL 的工具",而是一个能用多种方式跟你沟通的设计伙伴。
比如你问"玩家从接任务到完成任务的流程是什么",Agent 会画一个时序图;你问"物品系统的类关系",它会画一个类图。
对话的持久化:每个连接一个工作区
上一篇聊了 Tab 状态怎么按连接隔离存储在 IndexedDB 里。AI 对话也是一样的设计:
// 每个连接维护独立的对话列表
aiConversations: Record<string, ChatConversation[]>
// 切换连接时,对话跟着切换你在 PostgreSQL 连接上跟 Agent 讨论 RPG 数据库设计,切到 MySQL 连接上看另一个项目的 schema,再切回来,之前的对话还在。甚至 Agent 执行过的 SQL 历史也还在——因为 AI 调 run_sql 和你在编辑器里手写 SQL,走的是同一条执行路径,共享同一份历史记录。
Agent 不是外挂,它是 NovaDB 的原生能力。
Provider 无关:你用什么模型都行
AI 客户端的实现完全基于 OpenAI 兼容协议。base_url 配什么就是什么:
- OpenAI:
https://api.openai.com/v1 - 本地 Ollama:
http://localhost:11434/v1 - LM Studio:
http://localhost:1234/v1 - 任何兼容服务
interface AiConfig {
apiKey: string;
baseUrl: string; // 你想用什么服务就填什么
model: string; // 模型名
maxTokens: number; // 最大输出 token
systemPrompt: string; // 可自定义的 system prompt
}没有绑定任何一家 AI 厂商。 换模型只改一个配置,Agent 的工具调用能力不受影响。
这也是为什么我选了 OpenAI 兼容协议而不是直接对接各家 SDK——协议是稳定的,SDK 是会变的。
结尾:从工具到伙伴
回头看 NovaDB 的两篇文章,其实讲的是一个东西的两面:
- 上篇:怎么做"工具"——连接数据库、执行 SQL、管理密码。这是基础设施。
- 这篇:怎么做"伙伴"——理解意图、规划步骤、自动执行、可视化结果。这是 AI 能力。
两层叠在一起,才是完整的 NovaDB。
说实话,写 Agent 的代码量比写数据库客户端本身少多了。递归调用、工具定义、system prompt 注入——核心逻辑加起来不到 500 行。但设计决策很多:工具粒度怎么定、上下文怎么管理、结果怎么截断、前端怎么展示工具调用过程……
代码量不等于复杂度。500 行 Agent 代码背后的设计思考,比 5000 行 CRUD 代码多多了。
如果你也想给自己的桌面应用加 AI Agent 能力,NovaDB 的架构可以给你一个参考:递归 Agent 循环 + 工具注册表 + 动态 system prompt + 事件驱动前端。这四个组件组合起来,就能让 LLM 不只是回答问题,而是真正地"做事"。