前端Q公众号文章

System Prompt 不是写的,是拼的

你去翻 `dsh` 的源码,想找那个几千字的 System Prompt 模板文件。

前端Q

公众号 · 8 篇文章

点击上方 前端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() 的顺序是固定的:

text
收集全局 + 当前 scope 的所有提供方
  → 把工具参数摘出来
  → 应用规范排序
  → 跑 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 节点:

▸第一个 step,追加成节点 0
▸后面提示词变了,就地替换那个节点
▸如果这条路由声明了 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 失效和提示词打架。

▎可进一步阅读

▸System Prompt 子系统:https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/system-prompt.md
▸Session 子系统(surface 节点):https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md
▸Scope 子系统:https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/scope.md
图片

往期推荐

Multi-Agent Teams:让多个专家 Agent 像团队一样协作
图片
AI Agent 是怎么"想一步做一步"的?拆解 ReAct 模式
图片
从零开始:用 LangChain.js 构建你的第一个 Tool-Calling Agent
图片
最后


图片
图片
点个在看支持我吧
图片

前往微信阅读全文

内容来自公众号,可前往微信查看原文。

查看作者的更多文章 →订阅更新、RSS 与阅读指南 →