目标读者:已经用 dsh 或同类 CLI agent 做过真实项目,但每次开新会话都要重新解释一遍项目约定的人。
前置知识:知道 agent preset 大概是“给某个会话换一套工具和提示词”,不需要读过 dsh 源码。
示例来源:本文机制部分以一个真实开源预设为例,机器相关路径已全部占位符化。
本文核查时间:2026-09-13。
一、先问一个问题:AGENTS.md 为什么不够
大多数人给项目配 agent 的第一步,是在仓库根目录放一个 AGENTS.md(或者 CLAUDE.md)。这当然是必要的,但它有三个绕不过去的失效点。
第一,环境事实只能靠“读”。 假设你的项目横跨两套环境:计算和数据脚本在 Linux 侧,某个商业仿真软件只能装在 Windows 侧。你在 AGENTS.md 里写清楚了这件事,agent 也读到了。然后它要跑一个批处理脚本——它仍然可能在错误的一侧执行,因为“读到”和“被约束”是两件事。
第二,纪律只是建议。 你写了“参数必须来自文献,不许编造”。这句话对模型是个提醒,不是一道门。工具还是那些工具,该写的时候它照样能写。这类约定在压力下(用户催得急、上下文很长)最先被丢掉。
第三,也是最少被注意的一点:常驻成本。 AGENTS.md 要么每轮都在上下文里占位置,要么每次重新读一遍。项目约定越细致,这份成本越高,而其中大部分内容——比如“批量跑工况的七个步骤”——在大多数轮次里根本用不上。
这三点的共同解法,是把约定从“文件里的文字”升级成“会话装配的一部分”。dsh 里承载这件事的东西叫 agent preset(预设)。
需要先破除一个误解:预设不是一份更长的提示词。 一个预设是一个目录,里面是一个 composition 文件,它会决定这个会话装配哪些插件——也就是有哪些工具、有哪些 prompt 段、能加载哪些技能。把约定写进 persona 只是其中一种手段,而且往往不是最有效的那种。
二、判断基准:host 平面与 agent 平面
在动手写预设之前,必须先建立一个判断基准,否则后面每一个决定都会靠感觉。dsh 的运行时分成两个平面:
host 平面持有:
- 注册表本身——
tools、systemPrompt、agents、sessions - 跨越会话的东西——持久化、会话查询、存储、设置、凭据
- 沙箱与审批栈、模型路由
- 子代理注册表及其 spawn / fork 后端
agent 预设持有的是一个会话向这些注册表贡献的东西:
- 它自己的工具插件
- 它的 persona 与 prompt 段
- 它的 compaction 策略
判定规则只有一条,但它足够锐利:
有 agent 平面之外的消费者,就必须留在 host 平面。
拿子代理注册表来检验这条规则。它看起来非常“和 agent 相关”,所以很自然会想把它塞进预设里。但事实是:host 侧的 api-proxy 要跨会话查询它(比如网页端要列出某个会话起了哪些子代理)。一旦把它搬进预设,会同时坏掉两件事——host 那一行会永远等一个没人提供的服务;而第二个会话挂载同一个预设时会在注册时撞名,因为一个 provider 名字只能注册一次。
所以正确的分工是:预设贡献子代理的“工具”,注册表和它的后端留在 host。
这个区分带来一个很实用的推论:当你写 agent.cordis.yml 时,你在写的不是“配置”,而是“这个会话装配哪些插件”。注册表里的东西不是你的,你只是在往里面加行。
三、素材提取:从一个真实工作区说起
讲了原则,来看一个真实的例子。假设你有一个科研仿真工作区,形状大致是这样:
<PROJECT_NAME>/
├── <MICRO_SIM>/ # 微观仿真:分子动力学,颗粒碰撞与团聚
├── <MACRO_SIM>/ # 宏观仿真:离散相模型,气固两相流沉积
│ ├── udf/ # 边界条件源码
│ ├── profile/ # 由微观结果生成的参数表
│ ├── scripts/ # 构建 / 批跑 / 聚合脚本
│ └── runs/ # 各类工况目录
├── <REPORT_DATA>/ # 过程汇报(LaTeX)
├── <PAPER>/ # 最终论文(LaTeX)
├── <VENV>/ # Python 虚拟环境
├── task_plan.md # 当前阶段计划
├── findings.md # 本轮发现
└── progress.md # 跨会话进度日志
这类工作区有几个稳定的特征:环境分裂(计算在 Linux 侧、商业软件在 Windows 侧)、参数有严格的文献来源要求、报告有固定结构、而且它已经有一批约定载体了——AGENTS.md、CLAUDE.md、几个项目技能、以及那三个 .md 续接文件。
问题不是缺约定,而是这些约定散落在不同介质里,各自有各自的生效条件。所以第一步不是写代码,而是把已有约定归位。
这张表是全文最有复用价值的部分,值得直接照搬到自己项目上:
| 项目里的东西 | 性质 | 落到预设的哪里 | 理由 |
|---|---|---|---|
| 研究对象、领域术语、物理模型 | 事实背景 | persona | 每轮都需要,必须常驻 |
| 哪台机器跑什么、路径怎么对应 | 环境事实 | persona(工具路由约定) | 同上 |
| 参数不许编造、编译必须通过 | 纪律 | persona + 硬门控 | 需要“拒绝”的部分下沉到工具行 |
| 批量跑工况的操作步骤 | 流程知识 | skills | 按需加载,不常驻 |
| 能读不能写、能跑不能装 | 能力边界 | agent.cordis.yml 工具行 | 只有工具行能真正约束 |
| 开工先读什么、收工回写什么 | 续接约定 | persona | 属于行为习惯 |
从这张表里能抽出两条设计原则。它们基本能解释后面所有的取舍:
- 能落到工具行或 skill 的,不要写进 persona。 persona 是每轮都付的 token 成本,skill 是按需加载的。
- 需要“拒绝”的用工具行,只需要“提醒”的用 persona。 软引导会被无视,硬门控不会。
先把这两条记住,再往下看会更顺。
四、最小可运行预设
预设放在这里:
${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/
有两个容易踩的规则,先说清楚:
- 只扫一级子目录。 预设目录不能嵌套,
<id>直接就是那一层目录名。 <id>必须匹配[a-z0-9][a-z0-9-]*。 因为它会变成目录名,所以不能以连字符开头。
一个预设目录里最少要有两个文件。
preset.yml:给人看的元数据
name: 科研计算预设
description: "面向粉体/多相流仿真的科研 agent:中文 persona、双环境工具路由、科研写作纪律与跨会话续接。"
order: 0
这个文件的作用只有一个——在预设选单里显示名字和描述。不写它,你的预设会以裸目录名出现在列表里。 order 只对随部署发布的预设有意义,自己写的预设可以不填。
agent.cordis.yml:一组插件行
这是真正起作用的部分。一个能跑的最小版本大概长这样:
# persona:这个会话的身份与约定
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
prefix: |-
你是粉体/多相流仿真科研助手,服务于 <PROJECT_NAME> 项目。
交流一律使用简体中文;公式与技术名词保持英文。
当前模型为 {{model}},工作目录为 {{cwd}}。
# 工具:文件读写与检索
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
# 技能:按需加载的流程知识
- id: skill-filesystem
name: '@deepseek-ai/dsh-skill-filesystem'
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
注意 {{model}} 和 {{cwd}} —— 这两个由 dsh 在运行时解析,保持原样,不要替换。
生效条件
预设的挂载时机有两个特点,都值得记住:
- 只有“还没产出任何内容”的会话能切换预设。 一旦会话有了消息或工具调用,composition 就固定了——因为中途换工具会让历史里出现新装配无法执行的工具调用。所以改完预设,要新开一个会话才看得到效果。
- 一个预设的改动会不会产生新一代,只看 composition 文件本身。 系统记录的是
agent.cordis.yml的时间戳(mtime)与大小;文件一动,下一个新会话就会挂载新一代。而已经在跑的会话会一直留在它当初加入的那一代。
第二条有个重要后果,第九章会专门讲。
五、写 persona:把约定写成约束
persona 是最容易写坏的地方。典型症状是把它写成项目 README 的副本——洋洋洒洒几千字,其中大部分内容在大多数轮次里都用不到。
一个经过验证的结构是按下面的顺序组织,这和开源示例里的真实顺序一致:
- 身份
- 语言约定
- 领域背景
- 工具路由
- 写作纪律
- 续接工作流
关键在于每一段都要可执行。看一个对照:
反例(说了等于没说):
注意 Fluent 装在 Windows 上,脚本要在 Linux 上跑。
正例(给出事实、路径对应、以及理由):
工具路由约定:
- 分子动力学 / Python 数据分析 / LaTeX 编译在 Linux 侧(<WSL_DISTRO>):
经 git bash 执行 `wsl -d <WSL_DISTRO> -- bash -lc '...'`,
涉及 Python 时先激活 <WSL_PROJECT_ROOT>/<VENV> 再运行。
- 商业仿真软件在 Windows 侧(<ANSYS_INSTALL>):批跑走 run_batch.ps1。
- 路径对应:原生 <WSL_PROJECT_ROOT> ↔ UNC \\wsl.localhost\<WSL_DISTRO>\...
↔ 符号链接 <WIN_PROJECT_LINK>。重 IO(dump、.trn、批量小文件)
必须在原生目录内跑;9P 路径(UNC / 符号链接)只做轻量读写。
差别不只是“更详细”。正例做了三件反例没做的事:
- 给出了三侧路径的对应关系,agent 才能在任意一侧拿到路径后自己换算。
- 给了理由(“重 IO 必须留在原生目录”)。这一点常被忽略,但很重要——跨文件系统访问的性能损失是数量级的,agent 不知道原因,就无法在边界情况下做判断。比如它要决定“这个几百 MB 的结果文件该写哪儿”时,有理由可依。
- 明确了降级路径(“9P 路径只做轻量读写”),而不是一刀切禁止。一刀切的规则在遇到“确实需要从 Windows 侧读一个小文件”时会逼 agent 绕路。
同理,写作纪律也要写成可检查的形式,而不是形容词:
科研写作纪律:
- 报告结构:时间线 / 问题发现 / 参数空间 / 敏感性分析 / 物理机制 /
工程意义 / 后续计划。
- 图表遵循项目 plot_style.py 统一出版风格。
- 不写无依据的机理解释;数值改动需说明来源。
最后一段续接工作流,本质是把“行为习惯”编码进去:
跨会话续接:
- 开工前先读 task_plan.md / findings.md / progress.md,
确认当前阶段与未决项后再行动;完成后按项目约定回写。
这条的价值在长周期项目里会持续放大。新会话不必从零重建上下文,而是先读三个文件——它们本身是跨会话的长期记忆。
六、能力边界用工具行表达
persona 解决“知道该怎么做”,工具行解决“能做什么”。后者是硬的。
平台条件
dsh 的 composition 支持用 JS 表达式做条件行:
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
这两行是互斥的:Windows 上只装配 PowerShell,其他平台只装配 bash。忘了写平台条件的后果是:工具在错误的平台上被注册,然后在运行时报错——而不是在装配时被挡下来。
isolate realm:服务行必须放进隔离域
如果你要给预设挂一个服务(而不是一个工具),有一条硬规则:
服务行必须放进一个带
isolate的 group,否则它会发布到 root realm。
root realm 是进程级的。后果是第二个挂载同一预设的会话会直接撞名失败。isolate 的取值为 true 时,表示“每个会话一个私有实例”,这是绝大多数情况的正确选择;只有当这个服务确实要跨会话共享时,才用共享标签把它们池化。
一个真实的例子:Windows 上要真正的 Git Bash
dsh 在 Windows 上的默认 shell 是 PowerShell。如果你的项目脚本全是 bash(很常见),就需要一个私有的 shell 服务:
- id: gitbash-shell
name: cordis:group
group: true
isolate:
shell: true
config:
- id: gitbash-executor
name: ./gitbash-executor.mjs?v=50
disabled: !!js process.platform !== 'win32'
config: {}
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform !== 'win32'
这个 group 干的事是:提供一个 entry-local 的私有 shell 服务(它用 Git Bash 执行命令),然后让同组的 tool-bash 去消费它。于是 Windows 会话里的 bash 就真的是 bash。
这里必须说清楚它的边界,因为这是个诚实性问题。 它不绕过沙箱。MSYS 在受限令牌下可能启动失败,此时正确的做法是按正常流程走一次审批升级,而不是去篡改沙箱策略。把这个边界写进注释,比假装它没有更专业——因为下一个维护者会需要知道这一点。
七、让流程渐进披露:四阶段硬门控
前面几节是大部分预设都会用到的部分。这一节讲的是更进阶的机制,也是一个真实科研预设里最有技术含量的原创贡献。
动机
把全部工具一次性交给模型,有两个问题:
- 注意力税。 工具清单本身占上下文。一个几十个工具的面板,意味着每一轮都要为它们付 token,而其中大部分在当轮用不到。
- 模型会“大跃进”。 如果
write从一开始就可见,模型倾向于跳过理解直接开写。这不是模型不听话,而是你给了它这条路。
四个阶段
解法是给会话划分阶段,每个阶段只解锁一部分工具:
| 阶段 | 名称 | 解锁的工具 |
|---|---|---|
| 0 | 了解/对齐 | read glob grep web_search ask_user_question |
| 1 | 拟合方案 | todo_write exit_plan_mode |
| 2 | 开发 | write edit str_replace_editor |
| 3 | 验证 | bash pwsh read_image job_list job_output job_kill |
这个划分的逻辑是:先能看,再能想,然后能改,最后能跑。阶段的推进由完成信号驱动——比如阶段 1 在 todo_write 或 exit_plan_mode 被调用后自动进入阶段 2。这一点是有意设计的:如果靠模型自己调推进工具,它很可能会跳阶段。
硬门控是怎么实现的
这是最容易出 bug 的地方。核心调用是:
toolsSvc.restrict({ allow })
它有三个必须理解的语义。
第一,restrict 是交集语义,不是替换。
如果你设了一次 restrict,又设第二次,两次的交集才是结果。这意味着必须显式释放上一次的 restrict,否则工具面会越缩越小,最后卡死。做法是每个会话记住自己的 disposer,设新的之前先释放旧的:
const prev = sharedLift.get(sid)
if (prev) { try { prev() } catch {} ; sharedLift.delete(sid) }
// ... 然后再 restrict
const disposer = toolsSvc.restrict({ allow })
if (sid && disposer) sharedLift.set(sid, disposer)
第二,disposer 必须跨 generation 共享。
如果预设改了 composition 文件产生新一代,新一代的插件实例是新的。它必须能提起旧一代设下的 restrict,否则就继承了旧限制却无权释放——同样是越缩越小。做法是用 globalThis 上一个 Symbol.for(...) 命名的 Map 作为跨代的共享点。
第三,平台缺失的工具名会让整个 restrict 调用抛错。
假设你的 allow 列表里写了一个当前平台不存在的工具名(比如 Windows 上没有 bash),restrict 会直接抛异常。后果不是“那一个工具没解锁”,而是“整个阶段门控失效、退化成全量工具面”——一个静默的降级,很难发现。
所以正确的顺序是三重过滤:
const allow = [...allowed].filter(
(t) => GLOBAL_SAFE.includes(t) // 1. 本预设声明的工具全集
&& (known === null || known.has(t)) // 2. 当前运行时真实可限制的名字
&& !(memoryMuted(agent.session) && isMemoryTool(t)) // 3. 其它开关
)
if (allow.length === 0) return
并且要把 restrict 包在 try/catch 里,失败时保留全量目录而不是让会话瘫痪——同时打日志,别让它静默。
只暴露当前档
一个容易被忽略的细节:解锁窗口应该只包含当前阶段,而不是“当前 + 预放几档”。
export function windowFor(stage) { return Math.min(stage + 1, STAGES.length) }
为什么不做预放?因为一旦模型知道后面还有工具,它就会惦记。实测的后果是:模型会试图“提前”完成当前阶段以解锁下一档,产生大跃进,反而破坏了这个机制本来要保证的东西。只暴露当前档,模型就只能在当前阶段内把事情做好。
二级披露:拒绝调用,但允许查阅
硬门控带来一个副作用:模型想知道“有没有某个工具”都做不到。这会让它在需要时无从判断该向用户请求什么。解法是给两个元工具常驻:
tools_catalog:列出全部工具,但标注未解锁的状态tools_help:查单个工具的说明
关键设计是:这两个工具可以查阅未解锁的工具,但不能调用它们。 拒绝的信息还要写清楚归属:
解锁阶段: 3(当前调用会被拒绝;详情可提前查阅。
若你判断它应属于当前阶段,请指出工具分配问题(调整 STAGES),
而不是寻求绕过)
最后这半句是有意写的。它在引导模型:遇到门控时的正确反应是报告设计问题,而不是想办法绕过。 这一句本身就是一条防绕过的护栏。
常驻指引
阶段的“目标说明”要写进 prompt 段,而不是只在阶段切换时说一次。原因很实际:上下文压缩随时可能把早先的说明丢掉,而常驻的 prompt 段经压缩不丢。
设计哲学
把这套机制说成“限制模型”是不准确的。它真正做的是把“该想清楚再做”从一个建议变成一种结构:阶段 0 只能读,于是模型不得不先把问题看清楚;阶段 2 才有写权限,于是“想清楚”这件事在物理上先于“动手”。这比在 persona 里写“请先充分理解需求”有效得多。
八、验证:怎么知道预设真的装配了
写完预设,最需要避免的就是“看起来能用”。
观察当前状态
会话内可以直接查路由状态:当前处在哪个阶段、哪些工具可调、presentation 模式是什么。这是最快的自检手段。
源码级回归自测
一个很实用的做法是写一个独立的 selftest 脚本,对插件源码做字符串断言。它不启动运行时,只读文件:
const src = readFileSync(join(here, 'router-bootstrap-v34.mjs'), 'utf8')
const cfg = readFileSync(join(here, 'agent.cordis.yml'), 'utf8')
const fails = []
const check = (name, ok) => { if (!ok) fails.push(name) }
// 引导文案必须完整拼接
check('guide-unlock-order', tail.includes("+ 'Unlock order:'"))
// restrict 的 per-session disposer 必须在场
check('restrictLift-declared', src.includes('const restrictLift = new Map()'))
// 失败不能静默
check('restrict-not-silent',
src.includes("console.error('[router-bootstrap] applyStageRestrict failed:'"))
// 配置必须指向新一代(?v= 已递增)
check('config-v24-or-newer', /router-bootstrap-v34\.mjs\?v=(2[4-9]|[3-9]\d+)/.test(cfg))
这种自测的价值和边界都要说清楚。 它守的是“机制不被误删”,而不是“功能正确”。它挡不住逻辑错误,但能挡住一次重构中不小心删掉 disposer、或者忘记递增 ?v= 这类高发事故——而这些恰恰是最难在生产中发现、又最容易在编辑中引入的问题。
真实挂载才是最终判据
字符串断言全过,也不代表预设能挂载。真正的判据是新开一个会话,看它是否正常装配。失败时的排查顺序是固定的三步:
- composition 能不能解析?—— YAML 语法、行是否是合法的
{id, name, config}结构 - 行指定的模块能不能加载?—— 路径对不对、
?v=指向的文件在不在 - 有没有哪一行在等一个 composition 从没提供的服务?—— 这是最难查的一类,通常意味着你把一个 host 平面的服务误放进了预设
第 3 类错误会在会话创建时失败并回滚,报错会列出所有失败的行(包括 group 内部的),所以看到报错时重点看它点名了哪些行。
九、迭代循环,和一个必踩的坑
日常改动分两类,生效方式不同。
改 persona 或工具行 —— 直接编辑 agent.cordis.yml,新会话生效。因为 generation 的指纹就是这个文件的时间戳与大小。
改插件 .mjs —— 必须递增 ?v=N。
- id: router-bootstrap
name: ./router-bootstrap-v34.mjs?v=91 # 改完代码把它改成 92
这里是全文最值得记住的一个坑。原因在于:
generation 只以 composition 文件的指纹为准。旁挂的
.mjs改了,agent.cordis.yml的 mtime 没变,指纹就不变,于是新会话仍挂在原来那一代上,ESM 模块缓存继续返回旧代码。
症状是“我明明改了,怎么没生效”,而且不报任何错。递增 ?v=N 让 composition 文件的文本变了,指纹变化触发新一代,新 URL 也没有缓存命中,改动立刻生效。
这个机制还有一条容易忽略的性质:已运行的会话会保持旧代。 这既是优点(正在跑的长任务不会因为你的编辑而中途变样),也是排查时的陷阱(你在当前会话里永远验证不了刚改的插件代码)。
改动前的备份
预设目录在用户主目录下,不在项目仓库里,不受项目 git 保护。建议改动前整体复制一份带日期后缀的副本。这比“记得改了什么”可靠得多。
十、脱敏与开源:把“我的项目”变成“任何同类项目”
预设一旦跑顺,很自然会想开源出去。但直接从本地目录复制粘贴是不行的——它塞满了你的机器事实。这一步需要分三层处理,而且这三层不能混为一谈。
第一层:机器事实 → 占位符
| 占位符 | 含义 | 示例 |
|---|---|---|
<PROJECT_NAME> | 项目名 | MyThesis |
<WSL_DISTRO> | 发行版名 | Ubuntu |
<WSL_PROJECT_ROOT> | Linux 侧项目根(绝对路径) | /home/you/MyThesis |
<WIN_PROJECT_LINK> | Windows 侧符号链接路径 | C:\MyThesis |
<ANSYS_INSTALL> | 商业软件安装路径 | D:\ANSYS2024R2 |
务必注意:{{model}} 和 {{cwd}} 不在这个表里——它们由 dsh 运行时解析,替换掉就等于破坏了功能。
上表五项是 persona 真正依赖的环境事实。第三节那张目录树里出现的 <MICRO_SIM>、<VENV> 等是子目录名的占位,属于叙述方便,不必进 persona——判断标准是:agent 是否需要知道它才能正确行动。它需要在哪个环境跑什么命令,这是必须的;某个子目录叫什么,读了就知道,不必常驻。
第二层:研究对象 → 通用化
这一层是语义脱敏,不是字符串替换。具体的研究对象名、未发表的数值、结论性表述都要泛化成机制描述。比如把具体的物料名称写成“粉体颗粒”,把具体的数值结果去掉,保留“存在临界速度幂律关系”这样的机制表述。
第三层:个人标识 → 移除
用户名、家目录绝对路径、内网 git 地址、私有软件安装根目录。这一层最简单,但也最容易漏——因为它们常常藏在脚本注释和示例命令里,而不在显眼位置。
为什么不能只做字符串替换? 因为把路径变量收敛成占位符的过程,会强迫你回答一个问题:这个预设到底依赖哪几个环境事实? 你可能会发现某个“环境依赖”其实是历史遗留、根本不必写进 persona;也可能发现两个占位符其实是同一个东西。这一步本身就是设计工作,不是收尾工作。
版权与归属
如果预设构建在别人的工作之上(很常见——你多半是从某个上游 preset 改的),就有一个 NOTICE 文件要写清楚。参考资料里给了一个真实例子,它的结构值得照搬:
- 逐项列出上游来源:项目地址、版权人、许可证
- 明确列出哪些文件是派生的——不要含糊地说“部分代码来自上游”
- 明确划分原创部分与派生部分
- 商标与背书免责:说明这是一个社区产物,未获官方背书
第 3 点最容易做错。一个 composition 文件往往同时包含派生的插件行和原创的 persona——两部分的归属不同。把它整体算作“原创”是不准确的,整体算作“派生”又抹掉了你的工作。老老实实写清楚“该文件的哪些内容是原创”,是既准确又体面的做法。
文档要跟着改
开源前值得通读一遍 README,把这几个高发问题检查掉:
- 克隆命令里的仓库名和实际仓库名是否一致
- 引用的辅助脚本、模板文件是否真实存在
- 前置依赖是否指向真实存在的包
- README 的归属声明是否和
NOTICE一致
这几条听起来琐碎,但它们是读者对你的项目的第一印象,而且最容易在改名、重构后失效。
十一、常见坑清单
按“症状 → 原因 → 处理”整理,可以直接当排查表用:
| 症状 | 原因 | 处理 |
|---|---|---|
| 预设不出现在列表里 | 目录嵌套了 | 预设只扫一级子目录,<id> 就是那一层的目录名 |
| 列表里显示成裸目录名 | 没写 preset.yml | 补上 name 与 description |
改了 .mjs 但没生效 | 忘了递增 ?v=N | 递增版本号,新开会话验证 |
| 改了 persona 但没生效 | 在已有会话里验证 | 只有空会话能切换预设,必须新开会话 |
| 第二个会话挂载失败 | 服务行没进 isolate realm | 把服务行放进带 isolate 的 group |
| 设置完 restrict 后工具越来越少 | restrict 是交集语义 | 记住 disposer,设新的前先释放旧的 |
| 阶段门控毫无效果 | allow 里有平台不存在的工具名,整个调用抛错降级 | 三重过滤 + try/catch + 失败时打日志 |
| 约束被模型无视 | 用 persona 做了本该工具行做的硬门控 | 需要“拒绝”的改用工具行 |
| persona 越写越长 | 该下沉到 skill 的内容没下沉 | 流程类知识移到 skill,按需加载 |
| 升级 dsh 后预设改动丢失 | 直接改了随部署发布的预设 | 只改用户目录下的副本,不要动随部署发布的 |
| 换了机器预设就废 | 路径硬编码 | 占位符化,明确列出环境依赖 |
| 工具在错误平台被注册 | 忘了 disabled 平台条件 | 用 !!js process.platform 做互斥 |
十二、回到最初那个问题
开头那三个失效点,现在应该都有对应的解法了:
- 环境事实靠“读” → 写进 persona 的工具路由约定,给出三侧路径对应和理由
- 纪律只是建议 → 需要拒绝的部分下沉到工具行,用
restrict做硬门控 - 常驻成本 → 流程知识移到 skill,用阶段的渐进披露控制工具面
如果只记一件事,那应该是第二章那条判定规则:有 agent 平面之外的消费者,就必须留在 host 平面。 它决定了你写的每一行到底该放在哪,也是“预设能跑”和“预设一挂第二个会话就崩”之间的分界线。
预设真正值钱的地方,不在于它写得多详细,而在于它把哪些东西从“文字”变成了“结构”。一份写在 AGENTS.md 里、需要模型自觉遵守的约定,和一个写进工具行、模型绕不过去的约束,效力完全不同。前者是请求,后者才是工程。
参考资料
- DeepSeek Harness:https://github.com/deepseek-ai/deepseek-harness
- 本文示例预设(已脱敏模板):https://github.com/Aone2233/dsh-router-sci
- 上游渐进披露路由实现:https://github.com/yjh051108/dsh-routing-suite
- dsh
dsh-agent-presets包文档(预设发现、装配与创作) - 本站相关文章:Claude Code 跨平台使用指南、如何使用 Codex、清华毕设 LaTeX 模板与 AI 辅助写作入门
文档信息
- 本文作者:LLY
- 本文链接:https://orderly2233.org/2026/09/13/dsh-preset-authoring-guide/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)