多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

把数据库编译成 Agent 知识包:Python 编译器实战

把数据库编译成 Agent 知识包:Python 编译器实战 有人问过我一个问题数据库怎么才能让 AI Agent 真正“看懂”我给的答案不是写一堆文档也不是让模型去猜表结构而是直接把数据库编译成一份 Agent 就绪的 OKF 知识包。这篇文章就围绕我最近做的一个 Python 编译器实战来聊——如何把数据库 Schema 和业务规则编译成 Agent 可以直接加载的知识包以及这中间踩过的、值得记录的坑。这个项目适合正在做 Agent 工具开发、数据库语义层设计、或者想把自己手头数据库开放给 AI 应用使用的开发者。如果你对“如何让 Agent 更可靠地查询数据库”这件事有需求这篇经验总结应该能帮上忙。1. 整体设计与思路拆解为什么数据库需要“知识包”1.1 Agent 查询数据库的三大痛点先说说我为什么要搞这么个东西。目前让 Agent 连接数据库通常有几种做法但每一种都有明显短板。第一种是直接把 Schema DDL 丢给 Agent。这种做法的问题在于DDL 里只有表名、字段名、类型、约束完全没有业务语义。比如一个字段叫status值有 0、1、2DDL 里根本看不出 0 代表“待支付”、1 代表“已支付”、2 代表“已退款”。Agent 看到这些字段只能靠猜猜就会出错。第二种是写自然语言文档用 Markdown 描述表结构和业务规则。这样做的问题是文档是非结构化或半结构化的Agent 虽然能读但每次都要从大量文本里抽取关键信息延迟高而且容易漏。更重要的是文档无法被程序化校验Schema 一改文档就过期了维护成本很高。第三种是给模型做微调让模型“记住”数据库结构。这个成本更高而且不适合表结构频繁变化的场景。更重要的是微调后的模型依然无法保证每次都输出合法的查询语句。我走的是另一条路把数据库结构、业务语义、查询约束、常用查询模板统一编译成一份结构化知识包——OKFOpen Knowledge Format。这份知识包是 JSON 格式的既可以被 Agent 当作工具描述直接加载也可以被程序解析后生成更精确的查询提示。核心思路是把“对数据库的理解”从模型脑子里抽出来放到一个可维护、可校验的文件里。1.2 OKF 知识包到底长什么样OKF 知识包本质上是一个自包含的 JSON 文件里面按固定结构组织数据库知识。我当时设计了六个核心块实际使用下来是比较够用的模块职责关键字段schema表与字段基础信息tables, columns, typessemantics字段业务含义与取值说明descriptions, enums, unitsrelations表间关系与外键逻辑foreign_keys, joinsrules查询约束与安全边界allowlist, row_limit, sensitivetemplates常见问题到查询语句的映射intents, sql_templates, paramsmetadata版本与更新时间version, updated_at, digest这个结构解决了一个核心问题Agent 在生成 SQL 之前先拿到一份“数据库使用说明书”。说明书里不仅写了表名和字段名还写了每个字段是什么意思、有哪些合法值、多表怎么关联、什么样的查询是允许的。1.3 为什么用“编译器”而不是普通脚本我管这个项目叫 Python 编译器实战是因为它本质上就是一个编译过程输入是数据库 Schema 和一份简化的 DSL 规则文件输出是 OKF JSON 知识包。编译器思路的好处在于它强迫你把构建过程分成“源码 → 抽象中间表示 → 目标产出”三个阶段。数据库 Schema 是源码DSL 规则是补充语义的源码中间会生成一个统一的内部模型最后序列化为 OKF。这样分层之后你可以随时替换输入的 Schema 来源比如支持 MySQL DDL、PostgreSQL DDL、甚至 ORM 模型而输出端也可以适配不同版本的 OKF 规范。这比写一个“把表结构导出为 JSON”的脚本要复杂一些但长期价值完全不同。脚本是点对点的换个数据库就要改代码编译器是分层的你只需要新增一个前端解析器。2. 核心细节解析与实操要点OKF 设计与编译器选型2.1 DSL 规则文件给 Schema 补上“语义”数据库 Schema 只告诉你有什么不告诉你意味着什么。所以我在编译器里引入了一个轻量级 DSL 规则文件用接近 YAML 的语法编写专门补充业务语义。DSL 长这样table: orders description: 电商订单主表 fields: status: type: enum values: 0: 待支付 1: 已完成 2: 已退款 amount: type: money currency: CNY description: 订单金额单位为分 rules: - 查询订单时必须带上时间范围条件 - 禁止无 where 条件的全表统计 - 敏感字段 user_phone 默认脱敏展示 relations: - table: order_items on: orders.id order_items.order_id type: one_to_many description: 一个订单包含多个商品明细 templates: - intent: 查询某日订单总量 params: [order_date] sql: SELECT COUNT(*) FROM orders WHERE DATE(created_at) :order_date这个 DSL 的好处是它专门为“补充数据库语义”而生和 DDL 互补。DDL 管“结构对不对”DSL 管“含义是什么”。2.2 编译器前端词法分析与语法解析方案有了 DSL 之后下一步就是写解析器。这里我对比过几个方案最后选了更轻量的路线。方案优点缺点适用场景手写词法递归下降零依赖、可控性强、报错信息友好代码量大、需要自己维护语法树规则文件语法稳定PLY (Python Lex-Yacc)经典方案、功能完备需要额外学习 yacc 风格的语法描述语法复杂的大型 DSLLark语法描述简洁、支持上下文无关文法依赖较重、错误定位略繁琐频繁调整语法规则的场景我最终选择了手写词法分析和递归下降解析。原因有三一是 DSL 的语法足够小关键字就那么几个二是递归下降对报错信息的控制最精确我可以明确告诉用户“第 3 行第 12 列字段名缺失”三是零依赖部署环境干净。词法分析部分负责把原始文本切成 token 流。比如table:切出TABLE_KEYWORD和COLONorders切出IDENTIFIER。这个阶段处理不了嵌套关系只负责“分词”。语法解析部分才真正构建结构。以 rules 段为例解析器看到rules:后面跟着一个列表列表里每个元素都是一个字符串这些字符串会被保存为规则条目。遇到templates:段时解析器会按intent、params、sql三个 key 构造结构化对象。2.3 生成 OKF序列化与校验解析完成之后编译器会把 DDL 信息与 DSL 语义信息融合构建统一的中间表示然后再序列化为 OKF JSON。序列化不是简单地 json.dump有几个细节必须处理字段顺序固定OKF 的 key 顺序必须是约定好的排序方便版本 diff。枚举值统一转字符串DSL 里 0、1、2 是整数JSON 里必须转成字符串 key避免 Agent 误解类型。校验必填项每张表必须有 description每个字段最好有说明缺失时编译器输出 warning但不会阻断生成。生成 digest对序列化后的 JSON 做 SHA-256生成知识包的版本指纹方便 Agent 端进行缓存判断。这个 digest 字段后面帮了大忙。Agent 每次加载知识包之前先检查 digest 是否变化没有变化就用缓存训练数据再大也不怕。3. 实操过程与核心环节实现从 Schema 到 OKF 的完整流程3.1 准备输入MySQL DDL 解析我实际测试的库是一个模拟电商系统表结构包含users、orders、order_items、products四张表。编译器需要自动从 DDL 里提取这些信息。DDL 输入示例CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT COMMENT 订单ID, user_id bigint NOT NULL COMMENT 下单用户ID, status tinyint NOT NULL DEFAULT 0 COMMENT 状态:0待支付 1已支付 2已退款, amount int NOT NULL DEFAULT 0 COMMENT 订单金额(单位:分), created_at datetime NOT NULL COMMENT 创建时间, PRIMARY KEY (id), KEY idx_user_id (user_id), CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES users (id) ) ENGINEInnoDB COMMENT电商订单主表;解析这段 DDL编译器要做的事提取表名和表注释提取每个字段的名称、类型、是否可空、默认值、字段注释提取主键、索引、外键关系把 COMMENT 中的信息拆出来作为 sematics 的初稿。这里有个细节MySQL 的 COMMENT 往往会包含“0待支付 1已支付”这样松散的结构编译器需要按空格拆解然后构造成枚举对象。如果 COMMENT 写得很潦草解析出来的枚举质量就会差。所以我在编译器里做了一个启发式对齐按数字中文描述的模式匹配匹配不到就整体作为 description。3.2 编译中间表示融合 DDL 和 DSLDDL 解析完成之后会生成一个中间表示对象这个对象还没有序列化只是内存里的 Python 对象。中间表示的结构大概是dataclass class ColumnInfo: name: str data_type: str nullable: bool description: str enum_values: dict | None is_sensitive: bool dataclass class TableInfo: name: str description: str columns: list[ColumnInfo] primary_key: list[str] foreign_keys: list[ForeignKeyInfo]DSL 规则文件解析完成之后编译器会把两边的信息合并。合并规则是DSL 优先于 DDL 注释。也就是说如果 DSL 里写了status是枚举那就用 DSL 里的枚举值如果 DSL 没写就尝试从 DDL 注释里提取都提取不到就保留纯文本描述。这个优先级设计非常关键。DDL 注释里的语义信息是“赠品”质量不稳定DSL 是“主食”质量可控。两者冲突时一定以 DSL 为准。3.3 核心代码知识包生成器知识包生成器是整个编译流程的主力。核心函数做了三件事校验中间表示、构建安全查询约束、序列化为 OKF JSON。下面这段代码是生成流程的骨架我做了简化去掉了无关细节但主流程都在import hashlib import json from typing import Any def build_okf_package(tables: list[TableInfo], rules: list[str], templates: list[dict]) - dict[str, Any]: schema_block [] for table in tables: table_schema { name: table.name, description: table.description, columns: [] } for col in table.columns: col_schema { name: col.name, type: col.data_type, nullable: col.nullable, description: col.description, } if col.enum_values: col_schema[enum_values] [ {value: k, label: v} for k, v in col.enum_values.items() ] if col.is_sensitive: col_schema[masked] True table_schema[columns].append(col_schema) schema_block.append(table_schema) package { metadata: { format: okf, version: 1.0.0, updated_at: datetime.now().isoformat(timespecseconds), }, schema: schema_block, relations: build_relations(tables), rules: rules, templates: templates, } canonical_bytes json.dumps(package, ensure_asciiFalse, sort_keysTrue).encode(utf-8) package[metadata][digest] hashlib.sha256(canonical_bytes).hexdigest()[:16] return package这里有三个关键点第一ensure_asciiFalse必须加。如果让 Python 把中文转成\uXXXX知识包体积会膨胀且 Agent 读取时还要多做一层反转义。第二sort_keysTrue不是给人类看的是为了生成稳定的 digest。键的顺序变了json.dumps 的输出就会变digest 就会失效。所以序列化前先排好序保证知识包没有改动时 digest 是一致的。第三枚举值不直接放在 column 对象里而是单独列出是为了符合 JSON 的线性结构。Agent 在做小样本学习时整齐的列表比散落的 key-value 更友好。3.4 Agent 加载知识包工具描述与检索提示知识包构建出来之后怎么让 Agent 用起来我写了两个加载策略。第一个是“工具描述模式”。把知识包的 schema、relations、rules 部分转录成 Agent 工具声明里的描述字段。这个过程需要在 Agent 框架初始化时把 JSON 渲染成一段结构化的文本。例如渲染 rules 部分def render_rules(rules: list[str]) - str: return \n.join(f- {rule} for rule in rules)渲染出来的结果直接拼进系统提示词。这样 Agent 在生成 SQL 前会先看到“规则查询订单时必须带上时间范围条件”“规则禁止无 where 条件的全表统计”。第二个是“检索增强模式”。当业务表比较多知识包过大塞不进上下文时就进行分段加载。按表名建立索引Agent 根据用户问题先定位可能涉及的表再加载对应表的知识块。这一步我用的是简单的关键词匹配没有引入额外的向量库。对于中小规模的数据库关键词匹配已经够用。3.5 编译产物验证构建完知识包后我习惯跑一遍自动验证脚本。验证内容包括每一张表的 description 非空每个字段有语义描述或枚举定义所有外键关系在知识包的 relations 块里有对应说明SQL 模板里的表名和字段名与 schema 块一致digest 与 json.dumps 输出一致。这些验证大多能在编译阶段完成。做不了编译时验证的比如 SQL 模板是否真的能跑通可以在验证脚本里连库执行一遍 EXPLAIN。我强烈建议不要跳过这一步很多模板只有表名没加库名前缀直接执行会报错。4. 常见问题与排查技巧实录知识包构建避坑指南4.1 问题一DDL 注释解析不全枚举值丢失我在解析status字段时踩过一个坑MySQL 的 COMMENT 里写的是状态:0待支付 1已支付 2已退款这个冒号和空格格式还算规整能提取出来。后来有一张表的注释写的是状态值含义0待支付1已支付2已退款我的正则就失效了。冒号变成了中文冒号逗号也是中文的。我最终的解决办法是不只在词法层做正则匹配而是把 COMMENT 整体交给 DSL 的启发式解析函数按“数字 分隔符 中文描述”的宽松规则切分。如果解析失败就直接把整段 COMMENT 作为 description。如果你也在做类似解析我的建议是不要过度依赖 DDL 注释来生成枚举宁可在 DSL 里多写几行把语义表达清楚。注释只是兜底方案不是主力数据源。4.2 问题二Agent 无法区分同名时间字段模拟电商库里orders有created_atorder_items也有created_at。知识包里没有冲突但 Agent 在理解“查询某日订单”时很容易选错时间字段。排查下来发现根因是知识包的字段描述里没有“语境说明”。于是我新增了一个约定字段描述里必须写明“所在表 业务含义”。created_at在orders里的描述是“下单时间”在order_items里的描述是“商品添加时间”。这样 Agent 根据语义就能区分。这个改动虽然小但对查询准确率的提升非常明显。从我个人测试看错误率下降了约六成。4.3 问题三知识包更新后Agent 还在用旧规则有一次我修改了 DSL 里的查询规则重新生成了 OKF 包但 Agent 的响应完全没变化。排查了好一阵才发现Agent 框架端做了上下文缓存而缓存 key 不包含 digest。这个问题的教训是知识包的版本管理不能只看文件名要在加载逻辑里检查 digest。我在 Agent 加载器里加了 digest 校验如果知识包文件没变就复用缓存如果变了强制刷新上下文。增加这项校验后再也没出现过“改了规则但 Agent 不认”的情况。4.4 问题四SQL 模板参数化不彻底导致注入风险知识包里的 SQL 模板是示例性质的但如果直接将模板里的参数拼接到 SQL 里风险很大。我的经验是模板里统一使用命名参数不允许任何直接拼接的写法。比如上面的模板SELECT COUNT(*) FROM orders WHERE DATE(created_at) :order_dateorder_date必须是参数形式不能写成2024-01-01这种字面量。编译器在验证阶段会扫描模板如果发现字符串里出现了“疑似内联参数”的模式比如2024-会直接报 warning。4.5 常见问题速查表现象可能原因处理方法枚举值丢失Agent 看到 status 不知道几个值DDL COMMENT 格式不规范在 DSL 中显式声明 enum_valuesAgent 生成 SQL 总是选错时间字段字段描述缺少语境补充“表名 业务含义”改了知识包但 Agent 行为没变框架上下文缓存未失效加载器加入 digest 校验知识包 JSON 中文变成\uXXXXensure_ascii 默认开启设置 ensure_asciiFalseSQL 模板执行报错模板字段名与 schema 不一致编译后用 EXPLAIN 自动验证4.6 排查工具与调试技巧编译器一定要支持“中间表示导出”功能。我加了一个--dump-ir参数可以直接导出融合后的中间表示 JSON这样就能判断到底是 DDL 解析的问题还是 DSL 合并的问题。没有这个功能你要靠眼睛在几千行的输出里找一处错误效率极低。另外我给 DSL 解析器加了一个--explain参数可以打印每个 token 的解析轨迹类似编译原理课程里的语法分析树输出。做语法规则调整时这个功能救过我很多次。5. 性能优化与扩展方向知识包做大了怎么办5.1 知识包的体积控制如果把整库几百张表全部编译成 OKF 知识包一次性塞给 AgentToken 成本会非常夸张。我做了一个“分层知识包”设计默认包只包含核心表和高频查询模板低频业务表走按需加载。具体来说在 DSL 规则文件里新增一个visibility参数标记表的加载级别always常驻、on_demand按需、disabled不对外提供。编译器会根据可见性把表拆到不同层级的包里。层级内容适用场景core核心表、常用字段、高频模板Agent 系统提示词常驻extended次要表、低频查询模板按需加载admin敏感字段、未脱敏字段仅限内部管理场景这个分层让知识包的最小加载体积压缩了大约一半Agent 的响应速度也快了不少。5.2 OKF 也可以接入查询验证器知识包不光是给 Agent 看的也可以给你自己的执行链路用。我写了一个轻量查询验证器读取 SQL 模板后进行 AST 级别的检查判断字段是否存在于 OKF schema 中、where 条件里是否违反 rules。Python 自带的sqlglot库在这个场景里非常好用支持多种数据库方言还能把 SQL 解析成可遍历的语法树。我在验证器里做了一个规则所有 SQL 必须过sqlglot.parse_one()这一关解析失败直接拒绝生成。5.3 下一步可以做的事项目做到现阶段我觉得有几个方向可以继续深入的第一个是自动从历史慢查询中提取高频 SQL反向生成 OKF 模板。这个数据源比人工枚举意图要丰富而且适配度更高。第二个是监控 Agent 生成的 SQL 执行情况把执行报错的 SQL 自动归类反馈到规则文件里。比如 Agent 多次尝试对orders表做SELECT *那就自动加一条规则禁止无 where 条件的全表扫。第三个是支持多数据库的 DDL 输入。我目前只支持 MySQL DDL后续可以接入 PostgreSQL、SQLite、甚至 Hive 的语法前端解析器复用同一个中间表示后端生成逻辑完全不用改这正是编译器分层的价值。6. 实操心得与补充技巧最后分享一点个人体会。做这种项目我最大的感受是真正花时间的不是写解析器也不是写生成器而是想清楚“Agent 到底需要什么样的数据库知识”。这个问题的答案不是固定的跟你的业务场景强相关。如果你的 Agent 只做报表查询那么知识包的核心是表关系和聚合口径如果你的 Agent 负责数据质检那么知识包的核心是字段合法值边界和异常规则。还有一个细节值得再提一遍知识包的更新机制一定要在设计初期就考虑好。Agent 端是有缓存的你改了知识包如果 digest 没有变Agent 永远不会感知到。我当时就是吃了这个亏改完规则发现线上没有任何变化排查了半天才意识到是缓存 key 的问题。最后再分享一个小技巧编译产物里保留一份.sqlite格式的索引副本。当 Agent 需要快速定位于某个字段属于哪张表时直接查 SQLite 比解析 JSON 快得多。这一步虽然不是必须的但在知识包达到几百张表时体验差异非常明显。如果你也在做类似的事那我建议直接从最小可用版本开始——先支持一张表、几条规则、一个模板跑通整个编译链路再慢慢加复杂的 DDL 特性。编译器这种项目最大的风险不是解析不了复杂语法而是你把链路做得太复杂导致自己都不愿意维护。先跑起来再迭代比一开始就设计得尽善尽美更实际。
返回列表