# 设计说明 本文件记录 dsh-spec-forge 的判据、算法与成本账。README 只讲"有什么功能",这里讲"为什么这样设计、怎么算的"。 --- ## 一、为什么用 `nextStep` 而不是隐含约定 早期版本靠"分级器判了 L1,所以你该跳过体检"这种**隐含约定**来省步骤。实测失败:分级器明明返回 `fastTrack=true`,模型却因为技能文档里写着「第 2 步:体检(**必做**)」的措辞,照样把六步走完——一条一轮能做完的小需求白跑 3 次工具往返。 **分级判得再准,也拦不住一个写着"必做"的流程。** 所以改成**机器可读的显式指令**:`spec_recall` 直接返回 `nextStep`,常驻提示段与 Skill 文档都写「严格按 `nextStep` 执行」。同一件事只在一个地方计算、只在一个地方表述。 `nextStep` 现有三态: | 值 | 含义 | 工具调用数 | | --- | --- | --- | | `implement` | 直接实现,禁止追问 | 1 | | `confirm` | 先问清 `contentGap` 再实现 | 1 + 1 次提问 | | `triage` | 先调 `spec_triage`,按报告执行 | 2(L2)/ 4(L3) | --- ## 二、分级判据 ### 三级响应 | 等级 | 什么算这类 | 插件怎么做 | | --- | --- | --- | | **L1 原子操作 / 快速通道** | 单文件 CRUD 且字段/组件/默认值明确;带参考物的自包含新建;**取值型原子小改**(改文案/调样式/改按钮样式/字段改名/改默认值);或消息含"直接做/速做/不用问/别问/不要问/极速模式" | 不许追问,直接给执行清单 + 扫目标文件风格自举,疑虑标 `// TODO: [待确认]`;原子小改默认连沉淀都跳过 | | **L2 模块变更** | 模块级新增/调整,一句话推不全 | **按报告默认值直接执行,不追问**;仅"过短 + ≥2 维缺失 + 零锚点"或"缺内容"转一次性追问 | | **L3 架构重构** | 含架构/重构/拆分/迁移/升级/建表/跨文件/多模块 | 完整 Grill-me,问透为止 | ### 原子小改的两类闸门 判"能不能一轮直达"要看**两个独立的问题**,早期版本只看了第一个: **闸门组 A:范围(改动多大)**——三道,宁可漏判也不误判: 1. 需求长度 ≤ 48 字 2. 不含多任务连接词(以及 / 同时 / 顺便 / 还有 / 另外 / 并且 / 然后 / 一并 / 一起) 3. 不含大范围限定词(整体 / 全局 / 所有 / 全部 / 批量 / 每个 / 各个 / 多处 / 整个 / 全站 / 全量) 所以「加个按钮,同时把列表也重构一下」「统一所有按钮的文案」会被正确挡住,留在 L2。 **闸门组 B:内容可决性(有没有说清要做的对象是什么)**——按族区分: | 族 | 例子 | 缺的是什么 | 结果 | | --- | --- | --- | --- | | 取值型(copy / style / constant / rename / tiny / button-attr) | 改文案、调间距、改按钮样式、超时改成 30s | 取值(可逆、可见) | 放行 | | 容器型(button / column / route) | 加个按钮、加个路由、加一列、加个菜单项 | 新增物**是什么、干什么** | 无内容则 `confirm` | **判据一句话:缺「新增物的身份/用途」必问,缺「已有物的属性取值」自决。** 容器型的内容信号有三种,命中任一即认为已给出"是什么": 1. 容器名词前的修饰语 —— 「加个**忘记密码**按钮」 2. 用途动词紧邻容器 —— 「加个**导出**按钮」 3. 点击行为 / 引号文本 —— 「加个按钮,**点击跳转注册页**」 ### 两个刻意的设计决定 **① 目标文件路径不算内容信号。** 需求常带文件路径前缀(`@…/login/index.vue 登录页加个按钮`), 那是"改哪个文件",不是"按钮做什么"。若把它当内容,真实那条需求会被误放行。 **② 内容闸门不受长度闸门约束。** 长度闸门是"长文本≈多目标"的粗代理,而带长路径前缀的单点需求会被它误伤 (实测那条恰好 48 字,路径再长一个字符就整个漏掉)。所以内容检查只排除多任务/大范围信号,不看长度。 ### `confirm` 为什么独立于 `unactionable` L2 原有的追问安全阀是: ``` unactionable = tooShort && missing.length >= 2 && !hasAnchor ``` 它判断的是"整条需求还没成形"。但"缺内容"是另一回事——整条需求很清楚,只缺新增物是什么。 实测数据说明两者不能互相替代: | 需求 | tooShort | missing | hasAnchor | 若只降到 L2,安全阀会问吗 | | --- | --- | --- | --- | --- | | `登录页加个按钮` | true | 3 | false | 会 | | `@…/login/index.vue 登录页加个按钮` | **false** | 2 | **true**(识别出文件) | **不会** | 带路径前缀 → 变长 + 拿到 file 锚点 → 安全阀根本不触发。**所以"缺内容"必须是独立于长度与锚点的理由。** ### 为什么不直接走 `spec_triage` `confirm` 缺的只是一个信息,付一次完整四维体检不划算。所以它跳过第 2/3/4 步, 直接用一次 `ask_user_question` 问清 `contentGap` 里列出的内容就动手。 ### 提问权的封闭列举 只有三种情况允许 `ask_user_question`:`nextStep=confirm`、L3、L2 安全阀。 提问前禁用任何文件类工具(read/grep/glob/bash/ls),一次问完。 这道约束在常驻提示段、Skill 文档、工具描述三处同时写着。 ### L1 保守默认表 不明确时直接套用,宁可少做不瞎猜: | 维度 | 默认 | | --- | --- | | 列表展示 | 不展示(如需展示请主动告知) | | 业务校验 | 不加,仅做基础必填/非空校验 | | 后端支持 | 默认已支持(仅前端改动) | --- ## 三、召回算法 `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`"比"有个页面"值钱得多。 **通用基名降权**:`index.vue` / `main.js` / `app.vue` / `package.json` 这类任何项目里都有一堆的基名 不再吃满路径权重——否则 `…/login/index.vue` 与 `…/third-party-integration/index.vue` 会共享一个 权重 4 的 `index.vue`,看起来像命中了同一条路径。降权只降权重、保留 token,避免误伤"改 index.vue"类真实需求。 - **查询侧先聚焦再比。** 用户需求常常很长(带路径、叙述、寒暄),直接取 24 个 token 会有一多半是权重 1 的 2-gram, 稀释余弦与覆盖率。查询指纹先削掉低信号尾巴(强 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.30,而阈值只有 0.35——实测一份与需求**零词面重叠**的模板 也能拿到 0.298(阈值的 86%)。所以 `lexical === 0` 一律不命中:先验分仍参与打分(用于排序), 但"以前在同一个项目干过同类的事"不足以认定"这次该复用这份模板"。 ### 阈值标定 阈值默认 **0.35**,`matchThreshold` 可调:调低更易命中(会引入误召回),调高更严格。 下面是真实模板(非构造数据)的分离度: | 需求 | 得分 | 结果 | | --- | --- | --- | | 同类:Vue 管理页加 `isMainAdmin` 开关字段 | 0.63 | 命中 | | 同类:管理页表单加开关字段 | 0.558 | 命中 | | 无关:node_modules 加 .gitignore + 写 README | 0.111 | 不命中 | | 无关但同仓库同分类(唯一交集是通用基名 `index.vue`) | 0.311 | 不命中 | > 标定经验:曾打算"给词汇分量设门槛",但先量分布发现——真命中的词汇分 0.142 与假阳性的 0.129 > 几乎重合,设门槛会连真命中一起杀掉。最终只卡 `lexical === 0` 这条硬边界,转而去修真正的根因 > (某个 token 的权重赋值过高)。**改阈值类逻辑前请先重跑两两分布。** ### 性能 模板列表在进程内带读缓存(写盘版本号 + 文件名集合双重失效),模板库到几百份时也不会每次召回都全量读盘解析。 --- ## 四、四个工具 | 工具 | 什么时候被调用 | 干什么 | | --- | --- | --- | | `spec_recall` | 收到编程需求的**第一件事** | 翻模板库,把命中模板的澄清清单/标准改法/验收标准 + 项目禁区注入上下文,并给出 `level`、`fastTrack`、`contentGap` 与 `nextStep`。判定 `implement` 时会省略「澄清清单」并滤掉改法里引用 `spec_*` 的流程行(那两块与"禁止追问"冲突,且占返回量七成) | | `spec_triage` | `nextStep=triage` 时 | 四维体检 + 定 L1/L2/L3。L1 出执行清单,L2 出「已按默认执行」,L3 出完整 Grill-me。不重复扫模板库,也不接收 `cwd` | | `spec_distill` | L3 与安全阀场景澄清完毕、动手之前 | 把需求 + 澄清答案 + 禁区蒸馏成一份实现提示词;禁区为空会拦下 | | `spec_retro` | 任务收尾 | 把这次经验沉淀/更新成模板;digest 留空时自动从会话事件流提取摘要 | 0.3.3 的 `spec_store` 曾被并入 `spec_library`;**0.6.0 把 `spec_library` 整个删掉了**(工具数 5 → 4)。 依据:15 个真实会话里它被调用 **0 次**,却占 481 token/请求;而它承担的"看模板库 / 跨路径搬数据 / 清过期模板"三件事,根子上都是**文件系统自带能力** —— 模板库就是一堆带 frontmatter 的 markdown, 看一眼、拷一份、删一个文件都不需要插件代码。 --- ## 五、注入分四层 | 层次 | 机制 | 内容 | 成本 | | --- | --- | --- | --- | | 常驻 | 系统提示词 section | 由 `lib/render.js` 的 `ROUTING_CONTRACT` 渲染:三态路由 + 提问白名单 + 大文件纪律 + 沉淀门槛 | 约 489 token,始终占用(0.4.7 起内容与代码同源) | | **每轮** | `agent/pre-step` 注入(0.5.0) | 按本轮需求原文现算的 `nextStep` 硬指令,只命中 L1 一步直达 / 需求缺内容两态;`triage` 与普通对话不注入 | 命中 136(L1)/ 150(缺内容)token,不命中 0(`npm run token-audit` 实测) | | 按需 | 运行时 Skill | 完整流程说明书(含三层漏斗决策与大文件纪律) | 用到才加载 | | 执行 | 四个工具 | 上一节的表 | 调用才产生 | > **为什么要有"每轮"这一层**:常驻段与工具返回值都是"模型先读到、再自觉执行"的软约束。 > 真实会话里 20 次召回有 17 次紧接着调了 `spec_triage`(召回已经判过的结论,模型又走一遍流程); > 0.4.6 记录的事故则是模型自己替用户挑了按钮用途。pre-step 让插件**自己算、直接送**, > 不再经过"模型是否照做"这一环。判据与 `spec_recall` 同源(同一个 `classifyComplexity`、同一份原文), > 所以两处结论不可能自相矛盾——这是它敢下硬指令的前提。 > > 实现遵循内置插件的既有范式:`dsh-tool-skill` 用同一个事件注入 skill 目录, > `dsh-repeat-tool-reminder` 用同一个事件手搓 `createUserMessage`(本插件同样自己构造消息, > **不新增任何依赖**)。异常一律原样放行,注入失败只影响这一轮的提示,不影响本轮执行。 > 常驻段有一个自设参考线 `SYS_BUDGET = 450` token,**它不是 dsh 的硬限制**—— > dsh 的 `renderPrompt()` 只做 `sections` 排序 + `join('\n\n')`,没有任何截断或长度上限,超线只是多花 token。 > 曾经为压到线内做了一轮"为压缩而压缩",结果静默删掉了 4 条约束(其中「报错排查」只存在于该段,删了就真没了)。 > **请勿为了压线删约束**;`scripts/token-audit.js` 的判定已写成动态文案,超线只提示"按内容需要取舍"。 --- ## 六、成本归因 一次真实会话被逐事件解码后的账: | 项 | 量级 | 说明 | | --- | --- | --- | | 插件固定税 | ≈ 2.7K token / 请求 | 常驻提示段 ≈ 489 + 四个工具定义 ≈ 2237(0.6.0 实测) | | 插件单次调用产出 | 几百 token | `spec_recall` 未命中仅 43 token;`spec_triage` 报告 350~530 token | | 插件占整轮成本 | ≈ 4% | 8 次 `spec_*` 调用,返回 14.5K 字符,占全部工具返回的 3.2% | | **真正的成本大头** | **≈ 50%** | 两个 >100K 字符的文件被**整读**后,在其后约 73 步里被反复重计 | **结论:插件不是成本黑洞,整读大文件才是。** 长会话里约 98% 的 token 是 `cacheRead`(上下文每步重发), 所以最有价值的省钱手段是"别把大文件塞进上下文",而不是砍插件。 ### 往返成本 0.4.3 之前简单需求也要走满 3 次工具往返。治理后按真实模板库回放 7 条样本: 合计调用 22 → 14 次(−36%),简单需求全部 3 → 1 次(−67%)。省的不只是 token,还有每一跳的固定税。 ### 配套开关 不在插件里,在 dsh / 模型侧: - 长会话开启上下文压缩(compaction),避免每步重发全量上下文 - 预算敏感时降低 `reasoningEffort`——一次实测里 `high` 档的推理 token 占输出的 56%