用编译器思路约束大模型输出:从赌概率到确定性 1. 从赌概率到确定性一个编译器思路的诞生大模型应用开发这两年我最大的感受就一个字赌。你写一段提示词模型返回的结果对不对全靠运气。同一个问题问十遍可能八遍对、两遍错剩下那两遍还错得特别离谱。做Demo的时候无所谓反正演示嘛挑一次好的就行。但真到了生产环境这种不确定性就是灾难。我们团队去年做了一套基于大模型的结构化信息抽取系统从合同里抽关键字段。测试阶段准确率92%看着挺美。上线第一周就出事了——有一批合同里的金额字段被抽成了负数还有一批日期格式完全乱掉。排查下来发现模型在遇到某些特殊排版时会自作主张地改变输出格式。你没法跟它讲道理它也不是每次都错就是偶尔抽风。那段时间我就在想一个问题能不能像编译器那样把自然语言的模糊性在编译阶段就消解掉让运行时只处理确定性的逻辑这个想法听起来有点疯狂但仔细琢磨是有道理的。传统编译器做的事情是把人类可读的高级语言翻译成机器可执行的指令中间要经过词法分析、语法分析、语义分析、优化、代码生成等一系列步骤。每一步都有严格的规则约束保证最终产出的指令是确定的、可验证的。那我们为什么不能用同样的思路来处理大模型的输出核心洞察在于大模型本质上是一个概率性的文本生成器但我们可以通过工程手段在它外面包一层确定性的壳。这层壳负责把模型的自由输出约束到我们定义的规则空间里就像编译器把自由的自然语言约束到形式化的语法树里一样。我们把这个思路落地成了一个内部工具叫约束编译器。它不是真的去编译什么代码而是把提示词输出约束校验规则编译成一套可执行的验证流水线。模型还是那个模型但它的输出必须经过这层编译器的安检不合格的直接打回重试或者走降级逻辑。这套东西做完之后我们那个抽取系统的线上事故率从每周两三次降到了零。不是说模型变聪明了而是我们不再依赖它的聪明了。2. 核心设计思路把不确定性关进笼子里2.1 为什么是编译器而不是校验器很多人第一反应是这不就是个输出校验吗加个正则表达式不就行了真做过就知道正则表达式只能处理最简单的格式校验。一旦涉及嵌套结构、条件依赖、跨字段一致性正则就歇菜了。比如如果合同类型是租赁则租金字段必填且为正数如果是买卖则总价字段必填且与单价乘以数量一致——这种规则用正则写出来就是天书维护成本极高。编译器的思路不一样。它把规则抽象成语法定义和语义约束两层。语法层管的是长什么样比如JSON的括号匹配、字段类型语义层管的是什么意思比如数值范围、字段间的逻辑关系。两层分开定义分开执行最后统一编译成一个可执行的校验计划。这样做的好处是规则可组合、可复用、可测试。我们定义了一套DSL来描述输出约束大概长这样schema: type: object properties: contract_type: type: string enum: [lease, sale, service] amount: type: number minimum: 0 currency: type: string pattern: ^[A-Z]{3}$ required: [contract_type, amount, currency] if: contract_type: lease then: required: [monthly_rent, lease_term]这套DSL会被编译成一个校验器链每个校验器只负责一个维度的检查。模型输出先过语法校验再过语义校验最后过业务规则校验。任何一层不过就触发重试或者降级。2.2 三层架构的取舍整个系统分三层约束定义层、编译优化层、运行时执行层。约束定义层是给人看的用YAML或者JSON写规则尽量做到非技术人员也能看懂。这一层的设计目标是声明式——你只需要说我要什么不需要说怎么检查。编译优化层是核心。它把声明式的规则翻译成可执行的校验计划并且做优化。比如多个规则都检查同一个字段的类型那就合并成一次检查比如某个规则依赖另一个规则的结果那就调整执行顺序避免重复计算。这一层还会做静态分析提前发现规则之间的矛盾。比如你同时要求某个字段必填和禁止出现编译器会直接报错而不是等到运行时才发现。运行时执行层就是真正跑校验的地方。它接收模型输出按照编译好的计划逐步检查返回结构化的校验结果。如果通过输出规范化后的数据如果不通过返回具体的错误路径和原因方便上层决定是重试还是降级。这三层分开的好处是关注点分离。产品经理改规则只需要动第一层工程师优化性能只需要动第二层运维排查问题只需要看第三层。互不干扰。2.3 重试策略的设计哲学很多人忽略的一点是校验失败之后怎么办直接报错给用户那体验太差了。无脑重试可能陷入死循环。我们的做法是分级重试。根据错误的类型和严重程度采取不同的策略错误类型严重程度处理策略最大重试次数格式错误如JSON不合法低自动重试附加格式提示2类型错误如字符串给了数字中自动重试附加类型转换提示1语义错误如数值超范围高不重试直接降级0业务规则冲突极高不重试告警人工介入0这个策略不是拍脑袋定的是踩了无数坑之后总结出来的。格式错误通常是因为模型手滑重试一次大概率能好语义错误往往说明模型根本没理解任务重试也是浪费时间业务规则冲突则可能是规则本身有问题需要人工介入。实操心得重试的时候一定要把上一次的错误信息附加到提示词里告诉模型你上次错在哪了。实测下来带错误反馈的重试成功率比盲目重试高40%以上。3. 核心细节解析从规则定义到执行计划3.1 约束DSL的设计要点设计一套DSL最怕的就是过度设计。我们第一版搞了特别复杂的语法支持条件分支、循环、自定义函数结果没人愿意用。第二版砍到只剩最核心的几类约束反而推广开了。现在的DSL只支持五种约束类型约束string、number、boolean、array、object范围约束minimum、maximum、minLength、maxLength枚举约束enum限定取值范围模式约束pattern正则匹配依赖约束if-then-else条件必填或条件取值这五种覆盖了90%以上的场景。剩下的10%怎么办用自定义校验器扩展。我们预留了一个接口允许用Python写自定义校验逻辑编译的时候会把它包装成标准的校验器节点。custom_validator(cross_field_check) def validate_cross_field(data, context): if data.get(contract_type) lease: if data.get(monthly_rent, 0) * data.get(lease_term, 0) ! data.get(total_amount): return False, 租金乘以租期不等于总金额 return True, None自定义校验器的好处是灵活坏处是不可静态分析。编译器没法提前知道它会不会和别的规则冲突所以只能把它放在执行计划的最后等所有静态规则都过了再跑。3.2 编译优化的三个关键策略编译优化层做的事情说白了就是让校验跑得更快、更准。我们主要做了三件事第一规则合并。如果多个规则都在检查同一个字段的同一个属性就合并成一次检查。比如amount必须是数字和amount必须大于0合并成amount必须是大于0的数字。这样只需要遍历一次数据。第二短路求值。如果某个字段的类型检查没过那针对这个字段的所有其他检查都可以跳过。因为类型都不对后面的检查没有意义。这个优化在错误率高的场景下效果特别明显能减少70%以上的无效计算。第三并行执行。对于互不依赖的规则分支编译成可以并行执行的计划。比如检查字段A和检查字段B互不影响那就同时跑。我们用了一个简单的DAG调度器来实现实测下来比串行快3倍左右。注意事项并行执行虽然快但错误报告的顺序会乱。如果你的上层逻辑依赖错误顺序那就得关掉并行。我们踩过这个坑后来在编译配置里加了个开关默认开启并行需要顺序的时候手动关掉。3.3 错误路径的规范化校验失败的时候返回的错误信息必须精确到字段路径。不能只说校验失败得说字段contract.amount校验失败期望是大于0的数字实际收到字符串abc。我们定义了一套标准的错误路径格式用点号分隔嵌套层级用方括号表示数组索引contract.parties[0].name: 期望是字符串实际收到null contract.amount: 期望大于0实际收到-100 contract.currency: 期望匹配^[A-Z]{3}$实际收到rmb这套格式的好处是机器可读、人类可懂。上层逻辑可以根据路径决定怎么处理比如如果是parties数组里的某个元素出错可能只需要重新抽取那一个元素不用整个重来。4. 实操过程从零搭建一个约束编译器4.1 环境准备与依赖选型我们用的是Python技术栈核心依赖就三个Pydantic做基础的类型校验和数据结构定义jsonschema做JSON Schema的校验作为兜底NetworkX做DAG的构建和调度用于并行执行为什么选Pydantic而不是自己写类型检查因为Pydantic的校验器生态成熟性能也够用。实测下来Pydantic V2的校验速度比手写if-else快不了太多但代码可维护性高一个数量级。为什么还要jsonschema因为有些场景下用户直接给的就是JSON Schema我们得兼容。而且jsonschema的生态里有大量现成的工具比如格式检查、默认值填充可以直接复用。NetworkX纯粹是为了省事。自己写拓扑排序也不是不行但NetworkX的API更顺手而且调试的时候可视化方便。pip install pydantic jsonschema networkx就这三行环境就搭好了。不需要什么重型框架轻量级反而更可控。4.2 定义约束Schema第一步是定义约束的Schema。我们用Pydantic模型来描述from pydantic import BaseModel, Field from typing import Any, Optional, List, Dict class Constraint(BaseModel): type: str minimum: Optional[float] None maximum: Optional[float] None min_length: Optional[int] None max_length: Optional[int] None enum: Optional[List[Any]] None pattern: Optional[str] None required: bool False properties: Optional[Dict[str, Constraint]] None items: Optional[Constraint] None if_condition: Optional[Dict[str, Any]] None then_constraint: Optional[Constraint] None else_constraint: Optional[Constraint] None这个模型支持嵌套properties里可以继续定义子字段的约束items定义数组元素的约束。if-then-else用来表达条件依赖。定义好之后用户就可以用YAML或者JSON来写约束了。我们选YAML是因为写起来更简洁注释也方便。4.3 编译器的核心逻辑编译器的输入是约束Schema输出是一个可执行的校验计划。核心逻辑分三步第一步解析。把YAML解析成Constraint对象树。这一步用Pydantic的parse_obj就能搞定顺便做了基础的类型检查。第二步构建DAG。遍历Constraint树为每个需要检查的字段生成一个校验节点然后根据依赖关系连边。比如if-then-else会产生条件依赖数组元素的校验依赖于数组本身的类型检查。import networkx as nx def build_dag(constraint, path): G nx.DiGraph() node_id f{path}:{constraint.type} G.add_node(node_id, constraintconstraint, pathpath) if constraint.properties: for field_name, sub_constraint in constraint.properties.items(): sub_path f{path}.{field_name} if path else field_name sub_graph build_dag(sub_constraint, sub_path) G nx.compose(G, sub_graph) G.add_edge(node_id, f{sub_path}:{sub_constraint.type}) if constraint.if_condition: # 条件依赖then分支依赖于if条件 pass return G第三步拓扑排序生成执行计划。用NetworkX的topological_sort就能拿到执行顺序。如果要并行就按层级分组同一层级的节点可以并行执行。def compile_to_plan(constraint): dag build_dag(constraint) if not nx.is_directed_acyclic_graph(dag): raise ValueError(约束之间存在循环依赖) plan [] for generation in nx.topological_generations(dag): plan.append(list(generation)) return plan这个plan就是一个二维列表第一层是第一批可以并行执行的节点第二层是依赖第一批的节点以此类推。运行时按层执行层内并行层间串行。4.4 运行时执行与错误收集运行时执行器接收模型输出和编译好的plan逐层执行校验收集错误。def execute_plan(plan, data, dag): errors [] for generation in plan: for node_id in generation: node_data dag.nodes[node_id] constraint node_data[constraint] path node_data[path] value get_value_by_path(data, path) is_valid, error validate_value(value, constraint) if not is_valid: errors.append({ path: path, expected: constraint.type, actual: type(value).__name__, message: error }) return errors错误收集完之后上层逻辑根据错误类型决定重试还是降级。如果是格式错误就把错误信息格式化后附加到提示词里重新调用模型如果是语义错误就直接返回降级结果。实操心得错误信息里一定要包含期望值和实际值的对比。我们一开始只写了校验失败结果排查问题的时候完全不知道模型到底输出了什么。后来加上实际值排查效率提升了好几倍。5. 常见问题与排查技巧实录5.1 模型输出不是合法JSON怎么办这是最常见的问题。模型有时候会在JSON外面包一层markdown代码块有时候会多写一个逗号有时候会把引号写成中文引号。我们的处理策略是先清洗再解析。清洗规则包括去掉markdown代码块标记json和把中文引号替换成英文引号去掉尾随逗号如果解析失败尝试用正则提取第一个完整的JSON对象import re import json def clean_and_parse(text): # 去掉markdown代码块 text re.sub(r^(?:json)?\s*, , text.strip()) text re.sub(r\s*$, , text) # 中文引号替换 text text.replace(, ).replace(, ) # 去掉尾随逗号 text re.sub(r,\s*([}\]]), r\1, text) try: return json.loads(text), None except json.JSONDecodeError as e: # 尝试提取第一个JSON对象 match re.search(r\{.*\}, text, re.DOTALL) if match: try: return json.loads(match.group()), None except: pass return None, str(e)这套清洗逻辑覆盖了95%以上的格式问题。剩下的5%通常是模型输出了完全无关的内容那就只能重试了。5.2 重试次数用完了还是失败怎么办这是降级逻辑要解决的问题。我们的降级策略分三级一级降级返回部分结果。如果只有少数字段校验失败其他字段都通过了那就返回通过的部分失败的字段标记为null或者默认值。这样至少用户能拿到大部分数据。二级降级返回原始输出。如果校验完全失败但原始输出看起来还有一定结构那就把原始输出返回给用户让用户自己判断。同时附上校验错误信息方便用户理解哪里出了问题。三级降级返回兜底结果。如果连原始输出都没有那就返回一个预定义的兜底结果。比如空对象、默认值、或者一个明确的错误提示。注意事项降级策略一定要在业务层面确认过。有些场景下返回部分结果比返回错误更危险比如金融场景下金额字段缺失可能导致严重后果。这种场景下宁可报错也不能降级。5.3 校验规则太严导致重试率飙升这是规则设计的问题。我们一开始把规则定得特别细比如日期格式必须精确到秒货币代码必须是大写。结果模型经常在这些细节上翻车重试率高达30%。后来我们调整了策略核心字段严边缘字段松。核心字段比如金额、类型、ID必须严格校验边缘字段比如描述、备注、时间戳允许一定的模糊性。具体做法是给每个约束加一个strict标志strict为true的字段严格校验为false的字段做宽松处理。比如日期格式strict模式下必须匹配YYYY-MM-DD HH:mm:ss非strict模式下只要能被日期解析器解析就行。created_at: type: string format: date-time strict: false这个调整之后重试率从30%降到了8%效果立竿见影。5.4 常见问题速查表问题现象可能原因排查方法解决方案解析失败率高模型输出格式不稳定打印原始输出看格式问题加强清洗逻辑附加格式提示重试率飙升规则太严或模型能力不足统计各字段失败率放宽非核心字段约束换更强模型校验速度慢规则太多或数据太大看执行计划找瓶颈节点开启并行合并重复规则错误信息不明确错误收集不完整检查错误路径和实际值补充期望值和实际值对比条件规则不生效if-then-else逻辑写错单独测试条件分支用单元测试覆盖所有分支6. 这套思路还能怎么扩展6.1 从输出约束到输入约束我们目前主要做的是输出约束但其实同样的思路可以用在输入端。比如用户输入的问题里必须包含某些关键信息否则直接拒绝不浪费模型调用。输入约束的好处是省钱。一次模型调用少则几分钱多则几毛钱如果能在输入端就过滤掉无效请求长期下来能省不少。实现方式也类似定义一套输入Schema用户请求先过校验通过了再调模型。不过输入端的约束通常更宽松因为自然语言的表达方式太丰富了约束太严会误杀正常请求。6.2 从单次校验到多轮对话多轮对话场景下约束会变得更复杂。因为每一轮的输出可能依赖于上一轮的输出形成一个状态机。我们的做法是把对话状态也纳入约束体系。每一轮定义当前状态的约束以及状态转移的条件。编译器会把整个对话流程编译成一个状态转移图运行时按照图来执行。这个方向我们还在探索中目前只做了简单的状态管理复杂的场景还在踩坑。6.3 从规则约束到学习约束规则约束的局限性在于需要人工定义。如果场景特别复杂规则写起来就很痛苦。一个可能的扩展方向是从数据中学习约束。给一批正例和反例让系统自动推断出约束规则。这个思路在异常检测领域已经有成熟应用但用在模型输出约束上还比较新。我们试过一个简单的版本用决策树从正反例中学习字段的取值范围和依赖关系然后转成约束DSL。效果一般主要问题是学出来的规则太具体泛化能力差。但这个方向值得继续探索。6.4 性能优化的空间目前我们的校验器是纯Python实现的性能瓶颈主要在数据遍历和正则匹配上。如果数据量特别大可以考虑用Rust或者C重写核心校验逻辑Python只做调度。另一个优化方向是增量校验。如果模型输出只是部分字段变了那就只重新校验变化的字段不用全量重跑。这个在流式输出场景下特别有用。最后分享一个小技巧校验器的单元测试一定要覆盖边界情况。我们踩过最大的坑是一个正则表达式在空字符串上行为异常导致所有空字段都被误判为失败。后来加了边界测试这类问题就再也没出现过。这套约束编译器的思路本质上是用工程手段对抗概率模型的不确定性。它不是银弹不能解决所有问题但在结构化输出这个场景下确实把我们从赌概率的泥潭里拉了出来。模型可以继续用但它的输出必须经过我们的安检门。这扇门越精密我们对模型的依赖就越少系统的确定性就越高。