提示词装配:section、context、variable 与工具 schema
30 多个匿名投稿者,每个 agent 一份不同结果
本章基准:dsh
0.1.1-rc.2,commitb150a551b8d465e31e418e1b2eaf5e79bbb7d28e主要源码:
packages/core/system-prompt/src/(605 行)、packages/core/scope/src/(561 行),以及 30 多个往注册表里投稿的插件包
上一章讲了"模型看到的历史"从哪来。这一章讲另外两样东西:system prompt 和 tool schema。它们不在会话日志里,因为它们不是历史——它们在每个 step 之前重新装配一次。
问题的规模先说清楚。dsh 里有 30 多个包往提示词里投稿:tool-fs 的三个工具各自要一段使用指引、sandbox-policy 要告诉模型当前沙箱模式、plan-mode 要在计划模式下改变行为、persona 要替换掉整个人格、tool-cordis 要解释怎么修改自己的插件树。它们互相不知道对方存在,也不知道自己在最终 prompt 里排第几。
而且同一个进程里可能同时跑好几个 agent:主 agent 有全套工具,一个 subagent 只有只读工具且人格不同,一个 Code Mode agent 连工具列表都被折叠成一个 run_code。它们共享同一个注册表实例。
这一章讲这套注册表怎么在"任意多个匿名投稿者"和"每个 agent 看到不同结果"之间同时成立。
全章图示:assets/ch06-prompt-assembly.svg
1. 四种投稿:section、context、variable、tools
SystemPrompt 服务(system-prompt/src/index.ts:338)只暴露五个注册方法,其中四个是投稿:
section(section: PromptSection): () => void // 进 system prompt
context(context: PromptContext): () => void // 进 user-role 快照
variable(name, provider): () => void // {{插值}}
tools(provider): () => void // 工具 schema
suppressRuntimeContext(): () => void // 关掉本 scope 的全部 context
每一个都返回 Cordis effect disposer(第 2 章讲的那个)——注册即 effect,插件卸载自动摘掉投稿。
section 和 context 的区别是本章第一个关键点。 它们的输入结构几乎一样(name + order + text),但去向完全不同:
| section | context | |
|---|---|---|
| 去哪 | system prompt 字符串 | 一条 user-role 消息 |
| 进日志吗 | 不进 | 进(user/message 事件) |
| 变化时 | system prompt 变了 | 追加一条新快照 |
为什么要分成两个?因为动态内容放进 system prompt 会毁掉 KV cache。当前时间、当前沙箱模式、当前 todo 列表——这些每个 step 都可能变。如果它们在 system prompt 里,system prompt 每个 step 都是新的,provider 端的前缀缓存全部失效。
所以 dsh 把它们赶到历史尾部:context 的投稿被渲染成一条 user message 追加进日志(第 4 章的 RuntimeContextProjection),system prompt 保持稳定。而且这条 user message 是日志事件——符合上一章那条"model-visible ⟺ logged"。
sandbox-policy 是个标准例子(sandbox-policy/src/index.ts:113):
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.context({
name: 'sandbox:policy',
order: 110,
text: (context) => {
const session = context.agent?.session
return session === undefined ? '' : renderPolicyContext(this.resolve({ session }))
},
})
})
三个细节:它注册的是 context 不是 section(沙箱模式会被会话中途改变);text 是函数,每次装配重新求值;没有 agent 时返回 '',而空文本不贡献任何东西(renderContextSections 里 .filter(section => section.text.length > 0))。
拼装侧有一处小优化值得看,joinContextSections()(index.ts:236)和 renderContextSections() 被拆成两个导出:
export function renderContextSnapshot(assembly: PromptAssembly): string {
return joinContextSections(renderContextSections(assembly))
}
因为消费者要两样东西:模型要拼好的一整段,UI 要按贡献者归属的分段。如果只导出拼好的版本,UI 就得去重新切分那段散文。所以渲染一次、两处消费。agent.ts:232 用的正是这个:
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
拼出来的文本带一句固定的抬头:
Current runtime context. This snapshot supersedes earlier runtime-context snapshots.
这句话是模型能正确处理多个快照的全部依据——它明确说了"新的覆盖旧的",所以历史里堆着五个快照时模型知道该看哪个。搭配第 4 章那个 CLEARED 常量(从有上下文变成没有时写一条"none"),一个纯追加的日志就表达出了"当前值"的语义。
2. order:一个数字,一套约定
section 的排序就是 order 升序。注释里给了约定(index.ts:56):
| order | 谁 |
|---|---|
-100 |
harness:identity:You are an AI agent powered by DeepSeek Harness. |
0 |
deployment:persona:部署方的人格 |
50 |
plan-mode 的计划模式指引 |
100–199 |
工具使用指引 |
只有 -100 和 0 是硬编码在注册表构造函数里的(index.ts:357),其余全靠投稿者自觉遵守数字区间。这是个弱约定:没有任何机制阻止一个插件用 order: -1000 抢到 identity 前面。选弱约定的理由很实际——真正需要"排第一"的只有 identity,而真正需要精确相对顺序的只有工具指引之间,中间大片区域谁在前谁在后无所谓。
用一个数字而不是"依赖声明"(after: 'persona')也是同一个取舍:拓扑排序能表达精确关系,但它要求投稿者知道彼此的名字,而这里的投稿者恰恰是互不知情的。数字让每个插件只需要知道自己大概属于哪一档。
3. {{variable}}:为什么严格到会抛错
变量插值只支持一种形态:{{name}},名字匹配 /^[a-z][a-z0-9_]*$/。interpolate()(index.ts:258)在四种情况下抛错:
// 1. 畸形引用(有后续的 }} 说明作者想写引用但写错了)
throw new Error(`malformed prompt variable reference at "…" in ${kind} "${input.name}" …`)
// 2. 名字不合法(含 {{}} 空名)
throw new Error(`malformed prompt variable reference "{{${name}}}" …`)
// 3. 未注册的名字
throw new Error(`unknown prompt variable "{{${name}}}" …; registered variables: …`)
// 4. 注册了但这次装配没值
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly …`)
为什么不能像大多数模板引擎那样,未知变量渲染成空串?因为这段文本要发给模型,而模型不会告诉你它读到了半句话。一个部署方写了 Your working directory is {{cwd}}.,变量名打错成 {{cmd}},宽松渲染的结果是模型收到 Your working directory is .——完全合法的句子,完全错误的信息,而且没有任何地方会报错。抛错是唯一能让这个 bug 在第一个 step 就暴露的方式。
三个细节体现了这个"严格但不神经"的分寸:
孤立的 {{ 是普通散文。 后面没有任何 }} 时,{{ 原样保留。因为提示词里真的可能出现字面的花括号(讲代码、讲 Jinja 模板),只有"看起来确实想写引用"(后面有 }})才判定为畸形。
替换进去的值不再被扫描。 result += text.slice(last, open) + value 之后 last 直接跳到组后面。所以一个变量值里含 {{x}} 不会被二次插值——否则用户输入就能构造注入。
用 Object.hasOwn 而不是 name in variables。 注释写明了:Do not resolve unregistered names through Object.prototype。否则 {{constructor}} 或 {{tostring}} 之类的名字会意外命中原型链上的东西。
变量本身由谁提供?agent-loop 注册了三个(agent-loop/src/index.ts:351):
ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
注意全都可能返回 undefined(诊断路径下没有 agent)。所以第 4 种错误存在:变量注册了但这次没值。这个区分很有用——报错文本能告诉你"这个名字我认识,但这次装配它没有值",而不是"没这个名字"。
4. scope:同一个注册表,每个 agent 看到不同结果
这是本章最核心的机制。packages/core/scope(561 行)是一个独立的小包,被 system-prompt、tools、以及别的注册表共用。
它的基础是一个不透明的身份对象:
export type ScopeKey = object
createScope(ctx, key)(scope/src/index.ts:137)在 ctx 下开一个 Cordis fiber,然后把 key 挂在扩展出来的 context 上:
const fiber = ctx.plugin(scope)
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
于是任何通过 scoped 或它的子 context 注册的东西,都自动带上这个 scope 标签——scopeOf(ctx) 一读就知道。投稿者完全不需要知道 scope 存在:tool-fs 照常写 ctx.systemPrompt.section(...),它在全局树里挂载就是全局投稿,在某个 agent preset 里挂载就自动是那个 agent 的投稿。
一个 WeakMap,两个相反的方向
scope/src/index.ts:39 那段注释是整个包最值钱的一句:
One relation powers both directions of scope nesting: registration views inherit DOWN the chain (a child scope sees its ancestors' layers), and event admission extends UP it (a listener tagged with an ancestor receives events dispatched to a descendant key).
同一个 scopeParents: WeakMap<ScopeKey, ScopeKey>,被两个方向的机制共用:
注册视图向下继承。 一个子 scope 能看到祖先的投稿。这样一个 agent preset 可以给它下面所有 agent 装一段共同的指引。
事件准入向上扩展。 一个挂在祖先 scope 上的监听器,能收到子 scope 派发的事件。这样一个 standing composition 能观察它composed 出来的每个 agent。
但事件永不向下流。 scopeTarget()(scope/src/index.ts:170)的过滤器把这条写死了:
const tag = scopeOf(ctx)
if (tag === undefined) return true // 无标签监听器:全局收
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {
if (cursor === tag) return true // 标签是派发 key 或其祖先:收
}
return false // 标签在派发 key 之下:不收
A tag BELOW the dispatch key stays excluded。这一条是兄弟 agent 之间隔离的全部保证:agent A 的 pre-step 事件不会流到 agent B 的监听器,因为 B 的标签既不是 A 也不是 A 的祖先。
父链的写入还被锁上了。bindScopeParent()(scope/src/index.ts:72)只能绑一次,重绑需要原始绑定者持有的 ScopeParentBinding 句柄:
there is no open re-link path, so a scope's ancestry cannot be moved by anyone but the original binder
以及每次写入都做环检查——因为每个链消费者都会一路 walk 到根,一个环就是死循环。
层:全局一个,每个 scope 一个
ScopedLayers(scope/src/store.ts:159)管理这些层。装配时的合并规则(store.ts:208):
merge<V>(scope, pick): Map<string, V> {
const merged = new Map(pick(this.global).entries())
for (const layer of this.chainLayers(scope)) {
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
}
return merged
}
先全局,再沿 scope 链从最远祖先到最近依次覆盖。所以"最近的 scope 赢一个名字"——这就是 shadowing 的全部实现:一个 Map.set 同名覆盖。
chainLayers() 和 peek() 的区分很讲究。peek() 的注释:
Deliberately chain-blind: callers addressing one scope's OWN contributions (its restrictions, its guards) must not silently pick up an ancestor's
读"这个 scope 自己声明了什么"(比如它自己的工具限制)必须不看祖先;读"这个 scope 最终看到什么"才继承。两个语义两个方法,不给调用者留猜的空间。
还有两个细节:
读操作永不创建层。 peek 用 scoped.get() 不用 upsert。装配路径是高频的,不能因为查询就长出一堆空层。
空层被回收。 ScopedLayers.effect() 的 disposer 里:if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)。而且注册失败时也回收(store.ts:253)——action(layer) 抛错时,如果这个层是本次刚创建且还是空的,删掉它。否则一次失败的注册会永久留下一个空层。
重复注册的报错,按 scope 定制
PromptLayer 的构造函数(index.ts:315)里三个 NamedEntries 各带一句定制的报错:
this.sections = new NamedEntries(name => new Error(scope === undefined
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${name}" is already registered in this scope`))
全局层的重复注册顺手告诉你正确做法是什么:想覆盖就去 agent 的 agent.ctx 注册。这类"报错里带解法"的写法在 dsh 里非常普遍——因为犯这个错的人正是想做 override 的人。
persona 行:一个只能 scoped 挂载的包
packages/preset/persona 是 scope 机制的一个纯粹例子。它的模块注释(persona/src/index.ts:1):
dsh-system-promptowns the global persona as its own config, and registers that section unconditionally — so this row is scope-only. Mounted inside an agent preset it shadows the deployment persona for that one session … mounted globally it collides with the registry's own registration and fails loud.That constraint is the reason the row exists. An agent preset cannot mount the prompt registry itself, so without a row of its own a preset could change an agent's tools but never its identity.
它不做任何配置判断、不检查自己在哪。它就注册一个名叫 deployment:persona、order 为 0 的 section:
ctx.effect(() => ctx.systemPrompt.section({
name: PERSONA_SECTION,
order: PERSONA_ORDER,
text: config.text,
...(config.complete ? { complete: true } : {}),
}), 'persona.section()')
在 scope 里挂载 → 遮蔽部署人格;全局挂载 → 撞上注册表自己那条,fail loud。正确用法和错误用法用同一段代码,靠 scope 机制自动区分。
还有一处值得学:PERSONA_SECTION 和 PERSONA_ORDER 是从注册表 import 的,不是重新写字面量。注释解释了为什么:
two hardcoded copies would drift into a preset whose persona silently lands beside the deployment's instead of shadowing it
名字一旦漂了,遮蔽就变成了并列——而且不会报错,只是模型收到两段人格。所以这个常量必须只有一份。
5. complete:一个能吞掉全部 section 的开关
PromptSection.complete(index.ts:68)语义很重:
readonly complete?: boolean
Treat this contribution as the complete system prompt. Assembly still runs the cooperative waterfall so tools, contexts, and variables can be resolved, then restores this exact section as the sole prompt section.
用途是"我要完全控制 system prompt"——比如某个 subagent 需要一段和 harness 完全无关的提示词。但它做得很克制:
装配照常跑完。 工具、上下文、变量全部正常解析,waterfall 照常派发。只是最后把 sections 换成这一条。
多于一个 complete section 就失败。 if (completeSections.length > 1) throw。因为两个都声称"我是全部",没有合理的合并方式。
waterfall 之后才恢复。 assemble() 的最后(index.ts:536):
if (completeSection === undefined && !runtimeContextSuppressed) return transformed
return {
...transformed,
sections: completeSection === undefined ? transformed.sections : [completeSection],
contexts: runtimeContextSuppressed ? [] : transformed.contexts,
}
事件的 JSDoc 说明了为什么顺序是这样(index.ts:24):
A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt.
如果在 waterfall 之前就把 sections 砍成一条,监听器还能往里加东西。放在之后,complete 就是真的 complete——在做决定的那个操作里执行决定,而不是靠"没人会来改"。
suppressRuntimeContext() 用同样的手法处理 contexts:抑制生效两次,一次在装配时(contexts: runtimeContextSuppressed ? [] : …),一次在 waterfall 之后再清一遍。监听器加不回来。
6. 工具 schema:在 prompt 装配里,而不是在工具注册表里
SystemPrompt 管的第四样东西是工具 schema。这一点乍看奇怪——工具明明有自己的注册表(ctx.tools)。
答案在 PromptAssembly:
export interface PromptAssembly {
sections: AssembledSection[]
contexts: AssembledContext[]
tools: ToolSchema[]
variables: Record<string, string | undefined>
}
模型请求需要的四样输入,一次装配一起产出。 而 agent-loop 只调一次 assemble(),拿到的东西直接进请求(agent.ts:337 的 renderPrompt(assembly) 和 assembly.tools)。如果工具 schema 走另一条路,就会出现"prompt 是 A 时刻的、工具列表是 B 时刻的"——两者之间发生一次插件热更新,模型就会拿到一段讲述某个工具的指引,而那个工具不在它的可用列表里。
ctx.tools 通过 systemPrompt.tools() 投稿(tools/src/index.ts:832):
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
一行,把整个工具注册表接进提示词装配。
knownNames:限制过的工具依然是"已知"的
ToolProviderResult 有两个字段:
export interface ToolProviderResult {
readonly schemas: readonly ToolSchema[] // 这次装配实际可见的
readonly knownNames?: readonly string[] // 限制前的名字宇宙
}
为什么需要第二个?因为 toolOrder 配置要校验名字。部署方在配置里写 toolOrder: ['read', 'write', '<unlisted-tools>', 'bash'],如果某个 subagent 被限制成只有 read,那么在这个 agent 的装配里 write 和 bash 都不可见——但它们不是配置错误。
orderTools()(index.ts:164)就是靠这个区分的:
const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !knownNames.has(name))
if (unknown.length > 0) throw new Error(`toolOrder lists unregistered tool… known tools: …`)
注释一句话说清:Unknown configured names fail; known but restricted names may be absent. 拼错的名字失败,被限制掉的名字缺席。
wireSchemas()(tools/src/index.ts:980)在三种 presentation mode 下返回不同组合:
if (mode === 'native') return { schemas: 所有可见, knownNames: [...view.knownNames] }
if (mode === 'code') return { schemas: 只有 run_code, knownNames: [RUN_CODE_NAME] }
/* hybrid */ return { schemas: 所有可见 + run_code, knownNames: [...view.knownNames, RUN_CODE_NAME] }
code 模式下 knownNames 只有 RUN_CODE_NAME——因为在这个模式里,toolOrder 里写别的工具名是真的错:那些工具已经不以 schema 形式存在了。所以注释说 Restrictions do not make known tools invalid, but a mode collapse does。同一个字段承担了两种不同的"不可见",靠 provider 自己决定报不报。
TOOL_ORDER_REST:必须写的那个占位符
export const TOOL_ORDER_REST = '<unlisted-tools>'
toolOrder 必须包含它一次(validateToolOrder,index.ts:146)。未列出的工具插在这个位置,内部按名字字典序。
强制要求它的理由:没有它,"没列出来的工具去哪"就成了隐式默认(开头?结尾?)。而这个位置对模型是有意义的——部署方可能想把几个关键工具顶到最前面,剩下的排在后面;也可能想把某个降权的工具压到最后。让它显式,一个字段就表达了完整意图。
另外两处防守:
保留名不能被工具占用。 一个工具真的叫 <unlisted-tools> 会让排序语义崩掉,所以直接报错(index.ts:165)。
排序用码位比较,不用 locale。 compareToolNames()(index.ts:181)手写 </> 而不是 localeCompare:
Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine.
localeCompare 在不同 locale 下能给出不同顺序,而工具顺序是模型可见输入——它会影响输出,也会影响 KV cache 命中。同一份配置在土耳其语环境下产生不同的 prompt,是没人想调试的那种 bug。
schema 的 parameters 被 structuredClone
装配收集 schema 时(index.ts:495):
const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({
name,
description,
parameters: structuredClone(parameters),
}))
只挑三个字段(丢掉 provider 可能附带的别的东西),并且 parameters 深拷贝。因为这个对象接下来要交给 waterfall 监听器——它们可以修改 assembly。一个监听器往 parameters.properties 里塞个字段,如果不拷贝,改的就是工具定义里那份长期存活的对象,下一个 agent 的装配会看到污染。
这和上一章 session 那条"不可变所以不需要拷贝"看起来矛盾,其实是同一个原则的两面:会被修改的东西必须拷贝,不会被修改的东西不必拷贝。session 的事件深冻结所以共享;assembly 是可变的所以隔离。
7. system-prompt/assemble:一个 waterfall,一个 scope 过滤器
装配的最后一步是把整个 assembly 交给 waterfall(index.ts:532):
const transformed = await this.ctx.waterfall(
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
() => Promise.resolve(assembly),
)
三点:
scopeTarget(this, scope) 做路由。 不带标签的监听器全都收到;带标签的只在标签是当前 scope 或其祖先时收到。所以"给某个 agent 的提示词做最后调整"就是在那个 agent 的 scope 里注册一个监听器。
返回值是权威的。 事件 JSDoc:The returned value is authoritative. 监听器返回什么就是什么(除了 complete 和 suppression 的事后恢复)。这比"监听器只能追加"强得多——它能重排、能删除、能改写工具描述。
signal 只管这一次装配。 JSDoc 特别警告:
A supplied signal controls only this explicit assembly request and must not be retained to control later turns.
因为监听器拿到的 AssembleContext 里那个 signal 是当前 turn 的。一个监听器把它存起来当作"这个 agent 的生命周期信号",下个 turn 就会拿到一个已经 abort 的信号。
AssembleContext 本身是 merge-extensible 的,dsh-agent 往里加了一个字段(agent/src/runtime-types.ts:17):
declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
/** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */
agent?: Agent
}
}
所以 sandbox-policy 那个 context.agent?.session 才能拿到 session。注意 absent on diagnostics——--dump-config 之类的诊断路径会在没有 agent 的情况下装配,所以每个读 context.agent 的地方都必须处理 undefined。这也是为什么那三个 variable provider 全都返回可选值。
完整装配顺序
assemble()(index.ts:467)的执行顺序是有讲究的:
1. chainLayers(scope) ← 解析 scope 链
2. runtimeContextSuppressed ← 全局或链上任一层有抑制器就抑制
3. variables:全局 → 链(最远先) ← 最近的 scope 赢
4. sections / contexts:merge() ← 同名遮蔽
5. tool providers:全局 + 链上全部 ← 注意:不遮蔽,是累加
6. schema 收集 + structuredClone ← 顺便攒 knownNames
7. complete section 检查(>1 就抛)
8. 解析 section text(函数就调用)
9. contexts 排序 + 解析(被抑制则为 [])
10. orderTools(collected, toolOrder, knownNames)
11. waterfall('system-prompt/assemble')
12. 恢复 complete section / 再次清空被抑制的 contexts
第 5 步和第 3、4 步的差别很关键:section / context / variable 按名字遮蔽,tool provider 累加。因为前三者是"命名的槽位"(一个 persona、一个时间上下文),后者是"匿名的贡献者"(多个包各自提供各自的工具,没有覆盖的概念)。对应到存储上,就是 NamedEntries 和 AnonymousEntries 两个类(store.ts:30 和 store.ts:114)——后者用 Symbol() 做 key,所以两个相等的值仍然是两个独立注册。
AnonymousEntries 还被用在 runtimeContextSuppressors 上,suppressRuntimeContext() 的注释解释了为什么:Multiple suppressors remain independently disposable. 两个插件各自抑制,其中一个卸载不应该恢复上下文——必须两个都卸载才恢复。用计数器也能做,但 AnonymousEntries 让"每个注册返回自己的 disposer"这件事免费。
8. 装配在循环里的位置
回到第 4 章那个循环,看装配落在哪。preStep()(agent-loop/src/agent.ts:225):
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.dispatch.waterfall('agent/pre-step', { messages: claimed, ...position, signal }, …)
然后 assembly 被带进 step()(agent.ts:332):
const system = renderPrompt(assembly)
while (true) {
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
)
几个可以确认的事实:
每个 step 装配一次,不是每个 turn 一次。 因为 preStep() 在每次模型请求前都跑。工具在 turn 中间被卸载,下一个 step 的列表就变了。
renderPrompt 在 retry 循环外面。 LLM retry 复用同一个 system 字符串——重试不该看到不同的 prompt。
context 快照在 waterfall 之前算好。 它作为 messages 的一部分进入 agent/pre-step 的默认决策,所以 pre-step 监听器能看到它、能改它,也能把整个 step reject 掉。
renderPrompt 会丢掉空 section。 .filter(text => text.length > 0) 然后 .join('\n\n')。所以一个 section 的 provider 返回 '' 就等于这次不出现——不会留下一个空行。默认的 deployment:persona 就是空字符串(配置默认 persona: ''),什么都不配的时候它自动消失。
9. 设计代价
order 是弱约定,没有机制保障。 一个投稿者用 -1000 就能抢到 identity 前面,没人会拦。换成拓扑排序会更严格,但会强迫互不知情的插件互相引用名字。
complete 的语义是"全有或全无"。 想要"保留 identity 但替换其余"没有直接支持——要么全部替换,要么老老实实注册一个 order 0 的 section。这是刻意的简化:任何"部分替换"都需要表达"替换哪些",那就回到了 surface replacement 那种复杂度。
严格插值会让运行时报错。 一个 {{typo}} 在装配时抛错,而装配在 preStep() 里——这个 turn 直接失败。宽松渲染不会失败但会静默错。这个取舍在这里选严格,因为提示词错了比 turn 失败更贵。
每个 step 重新装配所有 section 和 context。 30 多个 provider 函数、schema 深拷贝、字符串拼接,每个模型请求跑一遍。没有缓存,因为"什么时候可以复用"的判定(哪个 provider 依赖哪些会变的状态)比重算更难做对。相对模型请求本身的延迟,这个成本可以忽略。
scope 链是运行时的 WeakMap,不在类型系统里。 忘了在 agent scope 里注册(用了全局 ctx)不会有编译错误,只会让投稿泄漏到所有 agent。tools.restrict() 和 tools.presentAs() 显式检查 scopeOf(ctx) === undefined 并抛错(tools/src/index.ts:1071、:946),但 section / context / variable 没有这个检查——因为它们的全局注册是合法用法。
knownNames 这个可选字段把校验语义交给了 provider。 provider 可以选择让某些名字"依然已知"从而豁免校验。这是必要的灵活(限制 vs mode 折叠),但也意味着"toolOrder 里的名字合不合法"这个判断分布在了注册表和 provider 两边。
10. 可迁移的经验
1. 把静态提示词和动态上下文分开,动态的那部分放到历史尾部。 这是本章最实用的一条。前缀稳定 = KV cache 能命中;而"当前状态"作为一条带"本快照覆盖之前快照"抬头的消息追加在末尾,纯追加的日志就表达出了可变状态。
2. 让投稿者匿名,用一个数字排序。 当贡献者数量到几十个且互不知情时,order: number 比依赖声明更实用。精确顺序只在小簇内部才重要。
3. 模板变量要严格失败。 面向模型的文本里,"渲染成空串"是最糟的失败模式——它产生合法的句子和错误的信息,而且不报错。同时给孤立 {{ 留出散文空间,避免严格变成神经。
4. 一个不透明 key + 一个 WeakMap,就能做出可嵌套的作用域。 不需要在类型系统里表达层级:ctx.extend({ [kScope]: key }) 让投稿者自动带标签而完全不感知。注册视图向下继承、事件准入向上扩展、事件永不向下流——三句话定义完整语义。
5. 区分"这个 scope 自己声明了什么"和"这个 scope 最终看到什么"。 两个语义给两个方法(peek / chainLayers),并在注释里写明为什么不能混。这类"看起来可以合并的读取"混在一起,会让一个 agent 的限制意外继承祖先的限制。
6. 命名槽位用 Map 覆盖,匿名贡献用 Symbol 累加。 两种注册语义对应两种数据结构,别用一个凑。顺带解决了"多个抑制器独立可弃"这类需求。
7. 覆盖机制里的名字常量必须只有一份。 persona 那个 import PERSONA_SECTION 而不是重写字面量的细节:名字漂了,遮蔽会静默变成并列。所有基于"同名覆盖"的机制都有这个坑。
8. 决定要在做决定的那个操作里执行。 complete 在 waterfall 之后恢复,而不是在之前就砍掉 sections。前者是执行,后者只是"希望没人来改"。
9. 报错文本里带解法。 already registered (for a per-agent override, register through that agent's agent.ctx instead)——犯这个错的人正是需要这条指引的人。
10. 模型可见的排序不能用 locale。 localeCompare 在不同机器上给不同结果,而工具顺序会影响模型输出和缓存命中。任何进入模型输入的排序都用码位比较。
11. 本章源码位置
| 位置 | 内容 |
|---|---|
packages/core/system-prompt/src/index.ts:31 |
system-prompt/assemble 事件:waterfall、scope 过滤、返回值权威 |
packages/core/system-prompt/src/index.ts:42 |
AssembleContext:merge-extensible,signal 只管本次 |
packages/core/system-prompt/src/index.ts:53 |
PromptSection:order 约定与 complete |
packages/core/system-prompt/src/index.ts:78 |
PromptContext:动态上下文,空文本不贡献 |
packages/core/system-prompt/src/index.ts:104 |
ToolProviderResult:schemas 与 knownNames 的分工 |
packages/core/system-prompt/src/index.ts:115 |
PromptAssembly:四样模型输入一次产出 |
packages/core/system-prompt/src/index.ts:128 |
PERSONA_SECTION / PERSONA_ORDER:可被替换的槽位 |
packages/core/system-prompt/src/index.ts:140 |
TOOL_ORDER_REST:必须显式写出的占位符 |
packages/core/system-prompt/src/index.ts:164 |
orderTools():未知名字失败,受限名字缺席 |
packages/core/system-prompt/src/index.ts:181 |
compareToolNames():码位比较,不用 locale |
packages/core/system-prompt/src/index.ts:212 |
renderPrompt():丢空 section,空行分隔 |
packages/core/system-prompt/src/index.ts:236 |
joinContextSections():抬头声明"本快照覆盖之前的" |
packages/core/system-prompt/src/index.ts:251 |
renderContextSections():保留贡献者归属 |
packages/core/system-prompt/src/index.ts:258 |
interpolate():四种抛错、孤立 {{、不二次扫描、Object.hasOwn |
packages/core/system-prompt/src/index.ts:304 |
PromptLayer:一个 scope 的全部投稿 |
packages/core/system-prompt/src/index.ts:315 |
重复注册的报错按 scope 定制并给出解法 |
packages/core/system-prompt/src/index.ts:353 |
构造函数:identity(-100) 与 persona(0) 是唯一硬编码的两条 |
packages/core/system-prompt/src/index.ts:415 |
suppressRuntimeContext():多个抑制器独立可弃 |
packages/core/system-prompt/src/index.ts:467 |
assemble():12 步装配顺序 |
packages/core/system-prompt/src/index.ts:495 |
schema 只挑三字段 + parameters 深拷贝 |
packages/core/system-prompt/src/index.ts:536 |
waterfall 之后恢复 complete 与 suppression |
packages/core/scope/src/index.ts:15 |
ScopeKey = object:不透明身份 |
packages/core/scope/src/index.ts:39 |
一个 WeakMap 支撑两个相反方向 |
packages/core/scope/src/index.ts:54 |
linkScopeParent():环检查 |
packages/core/scope/src/index.ts:72 |
bindScopeParent():只能绑一次,重绑需句柄 |
packages/core/scope/src/index.ts:137 |
createScope():fiber + ctx.extend({ [kScope]: key }) |
packages/core/scope/src/index.ts:170 |
scopeTarget():向上准入、绝不向下 |
packages/core/scope/src/store.ts:30 |
NamedEntries:命名槽位,重复即抛 |
packages/core/scope/src/store.ts:114 |
AnonymousEntries:Symbol key,等值也是两个注册 |
packages/core/scope/src/store.ts:180 |
peek():刻意 chain-blind |
packages/core/scope/src/store.ts:192 |
chainLayers():最远祖先在前 |
packages/core/scope/src/store.ts:208 |
merge():最近的 scope 赢一个名字 |
packages/core/scope/src/store.ts:226 |
effect():懒建层、失败回收、空层回收 |
packages/core/tools/src/index.ts:832 |
ctx.systemPrompt.tools(...):一行接入 |
packages/core/tools/src/index.ts:946 |
presentAs():scoped-only + 一个 scope 一个声明 |
packages/core/tools/src/index.ts:980 |
wireSchemas():三种 mode 的 schemas/knownNames |
packages/core/tools/src/index.ts:1019 |
requireCodeRuntime():用时读取而非静态 inject |
packages/core/agent-loop/src/agent.ts:225 |
preStep():装配 → context 投影 → pre-step waterfall |
packages/core/agent-loop/src/agent.ts:337 |
renderPrompt() 在 retry 循环之外 |
packages/core/agent-loop/src/index.ts:351 |
三个内置变量:provider / model / cwd |
packages/core/agent/src/runtime-types.ts:17 |
AssembleContext.agent:诊断路径下可能缺席 |
packages/preset/persona/src/index.ts:1 |
scope-only 行:正确与错误用法共用一段代码 |
packages/sandbox/sandbox-policy/src/index.ts:113 |
context() 而非 section():会变的事实进快照 |