dsh-voice 设计文档
August 16, 2026 · View on GitHub
1. 背景与目标
DeepSeek Harness 的 system prompt 由 dsh-system-prompt 这一 registry(注册器)组装:各插件通过 ctx.systemPrompt.section() 贡献有序段落,由 loop 在每个 step 前 assemble() 一次并 renderPrompt() 插值渲染。这给「切换对话口吻」提供了自然的挂点——口吻不是改代码,而是往 prompt 里塞一段动态文本。
本插件(dsh-voice)实现:
- 一个可切换的「对话口吻」机制:用户会话中随时
/voice <id>切换,立即生效并持久化。 - 口吻文件驱动:每个口吻是一个
*.voice.yaml文件,从内置/用户/项目三层目录发现,同名按优先级覆盖。 - 一个
create-voice元技能,引导新建口吻文件到配置目录。
2. 机制设计(harness 接缝)
三个接缝各司其职,都不改 loop:
| 接缝 | 用途 | 关键决策 |
|---|---|---|
ctx.systemPrompt.section() | 贡献口吻文本进 system prompt | order: 10——persona(0)之后、工具指导(100–199)之前;text 用 provider () => …,每次 assemble 重新求值 |
ctx.settings | 持久化当前口吻 id | 口吻是用户运行时可改的偏好,用 settings 而非 cordis config;base 放默认,用户层覆盖,热更新 live |
ctx.commands | /voice 人机命令 | handler 只读 invocation.rawInput,不发给模型 |
ctx.skills | create-voice 元技能 | 模型与人皆可调用,指导新建 voice 文件 |
数据流:
voice 文件(内置/用户/项目) ──listVoices()──▶ 口吻集合
settings(用户层) ──watch──▶ activeTone ──▶ voicePromptFor() ──▶ systemPrompt section
3. voice 文件格式
一个 *.voice.yaml 文件即一个口吻定义。自 v2 起,prompt 不再是自由字符串,而是由结构化字段通过 Handlebars 模板拼接:
version: 2 # 缺省 0,由 migrate 升到当前版本
id: sun-wukong # 必填,kebab-case,须与文件名 basename 一致
label: 孙悟空 (Sun Wukong) # 空串回退到 id
description: ... # 一句话说明
identity: # 身份背景(对象)
role: 齐天大圣·美猴王·孙行者
background: 《西游记》原著中的孙悟空……
address: 师父 # 角色对用户的称呼
style: | # 说话方式(字符串)
...
examples: # 场景示例(数组,每个 3–5 条对话)
- name: 初见 · 五行山下拜师
turns:
- speaker: 师父
text: ...
template: ... # 可选,自定义 Handlebars 模板覆盖默认拼接
最终 prompt 由 src/render.ts 用 Handlebars 渲染:你是{{identity.role}}。{{identity.background}} + 【说话方式】 + {{style}} + 【场景示例】(examples 非空时)。默认模板在 src/voice-schema.ts 的 DEFAULT_TEMPLATE,空 template 回退到它。
形状由 src/voice-schema.ts(schemastery)校验,是单一真相源;voice.schema.yaml 是同一 schema 的 JSON Schema 表达,由 pnpm schema:gen 生成,供第三方工具与人类阅读。
版本与迁移
version 字段为格式演进预留。src/voice-file.ts 维护一条迁移链 MIGRATIONS(key = 迁移前版本),加载时把任意历史版本连续升到 CURRENT_VOICE_VERSION(当前为 2)。迁移链:
0 → 1:补version字段。1 → 2:把 v1 的单一prompt字符串拆成identity/style/examples(识别【说话方式】/【场景示例】标记;识别失败保守地把全文放进style)。
dsh-voice migrate 会把旧文件原地写回为当前版本。坏文件在发现阶段跳过并告警,不阻断整体。
4. voice 目录发现
优先级(低 → 高,同名后者覆盖):
内置(包内 voices/) < 用户 ~/.agents/voice < 用户 ~/.dsh/voice
< 项目 <repo>/.agents/voice < 项目 <repo>/.dsh/voice
即:项目覆盖用户覆盖内置;同级 .dsh 优先于 .agents。这与 harness 的 skill 双根约定(.dsh / .agents)对齐。项目根由 .git 标记向上探测。读取是同步的(voice 文件少而小),结果按 project root 缓存。
写根(create-voice 落盘):用户级 ~/.dsh/voice 优先,.agents 环境回退 ~/.agents/voice;项目级 <repo>/.dsh/voice 优先,回退 <repo>/.agents/voice。
5. create-voice 元技能
skill/create-voice/SKILL.md 是标准的 SKILL.md(带 frontmatter),插件把它注册进 ctx.skills(runtime skill,modelInvocable + userInvocable)。流程是把「新建口吻」那套总结成可复用 SOP:
- 搜集角色——识别目标,搜索权威档案与真实台词原文。
- 分析说话方式——从台词归纳 4–8 条可执行风格规则,附一条「不得因文害意」硬约束。
- 写口吻文本——结构化字段:
identity(role/background/address)+style+examples(10 个场景,每个 3–5 条,场景互不相同),由 Handlebars 模板拼接成最终 prompt。 - 落盘校验——写
<id>.voice.yaml到 voice 目录,dsh-voice check校验。
6. 检查/迁移工具
bin/dsh-voice.mjs 是随包发布的 CLI:
dsh-voice check [dir...] # 校验 voice 文件形状(✓/✗)
dsh-voice migrate [dir...] # 报告并校验到当前版本
7. 目录结构
dsh-voice/
├── package.json # dsh.bundle + bin + peer 依赖
├── cordis.patch.yml # bundle 配置层
├── voice.schema.yaml # 生成的 JSON Schema
├── voices/ # 内置 voice(default + 10 个已故名人双语 + sun-wukong/handsome-guy/pretty-girl + friendly-rigorous/strict-code-reviewer)
├── skill/create-voice/SKILL.md # 元技能
├── bin/dsh-voice.mjs # check/migrate CLI
├── src/
│ ├── index.ts # 插件入口:section + settings + command + skill
│ ├── voice-schema.ts # schema(真相源)
│ ├── voice-file.ts # parse/validate/migrate(纯函数)
│ ├── voice-registry.ts # 目录发现 + 合并 + 缓存
│ ├── section.ts # 口吻渲染(纯函数)
│ ├── command.ts # /voice 解析与展示(纯函数)
│ ├── switch-detect.ts # 切换检测:折叠前后生效 voice 对比(纯函数)
│ ├── switch-reminder.ts# 切换提醒文案 + agent.inject 注入(下一 turn 的 user 消息)
│ ├── service.ts # ctx.voice 选择解析服务(暴露给其他 bundle 软读生效 voice)
│ └── skill-md.ts # SKILL.md frontmatter 解析
├── scripts/gen-schema.mjs # 生成 voice.schema.yaml
├── tests/
└── docs/design.md
分层原则:文件解析/校验/迁移(voice-file)、目录发现(voice-registry)、渲染/命令解析(section/command) 都是不依赖 cordis 的纯逻辑,CLI 与插件共用;index.ts 只做接缝编排。
8. 关键设计决策
- 口吻用
section(),不用variable():口吻是整段 model-visible 指导,不是{{name}}占位。 - 文件驱动而非硬编码:口吻是用户数据,应可增删、可覆盖,故用 voice 文件 + 目录发现,内置 voice 也走同一路径。
- 同步发现 + 缓存:voice 文件少而小,section provider 是同步的,故
listVoices同步读并缓存,坏文件跳过告警。 - 用 settings,不用 cordis config:切换是运行时会话内动作,必须可持久化、热更新。
/voice单层参数语法:不搞 flag/选项,/voice(查看)、/voice list、/voice <id>(切换)。- 迁移链而非一次性转换:
version+MIGRATIONS链,为格式演进预留,坏版本 fail-loud。
9. 验收标准(AC)
- 无参
/voice显示当前口吻与可用列表,当前项标注(current)。 /voice sun-wukong切换后,下一次 assemble 渲染的 system prompt 含孙悟空的口吻文本。/voice 未知id返回kind: 'error',并列出可用 id。- 未知/空口吻 id 回退到默认口吻,不抛错。
- 切换持久化到 settings 用户层,重启后仍生效。
*.voice.yaml文件可被dsh-voice check校验,坏文件被跳过并在发现阶段告警。- 旧版(无
version)voice 文件加载时自动迁移到当前版本。 create-voice元技能可被模型/用户调用,指导新建 voice 到 dsh 或.agents配置目录。
10. 非目标
- 不做「模型可切」的
set_tone工具(只做人类/voice)。 - 不做 Web UI、不做口吻的即时参数化生成。
- 不做独立 CLI 之外的
dsh voice --tone …启动参数。 - 不进 deepseek-harness monorepo,作为社区 bundle 独立分发。