
从去年开始越来越多的团队把 LLM 接进了日常开发流水线让模型写 CRUD 接口、补单元测试、生成数据解析脚本、甚至充当 Agent 自动调用工具。但我发现一个共性现象LLM 生成的代码“第一眼很正确”一旦进入边界条件、异常分支、副作用场景就很容易暴露出问题。尤其是自动生成的工具函数被 Agent 调用时一个“看起来没问题”的函数可能悄悄修改了数据库状态、发起了网络请求或者在没有做任何校验的情况下把脏数据继续往后传。这篇文章想讨论两个被很多人忽略、但正在成为 LLM 生成代码核心工程问题的概念Design by Contract契约式设计和Effects效果/副作用机制。我会先讲清楚这两个概念是什么再围绕一个真实的订单解析场景演示如何把契约和 effect 标记嵌入 LLM 生成的代码最后给出能够落地的工程建议。如果你正在用 LLM 辅助开发或者正在搭建 LLM Agent 工具调用链路这篇内容应该能帮你在“代码质量”和“模型输出可控性”之间找到一个更可靠的中间层。1. 为什么设计契约和 effects 是 LLM 生成代码的关键工程问题1.1 LLM 生成代码的价值与风险管理先看一个很常见的开发场景。业务方给了一段需求解析订单文本计算订单总额并支持折扣。开发者把这几个函数丢给 LLM几秒钟之后模型就输出了完整代码。代码风格统一、命名规范、甚至附带了注释。复制进项目跑一下正常路径结果完全正确。问题出在哪里如果传入的order_text是空字符串函数会怎样如果折扣率是负数函数会怎样如果订单里有一个商品金额是负数函数会怎样如果函数内部调用了日志系统、缓存系统或发送了网络请求调用方是否知道LLM 的优势在于生成“分布上最常见”的代码。它不知道你当前函数的上游数据是否经过了清洗也不知道这个函数未来会被哪些模块调用。它更像是一种“概率性代码补全”而不是一种“语义正确性证明”。所以当我们把 LLM 生成代码作为生产代码引入时真正需要的不是它看起来多规范而是它在任意输入下是否还能守住约定的行为边界。1.2 两个经常被忽略的工程概念传统软件工程里我们有一套成熟的方法来约束函数行为Design by Contract把调用者和实现者之间的约定显式表达出来。调用者负责满足前置条件实现者承诺给出满足后置条件的结果。Effects描述函数在执行过程中可能产生的副作用比如读写外部存储、网络调用、修改全局状态、抛出异常等。这两个概念在手工编写代码的时代就已经很强大但在 LLM 生成代码的场景下它们的价值会被放大很多倍。因为手工开发者可以在写代码时下意识地考虑调用上下文而 LLM 没有这种上下文感知能力它只能依赖明确写出来的约束。契约和 effect 正好是把“上下文约束”从人脑搬到代码里的工具。1.3 本文适合谁读、能解决什么问题本文适合以下读者正在使用 ChatGPT、Copilot、国产 LLM 等工具生成业务代码的开发者正在搭建 LLM Agent 工具调用体系需要控制工具函数副作用的工程师关注代码可维护性希望把模型输出纳入测试和评审体系的技术负责人。读完本文你会理解为什么“编译通过”不等于“行为正确”如何用前置条件、后置条件、不变量描述 LLM 生成代码的契约如何用 effect 标记约束 LLM 生成代码的副作用如何把契约和 effect 写进 Prompt让 LLM 生成的代码天然更可靠遇到“契约违反”“副作用失控”时应该按什么思路排查。2. Design by Contract把“正确”写进代码2.1 契约的本质Design by Contract 是由 Bertrand Meyer 在 Eiffel 语言中提出的一种软件设计方法。它的核心隐喻是函数调用就像一份法律合同。调用者有责任满足某些前置条件precondition实现者有责任在满足前置条件的前提下返回满足后置条件postcondition的结果在整个对象生命周期内需要始终保持某些不变量invariant。在 LLM 生成代码的语境下契约的作用更加直接它不给模型“自由发挥”的空间。比如你要求模型实现parse_order(text: str) - Order如果契约里明确写了“当text为空时抛出ValueError”模型生成的代码就有更高的概率真的处理空字符串而不是假设调用方不会传空值。2.2 三个核心要素前置条件Precondition前置条件描述“调用这个函数必须满足什么条件”。如果调用者不满足条件函数可以拒绝执行而不是返回一个奇怪的、无意义的默认值。# 前置条件text 非空且包含 order_id 前缀 def parse_order(text: str) - Order: if not text or not text.strip(): raise ValueError(text must not be empty) if not text.startswith(order_id): raise ValueError(text must start with order_id) # ... 后续解析逻辑后置条件Postcondition后置条件描述“函数执行完后返回值或对象状态必须满足什么条件”。如果函数的计算结果不满足后置条件说明实现内部出现了 bug。def calculate_total(order: Order) - float: total sum(item.price * item.quantity for item in order.items) # 后置条件总额必须非负 if total 0: raise ArithmeticError(total must not be negative) return total不变量Invariant不变量描述“在整个对象生命周期内从始至终都成立的性质”。对于订单对象来说一个常见不变量是订单中不能存在数量小于等于 0 的商品。dataclass(frozenTrue) class Order: order_id: str items: tuple[OrderItem, ...] def __post_init__(self): # 不变量订单 ID 非空 if not self.order_id: raise ValueError(order_id must not be empty) # 不变量商品项不可为空 if len(self.items) 0: raise ValueError(order must contain at least one item)2.3 为什么 LLM 更需要契约传统开发中契约的主要收益是让团队协作更清晰、错误定位更早。但在 LLM 生成代码的过程中契约还有一层额外价值它是可验证的约束也是 Prompt 设计的一部分。当你要求 LLM 生成代码时如果 Prompt 里只有“实现一个解析订单的函数”模型输出什么取决于训练数据里“大多数解析函数”长什么样子。但如果你在 Prompt 中明确写出前置条件、后置条件和不变量并进一步要求“这些规则必须用代码断言实现”模型就有了一个可对齐的规格说明书。它不再凭记忆编造行为而是在一个相对封闭的规则空间里填充实现细节。对于 LLM Agent 场景契约的作用更加重要。Agent 在决定是否调用某个工具前通常会读取工具的描述信息。如果每个工具的描述里都有明确的前置条件和后置条件Agent 做出错误调用的概率会明显下降。3. Effects给副作用划定边界3.1 什么是 EffectEffect 可以翻译为“效果”或“副作用”用来描述一段代码在完成主功能之外对外部世界产生的影响。纯函数是理想的相同输入永远得到相同输出不修改外部状态不执行 I/O。但真实业务代码不可能全是纯函数。我们总需要写数据库操作、发消息、调第三方 API。问题不在于有副作用而在于副作用没有被声明。当代码是人工编写时调用方可以通过阅读函数体来了解副作用。但当代码由 LLM 生成或者被 LLM Agent 动态调用时调用方可能是另一个程序也可能是 Agent 模型没有能力可靠地“读完函数体再决策”。这时候副作用必须被显式标记出来。3.2 常见副作用类型对于 LLM 生成代码和 LLM Agent 工具链路来说我习惯把这些副作用分成四类Effect 类型说明典型例子pure纯计算无外部影响字符串拼接、数学运算read_only只读取外部数据不修改查询数据库、读文件、GET 请求mutating修改外部状态写文件、更新数据库、发送 POST 请求io与外部世界交互但结果不可完全控制用户输入、网络超时、随机数这四类之间不是完全互斥的。一个函数可能既发起网络请求又修改本地缓存。但有了分类之后我们至少可以给每个函数一个“副作用签名”让调用方知道这个函数能不能被放到并行环境、能不能被重复执行、是否需要特别授权。3.3 副作用失控的后果在 LLM 生成代码的场景中副作用失控通常出现在两个层面。第一个层面是“代码内部的隐藏副作用”。LLM 生成的函数可能悄悄修改传入的可变对象、写日志文件、调用远程接口。代码评审时如果只关注主流程这些隐藏副作用很容易被漏掉。第二个层面是“Agent 工具调用的过度授权”。当 LLM Agent 可以调用一组工具时如果工具描述里没有 effect 信息模型就可能在一个“只读查询”场景下调用了一个“写入型工具”。这个问题在安全领域经常被讨论。要从根本上缓解需要两个措施一是工具接口的设计要遵循最小权限原则二是在工具描述里明确声明 effect 类型让 Agent 在调用前就知道这个工具会做什么。这里需要特别强调无论是让 LLM 生成调用代码还是让 Agent 选择工具都要确保在合法授权范围内、在测试环境中验证后再操作生产数据。任何涉及数据库变更、文件修改或外部请求的操作都应该遵循最小权限和审计留痕原则。4. 环境准备与示例场景说明4.1 演示环境本文的代码示例使用 Python 3 编写只依赖标准库不需要额外安装第三方包。示例中用到了dataclasses、functools、typing这些都是 Python 3.7 之后常见的标准库模块。版本需要根据你的实际项目调整。如果你使用的是 Java 项目可以用 JSR 380 的 Bean Validation 来表达契约如果使用 TypeScript可以用 zod 做运行时校验用函数式效果库管理副作用。本文的重点是“思路和结构”语言本身不是限制。4.2 业务场景假设我们假设一个电商模块收到了如下需求从一段文本中解析订单信息文本格式为order_id订单号; sku商品编码; price商品单价; quantity商品数量计算订单总金额应用折扣返回新的订单对象不影响原订单。这个场景非常适合演示契约和 effect因为它同时涉及字符串解析、数值计算和对象不可变性。4.3 项目结构规划为了便于演示我们把代码拆成两个文件demos/ ├── order_without_contract.py # 第一版无契约版本 ├── order_with_contract.py # 第二版引入契约 └── order_with_effect.py # 第三版引入 effect 标记实际项目中不需要刻意拆成三个文件这里是为了让三个版本之间的差异更加清晰。下面开始逐步编码。5. 实战从无契约代码升级为带契约与 Effect 标记的代码5.1 第一版只有“看起来对”的代码先模拟一个典型的 LLM 直接输出结果。它没有错误处理也没有任何约束。# 文件路径demos/order_without_contract.py from dataclasses import dataclass dataclass(frozenTrue) class OrderItem: sku: str price: float quantity: int dataclass(frozenTrue) class Order: order_id: str items: tuple[OrderItem, ...] def parse_order(text: str) - Order: parts text.split(;) order_id parts[0].split()[1] items [] for part in parts[1:]: kv part.split() if kv[0] sku: sku kv[1] elif kv[0] price: price float(kv[1]) elif kv[0] quantity: quantity int(kv[1]) items.append(OrderItem(skusku, priceprice, quantityquantity)) return Order(order_idorder_id, itemstuple(items)) def calculate_total(order: Order) - float: return sum(item.price * item.quantity for item in order.items) def apply_discount(order: Order, discount_rate: float) - Order: discounted_items tuple( OrderItem(item.sku, item.price * (1 - discount_rate), item.quantity) for item in order.items ) return Order(order_idorder.order_id, itemsdiscounted_items)如果你把这版代码交给测试同学很快会收到一堆 bug 反馈传空字符串会直接抛IndexError错误信息不友好传负数折扣率不会拦截会返回一个价格更高甚至为负的订单如果商品缺少quantity字段会直接NameError整个函数没有声明任何行为约束调用方只能靠看源码猜测。这就是典型的“编译通过但行为不可控”。LLM 生成这类代码很快但它把概率分布中“最常见的写法”复现了出来没有回答任何关于边界条件的问题。5.2 第二版引入前置条件与后置条件现在我们用显式的契约把函数的行为边界圈出来。这里定义两个简单的装饰器用来声明前置条件和后置条件。# 文件路径demos/order_with_contract.py import functools from dataclasses import dataclass def require(condition_func): 前置条件装饰器函数执行前检查条件 def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): if not condition_func(*args, **kwargs): raise ValueError(fPrecondition failed for {func.__name__}) return func(*args, **kwargs) return wrapper return decorator def ensure(condition_func): 后置条件装饰器函数执行后检查返回值 def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): result func(*args, **kwargs) if not condition_func(result): raise ArithmeticError(fPostcondition failed for {func.__name__}) return result return wrapper return decorator然后定义数据结构和业务函数。注意Order在__post_init__中维护不变量dataclass(frozenTrue) class OrderItem: sku: str price: float quantity: int def __post_init__(self): if not self.sku: raise ValueError(sku must not be empty) if self.price 0: raise ValueError(price must be positive) if self.quantity 0: raise ValueError(quantity must be positive) dataclass(frozenTrue) class Order: order_id: str items: tuple[OrderItem, ...] def __post_init__(self): if not self.order_id: raise ValueError(order_id must not be empty) if len(self.items) 0: raise ValueError(order must contain at least one item) require(lambda text: text is not None and text.strip() ! ) def parse_order(text: str) - Order: parts text.split(;) order_id parts[0].split()[1] if not order_id: raise ValueError(order_id must not be empty) items [] for part in parts[1:]: kv part.split() key kv[0] value kv[1] if key sku: sku value elif key price: price float(value) elif key quantity: quantity int(value) items.append(OrderItem(skusku, priceprice, quantityquantity)) if not items: raise ValueError(order must contain at least one item) return Order(order_idorder_id, itemstuple(items)) ensure(lambda total: total 0) def calculate_total(order: Order) - float: return sum(item.price * item.quantity for item in order.items) require(lambda order, discount_rate: 0 discount_rate 1) def apply_discount(order: Order, discount_rate: float) - Order: discounted_items tuple( OrderItem(item.sku, item.price * (1 - discount_rate), item.quantity) for item in order.items ) return Order(order_idorder.order_id, itemsdiscounted_items)现在再看之前的几个边界问题传空字符串给parse_order前置条件直接拦截传负数折扣率给apply_discount前置条件直接拦截商品数量为正、价格为正这些数据不变量在OrderItem创建时强制检查返回总额不可能为负算出来负值说明实现有 bug后置条件兜底。这就是契约式设计的价值它不是靠开发者“记得检查”而是把行为约束提升到代码结构层面让错误尽早暴露。5.3 第三版用 Effect 标记约束副作用有了契约之后函数的行为边界清晰了很多但还有一个问题调用方尤其是 LLM Agent怎么知道这个函数是否安全、是否会产生外部效果下面用装饰器给函数挂上 effect 标记。这里的实现只是一个简单示意你可以换成 Java 自定义注解、TypeScript 装饰器或者直接写在工具的 JSON Schema 描述里。# 文件路径demos/order_with_effect.py import functools from dataclasses import dataclass from enum import Enum, auto class EffectKind(Enum): PURE auto() # 纯计算无副作用 READ_ONLY auto() # 只读外部数据 MUTATING auto() # 修改外部状态 IO auto() # 与外部世界交互 def effect(*kinds: EffectKind): 给函数标记副作用类型 def decorator(func): setattr(func, __effects__, frozenset(kinds)) return func return decorator然后给上一版代码中的函数加上标记effect(EffectKind.PURE) require(lambda text: text is not None and text.strip() ! ) def parse_order(text: str) - Order: # 函数体与第二版相同 ... effect(EffectKind.PURE) ensure(lambda total: total 0) def calculate_total(order: Order) - float: ... effect(EffectKind.PURE) require(lambda order, discount_rate: 0 discount_rate 1) def apply_discount(order: Order, discount_rate: float) - Order: ...这三个函数都不访问外部系统所以标记为PURE。如果其中一个函数需要记录日志我们可以把它标记为IO。如果它需要把订单写入数据库就标记为MUTATING。有了标记之后Agent 工具路由可以读取这些元信息在调用前做决策def describe_tool(func) - dict: 把函数的 effect 信息暴露给外部调用方 kinds getattr(func, __effects__, {}) return { name: func.__name__, effects: [kind.name for kind in kinds], precondition: func.__doc__ or no documented precondition, }这样LLM Agent 在决定调用工具前看到的是一份带“副作用签名”的接口说明。它知道这个函数是只读的、可重入的、还是需要特别授权的。这比让模型通过函数名猜测要可靠得多。5.4 运行与验证保存第二版代码后可以在命令行里执行验证。为了看到契约的效果我加一个简单的测试脚本# 文件路径demos/test_contract.py from order_with_contract import parse_order, calculate_total, apply_discount # 正常路径 order parse_order(order_id1001; skuapple; price3.5; quantity2; skubanana; price2.0; quantity3) print(total:, calculate_total(order)) # 边界路径空文本 try: parse_order() except ValueError as e: print(parse_order error:, e) # 边界路径折扣率不合法 try: apply_discount(order, -0.2) except ValueError as e: print(apply_discount error:, e)预期输出类似total: 13.0 parse_order error: Precondition failed for parse_order apply_discount error: Precondition failed for apply_discount在实际项目中观察点有两个契约是否在第一时间拦截了非法输入非法输入被拦截后错误信息是否足够定位问题。这两个观察点也是后续排查和评审的重点。6. 常见问题与排查思路6.1 LLM 生成的代码不满足契约怎么办现象模型生成的代码即使声明了前置条件函数内部仍然没有检查或者检查了但异常类型不一致。可能原因LLM 生成的代码只把“契约描述”当作注释没有转换成真实的运行时断言或者模型没有理解“前置条件必须在函数入口处执行”。排查步骤检查函数入口是否有对应的if判断检查异常类型是否与设计一致比如统一抛ValueError而不是抛IndexError检查契约条件是否与 Prompt 中描述的完全一致。解决方案不要让 LLM “自觉”实现契约。在代码生成后的审查阶段用静态检查脚本扫描关键函数确认存在必要的防御性判断。另外可以把契约检查提取到装饰器或配置文件中减少模型自由发挥的空间。6.2 契约检查太多影响性能怎么办现象接口层增加了大量前置条件和后置条件检查每次调用都有额外开销。可能原因把高频调用链路中所有函数都加上了复杂契约检查而且条件本身涉及数据库查询或网络调用。排查步骤用 profile 定位哪些契约检查耗时最大区分“核心业务不变量”和“防御式检查”考虑把开销大的检查放到测试阶段和生产环境的采样日志中。解决方案契约检查并不是越多越好。优先级应该是数据不变量 涉及安全的副作用控制 业务规则校验。生产环境中可以把后置条件检查降级为日志告警而不是直接阻断。这里也提醒一下如果 LLM 生成的代码被直接接入生产环境的高频接口务必做好流量回放和灰度验证不要只依赖模型自测。6.3 工具函数副作用声明错误如何发现现象一个函数被标记为PURE实际运行时却修改了全局变量或写入了日志。可能原因LLM 生成代码时把外部调用隐藏在辅助函数中主函数表面上是纯函数或者模型对 effect 的理解有偏差。排查步骤代码评审时重点检查函数体内部是否调用了外部系统利用 Python 的 AST 分析或 Java 的字节码分析扫描函数内是否出现print、open、requests、sqlalchemy等关键字在测试环境中对标记为PURE的函数做幂等性和无状态验证。解决方案将 effect 标记纳入工具描述的规范字段并在 CI 中增加“副作用声明一致性检查”。如果发现声明为PURE的函数包含 I/O 调用构建直接失败。6.4 常见问题汇总表问题现象常见原因解决思路LLM 生成的函数不检查空值直接抛IndexError没有前置条件约束在 Prompt 中显式声明前置条件要求使用异常类型返回函数算出了负数总额缺少后置条件或内部数值逻辑错误在函数出口检查返回值不满足即抛异常折扣函数改了原订单对象可变对象被直接修改使用不可变数据结构或在不变量中声明不得修改原对象Agent 调用了不该写的工具工具描述缺少 effect 信息为工具补充 effect 标记设置权限校验契约检查过多导致接口变慢把业务校验与契约检查混在一起分级管理数据不变量必须检查业务校验可采样测试环境正常但生产环境失败生产数据不符合前置条件记录违约样例回访数据质量补充数据清洗7. 最佳实践与工程落地建议7.1 把契约写进 PromptPrompt 不只是给模型的自然语言任务描述它本身就是一份“接口说明书”。建议在 Prompt 中按固定模板描述函数契约请实现函数 parse_order(text: str) - Order 前置条件 - text 非空 - text 以 order_id 开头。 后置条件 - 返回的 Order.order_id 非空 - Order.items 长度大于 0 - 每个 OrderItem 的 price 和 quantity 均为正数。 不变量 - Order 对象不可变。 要求所有前置条件必须在函数入口处显示检查违反时抛出 ValueError。这样做的本质是把模型的可疑自由度压缩到最小让它的任务从“凭空实现一个解析函数”变成“在给定边界内填充逻辑”。当 Llama、GPT、Claude 一类模型看到完整约束时输出质量通常会比只给一句“解析订单文本”稳定得多。7.2 在 CI 阶段运行契约验证契约的价值只有在“自动化执行”时才能体现出来。建议在 CI 流程中加入一个专门阶段对 LLM 生成的代码运行契约测试。具体做法为每个工具函数维护一份契约清单包含前置条件、后置条件和不变量在 CI 中执行契约测试集输入包括正常数据、边界数据和非法数据对 LLM Agent 的工具函数额外检查 “effect 声明一致”确保标记为PURE的函数不会出现 I/O 调用。这些不需要昂贵的框架。用pytest加参数化用例就能覆盖大多数场景。7.3 为 Agent 工具接口补充 effect 说明在 LLM 应用开发中Agent 工具调用的可靠性比单个函数的正确性更影响整体体验。如果 Agent 无法判断某个工具是否会产生副作用它就会在错误的场景做出错误的决策。建议做法在工具描述中提供一个标准字段effects取值可以是pure、read_only、mutating、io。同时配合权限系统对mutating和io类工具增加二次确认或者专用凭证避免 Agent 在过度授权条件下执行危险操作。这也能提升 LLM 对工具链路的理解能力。训练模型虽然可以在一定程度上记住常见工具的行为但显式声明仍然是工程上最稳妥的约束手段。7.4 代码评审时重点检查哪些位置代码评审不可能取代自动验证但可以补齐自动化的盲区。对于 LLM 生成的代码我建议评审者重点看这几个位置函数入口是否立刻检查输入数据对象是否在创建时就满足不变量返回值是否经过后置条件校验函数体内部有没有隐藏 I/O 或状态修改异常类型和错误信息是否有区分度对 LLM Agent 暴露的工具是否有明确的 effect 描述。这些检查点不需要全部靠人眼完成可以写成一份 checklist加入团队的 PR 模板。这样时间长了之后LLM 生成代码的质量会逐渐逼近受控工程代码。8. 总结与进阶方向回到开头的问题为什么 Design by Contract 和 effects 是 LLM 生成代码的关键工程问题因为 LLM 不是形式化验证工具它更擅长根据统计规律生成看起来合理的代码。要让这种代码真正可用我们需要给它一个明确的行为边界并且在边界被突破时有机制能够尽早发现。Design by Contract 负责定义“输入-输出-状态”的约束effects 负责定义“函数对外部世界的影响范围”。两者组合起来就构成了一层独立于模型能力的质量防线。下一步可以继续学习的方向包括类型系统与运行时校验Python 里的dataclass、pydanticTypeScript 里的zodJava 里的 Bean Validation形式化方法与自动证明想要更严格地验证 LLM 生成的行为契约可以了解 TLA、Dafny 这类工具Agent 工具编排了解 MCP、工具路由和权限模型掌握如何把 effect 声明落地到 Agent 调用链中契约测试实践在现有项目里把核心业务函数逐步加上前置条件和后置条件积累一套可复用的契约测试用例。LLM 生成代码不是洪水猛兽但它也不应该是“黑盒魔法”。给它补上契约和 effect 这两个工程钩子之后模型输出就能从“看起来对”变成“可验证地对”。希望这篇笔记能把这两个偏工程底层的概念变成你日常 Prompt 设计和代码评审中的实际检查项。