☰
AI编程中数据建模即契约:从分形项目看如何约束代码生成
2026/10/21 16:58:13 网站建设 项目流程

分形生成实验做到第六期,我决定暂时停一下渲染器优化,回到数据建模这件事上。越是和AI协同编程深入,越发现一个被严重低估的东西:契约。这里说的不是人际契约,而是一种你和AI之间对数据结构、字段含义、边界条件达成共识的方式。代码写错了AI会报错,模型定错了AI是沉默的——它只会顺着你的错误模型继续生成错误代码,而且看上去一切正常。这篇就是我在分形项目里把“数据建模当契约用”的完整记录,包括我踩过的坑、最终沉淀下来的模板,以及从分形延伸到日常项目的通用打法。

1. 第六期实验回头补课:不做渲染,先收拾数据模型

1.1 前五期挖的坑:能用但难改

先交代一下背景。这个分形生成实验是我个人的一个长期模拟项目,前几期分别做了Mandelbrot集合的缩放漫游、Julia集合变体生成、分形树的递归绘制、GLSL着色器调优,以及多线程分块渲染。每一期都让AI编程助手参与了不少代码生成,单看功能它完成得都不错,Mandelbrot能放大、Julia能换种子、渲染速度也提上去了。

但问题出在第五期结束之后。我打算把前几期的代码合并成一套可复用的渲染管线,结果发现每期生成的模块都有各自的“脾气”。缩放漫游模块自己维护了一套坐标映射,把视图中心和缩放系数存成两个浮点数数组;分块渲染又另搞了一套矩形区域结构,字段名是region,里面塞着四个整数;到GLSL着色器那边,它干脆把所有参数都传成一个扁平的uniform vec4,注释里写着“第0位是iteration,第1位是zoom”。当时跑单功能都行,一旦要串起来,就得在各模块之间写大量的适配代码,几乎每个调用点都要猜测对方的数据到底长什么样。

这就是我在第六期给这个系列定的调子:不写新功能,先回头把数据模型统一掉。表面上是重构,实际上是把之前那些“隐性的、各写各的”数据结构约定,全部改成显性的、可检查的契约。

1.2 从“生成代码”到“生成结构”的思路转弯

很多人在用AI编程的时候,注意力都放在“让AI写函数”上,给一句需求让AI生成了几百行代码就觉得很爽。但这里有个误区:AI生成的代码质量,很大程度上取决于你对“结构”的约束。你要求它“写一个分形渲染器”,它会自己发挥;你要求它“在给定的数据模型上实现一个分形渲染器”,它的发挥空间就被限定在了一个安全范围内。

这一期我做的第一件事,就是把项目里所有跨模块流动的数据抽出来,逐个命名、定义字段、定取值范围。这个过程非常像给一个临时的、水平忽高忽低的协作者画施工图:我不会让它去决定承重墙在哪,我只会告诉它墙的高度和厚度必须是多少,剩下的装修它可以自由发挥。

如果说前五期教会我的是“AI能写出能跑的代码”,那第六期教会我的就是“AI能写出能从数据结构上保持一致性的代码,前提是你先给它一份足够清晰的建模”。这期实验本质上就是在验证这个判断。

2. “契约”在这件事里到底指什么:一份给AI看的边界声明

2.1 隐性契约与显性契约

写代码的时候,我们其实一直在和人、和未来的自己签契约。函数签名是契约,类的公共字段是契约,API的返回结构也是契约。没有契约的代码不是不能跑,而是没法在团队里长期演进——今天的你能读懂昨天的自己,三个月后的你未必想读懂。

在AI协同编程里,这个逻辑被放大了。普通的IDE里代码是静态的,你推理数据流靠上下文;而AI是生成式的,它在每一轮对话里都在“猜”你下一步想要什么结构。如果项目里的数据模型处于“只可意会”的状态,AI就会按照它自己见过的通用模板来猜。它猜对的概率,比你想象的低得多。这就是隐性契约的风险:你和AI各自认为的数据结构根本不在同一张图纸上。

