Commit 87a2cee7a43b59501864ae8aad966840859d321d
1 parent
3943354b
fix(task-decomposition): experience-lookup 关键词打分噪音压过信号(真实回归发现)
46 号文档 Phase 2 真实端到端回归(真实公司 p2verify0902 + 真实 goal/task/ Claude CLI)第一次跑就复现一个真缺陷:原始打分用「交集计数、>0 就算命中」, 一条长任务描述几乎必然跟经验库里的高频工程词汇偶然重合几个 bigram,导致 一个贪吃蛇任务被"验证修复要重新触发流程"这类无关的工程经验污染成 reasoning_source=memory,压过了本该触发的 deep research 分支。 改成 containment ratio(命中词数 / 经验块自身词数,对经验块归一化而不是 对查询归一化),阈值用真实噪音案例(~3%-4%)和合成相关案例(~16%-32%) 校准。补回归测试固定复现这次真事故场景。修完重启服务用第二条真实任务 (Markdown 转 HTML 工具)重新验证:reasoning_source 正确变成 deep_research,两轮调用(subtask-planning → org-hunt → subtask-planning) 真实跑通,全程零残留 CLI 子进程。46 号文档补了真实回归记录,包括一个 如实记录的局限:org-hunt 那次调用没有工具调用痕迹,更接近"多问一个角色 的第二意见"而非真的做了外部检索。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H85SS5hVC4u1PbVhjmrhH8
Showing
3 changed files
with
55 additions
and
10 deletions
docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md
| 1 | 1 | # 46 · Planning 应作为每个任务的默认前置步骤,覆盖三类信息来源 |
| 2 | 2 | |
| 3 | -> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md) 并修复。**Phase 1 代码已实现**(2026-09-02,见「Phase 1 实现记录」):新 agent `subtask-planning` + `enqueueTaskRun` 挂钩(收窄为显式 opt-in)+ 复用 R-9.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。**Phase 2 已实现(收窄版,2026-09-02,见「Phase 2 实现记录」)**:经验检索(新查询能力,扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭),单测覆盖,同样还没做真实端到端回归。"历史同类 goal 检索"这条来源本轮明确收窄未做。**Phase 3-5 涉及核心调度引擎范式级改动(任务池认领)+ 全新子任务级预算模型,风险量级不同,本轮不做,另起设计讨论** | |
| 3 | +> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md) 并修复。**Phase 1 代码已实现**(2026-09-02,见「Phase 1 实现记录」):新 agent `subtask-planning` + `enqueueTaskRun` 挂钩(收窄为显式 opt-in)+ 复用 R-9.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。**Phase 2 已实现且已做真实端到端回归(收窄版,2026-09-02,见「Phase 2 实现记录」)**:经验检索(新查询能力,扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭)。真实回归第一次就跑出一个真缺陷(关键词打分噪音压过信号,导致"经验库有答案"分支误触发)并已修复验证;两条真实任务的分解内容质量都好,但 deep research 分支目前更接近"多问一个角色的第二意见"而非真的做了外部检索(org-hunt 那次调用没有工具调用痕迹),如实记在实现记录里。"历史同类 goal 检索"这条来源本轮明确收窄未做。**Phase 3-5 涉及核心调度引擎范式级改动(任务池认领)+ 全新子任务级预算模型,风险量级不同,本轮不做,另起设计讨论** | |
| 4 | 4 | > **日期**: 2026-09-01 |
| 5 | 5 | > **触发场景**: 用户复盘 [43 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。 |
| 6 | 6 | > **格式对齐** `27-requirement.md`/`43-task-decomposition-granularity.md`:原话照搬 + 结构化复述,现状调研另起一节。 |
| ... | ... | @@ -304,6 +304,14 @@ goal 创建 |
| 304 | 304 | 2. **Deep research 复用 org-hunt,但用轻量提示词,不套用它的训练场景三件套契约**(`knowhow_raw.md`/`crawl_config.json`/`seed_data.json` ≥50 条)。原因:那套契约是为 FDE 训练场景校准的,直接套用在"人在等一条 task 入队"这种时间敏感场景会不可控地慢。org-hunt 本身的图节点契约、`deliverable.json` 模板都没动。 |
| 305 | 305 | 3. **Deep research 触发默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量)。org-hunt 的延迟/成功率在"日常任务规划、时间敏感"场景下从未验证过,且 Phase 1 已经因为一个新触发点疏于隔离出过真事故。这跟 Phase 1 `withSubtaskPlanning` 默认 `false` 是同一个谨慎级别——机制已实现且单测覆盖,但真实调用需要用户手动开关、挑单个任务验证。 |
| 306 | 306 | |
| 307 | -### 遗留:还没做真实端到端回归 | |
| 307 | +### 真实端到端回归(2026-09-02,已完成) | |
| 308 | + | |
| 309 | +用真实测试公司 `p2verify0902`(真实 org graph、真实 goal→task→dispatch)+ `ENABLE_DEEP_RESEARCH=1` 跑了两条真实任务,全程 `ps aux` 确认零残留 CLI 子进程。 | |
| 310 | + | |
| 311 | +**发现一个真实缺陷(已修复)**:第一条真实任务(贪吃蛇小游戏)跑完,`reasoning_source` 落成 `"memory"`,但 `experience_refs` 列出的命中全是 `ceo`/`orggraph-planning` 关于"校验失败怎么兜底路由""修复要重新触发验证"这类**跟游戏开发毫无关系**的工程流程经验——查了实际打分才发现根因:原始实现"关键词交集计数、只要 >0 就算命中",一条 100+ 词的任务描述几乎必然跟经验库里的高频工程词汇("验证""检测""流程")偶然重合 1-3 个 bigram,噪音就压过了信号,**直接吃掉了本该触发的调研分支**——这正是 Phase 2 acceptance 标准要求确认的"分支判断正确",第一次真跑就没通过。 | |
| 312 | + | |
| 313 | +修法:打分改成"这条经验自己的词里有多少比例被 task 提到"(对经验块词数归一化的 containment ratio,不是对查询词数),阈值(命中 ≥2 词且比例 ≥12%)拿真实噪音案例(约 3%-4%)和合成的真正相关案例(约 16%-32%)校准。补了一条回归测试固定复现这个真事故场景。修完重启服务、用第二条真实任务(Markdown 转 HTML 工具)重跑:`reasoning_source` 正确变成 `"deep_research"`、`experience_refs` 正确为空、`research_used: true`——分支判断修复生效,且日志确认真的走了两轮调用(subtask-planning → org-hunt → subtask-planning)。 | |
| 314 | + | |
| 315 | +**两条真实任务的分解内容质量都相当好**:贪吃蛇任务给出"先定义状态 → 渲染循环 → 输入监听 → 移动逻辑 → 吃食物 → 碰撞检测"六步,依赖关系合理;Markdown 工具任务甚至体现出"代码块围栏必须最先扫描保护,否则块内的 `#`/`**`/`[]` 会被后续正则误转换"这种真实的解析器实现顺序知识。这直接回答了 Phase 1/2 一直悬而未决的"内容有没有用"——回答是有用。 | |
| 308 | 316 | |
| 309 | -跟 Phase 1 一样,机制通不通已经用注入桩验证(三条 `reasoning_source` 分支、经验命中/未命中、调研开/关/成功/失败都有独立测试覆盖)。**没做的是**:真实开一次 `ENABLE_DEEP_RESEARCH=1`,挑一个真实任务,确认①经验检索真的能从生产环境的 dream 沉淀里查到有用内容(目前多数本体 dream 还没攒够料,可能长期查不到,这是预期状态不是 bug);②org-hunt 用轻量提示词跑起来的真实延迟、真实调研质量;③两轮调用叠加后的真实 token/时间成本。不在本轮自动测试范围内,需要用户后续单独决定何时验证。 | |
| 317 | +**一个诚实的局限,没有掩盖**:查了 org-hunt 那次调用的日志,全程没有 `l1-web-fetch` 工具调用痕迹——它大概率是直接用自己训练知识回答的,没有真的去查外部资料。轻量提示词只要求"给一段总结",没有强制要求引用可验证来源、没有强制要求真的调用检索工具。这意味着当前的 `"deep_research"` 分支实际提供的价值更接近"多问一个角色的第二意见",不是名副其实的外部调研——如果以后要坐实"真的查了外部资料"这件事,需要在提示词里明确要求工具调用证据(比如强制要求 sources 是真实可验证 URL),这次没有额外去做,留给以后需要更强调研可信度时再补。 | ... | ... |
server/services/experience-lookup.js
| ... | ... | @@ -8,8 +8,18 @@ |
| 8 | 8 | * |
| 9 | 9 | * 查询范围收窄到**这家公司已雇佣的 agent**——他们才是真正会去执行这条 task 的角色, |
| 10 | 10 | * 比空扫全系统本体贴题,也比"历史同类 goal 检索"(本轮不做,需要语义检索基础设施)便宜。 |
| 11 | - * 打分用最朴素的关键词重叠计数,不引入任何依赖——"查不到"本身就是有效信号,没必要在 | |
| 12 | - * 一个准确度上限很低的检索层上堆复杂度。 | |
| 11 | + * | |
| 12 | + * ── 打分:踩过一次真事故,记下来 ────────────────────────────────────────── | |
| 13 | + * 第一版用「原始交集计数、只要 >0 就算命中」,真实回归时(46 号文档 Phase 2 实现记录) | |
| 14 | + * 用一个真实贪吃蛇任务查真实 dream 数据,命中的全是 `ceo`/`orggraph-planning` 关于 | |
| 15 | + * "校验失败怎么兜底路由""修复要重新触发验证"这类**跟游戏开发毫无关系**的工程流程经验—— | |
| 16 | + * 命中原因是任务描述长(切出 100+ 个 bigram),跟经验库里的通用工程词汇("验证""检测""流程" | |
| 17 | + * 这类高频 bigram)偶然重合了 1-3 个,就被当成"经验库有答案",直接压过了本该触发的调研分支。 | |
| 18 | + * | |
| 19 | + * 现在改用**"这条经验自己的词里有多少比例被 task 提到"**(对经验块的词数归一化,不是对 | |
| 20 | + * 查询词数):查询长不该稀释信号,被稀释的应该是"提到了这条经验一星半点"这种弱命中。 | |
| 21 | + * 阈值(`MIN_CONTAINMENT_RATIO`/`MIN_MATCH_TOKENS`)是拿真实噪音案例(约 3%-4% 命中率)和一个 | |
| 22 | + * 合成的真正相关案例(约 16%-32% 命中率)校准出来的,不是拍脑袋——细节见该次真实回归记录。 | |
| 13 | 23 | */ |
| 14 | 24 | const fs = require('fs'); |
| 15 | 25 | const path = require('path'); |
| ... | ... | @@ -18,6 +28,11 @@ const { CROSS_COMPANY_MEMORY } = require('./dream-service'); |
| 18 | 28 | |
| 19 | 29 | const EXPERIENCE_FILE = 'experience.md'; |
| 20 | 30 | |
| 31 | +/** 一条经验至少要命中这么多词,单个偶然重合的 bigram 不算数 */ | |
| 32 | +const MIN_MATCH_TOKENS = 2; | |
| 33 | +/** 命中词数 / 这条经验自己的总词数,至少要到这个比例才算"查到了"(校准依据见文件头注释) */ | |
| 34 | +const MIN_CONTAINMENT_RATIO = 0.12; | |
| 35 | + | |
| 21 | 36 | /** |
| 22 | 37 | * 切词。中文没有空格分词,不引入分词依赖(也没这个必要——"查不到"本身就是有效信号), |
| 23 | 38 | * 用最朴素的字符双字滑窗(bigram)近似:把整句中文当一个词只会让任何两句话的重叠率 |
| ... | ... | @@ -56,10 +71,11 @@ function readExperienceBlocks(agentId) { |
| 56 | 71 | * @param {{companyId: string, task: object, limit?: number}} ctx |
| 57 | 72 | * @returns {{matches: Array<{agentId: string, title: string, body: string, score: number}>, |
| 58 | 73 | * queriedAgents: string[]}} |
| 74 | + * `score`:命中词数 / 这条经验自身词数(0-1 之间的比例,不是原始计数——见文件头注释) | |
| 59 | 75 | */ |
| 60 | 76 | function queryExperience({ companyId, task, limit = 5 } = {}) { |
| 61 | - const queryTokens = tokenize(`${(task && task.title) || ''} ${(task && task.description) || ''}`); | |
| 62 | - if (!companyId || !queryTokens.length) return { matches: [], queriedAgents: [] }; | |
| 77 | + const queryTokens = new Set(tokenize(`${(task && task.title) || ''} ${(task && task.description) || ''}`)); | |
| 78 | + if (!companyId || !queryTokens.size) return { matches: [], queriedAgents: [] }; | |
| 63 | 79 | |
| 64 | 80 | let agentIds = []; |
| 65 | 81 | try { |
| ... | ... | @@ -72,9 +88,11 @@ function queryExperience({ companyId, task, limit = 5 } = {}) { |
| 72 | 88 | const matches = []; |
| 73 | 89 | for (const agentId of agentIds) { |
| 74 | 90 | for (const block of readExperienceBlocks(agentId)) { |
| 75 | - const blockTokens = new Set(tokenize(`${block.title} ${block.body}`)); | |
| 76 | - const score = queryTokens.reduce((n, w) => n + (blockTokens.has(w) ? 1 : 0), 0); | |
| 77 | - if (score > 0) matches.push({ ...block, score }); | |
| 91 | + const blockTokens = tokenize(`${block.title} ${block.body}`); | |
| 92 | + if (!blockTokens.length) continue; | |
| 93 | + const hit = blockTokens.filter((w) => queryTokens.has(w)).length; | |
| 94 | + const score = hit / blockTokens.length; | |
| 95 | + if (hit >= MIN_MATCH_TOKENS && score >= MIN_CONTAINMENT_RATIO) matches.push({ ...block, score }); | |
| 78 | 96 | } |
| 79 | 97 | } |
| 80 | 98 | matches.sort((a, b) => b.score - a.score); | ... | ... |
server/tests/unit/experience-lookup.test.js
| ... | ... | @@ -75,6 +75,25 @@ describe('experience-lookup · queryExperience', () => { |
| 75 | 75 | if (irrelevant) expect(irrelevant.score).toBeLessThan(matches[0].score); |
| 76 | 76 | }); |
| 77 | 77 | |
| 78 | + it('长任务描述 + 通用工程词汇的经验只是偶然重合 1-2 个 bigram 时不算命中' | |
| 79 | + + '(真实回归事故复现:46 号文档 Phase 2 真实端到端验证时,一个贪吃蛇任务把' | |
| 80 | + + '"验证修复要重新触发流程"这类完全不相关的工程经验当成了"经验库有答案")', () => { | |
| 81 | + hire(COMPANY, AGENT); | |
| 82 | + writeExperience(AGENT, [ | |
| 83 | + { title: '验证修复要重新触发流程看真实产出', body: '不能只信别人汇报,要亲自跑一遍确认修复真的生效' }, | |
| 84 | + ]); | |
| 85 | + | |
| 86 | + const longTask = { | |
| 87 | + title: '实现命令行贪吃蛇游戏', | |
| 88 | + description: '用 Node.js 实现一个可以在终端里运行和游玩的贪吃蛇游戏。要求:使用方向键控制蛇的移动' | |
| 89 | + + '方向;蛇吃到食物后身体变长,并在场景中重新生成新的食物;蛇撞到边界或撞到自己身体时游戏立即' | |
| 90 | + + '结束,并在结束时显示本局得分。达成判据:在终端执行启动命令后能看到游戏画面并用方向键操作蛇' | |
| 91 | + + '移动;吃到食物后蛇身变长且得分增加;撞墙或撞到自己身体后游戏停止并打印出最终分数。', | |
| 92 | + }; | |
| 93 | + const { matches } = queryExperience({ companyId: COMPANY, task: longTask }); | |
| 94 | + expect(matches).toEqual([]); | |
| 95 | + }); | |
| 96 | + | |
| 78 | 97 | it('没有命中时返回空数组,不是异常', () => { |
| 79 | 98 | hire(COMPANY, AGENT); |
| 80 | 99 | writeExperience(AGENT, [{ title: '完全不相关的经验', body: '发工资流程走财务系统' }]); | ... | ... |