DeepSeek Harness 源码解析(十一):Preset、Persona 与默认模型怎样生效

从真实 Preset 注册表、Persona 和 Session Controller 追踪能力组合、修订保留、空白会话切换及模型选择,解释提示词和请求怎样使用同一次模型快照。

Hex4C59 17 分钟阅读 更新于 2026-10-17

把一个会话从 standard 切到 minimal,工具和系统提示词会跟着变化,模型却可以继续使用刚才选中的那一个。反过来,把部署默认模型从 A 改成 B,已经发出过请求的会话也可能继续使用 A。

dsh 分别记录这几件事:Preset 列出要安装哪些工具、提示词和其他插件;Persona 提供系统提示词里的身份段落;本会话接下来用什么模型,则根据会话选择、已有请求记录和部署默认值决定。第十篇讲过 Agent 怎样经过 setup、公告和销毁;这一篇继续看 setup 装进了什么,以及下一次模型请求从哪里取得选择。

本文面向能读 TypeScript 函数调用、但没读过该项目源码的开发者。先记住前文的三个对象:Context 供插件访问服务和登记资源,Scope 决定局部注册对哪些 Agent 可见,Session 记录一段会话的事件。下文的 provider 是模型服务路由,model 是该路由中的模型标识,reasoningEffort 是请求的推理强度参数。本文基于 DeepSeek Harness 0.2.1-alpha.2,核对日期为 2026-10-16。版本号取自项目 manifest,源码固定在该链接对应的提交。文末附 30 组真实运行时实验;其中的 route-a、model-a 等名称是合成数据,没有向模型服务发请求。

先分清三种“默认” #

配置或状态决定什么何时读取
Preset Registry 的 default、selectedDefault没有指定 Preset 时采用哪个能力组合解析或挂载一个 Preset 时
SystemPrompt 的部署 Persona、Preset 内的 Persona系统提示词中的身份段落和相关组装策略按当前 Agent 的 Scope 组装提示词时
AgentDefaultModel 的 provider、model、reasoningEffort没有会话选择和历史请求可沿用时采用哪个模型入口读取默认选择时
Session 的 model/selection、request/header本会话接下来选择什么、此前实际用了什么Session Controller 读取选择,或按会话日志重新计算状态时

这张表有一个前提:入口要安装对应的组合逻辑。 AgentRegistry.create() 的核心合同是创建实例、调用 setup 和发布 handle;它不会因为系统存在 agentPresets、agentDefaultModel 服务,就替所有调用方自动选择一套策略。核心创建合同

API Session 入口在 composeAgent() 中显式解析 Preset,并返回一个 setup:先给新 Agent 安装会话模型选择,再让它使用对应 Preset 修订,也就是这次注册后实际挂载的那份插件树。没有 Preset 服务时,入口仍安装模型选择。本文的创建实验直接运行了这个真实入口辅助类,再把 setup 交给真实 AgentLoop。入口组合

Preset 会提前挂载一棵插件树 #

一个 Preset 声明包含 id、可选显示信息、排序值和 plugins 列表。agent-preset 插件把声明交给 Registry;它本身不接管使用旧修订的 Agent。这个插件还声明了 EntryGroup.key,让子配置保留原始表达式,等各子插件真正激活时再求值。声明插件 · 定义与结构校验

下面是按仓库配置删节的结构示意,省略工具列表和部署前提,不能单独当成完整启动配置:

- id: preset-example
  name: '@deepseek-ai/dsh-agent-preset'
  config:
    id: example
    plugins:
      - id: persona
        name: '@deepseek-ai/dsh-persona'
        config:
          prefix: You are a helpful software engineer assistant.
      # 这里继续声明这个 Preset 需要的工具及其他组合插件。

仓库的 Web bundle 通过多个 patch 文件声明 standard、ptc、minimal、cordis,Registry 的部署默认值为 standard。这里的名字是这个版本随 Web bundle 提供的声明,其他入口可以组合不同集合。Web 默认声明 · bundle patch 列表 · standard 配置

