dsh 插件开发学习

32-系统提示词的配置

读完这篇你能:知道模型每次请求看到的那段”规矩”是由哪些来源拼出来的、按什么顺序拼;会给自己的插件加一段;以及搞清 @path 引用到底做了什么——它可能和你想的正好相反。

前置:31-把 DSH 嵌进自己的应用。约 30 分钟。

示例代码:dsh-prompt-lab

DSH 版本:0.2.0-rc.2

模型每次请求,开头都带着一段”你应该怎么做事”的文本。常见的误解是”那是一份写死的系统提示词文件”。

它不是。它是一个注册表拼出来的结果——很多插件各自贡献一段,组装时按顺序拼成一份。

这一篇讲这套配置,以及一个经常被误解的入口:@path。

目标

  • 知道系统提示词是怎么从多个贡献方拼起来的。
  • 理解段落排序的两级规则,以及 complete 段的约束。
  • 分清”工作区指引”和”@ 引用”是两件不同的事。

DSH 设计

系统提示词的内容天生分散在各插件手里。DSH 里每样功能都是一个插件:身份设定、工作区规矩、可用工具的清单、可插值的提示词变量,各归不同插件贡献,没有任何组件独占一份系统提示词。DSH 给的设施因此是一个注册服务(ctx.systemPrompt):插件各自往里登记段落,每段带名字与排序号。每次向模型发请求之前,系统把当前该出场的段落按顺序现场拼成一份。段落的文本可以是一个函数,按这次拼装的上下文现场求值。拼完还要再过一道「瀑布事件」(waterfall):监听器一个接一个传着改,每个改完必须交给下一个,挂在它上面的监听器能对整份结果做最后改写。所以提示词不是从某处读出来的,而是每一步由各方当场拼出来的。

登记这个动作本身是可撤销的。底层框架有一条通用约定:插件的一切注册都是「effect」,随插件的生命周期进出。段落登记也照此办理:每次登记都会拿到一个对应的撤销动作,登记它的插件被卸载时,这段文字跟着消失。同一层里段落名必须唯一,重复登记直接报错,而不是悄悄覆盖。所以「这段话是谁加的」始终可追,也不会留下插件已走、段落还在的残留。

「哪些段落出场」还可以按助手划圈。DSH 允许一个宿主进程同时跑多个智能体(agent),并支持让不同智能体用各自选定的工具、提示词与技能,这样一套组合叫「预设」(preset)。提示词一侧的对应机制是「作用域」:插件挂在哪个智能体的 ctx 下,它登记的段落就落进哪个作用域层;一次拼装带上哪个作用域名,就只有全局层与这一层的贡献出场。带作用域的段落还会遮蔽同名的全局段落,所以给某一个智能体换一套人设,不必去动大家共用的全局注册表。看一个小例子:

// 全局插件里登记一段 identity:
ctx.systemPrompt.section({ name: 'identity', order: 100, text: '你是通用助手。' })

// 另一个插件挂在翻译 agent 的 ctx 下,同名段落落进它的作用域层:
ctx.systemPrompt.section({ name: 'identity', order: 100, text: '你是翻译助手,只译不评。' })

// 给翻译 agent 拼装时,模型只看到「只译不评」那一句;给其他 agent 拼装时看到的仍是通用那句。

最后是一条硬约束:模型看见的东西必须留有记录。DSH 把一次对话发生的每件事按顺序记成「会话日志」,模型每次请求看到的历史全部从日志推导。运行时还有一条不变量检查:任何一次模型请求必须能从日志完整重建。系统提示词是模型看得见的输入,所以它没有资格做「请求里的一个临时字段」。拼装结果会作为一条特殊的系统消息节点写进日志,与对话的其他消息一起被保存、被压缩、被回放。

mermaid 图表

一段提示词从登记到抵达模型,全程只服从两种顺序:注册表里的拼装顺序,和日志里的记录顺序。

提示词是拼出来的

一次组装有一个上下文对象:

interface AssembleContext {
  scope?: ScopeKey
  signal?: AbortSignal
}

scope 是参与本次组装的作用域层——不在这个作用域里的提供方和监听器不参与;signal 是这次请求的控制信号。这个类型是可合并扩展的:dsh-agent 给它加了一个可选的 agent 字段,用来携带当前的 agent 实例,并且有个 assembleContextFor(agent, signal) 把这几样一起设好。

有一句边界要说清:裸组装既没有作用域,也没有信号。 也就是说”组装”这个动作本身可以在没有任何请求上下文的情况下发生——那种情况下只有全局提供方参与。

组装里还会解析工具 schema 集合:

interface ToolProviderResult {
  readonly schemas: readonly ToolSchema[]
  readonly knownNames?: readonly string[]
}

