分形生成实验做到第六个坑,我发现一个比算法本身更重要的点:数据建模。用AI协同编程时,最初我以为只要把需求说清楚,模型就能给出可用的递归代码。试过几次后发现,最值钱的不是那句“画一个L-system”,而是我预先定义好的那组数据结构。它像一份契约,把AI的想象力约束在我能接受的边界里,让每次生成的代码都有一致性、可测试性,也让我敢把后续迭代继续交给AI。
如果你也正在用AI辅助写代码,尤其是写递归、生成式图形这类容易被“发挥”的代码,建议先花十分钟把数据结构定下来。它能帮你省下的不是十分钟,而是一整天。我见过不少开发者给AI讲了一堆需求,最后生成函数里的返回值甚至和调用处对不上,原因就是双方没有一份共同认可的数据约定。这篇文章不聊分形图形的美观程度,就聊数据建模如何在AI协同编程里扮演契约角色,以及我在实验中总结出的实操方法。
1. 为什么数据建模是AI协同编程里的头号契约
1.1 分形的自相似,映射到代码生成的自相似
分形图形最核心的特点是自相似:不管放大多少倍,都能看到和整体相似的结构。以L-system为例,它的生成规则很简单,一个公理,几条重写规则,加上迭代次数,就能从一条字符串长出树、雪花、蕨类植物。关键点在于,每一次迭代都严格遵循同几条规则,规则一旦确定,输出就具备可预期性。
AI协同编程也是同样的逻辑。每一次让模型生成代码,本质上都是一次“迭代展开”。如果你每次给的提示词里都没有明确的数据规则,模型就只能靠概率猜测,生成的结果自然不稳定。数据建模在这里扮演的角色,就是那套“重写规则”:它定义了某个结构长什么样、字段叫什么、类型是什么、允许怎么变化。规则越清楚,AI生成代码的自相似性就越高,代码风格和接口形态才能保持一致。
我第一次意识到这一点,是在做一个分形生成器的时候。当时我让AI生成一个“根据迭代次数绘制分形曲线”的函数,提示词写得很随意,结果第一次生成的是返回字符串,第二次生成的是返回坐标点列表,第三次干脆把所有坐标画进了一张图里。三个版本没法拼在一起用。后来我把数据结构写清楚,AI生成的东西才稳定下来。
1.2 没有契约,AI生成的代码迟早失控
很多人觉得AI协同编程难,难在“表达不清楚”。其实不是表达不清楚,而是没有给对方一个可执行的边界。你让AI生成一个绘制分形的函数,如果没有定义输入参数、返回类型、内部状态结构,它就会自由发挥。
自由发挥的典型表现包括:参数里同时出现size和scale,递归函数里既用depth又用iteration,返回的是数组还是集合全看心情。这些在小项目里还能靠手工修,一旦模块多了,数据流跨三四个文件,类型漂移就会变成一场灾难。
我用一个比喻来解释:如果你想让人帮你装修房子,只说一句“装得好看一点”,工人一定不知道墙面用什么颜色、插座装在哪、柜子做多高。数据建模就是那张施工图,把尺寸和材料都标清楚。AI协同编程里的“施工图”,就是一段段显式的数据结构定义、函数签名和类型标注。没有它,AI只是个“很会发挥的装修师傅”,不是靠谱的施工方。
所以我在分形实验里立了个规矩:写任何功能之前,先把数据结构定义完。让AI生成的每一个函数,都只围绕这些结构去实现。这个习惯一旦养成,后面所有麻烦都少了一大半。
2. 定义数据契约的三个关键层次(实操方法论)
2.1 把数据结构当成第一公民,而不是实现细节
大部分人在给AI写需求时,第一句是“帮我写一个函数”,第二句才描述功能。我现在的做法反过来,先展示数据结构,再提功能需求。数据结构不是代码的一部分,它是整个任务里最高优先级的约束。
以分形生成为例,我在实验中定义了一个最基础的模型:
from dataclasses import dataclass from typing import Dict, Tuple, List @dataclass class Point: x: float y: float @dataclass class LSystemModel: axiom: str # 起始公理,比如 "F" rules: Dict[str, str] # 重写规则,比如 {"F": "F+F--F+F"} iterations: int # 迭代次数,决定分形复杂度 angle_deg: float # 转弯角度 start_point: Point # 初始绘制位置 start_angle_deg: float # 初始偏转角度 segment_length: float # 每段基础长度 output_coords: List[Tuple[float, float]] = None # 最终坐标点列表这段代码看起来很简单,但它把整个任务的所有“边界条件”都固定住了。AI看到这个模型,就知道:公理必须是字符串,规则必须是一个字典,迭代次数是整数,输出最终要落到坐标点列表上。它不会自己去发明一个画布对象,也不会在函数里返回一张图片路径,因为这些都不在这个模型里。
实测下来,数据类型越显式,AI在补充实现细节时的正确率越高。如果你使用类型标注不强的语言,也要尽量在注释里把每个字段的取值范围和用途写清楚。模型本身,就是给AI看的第一份契约。
2.2 输入契约、输出契约、状态契约,一个都不能少
我把数据契约拆成三层,分别对应函数参数、函数返回值和内部状态。这个拆法在分形生成实验里特别管用,因为递归算法的内部状态最容易被AI搞乱。
输入契约:外部调用方给什么。比如LSystemModel就是生成函数的输入契约,它规定了公理、规则、迭代次数等所有初始条件。没有输入契约,AI很可能把规则写进函数内部,导致外部没法配置。
输出契约:函数返回什么。在分形生成里,最稳定的输出契约是“坐标点列表”。不管中间怎么递归、怎么平移坐标,最后返回给渲染层的永远是List[Tuple[float, float]]。AI只要牢牢记住这个输出结构,就不会生成乱七八糟的返回值。
状态契约:递归过程中传递什么。很多生成算法需要维护当前坐标、当前方向、当前深度。如果你不定义状态结构,AI可能会用三个全局变量,或者把状态塞进一个字典里。我的做法是定义一个内部状态类型,比如:
@dataclass class TurtleState: point: Point angle_deg: float depth: int状态契约的好处是让递归函数的每一步都有迹可循。AI在写循环或递归时,只需要更新TurtleState,不需要额外设计一套状态方案。
2.3 用校验和测试,把契约变成强制约束
仅仅定义了数据结构还不够,AI生成的代码不会自动遵守它。你需要把契约变成强制约束,最简单的方式是加一层校验。
我通常会在模型里加一个__post_init__,对关键字段做检查:
from dataclasses import dataclass @dataclass class LSystemModel: axiom: str rules: Dict[str, str] iterations: int angle_deg: float segment_length: float def __post_init__(self): if self.iterations < 0: raise ValueError("迭代次数不能为负数") if not self.axiom: raise ValueError("公理不能为空") if not self.rules: raise ValueError("规则不能为空")这样无论AI生成的代码怎么调用,只要传入非法参数,程序会在最早阶段报错。错误出现得越早,定位成本越低。如果你在项目里引入了类型校验库,也可以用类型校验库的注解来完成这件事。核心原则只有一个:契约不能停留在注释里,必须能被机器执行。
我不太建议让AI自己写校验逻辑,因为AI往往会生成一堆“看起来严谨但实际上没检查关键约束”的代码。校验逻辑最好由你预先定义好,让AI围绕它去实现业务逻辑。
2.4 契约的调整必须走版本迁移,而不是原地改
数据建模过程中,最容易被低估的环节是变更管理。分形实验做到第三版时,我发现LSystemModel少了颜色映射,想在里面加一个palette: List[str]字段。这时候如果直接把字段加上,让AI继续生成代码,它可能会把旧代码里的颜色逻辑和新模型混在一起,生成一堆四不像。
正确做法是先定义迁移路径。比如旧模型有angle_deg,新模型想改成angle_deg和turn_angle_range两个字段,那就不要在一夜之间删掉旧字段。可以保留旧字段,并新增可选字段,然后让AI在函数注释里标明“当前版本兼容旧模型”。
另外一个实用建议是:每轮AI对话,都重新粘贴一遍完整的模型定义。千万不要说“沿用上一次的模型”,AI的上下文会漂移,尤其当会话很长时,它可能记住的是中间某个版本的模型。我开始使用“提示词模板+完整模型定义”之后,生成质量有了明显提升。
3. 完整跑通一次分形模型的AI协同编程
3.1 搭建一个可复现的L-system数据模型
我用一个具体案例来说明整套流程。目标是写一个分形生成器,能从公理和规则生成坐标点,并用于后续绘制。我先把所有数据结构定义好:
from dataclasses import dataclass, field from typing import Dict, List, Tuple @dataclass class Point: x: float y: float @dataclass class TurtleState: point: Point angle_deg: float depth: int @dataclass class LSystemModel: axiom: str rules: Dict[str, str] iterations: int angle_deg: float segment_length: float start_point: Point = field(default_factory=lambda: Point(0.0, 0.0)) start_angle_deg: float = 0.0这个模型不包含任何图形渲染逻辑,只负责描述“一个L-system分形生成任务需要哪些数据”。为什么要这么做?因为渲染是另一个关注点,混在一起会让AI分不清职责。数据模型越纯,契约越稳定。
我写这个模型的时候,特意把start_point设成有默认值,并用field(default_factory=...)生成一个新的Point。这个细节很重要,如果直接用Point(0.0, 0.0)作为默认值,所有实例会共享同一个对象,一旦某个地方修改了坐标,其他实例也会被影响。这是Python里常见的坑,AI也经常犯,不如一开始就写对。
3.2 把模型写进AI提示词,让契约变得可见
模型定义好之后,我不会直接发一句“帮我写分形生成逻辑”,而是会构造一个结构化的提示词。这个提示词的核心是:先放数据契约,再放任务要求,最后放验收标准。
下面是简化版提示词模板,我在实验里几乎每次都用到:
请根据以下数据结构,实现一个分形图形坐标生成函数。 数据契约(不可修改): - 输入:LSystemModel 类型 - 输出:List[Tuple[float, float]] - 内部状态:TurtleState 约束: - 递归过程中必须逐字扫描当前字符串,遇到规则中的字符则展开,遇到非规则字符则原样保留。 - 计算坐标时,只使用模型中的 segment_length 和 angle_deg。 - 迭代次数为 0 时,返回公理对应的单点线段坐标。 - 不要引入额外字段。 - 生成代码必须附带一个最小可运行示例。 实现语言:Python这套模板的关键点在于“数据契约(不可修改)”这几个字。AI倾向于遵循显式指令,如果你不说“不可修改”,它很可能顺手给你加一个颜色参数或图片尺寸参数。明确声明不可修改之后,生成结果基本不会再额外发明字段。
3.3 从模型到递归展开:AI生成的代码长什么样
下面是我在实际实验中,基于上述提示词得到的一份核心代码思路。代码不一定要完全一致,但结构是稳定的:
def expand_lsystem(model: LSystemModel) -> str: current = model.axiom for _ in range(model.iterations): next_str = [] for ch in current: next_str.append(model.rules.get(ch, ch)) current = "".join(next_str) return current def lsystem_to_points(model: LSystemModel) -> List[Tuple[float, float]]: instructions = expand_lsystem(model) state = TurtleState( point=Point(model.start_point.x, model.start_point.y), angle_deg=model.start_angle_deg, depth=0, ) points = [(state.point.x, state.point.y)] for ch in instructions: if ch == "F": import math rad = math.radians(state.angle_deg) new_x = state.point.x + model.segment_length * math.cos(rad) new_y = state.point.y + model.segment_length * math.sin(rad) state.point = Point(new_x, new_y) points.append((state.point.x, state.point.y)) elif ch == "+": state.angle_deg += model.angle_deg elif ch == "-": state.angle_deg -= model.angle_deg # 其他字符不改变坐标 return points注意这里expand_lsystem做了字符串展开,lsystem_to_points做了坐标计算。两个函数都严格使用模型字段,没有自己发明新的配置项。这就是契约的价值:你从代码里能一眼找到iterations、segment_length、angle_deg,所有数据来源都清晰可追溯。
我测试的时候会传一个最小模型,比如公理"F"、规则{"F": "F+F--F+F"}、迭代次数2、角度60。AI生成的代码在给定输入下输出固定长度的坐标点列表。这个测试一旦通过,就可以放心交给渲染层使用。
3.4 扩展模型:改契约,而不是让AI硬改代码
分形实验做到后面,自然要扩展。我想让不同的分形使用不同的颜色映射,于是决定给模型增加一个颜色字段。这次我没有说“帮我在代码里加个颜色”,而是先把模型改掉:
@dataclass class LSystemModel: axiom: str rules: Dict[str, str] iterations: int angle_deg: float segment_length: float palette: List[str] = field(default_factory=list)然后在提示词里加上一句:“渲染阶段需要根据坐标点在 palette 中的索引来着色,但坐标生成函数只负责返回点和对应索引。”这样AI就会明白:调色板只影响渲染,不影响坐标计算。
实测下来,通过修改数据契约来扩展功能,比直接让AI改代码要稳定得多。原因很简单,AI在理解“加一个功能”时,往往会重新设计整个代码结构,而如果你只让它围绕新模型做局部修改,它反而更保守、更可靠。
4. 常见问题与排查技巧实录
4.1 AI生成代码时凭空发明字段
我在实验中最常见的问题是:模型里明明没有image_width,AI却在渲染代码里写了一个model.image_width。问它为什么,它会说“我以为你需要设置图片宽度”。原因就是契约在提示词中不够突出,或者你用了“渲染”“图片”这些词,触发了它默认的画布逻辑。
解决办法很直接,第一,提示词里用大括号强调“数据契约中不存在的字段一律不允许使用”。第二,把模型定义放在任务描述之前,并在定义后用一句话点明:“后续所有函数必须只使用这些字段。”第三,在代码审查时先搜索可疑字段名,比如size、width、color,看它们是否属于模型。
我踩过一次坑之后,现在每次都会把“不得引入额外字段”直接写进提示词。这一句看起来不起眼,却能过滤掉大多数幻觉字段。
4.2 递归函数没有终止条件
AI生成递归代码时,容易忽略终止条件。尤其当模型里同时有iterations和depth两个概念时,它可能不知道该用哪个来控制停止。
我的模型里一开始就在TurtleState中设置了depth,但depth其实没有参与终止判断。后来调试时发现,AI生成的递归函数一直递归到内存爆掉。排查之后发现它用的是depth,但迭代次数是iterations,两者没有关联起来。
解决方案是修改模型注释,把职责写明白:
@dataclass class TurtleState: point: Point angle_deg: float depth: int # 仅用于记录递归深度,终止条件统一使用 model.iterations加这句注释后,AI生成的递归函数就正确使用model.iterations来停止递归了。这个细节让我意识到,数据模型的字段注释不是写给人类看的,更多是写给AI看的。注释也得当成契约的一部分来维护。
4.3 契约版本漂移,新代码和旧代码对接不上
分形实验做到第五版时,我把angle_deg从“浮点数字”改成了“可选浮动范围”,结果之前生成的一批测试数据全部失效。原因是我没有给AI提供新版本契约的迁移说明,它按照老直觉生成了新代码。
之后我养成了两个习惯。第一个是每次修改模型后,把旧模型复制到一个legacy_models.py文件里,然后在新模型周围写一个注释说明“这是第几版,废弃字段是什么”。第二个是每轮新的AI对话,都在开头完整粘贴当前模型定义,绝不说“沿用上一个消息”。
版本漂移问题最隐蔽,因为AI生成的代码语法上没错,但数据语义已经变了。唯一的预防方式就是让每次对话的上下文里都有完整且最新的契约。
4.4 缺少兜底校验,契约变成一纸空文
如果你的AI生成的代码已经偏离契约,但你又不想手工改代码,那至少要有兜底校验。我在分形实验里加了一个简单的校验函数:
def validate_points(points: List[Tuple[float, float]]) -> None: if not points: raise ValueError("坐标点列表为空") for p in points: if len(p) != 2: raise ValueError(f"坐标点格式错误: {p}") if not all(isinstance(v, (int, float)) for v in p): raise ValueError(f"坐标值类型错误: {p}")每次AI生成新函数,我都会让它先跑一遍这个校验。如果通不过,直接让AI根据报错信息修正。这一步可以把大量潜在问题挡在渲染之前,极大降低排查成本。
| 常见问题 | 根本原因 | 我的解决方案 |
|---|---|---|
| 凭空发明字段 | 契约不够显眼 | 模型定义前置,明令禁止额外字段 |
| 递归不终止 | 终止条件职责不清 | 在状态注释里写明统一用哪个字段 |
| 模型版本漂移 | 上下文缺少最新契约 | 每次新对话重新粘贴完整模型定义 |
| 生成的坐标不可信 | 缺少运行时校验 | 定义校验函数,让AI修正到通过为止 |
5. 写在实验之后:数据建模的真正价值
做分形生成实验做到第六篇,我对AI协同编程的认知已经变了很多。最开始我把它看成“用自然语言换代码”,后来我发现,代码质量根本不取决于语言描述有多花哨,而取决于你给对方划定了多明确的边界。数据建模就是划定边界的那支笔。
我现在写代码前会先用几分钟把数据结构画在纸上,哪怕只是几个方框和箭头。画完之后,再把它们翻译成代码里的dataclass和类型标注。这一步做完,后面的AI提示词几乎可以照着数据结构抄一遍,生成的代码也很少跑偏。
我自己在实验里踩过的最大坑,是把AI当成一个“能理解我意图的搭档”。事实上,它更擅长执行一份清晰、无歧义的说明书。数据建模就是那份说明书最核心的章节。你越早把这个章节写清楚,后面的迭代就越顺。
最后再分享一个我现在坚持的小习惯:每次改动数据模型,都会先跑一遍校验器和测试用例,再让AI继续生成后续代码。这个流程看起来多花了十来分钟,但省下的返工时间往往是按小时计算的。分形之美来自每一次迭代都遵守同一条规则,AI协同编程的高质量,本质上也是同一个道理。