上周三晚上十一点,做对话产品的一个朋友在群里丢了一张截图,说他们灰度环境里,前端已经不认识后端返回的东西了——模型不再只吐文字,而是吐一段带字段的界面描述,前端照着渲染,同一个问题,不同用户看到的卡片布局都不一样。这就是标题里说的那件事:大模型又一次大版本更新,对话产品正式接入了新一代模型,同时把所谓 Intelligent UI 推到免费用户面前。我这两年在做类似的大模型应用接入和前端渲染层,看到这个组合的第一反应不是"模型又强了",而是"渲染层和成本核算要重写了"。这篇东西想聊的就是这个:一次模型版本迭代,落到工程上到底要改哪些层,智能 UI 这种"让模型生成界面"的做法怎么落地才不翻车,免费额度放开之后的账怎么算,以及灰度阶段最容易踩的坑。适合正在做大模型应用接入的后端、前端、以及负责项目排期的同学看,不要求你懂模型训练,但要求你大概知道什么是结构化输出、什么是灰度。
1. 这次"接入新模型"到底改了哪几层
1.1 模型层、产品层、交互层的三重变化
很多人看到更新公告,第一反应是"模型变聪明了",这只是三层变化里最下面的一层。我习惯把一次大版本接入拆成三层来看,拆清楚之后,排期和风险评估才有意义。
模型层的变化是能力边界移动。上下文窗口、推理链长度、结构化输出的稳定性、多模态输入的支持范围,这些参数一动,下游的提示词、上下文裁剪策略、token 预算全都要跟着动。上一代模型我们按 8000 token 做上下文预算,写死在配置里的,新模型如果窗口翻倍,理论上可以放宽,但放宽之后单次请求的成本和首字延迟也会变,这就是连锁反应。
产品层的变化是能力暴露方式变了。模型能力再强,如果产品只给一个输入框,用户感知不到。所以每次大版本更新,产品侧一定会重新设计"哪些能力前置、哪些藏在二级菜单"。这次的智能 UI 就是典型的产品层动作:把模型的能力从"回答"扩展到"组织信息"。
交互层的变化最容易被低估。文字输出的交互是线性的,从上往下读;界面输出的交互是空间性的,用户会点、会滑、会展开折叠。这意味着埋点体系、可访问性、错误态展示、加载骨架屏,全部要重做。我们内部有过一次统计,一次输出形态的改变,前端改动量大约是后端的三倍。
注意:排期的时候千万别只按"换个模型接口地址"来估工,模型层可能只占一成工作量,剩下的都在产品层和交互层。
1.2 Intelligent UI 的本质:模型输出从"文字"变成"界面描述"
把这件事说透,其实就一句话:模型输出的不再是一段给人读的文本,而是一段给程序读的结构化描述,程序拿到之后渲染成界面。业内常见的叫法是"生成式 UI"或者"动态界面",本质都一样。
用生活里的类比,以前的对话产品像餐厅服务员口头报菜名,你听完自己想象;现在相当于服务员直接端上来一个拼盘,盘子的分格方式是根据你点的菜临时决定的。分格方式就是那段结构化描述,盘子本身是前端组件库。
这里面有个非常关键的工程判断:界面描述必须是受限的,不能是任意代码。我见过有人图省事,让模型直接输出 HTML 或者 JSX,前端 innerHTML 一塞就完事。这种做法在 demo 阶段很爽,上线基本等于开了个洞。正确做法是定义一套组件白名单加字段 Schema,模型只能从白名单里挑组件、填字段,渲染层只认识白名单里的东西,其余一律丢弃。这一点后面第 3 章会展开讲。
还有一点值得说:智能 UI 不是"每次都生成新界面"。真正好用的实现,是把高频场景固化成模板,模型只负责选模板和填参数,只有低频长尾场景才走完全动态生成。这个取舍直接决定了延迟和成本,也决定了你能不能把它开放给免费用户。
1.3 为什么免费用户也能用,这是最关键的产品信号
免费用户能用智能 UI,这件事的技术含义比表面上大得多。免费用户的单用户成本预算是极低的,通常在付费用户的十分之一量级。如果动态生成界面还能覆盖免费层,说明两件事成立:单次生成的成本已经压到足够低,并且渲染层的容错做得足够好——因为免费层的体验波动是不能靠客服兜的。
反过来说,如果你正在做类似功能,而你的方案在免费层跑不动,那基本可以判断方案偏重了。要么是每次请求都调大模型做全量生成,要么是渲染层没有缓存和模板复用。我的经验是,智能 UI 的成本大头从来不在模型调用,而在"每次都重新生成"这个坏习惯上。同一个问题被问一万次,答案结构九成是相似的,缓存命中率做上去,成本能掉一个数量级。
2. 接入前的架构决策:路由、成本与降级怎么排
2.1 为什么不能一刀切全量切换
新模型上线,最诱人的做法是"全量切过去,出问题再切回来"。我试过一次,教训很深刻。大版本之间的行为差异不是线性的,有些在老模型上表现很好的提示词,在新模型上会突然变得过度解释或者过度简略;有些工具调用的参数格式会有微妙变化;还有些场景的延迟特性完全不一样。
所以我现在一律推荐分层路由:按场景而不是按用户切。具体做法是维护一张场景路由表,把请求分成几类,每类指定主模型、备用模型和切换条件。下面这张表是我们内部实际用过的结构,可以直接抄:
| 场景类型 | 主模型 | 触发降级的条件 | 降级目标 |
|---|---|---|---|
| 短问答(<200字) | 新模型标准档 | 首字延迟 > 1.5s 或输出解析失败 | 上一代模型 |
| 长文分析(>2000字) | 新模型高容量档 | token 预算超限或超时 > 30s | 分段处理 + 上一代模型 |
| 智能 UI 生成 | 新模型结构化模式 | Schema 校验连续失败 2 次 | 固定模板兜底 |
| 工具调用链 | 新模型工具模式 | 参数校验失败或循环调用 | 单步调用 + 人工确认 |
| 免费用户高频问 | 缓存优先 | 缓存未命中才走模型 | 轻量模型 |
这张表的价值在于,它把"要不要切"从情绪判断变成了条件判断。切换条件里最重要的是可观测,也就是你得能实时拿到延迟、解析失败率、Schema 校验通过率这几个数,否则路由表就是一张废纸。
2.2 免费额度的成本账:三个必须算清的参数
开放给免费用户之前,我建议先把三个参数算清楚,不然很容易出现"上线三天预算烧完"的情况。
第一个是单次请求平均 token 消耗。它等于输入 token 加输出 token。输入侧的大头是系统提示词和历史上下文,输出侧的大头是结构化描述。这里有个容易忽略的点:JSON 格式的输出比自然语言输出更费 token,因为括号、引号、字段名都是成本。我们实测过,同样的信息量,严格 JSON 输出比自然语言多消耗大约 30% 到 50% 的 token。字段名如果起得很啰嗦,这个比例还会上升。所以字段名要短,能用t就别用title_text。
第二个是缓存命中率。这个参数直接决定成本曲线是线性还是次线性。计算方法很简单:命中率等于缓存命中请求数除以总请求数。免费层的请求分布极度集中,头部问题可能占四成以上,缓存命中率做到 40% 是现实目标,做到 60% 需要更激进的归一化策略(比如把问句统一小写、去掉语气词、做意图聚类)。
第三个是每次生成的平均渲染组件数。这个参数影响的不只是 token,还有前端性能。组件数从 5 个涨到 15 个,首屏渲染时间大概会翻倍。我的建议是给组件数设硬上限,超过就强制转成"摘要 + 展开"的两级结构。
把这三个参数代进去,粗算一下:假设单次请求 1500 token,其中 500 是输入、1000 是输出,输出里 60% 是结构化描述;免费层日活 10 万,人均 6 次请求;缓存命中 40%。那么实际走模型的请求是 36 万次,token 总量约 5.4 亿。这个量级下,成本优化的每个百分点都是有意义的,所以别嫌字段名短、别嫌模板复用土。
提示:做成本预估的时候,一定要按"输出 token"单独算一栏。很多团队的预算表只算总 token,结果输出侧失控的时候完全看不出来。
2.3 降级链路设计:五级兜底
智能 UI 这种"模型生成界面"的形态,失败模式比纯文本多得多。文本生成失败最差是答得不好,界面生成失败可能是整个页面空白。所以我一般要求至少五级兜底,从好到差依次是:
第一级,完整动态生成,Schema 校验通过,组件全部渲染。第二级,动态生成但有部分组件校验失败,丢弃失败组件、保留可用组件,页面顶部加一行轻提示。第三级,Schema 整体解析失败,退回固定模板,把模型的自然语言输出填进模板的正文区。第四级,模型调用超时或报错,走缓存里的相似问题结果。第五级,全部失败,展示纯文本回答加一个"重试"按钮。
这五级里,第三级是最容易被忽略也最救命的。因为 Schema 解析失败在实际线上并不罕见,尤其在模型刚开始灰度、提示词还在调的时候。做法很简单:每次请求都同时要求模型输出一份纯文本摘要,这份摘要不参与渲染,只在兜底时用。成本上多花一点输出 token,但换来的是页面永远不会空白。
还有个小技巧,降级事件必须打点,而且要带上失败原因分类。我们最早的打点只记"降级了",结果排查的时候完全不知道是模型问题、网络问题还是前端解析问题。后来改成按错误码分桶,问题定位时间从半天缩到十分钟。
3. 智能UI的核心实现:结构化输出与组件白名单
3.1 让模型"画界面"的正确姿势:Schema 约束而非自由发挥
我见过三种让模型生成界面的做法,按可靠性从低到高排:自由输出 HTML、自由输出 JSON、按 Schema 输出 JSON。第一种不要碰,第二种只在原型阶段用,生产必须用第三种。
Schema 约束的核心是把"模型能说什么"压缩到"模型只能在这些字段里填值"。比如你要生成一张信息卡片,不要告诉模型"生成一个卡片",而要告诉它"输出一个对象,type 必须是 card,必须有 title 字段(字符串,不超过 30 字)、items 字段(数组,1 到 6 个元素,每个元素含 label 和 value)"。描述得越具体,模型跑偏的概率越低。
这里有个反直觉的经验:字段越少,稳定性越高。一开始我设计了 12 种组件、40 多个字段,觉得这样表达力强。实际跑下来,模型的字段填充错误率随着字段数上升得很快,而且前端维护成本爆炸。后来砍到 5 种组件、18 个字段,覆盖率只掉了不到 10%,但错误率降了一大截。表达力不够的部分,用组合解决,不用新组件解决。
另一个关键点是给模型留一个逃生出口。Schema 里一定要有一个type: "text"的兜底类型,允许模型在不确定的时候只输出纯文本。没有这个出口,模型会被迫硬凑一个它其实不适合的结构,结果就是那 10% 的请求产出垃圾界面。有出口之后,模型会自己判断"这个问题不适合做卡片",反而更稳。
3.2 组件白名单是安全底线,不是可选项
前面提过,渲染层只认识白名单里的组件。这句话要落到实处,需要三道关。
第一道关在提示词层,明确告诉模型可用的组件枚举值,并强调"只能从以下列表中选择"。第二道关在服务端,收到模型输出后做一次白名单过滤,不在列表里的 type 直接丢弃整个节点,同时记录一次违规计数。第三道关在前端渲染层,用一张静态映射表把 type 映射到具体组件,映射表里没有的 type 渲染成占位符而不是报错。
三道关看起来冗余,实际上每一道都救过场。提示词层防的是模型幻觉,服务端防的是提示词被绕过,前端防的是服务端漏过滤。特别是第三道,它的作用不是安全,而是稳定性——线上出现未知 type 的时候,页面还能正常显示,用户不会看到白屏。
字段级别也要做约束。字符串字段限制长度,数字字段限制范围,数组字段限制元素个数,枚举字段限制取值。这些约束建议同时写在 Schema 描述里和服务端校验里,双保险。有个细节:URL 类字段必须做协议和域名白名单校验,不要让模型生成的链接直接进 href。这个坑我踩过,虽然最后没出事,但事后看确实不该省。
3.3 流式渲染与局部失败的补丁策略
智能 UI 的体验瓶颈往往不是生成质量,而是等待感。纯文本可以流式吐字,界面不行,因为界面必须等结构完整才能渲染。我们的做法是结构流式 + 骨架屏:模型按约定的顺序输出,先输出外层容器的 type,再输出各个子组件,服务端做增量解析,前端一边收一边渲染骨架,等某个子组件字段收全了再替换成真实内容。
这套方案有个前提:输出顺序必须是稳定的。所以提示词里要明确规定"先输出容器,再按顺序输出子项",并且用 JSON 的行分隔格式(每行一个完整对象)而不是嵌套结构,增量解析才好做。嵌套结构当然也能做增量解析,但解析器复杂度会上去一大截,我建议先用行分隔格式跑通,再考虑优化。
局部失败的处理原则是"丢弃而非修正"。某个子组件字段校验不通过,直接丢掉这个子组件,不要尝试用默认值补。原因很简单:默认值补出来的东西看起来是对的,但内容是假的,用户会信。丢掉之后页面短一点,用户至少知道信息不全。这个取舍在金融、医疗这类场景尤其重要,宁可少显示,不可错显示。
4. 实操:两小时搭一个能跑的智能UI原型
4.1 环境准备与服务拆分
原型阶段不需要搞得多复杂,我一般就三个进程:一个模型调用服务、一个静态页面、一个本地缓存。语言用 Python 加原生 JavaScript 就够了,别一上来就上框架,框架会把问题藏起来。
mkdir smart-ui-demo && cd smart-ui-demo python3 -m venv venv source venv/bin/activate pip install fastapi uvicorn httpx jsonschema python-dotenv依赖说明一下:fastapi和uvicorn起服务,httpx调模型接口,jsonschema做输出校验,python-dotenv管配置。四个就够,别装一堆。
服务拆成两个路由就够了:POST /api/generate负责调模型拿结构化输出,GET /返回静态页面。缓存先用进程内的字典,原型阶段完全够用,上线再换 Redis 之类。配置项只留三个:模型接口地址、密钥、超时时间。
注意:密钥别写进代码,用环境变量。原型阶段也一样,因为原型代码经常被直接改成生产代码,这个坏习惯一定要在第一天就掐掉。
4.2 提示词与Schema的具体写法
提示词我分成三段写:角色与任务、可用组件清单、输出格式约束。分三段不是为了好看,是为了出问题时能快速定位是哪一段导致的。
你是一个界面规划器。用户会提出一个问题,你需要判断用什么结构呈现信息最合适。 可用组件(只能从中选择,不得自创): - text:纯文本段落。字段:content(字符串,不超过 200 字) - card:信息卡片。字段:title(不超过 30 字)、items(1-6 个,每个含 label、value) - list:步骤或要点列表。字段:title、items(1-8 个字符串) - table:对比表格。字段:headers(2-5 个)、rows(1-10 行,每行列数与 headers 一致) - kv:键值对摘要。字段:pairs(1-8 个,每个含 k、v) 输出规则: 1. 每行输出一个完整的 JSON 对象,不要嵌套,不要 Markdown 代码块。 2. 第一个对象必须是容器,type 为 "root",含 blocks 数组,数组元素是后续对象的序号。 3. 不确定用什么结构时,只输出一个 text 对象。 4. 不要输出解释性文字。Schema 用 JSON Schema 写,下面是我实际用的简化版:
{ "type": "object", "required": ["type"], "properties": { "type": { "enum": ["root", "text", "card", "list", "table", "kv"] }, "content": { "type": "string", "maxLength": 200 }, "title": { "type": "string", "maxLength": 30 }, "items": { "type": "array", "maxItems": 8, "items": { "type": ["string", "object"] } }, "headers": { "type": "array", "maxItems": 5 }, "rows": { "type": "array", "maxItems": 10 }, "pairs": { "type": "array", "maxItems": 8 }, "blocks": { "type": "array", "maxItems": 12 } }, "additionalProperties": false }additionalProperties: false这一行很关键。不加的话,模型会自由发挥加字段,前端不认识,白占 token。加上之后,多出来的字段在服务端校验阶段就会被拦掉。
解析环节要把"一行一个 JSON"和"整段是一个数组"两种可能的输出都兼容掉。模型有时候不听话,会给你包成数组。我的做法是先按行切,逐行 try parse,失败的行单独记日志,能解析的行继续走。整段解析失败的兜底就是前面说的第三级降级。
4.3 渲染层:把JSON变成真实组件
渲染层我用原生 JS 写,核心是一张映射表加一个递归函数,不到一百行。
const REGISTRY = { text: (n) => `<p class="blk-text">${esc(n.content || '')}</p>`, card: (n) => ` <div class="blk-card"> <h3>${esc(n.title || '')}</h3> <ul>${(n.items || []).map(i => `<li><span>${esc(i.label)}</span><b>${esc(i.value)}</b></li>`).join('')}</ul> </div>`, list: (n) => ` <div class="blk-list"> <h3>${esc(n.title || '')}</h3> <ol>${(n.items || []).map(i => `<li>${esc(String(i))}</li>`).join('')}</ol> </div>`, table: (n) => ` <table class="blk-table"> <thead><tr>${(n.headers || []).map(h => `<th>${esc(h)}</th>`).join('')}</tr></thead> <tbody>${(n.rows || []).map(r => `<tr>${r.map(c => `<td>${esc(String(c))}</td>`).join('')}</tr>`).join('')}</tbody> </table>`, kv: (n) => ` <dl class="blk-kv">${(n.pairs || []).map(p => `<dt>${esc(p.k)}</dt><dd>${esc(p.v)}</dd>`).join('')}</dl>` }; function render(nodes) { const root = nodes.find(n => n.type === 'root'); const order = root && Array.isArray(root.blocks) ? root.blocks : nodes.map((_, i) => i); return order .map(i => nodes[i]) .filter(n => n && REGISTRY[n.type]) .map(n => REGISTRY[n.type](n)) .join(''); }esc函数必须做完整的 HTML 转义,& < > " '五个字符一个都不能漏。这段代码里最常见的翻车点是把esc写在部分字段上忘了另一部分,比如表格的 td 忘了转义。我的习惯是渲染函数里每一个模板插值都必须包esc,形成肌肉记忆,宁可多写不可少写。
还有一个细节:表格的列数校验。模型生成的 rows 里,某一行的列数可能和 headers 不一致,直接渲染会错位。稳妥做法是渲染时按 headers 长度裁剪或补齐,同时在服务端记一次告警。这种小错在线上出现频率比想象中高,尤其是表格列数超过 4 列的时候。
4.4 校验、埋点与回放
原型阶段就加上校验和埋点,别等上线。校验用jsonschema逐行跑,失败的行记下原始文本。埋点至少记五个字段:请求 ID、场景、生成耗时、校验通过率、组件数。这五个数在后期调优时是基础燃料。
回放能力是我最推荐提前做的一件事。做法很简单:把所有请求的模型原始输出按天存成 jsonl 文件,前端出问题的时候,把那段原始输出喂给渲染函数,本地就能复现。没有回放能力的时候,排查"用户说页面乱"这类问题基本靠猜,因为线上的上下文和参数复现不出来。
回放还有一个附带好处,就是可以拿历史数据跑新版本的 Schema。Schema 一改,拿过去一周的原始输出重新校验一遍,通过率的变化立刻就能看出来。这个动作每次改 Schema 前我都做,五分钟的事,能省掉一次线上事故。
5. 灰度与评测:怎么判断"真的变好了"
5.1 离线评测集怎么攒
判断新模型好不好,最忌讳拿几个自己想的例子试一下就说"感觉强了"。我的做法是建一个两百到五百条的评测集,来源是线上真实请求脱敏后的样本,按场景分层抽样。
分层维度我一般用三个:问题复杂度(单跳、多跳、需要计算)、期望输出形态(纯文本、表格、列表、卡片)、以及是否存在明确的对错(事实类、观点类、创作类)。每层至少三五十条,保证各层都有统计意义。
评测指标别只看"答得对不对"。智能 UI 场景至少要看四类:内容正确性(人工打分或规则校验)、结构合理性(生成的组件类型是否匹配问题类型)、Schema 通过率、以及平均输出 token。第三和第四个是硬指标,可以自动跑,第一个和第二个需要人工,建议先人工打一百条建立基线。
有个小技巧:结构合理性可以半自动化。比如问"对比 A 和 B",理想结构是 table 或 kv,如果模型输出了纯 text,就算结构不合理。这类规则写起来很快,能覆盖六七成的场景,剩下的人工兜。
5.2 在线灰度与四个核心指标
灰度我建议按请求维度而不是用户维度分,比例从 5% 开始,每两天涨一档。四个核心指标:
第一个是首屏可见时间(用户看到第一个非骨架内容的时间)。智能 UI 这个指标比传统首字节时间更重要,因为用户感知的是"内容出现了"。第二个是降级率,也就是走到第三级及以下兜底的比例,这个数超过 3% 就该停下来查。第三个是交互深度,用户有没有点开卡片、有没有滚动到底,这个指标反映界面是不是真的有用。第四个是追问率,同一个用户在短时间内对同一问题重复提问的比例,这个数如果上升,说明界面没把信息说清楚。
我自己踩过一个坑:只盯降级率,结果降级率很低,但追问率涨了 15%。后来看录屏才发现,生成的卡片把关键信息折叠进了一个需要点开的区域,用户没注意到。这说明指标之间必须交叉看,单看一个指标很容易得出错误结论。
5.3 回滚预案
回滚预案要写清楚三件事:回滚的触发条件、回滚的操作步骤、回滚后的数据核对。触发条件建议是量化的,比如"降级率连续 10 分钟超过 5%"或者"首屏可见时间 P95 超过 3 秒"。操作步骤要能在一个命令内完成,最好做成配置开关,改一行配置就回到旧链路。数据核对是回滚后容易被忘的:已经生成并缓存的结构化结果要不要清,这里建议清掉,因为新旧模型的字段口径可能不一致。
6. 常见问题与排查实录
6.1 高频问题速查表
下面这张表是我在过去几个项目里积累的,基本覆盖了智能 UI 上线初期九成的问题。
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 页面空白无内容 | Schema 整体解析失败且未触发兜底 | 查服务端日志里的原始输出 | 补兜底逻辑,输出纯文本摘要 |
| 卡片内容重复 | 模型输出了多份相似节点 | 统计同请求内的节点标题相似度 | 渲染层做去重,相似度阈值 0.8 |
| 表格错位 | rows 列数与 headers 不一致 | 逐行校验列数 | 渲染层按 headers 裁剪,服务端告警 |
| 首屏很慢 | 组件数过多,串行渲染 | 查组件数分布 | 设上限 12 个,超出转两级结构 |
| 输出被截断 | 输出 token 达到上限 | 查 finish 原因 | 提高上限或缩短字段描述 |
| 中英混排乱 | 字段长度约束未生效 | 抽样看超长字段 | 服务端硬截断,前端省略号 |
| 追问率上升 | 信息被折叠或摘要过度 | 看用户点击热区 | 关键信息前置到首屏 |
6.2 几个只有踩过才知道的坑
第一个坑是提示词里的示例反而害事。我早期在提示词里放了两个完整示例,本意是让模型照着格式来。结果模型变得极其喜欢模仿示例的结构,不管用户问什么都输出成示例那种两栏卡片。后来把示例删掉,只保留字段清单和规则,结构多样性立刻回来了。教训是:示例给一个足够,给两个就是负担。
第二个坑是字段名过短导致语义误解。前面说字段名要短省 token,但短过头会出事。比如我用v表示 value,模型有时候会把它理解成 version,填进去一串版本号。后来改成val,问题消失。这个度大概是两到四个字符,单字符风险太高。
第三个坑是缓存键的设计。缓存用的是用户问题原文,结果"这个多少钱"和"这个多少钱?"命中了两个不同的缓存条目。加上归一化处理(去标点、去语气词、转小写)之后,命中率涨了将近一倍。这个改动花了半小时,收益比调一周提示词都大。
第四个坑是流式解析的边界情况。增量解析的时候,一行数据可能被切成两次网络包到达,如果每收到一个包就尝试解析,会出一堆解析错误日志。正确做法是维护一个缓冲区,按换行符切分,只有完整的行才进解析器,剩下的留在缓冲区。这个 bug 我们查了两个小时,因为日志里错误量很大但功能看起来正常。
第五个坑是多语言场景的数字格式。表格里如果出现金额,不同语言区域的小数点和千分位符号不一样,模型有时候会混着输出。稳妥做法是让模型输出纯数字字符串,格式化交给前端按 locale 做。这个细节看起来小,但在涉及金额的场景里非常显眼。
7. 成本与体验的平衡:我自己的几条土办法
做这类功能做久了,会形成一些不太上台面但很管用的习惯。第一条是模板覆盖优先于动态生成,前面提过,这里再强调一次具体做法:上线后第一周,把请求按意图聚类,取前二十个高频意图手工做模板,剩下的走动态生成。这一条做完,成本能掉四成以上,而且高频场景的体验更稳定,因为模板是人工调的。
第二条是给免费用户单独一条轻链路。具体是:免费请求先进缓存,未命中的用更短的提示词(只保留组件清单,删掉所有规则说明),输出限制组件数不超过 6 个,超时就返回纯文本。这套配置下来,免费链路单次成本大约是付费链路的三分之一,体验上差一些但完全可用。用户对这个的容忍度其实比想象中高,只要不是白屏就行。
第三条是把 Schema 版本化。Schema 每次改动都打版本号,请求日志里记录版本,前端也按版本渲染。这样做的好处是回滚和灰度都能按版本切换,不用改代码。我们现在的做法是 Schema 版本跟着配置走,改配置就换版本,特别省事。
第四条是别在首屏放需要二次请求才能看全的内容。这条是纯体验经验。智能 UI 很容易生成一个"点击查看更多"的卡片,用户点开又是加载,体验很差。我的原则是首屏内容必须自足,宁可少放两块,也不放需要再请求的占位。
第五条是每周看一次被丢弃的组件统计。前面说校验失败就丢弃,但丢弃的数据不能白丢,要定期统计哪些 type、哪些字段最常出错。我靠这个统计发现过一个隐蔽问题:某个组件的items字段在问题涉及时间线的时候经常超长,原因是模型会把每个时间点都列出来。后来给这个场景单独加了条规则,超长就转成表格,问题解决。
这些办法都不复杂,难的是坚持做。做智能 UI 这类东西,模型能力是别人给的,你能控制的只有工程细节,而工程细节的累积效应比想象中大得多。我现在回头看,真正让体验变好的不是换了哪个模型,而是缓存、模板、降级这些土办法一件件堆起来的结果。