如果你写过一个月以上的AI原生应用,大概率经历过这种崩溃瞬间:一开始用一个两百字的系统Prompt加几个函数定义,效果惊艳,Demo轻松过关;等到第二个、第三个业务域接入后,输出开始飘了,字段时多时少,枚举值凭空多出几种,明明在Prompt里写了“必须严格按照JSON Schema输出”,模型还是会往优先级字段里塞一个不在枚举范围内的字符串。我在主导一个客服Agent项目时被这个问题反复折磨,也正是在某个改Prompt改到怀疑人生的深夜,我下决心自己写一套“本体驱动的AI原生应用开发”脚手架,这就是个人开源项目ontology-driven-dev的由来。
这个项目不是某个大厂的框架,也不是复杂的平台,它做的事很简单:把“业务本体”当作唯一事实源,自动生成AI原生应用开发中需要的类型定义、JSON Schema、Prompt语义片段、运行时校验器以及检索查询模板。如果你也正在做LLM应用,厌倦了“Prompt缝缝补补又三年”,或者想给RAG、Agent工具调用、结构化输出建立一个长期稳定、可演进的语义基座,那么这篇文章值得你花十分钟读完。我会完整拆解这个项目的设计动机、目录结构、跑通流程以及我踩过的坑,基本等同于“开源项目解读+实操说明”。
1. 我为什么觉得Prompt工程治标不治本
1.1 从“Prompt又能用了”到“Prompt又崩了”
所有做LLM应用的人都会经历一个循环:刚上线时模型乖乖听话,样本回放测试通过率95%以上;两个星期后,业务方提了新需求,你往Prompt里追加了几条指令和一个新字段;再跑回归,发现老场景的字段格式也开始变了。刚开始我以为是上下文太长导致注意力分散,于是优化指令顺序、减少冗余示例,短暂回血后,下一次变更又循环爆炸。
为什么会这样?因为Prompt本质上是“用自然语言写给模型的建议书”,它没有数据类型、没有约束优先级、没有关系完整性。模型对“字段status的合法值是open/triaged/in_progress”这句话的理解,和你对这句话的理解,以及业务方对这句话的理解,三者之间存在微妙的差异。你可以在Prompt里写一百遍“不要输出closed以外的值”,但只要上下文中出现过一次“settled”或者“finished”,模型就可能觉得“这也是一种合法的状态”。这不是模型笨,而是人在用文本表达语义约束时,注定会丢失精确性。
1.2 结构约束不等于语义约束
后来我把生成逻辑改成“必须返回JSON Schema校验通过的结果”,通过率确实上来了,但我很快发现另一个问题:JSON Schema只能约束“结构”——字段是否存在、类型是否为字符串、数组最大长度是多少——它完全管不了“语义”。
举个例子,工单系统里“一个已关闭的工单必须存在指派人”这条规则,JSON Schema表达不了。你只能额外写一堆校验函数。而比这更隐蔽的是关系方向问题:模型知道输出里有个字段叫“requester”,但它不理解“requester”和“assignee”之间的区别;如果业务方要求“紧急工单不能指派给离线坐席”,你不得不在Prompt里反复解释什么叫“紧急”、什么叫“离线”、什么叫“指派”。这类领域语义规则多了之后,Prompt逐步膨胀成一篇小论文,可维护性约等于零。
我开始意识到:AI原生应用缺的不是一个更好的Prompt模板,而是一张“领域地图”。这张地图里说明白:有哪些核心概念、概念之间什么关系、状态如何流转、哪些值一定不能越界。这张地图,就是本体(Ontology)。本体驱动的开发,就是让AI应用不再依赖“临时写的提示词”,而是依赖一份经过设计的、机器可读的领域规范。
2. ontology-driven-dev 的核心理念:让AI建立在领域事实上
2.1 “本体驱动”和其他方案的边界在哪里
很多人听到“本体”会先想到知识图谱,或者学术圈那些复杂描述逻辑。我一开始也担心这东西太重,直到我把本体理解为“数据库Schema的语义升级版”之后才想通。
- Prompt工程关注“怎么说”,核心产品是文本指令;
- Schema驱动关注“长什么样”,核心产品是数据结构定义;
- 本体驱动关注“领域里有什么、什么含义、如何流转”,核心产品是概念模型和关系规则。
如果把AI原生应用比作一个导航App,Prompt是导航员的播音话术,Schema是地图的图层格式,本体则是地图的图例和路标系统——图例定义好,路标不乱,导航员怎么说都不至于跑偏。RAG如果接入了本体,召回关键词能被映射到标准概念;Agent如果接入了本体,工具参数能被校验和自动补全;结构化输出如果接入了本体,模型输出就有了完整边界。这三件事是AI原生应用最常见的三个高危区,本体驱动恰好都能覆盖。
2.2 一条命令背后的编译闭环
ontology-driven-dev 的基础工作流可以概括成四步:定义、编译、生成、校验。
- 定义:用一份YAML或JSON描述文件,声明领域内的类(Class)、属性(Attribute)、关系(Relation)和业务规则(Rule);
- 编译:命令行工具读取这份描述,解析成中间表示(IR),并做语法和语义检查;
- 生成:针对不同消费端输出产物,包括TypeScript类型、JSON Schema、Prompt语义片段、SPARQL查询模板、运行时校验器;
- 校验:在LLM输出阶段,把模型吐出的内容重新扔回校验器,一旦违反本体定义的规则,立刻指出是哪个属性、哪个关系、哪个状态流转出了问题。
整个闭环里,你唯一需要手工维护的是本体文件。业务术语调整、枚举值增减、关系变更,都先改本体,再重新跑一遍生成命令。这就像在传统Web开发里先定义数据库Migration再生成ORM模型,而不是每个人各自写一份字段映射。对我个人项目来说,最大的受益点不再只是“准确率”,而是“改起来可控”:改Prompt不会改坏老场景,因为控制器不在Prompt里。
项目仓库目前的结构也保持了这个思路,我刻意让模块之间单向依赖,编译方向从本体出发,避免循环引用。
ontology-driven-dev/ ├─ cli/ # 命令行入口 ├─ packages/ │ ├─ parser/ # 本体描述解析,语法检查 │ ├─ model/ # 本体中间表示(IR) │ ├─ codegen/ # 多目标代码生成器 │ │ ├─ typescript/ # 生成TS类型和类型守卫 │ │ ├─ json-schema/ # 生成JSON Schema │ │ ├─ prompt-builder/ # 生成Prompt语义约束片段 │ │ └─ sparql/ # 生成知识图谱查询模板 │ ├─ validator/ # 运行时校验器,支持CLI和SDK两种用法 │ └─ devtools/ # 本地可视化与调试面板 └─ examples/ # 完整示例项目3. 仓库里五层模块到底在做什么
3.1 项目目录总览与每个包的设计意图
我用表格整理一下各模块的定位,这样你扫一眼就能判断自己对哪块感兴趣。
| 模块 | 产出 | 典型消费方 | 我为什么这么设计 |
|---|---|---|---|
| parser | 解析后的语义错误报告 | 开发者在CI中运行 | 用一份文件降低建模错误反馈周期 |
| model | 标准化的本体IR | codegen、validator | 让产物生成不依赖具体描述格式 |
| codegen/typescript | TS类型、类型别名、类型守卫 | 前端、后端服务 | 让业务代码在编译期就拿到领域类型 |
| codegen/json-schema | JSON Schema | LLM结构化输出、API参数校验 | 用标准化工具表达结构约束 |
| codegen/prompt-builder | 可直接拼进Prompt的语义约束文本 | AIAgent、工作流编排 | 让Prompt里的领域描述与本体自动同步 |
| codegen/sparql | SPARQL查询模板 | 知识图谱/RAG检索层 | 让自然语言查询能映射到标准概念 |
| validator | 校验函数、错误码、恢复建议 | 后端服务运行时 | 拦下LLM的“创造性输出” |
| devtools | 图形化本体查看器 | 建模讨论会 | 让不懂代码的业务同事也能参与评审 |
“生成Prompt语义约束”这一块是很多只在用Schema驱动方案的人最容易忽略的。代码生成不再只面向程序员,还面向LLM。比如,同样是“工单状态”,生成器会从本体里提取合法枚举、状态流转图、每一条流转的业务解释,把它们组织成一小段Prompt片段。这样你真正运行时的系统Prompt会变成“基础角色定义 + 按业务域组合的语义片段”,而不是一坨写死的Monolith Prompt。
3.2 一条命令如何串起所有产物
安装并初始化之后,最核心的体验是命令极简。下面是我常用的几条:
# 创建一个新项目的本体目录结构 ont-dev init service_desk # 根据本体文件生成全部代码 ont-dev build # 只生成JSON Schema,适合在LLM结构化输出场景用 ont-dev build --target json-schema # 把生成产物和本体做一致性检查,用于CI ont-dev check --frozenont-dev build会读取ontology/目录下的描述文件,然后并行调用各个codegen,最终写进generated/。我特意让所有产物都带一个“本文件由本体生成,请勿手工修改”的文件头,并且在CI里加了--frozen检查;谁要是手工改了生成物,或者改了本体没有重新build,流水线会直接红掉。
这种设计后来被证明非常值得。最开始我也是图省事,遇到一个字段不对就直接在生成物里改,结果三次过后本体和代码彻底分家,我甚至分不清哪个是真正的事实源。加--frozen之后,团队里所有人都养成了“先改本体,再跑build”的习惯,类似数据库迁移也会强制记录一样。
4. 用一个客服工单系统跑通“本体到AI应用”全流程
4.1 第一步:把客服工单的关键语义写成YAML
与其抽象地讲规则,不如直接演示。我挑了一个最常见也最容易失控的场景:客服工单分类、优先级和状态流转。下面是service_desk本体文件的精简版本,保留了最核心的约束。
ontology: name: service_desk classes: Ticket: attributes: ticket_no: type: string pattern: "^INC-[0-9]{6}$" description: "工单编号,全局唯一" summary: type: string max_length: 200 category: type: enum enum: - payment - network - account - hardware priority: type: enum enum: - P0 - P1 - P2 - P3 status: type: enum enum: - open - triaged - in_progress - waiting_customer - resolved - closed relations: requester: target: Customer cardinality: one assignee: target: Agent cardinality: zero_one rules: - name: closed_ticket_requires_assignee expr: "status == closed && assignee.empty" message: "已关闭工单必须存在指派人" - name: p0_ticket_cannot_be_waiting_customer expr: "priority == P0 && status == waiting_customer" message: "P0工单不允许等待客户侧回复" Customer: attributes: customer_id: type: string tier: type: enum enum: [normal, vip, key_account] Agent: attributes: agent_id: type: string online: type: boolean rules: - name: high_priority_not_assigned_to_offline_agent expr: "Ticket.priority in [P0, P1] && online == false" message: "紧急工单不能指派给离线坐席"这短短几十行里,已经表达了“工单是一个有编号、有分类、有优先级、有状态流转的对象”“它必须关联一个客户”“可能指派给一个坐席”“关闭前必须有人负责”“P0等级不能进入等待客户状态”。这些在传统Schema里很难表达的东西,现在都成了机器可读的事实。
定义完把文件放到ontology/service_desk.yaml,执行:
ont-dev build生成物会自动出现在generated/下面,包括service_desk.ts、service_desk.schema.json、prompt/service_desk.prompt.md、query/service_desk.sparql。
4.2 第二步:让生成物进入LLM工作流
我实际接入时最常用的组合是“JSON Schema约束输出 + 运行时校验器兜底”。下面是生成后的TypeScript类型和校验器用法,代码结构我做了一点简化,但足以说明问题。
import { z } from 'zod'; import { TicketSchema, validateTicket, ServiceDeskError } from './generated/service_desk'; const llmOutput = await chatCompletion({ messages: [ { role: 'system', content: loadPrompt('customer_triage') }, // ... ], response_format: { type: 'json_schema', schema: TicketSchema } }); try { const ticket = validateTicket(JSON.parse(llmOutput)); await saveTicket(ticket); } catch (err) { if (err instanceof ServiceDeskError) { // 错误已经包含具体字段,例如:Field [priority] receives [urgent], expected one of [P0, P1, P2, P3] await repairWithHumanOrLLM(err); } }这套组合在我的实际运行中,把“字段级结构错误”发生率降到很低。以前靠Prompt提示词约束,模型偶尔会输出"status": "resolved "这种带空格的值;现在JSON Schema先拦一遍,validator再拦一遍,即便漏网,错误信息也能明确告诉下游是哪个字段出了问题。
需要特别说明的是,我不是只依赖本体来“囚禁”模型。本体的价值更多在于给模型一张清晰的“地图”,让它知道什么内容该进哪个槽位。生成出的Prompt语义片段类似下面这样:
领域术语约束(来自本体 service_desk): - 工单编号格式:^INC-[0-9]{6}$ - category 的合法值:payment, network, account, hardware - status 的合法值:open, triaged, in_progress, waiting_customer, resolved, closed - 业务规则:已关闭工单必须存在指派人;P0工单不允许等待客户侧回复 - 关系说明:requester 是提交工单的客户;assignee 是负责处理工单的坐席把这段文本拼到系统Prompt里,模型对“领域词汇”的判断会稳定很多。它知道自己生活在一个有明确规则的世界里,而不是靠揣测用户的随口表述。
4.3 第三步:把本体用于RAG查询扩展和Agent工具调用
除了结构化输出,本体驱动还能反哺RAG。举个例子,用户问“上个月还没解决的支付问题现在怎么样了”,如果你只是做关键词检索,很可能漏掉用“payment”描述但状态是“waiting_customer”的工单,因为“还没解决”这个短语并没有直接对应某个状态。
有了本体以后,我可以让模型先生成“语义查询”而不是直接生成SQL或ES DSL。模型输出一个中间表示,比如“category=payment, status in [open, triaged, in_progress, waiting_customer, resolved]”,再由查询模板模块展开成具体检索条件。因为“未解决”的概念已经在状态枚举里做了隐式定义,展开时会自动排除closed状态。
在Agent工具调用场景里,本体也很有用。比如模型想调用“分配坐席”工具,参数里要传agent_id和ticket_id,validator会在执行前检查该工单优先级和坐席在线状态;如果P0工单被分配到离线坐席,直接返回给Agent一个机器可读的错误码和可选的下一个动作,而不是让Agent自己猜。这就是把业务规则前置到“语义层”,而不是散落在各个工具实现里。
5. 实测踩过的五个坑:本体建模远比画ER图考验人性
5.1 坑一:抽象层级不是越高越好,通天塔会变成无人区
我第一次给一个电商客服域建模时,往建模里注入了学术本能,把所有类都挂在一个顶级Entity下,属性也尽量复用,想着“这样以后扩展性最强”。结果跑起来后,LLM面对复杂的输出Schema反而懵了:因为它不知道该把“订单号”这个值放在Entity.identifier里,还是放在Order.order_no里。当模型可以在多个抽象层级之间做选择时,它就会随机漂移,校验器反而成了最忙的模块。
后来我总结出一条经验:本体建模的抽象层级应该以“系统边界上的业务契约”为准,而不是以“哲学上的类层级”为准。只有跨服务、跨模型、跨Prompt必须稳定共享的概念,才值得放进本体;内部临时变量、展示字段、一次性中间结果都不要进。抽象到恰好能表达业务规则就停手,别为了“未来可能用到”多建层。
5.2 坑二:枚举和关系方向必须采用“闭合世界假设”
LLM天然是“开放世界”的。它看到status的四个示例值后,可能觉得再加一个archived也无妨。我在早期项目里允许校验器在出现未知枚举时“放行并给warning”,结果生产环境数据里慢慢堆满了status: "closed_by_customer"、priority: "urgent"这种脏值,下游报表全部失真。
痛定思痛:凡是本体声明了枚举、规则、关系目标的地方,一律采用闭合世界假设——不在允许列表里的值直接报错。开放信息要建模成free_form属性,或者单独放进additionalNotes字段。另外关系方向也要显式写死,比如requester和assignee都必须注明主语和宾语语义,否则模型很容易把“客户指派人”和“客户被指派”搞反。我在本体描述里增加了natural_language_example字段,每个关系配一个“主语-谓词-宾语”的真实例句,生成Prompt时这些例句会被带进去,效果立竿见影。
5.3 坑三:本体与生成物的漂移会让事实源名存实亡
前期手改生成物是最大的债务来源。你改了一个TS类型,本地能跑,但Promp的语义片段还是旧的,RAG查询模板也是旧的,最终模型输出和业务代码对不上,排查起来比全都手写还痛苦。我的解决方案前面提到过:
- 生成物文件头注明“AUTO-GENERATED,请勿手工编辑”;
- CI里执行
ont-dev check --frozen,对比每次build后生成物的哈希,一旦不一致立即失败; - 本体文件变更时,Commit Message里必须带上
ontology:前缀,并且与生成物同时提交。
这三条配合下来,团队里没人再改生成物了。哪怕只是改一个枚举值的description,也会走“改本体->build->提交生成物”的流程,历史记录可审计。
5.4 坑四:规则表达式容易被写得太“智能”,反而变成另一个需要维护的系统
最初我在本体规则里支持了大量类似expr: "if any(assignee.history, h -> h.role == 'manager') then allow"的复杂表达式。这确实强大,但也意味着我要维护一套自己的规则引擎,调试成本极高。后来我意识到,本体驱动开发的第一目标是“把语义说清楚”,而不是创造一种新的编程语言。
现在我的规则只支持四类简单原语:必填约束、枚举约束、跨对象关系约束、有限状态流转合法性。更复杂的流程逻辑写回普通业务代码,通过调用validator暴露的断言函数来组合。这样本体文件仍然易读,业务同事也能看懂,规则层不会成为另一个技术债。
5.5 坑五:一次建模全领域是给自己挖坑
第五个坑非常隐蔽:想在一开始就把“客服+工单+支付+库存+物流”全部建模。你会陷入概念边界上的无穷争论,比如“支付工单到底算Ticket还是Payment?”结果一个多月交付不了任何东西。
个人项目和中小团队的正确姿势是“热启动”:先选两个月内必然要接入AI能力的核心业务域,只建30到50个核心概念和规则,跑通闭环;每迭代一个版本,再扩展示例和概念。ontology-driven-dev 本身也把示例设计为按业务域拆文件,支持import参考其他本体文件,而不是一定要一个巨无霸中心本体。这个设计就是被这个坑逼出来的。
6. 什么样的项目值得用“本体驱动”:收益评估与落地路径
6.1 收益明显的三类场景
第一阶段跑完,我也冷静评估过:不是所有AI项目都需要本体驱动。收益最明显的有三类场景。
第一类是业务流程复杂、状态和关系特别多的领域,比如客服系统、审批流、运维告警、医疗分诊。这类系统天然有大量枚举和跨对象规则,纯Prompt根本锁不住。
第二类是知识密集型RAG场景,需要回答“哪些概念等价、哪些词语是受控术语、状态之间有什么约束”的问题。本体能充当检索层的语义同义词表,显著提升召回质量。
第三类是Agent工具数量超过一定规模的场景。工具参数校验不再靠给每个函数写一堆if-else,而是由统一校验器根据本体规则拦截非法调用。当你的Agent要接20个工具时,这种集中管控会大幅减少“连锁幻觉”。
6.2 不适合的场景:别为临时脚本背一个重装备
反过来,如果项目只是个人兴趣Demo、一次性数据清洗脚本、或者一个纯闲聊Bot,本体驱动的初期成本会显得过高。一个只有三个字段的简单工具,用JSON Schema加一段Prompt完全够用,非要引入本体建模只会拖慢节奏。另外,如果团队里没有一个人愿意承担“领域建模者”的角色,也不建议硬推。本体文件虽然易读,但设计出稳定、准确、可演进的词汇表,确实需要业务和技术同时在线。
我的判断标准很简单:一条业务规则是否会出现在两个以上的Prompt或Agent工具里?如果会,它就值得进入本体;如果只在一次调用里出现,就暂时不值得。
6.3 个人项目从零开始落地的推荐路线
如果你看完后也想在自己项目里试试,我建议按下面这个顺序走,避免一口吃成胖子。
- 选定一个核心领域,不要贪多,比如“客服工单”或者“报销审批”;
- 只定义类、枚举、关键关系,先不写复杂规则;
- 跑
ont-dev init和ont-dev build,接入TypeScript类型和JSON Schema; - 在LLM结构化输出里用Schema约束,两周后确认稳定;
- 再加入prompt-builder生成的语义片段和validator,观察错误率变化;
- 等这套跑顺了,才考虑往RAG和Agent方向扩展查询模板和工具校验。
这六步每步都能独立交付,不会出现第一周全是建模、第二周还没见到AI效果的情况。我在自己另一个小项目里按这条路线走,两周内就看到了结构化输出错误率下降和回归成本下降。
最后再分享一个小技巧:我为ontology/目录单独配了Git规范,任何调整概念、枚举或规则的文件变更,提交信息必须以ontology:开头。跑一轮build后生成的diff我会花十分钟过一遍,确认没有意外的字段消失或关系变化。这十分钟看似额外开销,但比上线后让用户帮忙发现“模型突然不认识这个字段”要划算太多。本体驱动的开发方式不一定会让你的模型第一次就100%完美,但至少它能把“模型为什么犯错”从玄学变成可定位、可回滚、可复盘的工程问题。