schemas 是本次组装中对模型可见的工具,knownNames 是限制之前的名称全集。后者的用途很具体:区分「配置名拼写错误」和「已知工具在此作用域中被有意隐藏」。没有这个全集,这两种情况在报错时长得一样——一个是你写错了,一个是你按作用域故意关掉的,处理方式完全不同。

段落:名字、顺序、文本

一个贡献方注册的是这样一份东西:

interface PromptSection {
  readonly name: string
  readonly order: number
  readonly text: string | ((context: AssembleContext) => string)
  readonly interpolate?: boolean
  readonly complete?: boolean
}

五个字段,五个要点:

name 在同一层里必须唯一。 一层里重复注册会直接抛错——不是覆盖;而带作用域的段落会遮蔽同名的全局段落。这一条让”谁贡献了哪一段”始终可追。

order 决定位置,但排序是两级的:先按 order 升序,order 相同就按名称的代码单元顺序。所以注册顺序不代表显示顺序——两个 order: 900 的段落谁在前,取决于它们的名字,不取决于谁先注册。

text 可以是静态字符串,也可以是个函数,每次组装时按当前的 AssembleContext 求值。文本里还可以写 {{变量}},那些由 renderPrompt 在后面插值——求值和插值是两步,别混。

interpolate: false 让这一段跳过插值,整段按字面保留花括号。默认是 true;需要输出字面 {{…}}(比如生成给模型看的模板语法)时用它。

complete 最多只能有一个。 它的语义是”这一段就是完整的系统提示词了”:组装仍然会跑完整个协作式瀑布(好让工具、上下文、变量都被解析出来),然后把这个确切的段落恢复成唯一的提示词段。而如果有超过一个有效的 complete 段,组装会失败——这是刻意的:两段都声称自己是全部,没人能判断听谁的。

顺带说清一个容易混的概念:仓库贡献方通过 getSectionOrder() 解析服务持有的具名分配,而 runtime-context 贡献方走 getContextOrder()——两套独立的分配。所以别把”段落顺序”和”上下文顺序”当成同一个编号体系。

提示词的去向

有一处设计会让”提示词是请求参数”这个直觉彻底失效。

组装完之后,agent loop 用 renderPrompt 渲染各段,并把文本作为 system/message 表面节点提交:

  • 首个步骤作为 surface 的第 0 号节点追加;
  • 之后渲染文本变化时原地替换;
  • 或者当”已准备调用”声明了 systemPromptUpdate: 'in-history' 时,在序列延续期间把非空更新追加到已缓存历史之后。

结论就是:提示词是作为派生历史中的消息到达模型的,不是请求里的一个字段。

这件事有一串直接后果:提示词会进会话日志,因此能被压缩处理、被回放、被历史机制正常对待。如果它是请求字段,这一切都得另想办法。

从注册一段文本到它到达模型:

mermaid 图表

动态上下文

和段落平行的还有一份 PromptContext:

interface PromptContext {
  readonly name: string
  readonly order: number
  readonly text: string | ((context: AssembleContext) => string)
}

名字必须唯一(重复注册抛错),按 order 升序拼接,空文本不贡献任何东西。

它和段落的关键差别在缓存上:这份结构是”缓存安全”的,agent loop 只在完整当前快照发生变化、或者被压缩移除了,才会把新的记录在保留的模型历史之后。它的定位是”动态模型上下文物化为持久的 user-role 快照“。

用一句话区分两者:段落是系统提示词的一部分,动态上下文是历史里的一条 user 消息。前者拼在开头,后者按变化追加。

工作区指引

模型为什么好像”天生知道”这个仓库的规矩?因为有一条自动注入的链:agent-instructions。

它向 agent 提供来自用户全局文件和项目级文件的工作区指引,这些文件与 AGENTS.md 兼容——也就是说 DSH 自己的 AGENTS.md 和你的项目 AGENTS.md 走的是同一条路。

关于它的行为,有三点值得知道:

  • 它为第一次请求加载适用的指令链。
  • 它不会持续监视外部编辑。 后续发现靠两条路:成功的文件系统操作会发现新适用的嵌套文件、并让之后的变更或移除可见;恢复会话会对账基线。所以如果你在会话进行中从编辑器里改了一个 AGENTS.md,别指望它立刻生效——触发条件不是”文件变了”,而是”发生了一次成功的文件操作”。
  • 有字节预算。 超了之后:较宽泛的文件先被省略,最具体的文件最后被截断;空指令链不添加任何内容。

那个预算的取舍方向值得注意:越具体越优先保留。嵌套在子目录里的 AGENTS.md 通常比根目录那份更贴近当前任务,所以先牺牲宽泛的。

dsh-base 默认启用这个行为,profile 可以禁用。

@ 引用到底是什么

现在说那个常见的误解。

很多人以为 @some/file.ts 的含义是”把这个文件读进来给模型看”。不是。

