“分形生成实验”这个系列,我终于做到了第六期。前五期分别处理了科赫雪花、分形树、曼德勃罗集可视化、迭代函数系统,还有性能优化那一期里折腾的调色板缓存。每一期都在跟同一个问题缠斗:怎么把数学上很漂亮的分形结构,变成计算机里不崩不卡还能看的程序。第六期我想加码,做L系统分形——就是那种能长出植物分支、像蕨类叶片一样的结构。结果这次卡住我的不是算法,不是性能,而是数据建模。准确地说,是我在和AI编程助手协作时,发现了一个被严重低估的东西:数据结构本身就是人和AI之间的契约。
这篇文章我会把整个思考过程、踩坑记录和最终的实操方案都摊开来讲。如果你也在用AI辅助写代码,尤其是写那种跨模块、多状态、递归嵌套的程序,这篇文章应该能帮你省下不少和AI来回拉扯的时间。
1. 分形实验里的意外卡点:瓶颈不在算法,在数据
1.1 做了五期分形实验,这期却被参数追着跑
L系统分形的原理其实不复杂。它靠一条“公理”加几条“重写规则”不断迭代,每个字符被替换成一段新的字符串,最后把字符串解释成绘图指令。理论上只要规则定义得够好,就能生成极其复杂的自然形态。
问题出在代码量上。前几期用turtle或者Pygame直接画,核心逻辑几十行就够。但L系统涉及三个模块:规则解析器、结构生成器、渲染器。模块一多,AI编程助手的表现就不太对劲了。
我最初的做法是直接把需求抛给AI:“请用Python生成一棵分形树,使用递归绘制。”它唰唰写出来一段代码,看着很规整,跑起来却总是歪的。要么树枝角度方向反了,要么递归深度没限制直接栈溢出,要么颜色参数传着传着就丢了。最离谱的一次,AI把分形树的生长角度存成元组,渲染时又当单个浮点数用,整个画面像被风吹散了一样。
单看每个函数都没问题,拼起来就是不对劲。我原来以为这是AI“笨”,后来才发现是我没给它一个足够稳定的数据结构。它只能靠函数名和上下文猜,猜出来的结果自然不可控。
1.2 病根找到了:AI擅长局部逻辑,不擅长全局数据一致性
我做了个对比实验。同一个AI编程助手,让它单独实现“把角度转换为弧度”的工具函数,一次通过;让它实现“规则解析器+图元生成器+渲染调度”的三模块协作代码,改了四轮还是有问题。
这个现象很说明问题:AI训练出来的能力,强在局部逻辑推断,弱在跨模块的数据一致性。模块A里叫angle的字段,在模块B里可能被理解为弧度,在模块C里又被理解成角度。人写代码时脑子里有全局图景,能本能地保持一致;AI没有这个图景,它只能从你给的上下文里推断。
所以关键在于:把全局图景外化成一个明确的“契约”——也就是数据结构的完整定义、每个字段的语义说明、边界条件的约束。契约清晰了,AI的推断空间就被压缩了,它不需要猜,照着契约写就行。
我在分形实验里第一次体会到这个道理时,有点恍惚:原来跟AI协作,最重要的不是提示词技巧,而是数据建模。提示词是临时的对话技巧,数据结构才是长期生效的合作协议。
2. 数据建模的整体设计思路:先定契约,再写代码
2.1 分形生成系统的三层数据模型,每层只认接口不认实现
给L系统做数据建模之前,我先把整个系统的数据流拆成了三层。这个方法不只在分形里有用,任何渲染类项目都可以参考。
第一层是生成参数层。它负责保存所有外部输入的配置:种子字符串、迭代次数、转角、长度、缩放比例、终止阈值等。简单说,这一层就是“用户想画什么”的描述。
第二层是结构表示层。它负责把配置变成一棵可遍历的树形结构,比如分形树的每一条树枝、每个分支节点、每个末端的相对位置关系。这是整个系统的核心层,也是和AI协作时最容易出问题的层。
第三层是渲染数据层。它负责把结构变成最终能画出来的东西:线段、圆形、多边形、颜色值、线宽等。渲染器只认这一层的数据,不关心你用的是L系统还是递归分形。
我用一个表格把这三层的关系列清楚:
| 层次 | 核心职责 | 典型数据 | 对外接口 |
|---|---|---|---|
| 生成参数层 | 描述输入配置 | 种子、迭代深度、角度、长度比 | 提供配置对象 |
| 结构表示层 | 生成分形拓扑 | 节点树、分支列表、父子关系 | 提供分支列表 |
| 渲染数据层 | 输出可绘制图元 | 线段、图元、颜色、宽度 | 提供图元列表 |
设计这个分层时,我有一条原则:层与层之间不许直接传裸dict,不许跨层访问内部变量。每层只能通过定义好的数据类做单向传递。这条约束对AI协作特别关键——AI生成代码时,会自动沿着你定义的数据边界走,不会随手新建一个字典绕过接口。
2.2 分形结构为什么天生适合“树形契约”
分形有两个本质特点:自相似和递归。这两个特点决定了它特别适合用树形数据结构表达。一棵树的每个节点都可以继续带子节点,子节点又和父节点保持同样的结构关系——这就是“自相似”在数据层面的直接映射。
我在前几期实验里用过两种写法。第一种是turtle式写法:递归函数里直接画线、转弯、再递归。代码短,看起来很爽,但数据是“流式”的——每一层递归的状态都散落在调用栈里,没有实体数据结构。第二种是先把所有分支信息收集到列表里,递归结束之后统一交给渲染器。
第二种写法一开始我觉得“多此一举”,但配合AI协作之后,优势非常明显。递归函数只需要负责“返回一个包含子分支的列表”,渲染器只需要负责“遍历列表画线”。每个函数的输入输出都极其明确,AI不需要猜。而且分形树这个场景里,分支对象天然有父子关系,用树形结构表达,几乎是零认知成本。
后来我把这个经验总结成一句话:递归逻辑要产生“数据”,而不是产生“副作用”。副作用式的写法人看着爽,AI看着懵;数据式的写法虽然多写几行,但人和AI都能一眼看明白。
3. 核心数据结构与契约落地:从dataclass到类型约束
3.1 定义分形节点:一个数据类撑起整个递归结构
我在Python里用dataclass把三个层次的数据契约固定了下来。这里直接把最核心的代码贴出来,你可以直接抄走改改用:
from dataclasses import dataclass, field from typing import List, Optional, Tuple @dataclass class FractalConfig: """生成参数层契约""" axiom: str # 初始公理,例如 "F" rules: dict # 重写规则,例如 {"F": "F[+F][-F]"} iterations: int # 迭代次数,控制递归深度 angle_deg: float # 基础转角,单位是度 length: float # 主干长度,单位是像素 length_ratio: float # 每级子枝相对父枝的缩放比例 min_length: float = 2.0 # 终止条件:长度小于该值就停止 @dataclass class BranchSpec: """结构表示层契约:一棵分支节点的完整描述""" start: Tuple[float, float] # 起点坐标 angle_deg: float # 绝对角度,单位度 length: float # 长度 depth: int # 当前深度 children: List["BranchSpec"] = field(default_factory=list) # 可选的父节点引用,用于回溯调试,不参与渲染 @dataclass class RenderPrimitive: """渲染数据层契约:绘制器只认这个""" kind: str # "line" 或 "dot" start: Tuple[float, float] # 起点 end: Tuple[float, float] # 终点 color: Tuple[int, int, int] # RGB颜色 width: float # 线宽这段代码我写的时候特别注意了几件事。每层一个数据类,职责单一;字段类型全部标注清楚,坐标用元组但明确写出是(x, y);单位在注释里写死,避免弧度角度互相踩。
这些细节在人和人协作时可能不算什么,但在AI协作里是决定性的。AI读到angle_deg这个字段名,再看到注释里写着“单位是度”,它生成后续代码时大概率会保持这个语义。如果你只写angle,它就可能按弧度去处理。
3.2 数据契约的三不原则:别让AI猜,别让模型含糊
光定义数据类还不够,我在实操中总结出了三条“契约设计原则”,违反任何一条都会在AI协作阶段付出成倍的返工成本。
第一,不传裸元组。分形树早期的代码里,我用过(x, y, angle, length)这样的元组表示分支。单看还好,一旦代码超过50行,谁分得清(1, 2, 90, 30)里哪个是坐标哪个是角度?AI更分不清。改成BranchSpec之后,字段名自带语义,AI一眼就能理解每个值的含义。这个原则扩展到任何数据模型:超过两个字段就别用裸元组,超过五个字段就该考虑数据类。
第二,不做隐式状态。有一版代码我用全局变量维护当前角度和当前深度,递归函数里直接读写全局变量。AI改写其中一个函数时,根本不知道还有全局状态在影响结果,改出来的代码看着正常,跑起来全乱。后来我把角度和深度全部放进BranchSpec里显式传递,问题立刻消失。隐式状态对AI来说是黑盒,契约设计必须把所有状态显式化。
第三,不做无边界递归。L系统如果不设终止条件,字符串会指数级膨胀,程序直接爆炸。我在FractalConfig里显式加了min_length和iterations两个终止条件,并规定递归时必须同时检查这两个阈值。这样AI生成的递归代码即使写得很“激进”,也会被这两个阈值拦住,不会出现无限循环。
这三条原则本质上是在说同一件事:契约的颗粒度要足够细,所有影响行为的状态都要在数据结构上有明确位置。AI不会主动问“这个参数是什么”,它只会照着你的字段名和注释猜,你给得越清晰,它猜得越准。
4. 和AI协同编程的实战:契约对了,代码质量直接跳档
4.1 提示词里的契约:两种写法,两种结果
很多人以为AI写代码靠的是“咒语”,其实不是。同一个AI工具,给它不同的数据上下文,产出质量可能差几个档次。我用同一个L系统分形需求做过对比。
第一次,我用的提示词是:“用Python实现L系统分形树,使用递归绘制,支持分支角度和长度控制。”
AI给了我一个用dict传参的实现,所有配置都塞在一个大字典里,没有类型定义。功能能跑,但我想加“子枝随机倾斜”功能时,它改了一处,另一处就崩,因为字典里的键在多个函数间共享,它分不清哪些地方要同步改。
第二次,我换了个方式。先把前面那段dataclass代码贴进对话,然后加一句:
“请基于以上数据结构,实现L系统规则解析函数,输入FractalConfig,输出BranchSpec列表。”
AI这次生成的代码几乎零修改跑通。因为它的所有推断都被限制在我定义的数据边界里,它不需要猜angle到底是什么,不需要猜返回格式是什么,只需要专注实现算法逻辑。
这两种写法的差距,本质是“让AI自由发挥”和“让AI在契约内执行”的差距。后者看起来限制更多,但产出的代码反而更可靠,因为数据层的错误空间被压缩到了极致。
4.2 迭代流程:让AI改逻辑,别让AI改契约
和AI协作的时间一长,我形成了一个固定工作流:契约文件先行,AI实现随后。
具体操作是:先把所有数据类定义放在一个单独的contracts.py文件里,每次让AI改代码之前,先把这份契约文件贴给它,让它基于契约写实现。需要扩展功能时,我先自己改契约文件,再让AI基于新契约重构相关函数。
我举一个真实例子:给分形树增加“随机角度扰动”。原始契约里只有angle_deg一个字段,AI实现的所有旋转逻辑都会引用它。如果直接跟AI说“加一个随机扰动”,它大概率会自己随便造一个字段出来,或者复用现有参数导致语义混乱。
我的做法是先把契约改成这样:
@dataclass class FractalConfig: ... angle_deg: float # 基础转角 angle_jitter: float = 0.0 # 转角随机扰动范围,单位度然后把新契约发给AI,告诉它:“请在结构生成逻辑中,让每个子分支的角度在angle_deg ± angle_jitter范围内随机取值。”
这样AI改动的位置就很精准——它不会去动FractalConfig的其他字段,也不会去动渲染层,只会修改结构生成层的角度计算逻辑。改动完之后回归测试基本没有意外。
这个工作流的核心是:让AI在给定契约内做局部逻辑改动,而不是让AI自行扩展或重构数据结构。因为数据结构的调整需要全局一致性,而AI的强项不在这。每次AI接手代码前,契约都已固化,相当于给了一次性给AI立好规矩。
5. 常见问题与排查技巧实录
5.1 “假清晰”的数据契约长什么样
数据模型定义出来了,不代表契约就清晰了。我在实验里遇到过三种“假清晰”的坑,每个都花了不少时间排查。
第一种坑叫“类型对了,取值范围没约束”。angle_deg: float看着很明确,但我一开始没规定它必须落在0到360之间。AI生成的随机角度逻辑,时不时冒出一个负角度或者1060度的值,画出来的分形树交叉重叠,完全没法看。后来我在契约注释里写明“所有角度需归一化到[-180, 180]”,并在解析入口做了一次校验,问题才根除。
第二种坑叫“结构对了,生命周期没约定”。BranchSpec里的depth字段,原本是为了调试方便加的。结果AI在某些逻辑里把它当作渲染层级,在某些逻辑里把它当作权重值,还差点用它做了颜色映射。这就是没在注释里说明“depth只表示结构深度,不参与渲染计算”的后果。
第三种坑叫“契约只覆盖了正常路径”。我的初始契约假设了每个树枝都有至少一个子分支,没有考虑叶子节点。AI在处理叶子节点时,生成了一堆空的children列表,渲染器遍历时明明没有数据却还调用绘图函数,输出了一些幽灵像素点。后来我在渲染层加了“空列表直接跳过”的逻辑,才彻底解决。
这三个坑的共同点是:字段名和类型看着都对,但语义、取值范围、边界行为没有约束到位。真正的契约约束的不只是“有什么字段”,而是“每个字段在什么场景下是什么含义”。
5.2 排查流程与速查表
数据契约设计得再好,调试时还是会遇到谜之现象。我的排查思路是:从渲染结果反推数据层。
如果画面整体错乱,优先怀疑结构表示层的数据有没有正确生成。这时候我一般会写一行测试代码,把BranchSpec列表dump成JSON看一眼,确认每个分支的坐标和角度是否连续合理。
如果画面局部缺失或断裂,优先怀疑递归生成逻辑。比如明明设置了6次迭代,输出却只有3层深,大概率是终止条件被AI误用了min_length而不是iterations,或者反过来。
如果画面完全乱成一团,比如树枝横七竖八地乱长,几乎可以断定是角度单位混了。要么是弧度角度混用,要么是起始角度没有归一化。
我把这些经验整理成了一张速查表,方便你遇到问题时直接对照:
| 症状 | 疑似契约问题 | 修复方式 |
|---|---|---|
| 树枝角度随机乱飞 | 角度单位混用或未归一化 | 统一用度,入口处校验范围 |
| 递归深度不够 | 终止条件优先级错乱 | 明确iterations和min_length的检查顺序 |
| 渲染时有多余像素 | 叶子节点空列表未处理 | 渲染层跳过空children |
| 添加新参数后旧逻辑崩溃 | 参数语义被AI扩展污染 | 先改契约文件,再让AI同步修改 |
| 数据生成正常但画面偏移 | 坐标起点或长度单位不一致 | 检查start坐标是否基于全局坐标 |
除了对照速查表,我还有两个调试中特别顺手的技巧。第一个是给每个BranchSpec打印结构化数据,用pprint输出,一眼能看出层级关系。第二个是把递归的层数作为前缀打印出来,比如depth=3的分支数据前面加三个缩进,这样能直观看到树形数据的嵌套是否对齐。
这两个技巧在和AI协作时尤其有用——你能直接把错乱的样例数据贴给AI,让它对照契约找问题。AI看你给的数据比看你描述的“好像哪里不太对”要靠谱得多。
6. 从分形实验带走的一点迁移经验
做完这期L系统分形实验,我最大的收获反而不是分形本身。数据契约这套思路,几乎可以平移到任何“人+AI”协作的编程场景里。
我现在写任何稍微复杂一点的模块,都会先花十分钟把数据模型写出来,再开始写逻辑。哪怕最终不直接交给AI实现,这个数据模型也能帮我理清思路。因为数据模型一旦清晰,功能拆分、模块边界、异常处理方案都会跟着清晰。
用AI编程的时候,更是如此。与其花时间琢磨“怎么让AI更聪明”,不如先想想“怎么把我要的东西定义得更准确”。AI不理解业务,但它能读懂定义清楚的数据结构。所谓协同,不是让AI替你思考,而是你替AI清除思考的障碍。
我个人现在的习惯是:每次新建项目,第一个文件永远是contracts.py,里面只放数据类和类型定义。后续所有功能开发,都围绕这个文件展开。谁要是问我AI编程有没有捷径,我一定先回复一句:先把你的数据契约定好,再谈其他。这一步省下来的返工时间,比任何提示词技巧都管用。