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

先问一个问题

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

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

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


静态字符串拼接的问题

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

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

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

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 Cachingcache_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 技能和工作流,不是演示级的,是用在实际项目里的。

更多内容见我的个人主页

相关推荐
罗西的思考3 小时前
机器人模型(WM / WAM / VLA)综合分析与对比:从「看」到「想」再到「做」
人工智能·算法·机器学习
火山引擎开发者社区3 小时前
基于 AgentKit 的端到端需求交付平台:从个人提效到组织提效的 AI 落地实践
人工智能
三声三视4 小时前
75 条文章索引被一条 add 清成 1 条,退出码还是 0:tri-article 的 index.py 我读了 205 行
人工智能·ai·skill·tri-skills·tri-article
蓝速科技4 小时前
医院导诊 AI 数字人一体机场景适配与落地指南丨蓝速科技
运维·数据库·人工智能·科技·自然语言处理·技术分享
QYR-分析4 小时前
重轨受电弓行业深度报告:市场格局、技术迭代与发展前景
大数据·数据库·人工智能
蔡俊锋4 小时前
DeepSeek 语音对话灰度上线:四种音色背后的端侧 AI 交互架构与商业逻辑
架构·大模型·语音交互·deepseek·端侧ai
火山引擎开发者社区4 小时前
# 开发者集结!共探 AI Agent 创新应用新可能
人工智能
牛油果子哥q4 小时前
生产级AI项目上线全流程:Docker容器化、服务编排、监控告警、日志收集、容灾降级、线上运维闭环
人工智能·ai
Pocker_Spades_A4 小时前
视频不用再一张张截图:ClipSketch AI 把关键画面转成漫画,还能顺手生成文案
人工智能·音视频