Registry 的选择顺序是 显式 id ?? selectedDefault.get() ?? default。其中 selectedDefault 是可更新的用户默认值,default 来自部署。它们都只选出要找的 id;找不到或组合损坏时仍按对应错误处理,不会偷偷退回另一个健康 Preset。显式空字符串也不会触发 ?? 的回退。本文实验核对了部署默认、用户默认和显式选择分别生效。默认选择与 Schema

注册会立即创建一个由 Registry 负责清理的 Scope,在里面挂载插件树。之后,多个 Agent 可以通过各自 Scope 的查找关系,读取这份修订中已经登记的工具、提示词和其他资源,无须每创建一个 Agent 就复制安装一遍插件。本文实验创建三个真实 Agent,两个加入同一修订,第三个加入另一 Preset;它们看到各自的工具,根作用域的工具列表仍为空。修订激活

挂载成功还要经过审计 #

PresetTree 复用 Loader 的条目机制,但 write() 是空实现:这棵运行时树自身不负责把声明写进配置文件。创建这棵树时,还会恢复声明方 entry 原有的 subtree/subgroup 关联,让外层 Loader 继续按原来的子树、子组关系管理该条目。运行时树

挂载会检查启用条目的导入、激活和缺失依赖。失败的声明仍保留在面向管理端的声明列表 roster 中,带 broken 诊断,其他健康 Preset 可以继续使用。resolve() 返回这份诊断,真正要保留并挂载修订的 retain() 才以 agent-preset/invalid 拒绝;不能把“查到了 id”理解成“组合已可用”。未知 id 则是 agent-preset/not-found。诊断与列表 · 保留前的检查

缺服务的 PENDING 条目需要再判断。挂载先允许它留下;后续诊断等待 Host Loader 树稳定,再重新审计。若服务只是尚未激活,条目会自行变为可用。实验先观察到 waiting for exampleLateService,提供该服务后再次解析,同一声明恢复正常。因此,Host 条目自己的激活过程不能再等待这种完整诊断:诊断在等 Host Loader 的条目全部稳定,其中又包括这个尚未完成激活的条目,两边就可能互相等待。条目审计 · 重新诊断

Preset 内注册服务还要显式使用合适的 isolate 服务域。否则提供者可能写入根域,让本来属于一套组合的服务影响其他会话。挂载检查这种根域泄漏,并拒绝有问题的声明;实验分别核对了直接提供服务的拒绝与隔离组的成功。serviceFor() 可以按 Agent 实际加入的修订查找其中的服务。泄漏检测 · 挂载提交

这些都是同进程插件的组合检查。isolate 管服务解析域,Scope 管贡献可见性;这里没有进程、文件系统或网络沙箱。检查到某次挂载合法,也不能推出任意插件未来的所有副作用都已得到隔离。

同一个 Preset id,可以同时存在两个修订 #

Registry 给每次成功激活创建独立的 Generation,记录 Scope、挂载结果、使用者数量 users 和 retired 标记。Agent 加入时,把自身 Scope 的查找父链连接到这代 Scope,之后查工具和提示词就能找到这一代的注册;同时增加使用者计数,并登记退出时减少计数的 effect。这条查找父链决定 Agent 能用哪套资源,与上一章记录业务父子关系的 parentAgent、负责清理实例的调用方 Fiber 各有用途。Generation 与 Binding · 绑定与引用计数 · Scope 父链

撤下一个声明时,Registry 先从当前声明表删除它,再把旧修订标为 retired。只有 retired 且 users 为零,才释放它的 Scope。于是可以重新注册同名 standard:之后创建的 Agent 加入新修订,已经加入旧修订的 Agent 保留原来的工具和提示词。撤下与回收 · 回收条件

下面从“旧 Agent A 及子 Scope C 仍保留 G1,新 Agent B 已加入 G2”的时点开始。切换退出步骤,观察声明列表和实际存活修订为何不同。G1/G2 是本文用于区分两代对象的标记。

同名 Preset 的两代修订与引用计数:旧 Agent 和子 Scope 保留 G1,新 Agent 加入 G2,最后一个使用者退出后才能回收 · 在独立窗口中查看