2.2 为什么AI编程里数据建模容易被低估

被低估有两个原因。一是“入门门槛不高”:写一个渲染循环、调一个颜色映射,出效果很快,很容易让人误以为难点在算法不在数据。二是“数据建模的收益滞后”:今天定好的结构,可能要等三四个迭代之后才会体现价值,而AI编程天然鼓励快速出活,人很容易跳过建模直接要代码。

我在实际项目里的感受是,数据建模在整个AI协同流程里,是最不像“编程”的环节,却最能决定成败。算法选错可以换,渲染思路错了可以推翻,唯独数据结构一旦被AI在代码里铺开,修改成本是指数级上升的。它可以渗透进十几个函数、跑进缓存逻辑、影响序列化和热更新。等到你想改一个字段名的时候,你面对的是AI一遍遍生成过的所有连带调用。

2.3 LLM的会话特性决定数据契约必须外置

还有一个很现实的技术原因:主流AI编程助手的会话记忆是碎片化的。你在对话开头定义了FractalParams有五个字段,但几轮之后它在写另一个模块时,很可能只记得“有一个参数对象”,然后顺手就自己新造了一个RenderSettings,字段还都对不上。不是AI笨,而是它本质上是在做概率预测,你放在对话上下文里的约束会随着距离变远而衰减。

所以,数据建模在AI协同里的真正作用,是把你脑子里的隐性约定,变成一段稳定的、存在于项目里的文本,让AI每次读到代码时都能看到这份契约,而不是依赖它记住上上轮你说过的话。这就是显性化的价值:它不依赖AI的“记忆”,只依赖AI的“阅读”。

3. 我在分形项目里实际写下的那套“契约”长什么样

3.1 分形项目的数据结构——先定义坐标与像素的分离

我先把整套渲染管线的核心数据流列了出来。分形生成从输入到输出,大致经过坐标映射、迭代计算、颜色映射、像素写入四个环节。为了让AI在任何一个环节都不会搞乱数据,我把每个环节之间流动的“信封”单独定义成类型。

第一组是视口与几何参数。我定义了一个不可变的Viewport,它只负责描述“我们想看复平面上的哪一片区域”,包含center_real、center_imag、half_width、half_height四个字段,分别代表中心点的实部、虚部和半宽高。为什么不用bounding_box?因为我发现AI在处理“半宽高”时不容易犯符号错误,而处理“左上角+右下角”时总是把缩放算反。

第二组是颜色映射的控制点。我把它从渲染参数中单独拆出来,定义一个ColorMap,它的核心是stops数组,每个stop包含position和color,插值方法只能是线性或样条。这一步的建模决定了后续着色器代码能不能做到“参数与逻辑分离”——颜色怎么插值属于数据,不属于算法。

第三组是任务描述。RenderTask把一个完整的渲染请求封装起来:它引用一个Viewport、一个ColorMap、一个最大迭代次数,再加上输出图像的宽度和高度。

第四组是像素结果。我不让AI把渲染结果直接做成一个“图像对象”,而是用一个带坐标的PixelRecord结构,字段是x、y、iterations、rgb。每个像素都是独立的,分布式计算和分块渲染都基于这条记录展开。

这套建模最核心的意图,是把“几何世界”和“屏幕世界”彻底分开:凡是描述复平面上坐标的,都不允许混入图像宽高;凡是描述输出图像的,都不允许假设输入坐标。AI如果在此之前自己发挥,特别喜欢在渲染函数里用屏幕像素坐标去推迭代初值,导致放大之后画面错位。现在这条边界被数据模型直接锁死了。

3.2 用不变式锁死AI的自由发挥空间

光定义字段还不够,因为AI可能给你传一个zoom=0,或者传一个空的颜色控制点数组,然后它的代码在外面就炸了。我后来在契约里加了一块“不变式”部分,这是数据建模里最有威力的几条约束。

