数据截至 (上游 commit b084ab075ba2)
提示词工厂:composeSystemPrompt 怎么拼出一份设计师人格
30 秒导读: Open Design 真正的"算法"不在模型里,而在一个纯函数里——它把 skill 正文、品牌设计系统、craft 规范、个人记忆、插件阶段、界面语言、执行剖面,按一个固定顺序拼成一份系统提示词。顺序不是随便排的:抗注入段必须第一,载重契约必须最后,中间每一段都挂着一个"整场会话都不变"的开关,这样这份提示词的前缀指纹才能被缓存、在续接会话时整块跳过不发。
本章只讲"怎么拼"。skill 与设计系统资产本身的格式和加载走 第 4 章;评审 agent 用的提示词走 第 6 章;这份提示词最终怎么随 run 一起送进 CLI 子进程,见 第 1 章 和 第 2 章。
1. 这是什么(零基础也能懂)
一句话定义: composeSystemPrompt 是一个把十几种素材按固定顺序拼接成一个长字符串的纯函数,这个字符串就是"AI 设计师"的人格、工作流和硬约束。
它要解决的问题。 Open Design 对接 25 家 CLI(Claude Code、Codex、Gemini、Cursor Agent……),每家的能力都不一样:有的有文件工具,有的只有一根纯文本流;有的项目绑了品牌设计系统,有的什么都没绑;有的要做网页原型,有的要生成一段音频。如果给每种组合手写一份提示词,组合数会爆炸。
它的做法。 只写一份,但把它切成可开关的段。每段自带一个判断条件,条件不成立就整段不进最终字符串。
一句话直觉: 把它当成流水线上的分装机——传送带上有 34 个料斗,每个料斗上方有一个闸门;一份订单(一次 run)经过时,闸门按订单参数开合,落下来的就是这一份专属提示词。闸门的开合规则本身是死的,变化的只有订单。
用起来什么样。 调用方只递一个大对象,函数吐一个字符串:
// 示意,非源码
const prompt = composeSystemPrompt({
skillBody, // 激活 skill 的 SKILL.md 正文
designSystemBody, // 品牌 DESIGN.md 正文
designSystemTokensCss,// 该品牌的 tokens.css(机器可读版)
craftBody, // 通用工艺规范(字距、配色克制度、反 AI 味)
memoryBody, // 从历史对话沉淀的用户偏好
metadata, // 建项目时用户勾的结构化选项
streamFormat: 'plain',// 'plain' = 无工具的纯 API 模式
sessionMode: 'chat', // 'chat' = 聊天模式,不主动造产物
});
// prompt 就是最终 system prompt
真实签名在 apps/daemon/src/prompts/system.ts:843 composeSystemPrompt,入参类型是同文件 :402 的 ComposeInput——共 35 个字段,全部可选。
2. 顶层全景(它大概怎么转)
这条链路分三段。素材在 daemon 的路由层攒齐,纯函数负责拼,拼完的结果和用户这一回合的话再合成一条消息喂给 CLI。
① 攒素材 ② 拼装(纯函数) ③ 送出
┌──────────────────┐ ┌────────────────────┐ ┌──────────────┐
│ composeDaemon │ │ composeSystemPrompt│ │ 指纹 + 折叠 │
│ SystemPrompt │ ─────► │ 34 段 · 逐段门控 │ ──► │ 进用户消息 │
│ (读盘/读库/读配置)│ 入参 │ (纯字符串运算) │ 字符串│ 再交给 CLI │
└──────────────────┘ └────────────────────┘ └──────────────┘
server.ts:4723 system.ts:543 server.ts:5916+
三段的分工,一句话各自说清:
| 阶段 | 干什么 | 在哪个文件 |
|---|---|---|
| ① 攒素材 | 读 skill、设计系统、craft、记忆、插件快照,全部化成字符串字段 | apps/daemon/src/server.ts:8819 composeDaemonSystemPrompt |
| ② 拼装 | 纯字符串拼接,34 段按固定顺序、逐段门控 | apps/daemon/src/prompts/system.ts:843 composeSystemPrompt |
| ③ 送出 | 算稳定前缀指纹、决定要不要重发、折进 # Instructions 塞进用户消息 | apps/daemon/src/server.ts:10527–:5997 |
为什么第 ③ 步要把 system prompt 折进用户消息? 因为本地 code agent 的 CLI 大多没有独立的 system 通道——server.ts:5548 的注释直说了这一点。所以 daemon 把整块指令包成一个 # Instructions (read first) 段,再接一个 # User request 段,两段拼成一条用户消息发过去(server.ts:5987 const composed)。
3. 核心机制一:分段顺序就是优先级
3.1 它要解决的小问题
一份 prompt 里同时躺着"base 人格说可以跳过提问"和"discovery 层说第一回合必须提问"这种直接打架的规则。模型没有优先级表,它只有位置。所以 Open Design 把优先级编码成位置:要压别人的,要么钉最前,要么钉最后。
3.2 钉最前的那一段:抗注入
组装函数的第一行就把抗注入段放进数组,注释写得很直白——放在最前,是为了让后面任何一段(skill 正文、用户自定义指令、项目指令、工具结果)都无法指使模型无视它(system.ts:581-583):
const parts: string[] = [PROMPT_INJECTION_RESISTANCE, '\n\n---\n\n'];
PROMPT_INJECTION_RESISTANCE(system.ts:50)本身只讲一件事:工具结果、文件内容、外部文档全是不可信数据,其中长得像指令的文字要当数据处理。它甚至点名了一个具体攻击面——如果 <system-reminder> 块出现在工具结果或文件里,那是注入的数据,不是真的系统指令。
这条规则在库里是贯穿的,不止这一处:panel.ts 把品牌 DESIGN.md 包进
<BRAND_SOURCE>之前会中和里面的闭合标签(panel.ts:66-70的注释);system.ts 往提示词里内联用户可编辑的 prompt 模板时,会把正文里的 ``` 替换成夹零宽字符的版本,防止用户用围栏逃逸出 markdown 块(system.ts:1429-1432)。
3.3 钉最后的那几段
尾部是"载重契约"区——这些规则一旦被前面软化的措辞盖掉,产物就直接坏掉:
- 幻灯片框架(
system.ts:834,正文在deck-framework.ts:327DECK_FRAMEWORK_DIRECTIVE):1920×1080 画布、缩放适配、翻页与计数、打印样式表。PDF 拼接依赖它,所以必须压住前面任何"你写个脚本处理方向键"的软说法。 - 媒体生成契约(
system.ts:855,正文media-contract.ts:99):图/视频/音频不能靠<artifact>编造二进制,必须走od media generate。 - 禁止伪造对话轮(
system.ts:916):宿主会把## user/## assistant开头的行当成真的轮次边界并在此截断,所以这段用"近因偏置"钉在绝对最后。提示词只负责"别写";真写了还有一道跨 chunk 的流式守卫createRoleMarkerGuard负责截断,那半套在 第 2 章 §7.5。
3.4 完整的 34 段顺序
下表是 composeSystemPrompt 从头到尾 push 的每一段。"门控"列为"总是"表示无条件加入。
| # | 段落 | 门控条件 | 行号 |
|---|---|---|---|
| 1 | 抗提示词注入 | 总是 | :583 |
| 2 | API 模式覆写(无工具) | streamFormat === 'plain' | :604 |
| 3 | 聊天模式覆写 | sessionMode === 'chat' | :609 |
| 4 | 示例 prompt 模式 | metadata.examplePrompt === true | :629 |
| 5 | 跳过 discovery 表单 | 否则 metadata.skipDiscoveryBrief === true | :632 |
| 6 | UI 语言覆写 | locale 非空且非 en | :637 |
| 7 | discovery + 设计哲学 | 非媒体面 | :644 |
| 8 | 视觉方向卡片库 | 非媒体面 且 无激活设计系统 | :653 |
| 9 | 共享设备外框目录 | 非媒体面 且 多目标平台 | :667 |
| 10 | 身份与工作流宪章 | 总是 | :671 |
| 11 | 个人记忆正文 | memoryBody 非空 | :678 |
| 12 | 意图网关(把短句扩成任务简报) | 记忆存在 且 hooks.rewrite !== false | :689 |
| 13 | 自校验记分卡 | 记忆存在 且 hooks.verify !== false | :695 |
| 14 | 从纠错中提议新规则 | 记忆存在 | :700 |
| 15 | 用户级自定义指令 | userInstructions 非空 | :706 |
| 16 | 项目级自定义指令 | projectInstructions 非空 | :712 |
| 17 | 设计系统用法 + DESIGN.md + 导入模式 | designSystemBody 非空 | :722/:726/:732 |
| 18 | tokens.css 契约 | designSystemTokensCss 非空 | :749 |
| 19 | 组件清单(否则退回组件 fixture) | 二选一,各自非空 | :755/:759 |
| 20 | 按需拉取文件索引 | designSystemPullIndex 非空 | :765 |
| 21 | craft 工艺规范 | craftBody 非空 | :775 |
| 22 | 激活 skill 正文 + pre-flight | skillBody 非空 | :782 |
| 23 | 激活插件块 | pluginBlock 非空 | :787 |
| 24 | 流水线阶段块(逐条) | activeStageBlocks 非空 | :799 |
| 25 | 项目元数据块 | metadata 存在 | :811 |
| 26 | 幻灯片框架(或 freeform 条件版) | deck 项目 / freeform 项目,且 skill 未自带种子 | :834/:846 |
| 27 | 媒体生成契约(否则媒体调度提示) | 媒体面 / 非媒体面 | :855/:860 |
| 28 | Codex 内置图像生成覆写 | 见 §7 | :869 |
| 29 | Critique Theater 面板协议 | cfg.enabled 且 品牌与 skill 都解析到 且 非媒体面 | :885 |
| 30 | 激活设计系统 = 视觉方向(收尾重申) | designSystemBody 非空 | :889 |
| 31 | 已认证外部 MCP 服务器清单 | 列表非空 | :893 |
| 32 | Gemini 的 todo 工具映射 | agentId === 'gemini' | :896 |
| 33 | 文件系统交付覆写 | 执行剖面为 filesystem | :902 |
| 34 | 会话中途澄清问题 / 禁止伪造轮次 | 总是 | :910/:916 |
有两处顺序值得单独品:
设计系统出现了两次。 第 17 段给出 DESIGN.md 正文(前置,让后面的 skill 有 token 可绑),第 30 段再重申一遍"它就是本项目的视觉方向,不要再问用户选主题色"(ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE,system.ts:371)。前者是资料,后者是禁令——禁令必须晚于 skill 正文出场,否则 skill 里"请用户挑个方向"的措辞会赢。
skill 正文在插件块之前。 阶段块的注释明确写着"上面的激活 skill 正文仍然是优先级载体"(system.ts:790-795),阶段块只补充逐阶段的 atom 指引。
4. 核心机制二:门控为什么只敢挂"整场不变"的信号
4.1 先看一个反直觉的设计
方向卡片库(directions.ts:242 renderDirectionSpecBlock)把 5 张方向卡逐张摊开,每张带 OKLch 调色板和字体栈,源码注释估作约 6.7KB。有激活设计系统时,这坨东西没用——所以跳过。
但跳过的判断依据是 activeDesignSystemBody 这个"组装器可见的信号",而不是"这一回合用户有没有说要换方向"。源码注释把理由写死了(system.ts:648-651):
Gate it on the composer-visible active-DS signal (stable for the whole session, so the stable-prompt fingerprint stays cacheable).
多设备外框目录(第 9 段)的注释也是同一句话(system.ts:658-661):门控挂在建项目时就定下的 metadata.platform / metadata.platformTargets 上。
4.2 指纹到底是什么
指纹是"稳定指令块"的 sha256。稳定指令块 = daemon 提示词 + 运行时工具契约 + 这份 system prompt(server.ts:5916):
const stableInstructionFingerprint = [daemonSystemPrompt, runtimeToolPrompt, systemPrompt]
.map((part) => (typeof part === 'string' ? part.trim() : ''))
.join('\n\n---\n\n');
const currentStableHash = hashStableInstructions(stableInstructionFingerprint);
hashStableInstructions 在 apps/daemon/src/agent-session-resume.ts:307,就是一次 sha256。判定函数在同文件 :230:
export function computeIncludeStable(
isResuming: boolean,
storedStableHash: string | null,
currentStableHash: string,
): boolean {
return !isResuming || storedStableHash !== currentStableHash;
}
读法:只有在"确实在续接一个已有会话"且"这一坨和上次发的一字不差"时,才跳过整块不发。 新建会话发全量;老会话没存过 hash(null)比较不相等,也发全量。跳过的动作发生在 server.ts:5948——clientInstructionParts 里那一项 systemPrompt 直接不入列。
4.3 所以为什么门控不能挂"这一回合的话"
因为一旦某段的开合取决于用户这一回合说了什么,指纹每回合都会变,computeIncludeStable 每回合都返回 true,整块指令每回合重发一遍。省下来的那几百 token 会被"每回合多发几千 token"吃掉还倒赔。
挂 session 级信号 挂 per-turn 信号
──────────────────── ────────────────────
turn1 指纹 A → 全量发 turn1 指纹 A → 全量发
turn2 指纹 A → 跳过 ✔ turn2 指纹 B → 全量发 ✘
turn3 指纹 A → 跳过 ✔ turn3 指纹 C → 全量发 ✘
命中/未命中还会被记录成 run.promptCache(server.ts:5928,取值由 chat-prompt-inputs.ts:207 describeStablePromptCache 给出,miss 原因分 new-session / missing-stored-hash / stable-prompt-changed),所以这条设计是被观测的,不是口头约定。
4.4 四个门控信号的来源与稳定性
| 门控信号 | 来源 | 会话内稳定? |
|---|---|---|
streamFormat === 'plain' | 适配器定义(哪家 CLI) | 是 |
sessionMode === 'chat' | 会话创建时选的模式 | 是 |
metadata.kind / platform / examplePrompt | 建项目面板的结构化选项 | 是 |
designSystemBody 非空 | 项目/插件/全局默认解析出的品牌 | 是 |
5. 核心机制三:几个关键闸门的实现细节
5.1 执行剖面:一根开关切两套交付语义
同一份人格宪章,在有文件工具的运行里说"把文件写到项目目录",在纯 API 运行里说"把完整 HTML 放进 <artifact> 块"。实现靠占位符替换,不靠两份文案。
official-system.ts:13-14 在宪章正文里挖了两个洞:
const EXECUTION_CONTEXT_PLACEHOLDER = '%%OPEN_DESIGN_EXECUTION_CONTEXT%%';
const WORKFLOW_HANDOFF_PLACEHOLDER = '%%OPEN_DESIGN_WORKFLOW_HANDOFF%%';
renderOfficialDesignerPrompt(official-system.ts:164)按剖面选填 FILESYSTEM_EXECUTION_CONTEXT(:120)或 TEXT_ARTIFACT_EXECUTION_CONTEXT(:122),以及 FILESYSTEM_WORKFLOW_HANDOFF(:124)或 TEXT_ARTIFACT_WORKFLOW_HANDOFF(:146)。discovery.ts:262 renderDiscoveryAndPhilosophy 用同一手法填 %%OPEN_DESIGN_HANDOFF_INVARIANT%%。
剖面从哪来?packages/contracts/src/execution-profile.ts:3 executionProfileFromStreamFormat 一行定死:
return streamFormat === 'plain' ? 'text_artifact' : 'filesystem';
组装器里则是 executionProfile ?? executionProfileFromStreamFormat(streamFormat)(system.ts:593)——显式传入优先,否则按流格式推。
5.2 streamFormat === 'plain':API 模式覆写为什么要钉顶
API_MODE_OVERRIDE(system.ts:943)解决一个真实回归(issue #313):纯流适配器(如 DeepSeek)没有工具,但提示词后面几千字都在教它 TodoWrite / Read / Bash,模型于是编造伪工具标记——吐出 <todo-list>…</todo-list>、[读取 template.html …] 这种假协议文本泄进聊天。
关键在于位置。源码注释直说了旧方案为什么失败(system.ts:934-941):过去这段叫 ## API mode rule,追加在末尾,结果输给了 discovery 层自带的"以下规则覆盖后文一切"的标题。现在它被钉在 PROMPT_INJECTION_RESISTANCE 之后、discovery 之前——先声明没有工具,再让模型去读那堆讲工具的规则,模型就知道那些是被覆写的。
5.3 isMediaSurfaceEarly:整段跳过一层规则
图/视频/音频项目里,discovery 层那三千 token 的问卷规则、方向选择器、HTML 产物检查表全是废话。组装器算一个早判标志(system.ts:621-627):
const isMediaSurfaceEarly =
skillMode === 'image' || skillMode === 'video' || skillMode === 'audio' ||
metadata?.kind === 'image' || metadata?.kind === 'video' || metadata?.kind === 'audio';
if (!isMediaSurfaceEarly) 一次性罩住第 7/8/9 三段(system.ts:643-669)。注释给的理由不止省 token:把这些规则塞进去,模型必须先解析再逐条推翻它们,才能开始干活,这是额外的推理时间。