composeFrom(childCtx, parentCtx) 继承的是父作用域实际加入的那一代。即使当前声明表里的同名 Preset 已经换成 G2,子 Scope 仍可加入 G1;它不重新用 id 查询“最新版”。本文实验让 A 退出后,C 继续保留 G1,直到 C 退出才观察到旧修订被回收。继承确切修订

这里的共享也值得保留在脑中:同一修订里的插件可能被多个 Agent 使用。需要每 Agent 独立状态的插件,应根据作用域和 Agent 身份管理状态,不能仅凭“我挂在 Preset 里”就把插件自身的普通字段当成每会话私有字段。保留一个挂载修订,也不会冻结插件的所有内部状态和外部依赖;本文替换实验撤下并重新登记的是整份声明,插件内部的动态更新仍要按各自合同判断。

除了 Agent,acquireScope() 也能为读取操作临时保留一份修订引用,返回值称为只读租约。例如,为当前没有活 Agent 的冷会话生成展示内容,读取结束后就必须释放租约;实验确认,声明撤下后租约仍保留修订,重复调用它的异步释放只扣除一次。inspectCompositions() 可以查看所有保留修订,传 Agent Context 则只查看它加入的那代。读取租约 · 实际修订检查

管理界面读到的是哪份信息 #

list() 返回当前声明,包括 broken 项;先按 order 排序,未填 order 的放后面,再按 id 排序。远程 roster 另外标记哪个 id 是默认值。readDocument() 输出声明中的插件列表 YAML,并保留 !!js;它用于阅读声明,没有把运行后的所有动态注册反向合成为原配置。列表与文档读取

这与查看“所有保留修订”不同。声明表里只有新 standard,旧 Agent 仍保留旧 standard,是两个接口分别描述当前声明与实际使用者的结果。显示名字也有独立规则:内置 id 且没有自定义 name 时走内置翻译;提供了 name 的声明保留作者文案,不能只凭 id 就把自定义名字替换掉。显示信息解析

空白会话切换 Preset,必须留下日志 #

Preset 可以在会话还没开始过一轮处理(Turn)时切换。公开的 select() 按 Agent id 串行排队,再读取由会话事件计算出的 turnBoundary 状态。像这样从事件整理出的可读状态,在代码中称为投影。如果已有打开的 Turn,或 lastTurn > 0 表示已有此前的 Turn 记录,就以 agent-preset/locked 拒绝。因此,“现在 Agent 是 idle”不等于“会话从未开始”。选择与锁定

一次成功选择先把 Agent 的 Scope 改接到新修订,再追加 agent-preset/selected 事件。Session 创建时的记录 header 仍保存最初的 Preset;agentPreset 投影先读 header,再按后续选择事件更新状态,恢复出当前组合。否则,用户空白时切到了 minimal,重新读取历史却仍按创建时的 standard 组合,工具和提示词就会与会话实际经历不一致。选择日志与投影

本文实验观察到的三份状态是:

创建 header.agentPreset  = standard
当前 agentPreset 投影   = minimal
当前可见工具             = minimal 的工具

底层 recompose() 只做重绑定和工具变化通知,空白检查由调用方负责,也不替调用方追加这条选择日志。实验在已打开 Turn 的 Agent 上直接调用它,确实能改变组合而不改变投影。因此,面向用户的切换应使用包含空白检查和选择日志的入口;低层方法只改了可见资源,并未完成这次用户操作需要的全部步骤。低层重绑定合同

按 id 排队也只说明多次 select 的处理顺序。源码先重绑定、后 append,没有把任意插件副作用、并发工作启动与所有持久化失败包装成一个数据库事务;本文实验没有把它扩大为这类保证。

Persona 如何覆盖部署身份 #

SystemPrompt 全局注册了两个具名段落:deployment:persona-prefix 和 deployment:persona-suffix。Preset 内的 Persona 在自身 Scope 下注册同名段落。组装时,近处 Scope 中同名贡献覆盖远处及全局贡献,再按顺序排列。Persona 注册 · 按 Scope 合并

Persona 的配置不多,但每项都会改变组装结果。这里的动态 Context 指准备发给模型的运行时信息片段,与插件用来访问服务的 Cordis Context 对象不同:

字段默认与作用容易遗漏的条件
prefix必填,身份前缀模板空串仍占据同名位置,渲染时再丢弃空段落
suffix默认空串,排在内置指导段落之后省略也会覆盖掉部署 suffix,不会自动沿用它
complete默认 falsetrue 时 prefix 成为唯一系统提示词段落
includeRuntimeContext默认 truefalse 抑制可省略的动态 Context,required Context 仍保留

配置字段

“注册空文本”与“不注册这个段落”会得到不同结果。同名查找先选中离 Agent 更近的空文本,所以部署身份已被覆盖;到了 renderPrompt(),这个空段落才被丢弃。卸载这份 Persona 后,同名查找才能重新取得部署身份。Persona 若挂在全局,会与 SystemPrompt 已有的同名全局注册冲突并失败。本文实验分别检查了空文本、恢复与全局冲突。空段落渲染

complete 不会删除工具 #

complete: true 约束系统提示词段落。SystemPrompt 在 waterfall 变换后,仍把有效的 complete 段落恢复为唯一 section;工具目录和动态 Context 是 assembly 中另外的字段。出现多个有效 complete 段落会报错。完整提示词的组装

实验同时安装工具、普通指导、complete Persona 和两个 Context,再设置 includeRuntimeContext: false。结果为:系统提示词只有 Persona prefix;工具仍在;可选 Context 被移除,required Context 保留。这里没有推导出“所有运行时信息都已消失”。仓库 minimal Preset 同时设置 complete 与禁用可选运行时 Context,仍继续声明自己的终端等工具。minimal 配置

includeRuntimeContext: true 表示这份 Persona 不增加抑制项;如果部署或外层 Scope 已经抑制可选 Context,它不会把那些贡献强行打开。组装检查的是全局和 Scope 父链中是否存在抑制项。抑制项的合并

模板变量严格解析 #

