系统提示词:组装与准入

📑 目录

第 8 篇把事件分了类,其中最有分量的一条持久通道是 system/message:它是模型每次请求都能看到的指令,也是「模型可见即已记录」这条原则的主考场。本篇回答三个问题:系统提示词由什么组成、如何渲染成形、又如何随着步骤的推进进入派生历史。第三个问题最容易被低估——提示词会变化,而变化必须以一种模型历史和日志都能接受的方式发生。

提示词的组成:片段、变量与工具 schema

打开 packages/core/system-prompt/src/index.ts,看不到一整个提示词字符串,只看到三类零件。第一类是片段(section):每段带名字、文本、序号与可选的 complete 标记,内容从「我是谁」(HARNESS_IDENTITY)到部署人格、计划策略、团队策略各司其职。序号来自同一张中央表 SECTION_ORDERS(index.ts:125)——片段不按注册先后排,而按这张表排,插件往哪个缝里插内容由此而定。complete: true 的片段是排他的:一次组装里若激活了两个,assemble() 直接抛错,因为「整段替换」的语义不允许二义。

第二类是变量。渲染时执行严格插值:renderPrompt()(index.ts:279)扫描 {{variable}} 引用,未定义或畸形的引用直接抛错,空片段被丢弃,其余用空行拼接。这条严格性值得留意——提示词是模型行为的契约,让缺失的变量静默渲染成空字符串,等于让契约带病上线。

第三类是工具 schema。assemble()(index.ts:558)在排序前先收集所有提供方的工具 schema 与 knownNames,随组装结果一起交给后续流程。这解释了第 8 篇「agent/request 不能改消息」的另一半:工具清单不是请求参数,而是组装产物的一部分;想增删模型可用的工具,走注册表(第 10 篇),而不是在请求对象上动刀。

组装产出的其实不止提示词文本。PromptAssembly 里还有一份运行时上下文快照(renderContextSnapshot(),index.ts:290):那些随状态实时变化的内容——当前计划、待办进度、运行中的任务——以独立段落组装,可以被抑制器整体压下(assemble() 里的 runtimeContextSuppressors),比如某些部署形态决定「这个 agent 不该看到任何动态上下文」。文本与快照分 channel,模型看到的始终是同一份组装结果,而快照的去留是一个有开关的产品决定。

组装本身是分层与遮蔽的:全局层先提供片段与变量,作用域链上的层按「远者先入、近者胜出」覆盖同名项,最后过一遍 system-prompt/assemble waterfall——这就是第 8 篇分类表里那个允许「改写组装结果」的扩展点。waterfall 的返回值是权威的,只有一个例外:生效的 complete 片段会在 waterfall 之后被恢复为唯一的 prompt 段——排他性由组装器最终把守,监听者改不掉。整个渲染结果是确定性、可复算的:同一个日志状态,组装产物必然相同。

flowchart TD
  G["全局层:片段 + 变量 + 工具 schema"] --> M["作用域链逐层遮蔽 近者胜"]
  S["插件注册片段与变量"] --> M
  M --> A["assemble(): 排序 + complete 唯一性校验"]
  A --> W["system-prompt/assemble waterfall 可改写"]
  W --> R["renderPrompt(): 严格插值 空段丢弃"]
  R --> P["组装产物: 文本 + contexts 快照 + 工具清单"]

请求没有 system 字段

这是 dsh 与多数 agent 框架的一个根本分歧。送到 llm/stream 的请求里没有 system 字段;系统提示词作为 system/message 事件写进会话日志,surface 节点之一,模型历史由 deriveMessages()(packages/core/session/src/index.ts:856)从日志现算——提示词只是历史里 role 为 system 的那几段。

这个设计把第 5 篇的原则落到了实处。提示词对模型可见,因此它必须已记录;它已记录,因此每个请求都可以从日志重建,request/header 让每个请求成为日志的纯函数;它是纯函数,因此请求可以被深度冻结、可以被安全地重试——第 11 篇会看到这些性质如何一环扣一环。代价是:提示词不再是请求前随手拼接的字符串,而是日志状态的一次投影,改动它必须写成事件。

