dsh-spec-forge · 需求锻造
一个 DeepSeek Harness(dsh)插件。它管两件事:在你动手前,把模糊需求问清楚;在你做完后,把这次的经验存下来,下次再遇到同类需求时自动顶上来。
实测于
@deepseek-ai/dsh0.1.2-alpha.4(Windows + Web profile)。
dsh 仍是 developer preview,API 会有破坏性变更;插件已锁定其 API 面,升级 dsh 后如失效请看 CHANGELOG。
它解决的是什么
编程对话里最磨人的不是写代码,而是这几件事反复发生:
- 需求说不清就开工。 "帮我优化一下那个查询"——哪个查询?优化成什么样?做完才发现理解错了。
- 同一套规矩每次都重新交代。 "common/Result.java 别动""Controller 别写业务逻辑"——换了个会话,模型又踩一次。
- 同一个套路,每次都从零描述。 你第 N 次让模型"加个分页查询接口",它还是第 1 次见到这个需求的样子。
- 好用的提示词用完就丢。 你花半小时调教出来的一段标准改法,关掉对话就没了。
这个插件把这些经验沉淀成明文 Markdown 模板库,存在 ~/.dsh/spec-forge/ 下。你看得见、改得动、能进 Git——它不是黑盒。
装好之后,一次任务是这样走的
整条链路是五个工具串起来的闭环:
收到需求 → spec_recall(翻历史模板、项目禁区,并给出 fastTrack 分级)
↓
fastTrack=true(L1:原子操作 / 带参考物的自包含新建)──→ 直接动手
↓ 否则 (跳过体检与提炼)
spec_triage(四维体检 + 定级)
↓
L1 执行清单 / L2 按默认执行(都不追问) / L3 完整 Grill-me
↓
spec_distill(L3 与安全阀场景按需)→ 动手写代码
↓
spec_retro(任务链收尾沉淀)→ 下次同类需求被召回
举一个真实触发过 L1 的例子。用户只发了这么一句:
"index.vue 这个物业管理员管理页面的新增/修改接口增加一个主管管员字段 isMainAdmin,值为1是,0否,默认为否,这个字段用开关来显示,请帮我完成这个需求"
spec_recall 当场返回 fastTrack: true,直接给出执行清单(跳过体检与提炼),一个问题都不问:
- 改动文件:
index.vue - 字段名:
isMainAdmin - UI 组件:按"开关"推断为
el-switch - 默认值:
否 - 列表展示:默认不展示(保守方案,没说就不加)
- 风格自举:先扫
index.vue最近的表单代码,沿用现有写法
在 v0.1.0 里,同样是这条需求,模型会一口气连问 5 个问题——哪怕每个问题它自己都能推断出答案。这就是 L1 通道要治的病:该闭嘴干活时别装严谨。0.4.0 又扩了一类快速通道:带参考物的自包含新建——"参考现有列表页新建一个订单页"这类需求,实现细节可以从参考物自举,同样一轮做完、不问。
L2、L3 则反过来:需求模块级、一句话推不全时(比如"帮我优化一下那个查询"),0.4.0 起的策略是 L2 直接按报告给出的默认值动手,不再列问题——把"问"的额度全部留给真正需要的 L3。唯一的例外是「L2 安全阀」:需求确实过短、缺一半以上维度、且没有任何可推断锚点(无文件、无字段、无组件、无默认值)时,才允许一次性问清核心,问完立刻动手。L3 架构级重构才放开完整 Grill-me,追问前有硬性急停:不允许预扫工作区去"更懂业务",省 token 也省时间。
三个等级判据与响应的速查:
| 等级 | 什么算这类 | 插件怎么做 |
|---|---|---|
| L1 原子操作 / 快速通道 | 单文件 CRUD,字段/组件/默认值明确;带参考物的自包含新建;或消息含"直接做/速做/不用问/别问/不要问/极速模式" | 不许追问,直接给执行清单 + 扫描目标文件风格自举,疑虑标 // TODO: [待确认] |
| L2 模块变更 | 模块级新增/调整,一句话推不全 | 按报告默认值直接执行,不追问;仅"过短 + ≥2 维缺失 + 零锚点"转一次性追问 |
| L3 架构重构 | 含架构/重构/拆分/迁移/升级/建表/跨文件/多模块 | 完整 Grill-me,问透为止 |
L1 还有一张"保守默认表"兜底:列表默认不展示新列、默认不加业务校验、默认后端接口已就绪——只有用户明确说要才会放开,宁可少做不瞎猜。
沉淀:不是每轮对话都要存
一开始我们担心两个方向:存太勤,模板库会被琐碎对话灌水;存太懒,真正值钱的套路又漏掉。现在沉淀由三层漏斗把关:
- 代码层硬门槛。 插件自己判断这个会话有没有"资格"沉淀:必须真实改过代码(有 edit/write 类工具调用),且工具调用次数达标。纯问答、只读诊断、一次性咨询——直接拦下,不提示、不打扰。
- 价值三问(留给模型自检)。 调用沉淀工具前过一遍:这做法下次还会用吗?结论跨项目成立吗?用户会反复提同类需求吗?任一为否就跳过。
- 任务链合并。 一条任务链只沉淀一次,中途的小修小补(改个编译错、修个警告)并进最终那份模板,不产生碎片。
模型判断失误漏了沉淀?兜底在 spec_recall:下次这个会话又提出编程需求时,插件会检查"上一轮明明改过代码却没沉淀",随召回结果提醒一句。你也可以随时主动说——"把这次沉淀成模板" 会无条件触发。
沉淀出来的模板是同名幂等的:同一类需求再次沉淀会覆盖更新旧模板而不是无限堆积。每份模板长这样(六个段落,沉淀和复用共用一套结构):
# Spring Boot 新增分页查询接口
分类:`feature/api` 标签:`java` `spring-boot`
## 触发场景
当用户要求新增支持分页的查询接口时适用。
## 需求澄清清单
- 分页参数用 pageNum/pageSize 还是 offset/limit?
- 返回 VO 是否包含关联表字段?
## 标准改法
1. XxxController 新增方法
2. XxxService 与 XxxServiceImpl 实现
3. Mapper XML 写查询 SQL
## 禁区
- 不要修改 common/Result.java 的返回结构
## 提示词模板
```text
按 Controller → Service → ServiceImpl → Mapper 四层实现……
验收标准
- [ ] mvn -q test 通过
完整示例见 templates/example-spring-pagination.md。
禁区会写进项目档案,不只是跟着模板走:每次会话识别到的"不能改",会累积到该仓库的 profile.md,此后所有同类需求自动注入,不用你反复交代。
存储是双层明文目录,项目层优先于全局层:
<storageRoot>/
├── global/ # 全局层:跨仓库通用的习惯
│ ├── templates/<id>.md
│ └── profile.md
└── projects/<repo-hash>/ # 项目层:按仓库路径哈希隔离
├── templates/<id>.md
└── profile.md # 该项目的禁区与约定
仓库哈希 = 工作目录路径归一化后的 sha256 前 12 位(Windows 路径同样适用)。所有落盘都是原子写(先 .tmp 再 rename),崩溃不会留下半个文件。
模板库放哪里:三种存储模式
| 模式 | 实际路径 | 适用场景 |
|---|---|---|
workspace(默认,0.3.3+) |
<启动 dsh 的 cwd>/.dsh-spec-forge/ |
工作区与 $DSH_HOME 不同盘,避免跨盘 EPERM;模板库跟随当前项目 |
home |
$DSH_HOME/spec-forge/ |
旧版(≤0.3.2)默认;适合把模板库统一存在用户目录 |
storageHome 显式 |
你给的任意绝对路径 | 想放到自定义位置(如 OneDrive 同步盘) |
跨盘写会触发 dsh 的 workspace-write 沙箱 EPERM(用户常反馈的"C 盘被拒绝"就是这个)。新装用户无须配置——0.3.3 起默认跟随工作区;老用户升级后如果还在用旧路径,调用 spec_library({ action: 'info' }) 即可看到当前模式与路径,必要时调 spec_library({ action: 'migrate' }) 把 $DSH_HOME/spec-forge 拷过来(默认复制保留源,验证后再传 move: true 删除源)。
0.4.1 布局修正:0.3.3/0.4.0 因把已是数据根的
home又追加了一层,数据实际落在<root>/spec-forge/…(home模式下导致历史模板全部读不到、migrate写到了插件不读的位置)。0.4.1 起<root>即数据根,与实际目录一致;启动时会自动把遗留的<root>/spec-forge/…一次性上移归位(新位置已有数据则不动)。若你在此期间用过workspace模式,无需手工处理。0.4.2:上移归位后若源目录已空,会顺手删掉它——0.4.1 会在曾踩坑的机器上永久留下一个
<root>/spec-forge/空壳。
手动覆盖配置:改的是 cordis.patch.yml
profile 的行配置不是写成一个 spec-forge: 缩进块,而是改在 profile 目录的 patch 文件里,且它必须是顶层 YAML 数组:
~/.dsh/profiles/<profile>/cordis.patch.yml
# 顶层数组;条目用 id 定位到已有行。
# ⚠️ id 定向补丁会「整体替换」该行的 config,不是合并——
# 所以下面必须完整重述所有字段,否则漏掉的字段会回落到插件默认值。
- id: spec-forge
config:
autoRecall: true
autoRetro: true
matchThreshold: 0.35
maxInjectTemplates: 2
injectMaxChars: 4000
defaultScope: project
storageRoot: home # 改这里:workspace(默认)| home
storageHome: '' # 非空则强制覆盖 storageRoot;建议留空
retroMinToolCalls: 2
retroRequireCodeChange: true
strictDistill: true
改完重启 dsh,用 dsh --profile <profile> --dump-config 确认 # == dsh-spec-forge 段里的值与预期一致。
模板库也会旧。 spec_library 会统计超过 90 天未被命中的过期模板并列出名字——模板不是越多越好,旧模板会稀释召回精度。但它只报告不擅自动手,只有你明确说"清理过期模板"才会物理删除(删了不可恢复)。
召回:它怎么认出"这是同类需求"
spec_recall 收到需求后做三件事:把需求原文拆成加权指纹 → 和模板库里每一份比对打分 → 超过阈值就把最像的(默认最多 2 份,可配)连同项目禁区一起注入上下文。
打分是透明的,六个维度:
总分 = 0.62×词汇相似度 + 0.14×同分类 + 0.08×标签重叠
+ 0.08×同仓库 + 0.05×新鲜度 + 0.03×使用热度
词汇相似度 = 0.6×加权余弦 + 0.4×覆盖率
- 词汇相似度是主项。 指纹按 token 加权:路径(4) > 技术词(3) > 标识符(2) > 中文 2-gram(1),"改
index.vue"比"有个页面"值钱得多。 - 查询侧先聚焦再比。 用户需求常常很长(带路径、叙述、寒暄),
fingerprint默认取 24 个 token 里一多半是权重 1 的 2-gram,会稀释余弦与覆盖率。0.3.2 起查询指纹先削掉低信号尾巴(强 token 全保留 + 最多 8 个 2-gram)再进打分——同一条真实需求,聚焦前后词汇分差约 0.03~0.1,跨仓库(无同仓库加分)时这点余量就是命中与否的分界线。落盘模板的指纹保持完整不动。 - 同分类按一级比。 模板的二级分类(如
feature/api里的api)由模型沉淀时自由填写、不可控,所以feature/api与feature/pagination视为同类。查询侧的分类和标签是插件从需求原文现推断的,不依赖模型自觉。 - 标签重叠做了别名归一。 模板里存的是
a-switch,需求里写的是"开关",两边要能对得上——组件别名(a-switch→switch、element-plus→elementplus)、中英技术词("分页"→pagination)都会归一到同一把钥匙上;重叠率按"模板标签被查询覆盖的比例"算,模板标签都能在需求里找到说法就算高重叠。 - 同仓库 + 新鲜度 + 热度是调节项。 本仓库沉淀过的模板优先;90 天半衰期衰减;命中越多越可信但用对数压平,避免马太效应。
阈值默认 0.35,matchThreshold 可调:调低更易命中(会引入误召回),调高更严格。实测分离度(用真实模板,非构造数据):
| 需求 | 得分 | 结果 |
|---|---|---|
同类:Vue 管理页加 isMainAdmin 开关字段 |
0.63 | ✅ 命中 |
| 无关:node_modules 加 .gitignore + 写 README | 0.11 | ❌ 不命中 |
召回侧还有一处工程优化:模板列表在进程内带读缓存(写盘版本号 + 文件名集合双重失效),模板库到几百份时也不会每次召回都全量读盘解析。
诚实边界:这不是向量检索,是零依赖、零成本、可解释的关键词指纹。对"说法完全不同但语义相同"的需求召回有限——这是刻意取舍,调阈值只能缓解不能根治。
成本:它花多少,大头在哪
一次真实会话被逐事件解码后的账(详见 spec-forge-成本归因分析):
| 项 | 量级 | 说明 |
|---|---|---|
| 插件固定税 | ≈ 2.7K token / 请求 | 常驻提示段 ≈ 358 + 五个工具定义 ≈ 2330(只出现在发往模型的完整请求里) |
| 插件单次调用产出 | 几百 token | spec_recall 未命中仅 43 token;spec_triage 报告 350~530 token |
| 插件占整轮成本 | ≈ 4% | 8 次 spec_* 调用,返回 14.5K 字符,占全部工具返回的 3.2% |
| 真正的成本大头 | ≈ 50% | 两个 >100K 字符的文件被整读后,在其后约 73 步里被反复重计 |
结论:插件不是成本黑洞,整读大文件才是。 所以 0.4.0 把「大文件纪律」写进了系统提示与 Skill:
>20K字符的文件禁止整文件 read,先grep定位行号再用 offset/limit 分段读- 确需整体理解时,读一次后立刻落一份「要点摘要」,后续只引用摘要
- 不要为了"摸清业务"预扫工作区(需求没问清前的代码大概率用不上)
配套的两个省钱开关(不在插件里,在 dsh / 模型侧):长会话开启上下文压缩(compaction)避免每步重发全量上下文;预算敏感时降低 reasoningEffort——一次实测里 high 档的推理 token 占输出的 56%。
五个工具
| 工具 | 什么时候被调用 | 干什么 |
|---|---|---|
spec_recall |
收到编程需求的第一件事 | 翻模板库,把命中模板的澄清清单/标准改法/验收标准 + 项目禁区注入上下文,并给出 level 与 fastTrack |
spec_triage |
召回返回 fastTrack=false 时 |
四维体检 + 定 L1/L2/L3。L1 出执行清单,L2 出「已按默认执行」(仅安全阀转一次性追问),L3 出完整 Grill-me |
spec_distill |
L3 与安全阀场景澄清完毕、动手之前 | 把需求 + 澄清答案 + 禁区蒸馏成一份实现提示词;禁区为空会拦下 |
spec_retro |
任务收尾 | 把这次经验沉淀/更新成模板;digest 留空时自动从会话事件流提取摘要 |
spec_library |
用户问"模板库里有什么""放哪""怎么搬过来" | action: list 展示数据目录/模板清单/命中统计/项目禁区/过期模板;action: info 查看存储模式、路径与跨盘判定;action: migrate 把旧库一次性迁移过来(move: true 迁移后删源) |
0.4.0 把原先独立的
spec_store并入了spec_library(action: info / migrate),工具数从 6 降到 5,省下约 480 token/请求的常驻工具定义成本。
注入分三层,成本不同:
| 层次 | 机制 | 内容 | 成本 |
|---|---|---|---|
| 常驻 | 系统提示词 section | 路由规则 + 等级响应 + 大文件纪律 + 急停规则,约 358 token(0.4.0 重写后实测) | 始终占用 |
| 按需 | 运行时 Skill | 完整流程说明书(含三层漏斗决策与大文件纪律) | 用到才加载 |
| 执行 | 五个工具 | 上面这张表 | 调用才产生 |
安装
前置:dsh 可用、Node 22+、pnpm 在 PATH 上(dsh plugin 内部转发 pnpm,没装就 npm i -g pnpm)。
方式一(推荐,装正式发布):
dsh plugin --profile web add github:<你的账号>/dsh-spec-forge
dsh web # 必须重启,插件才会组合进插件树
方式二(本地源码调试):插件里的裸导入(@deepseek-ai/dsh-tools 等)会从插件目录向上找 node_modules。已装过 dsh + pnpm 就直接用方式一;源码调试则把 node_modules/@deepseek-ai 指到 profile 的公共依赖目录(Windows 用 junction、macOS/Linux 用 ln -s),或用 --patch 叠加启动(Windows 下 patch 里的 name 必须写成 file:/// URL,裸盘符会报 ERR_UNSUPPORTED_ESM_URL_SCHEME)。本仓库 dev/ 下的 patch 含本机绝对路径、已被 .gitignore 排除,仅供本地调试。
验证装没装上:
# Bash / Git Bash(dsh 不在 PATH 时经 pnpm 调用)
pnpm dsh --profile web --dump-config | grep spec-forge
# PowerShell(没有 grep,用 Select-String)
pnpm dsh --profile web --dump-config | Select-String spec-forge
输出里应出现一段 # == dsh-spec-forge 及其配置块。--dump-config 只合成插件树、不启动服务,是排障第一招。
配置
在 profile 的 cordis.patch.yml 中调整(写法见上文「手动覆盖配置」小节——id 定向补丁会整体替换 config,必须完整重述所有字段),均有默认值:
| 配置 | 默认 | 说明 |
|---|---|---|
autoRecall |
true |
是否注入常驻路由提示 |
autoRetro |
true |
是否在任务完成后提示沉淀 |
matchThreshold |
0.35 |
命中阈值,低更易命中、高更严格 |
maxInjectTemplates |
2 |
单次最多注入几份模板 |
injectMaxChars |
4000 |
注入上下文上限字符数 |
defaultScope |
project |
沉淀默认落项目层还是全局层 |
retroMinToolCalls |
2 |
自动复盘要求的最少工具调用数 |
retroRequireCodeChange |
true |
沉淀提醒要求真实改过代码,纯问答/只读不提醒 |
strictDistill |
true |
提炼时强制要求禁区,空则报错 |
storageRoot |
workspace |
存储模式:workspace(0.3.3 默认,跟工作区)/ home(放 $DSH_HOME,兼容旧版)/ 设置 storageHome 绝对路径时此字段被忽略 |
storageHome |
空 | 自定义数据目录(绝对路径)。非空时优先于 storageRoot |
验证
npm test # 单元测试:168 个,覆盖指纹(含序列化回归)/匹配/会话提取/存储(含布局与空壳回归)/渲染/分类器/沉淀门槛/读缓存/查询聚焦/路径解析/迁移
npm run smoke # 端到端冒烟:沉淀→召回→注入→体检→完成判定→幂等 整条链路
npm run verify # 加载验证:mock ctx 执行 apply(),确认工具都能注册、schema 合规
npm run token-audit # 静态 token 预算审计:常驻/工具定义/SKILL/单次调用产出/真实库命中注入
冒烟的真实输出(可作验收基线):
1. 沉淀:一次任务结束后写入模板 [PASS] 模板已落盘
指纹与布局自检 [PASS] 指纹 8 项损坏 0 项 / 落盘无 [object Object] / 无多余 spec-forge 层级
2. 召回:同类需求命中 [PASS] 得分 0.499,lexical=0.635(确认靠词汇相似度而非仅元数据)
3. 召回:异类需求不命中 [PASS] 得分 0.136
4. 注入:禁区/澄清清单/标准改法进上下文 [PASS]
5. 体检:模糊需求被拦下要求澄清 [PASS] 缺失 要实现什么/哪些不能改/上下文
6. 完成判定:不做完的活不误判为完成 [PASS]
7. 幂等:同类需求再次沉淀是覆盖非堆积 [PASS] 仍 1 份
真实环境验收,装完之后可以照着试:
- 发一条简单 CRUD 需求(如"index.vue 加 isMainAdmin 字段,开关,默认 0"),观察是否直接动手不追问;
- 发一条模块级需求("给订单模块加导出,要 Excel 和 CSV"),观察是否按报告默认值直接执行、不列问题;再发一条极短且无锚点的("帮我改一下那个查询"),观察是否只一次性问清核心(安全阀);
- 发一条完整需求(文件路径 + 禁区 + 验收命令),观察是否走完 召回→体检→提炼→实现→沉淀;
- 发一条带"直接做"的需求,观察是否无条件进 L1 快通道;发一条"参考现有列表页新建一个订单页"的需求,观察是否因带参考物的自包含新建直接 fastTrack;
- 检查模板落盘:
ls <工作区>/.dsh-spec-forge/projects/<hash>/templates/(老模式为~/.dsh/spec-forge/...); - 再发一条同类需求,确认
spec_recall召回刚沉淀的模板(模型会引用其中的澄清清单)。
它不做什么(已知边界)
- dsh 还是开发者预览版。 插件锁定的 API 面是
ctx.tools.register/systemPrompt/skills/exec.agent.session;0.3.0 起不再依赖turn/end事件(它不含会话事件流)。升级 dsh 后失效,先--dump-config排查再看 CHANGELOG。 - 匹配不是向量检索。 关键词指纹对"说法完全不同但语义相同"的需求召回有限,这是刻意的零依赖取舍。
- 沉淀时机有三层保障但非绝对。 代码层硬门槛 + 模型价值三问 + 任务链合并,理论上仍可能漏——漏了
spec_recall会提醒,或直接说"把这次沉淀成模板"。 - "任务完整结束"是启发式判定,依据事件流结构判断,准但不敢说绝对可靠。
- 急停规则是提示词约束,不是硬拦截。 "提问前不许扫工作区"写死在模型可见的四层文本里,实测有效,但模型仍可能违背——等 dsh 出工具级前置钩子才能根治。
- 复杂度分级是启发式。 判据基于正则信号,生僻表述可能漏检;漏检一律回退 L2(按默认执行),不会默认 L1 瞎干。过短且无锚点的需求会触发「L2 安全阀」只问一次。DDL 信号做了前后视断言防误伤("新建订单列表页""搜索表单组件"不会被当成建表),但中文复合词仍有边界,遇到误判可在
lib/classify.js的L3_PATTERNS里调整。 - 插件与宿主同进程同权限。 它只读写
<storageRoot>/spec-forge(默认<启动 dsh 的 cwd>/.dsh-spec-forge/,可在配置里改成$DSH_HOME/spec-forge或绝对路径),不联网、不执行 shell、不读凭据;源码公开,装前可自行审查。
卸载
dsh plugin --profile web remove dsh-spec-forge
# 若声明了 dsh.bundle.patch,还需清理 profile 的 cordis.patch.yml 对应行
dsh web # 重启生效
模板数据在插件目录之外,卸载不删沉淀。要彻底清空:rm -rf ~/.dsh/spec-forge
开发与发布
变更记录见 CHANGELOG.md。发布流程:改代码 → npm test → 升 package.json 版本 → CHANGELOG 顶部加条目 → 同步 README → commit & push → 在 profile 目录 env -u NODE_OPTIONS pnpm update dsh-spec-forge 刷新锁文件(pnpm-lock 会钉住 GitHub 依赖的提交 SHA,不改锁文件重装仍是旧代码)。
许可证
MIT