先问一个问题
如果 dsh 里同时加载了 20 个插件,每个插件都想往 system prompt 里写点东西------有的要加角色描述,有的要加工具使用规范,有的要注入当前工作目录------最后模型收到的 system prompt 是什么样子的?
这 20 段内容谁先谁后?某个插件被卸载了之后,它贡献的那段怎么消失?有没有可能让某一段内容每次请求时都动态更新?
这些问题都指向同一套机制:ctx.systemPrompt 服务。
静态字符串拼接的问题
最简单的做法是:每个插件把自己的提示词字符串暴露出来,主流程依次拼接。
这种做法马上会遇到三个麻烦:
- 顺序不可控:各插件按加载顺序排列,但逻辑上"角色定义"应该排在最前,"工具列表"应该排在最后------插件加载顺序和内容逻辑顺序不是同一回事
- 耦合严重:主流程需要知道每个插件的名字和接口,插件之间形成隐式依赖
- 无法动态更新:静态字符串组装一次就固定了,但有些内容(比如当前时间、工作目录)每次请求时都应该重新求值
dsh 的解法是注册制 + 排序:每个插件把自己的提示词片段注册到一个中央服务,由中央服务统一管理排序和组装。
ctx.systemPrompt 服务:注册一个 Section
Section 是 dsh 提示词体系的基本单元------一段有名字、有顺序的提示词片段。
typescript
// 最简单的用法:注册一个静态 Section
ctx.systemPrompt.section({
name: 'my-plugin:instructions', // 唯一名称;重复注册同一个名字会抛错
order: 1000, // 排序值,所有 Section 按升序排列
text: 'You are a helpful assistant.', // 静态文本
})
name 的命名惯例是 插件名:段落名,避免不同插件之间的名称冲突。
order 决定这段内容在最终 system prompt 里的位置。dsh 内置了一些预定义的顺序位置(通过 ctx.systemPrompt.getSectionOrder() 获取),插件可以把自己的 Section 插入到合理的位置,而不是和别的插件争抢固定的数字。
section() 方法返回一个 disposer 函数------调用它就能把这个 Section 从服务里移除。dsh 的插件系统会在插件卸载时自动调用 disposer,确保不会留下"僵尸"提示词片段。
动态 text:每次请求重新求值
有些内容需要在每次组装时动态计算。把 text 设为一个函数就可以了:
typescript
// 动态 text:每次组装时都会重新调用这个函数
ctx.systemPrompt.section({
name: 'my-plugin:context',
order: 2000,
text: (context) => {
// context.scope 是当前 Agent 的作用域(区分不同子 Agent)
// context.signal 是当前 Turn 的取消信号(可用于中断长时间操作)
return `Current time: ${new Date().toISOString()}`
},
})
context 对象里携带了当前的运行上下文------你可以从里面读取 Agent 信息、取消信号、当前会话的元数据,然后据此生成不同的提示词内容。
静态文本用 text: string,动态内容用 text: (context) => string,dsh 在组装时会自动区分处理。
变量插值:{{variable_name}}
如果同一个动态值要在多个 Section 里用到,每个 Section 都写一遍 context.agent?.session?.header?.cwd 太繁琐。dsh 提供了变量注册机制:
typescript
// 注册一个变量(可以在任意 Section 的 text 里用 {{变量名}} 引用)
ctx.systemPrompt.variable('user_name', (context) => {
// 从 Agent Session 的 header 里读取工作目录,或者返回默认值
return context.agent?.session?.header?.cwd ?? 'unknown'
})
// 在 Section 里引用变量------不需要自己做字符串插值
ctx.systemPrompt.section({
name: 'my-plugin:greeting',
order: 500,
text: 'Hello, {{user_name}}! I am your AI assistant.',
})
组装时,dsh 会先对所有动态变量求值,然后把结果替换进各 Section 的文本里。变量同样按作用域隔离------后面会讲到这一点。
作用域遮蔽:子 Agent 的提示词定制
dsh 支持多 Agent 场景。当一个 Agent 派生出子 Agent 时,子 Agent 可以有自己的 Section,覆盖(遮蔽)父 Agent 里同名的全局 Section。
规则很简单:
- 全局 Section 对所有 Agent 可见
- 作用域 Section(绑定到某个具体的 Agent 作用域)会遮蔽同名的全局 Section
- 遮蔽只在那个作用域内生效,不影响其他 Agent
这让你可以在不修改全局配置的前提下,为特定用途的子 Agent 注入完全不同的角色定义。
还有一个特殊字段 complete?: true:
typescript
ctx.systemPrompt.section({
name: 'core:agent-instructions',
order: 0,
text: 'You are a specialized code review agent. Be concise and focus on bugs.',
complete: true, // 设置为 true 时,这段就是完整的 system prompt,其他所有 Section 被忽略
})
complete: true 意味着"我就是整个 system prompt,别的插件都别插话"------适合需要完全接管提示词的场景(比如专用工具 Agent)。
工具 Schema 自动注入
每次请求前,除了文本 Section,模型还需要知道有哪些工具可以调用。工具的 schema(格式说明)也会作为提示词的一部分注入。
typescript
// ctx.systemPrompt.tools() 注册一个工具 schema 提供方
// 通常由 ctx.tools 服务内部自动注册,不需要插件手动调用
// 下面的代码说明内部机制,理解即可
ctx.systemPrompt.tools((context) => {
return {
// 当前 Agent 作用域下所有已注册工具的 schema
schemas: ctx.tools.schemas(context.scope),
// 工具名称列表(用于简洁引用)
knownNames: ctx.tools.knownNames(context.scope),
}
})
工具 schema 是动态的------每次 Step 开始前重新求值。这样,即使某个工具在运行时被动态注册或注销,模型请求里总能看到最新的工具列表。
waterfall 扩展点:在组装前拦截
有时候,你需要在所有 Section 都排好序之后、正式渲染为字符串之前,对整个集合做一次处理------比如根据当前状态压制某个 Section,或者动态插入一段紧急提示。
dsh 提供了 system-prompt/assemble 扩展点:
typescript
// waterfall 监听器:在最终组装前拦截 sections 列表
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
// assembly.sections --- 已排好序的 Section 列表
// assembly.contexts --- 动态 context 列表
// 可以读取、修改,或完全替换 sections
if (someCondition) {
// 例:在某种条件下压制某个 Section
assembly.sections = assembly.sections.filter(
s => s.name !== 'some-plugin:section-to-suppress'
)
}
// 必须调用 next()------不调用相当于中断了整个组装链
return next()
})
这是一个 waterfall 模式:所有监听器按注册顺序依次执行,每个监听器可以修改 assembly 对象后调用 next() 把控制权交给下一个。如果不调用 next(),后续监听器和默认的渲染步骤都会被跳过。
Prompt Caching:减少重复 token 处理成本
dsh 支持 Anthropic API 的 Prompt Caching (cache_control)特性。
核心思路:当 system prompt 里有大段稳定的内容(比如工具文档、背景知识库、长篇角色设定),每次请求都重新处理这些 token 是一种浪费。通过在这些段落的末尾注入 cache_control: { type: 'ephemeral' } 标记,可以告诉 API"这里之前的内容可以缓存,下次请求如果一样就不用重新计算"。
哪些内容适合缓存?
- 长且稳定:内容在多次 Turn 之间基本不变
- 靠前:缓存断点的位置越靠前,缓存命中越多
- 例子:工具列表(很长但每步都一样)、静态背景知识、系统角色定义
不适合缓存的内容:动态时间戳、每 Step 都变化的状态数据------因为内容变了缓存就会失效,反而占用了一个宝贵的断点位置。
dsh 在 renderPrompt 阶段自动注入 cache_control 标记,插件作者通常不需要手动处理。
完整执行流程图
每次 Step 开始时,system prompt 的组装流程如下:
scss
每次 Step 开始前:
ctx.systemPrompt.assemble(context)
│
├── 收集所有已注册的 Section(全局 + 当前作用域)
├── 作用域 Section 遮蔽同名全局 Section
├── 按 order 升序排列(order 相同则按 name 的代码单元顺序)
├── 对动态 text 函数求值
├── 插值 {{variables}}
│
▼
system-prompt/assemble waterfall
│ 所有监听器依次执行,可修改 sections/contexts/tools
│
▼
renderPrompt()
│ 拼接所有段落文本
│ 注入 cache_control 标记(适合缓存的段落末尾)
│
▼
surface 事件写入 Session 日志
│ 首个 Step → 追加 system/message 节点
│ 后续 Step 提示词有变化 → 替换最新的系统节点
│ 提示词与上次相同 → 复用已有节点(不重复写入)
最后一步的"复用"很关键:如果两次 Step 之间 system prompt 没有任何变化,Session 日志里不会产生新的 system/message 事件,减少了不必要的日志膨胀,也节省了 Prompt Caching 命中判断的开销。
实战:注册自定义 Section
把以上所有机制串在一起,写一个完整的插件示例:
typescript
// 完整插件示例:注入工作目录上下文
export const name = 'my-context-plugin'
export function apply(ctx: Context): void {
// 1. 注册一个动态 Section,把当前工作目录注入 system prompt
ctx.systemPrompt.section({
name: 'my-context-plugin:workspace',
// getSectionOrder 读取预定义的顺序位置,而不是写死一个数字
order: ctx.systemPrompt.getSectionOrder('context:workspace'),
text: (context) => {
const cwd = context.agent?.session?.header?.cwd
// 没有工作目录时返回空字符串,dsh 会自动跳过空段落
if (!cwd) return ''
return `Working directory: ${cwd}\nAll file operations are relative to this directory.`
},
})
// 2. 注册一个变量,供其他 Section 复用
ctx.systemPrompt.variable('workspace_cwd', (context) => {
return context.agent?.session?.header?.cwd ?? 'not set'
})
// 3. 用作用域 Section 覆盖全局 Section(仅在当前子 Agent 生效)
// 适用场景:为某种特殊任务创建专用 Agent,需要完全自定义角色
ctx.systemPrompt.section({
name: 'core:agent-instructions', // 与全局同名,会遮蔽它
order: 0,
text: 'You are a specialized code review agent. Be concise and focus only on bugs and security issues.',
complete: true, // 整个 system prompt 只用这一段
})
}
几个值得注意的细节:
- 动态
text函数返回空字符串时,dsh 会自动跳过这个 Section,不会在最终提示词里留下空行 getSectionOrder()比写死数字更健壮------如果 dsh 内部调整了某个预定义位置的 order 值,你的插件会自动跟着调整complete: true和普通 Section 可以共存于同一个插件,它只在被激活的那个作用域内生效
设计总结
| 设计决策 | 解决的问题 |
|---|---|
| 注册制 + disposer | 插件卸载时自动清理,不会留下残余提示词 |
| order 排序 | 内容逻辑顺序与插件加载顺序解耦 |
| 动态 text 函数 | 每次请求前重新求值,支持时间戳、状态等动态内容 |
| 变量插值 | 避免在多处重复同一段动态求值逻辑 |
| 作用域遮蔽 | 子 Agent 可以定制提示词,不影响全局 |
complete: true |
专用 Agent 可以完全接管 system prompt |
| waterfall 扩展点 | 在组装完成后、渲染前提供一个统一的拦截机会 |
| Prompt Caching | 长稳定段落自动标记缓存断点,减少 token 处理成本 |
系列下一篇
下一篇 能力 Seam 讲 dsh 怎么通过一行配置替换整个文件系统、Shell、沙箱实现------同一套工具接口,背后可以是本地 Shell、Docker 容器、还是云端沙箱,对插件完全透明。
在 PrimeSkills 可以找到已在真实企业场景验证过的 AI Agent 技能和工作流,不是演示级的,是用在实际项目里的。
更多内容见我的个人主页