准入:四种情形的决策树

提示词每次步骤都会重新渲染,但「渲染出来了」不等于「可以送进历史」。准入由 SystemPromptProjection.project() 完成(packages/core/agent-loop/src/runtime-context.ts:65),输入是新渲染的文本与当前路由的事实(inHistory 能力、是否开启新请求序列),输出是一组节点更新意图——追加,或对若干既有节点做空内容替换。它的决策树只有四个分支:

flowchart TD
  R["渲染出新提示词"] --> H{"日志里已有 system 节点?"}
  H -->|"没有"| A["追加一个节点 即使文本为空"]
  H -->|"有"| C{"路由支持 in-history 追加 且未开新序列 且文本非空?"}
  C -->|"是"| D{"与最近非空节点相同?"}
  D -->|"是"| E["无更新"]
  D -->|"否"| F["追加非空新节点 缓存前缀之后"]
  C -->|"否"| G["头节点替换为全文 其余非空节点逐个空内容替换"]
  R --> Z{"渲染结果为空?"}
  Z -.->|"清空全部节点"| G

前两个分支好理解:首个获准的步骤会预约 node 0 作为锚点,哪怕渲染文本为空——这是为后续「in-history 追加」准备的落点,空节点不产生协议消息,但让追加有据可依。当路由声明支持 systemPromptUpdate: 'in-history' 且请求序列延续时,非空的新提示词作为新节点追加在缓存前缀之后,模型端既看到新指令,历史里旧节点也原样保留。

后两个分支是设计的锋芒。路由不支持、序列断开、或渲染结果为空,都走向同一条归一化路径:头节点替换为最新全文,其余非空节点逐个被空内容替换——注意是替换,不是删除。第 5 篇讲过,surface 的 replace 只遮蔽节点,日志记录原样保留。于是「清空提示词」在模型看来是所有系统节点变空,在日志里则是一串可审计的替换事件;人类 transcript 读取方仍然能看到旧指令曾经存在。模型永远看不见过期指令,但历史从未丢失它。

两个事实让这个决策树可以信赖。其一,准入依据的是 prepareCall() 返回的路由能力,而不是上一次的 request/context 快照——能力在发起请求的那一刻被重新确认,避免了「按旧能力写入、新路由却无法解释」的竞态。其二,决策完全由日志状态与渲染结果决定,是确定性的:同样的输入必然产生同样的节点更新,重试(第 11 篇)因此可以安全地复用同一份已渲染组装,而不必重跑准入。

变化从哪里来

工具变更不依赖路由能力——注册表增减工具只影响下一轮组装的 schema 清单,与提示词文本走不同的通道。MCP 的集成是个典型例子:每个 MCP 服务器连接的指令发布为系统提示词作用域章节(packages/mcp 的 tools.ts 把发现的能力注册为普通 Harness 工具,服务器指令进提示词),第三方的指令由此进入同一个遮蔽与排序体系,而不需要循环代码开任何后门。

把视角拉高,系统提示词在 dsh 里的位置可以用一句话说清:它是日志的函数,而不是请求的属性。组装决定内容,准入决定它如何成为历史,渲染与提交则完全被日志状态锁死。想改模型看到的世界,不要找请求对象——去写片段、写变量,或者写一条 system/message。

小结

系统提示词由片段、变量与工具 schema 组装而成,组装是确定性、可复算的;它被写进会话日志而不是请求字段,因此「模型可见即已记录」在指令层面成立。准入决策树只有四个分支:预约锚点、in-history 追加、全文归一化、空渲染清空——每个分支都以追加或空内容替换的形式落进日志,历史不被篡改,模型不见旧令。

提示词里那张工具清单,是模型与执行世界之间的合同。下一篇我们看合同另一端的履行:一次工具调用从注册、把关、审批到结算,要穿过一条怎样的流水线。