Persona 模板里的完整 {{name}} 会在渲染时解析。合法名字匹配 [a-z][a-z0-9_]*;未注册名字、当前值为 undefined、带点号等非法完整引用都会报错。孤立的 {{ 没有后续闭合时按普通文字保留,替换后的值不会再次扫描。变量规则 · 插值实现

因此,{{model}} 能否渲染,取决于这次 assembly 是否真正提供了 model 变量。配置里写了花括号,并不会自动发起服务查询或从任意 JavaScript 对象读取属性。

默认模型服务只管理默认选择 #

AgentDefaultModelConfig 把 provider、model、reasoningEffort 都声明为 volatile 引用:配置更新时保留引用本身,之后读取便能取得新值。currentSelection() 每次读这些引用,返回一个独立对象;省略 reasoningEffort 时,返回值也省略该字段。本文用真实 Loader 更新配置,核对 provider/model 的变化和 reasoningEffort 的清除。默认模型服务

saveSelection() 是另一个操作。它要找到服务所属的配置 entry 和 ConfigEditor,才能提交完整配置;缺少其中任一项就返回,并不直接改写内存里的默认值。连续保存进入同一个 Promise 队列,按提交顺序执行;某次写入失败会拒绝该调用,但不会堵住后续保存。保存队列

实验先在没有 ConfigEditor 时保存,默认值保持原样;另用受控编辑接口卡住并拒绝第一次保存,确认第二次等待后继续执行,且使用提交当时捕获的字段。它验证的是队列和接口行为,没有验证文件落盘、权限或崩溃恢复。

API 的模型切换操作还会先校验并解析请求的 provider/model,再记录会话选择,随后尝试保存为部署默认值。保存失败只记录警告,会话选择已发生的变化保留。因此,排查“这次选择生效了,重开后默认值没保存”时,要分别检查两条结果。模型命令处理

本会话下一次用什么模型 #

API Session Controller 会为每个 Agent 保存一份模型选择引用。它先从 Session 的事件记录中恢复选择:modelSelection 投影里的 pending 表示已经选中、但还没出现匹配请求记录的模型。之后每次读取 current,也就是“此刻准备下一次请求应使用的选择”,都按下面的顺序决定:

  1. 有待使用的 pending 选择,就使用它。
  2. 否则,若日志中已有最近的 request/header,就沿用那次实际请求的 provider/model。这里的 header 是一条记录请求所用配置的会话事件。
  3. 前两者都没有,才读取当前部署默认值。

会话选择读取

这解释了开头的情况:尚无请求历史、也没有会话选择的 Agent 可以随默认值变化;有请求历史的会话沿用历史路由。本文同时创建两个真实 Agent,一个保留空白,一个追加受控请求头,再更新默认配置,实际观察到前者读到新默认,后者继续读旧模型。

selectForNextRequest() 先追加 model/selection,再更新运行时缓存。投影区分 lastUsed 和 pending:前者是已记录请求的模型,后者是还在等对应请求出现的选择。请求头只有与 pending 的 provider、model、reasoningEffort 全部相同,才把 pending 清掉,这个动作就是消费选择。会话事件监听器还会同步清理内存中的匹配选择。

例如,假设用户已选 B,但日志先收到了一条 A 的请求头,B 仍留在 pending 中;等一条与 B 的三个字段全部匹配的请求头出现,pending 才清空。之后没有再选模型时,会话沿用的就是最近记录的请求选择,改部署默认值不会越过这条历史记录。选择日志与缓存 · 选择投影 · 请求消费监听

reasoningEffort 还有一处细节:恢复历史请求选择时,如果请求头标记这个 effort 来自 adapter 默认值,就不把它恢复成会话主动选择。否则,adapter 自动补出的 effort 就会被当成会话明确选定的值;之后再次组装请求时,就会显式传入这个值,而不再保留“本会话没有指定 effort”的含义。历史 effort 的处理

这套顺序属于 API Session 的策略。直接给 AgentLoop 配置 provider/model,或从另一个 SDK、Headless 入口创建 Agent,仍应检查该入口安装了什么选择逻辑。不能把这张优先级表当成 LLM adapter 内部所有配置的总覆盖规则。

提示词和请求必须使用同一份选择 #

如果提示词刚写着“你正在使用模型 A”,请求发出前 UI 又选择了 B,应该把当前请求发给谁?dsh 的做法是:先确定本次组装使用 A,本次请求也使用 A;后来的选择留到下一次组装。

具体由 installModelSelection() 在开始组装提示词、工具和运行时信息时,先读出并保存 selection.current。等后续组装完成,再把先前保存的值放进 selection.assembled,并覆盖本次组装结果 assembly 的 provider/model 变量。current 是此刻的选择,assembled 则保存本次已经据此准备输入的选择。组装快照

后面的 agent/request 监听器读取 assembled,把本次请求固定到刚才准备输入时选定的模型;中途改变的 current 不会改写它。监听器在其他请求配置处理完成后覆盖 provider/model,并先删除此前配置带来的 reasoningEffort;只有捕获值显式包含 effort 时才重新加入。这样,“改选模型但省略 effort”可以回到新模型的默认行为,同时保留 temperature 等其他请求字段。请求覆盖

下面对照“组装后改选 B”和“组装前已经选择 B”。观察 current、assembled、提示词变量和请求配置分别在哪一步变化;A/B 都是实验中的合成路由。

模型选择的六步时序:提示词组装捕获 current,当前请求读取 assembled,后来的改选在下一次组装生效 · 在独立窗口中查看

30 组实验中的快照案例按真实入口执行:先组装 A 的提示词,再把 current 改成 B,然后运行 Agent 的真实 request waterfall。结果是本次提示词变量与请求配置都为 A;下一次组装变成 B。实验直接调用这些真实机制,没有把合成配置送到网络 adapter。

模型路由改变时,agent/pre-step 还可能向下一次获准请求追加用户角色的模型变化说明。它比较 assembled 与最近请求头的 provider/model;初次请求、仅 effort 改变、被拒绝或中止的步骤、某些没有请求的空步骤都不追加。请求头尚未成功记录前失败,下次仍可能重新加入说明。模型变化说明 · pre-step 条件

配置里的 id 还要与会话身份分开 #

AgentLoop 的 agents[] 配置可以在插件启动时创建 Agent。这里的 id 是稳定配置标签,也用作未指定 sessionId 时的新会话 id 前缀。显式 sessionId 提供固定身份;挂载持久化后端时,后端已有这段会话的历史就走恢复,没有历史才创建。resumeSessionId 则直接要求恢复,两种精确身份不能同时声明,同一批配置也不能重复占用同一个精确身份。配置身份与校验 · 启动分支

launcher 还可以通过 configuredAgentIdentities 提供会话身份,并替换对应配置里的 sessionId/resumeSessionId。这样,改写模型路由的配置层不会顺带丢掉 launcher 指定的会话身份。这部分是本次静态源码核对,没有把它列入 30 组运行实验。launcher 身份覆盖

Preset 的 id 标识一套工具与提示词等插件组合,AgentLoop 配置 id 标识一个启动条目,SessionId 标识一段会话。读配置时,把这三个名字分别标出来,再检查入口将它们传到哪里,会比从相同字符串猜关系可靠。

用 30 组实验复现这些差异 #

下载完整实验包,解压后运行:

npm ci --ignore-scripts --no-audit --no-fund
DSH_SOURCE=/path/to/deepseek-harness npm run verify

源码 checkout 需要对应本文固定版本与提交。run.mjs 会检查提交和可达包的状态,扫描静态运行 import/export,将真实源码转译到独立临时目录。结束或失败后清理自己的产物,不向 dsh checkout 安装依赖。

本次实际环境为 macOS 27.0.1 arm64、Node v25.9.0。运行闭包包含 35 个工作区包、144 个模块;外部运行依赖固定为 zod 4.4.3、js-yaml 4.3.2、semver 7.8.5、resolve.exports 2.0.3,转译器为 TypeScript 6.0.3,校验值保存在随包 lock 中。

范围实际观察
Preset 声明与挂载默认与显式选择、同修订共享、错误诊断、PENDING 恢复、禁用条目、服务隔离
修订与切换旧修订保留、子 Scope 继承、租约释放、空白选择入日志、Turn 锁定、队列失败后继续
PersonaScope 覆盖、空文本与恢复、全局冲突、严格变量、complete 与工具/Context 的分工
默认模型volatile 更新、独立返回对象、缺编辑器时不保存、保存顺序与拒绝后的继续
会话模型选择默认与历史路由、pending 投影、匹配消费、adapter 默认 effort、组装快照、变化说明
入口边界切 Preset 保留会话模型;直接核心创建不会自动组合 Preset

所有涉及 Agent 的主线都运行真实 AgentLoop、Session 和相关投影;工具是合成贡献,配置编辑接口是受控适配器。没有调用真实模型、执行实际工具任务、运行完整 Web/IPC 产品链路,或验证配置文件与日志后端的磁盘持久性。转译只检查语法,不代表全仓库语义类型检查。

继续定位源码,可以在固定版本仓库根目录运行:

rg -n 'register\(|retain\(|collect\(|composeFrom|async select' packages/preset/agent-preset-registry/src/index.ts
rg -n 'auditRows|leakedServices|mountPreset' packages/preset/agent-preset-registry/src/mount.ts
rg -n 'complete|includeRuntimeContext|PERSONA_' packages/preset/persona/src/index.ts
rg -n 'currentSelection|saveSelection|volatile' packages/core/agent-default-model/src/index.ts
rg -n 'selectionFor|composeAgent|selectForNextRequest|consumeSelection' packages/api/session-controller/src/agent.ts
rg -n 'selection.current|selection.assembled|agent/request|agent/pre-step' packages/core/agent/src/model-selection.ts

下一篇进入 Turn、Step、Inbox 与执行状态转换,沿一次输入怎样唤醒 Agent、怎样在 Step 边界继续或停止往下读。到那里,Preset 提供的工具与提示词、会话选择捕获的模型,才会一起进入实际执行循环。

一起讨论

选中正文中的文字,可以针对这段内容发起讨论。

正在读取讨论…

在 GitHub 查看与管理讨论 ↗

文章讨论