大模型应用实战

DeepSeek Harness 系列(06):System Prompt 组装——动态提示词的工程实现

每次模型请求前,dsh 是怎么把几十个插件的提示词片段拼成一个完整的 system prompt 的?这篇文章拆解 ctx.systemPrompt 的注册机制、段落排序、动态变量、工具 schema 自动注入,以及 Prompt Caching 怎么用。

·约 11 分钟阅读·AI Engineering

先问一个问题

如果 dsh 里同时加载了 20 个插件,每个插件都想往 system prompt 里写点东西——有的要加角色描述,有的要加工具使用规范,有的要注入当前工作目录——最后模型收到的 system prompt 是什么样子的?

这 20 段内容谁先谁后?某个插件被卸载了之后,它贡献的那段怎么消失?有没有可能让某一段内容每次请求时都动态更新?

这些问题都指向同一套机制:ctx.systemPrompt 服务。


静态字符串拼接的问题

最简单的做法是:每个插件把自己的提示词字符串暴露出来,主流程依次拼接。

这种做法马上会遇到三个麻烦:

  1. 顺序不可控:各插件按加载顺序排列,但逻辑上"角色定义"应该排在最前,"工具列表"应该排在最后——插件加载顺序和内容逻辑顺序不是同一回事
  2. 耦合严重:主流程需要知道每个插件的名字和接口,插件之间形成隐式依赖
  3. 无法动态更新:静态字符串组装一次就固定了,但有些内容(比如当前时间、工作目录)每次请求时都应该重新求值

dsh 的解法是注册制 + 排序:每个插件把自己的提示词片段注册到一个中央服务,由中央服务统一管理排序和组装。


ctx.systemPrompt 服务:注册一个 Section

Section 是 dsh 提示词体系的基本单元——一段有名字、有顺序的提示词片段。

// 最简单的用法:注册一个静态 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 设为一个函数就可以了:

// 动态 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 提供了变量注册机制:

// 注册一个变量(可以在任意 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:

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(格式说明)也会作为提示词的一部分注入。

// 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 扩展点:

// 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 的组装流程如下:

每次 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

把以上所有机制串在一起,写一个完整的插件示例:

// 完整插件示例:注入工作目录上下文
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 技能和工作流,不是演示级的,是用在实际项目里的。

更多内容见我的个人主页