做AI原生应用的朋友应该都有过这种体验:大模型单看很聪明,一落到具体业务场景里就开始“自由发挥”。今天让它输出结构化数据,它给你来一段带Markdown的散文;明天让它按业务规则做判断,它又擅自改了规则里的边界条件。我早期做基于大模型API的垂直应用时,最大的痛点根本不是接口调用,而是“模型在业务边界内稳定工作”这件事本身。后来我围绕“本体驱动”这个方向做了一个个人开源项目ontology-driven-dev,核心思路其实一句话就能说清:在AI应用里先立一套显式的领域本体,让模型在本体框架下理解和生成信息。这篇文章就围绕这个项目说说设计思路、实操过程,以及我踩过的一些坑。这个项目适合谁看?两类人。一类是做复杂业务系统集成的开发者,你手上有明确的领域实体、状态机、业务规则,但不知道怎么让大模型在既定轨道里干活;另一类是刚开始接触大模型应用、被“幻觉”和“输出不稳定”折磨的新手,想找一个比裸Prompt更可控的开发范式。项目本身不算大,但方法论是通用的,我会尽量把底层逻辑拆开讲。1. 为什么AI原生应用需要本体驱动1.1 先捋清楚:本体到底是什么本体(Ontology)这个词来自哲学,研究的是“存在”的分类和关系。计算机领域借用了这个概念,但落到工程上没那么玄乎:本体就是对一个领域内概念、属性、关系、规则的显式化、形式化描述。你可以把它理解成一份“业务知识结构蓝图”——画清楚这个领域有哪些东西、每个东西有哪些属性、东西和东西之间是什么关系、什么条件下允许做什么操作。生活化类比:图书馆的图书分类法就是本体。书有类目、有作者、有出版年份,类目之间有从属关系,读者根据分类号能快速定位一本书。如果没有这套分类法,你只能靠书名逐个找,效率极低;如果每本书都随便贴标签,图书馆很快就会变成仓库。AI原生应用也一样,大模型本身是个无比庞大的“藏书库”,但你的业务是一间需要精确管理的小图书馆,没有自己的分类法,大模型就只能凭感觉给你乱贴标签。在ontology-driven-dev这个项目里,我对“本体”做了一个务实收窄:它不要求你搞完整的OWL描述逻辑或者推理机,而是一份可机器解析的领域Schema,包含类(Class)、属性(Property)、关系(Relation)、规则(Rule)四类元素。项目目录里专门有ontology/目录存放这些定义,格式上兼容JSON Schema,便于LLM和程序同时解析。1.2 只用Prompt写业务规则,为什么不够很多人第一反应是:业务规则我写进Prompt不就行了?比如“你是客服机器人,退款条件必须是订单已发货且超过7天”,确实能跑,但问题会随着系统复杂度迅速爆发。第一个问题,自然语言的规则没有强制性。大模型本质是概率生成,不是规则执行引擎。你告诉它“必须输出JSON”,它大概率输出JSON,但字段名偶尔会从orderId变成order_id,枚举值偶尔会从pending变成Pending。写进Prompt的规则对大模型来说是“强建议”,不是“硬约束”。第二个问题,规则散落在各个Prompt里,没有统一入口。今天这个需求写进系统Prompt,明天那个规则写进用户Prompt,后天某个校验逻辑写在代码里。业务人员问你“现在退款规则到底有哪些”,你根本答不上来。系统越做越大,规则自相矛盾的情况越来越多,而且模型会随机挑选它认为“更合理”的那条执行。第三个问题,业务规则一旦变化,Prompt调优成本极高。我见过太多团队临时改Prompt规则,结果几个功能互相影响,模型行为变得完全不可预测。本体驱动的思路是把规则从Prompt里抽出来,放到结构化的本体定义里,Prompt只负责“如何执行本体”,而不是“本体是什么”。1.3 本体驱动实际解决的问题清单用ontology-driven-dev跑完几个真实业务Demo后,我总结出它最实在的几个价值:输出结构化稳定:模型返回的数据能落到本体的类和属性定义上,字段命名、枚举值、嵌套结构可控。知识可复用:本体定义一次,可以在意图识别、信息抽取、工具调用等多个环节重复使用,不用每个Prompt里重新写一遍业务概念。幻觉被约束:当模型知道“订单状态只可能有这五种取值”“工单优先级只有三级”时,它在这些维度上瞎编的概率大幅下降。业务规则可演进:本体独立于Prompt和代码,业务方改规则只动本体文件,系统可维护性明显好于规则散落在大段Prompt里的做法。可解释性增强:数据出了问题时,可以追查“是模型输出错了”还是“本体定义有歧义”,而不是在黑盒Prompt里反复试。2. 本体驱动开发的核心设计思路2.1 一句话概括架构:三层结构我在项目里把整体架构分为三层,这个划分也是我实际做多个项目后沉淀出的惯例结构。本体层(Ontology Layer):负责定义领域概念和规则,放置所有业务知识。这一层是纯静态的,不依赖任何大模型能力,只描述“业务是怎样的”。适配层(Adapter Layer):负责大模型和本体之间的对齐。它把本体定义注入到Prompt中,让模型输出尽可能贴合本体;再把模型输出拿回来,用本体Schema做校验和纠正。这一层是整套系统的关键所在。执行层(Execution Layer):负责真正的业务动作。以校验通过的、实例化后的本体数据为输入,决定调用哪个函数、更新什么状态、走什么流程。我把这个架构比喻成“给大模型画了一副骨架,再让它往骨架上填肉”。没有骨架,模型生成的内容是飘的;骨架太死,模型又丧失了灵活性。这个度需要在实践中反复标定,后面会详细讲。2.2 本体层的建模方法本体的建模不需要一上来就搞大而全。我建议从一张“业务名词表”开始:把业务场景里所有出现的名词列出来,去掉同义词,合并近义词,然后逐个问三个问题——它是什么(类)、它有什么特征(属性)、它跟其他名词有什么关系(关系)。举一个我在项目README里实际用过的“订单场景”例子。假设你在做一个售后客服助手,业务名词表里会出现:客户、订单、退款申请、商品、物流、售后客服。接下来:类:订单、商品、退款申请、客户属性:订单有订单号、下单时间、金额、状态;退款申请有申请原因、审核状态、处理人关系:客户“提交”退款申请,退款申请“关联”订单,订单“包含”商品把这个梳理过程写成机器可解析的本体,在项目里用JSON Schema表示就是这个样子:{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Order", "description": "订单领域本体的核心类定义", "type": "object", "properties": { "orderId": { "type": "string", "description": "全局唯一订单号" }, "amount": { "type": "number", "minimum": 0, "description": "订单实付金额" }, "status": { "type": "string", "enum": ["CREATED", "PAID", "SHIPPED", "COMPLETED", "CANCELLED"], "description": "订单状态,取值必须来自枚举" }, "items": { "type": "array", "items": { "$ref": "#/$defs/OrderItem" } } }, "$defs": { "OrderItem": { "type": "object", "properties": { "productId": { "type": "string" }, "quantity": { "type": "integer", "minimum": 1 } }, "required": ["productId", "quantity"] } }, "required": ["orderId", "amount", "status", "items"] }这个文件就是本体层的“最小单元”。注意看,里面没有一句自然语言规则,全是结构化的类型约束、枚举约束、必填约束。这些约束同时被两方消费:一方是随后要讲到的适配层,用来生成Prompt;另一方是校验器,用来检查模型输出。2.3 为什么选本体驱动,而不是纯RAG或纯提示词工程我在项目里整理过一个对比,方便评估三种技术的适用边界,这里直接放出来:方案可控性开发成本适用场景主要局限纯提示词工程低低原型验证、Demo规则无法复用,行为不稳定,复杂规则无法保证RAG(检索增强生成)中中知识问答、文档处理检索的是碎片化文本,无法表示结构化关系和规则本体驱动开发高中到高业务系统集成、流程自动化、知识密集型应用需要前期建模投入,系统越小收益越小RAG解决的是“模型不知道某些知识”的问题,它检索的是文档片段,本质上仍是自然语言。本体驱动解决的是“模型知道但不按业务规则来”的问题,它是结构化的、可推理的。两者可以配合使用——本体定义结构和规则,RAG提供参考文档,但本体是主干。纯提示词工程更像手写便签,贴得到处都是,改起来全凭感觉;本体驱动更像数据库Schema,一旦定下来,全系统共享同一份结构约束。后者的前期成本更高,但跨过一定复杂度后收益是前者的指数级。3. 项目核心模块与实操过程3.1 项目整体模块划分ontology-driven-dev的目录结构我按模块职责来组织,大体是这样:ontology-driven-dev/ ontology/ # 本体定义文件,JSON Schema为主 order.schema.json refund.schema.json adapter/ # 适配层:Prompt生成、输出解析、校验 promptBuilder.ts validator.ts repair.ts engine/ # 执行层:动作编排,与业务系统对接 dispatch.ts stateMachine.ts examples/ # 完整可运行的示例项目 support-bot/ # 售后退款助手示例 repair-bot/ # 设备报修助手示例 tests/ # 本体和适配层的单元测试模块划分的核心原则是:业务知识(ontology/)、模型适配(adapter/)、执行动作(engine/)三者严格分离。任何一层都不应该直接修改另一层的代码。业务方改规则,只动ontology/;模型品牌或版本变化,只动adapter/;业务流程调整,只动engine/。3.2 一个可落地的示例:智能报修助手我在项目examples/里放了一个“设备报修助手”的完整Demo,很有代表性,这里拆解它的实现过程。业务背景设定得简单一些:企业内部员工可以提交设备报修单,系统需要从员工的自然语言描述中提取工单信息,判断设备类型和故障类型,然后自动路由到对应的处理流程。第一步,定义报修工单本体。核心类是RepairTicket,属性包含设备类型、故障描述、故障类型、紧急程度、报修人、期望处理时间。注意这里的设备类型和故障类型都用枚举约束,这是后面校验的基础:{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "RepairTicket", "type": "object", "properties": { "deviceType": { "type": "string", "enum": ["PRINTER", "LAPTOP", "DESKTOP", "NETWORK", "OTHER"], "description": "设备类型,必须从枚举取值" }, "faultType": { "type": "string", "enum": ["HARDWARE", "SOFTWARE", "NETWORK", "POWER", "UNKNOWN"], "description": "故障类型,无法判断时用UNKNOWN" }, "urgency": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH"], "description": "紧急程度,根据影响范围判断" }, "description": { "type": "string", "description": "用户原始故障描述" } }, "required": ["deviceType", "faultType", "urgency", "description"] }第二步,编写适配层的Prompt构建逻辑。这里有两个关键操作:把本体定义中描述类和属性的信息转换为Prompt片段;以及明确要求模型“输出必须满足Schema”。我在项目里用的是TypeScript,核心逻辑像这样:import { JSONSchema } from "./types"; function buildExtractionPrompt(schema: JSONSchema): string { const enumFields = Object.entries(schema.properties) .filter(([, value]) => (value as any).enum) .map(([key, value]) => { return `- ${key}: 取值必须严格限定为 [${(value as any).enum.join(", ")}]`; }) .join("\n"); return [ `你是设备报修工单信息抽取器。`, `根据用户的报修描述,抽取一个JSON对象,对象结构必须严格满足以下字段定义:`, `- deviceType: ${(schema.properties as any).deviceType.description}`, `- faultType: ${(schema.properties as any).faultType.description}`, `- urgency: ${(schema.properties as any).urgency.description}`, `- description: 原始描述原文抄录`, ``, `枚举约束如下:`, enumFields, ``, `只输出被抽取的JSON,不要包含任何解释、前缀或Markdown代码块标记。`, ].join("\n"); }这个函数的价值在于:本体的枚举、描述、必填约束全部通过程序从JSON Schema生成,不需要手写大段业务Prompt。业务侧改了枚举,Prompt下一分钟自动跟着变,不会出现两边不同步的情况。第三步,调用大模型并获取抽取结果。这里我封装了一个简易调用函数,用于说明接入点:export async function extractTicket( userInput: string, schema: JSONSchema, callLLM: (prompt: string) => Promise<string> ): Promise<RepairTicket> { const prompt = buildExtractionPrompt(schema); const rawContent = await callLLM(`${prompt}\n\n用户描述:${userInput}`); return parseAndValidate(rawContent, schema); }callLLM是一个注入的函数,你可以往里接自己的模型接口。这是为了让项目本身不绑定任何特定模型服务,保持中立。3.3 让LLM输出对齐本体的关键写法仅仅把Schema贴进Prompt是不够的,我从实际测试中总结出几个必须写清楚的约束点。第一,明确声明“只输出被抽取的JSON”。不写这句话时,模型有很大的概率在JSON前面加一句“好的,根据您的描述,我提取出以下信息”,或者把JSON包在Markdown代码块里。做了本体约束以后,编写专门的解析器剥离多余内容。第二,枚举约束必须逐项列出,并且写明“必须严格限定为”。这里要强调模型对“近似值”的偏好:你以为它只会输出PRINTER和LAPTOP,它有时会输出printer或者Printers,所以Prompt里我会额外写一句“枚举值区分大小写,禁止使用同义词或近似变体”。第三,本体里允许模型表达不确定性的字段,要显式给一个“UNKNOWN”或“OTHER”的逃逸出口。比如故障类型无法从用户描述中判断,如果枚举没有UNKNOWN,模型就只好瞎猜一个。给出口不是破坏规则,而是让规则覆盖真实世界的模糊性。第四,Prompt里描述字段时,我会同时带上JSON Schema里对应字段的description。这个description是建模阶段我们写业务语义的地方,它直接影响模型抽取的准确率。测试下来,语义描述写得清楚的情况下,字段抽取准确率能提升十个点以上,这属于本体和Prompt之间的联动红利。3.4 校验失败后的自动纠偏逻辑模型输出一定会有非法的时候,所以校验和纠偏逻辑不能省。我在adapter/validator.ts里实现了分级修复策略:第一级,基础格式修复。去掉Markdown代码块标记,去掉首尾的纯文本解释,尝试直接解析JSON。这一级能解决大约六成以上的非法输出。第二级,Schema校验。用本体做校验,收集所有校验错误,包括字段缺失、枚举值非法、类型错误、嵌套结构错误。校验结果反馈到纠偏模块,进行精准修复。第三级,基于错误信息的二次生成。这是项目比较核心的一个机制:把校验错误作为消息回传给大模型,让它基于原输出和错误提示做修改。相当于给模型一次“自查重答”的机会。Description代码如下:export async function parseAndValidate( rawContent: string, schema: JSONSchema ): Promise<RepairTicket> { const cleaned = cleanRawContent(rawContent); // 第二级:本体Schema校验 const errors = validate(cleaned, schema); if (errors.length === 0) { return cleaned as RepairTicket; } // 第三级:二次修复 const repairPrompt = [ `你刚才输出的JSON没有通过以下业务规则校验:`, ...errors.map((e) => `- ${e.message}`), ``, `请基于上一条输出,仅修改不合规字段,保持其他内容不变,重新输出完整JSON。`, ].join("\n"); const repaired = await callLLM(repairPrompt); return parseAndValidate(repaired, schema); }注意这个递归调用设了一个最大重试次数(一般是两次),防止模型反复修都修不对时无限消耗接口费用。我在实际运行中观察到的数据是:第一轮直接通过率大概在70%左右,进入修复流程后累计通过率能到95%以上,剩余不到5%确实需要人工介入,通常是用户描述太模糊,信息确实不够。3.5 执行层的动作编排校验通过后,RepairTicket实例就具备了结构化的执行依据。在engine/dispatch.ts里,我根据urgency和设备类型做路由:紧急程度为HIGH的单子直接进入加急队列,并通知维修组;PRINTER类设备转给打印设备专员;NETWORK类故障转给网络组;UNKNOWN的故障先进入人工确认队列。这个执行层看起来简单,但它体现了一个重要转变:大模型在这里不是“最终决策者”,而是“信息抽取器”。决策动作由既定的业务逻辑基于本体实例化数据来执行。模型可能会偶尔判断错urgency,但一旦决定以结构化数据进入业务系统,规则是确定的、可审计的、可以回滚的。对于企业级系统来说,这一点比追求模型单点准确率更重要。4. 常见问题与排查技巧实录4.1 本体定义太细,模型反而学不会这是我从实践中踩过的最大一个坑。做第一个版本的本体时,我追求完备,一个订单类写了40多个属性,嵌套了三层子对象,枚举值全部铺开。结果模型输出通过率惨不忍睹,大量字段缺失和层级错乱。后来我把本体结构调整为:核心识别字段控制在十个以内,嵌套不超过两层,属性都加上业务描述,通过率立刻回升。道理不复杂:大模型处理结构化约束的能力有限,字段越多、嵌套越深,它漏输出的概率就越大。本体的粒度应该以“业务决策所需信息”为准,而不是把所有可能知道的细节都建模进去。多一个非核心字段,就多一分出错的概率。这是一个典型的“少即是多”场景。4.2 模型不按枚举值返回怎么办枚举值非法是最常见的校验错误,占比大约四成。除了在Prompt里强调枚举约束外,还有一个实用技巧:在建模时给每个枚举值一个“别名表”。比如LAPTOP这个枚举值,用户可能说“笔记本”、“电脑”、“笔电”,本体里就维护一个deviceTypeSnonyms映射,在质检环节做一次别名归一化。模型如果输出了alias,我们先把别名映射成标准枚举值,再做校验。注意,别名归一化一定要在Prompt约束之后,作为兜底策略。主要约束还是靠Prompt让模型直接输出标准值,不能依赖归一化处理,否则模型会越来越随意。4.3 本体更新之后,历史会话状态怎么迁移这个问题容易被忽略。本体定义了订单状态的五个枚举值,某天业务方说要增加一个REFUNDING状态,新枚举值不会影响新会话,但历史会话里如果缓存了旧的工单状态,新旧定义就冲突了。我在项目里给每个本体定义了version字段,适配层读取时会检查版本一致性;执行流程遇到旧版本数据会走一次“状态迁移”脚本,把旧状态映射到新枚举。枚举值变更时都要求写出迁移映射表,纯新增枚举值迁移最简单,重命名或拆分的枚举值迁移要特别小心。4.4 什么时候不应该用本体驱动说这个可能有点反直觉,但本体驱动不是万能的。当你的应用只有一两个Prompt、输出结构很简单、业务规则基本没有时,引入本体反而是累赘。本体驱动的收益和业务复杂度强相关:系统越大,规则越多,收益越突出;小Demo用本体驱动只会增加开发量和理解成本。另外,纯开放域创作类应用(写诗、写故事、头脑风暴)也不适合本体驱动。这类场景要的就是模型的发散性,强加一套结构约束就扼杀了核心能力。本体驱动适合的是有明确业务边界和规则的系统——客服、审批、工单、诊断、数据治理这一类。4.5 几个排查工具与调试技巧调试本体驱动应用有一个比较好用的方式:把每个步骤的输入输出都记录下来,形成一条可回放的链路。我通常记录三份数据:原始用户输入、模型第一轮输出及校验错误列表、修复后最终输出。业务侧反馈“抽取结果不对”时,回放这条链路十分钟就能定位问题是在本体定义、Prompt构建还是模型行为上。针对本体本身的调试,写单元测试非常值得。项目tests/目录里为每个Schema配了一组样本:一组合法输入,一组边界输入,一组非法输入。这三个用例会跑校验、修复、路由全链路。这样本体一变更,回归成本很低,业务方改规则时也更放心。我自己的体会是,本体驱动开发不是要用一个架构解决所有问题,而是在大模型能力和业务稳定性之间搭一座桥。这座桥一开始搭的时候确实费功夫,你要梳理业务流程、定义类与关系、设计枚举值,这些都不是能靠一时的灵感完成的事。但一旦搭好,后续的每个新功能、每次规则调整,都是在桥上走路而不是重新爬一座山。实际做下来,它的最优使用方式不是取代你的业务系统,也不是束缚大模型的能力边界,而是让两边都回到自己最擅长的事:业务系统做确定性的决策和执行,大模型做灵活的理解和抽取。最后补充一个小技巧:如果你打算在项目里引入本体驱动,不要直接去找现成的行业本体库,先花半天时间把你系统里的业务名词表拉出来,能列出一百个名词,就已经胜过绝大多数没有建模意识的项目了。
做AI原生应用的朋友应该都有过这种体验:大模型单看很聪明,一落到具体业务场景里就开始“自由发挥”。今天让它输出结构化数据,它给你来一段带Markdown的散文;明天让它按业务规则做判断,它又擅自改了规则里的边界条件。我早期做基于大模型API的垂直应用时,最大的痛点根本不是接口调用,而是“模型在业务边界内稳定工作”这件事本身。后来我围绕“本体驱动”这个方向做了一个个人开源项目ontology-driven-dev,核心思路其实一句话就能说清:在AI应用里先立一套显式的领域本体,让模型在本体框架下理解和生成信息。这篇文章就围绕这个项目说说设计思路、实操过程,以及我踩过的一些坑。这个项目适合谁看?两类人。一类是做复杂业务系统集成的开发者,你手上有明确的领域实体、状态机、业务规则,但不知道怎么让大模型在既定轨道里干活;另一类是刚开始接触大模型应用、被“幻觉”和“输出不稳定”折磨的新手,想找一个比裸Prompt更可控的开发范式。项目本身不算大,但方法论是通用的,我会尽量把底层逻辑拆开讲。1. 为什么AI原生应用需要本体驱动1.1 先捋清楚:本体到底是什么本体(Ontology)这个词来自哲学,研究的是“存在”的分类和关系。计算机领域借用了这个概念,但落到工程上没那么玄乎:本体就是对一个领域内概念、属性、关系、规则的显式化、形式化描述。你可以把它理解成一份“业务知识结构蓝图”——画清楚这个领域有哪些东西、每个东西有哪些属性、东西和东西之间是什么关系、什么条件下允许做什么操作。生活化类比:图书馆的图书分类法就是本体。书有类目、有作者、有出版年份,类目之间有从属关系,读者根据分类号能快速定位一本书。如果没有这套分类法,你只能靠书名逐个找,效率极低;如果每本书都随便贴标签,图书馆很快就会变成仓库。AI原生应用也一样,大模型本身是个无比庞大的“藏书库”,但你的业务是一间需要精确管理的小图书馆,没有自己的分类法,大模型就只能凭感觉给你乱贴标签。在ontology-driven-dev这个项目里,我对“本体”做了一个务实收窄:它不要求你搞完整的OWL描述逻辑或者推理机,而是一份可机器解析的领域Schema,包含类(Class)、属性(Property)、关系(Relation)、规则(Rule)四类元素。项目目录里专门有ontology/目录存放这些定义,格式上兼容JSON Schema,便于LLM和程序同时解析。1.2 只用Prompt写业务规则,为什么不够很多人第一反应是:业务规则我写进Prompt不就行了?比如“你是客服机器人,退款条件必须是订单已发货且超过7天”,确实能跑,但问题会随着系统复杂度迅速爆发。第一个问题,自然语言的规则没有强制性。大模型本质是概率生成,不是规则执行引擎。你告诉它“必须输出JSON”,它大概率输出JSON,但字段名偶尔会从orderId变成order_id,枚举值偶尔会从pending变成Pending。写进Prompt的规则对大模型来说是“强建议”,不是“硬约束”。第二个问题,规则散落在各个Prompt里,没有统一入口。今天这个需求写进系统Prompt,明天那个规则写进用户Prompt,后天某个校验逻辑写在代码里。业务人员问你“现在退款规则到底有哪些”,你根本答不上来。系统越做越大,规则自相矛盾的情况越来越多,而且模型会随机挑选它认为“更合理”的那条执行。第三个问题,业务规则一旦变化,Prompt调优成本极高。我见过太多团队临时改Prompt规则,结果几个功能互相影响,模型行为变得完全不可预测。本体驱动的思路是把规则从Prompt里抽出来,放到结构化的本体定义里,Prompt只负责“如何执行本体”,而不是“本体是什么”。1.3 本体驱动实际解决的问题清单用ontology-driven-dev跑完几个真实业务Demo后,我总结出它最实在的几个价值:输出结构化稳定:模型返回的数据能落到本体的类和属性定义上,字段命名、枚举值、嵌套结构可控。知识可复用:本体定义一次,可以在意图识别、信息抽取、工具调用等多个环节重复使用,不用每个Prompt里重新写一遍业务概念。幻觉被约束:当模型知道“订单状态只可能有这五种取值”“工单优先级只有三级”时,它在这些维度上瞎编的概率大幅下降。业务规则可演进:本体独立于Prompt和代码,业务方改规则只动本体文件,系统可维护性明显好于规则散落在大段Prompt里的做法。可解释性增强:数据出了问题时,可以追查“是模型输出错了”还是“本体定义有歧义”,而不是在黑盒Prompt里反复试。2. 本体驱动开发的核心设计思路2.1 一句话概括架构:三层结构我在项目里把整体架构分为三层,这个划分也是我实际做多个项目后沉淀出的惯例结构。本体层(Ontology Layer):负责定义领域概念和规则,放置所有业务知识。这一层是纯静态的,不依赖任何大模型能力,只描述“业务是怎样的”。适配层(Adapter Layer):负责大模型和本体之间的对齐。它把本体定义注入到Prompt中,让模型输出尽可能贴合本体;再把模型输出拿回来,用本体Schema做校验和纠正。这一层是整套系统的关键所在。执行层(Execution Layer):负责真正的业务动作。以校验通过的、实例化后的本体数据为输入,决定调用哪个函数、更新什么状态、走什么流程。我把这个架构比喻成“给大模型画了一副骨架,再让它往骨架上填肉”。没有骨架,模型生成的内容是飘的;骨架太死,模型又丧失了灵活性。这个度需要在实践中反复标定,后面会详细讲。2.2 本体层的建模方法本体的建模不需要一上来就搞大而全。我建议从一张“业务名词表”开始:把业务场景里所有出现的名词列出来,去掉同义词,合并近义词,然后逐个问三个问题——它是什么(类)、它有什么特征(属性)、它跟其他名词有什么关系(关系)。举一个我在项目README里实际用过的“订单场景”例子。假设你在做一个售后客服助手,业务名词表里会出现:客户、订单、退款申请、商品、物流、售后客服。接下来:类:订单、商品、退款申请、客户属性:订单有订单号、下单时间、金额、状态;退款申请有申请原因、审核状态、处理人关系:客户“提交”退款申请,退款申请“关联”订单,订单“包含”商品把这个梳理过程写成机器可解析的本体,在项目里用JSON Schema表示就是这个样子:{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Order", "description": "订单领域本体的核心类定义", "type": "object", "properties": { "orderId": { "type": "string", "description": "全局唯一订单号" }, "amount": { "type": "number", "minimum": 0, "description": "订单实付金额" }, "status": { "type": "string", "enum": ["CREATED", "PAID", "SHIPPED", "COMPLETED", "CANCELLED"], "description": "订单状态,取值必须来自枚举" }, "items": { "type": "array", "items": { "$ref": "#/$defs/OrderItem" } } }, "$defs": { "OrderItem": { "type": "object", "properties": { "productId": { "type": "string" }, "quantity": { "type": "integer", "minimum": 1 } }, "required": ["productId", "quantity"] } }, "required": ["orderId", "amount", "status", "items"] }这个文件就是本体层的“最小单元”。注意看,里面没有一句自然语言规则,全是结构化的类型约束、枚举约束、必填约束。这些约束同时被两方消费:一方是随后要讲到的适配层,用来生成Prompt;另一方是校验器,用来检查模型输出。2.3 为什么选本体驱动,而不是纯RAG或纯提示词工程我在项目里整理过一个对比,方便评估三种技术的适用边界,这里直接放出来:方案可控性开发成本适用场景主要局限纯提示词工程低低原型验证、Demo规则无法复用,行为不稳定,复杂规则无法保证RAG(检索增强生成)中中知识问答、文档处理检索的是碎片化文本,无法表示结构化关系和规则本体驱动开发高中到高业务系统集成、流程自动化、知识密集型应用需要前期建模投入,系统越小收益越小RAG解决的是“模型不知道某些知识”的问题,它检索的是文档片段,本质上仍是自然语言。本体驱动解决的是“模型知道但不按业务规则来”的问题,它是结构化的、可推理的。两者可以配合使用——本体定义结构和规则,RAG提供参考文档,但本体是主干。纯提示词工程更像手写便签,贴得到处都是,改起来全凭感觉;本体驱动更像数据库Schema,一旦定下来,全系统共享同一份结构约束。后者的前期成本更高,但跨过一定复杂度后收益是前者的指数级。3. 项目核心模块与实操过程3.1 项目整体模块划分ontology-driven-dev的目录结构我按模块职责来组织,大体是这样:ontology-driven-dev/ ontology/ # 本体定义文件,JSON Schema为主 order.schema.json refund.schema.json adapter/ # 适配层:Prompt生成、输出解析、校验 promptBuilder.ts validator.ts repair.ts engine/ # 执行层:动作编排,与业务系统对接 dispatch.ts stateMachine.ts examples/ # 完整可运行的示例项目 support-bot/ # 售后退款助手示例 repair-bot/ # 设备报修助手示例 tests/ # 本体和适配层的单元测试模块划分的核心原则是:业务知识(ontology/)、模型适配(adapter/)、执行动作(engine/)三者严格分离。任何一层都不应该直接修改另一层的代码。业务方改规则,只动ontology/;模型品牌或版本变化,只动adapter/;业务流程调整,只动engine/。3.2 一个可落地的示例:智能报修助手我在项目examples/里放了一个“设备报修助手”的完整Demo,很有代表性,这里拆解它的实现过程。业务背景设定得简单一些:企业内部员工可以提交设备报修单,系统需要从员工的自然语言描述中提取工单信息,判断设备类型和故障类型,然后自动路由到对应的处理流程。第一步,定义报修工单本体。核心类是RepairTicket,属性包含设备类型、故障描述、故障类型、紧急程度、报修人、期望处理时间。注意这里的设备类型和故障类型都用枚举约束,这是后面校验的基础:{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "RepairTicket", "type": "object", "properties": { "deviceType": { "type": "string", "enum": ["PRINTER", "LAPTOP", "DESKTOP", "NETWORK", "OTHER"], "description": "设备类型,必须从枚举取值" }, "faultType": { "type": "string", "enum": ["HARDWARE", "SOFTWARE", "NETWORK", "POWER", "UNKNOWN"], "description": "故障类型,无法判断时用UNKNOWN" }, "urgency": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH"], "description": "紧急程度,根据影响范围判断" }, "description": { "type": "string", "description": "用户原始故障描述" } }, "required": ["deviceType", "faultType", "urgency", "description"] }第二步,编写适配层的Prompt构建逻辑。这里有两个关键操作:把本体定义中描述类和属性的信息转换为Prompt片段;以及明确要求模型“输出必须满足Schema”。我在项目里用的是TypeScript,核心逻辑像这样:import { JSONSchema } from "./types"; function buildExtractionPrompt(schema: JSONSchema): string { const enumFields = Object.entries(schema.properties) .filter(([, value]) => (value as any).enum) .map(([key, value]) => { return `- ${key}: 取值必须严格限定为 [${(value as any).enum.join(", ")}]`; }) .join("\n"); return [ `你是设备报修工单信息抽取器。`, `根据用户的报修描述,抽取一个JSON对象,对象结构必须严格满足以下字段定义:`, `- deviceType: ${(schema.properties as any).deviceType.description}`, `- faultType: ${(schema.properties as any).faultType.description}`, `- urgency: ${(schema.properties as any).urgency.description}`, `- description: 原始描述原文抄录`, ``, `枚举约束如下:`, enumFields, ``, `只输出被抽取的JSON,不要包含任何解释、前缀或Markdown代码块标记。`, ].join("\n"); }这个函数的价值在于:本体的枚举、描述、必填约束全部通过程序从JSON Schema生成,不需要手写大段业务Prompt。业务侧改了枚举,Prompt下一分钟自动跟着变,不会出现两边不同步的情况。第三步,调用大模型并获取抽取结果。这里我封装了一个简易调用函数,用于说明接入点:export async function extractTicket( userInput: string, schema: JSONSchema, callLLM: (prompt: string) => Promise<string> ): Promise<RepairTicket> { const prompt = buildExtractionPrompt(schema); const rawContent = await callLLM(`${prompt}\n\n用户描述:${userInput}`); return parseAndValidate(rawContent, schema); }callLLM是一个注入的函数,你可以往里接自己的模型接口。这是为了让项目本身不绑定任何特定模型服务,保持中立。3.3 让LLM输出对齐本体的关键写法仅仅把Schema贴进Prompt是不够的,我从实际测试中总结出几个必须写清楚的约束点。第一,明确声明“只输出被抽取的JSON”。不写这句话时,模型有很大的概率在JSON前面加一句“好的,根据您的描述,我提取出以下信息”,或者把JSON包在Markdown代码块里。做了本体约束以后,编写专门的解析器剥离多余内容。第二,枚举约束必须逐项列出,并且写明“必须严格限定为”。这里要强调模型对“近似值”的偏好:你以为它只会输出PRINTER和LAPTOP,它有时会输出printer或者Printers,所以Prompt里我会额外写一句“枚举值区分大小写,禁止使用同义词或近似变体”。第三,本体里允许模型表达不确定性的字段,要显式给一个“UNKNOWN”或“OTHER”的逃逸出口。比如故障类型无法从用户描述中判断,如果枚举没有UNKNOWN,模型就只好瞎猜一个。给出口不是破坏规则,而是让规则覆盖真实世界的模糊性。第四,Prompt里描述字段时,我会同时带上JSON Schema里对应字段的description。这个description是建模阶段我们写业务语义的地方,它直接影响模型抽取的准确率。测试下来,语义描述写得清楚的情况下,字段抽取准确率能提升十个点以上,这属于本体和Prompt之间的联动红利。3.4 校验失败后的自动纠偏逻辑模型输出一定会有非法的时候,所以校验和纠偏逻辑不能省。我在adapter/validator.ts里实现了分级修复策略:第一级,基础格式修复。去掉Markdown代码块标记,去掉首尾的纯文本解释,尝试直接解析JSON。这一级能解决大约六成以上的非法输出。第二级,Schema校验。用本体做校验,收集所有校验错误,包括字段缺失、枚举值非法、类型错误、嵌套结构错误。校验结果反馈到纠偏模块,进行精准修复。第三级,基于错误信息的二次生成。这是项目比较核心的一个机制:把校验错误作为消息回传给大模型,让它基于原输出和错误提示做修改。相当于给模型一次“自查重答”的机会。Description代码如下:export async function parseAndValidate( rawContent: string, schema: JSONSchema ): Promise<RepairTicket> { const cleaned = cleanRawContent(rawContent); // 第二级:本体Schema校验 const errors = validate(cleaned, schema); if (errors.length === 0) { return cleaned as RepairTicket; } // 第三级:二次修复 const repairPrompt = [ `你刚才输出的JSON没有通过以下业务规则校验:`, ...errors.map((e) => `- ${e.message}`), ``, `请基于上一条输出,仅修改不合规字段,保持其他内容不变,重新输出完整JSON。`, ].join("\n"); const repaired = await callLLM(repairPrompt); return parseAndValidate(repaired, schema); }注意这个递归调用设了一个最大重试次数(一般是两次),防止模型反复修都修不对时无限消耗接口费用。我在实际运行中观察到的数据是:第一轮直接通过率大概在70%左右,进入修复流程后累计通过率能到95%以上,剩余不到5%确实需要人工介入,通常是用户描述太模糊,信息确实不够。3.5 执行层的动作编排校验通过后,RepairTicket实例就具备了结构化的执行依据。在engine/dispatch.ts里,我根据urgency和设备类型做路由:紧急程度为HIGH的单子直接进入加急队列,并通知维修组;PRINTER类设备转给打印设备专员;NETWORK类故障转给网络组;UNKNOWN的故障先进入人工确认队列。这个执行层看起来简单,但它体现了一个重要转变:大模型在这里不是“最终决策者”,而是“信息抽取器”。决策动作由既定的业务逻辑基于本体实例化数据来执行。模型可能会偶尔判断错urgency,但一旦决定以结构化数据进入业务系统,规则是确定的、可审计的、可以回滚的。对于企业级系统来说,这一点比追求模型单点准确率更重要。4. 常见问题与排查技巧实录4.1 本体定义太细,模型反而学不会这是我从实践中踩过的最大一个坑。做第一个版本的本体时,我追求完备,一个订单类写了40多个属性,嵌套了三层子对象,枚举值全部铺开。结果模型输出通过率惨不忍睹,大量字段缺失和层级错乱。后来我把本体结构调整为:核心识别字段控制在十个以内,嵌套不超过两层,属性都加上业务描述,通过率立刻回升。道理不复杂:大模型处理结构化约束的能力有限,字段越多、嵌套越深,它漏输出的概率就越大。本体的粒度应该以“业务决策所需信息”为准,而不是把所有可能知道的细节都建模进去。多一个非核心字段,就多一分出错的概率。这是一个典型的“少即是多”场景。4.2 模型不按枚举值返回怎么办枚举值非法是最常见的校验错误,占比大约四成。除了在Prompt里强调枚举约束外,还有一个实用技巧:在建模时给每个枚举值一个“别名表”。比如LAPTOP这个枚举值,用户可能说“笔记本”、“电脑”、“笔电”,本体里就维护一个deviceTypeSnonyms映射,在质检环节做一次别名归一化。模型如果输出了alias,我们先把别名映射成标准枚举值,再做校验。注意,别名归一化一定要在Prompt约束之后,作为兜底策略。主要约束还是靠Prompt让模型直接输出标准值,不能依赖归一化处理,否则模型会越来越随意。4.3 本体更新之后,历史会话状态怎么迁移这个问题容易被忽略。本体定义了订单状态的五个枚举值,某天业务方说要增加一个REFUNDING状态,新枚举值不会影响新会话,但历史会话里如果缓存了旧的工单状态,新旧定义就冲突了。我在项目里给每个本体定义了version字段,适配层读取时会检查版本一致性;执行流程遇到旧版本数据会走一次“状态迁移”脚本,把旧状态映射到新枚举。枚举值变更时都要求写出迁移映射表,纯新增枚举值迁移最简单,重命名或拆分的枚举值迁移要特别小心。4.4 什么时候不应该用本体驱动说这个可能有点反直觉,但本体驱动不是万能的。当你的应用只有一两个Prompt、输出结构很简单、业务规则基本没有时,引入本体反而是累赘。本体驱动的收益和业务复杂度强相关:系统越大,规则越多,收益越突出;小Demo用本体驱动只会增加开发量和理解成本。另外,纯开放域创作类应用(写诗、写故事、头脑风暴)也不适合本体驱动。这类场景要的就是模型的发散性,强加一套结构约束就扼杀了核心能力。本体驱动适合的是有明确业务边界和规则的系统——客服、审批、工单、诊断、数据治理这一类。4.5 几个排查工具与调试技巧调试本体驱动应用有一个比较好用的方式:把每个步骤的输入输出都记录下来,形成一条可回放的链路。我通常记录三份数据:原始用户输入、模型第一轮输出及校验错误列表、修复后最终输出。业务侧反馈“抽取结果不对”时,回放这条链路十分钟就能定位问题是在本体定义、Prompt构建还是模型行为上。针对本体本身的调试,写单元测试非常值得。项目tests/目录里为每个Schema配了一组样本:一组合法输入,一组边界输入,一组非法输入。这三个用例会跑校验、修复、路由全链路。这样本体一变更,回归成本很低,业务方改规则时也更放心。我自己的体会是,本体驱动开发不是要用一个架构解决所有问题,而是在大模型能力和业务稳定性之间搭一座桥。这座桥一开始搭的时候确实费功夫,你要梳理业务流程、定义类与关系、设计枚举值,这些都不是能靠一时的灵感完成的事。但一旦搭好,后续的每个新功能、每次规则调整,都是在桥上走路而不是重新爬一座山。实际做下来,它的最优使用方式不是取代你的业务系统,也不是束缚大模型的能力边界,而是让两边都回到自己最擅长的事:业务系统做确定性的决策和执行,大模型做灵活的理解和抽取。最后补充一个小技巧:如果你打算在项目里引入本体驱动,不要直接去找现成的行业本体库,先花半天时间把你系统里的业务名词表拉出来,能列出一百个名词,就已经胜过绝大多数没有建模意识的项目了。