我规定:Viewport的half_width和half_height必须同时为正;ColorMap至少要有两个控制点;RenderTask的max_iterations必须介于1和10000之间;PixelRecord的rgb三个通道必须落在0到255闭区间。这些规则看起来简单,但放到AI协同里效果非常明显——AI生成代码时会倾向于“不破坏规则”,因为你在契约里已经用自然语言明确描述了“违反后会产生未定义行为”。

我还把坐标映射的换算关系做成了契约里的公式注释:复平面坐标等于center + half_size * (pixel / resolution - 0.5)。我知道这个公式写进注释里会让AI生成的代码几乎没有第二种写法,这正是我要的:该统一的逻辑统一掉,该自由的自有,不要把算法的自由度浪费在这种容易出错的小地方。

3.3 一份可以直接粘进prompt的契约文本

我专门维护了一个CONTRACT.md文件放在项目根目录,然后又从里面摘出一段精简版,每次让AI写新模块时直接粘到prompt里。精简版长这样:

项目契约(用于AI代码生成): 1. 所有跨模块数据必须使用以下类型,禁止在函数内部新造结构体: - Viewport: center_real:float, center_imag:float, half_width:float>0, half_height:float>0 - ColorMap: stops:List[ColorStop], interpolation:"linear"|"spline" - RenderTask: viewport:Viewport, colormap:ColorMap, max_iterations:int(1..10000), width:int, height:int - PixelRecord: x:int, y:int, iterations:int, rgb:tuple[float,float,float] 2. 复平面坐标换算统一使用:z = center + half_size * (pixel / resolution - 0.5) 3. 渲染函数必须是纯函数,只接收RenderTask,返回List[PixelRecord],不得读写全局状态。 4. 禁止修改契约中已有字段的含义,需要扩展时先讨论字段设计再写实现。

这段文本最厉害的地方是第四条。它让AI在准备自己发明新结构的时候,至少会先“询问”一下要不要绕开契约,而不是直接自作主张。后面我实测下来,AI生成时遵守契约的次数明显提升,甚至有一两次它主动在注释里标注“此处按契约扩展了新字段”。

4. 反面案例:没有契约时AI悄悄替我做了多少决定

4.1 一次翻车实录:动画插值项目里的“结构失序”

在认真建立契约之前,我曾经让AI写过一个分形漫游动画模块。需求很简单:给一组Mandelbrot缩放的关键帧,按时间插值,把每帧渲染出来。我当时的prompt很随意,大概意思就是“帮我写一个分形漫游动画,支持关键帧插值,能输出图片序列”。

AI很兴奋地开始了。它自己设计了一套FractalNavigator类,负责存关键帧和当前时间;一个Interpolator类,负责做补间;一个FrameCache类,负责缓存已渲染帧;还有一个AnimationRecorder,负责把帧合成视频。这是典型的AI式过度设计:类名精致、结构听上去合理、函数之间还有注释。

真正的问题出现在数据格式上。FractalNavigator内部存的关键帧是嵌套字典,里面既有浮点数列表又有字符串标记;FrameCache缓存的对象直接复用了渲染输出的numpy数组,没有定义任何边界;最离谱的是Interpolator的插值结果会莫名其妙地和FrameCache的索引绑定在一起,因为AI把“当前帧号”存在了FractalNavigator的同一个对象里。我当时没太在意,觉得类名都挺一致的,能跑就行。

4.2 顺着内存暴涨追出来的排查链路

跑了大概500帧之后,内存开始以肉眼可见的速度膨胀,动画帧率也掉得厉害。我先用tracemalloc做了个内存快照,发现大量内存堆积在FrameCache里——这个缓存本来应该只保留最近5帧,结果它的淘汰逻辑因为字段名写错,从来没有触发过淘汰。

再往下追,问题更深。插值器输出的viewport.zoom是浮点数,但FractalNavigator里存的是列表,每次插值返回的都是新的列表对象,而渲染器拿着这个列表再去做坐标映射,出现了一个隐式的类型转换:列表被当成数组除了一下,中心点坐标全都偏移了几个像素。所以动画看起来在缩放,实际在缓慢地漂移。这个漂移不是因为算法错误,而是因为“坐标的对象”从没有在数据结构上被显式定义出来,AI在每一个交叉口都替我做了一个它认为“理所当然”的决定。

