第 5 章 系统提示词是怎么拼出来的
摘要:本章深入解析 DeepSeek Harness 中系统提示词的组装机制。系统提示词并非硬编码,而是由插件通过段落(PromptSection)、动态上下文(PromptContext)和工具 schema 提供方三类贡献拼接而成。核心设计在于系统提示词作为「历史中的消息」而非请求顶层字段传递,以保障请求可从日志重建。工具 schema 通过显式允许列表仅暴露 name、description、parameters 三个字段,其余执行细节对模型完全不可见。本章还涵盖更新规则、作用域组装上下文及实操示例,帮助读者理解并编写自己的提示词段落插件。
标签:系统提示词PromptSectionPromptContextToolSchema可重建性插件机制DeepSeek Harness
模型每次开口前,都会先看到一段「系统提示词」。这段文字不是硬编码在某个文件里的,而是由一堆插件各自贡献一段、然后按规则拼起来的。这一章讲清楚拼接规则——顺便解决一个经典困惑:「为什么我在系统提示词里改的东西有时候没生效」。
5.1 三个贡献类型:段落、上下文、工具 schema
组装这件事,官方交给ctx.systemPrompt这个服务。它接受三类输入:
| 类型 | 注册方法 | 它进入请求的哪一部分 |
|---|---|---|
| 段落 PromptSection | ctx.systemPrompt.section() | 拼成系统提示词文本。第一条追加为 surface 第 0 号节点 |
| 动态上下文 PromptContext | ctx.systemPrompt.context() | 作为一条**持久的 user-role 快照(某一瞬间的完整状态副本。拿到快照之后别人再改,也不会影响你手上这一份)**被物化。只在完整当前快照发生变化或被压缩移除时才重新记录 |
| 工具 schema 提供方 | ctx.systemPrompt.tools() | 贡献这次组装里模型可见的工具 schema 集合 |
| 提示词变量 | ctx.systemPrompt.variable() | 给段落文本里的{{变量}}占位符提供值 |
图 18|排序、遮蔽、waterfall、渲染——四步之后,一段文本变成了一条 surface 节点。
5.2 段落的字段:五个就够了
一个PromptSection的字段非常少,但每个都有明确用途:
| 字段 | 必填 | 说明 |
|---|---|---|
name | 是 | 唯一名称。重复注册会抛异常,所以起名要带上你的插件前缀。 而且它还有一个副作用:order 相同时按名称排序,所以名字也参与排序 |
order | 是 | 排序权重,升序拼接。同一个 order 时按名称的代码单元顺序排 |
text | 是 | 静态文本,或者一个每次组装时求值的函数(参数是这次组装的AssembleContext)。用函数就能做到「提示词随当前 agent 变化」 |
interpolate | 否 | 是否插值{{变量}}。默认 true;设成 false 就保留字面文本(写代码示例时有用) |
complete | 否 | 把这个贡献当作完整的系统提示词。多个生效的 complete 会让组装失败 |
还有一个配套的结构PromptContext,字段更少:name、order、text。区别在于它的产物是一条持久的 user-role 快照,而不是拼进系统提示词文本里。
怎么选:段落还是上下文
问自己一句:「这段信息是每个请求都该在的稳定说明,还是『当前状态』?」
·稳定说明(我是谁、怎么用工具、项目规范)→ 用段落
section()·当前状态(现在打开了哪些文件、有哪些可选项)→ 用上下文
context(),因为它是快照,变了才重新记录
5.3 最容易困惑的一点:系统提示词是「消息」,不是「请求字段」
这是本章最重要的一节。官方在这个设计上专门写了一篇决策记录,标题就叫「系统提示词作为 surface 节点」。
传统做法是:把系统提示词放在请求的一个system字段里。DeepSeek Harness 的做法不同——渲染后的系统提示词是通过system/message历史传递的。
提示词仅通过
system/message历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新……不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换。
再看官方在 LLM 一页里的说法:
请求的
system字段不设置——GenerateOptions.system服务于标题提供方等直接单次调用方。
也就是说:**循环构建的请求里,系统提示词是「派生历史里的第一条消息」,不是请求的顶层字段。**请求的system字段是留给那些不走循环的直接调用方(比如自动生成会话标题)用的。
为什么要这么设计
官方给出的理由是「可重建性」。回顾第 4 章:请求必须是日志的纯函数。如果系统提示词放在请求的顶层字段里,那么它就不在日志的事件流中,重建请求时就没有依据。而把它作为system/message事件放进日志,它就成了历史的一部分,可以被完整重建。
这是一处很典型的「用一个约束(可重建)统一了一片设计」的例子。你在读官方文档时看到的各种看起来多余的规则,多半都能追溯到类似的一条底层约束。
由此带来的更新规则
因为系统提示词是历史节点,它的「变化」就有了三种处理方式,取决于路由是否声明支持「历史内更新」:
| 情况 | 处理方式 |
|---|---|
路由声明了systemPromptUpdate: 'in-history' | 模型会把messages里任意位置最新的system消息读作完整的有效系统提示词。所以循环可以把变化后的非空提示词追加到已缓存历史之后,而不是改写第 0 条消息——这样前面的缓存前缀就保住了 |
| 路由没有声明该模式 | 模型只读开头的 system 消息。所以循环必须把非空提示词文本归并到首个系统节点,并为后续的系统节点记录「空内容替换」 |
| 渲染文本变成空 | 清除所有生效的系统节点,模型不再看到任何旧提示词。注意「空节点」不会去恢复旧文本 |
图 19|三种更新方式的存在,是因为不同模型路由的能力不同。更新规则要跟着路由能力走。
陷阱
新手常犯的错误:「我把系统提示词清空了,但模型好像还记得之前的指令」。请对照上表情况 C:空渲染文本会清除所有生效的系统节点。如果你观察到旧指令仍在起作用,那多半不是提示词的问题——想想它是不是被写进了 user 消息、或者被某个 skill 注入进了对话历史。
5.4 工具 schema 是怎么进提示词的
工具需要让模型知道自己的存在和参数格式。这件事通过ToolSchema完成,它的结构很简洁:
interfaceToolSchema{/** 请求把工具定义「延迟加载」进模型上下文,独立于是否用工具添加块记录它 */deferLoading?:truename:stringdescription:string/** 参数的 JSON Schema 对象 */parameters:Record<string,unknown>}官方特别说明了它为什么声明在dsh-llm而不是dsh-tools:因为它是一个模型请求(GenerateOptions)的一部分。dsh-tools的ToolDefinition和dsh-system-prompt的PromptAssembly都从那里 import 它。这是一个「按职责而非按直觉放置类型」的例子。
还有一个关键的安全细节,官方在工具一页里写得很明确:
注册表的
schemas()通过显式允许列表构建面向模型的ToolSchema[];output/execute/projectContent/finalizeContent/timeoutMs/isConcurrencySafe/presentCall/presentResult绝不能泄漏到模型请求中。
也就是说:你写工具时声明的那些执行逻辑、超时预算、UI 卡片函数,模型一个字节都看不到。它只看到name、description、parameters这三个字段。这是显式允许列表,不是黑名单——新增字段默认是不会被发出去的。
关键
你在
description里写的东西,是模型理解「这个工具是干什么的」的唯一依据。所以写工具时,description 的质量直接决定模型用得对不对。这不是文档,这是提示词。
5.5 工具提供方结果里的两个字段
如果某个插件想让工具集合随作用域变化,它注册一个「工具 schema 提供方」。返回值是两个字段:
| 字段 | 作用 |
|---|---|
schemas | 这个提供方对本次组装贡献的工具 schema |
knownNames | 提供方在「限制之前」的名称全集。用途是区分两种情况:一个名字是「配置里拼错了」还是「这个工具在此作用域中被有意隐藏了」。没有这个字段,配置校验只能报一句含糊的「工具不存在」 |
knownNames这个设计值得单独夸一句:它解决的是「错误信息质量」问题。很多框架到这里就报个「未知工具」了事,用户得自己猜是拼错了还是被策略挡了。
5.6 一个完整的组装上下文
每次组装都会有一个AssembleContext,它标识这次组装解析的作用域层,并可以携带该请求的显式控制信号。它也是可合并扩展的——dsh-agent给它加了一个可选的agent字段,用来携带当前 agent 实例;辅助函数assembleContextFor(agent, signal)则一起设置这些字段。
官方对「裸组装」的说明是:**它既没有作用域,也没有信号。**这时只有全局提供方和「无主体」的监听器参与。
这个设计让「同一个进程里不同 agent 看到不同的提示词」成为可能——这正是第 9 章「作用域」要展开的内容。
5.7 实操:写一个自己的提示词段落
把这一章的知识串起来,一个真实可用的提示词段落插件长这样:
importtype{Context}from'@deepseek-ai/cordis'exportconstname='my-guidelines'exportconstinject=['systemPrompt']exportfunctionapply(ctx:Context){// 1) 一段静态说明ctx.systemPrompt.section({name:'my-plugin:guidelines',// 名字带上前缀,避免和别人撞车order:250,// 排在 AGENTS.md 之后、skill 之前text:'回答时优先给出可执行的命令,不要只讲原理。',})// 2) 一段动态说明:随当前 agent 变化ctx.systemPrompt.section({name:'my-plugin:workspace',order:260,text:(assembly)=>assembly.agent?`当前工作目录是${assembly.agent.session.header.cwd}。`:'',})// 3) 一个变量,供上面或别人的段落引用 {{repo_name}}ctx.systemPrompt.variable('repo_name',()=>'my-repo')}三点值得注意:
注册是副作用。插件卸载时这些段落会自动消失,你不需要写清理代码。
inject: ['systemPrompt']让 Cordis 等到提示词服务就绪再启动你这个插件。名字冲突会直接抛异常。所以永远带上前缀。
5.8 这一章要带走的三句话
这一章要带走的三句话
**系统提示词是拼出来的,不是写死的。**顺序由 order 决定,同名会被作用域遮蔽。
**它是历史里的消息,不是请求的顶层字段。**这是「请求可从日志重建」这条约束的直接后果。
**模型的输入面只有三个字段:name、description、parameters。**其他全是执行期的私有信息,不会外泄。