点击上方 前端Q,关注公众号
回复加群,加入前端Q技术交流群
你去翻 dsh 的源码,想找那个几千字的 System Prompt 模板文件。
找不到。
因为它不存在。DeepSeek Harness 的系统提示词没有一份完整原文,它是每次模型调用前,由一堆插件各交一段、排序、跑一遍瀑布,当场拼出来的。
这个设计乍看有点绕。但你想想:Skill 要往里塞能力说明,Plan Mode 要塞计划纪律,子目录的 AGENTS.md 要塞项目规矩,工作区信息要实时更新——如果只有一份静态模板,这些东西往哪放?
四种投稿方式
ctx.systemPrompt 开放了四个注册口,每个解决不同的问题:
section() 交一段提示词。 有 name(重名直接抛)、order(升序拼接,同序按名字的 code-unit 排)、text(静态字符串,或者一个每次拼装时现算的函数)。
context() 交一段动态上下文。 它是 section 的"缓存安全版"——会被物化成一条持久的 user 角色快照,只在内容变了、或者被压缩掉之后,才重新记一次。
variable() 交一个变量。 名字必须匹配 [a-z][a-z0-9_]*,提供方可以返回 undefined,但只要有 section 引用了它,渲染当场失败。
tools() 交一批工具 schema。 返回 { schemas, knownNames? }。
section 和 context 的区别,是我一开始最没搞懂的地方。
后来想明白了:section 进系统提示词,context 进对话历史。
前者每次重拼都可能变,属于指令;后者是"当前工作区有哪些文件""现在几点"这类会话事实,做成持久快照放在保留历史之后,KV Cache 就不会因为它变了而整段失效。
那个 knownNames 字段也很有意思。它是限制生效之前的工具名全集,用来区分两种情况:你在配置里写了个不存在的工具名(打错了),和这个工具确实存在但在当前 scope 里被故意藏起来了。
一个字段,把"配置报错"和"权限收紧"分开了。
拼装流水线
assemble() 的顺序是固定的:
→ 把工具参数摘出来
→ 应用规范排序
→ 跑 system-prompt/assemble 瀑布
→ 如果有 complete 段,还原成唯一的那段
→ agent-loop 用 renderPrompt 渲染
→ 作为 system/message 提交
system-prompt/assemble 是一条 waterfall,官方管它叫 expert waterfall——返回值就是最终结果,权威。
但它头上压着一个东西:complete。
一个 section 如果标了 complete: true,意思是"我这段就是完整的系统提示词"。这时候拼装照样会把整条协作瀑布跑完(工具、上下文、变量都要解析),然后把这段原样还原成唯一的提示词。
官方补了一句很关键的话:complete 段是在瀑布之后还原的,所以监听器既没法往这个 scope 的提示词里加东西,也没法替换它。
这是一个我很欣赏的权力设计。你想完全接管提示词,有一条明路(注册一个 complete 段),而不是靠在瀑布里抢跑、靠注册顺序赌运气。
顺带一提,超过一个生效的 complete 段,拼装直接失败。宁可炸,也不猜该听谁的。
谁遮谁:scope 说了算
所有注册都是在调用方所在的 scope 里生效的。
规则一句话:同名的 scoped 条目遮蔽 global 条目。
Section 是这样,variable 是这样,工具也是这样。
这意味着一台 dsh 可以同时跑好几个 Agent,每个 Agent 看到的系统提示词都不一样,但它们共享同一套全局默认值。子 Agent 想换个人设?在自己的 scope 里注册一个同名 section 就行,不影响别人。
官方还专门给了两个命名槽位:deployment:persona-prefix 和 deployment:persona-suffix,位置分别由 DEPLOYMENT_PERSONA_PREFIX 和 DEPLOYMENT_PERSONA_SUFFIX 这两个名字集中分配。想改人设,往这两个槽里填,不用自己算 order 数字。
还有一个我觉得很体贴的 API:suppressRuntimeContext()。
它把当前 scope 里所有动态运行时上下文都静音,但不改变那些拥有或强制这些事实的服务。多个静音器各自独立可销毁。
翻译一下:你想让某个子 Agent 看不到那一堆环境噪音,不需要去卸载对应的插件,也不需要担心别人也跟着瞎。加一个静音器,用完销毁。
提示词最后变成了什么
这是最反直觉的一点:渲染完的提示词不是请求里的一个字段,它是一条消息。
agent-loop 拿 renderPrompt 渲染出文本,然后作为 system/message 提交成 surface 节点:
systemPromptUpdate: 'in-history',非空的变更会在缓存历史之后追加一个新的 system 节点为什么要这么设计?
因为这套运行时有条铁律:模型看得见的一切,都必须能从日志重建。 如果提示词只是请求里的一个 system 字段,它就游离在日志之外了——你回放的时候,永远不知道当时模型到底被灌了什么。
做成 surface 节点,它就是 derived history 的一部分。哪次改了、改成什么、盖掉了哪个旧节点,全在账上。
对应地,请求信封 EpochHeader 里明确禁止出现 system 字段。改提示词只动 system 节点,信封纹丝不动。这样一来,"请求变没变"和"提示词变没变"是两件可以分开判断的事。
优劣势
优势:提示词有了真正的扩展点。
装一个 Skill 插件,它自己往里加一段;开 Plan Mode,多一段计划纪律;切个 scope,人设就换了。你不用去改任何一个"主模板",也不会遇到"两个插件抢着改同一个字符串"这种烂事。
而且因为拼装是纯函数式的(section 要么静态、要么是 AssembleContext 的函数),同样的输入拼出同样的结果,可测试。
劣势:整段提示词没有"一眼看全"的地方。
想知道模型到底收到了什么,你得去看日志里那条 system/message,或者自己跑一次 assemble()。源码里搜不到完整原文——对新人来说这个上手坡度是真实的。
order 数字也是个隐患。虽然官方提供了 getSectionOrder() 来集中分配,但第三方插件自己拍脑袋定的 order,撞车了就得靠名字的 code-unit 顺序兜底。这不是个让人安心的兜底。
预览阶段这些类型和事件名还在变,我写这篇时依据的是官方 docs/subsystems/system-prompt.md。
我的判断
我们在 Harness 工程里反复说一件事:别什么都往 System Prompt 里塞。
但"别塞"只是态度,没解决问题——那些信息总得有地方去。
DeepSeek 的答案是把提示词从一份文档变成一次装配:谁要加内容,就去注册一个有名字、有顺序、有作用域、可销毁的贡献。要么进 section(指令),要么进 context(会话事实),要么做成变量。想全接管,就走 complete 这条明路。
这比"大家一起改一个模板文件"强太多了。前者是有契约的协作,后者是抢方向盘。
如果你在自己的项目里做 Prompt 管理,我建议至少抄两件事:给每段贡献一个唯一名字和一个显式顺序,以及把"指令"和"会话事实"分开存放。光这两条,就能省掉后面大量的 KV Cache 失效和提示词打架。
▎可进一步阅读
往期推荐