返回文章列表

从数据库客户端到 AI Agent:NovaDB 的第二条命

658·5 分钟阅读
RustTauriAI 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 游戏的数据库,然后建出来",它会:

  1. get_schema 看看当前数据库里有什么
  2. run_sql 执行 CREATE TABLE 语句
  3. 再调 run_sql 创建索引、外键
  4. 再调 get_schema 确认建好了
  5. 最后给你一份完整的设计文档

整个过程你不需要干预,它自己规划、自己执行、自己验证。

五个工具: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_schemaget_table_infoget_database_infoget_server_version 让 Agent 能"看到"数据库的当前状态
  • 行动run_sql 让 Agent 能执行任意 SQL——建表、插入数据、创建索引,无所不能

这五个工具的设计哲学是:给 Agent 最大的自由度,而不是限制它只能做什么。

run_sql 接受任意 SQL 字符串,不是只允许 SELECT。这意味着 Agent 可以 CREATE TABLEALTER TABLEINSERT,甚至 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)

注意几个设计决策:

  1. PK 列用 * 前缀标记,让 LLM 一眼看出主键
  2. FK 用 FK→table.column 标记,让 LLM 理解表间关系
  3. 紧凑格式而不是完整 JSON,节省 token 预算
  4. 每次请求都重新获取,保证 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 用数据库图标
  • 实时计时器:用 useLiveTimer hook,每 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);

分两种情况:

  1. 有外键关系的表:用 Dagre 的分层算法,从左到右排列,自动计算层次
  2. 孤立的表:放在右边的网格里,不会干扰主图的布局

这个设计有个实际考量:游戏数据库经常有一些"配置表"(比如 item_typesskill_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_inventorycharacter_skillscharacter_quests 三个关联表围绕着它,itemsskillsquests 三个独立实体表在另一侧。

从"我想做个 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 不只是回答问题,而是真正地"做事"。