第一次跑通这个风格构建器的时候,我盯着预览 iframe 里那套暖灰色主题愣了几秒:比我手工调的样式更像人做的。这个项目名看着唬人,要解决的问题其实很具体——用户用一句话描述他想要的气质,系统生成一套可落地的颜色、字体、间距、圆角、阴影、动效,以及基础组件样式。真正动手之后我才发现,让单个大模型全流程硬扛,要么代码里混着多余的语义判断,要么流式预览乱成一团。最后我在 CF Inference 上把 GLM-5.3、Kimi K2.7 Code 和 GLM-5.3 Flash 拆成三段协作,才算把这条链路跑稳。这篇我会把这套架构、调用代码、踩坑过程摊开讲,适合正在做 AI 生成 UI 风格、设计令牌、低代码主题系统的人,或者想搞清楚多模型编排怎么落地的人。
1. 这个"风格构建器"到底在构建什么:先想清楚应用边界
1.1 "Lovable"不是可爱,是气质可量化
Lovable Style Builder 这个项目名听起来偏感觉、偏设计,但它落到工程上其实就是一件事:在"风格描述"和"可执行代码"之间架一座桥。风格在传统前端里是一堆设计决策:主色、辅色、文字层级、间距比例、圆角曲率、阴影深度、动效时长。把这些决策变成 CSS 自定义属性,后续页面所有组件都可以引用同一套设计变量,这才是"风格系统"而不是"一张好看的截图"。
举个例子。用户输入:"想要一个适合独立开发者工具站的清爽风格,偏蓝灰,清晰但不冰冷,有一点人文感"。GLM-5.3 解析出来的结构化中间结果大概是:
{ "summary": "blue-gray, calm, approachable", "foundation": { "color_axis": "low saturation, medium brightness", "warmth": 0.3, "contrast": "aa" }, "tokens": { "color_bg": "#f6f7f9", "color_text": "#1f2933", "color_accent": "#3b6ea8", "spacing_ratio": 4, "radius_base": 6 }, "keywords": ["clear", "calm", "humane", "developer-tool"] }为什么一定要这层结构?因为"偏蓝灰""人文感"这种描述,直接拿去让代码模型写 CSS,每次生成都会偏一个方向,今天偏冷、明天偏暖。先把语义钉到一个固定结构的 JSON 上,后续生成工具站版本、移动端版本、原型工具版本才可能保持同一套气质。这一步是整个链路最容易被跳过、也最值得做的地方。
1.2 为什么把一件事拆给三个模型,而不是让一个模型干到底
最初我只想用一个大模型完成"描述到代码"的全部工作。实测下来,单模型全链路的问题不是不会做,而是"所有工作揉在一起后,任何一处调优都会牵扯到另外几处"。语义解析需要低温度保证 JSON 稳定;代码生成需要高 token 上限、低幻觉;流式交互需要低延迟,只能用短 prompt 和小输出。这三个要求互相打架。
CF Inference 上 GLM-5.3、Kimi K2.7 Code 和 GLM-5.3 Flash 干脆是三个不同侧重的模型,拆开以后每个环节的 prompt 都变得很短,调一处不会影响另外两处。我当时给这个分工打了一个比方:GLM-5.3 是需求分析师,把用户嘴里含糊的"感觉"翻译成清晰的规格书;Kimi K2.7 Code 是前端工程师,拿到规格书老老实实写 CSS;GLM-5.3 Flash 是展厅接待员,用户指着某个按钮说"再圆一点",它只负责把这条指令转成最小修改单,真正动手改样式的仍然是程序本身。
1.3 程序在链路里不是旁观者
多模型编排最容易误解的地方是"所有事情都交给模型"。实际做下来,三个模型之间的缝隙里塞了大量规则代码:JSON 格式校验、缺失字段补全、CSS 可解析性检查、颜色对比度计算、非法样式注入过滤。这些工作如果交给模型做,又贵又不稳定;换成确定性的函数处理,几百毫秒内完成,且每次结果完全一致。
整体形态上,这个项目就是一个纯静态前端页面加上 Workers 无服务器函数,再加三个模型接口,最后在浏览器里放一个 iframe 预览沙箱。没有自建服务器,所有中间状态通过请求参数和 KV 短缓存传递。用户看到的是一个对话框加一个实时预览区,实际上背后每次操作都在跑一条三模型的流水线。
2. CF Inference 跑大模型的真实姿势:绑定模型与拉取方式
2.1 它不是一个"远程 GPU 服务器",而是按需拉起的执行沙箱
进入实现之前,先要理解 CF Inference 和一般模型 API 的区别。我在刚接触时把它当成一个普通的 HTTP 模型网关来用,结果发现它的延迟特征完全不同。这里的核心机制是:模型文件提前分发到各个边缘节点,请求到来时在离用户最近的节点拉起来执行。好处是平时延迟可以做到很低,坏处是如果一个节点还没缓存过模型,第一次请求会有明显的冷启动时间。
我在实测中发现连续调用同一个模型时,P50 延迟明显好于首次调用。一开始以为是自己代码写错了,后来看了几个请求的时间线才确认是冷启动规律。处理办法很朴素:加一个专门的预热接口,发布前或准备演示前主动打几轮,把模型"热"在目标节点上。如果做的是对外产品,还得接受现实:总有一些用户会碰到第一次调用的延迟,所以前端必须有 loading 状态和超时提示。
2.2 三个模型各自的角色与参数取向
三个模型放在同一条流水线里,各自的参数取向完全不同。我用一个表格记录下当时的配置,方便后面写代码时对照:
| 模型 | 链路角色 | 温度 | max_tokens | 主要输出 |
|---|---|---|---|---|
| GLM-5.3 | 需求解析与风格本体生成 | 0.2~0.3 | 800 | 结构化 JSON 风格描述 |
| Kimi K2.7 Code | 前端样式与设计令牌生成 | 0.2~0.4 | 4096 | CSS 代码和注释 |
| GLM-5.3 Flash | 流式反馈、局部改写指令 | 0.4~0.5 | 512 | JSON 补丁指令 |
选择依据不是谁比谁强,而是各管一段。GLM-5.3 在长指令的语义提取和中文表达理解上更稳,尤其是对"人文感""克制""温暖"这类抽象词;Kimi K2.7 Code 的目标就是代码生成,实测它生成的 CSS 在嵌套括号、引号配对方面的完整率明显更高,给它 JSON 规格书比给自然语言描述更合适;GLM-5.3 Flash 是轻量变体,它折腾几轮测试后发现,让它处理高频小请求时延迟明显低,用来做"实时修改指令"再合适不过。
2.3 从绑定到首个调用
在 CF 控制台创建 Worker 之后,需要在配置文件里开启 AI 绑定。我的 wrangler 配置大概是这样的:
// wrangler.jsonc { "name": "style-builder", "main": "src/index.ts", "compatibility_date": "2025-01-01", "ai": { "binding": "AI" } }然后是最小调用代码:
export interface Env { AI: Ai; } export default { async fetch(req: Request, env: Env) { const response = await env.AI.run("@cf/glm-5.3", { messages: [ { role: "system", content: "你把用户对界面风格的一句话描述解析成JSON。只输出JSON。", }, { role: "user", content: "清爽的开发者工具站,偏蓝灰,简洁但不冰冷,有一点人文感", }, ], temperature: 0.3, max_tokens: 800, }); return Response.json(response); }, };注意两个细节:模型 ID 我这里写的是"@cf/glm-5.3",实际要以控制台模型库展示的标识为准,不同账号的可调用模型范围不完全一样;响应结构也不是所有模型都相同,有的返回字段是 response,有的在 output.content 里,写代码时最好先用一次透传接口把原始响应打出来看一眼。这一步虽然琐碎,但能省掉后面很多"为什么解析不到内容"的排查时间。
3. 三个模型的分工编排:解析、生成、实时迭代
3.1 第一棒:GLM-5.3 做需求结构化与风格本体解析
这条流水线的触发点是一个系统 prompt。我需要它把自由描述转成稳定的 JSON,这个环节我踩过的最大坑是:一开始让它"自由发挥解释用户需求",结果同一句话生成两次,字段名都对不上,有的叫 color,有的叫 primary_color,程序没法稳定消费。所以 prompt 里必须把字段名、格式、取舍规则全部写死。
我当时给 GLM-5.3 的 system prompt 关键内容是:
"你需要将用户的界面风格描述解析为风格本体JSON。JSON结构固定为:{ summary: string, foundation: { color_axis, warmth, contrast }, tokens: { color_bg, color_text, color_accent, spacing_ratio, radius_base }, keywords: string[] }。所有颜色使用十六进制。warmth 取值 0~1。不要输出JSON以外的内容。"
这里为什么选 GLM-5.3 而不是 Kimi?因为我在几组测试里发现,GLM 对这种描述里隐含的气质还原更好,"人文感"会映射成偏暖的灰、圆角适当增大,而代码模型更倾向于输出一个保守的默认蓝色模板。解析完成之后,程序还要做一次 JSON.parse 并校验必填字段,不满足就自动补默认值,不能直接抛给下游。
3.2 第二棒:Kimi K2.7 Code 从规格书到 CSS
拿到结构化 JSON 后,把它整段拼进 Kimi 的 prompt。这个环节的核心原则是:只让它做"从规格到代码"的转换,不再让它做任何语义判断。prompt 里我明确写了:
"以下是风格本体JSON。请生成一份完整的 CSS 样式文本,包含 :root 自定义属性、body 基础样式、按钮/卡片/输入框组件样式。不要解释,只输出 CSS,不要放在 markdown 代码块中。"
这里有一个小技巧:虽然模型不一定每次遵守"不要放在 markdown 代码块中"的约束,但写进 prompt 后,被围栏包裹的概率会明显下降。程序里仍然要做兜底剥离,后面会讲具体做法。
Kimi 生成的 CSS 有几个硬性要求:所有颜色、间距、圆角都以 CSS 自定义属性的形式暴露;组件样式只引用这些变量,不写死具体数值。这样用户预览时只需要覆盖变量值,就能在不重新生成全部样式的情况下看到整体变化。这也是"风格系统"和"一次性样式"的本质区别。
3.3 第三棒:GLM-5.3 Flash 处理用户的即时微调
用户看到预览之后,最常见的反馈是"按钮圆角再大一点""主色再冷一点"这类局部诉求。如果每次反馈都把完整风格 JSON 重新喂给 Kimi 做全量生成,成本会迅速膨胀,延迟也不可能控制在两秒以内。我让 Flash 充当一个"最小差异生成器":
"用户当前风格JSON为:{...}。用户反馈:按钮圆角再大一点。请只输出一个JSON patch,格式为:{ operation: 'update', path: 'tokens.radius_base', value: ... } 或 { operation: 'update_css', selector: '.btn', property: 'border-radius', value: '...' }。不要输出解释。"
应用层拿到 patch 后直接覆盖本地 JSON,再走一遍 CSS 变量替换逻辑。这套设计里最核心的一点是:Flash 不直接生成 CSS,只生成修改意图,由确定性代码落地。整个样式系统始终可控,不会出现模型越改越跑偏的情况。
3.4 状态机与降级策略
整条链路需要一个 requestId 贯穿,状态存在 KV 或者 Worker 内存里。我用一个联合类型描述阶段:
type Stage = | { step: "parsed"; json: StyleJSON } | { step: "generated"; css: string } | { step: "adjusted"; json: StyleJSON; css: string };每个阶段最多重试两次。GLM-5.3 输出 JSON 解析失败时,我会降级为调用 Flash 修复 JSON;Kimi 超时时,先返回上一版 CSS 顶着,同时后台重新生成;Flash 生成 patch 失败,直接忽略这条反馈,不阻塞用户。用户感知到的是页面偶尔没有变化,而不是整条流水线白屏。工程上的降级往往比模型本身的准确率更重要,因为模型总有措手不及的时候。
4. 流式输出与实时预览的工程实现
4.1 流式调用和普通调用的差别
实时预览靠流式输出支撑。原以为加一个 stream: true 就行,真正对接时才意识到,CF Inference 返回的是一个原生 ReadableStream,Worker 需要自己把它转成浏览器能读取的事件流格式。调用代码本身很简单:
const stream = await env.AI.run("@cf/kimi-k2.7-code", { messages: [ { role: "user", content: buildCssPrompt(styleJson) }, ], stream: true, } as any);真正的麻烦在后半段:每个分片是一堆字节,不保证按文本块切分,也不能保证一条 UTF-8 字符不被切成两半,所以需要自己做增量解码。
4.2 Worker 端把字节流转成事件流
我的做法是写一个 TransformStream,把原始字节流转成 SSE 格式,因为最后要接到 fetch 上逐块读,有没有事件流头,体验差很多。
function aiStreamToSSE(readable: ReadableStream): ReadableStream { const decoder = new TextDecoder(); return readable.pipeThrough( new TransformStream({ transform(chunk, controller) { const text = decoder.decode(chunk, { stream: true }); const escaped = text.replace(/\n/g, "\\n"); controller.enqueue(`data: ${escaped}\n\n`); }, flush(controller) { controller.enqueue("data: [DONE]\n\n"); }, }) ); }这里有一个很容易踩的细节:如果不做转义,模型输出里的换行会把 SSE 的消息边界打乱,前端解析出来的内容就少一段。真实生产环境还要对引号、反斜杠做处理,这不是"能跑"和"好用"的差距,而是"能看"和"乱码"的差距。
4.3 前端逐 token 渲染进预览沙箱
前端逻辑不复杂:fetch 到 SSE 地址后,用 ReadableStream reader 逐块读取,不断把已收到的 CSS 拼进 iframe 的 srcdoc。渲染不能每收到一个分片就写一次 DOM,会卡顿。我用 requestAnimationFrame 做帧合并,一帧只写一次。
async function consumeSSE(url) { const res = await fetch(url); const reader = res.body.getReader(); const decoder = new TextDecoder(); let css = ""; let framePending = false; while (true) { const { done, value } = await reader.read(); if (done) break; css += decoder.decode(value, { stream: true }); if (!framePending) { framePending = true; requestAnimationFrame(() => { renderCSS(css); framePending = false; }); } } }renderCSS 里把样式包进 style 标签,再加一段固定 HTML 骨架,展示按钮、卡片、输入框等样例组件。用户在对话框里打完一句话,两三秒内就能看到颜色和圆角逐渐成形,这种即时反馈对生成类工具来说是决定体验的关键。
4.4 沙箱安全与样式注入防护
因为要把模型生成的 CSS 直接注入页面,理论上存在样式注入风险。我用两层防护:第一层是 iframe 沙箱,只放开 allow-scripts,禁止顶层导航;第二层是对 CSS 做朴素过滤,剥离 @import、url( 这类危险片段。模型生成的内容默认不能被信任,这个底线必须守住。沙箱配上 srcdoc 还有个额外好处:iframe 重新渲染时不会影响外层应用状态,风格生成和历史记录都留在主页面上,预览区随便替换。
5. 踩过的坑与质量调优:从能跑到好用
5.1 模型偶尔把 CSS 包进 markdown 围栏
第一个正式能跑的版本里,我把 Kimi 的输出直接塞进 iframe,结果前几次预览里能看到一串反引号和"css"字样。确实,很多代码模型训练时习惯输出 markdown 代码块,即使 prompt 里说了不要。处理办法是在写入沙箱前先做文本清理:
function extractCss(raw: string): string { const fenced = raw.match(/```(?:css)?\s*([\s\S]*?)```/); if (fenced) return fenced[1]; const styleStart = raw.indexOf("{"); if (styleStart > -1) return raw.slice(styleStart - 20); return raw; }这段逻辑不是万能兜底,但它能把最常见的情况处理掉。更可靠的做法是让清理函数返回一个对象,里面同时包含"是否成功剥离""原始长度""剩余长度"这类信息,方便做质量监控与告警。
5.2 同一个描述连续生成两次,差异大到没法做 AB
设计师对一致性很敏感。我做过统计:温度 0.7 时 Kimi 两次生成的色板偏差可以覆盖十几个色阶;降到 0.2,主色色差才明显收窄。但只降温度也不行,圆角、间距偶尔会与 JSON 里的规定不一致。最后我加了一道确定性校验:按结构化 JSON 里规定的令牌值,用代码覆写关键 CSS 自定义属性,再让 Kimi 只负责生成组件外观。
这个方案的道理在于:把"变量"和"使用变量的代码"分离。模型负责外观层,程序保证变量层恒定。用户再说什么"换一个主色",程序只需要改一个变量,不需要重新生成全部组件样式。效果上的感觉是"模型很听话",实际上是工程约束把模型的自由度拿掉了一部分。
5.3 Flash 改样式时总想重写整个方案
初次使用 Flash 做局部修改时,我的指令里没约束输出形式,它经常返回一整套完整 CSS。逻辑上没错,工程上很痛:没法做差异对比、上下文越堆越长、token 成本失控。后来把 prompt 改成"只输出 JSON patch",它生成的东西稳定在 50 到 120 token 之间,而且每次修改都有据可查。
Flash 给用户带来的体验也变了:用户说"按钮再圆一点",界面不会闪一下然后全部刷新,而只是几毫秒后按钮的圆角变大。整条链路从"重新渲染"变成了"局部更新",看起来像是做了一个原生应用。
5.4 冷启动和请求峰值的处理
CF Inference 的按需拉起机制有一个特点:某个节点没调用过模型时,首次请求可能慢 2 到 4 秒。用户视角里就是"卡住了没反应"。我的处理是三层:Worker 端加 8 秒超时,超时返回当前已生成的部分预览;发布时对三个模型各做几轮预热请求;把单用户并发控制在合理范围,避免突发请求全部落在冷节点上。这些措施不能彻底消灭冷启动,但能把"用户以为网站坏了"的概率降到很低。
5.5 查询成本隐性超支:每次重复传完整风格 JSON
最容易被忽略的是 token 成本。每一轮生成都把完整风格 JSON,甚至完整 CSS 塞进 prompt 传给模型,用户连续调整十几次时,上下文会从 1k 涨到 6k。改成把首次生成结果写入 KV,后续调整只传"差异指令加必要上下文"之后,上下文稳定在 1k 左右。这个优化和模型选型无关,纯粹是工程习惯,但对长期运行成本的改善非常直接。
6. 效果评估与后续还能往哪里扩
6.1 用一套简单评测集衡量生成质量,而不是只靠"看着还行"
我准备了三类评测样例:工具站清爽风、博客文字感、电商促销热烈风。每类跑 10 次,统计四个指标:
| 指标 | 目标 | 实测表现 |
|---|---|---|
| CSS 可解析成功率 | 100% | 恢复到 99% 以上 |
| 颜色对比度 AA 达标率 | ≥95% | 从 70% 提升到 95% |
| 重复生成主色色差 | 色差小于 6 | 温度 0.2 时稳定达标 |
| 人工主观评分 | 4/5 | 3.8~4.5 之间 |
一个重要结论是:把"对比度检查"从模型环节移出来,变成程序检查之后,AA 达标率明显提升。模型能感知视觉规律,但 WCAG 规则是数值计算,交给确定性代码处理才是正解。
6.2 从 CSS 模板扩展到组件库和框架配置
CSS 版本跑通后,我把 Kimi 的 prompt 扩展到了 React 组件库主题文件、Tailwind 配置、字型缩放表。流程不变:风格本体 JSON 到目标平台代码。同一套"气质描述"可以同时产出 Web 端、移动端、原型工具端多个版本,这比维护多套手工主题省力很多。
6.3 反向链路:上传截图逆向提取风格
反向流程的价值很快就被团队认可:用户贴一张截图,程序先让 GLM-5.3 提取颜色、字体、圆角等视觉特征,生成风格本体 JSON,再走正常的 Kimi 生成链路。这个能力在产品里可以包装成"从参考图一键转换风格",比让用户一个个去填色值自然得多。
6.4 最后留下的经验
如果你也在做类似的生成工具,我最想让你带走的不是某个 API 参数,而是一个观念:大模型做生成,工程做约束。三个模型各管一摊,表面上增加了系统复杂度,实际上每一段都可以独立优化、独立降级、独立计费。真正让输出稳定的,恰恰是那些看着不起眼的规则代码——JSON 校验、CSS 剥离、颜色对比度计算、patch 合并。它们把三个模型从"偶尔惊艳的玩具"变成了"每天可用的工具"。我自己的体会是,多模型编排的收益从来不在单次生成的惊艳程度,而在于你能随时把某一棒换掉,却不用担心整条链路崩掉。