System Prompt 四层组装
你以为 System Prompt 是部署时写死的一段字符串。它不是。它是一个四层结构——sections、contexts、tools、variables——通过 global/scope layers 收集、name-merge 覆盖、order 排序、strict interpolation、waterfall 拦截,最终组装成 PromptAssembly。这个过程每次模型调用都重跑一次。
一段字符串解释不了它
打开一次模型请求之前,Harness 要准备的不只是那句 “You are a helpful assistant…”。工具描述、persona、当前工作目录、git 分支、已打开文件、运行时上下文都会参与进来。如果把这些都理解成“几段字符串最后 join 一下”,后面很多代码会读不顺。
这里真正要分清的是三件事:哪些内容属于 system prompt,哪些内容应该作为 user-role context 进入请求,哪些内容只是供模板渲染时引用的变量。
它实际是什么
Harness 的 System Prompt 不是一段字符串。它是一个叫 PromptAssembly 的结构体,有四个严格分离的层面:
- Sections —— 有序命名文本块,组成传统意义上的 system prompt。
- Contexts —— 动态运行时上下文,渲染后变成 user-role 消息,不进 system prompt。
- Tools —— 工具 schema 列表,有独立排序规则。
- Variables —— 键值对,供 sections 和 contexts 中的
{{name}}引用。严格模式:未定义即抛错。
这四层通过 global + scope layers 收集、name-merge 覆盖、order 排序、waterfall 拦截,每次模型调用都完整重跑一遍。没有缓存,没有”拼好就不动了”。
从最简场景推导:一个 section 怎样进入 prompt
我们从最小的情况开始:你启动 Harness,什么插件也没加,只有默认配置。
SystemPrompt 服务在构造函数里做了两件事:
// 简化后的构造逻辑
if (config.includeHarnessIdentity ?? true) {
this.section({
name: 'harness:identity',
order: -100,
text: 'You are an AI agent powered by DeepSeek Harness.',
})
}
this.section({
name: PERSONA_SECTION, // 'deployment:persona'
order: PERSONA_ORDER, // 0
text: config.persona ?? '',
})
两个 section 注册到了 global layer。harness:identity 的 order 是 -100,deployment:persona 的 order 是 0。数值越小越靠前。
现在调用 assemble()。这个方法做的第一步是取层级链:
const scopeLayers = this.layers.chainLayers(scope)
因为我们没传 scope,scopeLayers 是空数组。只有 global layer 参与。
接下来收集 variables。global layer 里没人注册过 variable,所以 variables 是空对象 {}。
然后 merge sections。this.layers.merge(scope, layer => layer.sections) 从 global layer 取出所有 sections(scope layer 为空,没有覆盖),结果是 harness:identity 和 deployment:persona 两个 section 的 map。
按 order 排序:-100 在前,0 在后。
检查 complete sections:两个都不是 complete,通过。
收集工具:没有 tool provider,tools 为空数组。
收集 contexts:没有 context provider,contexts 为空数组。
跑 waterfall:没有 listener,assembly 原样返回。
最终调用 renderPrompt(assembly):
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections
.map(section => interpolate(section, assembly.variables, 'section'))
.filter(text => text.length > 0)
.join('\n\n')
}
两个 section 的文本都不含 {{variable}} 引用,interpolate 原样返回。persona 是空字符串,被 filter 过滤掉。最终 prompt 只剩一行:
You are an AI agent powered by DeepSeek Harness.
这就是最简场景。一个 section 从注册到进入 prompt 的完整路径:注册 → 进入 global layer 的 NamedEntries → assemble 时被 merge 取出 → 按 order 排序 → 跑 waterfall → render 时 interpolate + filter empty + join。
现在你加一个 persona:
await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness.' })
persona 不再是空字符串,不会被 filter 掉。最终 prompt 变成:
You are an AI agent powered by DeepSeek Harness.
You are DeepSeek Harness.
两段之间用 \n\n(空行)分隔。每个 section 是一个独立段落。
如果你想关掉 harness identity:
await ctx.plugin(SystemPrompt, {
includeHarnessIdentity: false,
persona: 'You are a helpful software engineer assistant.',
})
构造函数不会注册 harness:identity,最终只有 persona 一段。
这就是 sections 层的最简工作原理:命名 + 有序 + 空文本自动过滤。
复杂场景
多 section 与 order 约定
实际部署中,sections 不止两个。插件会注册自己的 section:
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => `cwd: ${process.cwd()}` })
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
注意 text 可以是函数。每次 assemble 时重新调用,拿到当时的值。这不是缓存——每次模型调用都会重新求值。
order 约定是:
- 负数(如 -100):harness 级别的身份声明,在 persona 之前。
- 0:部署 persona。
- 正数小值(10、20):通用规则和环境信息。
- 100-199:工具使用指导。
最终顺序只看数值,不看注册顺序。你先注册 order: 20 的,再注册 order: 10 的,最终 10 在前面。
scope 覆盖:同名 section 的替换语义
当你创建一个 agent scope 并在那个 scope 里注册了一个与 global 同名的 section,scope 层的会替换 global 层的。不是追加,不是合并,是整个替换。
// global 层
ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the deployment.' })
// scope 层(agent 专用)
scope.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
当 assemble({ scope: agentScopeKey }) 时,persona 是 “You run tests.”。当 assemble() 不传 scope 时,persona 是 “You are the deployment.”。
关键细节:被覆盖的 global section 的 text provider 不会被调用。不是”调用了但丢弃结果”,而是根本不调用。merge 发生在 text 求值之前——先决定谁参与,再求值。这避免了有副作用的 provider 在被覆盖时仍然执行的问题。
同层内不允许同名 section。你在 global 层注册两个叫 dup 的 section,第二次注册直接抛异常:prompt section "dup" is already registered。错误信息还会提示你:“for a per-agent override, register through that agent’s agent.ctx instead”。
variable 插值:严格模式
sections 和 contexts 的文本里可以写 {{variable_name}}。render 阶段会做插值替换。
严格模式意味着三种情况会抛错:
情况一:引用了未注册的变量。
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'on {{modle}}' })
ctx.systemPrompt.variable('model', () => 'm')
// renderPrompt 时抛:unknown prompt variable "{{modle}}" in section "persona"; registered variables: model
它甚至会告诉你已注册的变量列表——帮你发现 typo。
情况二:变量已注册但值为 undefined。
ctx.systemPrompt.variable('cwd', () => undefined)
ctx.systemPrompt.section({ name: 's', order: 0, text: 'in {{cwd}}' })
// renderPrompt 时抛:prompt variable "{{cwd}}" has no value for this assembly (section "s")
provider 返回 undefined 是合法的(表示”这个变量在这个 scope/context 下没有值”),但如果有 section 引用了它,render 阶段会报错。
情况三:格式畸形。
// {{ model }} 中间有空格 → 抛 malformed 错误
// {{}} 空名称 → 抛 malformed 错误
// {{{model}}} 额外花括号 → 抛 malformed 错误
变量名必须匹配 /^[a-z][a-z0-9_]*$/:小写字母开头,只含小写字母、数字、下划线。
还有一个重要的安全细节:替换后的值不会被再次扫描。如果变量 model 的值是 "literal {{sneaky}} inside",render 后就是字面量 literal {{sneaky}} inside,不会去找一个叫 sneaky 的变量。这防止了注入攻击。
另一个细节:Object.hasOwn(variables, name) 而不是 name in variables。这意味着即使变量名碰巧叫 constructor 或 toString,也不会从 Object.prototype 上取到值。只有显式注册的变量才算。
variable 的 scope 覆盖
变量也遵循”近覆盖远”的规则:
// global
ctx.systemPrompt.variable('mode', () => 'normal')
// scope
scope.ctx.systemPrompt.variable('mode', () => 'strict')
assemble 时先收集 global variables,再按 scope chain 从远到近覆盖。最近的 scope 赢得同名变量的值。
complete section:完整替换语义
有些场景下,你不想让 Harness 的默认 identity、persona、rules 这些 section 出现在 prompt 里。你想完全控制 system prompt 的内容。这时候用 complete: true:
ctx.systemPrompt.section({
name: 'complete',
order: 10,
text: 'You are a code reviewer. Review the diff below.',
complete: true,
})
assemble 仍然会跑完整流程——收集所有 sections、tools、variables、contexts,跑 waterfall。但是 waterfall 跑完之后,sections 会被强制替换为只有这一个 complete section。waterfall listener 往 assembly.sections 里加的东西全部被丢弃。
重点:complete section 的文本是在 waterfall 之前快照的。waterfall listener 可以修改 assembly.sections 里那个 complete section 的 text 字段——但没用。assemble 在 waterfall 前记住了它的原始文本,waterfall 后恢复。
// 测试证明:
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
const complete = assembly.sections.find(s => s.name === 'complete')
complete.text = 'mutated' // 修改了
assembly.sections.push({ name: 'late', text: 'late' }) // 追加了
return next()
})
// 最终 assembly.sections 仍然是 [{ name: 'complete', text: 'Exact prompt.' }]
如果同时有两个 complete: true 的 section 生效,assemble 直接抛错:multiple complete prompt sections are active: "first", "second"。不会合并,不会选一个,直接失败。
runtime contexts:不在 system prompt 里
contexts 和 sections 长得很像——有 name、order、text。但它们的归宿完全不同。
sections 渲染后拼成 system prompt(system role 消息)。contexts 渲染后变成一条 user-role 消息,带一个固定前缀:
Current runtime context. This snapshot supersedes earlier runtime-context snapshots.
{context 1 text}
{context 2 text}
为什么是 user role?因为 runtime context 是变化的状态快照(当前目录变了、git 分支切了、打开的文件换了),而 system prompt 是不变的身份设定。把变化的东西放在 user 消息里,模型才会把它当”最新信息”处理。前缀 “This snapshot supersedes earlier runtime-context snapshots” 明确告诉模型:以前的快照作废了,看这个。
contexts 也支持 scope 覆盖和 suppress。suppressRuntimeContext() 可以让某个 scope 完全不注入 runtime context——适用于不需要环境感知的 agent。
tools 排序
工具列表有独立的排序逻辑。orderTools() 做两件事:
-
如果没配置
toolOrder,所有工具按名称字典序(code-unit 比较,locale-independent)排列。不管谁先注册的,最终顺序只看名字。 -
如果配置了
toolOrder,格式是一个字符串数组,里面必须恰好包含一次'<unlisted-tools>'。列出的工具按数组顺序排,没列出的工具收集起来按字典序排,插入到<unlisted-tools>的位置。
// 配置
toolOrder: ['todo_write', '<unlisted-tools>', 'bash']
// 注册的工具:bash, echo_b, todo_write, echo_a
// 最终顺序:todo_write, echo_a, echo_b, bash
toolOrder 里出现了未注册的工具名?抛错。配置了重复的工具名?抛错。没放 <unlisted-tools>?抛错。validation 在 load 时和 assemble 时分别做——load 时检查格式,assemble 时检查名称是否存在。
有一个微妙点:knownNames 和 schemas 的区别。tool provider 可以返回 { schemas: [...], knownNames: [...] }。knownNames 是”我知道的所有工具名”,schemas 是”这次 assembly 实际可见的工具”。一个工具可能因为权限被隐藏(不在 schemas 里),但它仍然在 knownNames 里——所以 toolOrder 引用它不会抛错,只是这次 assembly 里看不到它。
waterfall:最终拦截点
system-prompt/assemble 是一个 waterfall 事件。listener 按注册顺序排列,每个 listener 收到当前 assembly,可以修改它,然后调 next() 传给下一个 listener。
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
assembly.sections.push({ name: 'from-listener', text: 'extra guidance' })
assembly.variables['extra'] = 'from-waterfall'
return next()
})
waterfall 的关键特性:
- listener 可以追加 section、修改 text、添加 variable、追加 tool。
- listener 可以不调
next(),直接返回一个全新的 assembly——这是 short-circuit,后续 listener 全跳过。 - listener 追加的 section 不会被重新排序——canonicalization(order sort、tool order)在 waterfall 之前已经做完了。listener 自己负责它追加内容的位置。
- scope-filtered dispatch:在 scope context 注册的 listener 只在那个 scope 的 assemble 时被调用。global listener 每次都被调用。
但是——waterfall 的修改不能绕过 complete section 恢复。如果有 complete section,waterfall 跑完后 sections 会被强制替换为快照的 complete section。waterfall 往 sections 里加的东西都白加了。
这设计保证了:声明 complete: true 的 section 就是最终的 system prompt,任何插件都无法偷偷往里面塞东西。
disposal 与变更通知
每个 section()、context()、tools()、variable() 调用都返回一个 disposer 函数。调用它就注销这个注册。注销后下次 assemble 不再包含它。
注册和注销都会触发 system-prompt/change 事件。这让依赖方知道 prompt 结构变了,需要重新 assemble。
如果一个 Cordis fiber(插件实例)被 dispose,它通过 ctx 注册的所有 prompt 贡献自动注销。这保证了 HMR(热模块替换)安全——卸载一个插件不会在 prompt 里留下残余。
失败边界
边界一:variable 未定义不会静默失败
这是最常踩的坑。你在 persona 模板里写了 {{workspace}},但那个注册 workspace 变量的插件还没加载(或者在某个 scope 下它返回了 undefined)。你不会得到一个缺了一块的 prompt——你会得到一个抛到调用栈最顶层的异常。
为什么这样设计?因为 prompt 里出现一个 {{workspace}} 字面量(或者一个空位)比抛错更糟糕。模型会把它当指令执行,或者行为完全不可预测。fail loud 比 silent garbage 好。
错误信息会告诉你:哪个 variable、在哪个 section/context 里、已注册的变量列表是什么。足够你定位问题。
边界二:同名 section 是覆盖不是追加
你在 agent scope 里注册了一个叫 deployment:persona 的 section,想”给 persona 加点东西”。结果 global 的 persona 消失了——被你的 section 完全替换了。
这不是 bug,是 feature。agent preset 覆盖 deployment persona 就是靠这个机制。但如果你只是想追加,你应该用一个不同的 name(比如 agent:extra-rules)和一个略大的 order。
边界三:complete section 是核武器
你标记了 complete: true,然后发现:
- harness identity 不见了
- 你精心注册的 rules section 不见了
- waterfall listener 追加的 guidance 不见了
全部被丢弃。只剩你那个 complete section 的文本。而且是 waterfall 之前快照的文本——waterfall 修改也被丢弃。
这是设计意图:complete 意味着”我完全控制 system prompt 的内容,不接受任何干扰”。但 tools 和 variables 仍然正常工作——complete 只替换 sections,不影响其他三层。
边界四:toolOrder 配了不存在的工具名
你从另一个项目抄了一份 toolOrder 配置,里面有 ghost_tool。你的项目没注册这个工具。assemble 时直接抛错:toolOrder lists unregistered tool "ghost_tool"。
注意不是”静默忽略”。Harness 认为配置里出现不存在的工具名是 misconfiguration——可能是 typo,可能是忘了装插件。fail fast 比 silent ignore 好。
但有一个例外:如果那个工具在 knownNames 里(provider 报告它知道这个工具),只是在当前 assembly 的 schemas 里被隐藏了(比如因为权限),那不算错误——只是这次看不到。
边界五:contexts 不在你以为的地方
你写了一个 context provider,注册了一个 name: 'git-status' 的 context。你去看模型收到的 system prompt——里面没有 git status 的信息。
因为 context 不在 system prompt 里。它在消息列表里,作为一条 user-role 消息出现在对话尾部。你需要看的是 messages 数组的最后几条,不是 system prompt 字段。
边界六:waterfall listener 追加的内容不受 order 排序
waterfall listener 往 assembly.sections 里 push 的 section,不会被重新按 order 排列。canonicalization 在 waterfall 之前已经完成了。如果你在 waterfall 里 push 了一个 order: -999 的 section,它出现在数组最后面,不会跑到最前面。
你如果需要精确控制位置,要用 splice 插入到正确的 index,或者在 waterfall listener 里 sort 整个数组——但后者会打乱其他 listener 的预期。最佳实践是:如果你需要参与排序,注册为正常 section,不要在 waterfall 里动手脚。
边界七:Object.prototype 不是你的变量
有人好奇:如果我引用 {{constructor}},会不会从 Object.prototype 上拿到 constructor 函数然后塞进 prompt?
不会。interpolate 用 Object.hasOwn(variables, name) 检查,而不是 name in variables。prototype chain 上的属性对它来说不存在。未注册就是未注册,抛 unknown 错误。
但如果你真的注册了一个叫 constructor 的变量(名称符合 [a-z][a-z0-9_]* 规则),它能正常工作——Object.hasOwn 会返回 true,取到你注册的值。
边界八:invariant 守卫
system-prompt-invariant 插件在 waterfall 的最外层(prepend + global)注册了一个 validator。它检查 waterfall 返回的最终 assembly:
- section name 必须非空且不重复
- context name 必须非空且不重复
- tool name 必须非空
- variable name 必须匹配命名规则
- text 字段必须是 string
如果某个 waterfall listener 返回了畸形的 assembly(比如往 sections 里塞了一个 name: '' 的条目),invariant 会拦截并抛错。这是最后一道防线。
完整组装流程图
把上面所有环节串起来:
flowchart TD
START["systemPrompt.assemble(context)"] --> CHAIN["chainLayers(scope) 取层级链"]
CHAIN --> VARS["收集 variables: global → scopes(远→近覆盖)"]
VARS --> MERGE["merge sections by name: scope 覆盖 global"]
MERGE --> SORT["sections 按 order 升序排列"]
SORT --> COMPLETE_CHECK{"几个 complete section?"}
COMPLETE_CHECK -->|"0"| SNAPSHOT_NONE["completeSection = undefined"]
COMPLETE_CHECK -->|"1"| SNAPSHOT_ONE["快照 completeSection 文本"]
COMPLETE_CHECK -->|">1"| THROW["抛错: multiple complete"]
SNAPSHOT_NONE --> TOOLS
SNAPSHOT_ONE --> TOOLS
TOOLS["收集所有 tool providers → orderTools 排序"] --> CTX_CHECK{"runtimeContext suppressed?"}
CTX_CHECK -->|"是"| CTX_EMPTY["contexts = []"]
CTX_CHECK -->|"否"| CTX_COLLECT["merge contexts by name → order 排序"]
CTX_EMPTY --> WF["system-prompt/assemble waterfall"]
CTX_COLLECT --> WF
WF --> RESTORE{"completeSection 存在?"}
RESTORE -->|"是"| FORCE["sections = [completeSection], contexts = []"]
RESTORE -->|"否"| KEEP["保留 waterfall 结果"]
FORCE --> DONE["返回 PromptAssembly"]
KEEP --> DONE
连接下一章
System Prompt 组装完成后,它和工具列表一起,作为模型请求的一部分发送出去。但模型请求还需要另一样东西:历史消息。你可能以为历史消息是从 UI 的 state 里直接取的——发过什么存什么,读出来就是历史。下一章你会看到,Harness 的历史消息不是存储的——它们从事件日志投影重建。