这次排错花了我整整一个下午。回过头看,问题根源根本不在算法精度,而在我没有给AI一份契约:我没有说Viewport必须有四个明确命名的浮点字段,没有说插值结果必须和缓存输入是同一个类型,没有说动画模块之间只允许通过RenderTask传递数据。AI用它的“常识”把这种缺失补齐了,而这种补齐害了我。

4.3 典型症状对照表

经过那一次,我总结了一张“没有契约时AI代码会出现的典型症状”对照表,现在做代码评审时随手就能用上:

症状本质原因契约缺失点
同一份坐标数据在多个模块里类型不同AI在不同上下文里各自猜测字段类型没有定义跨模块的公共类型
缓存、全局变量、对象内部状态纠缠AI为了省事把数据状态藏进类内部没有规定“跨模块数据必须显式流动”
修改一个参数导致三个文件行为异常字段含义被AI在不同位置赋予不同解释没有给出字段语义的显式说明
运行能过、结果缓慢漂移隐式类型转换或坐标单位混用没有锁死坐标换算公式
每次重新生成代码,结构都和前次不同AI每次都在重新猜测结构没有把契约作为prompt固定上下文

这张表现在成了我和AI协作的标准检查项。凡是新模块跑完,先不急着看功能,先对表检查结构有没有失控。

5. 把契约做成活文档:校验器、样例与AI的配合流程

5.1 契约不是纸面文章:用校验器让AI看到约束

契约文本写得好,但AI毕竟是概率模型,它可能在情绪高昂的时候忽略你写的那句“必须大于0”。所以我又做了一层代码层面的校验器,把这些规则变成可执行的断言。

我用Python的dataclass实现了这些类型,然后在__post_init__里做检查。比如Viewport的校验逻辑:

from dataclasses import dataclass @dataclass(frozen=True) class Viewport: center_real: float center_imag: float half_width: float half_height: float def __post_init__(self) -> None: if self.half_width <= 0 or self.half_height <= 0: raise ValueError("Viewport的半宽和半高必须为正数") if not all(map(lambda v: v == v, [self.center_real, self.center_imag])): raise ValueError("中心点坐标不能为NaN")

这个校验器的存在不是为了防AI,是为了防我自己在跟AI协作时过于乐观。只要数据在进渲染函数之前先过一遍校验,AI生成出来的调用点如果传了错误结构,马上就会暴露。对一个动辄生成上百行代码的AI来说,清晰可执行的报错信息,比任何口头叮嘱都更有引导力。

我还注意到一个细节:把校验器写进项目代码之后,AI在后续生成中会“读到”这些检查逻辑,然后自动规避一些明显违反约束的写法。这等于把契约从prompt扩展到了代码环境里,AI的注意力机制会自然被这些断言吸引。

5.2 fixture样例与验收方式

为了让AI更有效地理解契约,我还在项目里建了一个fixtures目录,放了三个经典样例:一个标准Mandelbrot缩放视图,中心位于复平面的某个经典位置;一个Julia种子特写,坐标围绕某个特殊值;一个负空间边界视图,专门测试坐标出界时渲染器会不会崩。这些样例不是用来做单元测试的,是用来做“契约运行的图像化证明”的。

每次让AI写完新功能,我都要求它先跑一遍三个fixture样例,把输出图片贴回来对照。为什么要这样做?因为在跟AI协同的时候,文字协议再完善,也不如几张参考图稳定。AI在生成渲染代码时如果能对照“这个输入应该出这种图”,它调整参数的行为会收敛得非常快。而且,样例作为验收标准,让“契约是否被遵守”有一个可检查的判据,不是说“我觉得可以”,而是“渲染结果和fixture参照图一致才算通过”。

5.3 把契约放进prompt的顺序与表述模板

我后来的口诀是:先契约、再样例、后任务。prompt里前两段永远贴着契约文本和相关样例,第三段才写具体功能需求。一个典型的prompt格式是:

