ÉLAN 是怎么把一张卡变成四张照片的
ELAN 多层 Prompt 引擎剖面图
上一篇讲了 ÉLAN 想做什么:交付一个完整的社交媒体时刻,照片加文案,投射那种不经意的高级感。核心概念是「不经意的优越感」,载体是灵感卡。
这篇讲实现。一张灵感卡是怎么变成 4 张照片和一段文案的,prompt 长什么样,结果怎么实时推给用户,一次生成到底花多少钱,挨个讲一遍。
一条 prompt 拼出来有 10 段
每次灵感卡生成,最后都会走到 buildMuseCardPrompt() 这个函数。它拿一张卡的数据和一个镜头序号,拼出一整段 prompt,一共 10 个段落,各管各的事。
第 1 段:参考图标注。 告诉模型看到的这个人是谁。实际的照片是另外发的(发送顺序后面会讲),这一段只做身份锚定:
Generate a photo of the EXACT person shown in the 2 reference image(s) above.
Their face must be IMMEDIATELY recognizable — same eyes, nose, lips, face shape, skin tone.
Match their body type and proportions realistically.
这段是故意写这么短的。很早就发现一个事:身份描述写得越多,反而越跟照片「抢注意力」。照片本身负责身份,文字只需要说一句「用那些照片」就够了。
第 2 段:场景。 直接从灵感卡的 scene.description 拉过来:
SCENE: 豪华度假村无边泳池,俯瞰无际大海,金色黄昏将水面染成碎金。
LIGHTING: 黄金时刻侧逆光,暖橙色光晕,水面反光形成自然柔光
ENVIRONMENT STYLE: 无边泳池边缘,远景为开阔海面,天边云霞渐变
场景描述全是用中文写的,这个是有意的。目标用户是中国人,审美参照是中国社交媒体,用中文写场景 prompt,出来的构图比先翻译成英文再发给模型要对味得多。Gemini 对中文 prompt 的理解还是不错的。
第 3 段:服饰加奢侈品暗示。 暗示是泛化的,不带品牌名:
OUTFIT: 精致泳衣搭配真丝纱笼,设计师太阳镜随意架于发顶
OUTFIT DETAILS: 真丝纱笼; 设计师墨镜; 精致泳装
COLOR PALETTE: 沙金色, 象牙白, 玫瑰裸粉
我们不会在 prompt 里写「爱马仕丝巾」,写的是「真丝纱笼」。品牌名只存在卡片的 brandHints 数组里,作为 SCENE AESTHETIC 传给模型——让它理解这是什么档次的奢华,但不去画具体的品牌 logo。这个跟品牌安全有关,后面会展开讲。
第 4 段:叙事镜头角色。 4 张照片,每一张都有自己的叙事定位:
SHOT ROLE: establishing — 宽幅全景:泳池延伸至海天交界,人物处于远景左三分之一
COMPOSITION: 超宽画幅,泳池线条引导视线,强调空间的辽阔
四个标准角色:establishing 全景建立场景,portrait 中景聚焦人物,detail 特写某个局部(脚踩进水里、手搭在书上这种),mood/closing 氛围收尾,一般是剪影或者背影。这样发小红书的时候,四张拼在一起是一个完整的视觉叙事。要是四张全随机生成,各拍各的,这个叙事就拼不出来。
第 5 段:pose 和构图。 镜头级别的 pose 指令,没有就用卡片的默认值。
第 6 段:调色。 用自然语言描述色彩方向:
COLOR GRADING: golden hour warmth with amber tones, slightly lifted shadows,
creamy highlights, film-like grain
这里试过两种方式:结构化参数(temperature: warm, saturation: medium 这种)和自然语言描述。实测下来自然语言的一致性更好。模型理解一个整体效果,比同时满足好几个独立约束要靠谱。
第 7 段:情绪。 一句话的整体氛围。
第 8 段:VANITY_DESIGN_INSTRUCTIONS。 核心的差异化指令,下面单独讲。
第 9 段:身份锁定尾注。 最后再提醒一次保持人脸一致:
FACE IDENTITY: must match the reference photos exactly. Same person, immediately recognizable.
If style conflicts with identity, choose identity.
那句「风格和身份冲突时,选身份」是后来加的。模型有时候为了更好地匹配某个风格方向,会牺牲脸部的准确度。显式告诉它身份优先之后,效果明显好了。
让奢侈品「不经意」出现
第 8 段的指令实际长这样:
LUXURY PLACEMENT RULES (CRITICAL):
- Any luxury brand items (bags, jewelry, scarves) must appear INCIDENTALLY,
never centered or prominently displayed
- Brand logos should be partially visible or at natural angles,
as if the camera happened to catch them
- The photo should look like a candid life moment, NOT an advertisement
- Focus should be on the person and the scene atmosphere,
not on the luxury items
- The overall feeling should be "this is just my normal Tuesday"
rather than "look what I have"
五条规则,每条都有具体的作用,挨条说一下。
「不经意出现,绝不放正中」。模型默认会走产品摄影的构图,把奢侈品放画面正中间——训练数据里的品牌物品大多就是这么拍的。这条就是去覆盖那个默认行为。
「部分可见或自然角度」。完整正面的 logo,一看就是广告;被丝巾折叠遮住一半的 logo、45 度角随手放着的包,才是真实生活。这就是小红书帖子的视觉语言。
「随拍生活瞬间,不是广告」。Gemini 天然爱出杂志风、商业风的构图——完美打光、居中主体、干净背景,看起来很厉害,但一眼就是「拍出来的」。目标用户恰恰想避免这种感觉,所以得把这张照片的意图直接讲给模型听。
「焦点在人和氛围上」。告诉模型什么应该放中间:人的脸、姿态、整体环境。奢侈品往后排,往画面边上放。
「这只是我普通的周二」。这句试了好几种说法。学术一点的表达(project nonchalant affluence 这种)不好使,口语化的情绪目标——直接说你想让看的人是什么感觉——效果最稳定。
没这段指令的时候,出来的基本就是奢侈品广告大片:人站正中间举着包,logo 清清楚楚,打光很专业。照片是好看的,好看是真好看,但发到小红书,谁都能一眼认出来这是广告或者 AI 生成。加了之后奢侈品就往边上走了,包放在人旁边的椅子上,半截出画面,酒店标识在背景里的杯子上,稍微虚焦,焦点全回到人身上。小红书上真实的凡尔赛帖子就是这个样子。
加了 VANITY 指令后,奢侈品从居中的广告位退到画面边缘,焦点重新落回人身上。
参考图要放最前面,还要发两遍
prompt 写得再好,参考图的发送方式不对,人脸一致性照样上不去。
做了大量测试之后发现一个规律:Gemini 对 parts 数组里排在前面的图片,注意力明显更高。所以 generate-photo.ts 里的 buildParts() 函数有一个严格的顺序:
- 先发参考图,前面不放任何文字。每张图实际上发了两遍,用来加强权重
- 然后是身份关系指令——"Use the uploaded image(s) strictly as the FIXED IDENTITY REFERENCE..."
- 再是场景/风格 prompt,也就是
buildMuseCardPrompt()的输出 - 场景参考图放最后——如果用户上传了自定义场景照的话
图片发两遍这个事,一开始我也觉得匪夷所思,会不会把模型搞混?实测下来没有,每张参考图发两遍,对人脸一致性有明显提升。本质上就是给身份信号更大的权重——同一个东西喂两遍,模型就更把它当回事。
参考图排在 parts 数组最前面并发两遍,拿到最高注意力,人脸一致性因此更稳。
4 分钟的等待怎么处理
用户点了「光影创作」之后,要等 4 张照片生成。每张 15 到 60 秒不等,四张加起来可能要 4 分钟。4 分钟,对一个盯着屏幕等照片的人来说是很长的。
推送这块选了 SSE(Server-Sent Events)而不是 WebSocket,原因大概几个:
一是单向就够了。客户端只需要接收事件,初始请求之后不需要再往回发数据,SSE 就是为这个模式设计的,WebSocket 的双向能力纯属浪费。
二是基建更简单。SSE 走标准 HTTP,不用额外开 WebSocket 服务器,不用处理协议升级,也不用管理长连接池。在 Vercel 的 serverless 架构上这点很重要——WebSocket 需要 Edge Functions 加一堆专门的配置。
三是天然的断线重连。移动网络掉线是家常便饭,特别是在国内,SSE 内置的重连协议自动就处理了;WebSocket 断了要自己写恢复逻辑。
实现很直白,API 路由创建一个 ReadableStream,4 张照片并行生成:
const stream = new ReadableStream({
async start(controller) {
send("started", { sessionId, totalPhotos });
// 并行启动所有生成请求
const promises = poses.map((_, i) => generateWithQc({ prompt, index: i }));
// 每完成一张就推送
const pending = promises.map(p => p.then(async (res) => {
if ("error" in res.result) {
send("photo_failed", { index: i, error: res.result.error });
} else {
send("photo_completed", { index: i, imageId, previewUrl });
}
}));
await Promise.allSettled(pending);
send("completed", { successful, failed, total });
},
});
SSE 事件就四种:started(session ID 加总数)、photo_completed(带预览 URL)、photo_failed(带错误信息)、completed(最终统计)。客户端每收到一张就渲染一张,不用等全部完成。
等待的体验也做了设计。就算有流式推送,15 到 60 秒一张还是很长。加载界面没有放进度条——进度条暗示时长可以预测,但这个预测不了。放的是灵感卡的预览图轮播,配上「你的光,刚刚好」这句话。每张照片完成后替换掉一个 loading 占位符,带入场动画。整体感觉就是照片一张一张送到了你手上,而不是让用户盯着一个「处理中」干等。
人脸漂移和质检
AI 写真生成最让人崩溃的问题就是人脸漂移:生成出来的人长得有点像参考照片里的人,但明显不是同一个人。用户对自己的脸太熟悉了,一点点不对都能看出来。
ÉLAN 的做法是用 Gemini Flash(纯文本模型,便宜且快)加了一层生成后质检。每张照片出来之后,把生成图和原始参考图一起发给 Flash,问一个很简单的问题:「这是同一个人吗?」
返回 same_person: false 的话,系统最多重试 2 次。全部重试都失败,就用最后一张结果——给用户看一张 80% 像的照片,总比什么都没有强。重试次数和质检次数都计入成本模型。
每张生成图交给 Flash 问「是同一个人吗」,不是就最多重试两次,都失败则兜底用最后一张。
每次质检只花几厘钱,增加 2 到 5 秒延迟,能抓住最离谱的漂移。实测下来,大概 15-20% 的首次生成会被质检拦下来,其中约 80% 重试之后能过。
配文系统
配文跑在另一个模型上,Gemini 3 Flash,纯文本,比图像生成模型便宜得多。
三种风格,各有各的性格。
凡尔赛。 不经意炫耀:文案说小事,照片露大事。风格指令是这么写的:
"文案描述一件普通小事或日常瞬间,绝对不能直接提及场景名称、品牌名或价格。照片本身已经在炫耀,文案要显得漫不经心、理所当然。"
参考示例库里的例子:「难得什么都不想,泡了一整天的水」「说好九点起,结果又赖了两小时的床」。
文艺。 极短的诗。「用感受和意象写,不叙事、不解释。」示例:「水天一色,心也跟着透明了」。
简约。 一句话。「一个极短的中文句子或词组,极致留白。」示例:「泡着不想动」。
平台适配这块,每种风格对小红书和朋友圈有不同的字数和格式规则:
| 小红书 | 朋友圈 | |
|---|---|---|
| 凡尔赛 | 200 字,3-5 个话题标签,最多 2 个 emoji | 80 字,不加标签,最多 1 个 emoji |
| 文艺 | 150 字,3-5 个话题标签,最多 1 个 emoji | 60 字,不加标签,最多 1 个 emoji |
| 简约 | 50 字,3-5 个话题标签,不用 emoji | 30 字,不加标签,不用 emoji |
prompt 里会指示模型一次生成 3 条候选,返回 JSON 数组。用户默认看第一条,点「换一换」切换。话题标签从文案里提取出来单独显示(仅小红书)。整个配文调用 2 到 4 秒搞定。
配文 prompt 里还有一条硬规则:禁止提及任何品牌名、酒店名、餐厅名、景点名。这个是凡尔赛公式的延伸——文案里写了「住了四季酒店」,那种不经意的感觉就全毁了。所以文案要足够泛化,奢华只在照片里可见。
一次出图花多少钱
这块的成本结构极度倾斜,倾斜的原因是图片的 output token 单价是文字的 120 倍——所以钱基本全花在出图这一步上。
一次标准的 4 张出图,成本有五个组成部分:输入文本 token、输入参考图 token、输出图片 token、人脸质检、配文生成。其中输出图片一项占了大概 96%。其他所有东西——prompt、参考图、质检、配文——加起来都是零头。一次 session 总共花不到一块钱。
分辨率越高越贵,2K 大概是 1K 的 1.5 倍,4K 大概 2.3 倍。额度系统里的分辨率倍率(1x、2x、4x)大致就是跟着 API 成本比例走的。
漂移重试是个变量。一张图质检不过、重试两次,那张图的生成成本就是 3 倍;最差情况是 4 张全部重试两轮,一次 session 能到基础成本的接近 3 倍。不过实际跑下来,重试带来的额外开销不算大。
几个真实踩到的问题
品牌安全。 早期灵感卡的 prompt 里是直接写品牌名的,「Hermès Birkin 手袋」「安缦度假村的标识」这种。Gemini 有时候真的会把可辨认的品牌 logo 和产品画出来,法律上这是雷区。后来把所有 prompt 改成了泛化的奢侈品描述(「真丝丝巾」「设计师墨镜」),品牌名只保留在 brandHints 数组里作为 SCENE AESTHETIC——让模型理解奢华的档次,但不去复制具体的知识产权。
4 张照片之间的人脸一致性。 每张照片是独立生成的,没有办法在多次 Gemini 调用之间「锁定」同一个身份。人脸质检能抓住最明显的漂移,但微妙的不一致还是会有:肤色略有差别,下颌线在某一张里更尖一点,眼距微妙地不一样。发一组小红书照片问题不大,仔细比较还是能看出来。这是当前 API 的根本限制,真正的跨生成身份锁定,要么微调,要么换一套完全不同的架构。
2 小时过期。 生成的图片存在 Vercel Blob 里,2 小时后自动删除。这是成本考量——永久保存每张生成图,存储费很快就失控了。但用户有时候生成了一组照片,出去转了一圈回来,照片没了。Redis 里的 session 元数据也是 2 小时过期。这个事跟每个内测用户都解释过,是吐槽率最高的问题。更长的过期时间,或者一个「保存到永久」的功能,已经在计划里了。
输出图片占 96% 成本这件事。 一开始的计划是内测期间无限生成。算了一下实际的单次成本之后,这个计划就死了——就算只是 1K 分辨率,一天跑一百次,API 账单也很可观。额度系统就是这么来的,不是什么商业策略,纯粹是不想测试阶段就把自己烧破产。
prompt 长度和人脸准确度直接矛盾。 场景、服饰、情绪、构图的描述写得越丰富,模型分配给参考图的注意力就越少。现在的 prompt 是精简过的,早期版本比这长 3 倍。图片优先的架构(先发参考图再发文字)是最大的改善,但这个 tradeoff 始终存在。有时候一张构图很美的照片,人脸还原度只有 80% 而不是 95%。
配文质量有波动。 3 条候选的机制有帮助,但 Gemini Flash 偶尔还是会生成太直白的文案(「今天的泳池好美」这种),或者抽象到看不懂。「禁止提及品牌名」的规则有时候也会让模型过度矫正,写得太含糊。在考虑加一层生成后的关键词过滤,拦住含违禁词的文案重新生成——代价是延迟和成本都会增加。
反正 ÉLAN 跟 Gemini 说话的方式大概就是这么一套:prompt 分十段各管各的,参考图放最前面还发两遍,四张并行生成走 SSE 推,后面挂个质检兜底。每一块都是试出来的,就这么回事。
上一篇讲产品愿景。下一篇会更深入灵感卡的设计:现在的 18 张卡是怎么做出来的、每张卡背后的数据结构、以及新卡的编辑流程。
This post is also available in English.


