DeepSeek Harness 的系统提示词是拼出来的:order 数值、可插入的位置与几处撞车

2026-08-17

给 dsh 写插件的人早晚会碰到同一个问题:我想让模型在动手之前先读一句自家的规矩,照着别的插件抄一行 ctx.systemPrompt.section({ name, order, text })order 填几?

抄来的那个数字大概率是 100 或者 0。填完之后你会发现,这句话要么夹在一堆工具说明中间,要么被别的东西挡在后面。问题不在写法,在于 order 在这套代码里是有约定的,而约定散在三个地方:packages/core/system-prompt/src/index.ts 里的 JSDoc、同一个包的 README.md,以及三十来处真实注册点里硬编码的那些数值。这三处并不完全对得上。

先把限定摆在前面:这个仓库的 README 自己写着处于 developer preview(开发者预览)阶段,并用大写强调「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」。本文所有字段名、默认值、order 数值都是我们在快照 47f9438(版本 0.1.0-rc.5)的源码里读到的,随时会变,别把它当成稳定 API 抄进生产配置。

一次组装发生在什么时候

入口在 packages/core/agent-loop/src/agent.ts。私有方法 preStep() 里有这么一行:

const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))

preStep() 由 turn 循环在每个 step 开始前调用一次。拿到的 assembly 一路传进 step(),在那里才真正被拼成字符串:

const system = renderPrompt(assembly)

这两行分处 agent.tspreStepstep 两个方法里,中间隔着 agent/pre-step 这一跳。分开的后果是:注册阶段登记的只是「贡献者」,真正求值发生在每一步之前。PromptSection.text 的类型是 string | ((context: AssembleContext) => string),写成函数的话,每次组装都会带着这次组装的 AssembleContext 调用一次。想让某段文字随会话状态变,就把它写成函数,而不是在注册时算好一个字符串。

排序:一条从 -100 开始的数轴

assemble() 里的排序只有一句:把合并后的 section 按 order 升序排。renderPrompt() 随后做三件事——逐段插值、丢掉空字符串、用空行把剩下的接起来。没有分组、没有标题、没有分隔符,就是拼接。

PromptSection.order 的 JSDoc 写明的约定是:-100 是 harness identity,0 是 deployment persona,工具指引占 100–199,其它负数也排在 persona 之前。