请阅读项目契约(见下方),确保所有代码只使用契约内定义的类型。 参考fixtures目录下的三个样例场景,如果你需要验证请先运行它们。 任务:在RenderTask到PixelRecord的转换之间,实现一个支持双线性插值采样的渲染器实现。 只修改渲染模块,不要改动契约类型定义。

这个顺序很关键。如果先把任务写成一大段,AI很容易陷入对需求的自由解读,等到提到契约时它已经决定了自己要用的数据形态。先契约、后任务,AI会把“按契约实现”当成前置条件而不是可有可无的参考。

我试过对比:同一需求,不带契约文本的prompt生成的代码,结构合理率大概只有一半;带契约的prompt,基本每次都能保持同一套结构,最多在函数命名上有点小差异。这就是“活文档”和“参考文档”的区别。

6. 从分形实验到日常项目:这套打法的适用边界与操作顺序

6.1 什么情况下值得做数据建模

因为分形项目的收益太明显,我后来把这套“数据建模先行”的方法搬到了几个日常项目里,包括一个图像处理Demo、一个跨平台小工具,以及一个简单的日志分析系统。实践下来,这套打法不是所有场景都值得上,但有一个很清晰的判断标准:只要AI需要写多个文件、状态流动超过三层、或者后续迭代超过一周,数据建模就值得先做。

反过来,一次性脚本、纯算法改写、两个函数之间的简单转换,就完全没必要建什么契约。那种场景AI的自由发挥反而更高效,你给它一个输入文件,它还你一个输出文件,中间结构无所谓。数据建模投入产出比最低的地方,就是“中间结果根本没有人看”的场合。

还有一类项目,建模的收益看起来很虚但实际很扎实:当你需要连续多天与AI协作同一个项目时。第一天你制定了数据结构,第五天你再让AI添加新功能,它看到项目里已经有明确的类型定义和校验器,就不会再重起炉灶。这个“长期记忆”不是靠对话历史,而是靠代码里的契约本身延续的。

6.2 操作顺序:模型先行、验证跟进、功能最后

我现在操办一个AI协同项目的基本顺序是四步,每一期分形实验的迭代严格按这个顺序走,效率高过之前随便开聊:

  1. 写数据模型:确定所有跨模块流动的类型,精确到字段名和类型,允许暂时不写实现。
  2. 写校验器:把不变式和取值范围写成代码断言,确保结构错误能在入口处暴露。
  3. 写样例输入:至少两个fixture,覆盖最典型和最容易出错的输入。
  4. 让AI实现功能:把契约文件、样例说明、任务描述按顺序粘进prompt,要求它不能改动契约定义。

这个顺序的精髓是“结构先行于行为”。AI在前三步里没有收到任何诱导它自由发挥的信号,等它终于开始写功能代码时,它的结构选择已经被钉在了非常窄的范围内,剩下的只是在填空和组合。它生成的代码会变得异常“听话”。

6.3 让AI反提案再拍板的工作方式

最后分享一个我后来养成的习惯。每次要新增跨模块的数据结构时,我不是自己拍脑袋定好就扔给AI,而是让AI先给我一份“结构提案”,列出它认为需要的字段、类型和理由,然后我来审阅、修改、拍板,再把最终版写进契约文件。

这个方法妙在两点。第一,AI在海量代码语料里见过各种模式,它提出的候选结构通常比我想得更全面,尤其是边界字段很容易被它补上;第二,因为最终的契约是我确认过的,一旦出了bug,责任判定非常清楚——不是我没约束,也不是AI瞎猜,而是契约本身设计有缺陷,修正方向明确。

在分形项目的第六期里,我就是用这套方式把整个渲染管线的数据流重新过了一遍。现在这个模拟项目终于长成了一个像样的架子:渲染算法、颜色方案、动画脚本和调度代码各自独立,数据流有迹可循,任何模块都能单独替换。接下来我准备在这个基础上继续做第七期的景深效果实验,这次不会再让AI替我决定场数据长什么样了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询