file-reference 这条缝提供的是路径补全:

  1. 宿主驱动的 UI 为指定的 agent 请求路径候选;
  2. 你在输入框里打 @path 或者 @"path with spaces";
  3. 选中候选后,匹配的 mention 作为普通提示词文本插入。

到第 3 步为止,全部事情就结束了。原文写得很直接:

选中候选绝不读取或附带文件内容;模型必须调用文件系统工具才能查看文件。

而且 seam 本身不拥有文件系统访问——候选的提供、排序、缓存与失效都是具体提供方的事(比如那个本地实现)。

这个设计不是偷懒,是把”提及”和”读取”分成两件事:

  • 提及是意图表达——你告诉模型”我说的是这个文件”;
  • 读取是一次工具调用——它有权限检查、有观察策略、有版本校验、会被记进日志。

如果选中候选就自动附带内容,那条路径就绕过了后面这套管控:读了一个没权限的文件、读了一个刚刚变过的文件、读了一次就该被记账却没记的读取。所以它故意把读文件留给工具。

这条缝上的一轮交互:

mermaid 图表

顺带一提,浏览器那边拿到的是同一套发现能力:Session Controller 通过 fileReferences/list 这个 Remote 把它暴露出去。

加一段自己的规矩

demo dsh-prompt-lab 就是注册一段:

function render(context: { agent?: unknown }): string {
    // 动态求值的用处:内容可以随组装上下文变。这里只是把有没有 agent 写出来。
    const binding = context.agent === undefined ? '(没有绑定 agent)' : '(已绑定到一个 agent)'
    return [
        `本仓库的约定${binding}:`,
        '- 新增的文档放在 docs/ 下,按主题分目录;',
        '- 提交信息用祈使句,一行说清改了什么;',
        '- 改完代码跑一次 lint,不要让格式问题混进提交。',
    ].join('\n')
}

export function apply(ctx: Context): void {
    // 注册本身就是 effect:卸载时这一段跟着消失。
    ctx.systemPrompt.section({
        name: SECTION_NAME,
        order: 900,
        text: render,
    })
}

text 给的是函数而不是字符串——这就是”每次组装时按上下文求值”的入口。哪怕你只写静态内容,用函数也不吃亏:以后要按会话改内容时不用改结构。

order: 900 是个靠后但不至于压掉收尾段的位置。想放进哪个区间,看的是那套具名分配(getSectionOrder()),而不是随便挑一个大数。

验证

cd code/dsh_plugin/dsh-prompt-lab && ./install.sh

三步:

# 1. 配置对不对
dsh --profile test --dump-config | grep -A3 'id: dsh-prompt-lab'

# 2. 问一件只有这段规矩才知道的事
dsh --profile test "本仓库新增的文档应该放哪?"

# 3. 把 order 改成 100,再问一次

第二步能答对,说明这段真的进了提示词。第三步是个有意思的实验:同一段文本,位置变了,模型对它的注意力分配会不一样——靠前的位置更容易被当成”总纲”,靠后的位置更容易被当成”补充说明”。所以 order 不只是排版问题。

想验证”提示词是表面节点”这件事,去看会话日志:里面会有一条 system/message 节点,而模型请求里并没有一个叫”系统提示词”的字段。

注意事项

  • 段落名在同一层里必须唯一。 一层内重复注册抛错,不覆盖;带作用域的段落会遮蔽同名的全局段落。名字也是你日后排查”这段话谁加的”的唯一线索,起得具体些。
  • 排序是两级的。 order 相同就按名称的代码单元顺序——注册顺序不决定显示顺序。
  • complete 最多一个。 两个有效的 complete 段会让整个组装失败。别在插件里随手加它。
  • 文本求值与 {{变量}} 插值是两步。 前者在组装时按上下文算,后者由 renderPrompt 在做渲染时替换;未知引用、已注册却没有值的引用、格式错误的完整组都会直接抛错。要整段保留字面花括号,就给这一段 interpolate: false。
  • 提示词是表面节点,不是请求字段。 所以它会进日志、会被压缩处理、能回放。想在别处读它,走会话日志这条正路。
  • 段落与动态上下文是两种东西。 段落拼进系统提示词;动态上下文是历史里一条持久的 user 角色快照,且只在快照变化或被压缩移除时才追加。
  • agent-instructions 不监视外部编辑。 触发条件是”发生了成功的文件操作”或”恢复会话对账”,不是”文件 mtime 变了”。改完 AGENTS.md 想立刻生效,别干等。
  • 字节预算先丢宽泛的。 超预算时较宽泛的文件先被省略,最具体的文件最后才被截断——嵌套越深越优先保留。
  • @path 不会把文件内容喂给模型。 它插入的是一段普通提示词文本;想看到内容,模型必须自己调文件系统工具。这个设计是为了不让”提及”绕过读取的权限、观察与记账。
  • 别把段落顺序和上下文顺序混用。 一个走 getSectionOrder(),一个走 getContextOrder(),是两套编号。