ENZH

打开引擎盖看 ÉLAN 怎么跑的

ELAN 多层 Prompt 引擎剖面图ELAN 多层 Prompt 引擎剖面图

上一篇讲了 ÉLAN 想做什么:交付完整的社交媒体时刻——照片加文案,投射不经意的高级感。核心概念是"不经意的优越感",载体是灵感卡。

这篇把引擎盖打开。一张灵感卡怎么变成 4 张照片和一段文案?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.

最后那句话——"风格和身份冲突时,选身份"——是后来加的。我发现模型有时候为了更好地匹配某个风格方向,会牺牲脸部准确度。显式地告诉它优先身份,效果明显好了。


核心秘密:让奢侈品"不经意"出现

实际的指令长这样:

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 生成。

有了这段指令之后,奢侈品往边上走了。包放在人旁边的椅子上,半截出画面。酒店标识在背景里的杯子上,稍微虚焦。人是主体,奢华是语境。这才是真正的"凡尔赛"帖子长的样子。


先发照片,再说话——顺序决定一切

Prompt 写得再好,如果参考图的发送方式不对,人脸一致性就上不去。

我做了大量测试,发现一个关键规律:Gemini 对 parts 数组前面的图片注意力明显更高。 所以 generate-photo.ts 里的 buildParts() 函数有严格的顺序:

  1. 先发参考图 — 前面不放任何文字。每张图实际上发了两遍来加强权重
  2. 身份关系指令 — "Use the uploaded image(s) strictly as the FIXED IDENTITY REFERENCE..."
  3. 场景/风格 promptbuildMuseCardPrompt() 的输出
  4. 场景参考图最后 — 如果用户上传了自定义场景照

图片重复发两遍这件事一开始我也觉得匪夷所思——会不会搞混模型?但实测下来,每张参考图发两遍对人脸一致性有明显提升。本质上是给身份信号更大的"权重"。


4 分钟的等待怎么办

用户点了"光影创作"之后,要等 4 张照片生成。每张 15-60 秒不等。加起来可能 4 分钟。

我选了 Server-Sent Events (SSE) 而不是 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(会话 ID + 总数)、photo_completed(带预览 URL)、photo_failed(带错误信息)、completed(最终统计)。客户端可以每收到一张就渲染一张,不用等全部完成。

等待的体验设计。 就算有流式推送,15-60 秒一张照片也还是很长。加载界面不显示进度条(进度条暗示可预测的时长,但我们没法预测)。取而代之的是灵感卡的预览图轮播,配上"你的光,刚刚好"。每张照片完成后替换一个 loading 占位符,有个入场动画。感觉是照片在"到达",而不是在"处理"。


人脸漂移:用户对自己的脸太熟了

AI 写真生成最让人崩溃的问题就是"人脸漂移"——生成出来的人长得有点像参考照片里的人,但明显不是同一个人。用户对自己的脸太熟悉了,一点不对都能看出来。

ÉLAN 用 Gemini Flash(纯文本,便宜且快)做了一层生成后质检。每张照片出来后,把生成图和原始参考图一起发给 Flash 模型,问一个简单问题:"这是同一个人吗?"

如果返回 same_person: false,系统最多重试 2 次。如果全部重试都失败,就用最后一张结果(给用户看一张 80% 像的照片总比什么都没有好)。重试次数和质检次数都计入成本模型。

每次质检只花几厘钱,增加 2-5 秒延迟。但能抓住最离谱的漂移。实测下来,大约 15-20% 的首次生成会被质检拦下,其中约 80% 重试后能过。


照片有了,文案才是灵魂

配文系统跑在另一个模型上——Gemini 3 Flash(纯文本)——比图像生成模型便宜得多。

三种风格,各有各的性格:

凡尔赛。 不经意炫耀。文案说小事,照片露大事。风格指令:

"文案描述一件普通小事或日常瞬间,绝对不能直接提及场景名称、品牌名或价格。照片本身已经在炫耀,文案要显得漫不经心、理所当然。"

参考示例库里的例子:"难得什么都不想,泡了一整天的水"、"说好九点起,结果又赖了两小时的床"。

文艺。 极短的诗。"用感受和意象写,不叙事、不解释。" 示例:"水天一色,心也跟着透明了"。

简约。 一句话。"一个极短的中文句子或词组,极致留白。" 示例:"泡着不想动"。

平台适配。 每种风格对小红书和朋友圈有不同的字数限制和格式规则:

小红书朋友圈
凡尔赛200 字,3-5 个话题标签,最多 2 个 emoji80 字,不加标签,最多 1 个 emoji
文艺150 字,3-5 个话题标签,最多 1 个 emoji60 字,不加标签,最多 1 个 emoji
简约50 字,3-5 个话题标签,不用 emoji30 字,不加标签,不用 emoji

Prompt 指示模型生成 3 条候选,返回 JSON 数组。用户默认看第一条,可以点"换一换"切换。话题标签从文案里提取出来单独显示(仅小红书)。整个配文调用 2-4 秒搞定。

配文 prompt 里有一条硬规则:"禁止提及任何品牌名、酒店名、餐厅名、景点名"。这是凡尔赛公式的延伸——如果文案里写了"住了四季酒店",那种不经意的感觉就全毁了。

文案必须足够泛化,让奢华在照片里可见。


一次出图到底烧多少钱

一次标准的 4 张出图,成本结构长什么样?关键信息:生成图片的 output token 单价是文字的 120 倍。所以成本结构极度倾斜。

一次出图有五个成本组成部分:输入文本 token、输入参考图 token、输出图片 token、人脸质检、配文生成。其中输出图片占了约 96%

其他所有东西——prompt、参考图、质检、配文——加起来都是零头。一次会话总共花不到一块钱。

分辨率越高越贵——2K 大约是 1K 的 1.5 倍,4K 大约 2.3 倍。额度系统的分辨率倍率(1x, 2x, 4x)大致跟踪 API 成本比例。

漂移重试是变量。一张图质检不过重试两次,那张图的生成成本就是 3 倍。最差情况(4 张全部重试两轮),一次会话能到基础成本的近 3 倍。但实际重试带来的额外开销不算大。


坦白时间

说几个真实遇到的问题。

品牌安全。 早期灵感卡的 prompt 里直接写品牌名——"Hermès Birkin 手袋""安缦度假村的标识"。Gemini 有时候真的会生成可辨认的品牌 logo 和产品,这在法律上是雷区。后来我把所有 prompt 改成了泛化的奢侈品描述("真丝丝巾""设计师墨镜"),品牌名只保留在 brandHints 数组里作为"SCENE AESTHETIC"——让模型理解奢华的档次,而不是去复制具体的知识产权。

4 张照片间的人脸一致性。 每张照片是独立生成的,没有办法在多次 Gemini 调用之间"锁定"同一个身份。人脸质检能抓住最明显的漂移,但微妙的不一致还是会有:肤色略有差别,下颌线在某一张里更尖,眼距微妙地不同。发一组小红书照片问题不大。但仔细比较的话能看出来。这是当前 API 的根本限制——真正的跨生成身份锁定需要微调或者完全不同的架构。

2 小时过期。 生成的图片存在 Vercel Blob 里,2 小时后自动删除。这是成本考量——永久保存每张生成图的存储费会很快失控。但用户有时候生成了一组照片,出去转了一圈回来,发现照片没了。Redis 里的会话元数据也是 2 小时过期。我跟每个内测用户都解释过这个限制,它是吐槽率最高的问题。更长的过期时间或者"保存到永久"功能已经在计划里了。

"输出图片 token 占 96% 成本"的问题。 我一开始计划内测期间无限生成。算了一下实际的单次成本之后,这个计划就死了。即使只是 1K 分辨率,一天跑个一百次,API 账单就很可观了。额度系统的诞生不是因为商业策略,是因为不想测试阶段就破产。

Prompt 长度 vs 人脸准确度。 这两个东西直接矛盾。场景、服饰、情绪、构图的描述越丰富,模型分配给参考图的注意力就越少。现在的 prompt 已经是精简过的——早期版本长了 3 倍。图片优先架构(先发参考图再发文字)是最大的改善,但这个 tradeoff 始终存在。有时候一张构图很美的照片,人脸的还原度只有 80% 而不是 95%。

配文质量波动。 3 条候选的机制有帮助,但 Gemini Flash 偶尔会生成太直白的文案("今天的泳池好美")或者太抽象到看不懂的文案。"禁止提及品牌名"的规则有时候让模型过度矫正,写得太含糊。我在考虑加一层生成后的关键词过滤——拦住含有违禁词的文案然后重新生成——但那会增加延迟和成本。


这就是 ÉLAN 跟 Gemini 说话的方式。不是一个聪明的 prompt,是一整套分层的指令系统、图片位置的架构决策、并行生成加流式推送、以及一个抓住最差结果的质检循环。

上一篇讲产品愿景。下一篇会更深入灵感卡设计——我怎么做出现在的 18 张卡、每张卡背后的数据结构、以及新卡的编辑流程。


This post is also available in English.


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0