约定归约定,真实数值得去注册点数。除 packages/core/system-prompt 自身之外,我们在 packages/**/src 下数出 30 处 systemPrompt.section( 调用,分布在 27 个文件里(packages/core/tools/src/index.ts 一个文件里就有 4 处,因为全局注册和 presentAs() 的按 scope 注册各写了一遍)。把其中带明确数值的挑出来:

ordersection 名注册处
-100harness:identitypackages/core/system-prompt/src/index.ts 构造函数
-99harness:sourcepackages/boot/app-boot/src/index.ts
-98app:web-surfacepackages/bundle/web-app/src/index.ts
0deployment:persona注册表构造函数 / packages/preset/persona/src/index.ts
50plan:policypackages/plan/plan-mode/src/index.ts
99tools:code-onlypackages/core/tools/src/index.ts
100–104tool:read / tool:write / tool:edit / tool:glob / tool:greppackages/fs/ 下各文件
105tool:bashtool:pwshpackages/shell/tool-bash/packages/shell/tool-pwsh/
106tool:ptytool:jobspackages/terminal/tool-terminal/packages/jobs/tool-jobs/
110–115tool:web_search / tool:web_fetch / tool:lsp / tool:session-query / tool:goal / tool:cordis各自的工具包
115tool:<workflow 工具名>packages/workflow/tool-workflow/src/index.ts
116tool:ralphpackages/workflow/tool-ralph/src/index.ts
116.5tool:<subagent 工具名>packages/subagent/tool-subagent/src/index.ts
117tool:reportpackages/subagent/tool-subagent-report/src/index.ts
150tools:sdkSDK_SECTION_ORDERpackages/core/tools/src/code-mode.ts
190结构化输出说明、ui:deliverable-file-referencespackages/subagent/subagent-in-process-driver/src/structured.tspackages/client/ui-deliverables/src/index.ts

这张表里有两个数值值得单独说。

一个是 99 的 tools:code-only。它比工具指引的起点小 1,源码注释自述了原因:每个工具都注册自己那段指引、谁也不会说明自己是怎么被调用的,它们统统排在 SDK_SECTION_ORDER 之前的 100–199 区间里;如果没有这条规则先出现,模型读到的就是一整份工具目录加上「只能调 run_code」的矛盾说法。COLLAPSE_SECTION_ORDER = 99 就是把这条规则摆到那批指引前面而不是后面。

另一个是 116.5。SUBAGENT_SECTION_ORDER 直接写成了小数,注释自述是「在受限委派策略之后、子体上报之前」——因为 116 和 117 已经被占了。ordernumbersection() 对它只校验 Number.isFinite(不是有限数就抛 TypeError),所以插小数是合法的。你要往两段既有文字中间塞东西,这就是唯一的办法:不改别人的数值,取个小数。

撞车的地方

同一个 order 上有多段的情况,我们在上面这批注册点里数到四处:105 上是 tool:bashtool:pwsh,106 上是 tool:ptytool:jobs,115 上是 packages/extensions/tool-cordis/src/index.tstool:cordispackages/workflow/tool-workflow/src/index.ts 注册的那段工具指引,190 上是子代理的结构化输出说明和 ui:deliverable-file-references

packages/core/system-prompt/README.md 的「Known Limitations and Deferred Work」一节自述了这种情况的行为:共享同一个 order 值的 section 按注册顺序决定先后,而注册顺序是插件加载的产物;确定性依赖的是「各段取不同 order」这个约定,和被规范化过的工具顺序不一样。

这两处放在一起看,结论对使用者是明确的:如果你的插件和别人撞了 order,谁在前谁在后取决于加载顺序。要稳定,就别去占已经有人的数值。

能往里插东西的五个口

SystemPrompt 这个 service 类上一共六个公开方法:sectioncontextsuppressRuntimeContexttoolsvariableassemble。真正用来「往里插东西」的是其中四个,再加上一个不属于方法的 waterfall 事件,共五个口,各管一类东西:

  • section(section) —— 往系统提示词里加一段有序文字。
  • context(context) —— 加一段动态运行时上下文。注意这个不进系统提示词,下面单说。
  • variable(name, provider) —— 注册一个 {{name}} 变量。
  • tools(provider) —— 贡献一批工具 schema。ToolRuntime 会自动把自己注册成工具提供方。
  • system-prompt/assemble waterfall —— 在组装完成、约束生效之前,协作式地改动或替换整个 assembly。

剩下的 suppressRuntimeContext() 不贡献内容,只管压制动态上下文;assemble() 则是消费方,由 loop 调。

这五个口都受同一套 scope 规则约束:注册落在调用方 context 所属的层上,通过 agent.ctx 注册就只对那一个 agent 生效,并且同名遮蔽全局packages/preset/persona 这个包的模块注释把这条规则的后果写得很直白:因为注册表自己无条件注册了 deployment:persona,这个 persona 行只能挂在 agent scope 里去遮蔽它;挂到全局就会和注册表自己的注册撞名字,直接失败。同一份代码,挂错层就是报错,这一点抄配置的时候特别容易踩。

complete: true:把上面全部作废

PromptSection 有个可选字段 complete。一段有效的 complete section 会在协作式组装跑完之后,被恢复成唯一的提示词段落——其它所有 section 全部消失。但组装本身照跑不误,工具、上下文、变量都还会被解析出来。

同时存在一个以上有效的 complete section 会让整次 assemble 失败,抛出的错误信息里会列出这些 section 的名字。system-prompt/assemble 的 JSDoc 也写明:complete section 是在 waterfall 之后恢复的,所以监听器无法往那个 scope 的系统提示词里加东西或替换它。

也就是说,这个开关不是「优先级很高」,是「把整根数轴掐掉」。packages/preset/persona 的配置里就带着 complete 这个布尔项。

变量插值是严格模式,而且宁可炸

renderPrompt() 里的插值规则写得相当保守,几种情况会直接抛错:

  • 引用了没注册的名字。查表用的是 Object.hasOwn,所以 {{constructor}} 这类原型链上的名字也算未知。
  • 名字注册了但这次组装解析出 undefined
  • {{…}} 组格式不合法;变量名必须匹配 /^[a-z][a-z0-9_]*$/
  • {{ 之后没有完整闭合组、但后面还跟着一个 }}——{{{model}}} 就落进这一条。

唯一放行的是:一个 {{ 后面整段文本里再没有 }},那它就是普通文字,原样带过。另外,替换进去的值不会被再扫一遍,所以变量值里带 {{ 不会二次展开。README 里给这套行为的说法是「fail loud beats shipping a malformed prompt」(宁可大声失败,也别把畸形提示词发出去)。

变量本身由谁注册?packages/core/agent-loop/src/index.ts 里连着三行:providermodelcwd。这里有一处口径差异值得记一笔:packages/core/system-prompt/README.mdpersona 配置说明和「Extension points」两处都只写了 agent loop 注册 modelcwd,而源码里同一段还注册了 provider。两处不一致,以源码为准;差异本身我们只陈述到这里。

运行时上下文不是系统提示词

context() 注册的东西和 section() 是两条路。它们不进系统提示词,而是被渲染成一份用户角色的快照消息。joinContextSections() 会在正文前面固定加一句抬头:Current runtime context. This snapshot supersedes earlier runtime-context snapshots.;当所有上下文都为空、但历史上存在过快照时,投影出的是另一句 Current runtime context: none. Earlier runtime-context snapshots no longer apply.

packages/core/agent-loop/src/runtime-context.ts 里的 project() 只在快照内容与上次保留的值不同时才产出新消息(该方法的 JSDoc 原话是「只在保留值发生变化时创建未提交的快照」)。这么放的理由在 packages/interaction/user-approval/src/index.ts 注册 approval:policy 那几行上方的源码注释里自述过:完整的当前值走在保留历史之后,所以切换策略不会去改写那段稳定的系统提示词缓存前缀。

仓库里注册动态上下文的地方,我们在非测试源码里只数到三处:packages/sandbox/sandbox-policy/src/index.tssandbox:policy(order 110)、packages/interaction/user-approval/src/index.tsapproval:policy(order 115)、packages/subagent/subagent/src/child-agent.tssubagent:delegation(order 120)。前两处的 text 都做了同一件事:context.agent 为空时返回空串,因为一次裸的 assemble() 没有会话可陈述。你自己写 provider 时也得容忍这些字段缺席。

关掉它有两条路:配置 includeRuntimeContext: false,或者在某个 scope 里调 suppressRuntimeContext()。README 特别强调了一句:压制掉的只是这些上下文文字,拥有并执行底层策略的那些 service 照常工作。沙箱策略的文字不出现在提示词里,不代表沙箱不生效——反过来说,模型也就不知道有这条策略。这类涉及本机执行的能力边界,别靠「提示词里没写」来判断。

toolOrder 的失败时机

最后一个容易被坑的口径是 toolOrder。它是一份模型可见的工具顺序列表,必须且只能包含一个保留项 <unlisted-tools>(源码常量 TOOL_ORDER_REST),没列到的工具按名字的字典序落在这个保留项的位置上;整个配置省略时就是纯字典序。

坑在校验分了两段。列表形状问题(重复项、缺保留项)在配置加载时就抛;而「列了一个根本没注册的工具名」要等到 assemble() 才拒绝。README 的「Known Limitations」把这条写成了已知限制:toolOrder 配错会在提示词组装时——也就是第一个 turn——才浮现,而不是在启动时。按 README 的说法,在随附的 loop 下这一 turn 会在发出任何模型请求之前失败。

顺带一提,这里还有一处文档与源码的差异:README 的「Key types」把 PromptAssembly 写成 { sections, tools, variables } 三个字段,而 index.ts 里的接口定义是四个,多一个 contexts。差异如此,以源码为准。

所以你那句话该填几

回到开头的问题。如果你要加的是一段工具用法指引,它属于 100–199 这段,取一个还没人占的数;如果是全局行为约束,看看 plan:policy 取的 50,在我们数到的这批注册点里,1 到 98 这一段只有它一个;如果非要挤进两段既有文字中间,学 SUBAGENT_SECTION_ORDER 取小数。至于身份和 persona 那三个负数位置,harness:identity 是注册表自己在构造函数里放的,可以用 includeHarnessIdentity: false 关掉,但 README 写明这个开关只应该在「由兼容性部署接管整份系统提示词」时才用。

最后一处小差异留给爱抠细节的人:PERSONA_ORDER 常量的注释把 order 0 的 persona 称为「模型读到的第一段」,但同一个文件的构造函数默认在 -100 注册了 harness identity,PromptSection.order 的 JSDoc 也写明其它负数同样排在 persona 之前。两处措辞不一致,实际顺序以 assemble() 里那次升序排序为准。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。