dsh-attention-health
给 DSH(DeepSeek Harness)做上下文健康监测 + 零模型交接文档生成。
当前状态:三个阶段均已落地并实测生效(离线工具 → host 插件 projection → Web UI 提示条)。
安装
标准 DSH 插件包(host + 浏览器双面,一个包):
dsh plugin --profile web add -w dsh-attention-health
-w 不能省:profile 自身是 pnpm workspace 根,否则会报 ERR_PNPM_ADDING_TO_ROOT;
- 包管理器请用与 profile 一致的 pnpm 主版本(profile 的
node_modules/.modules.yaml 记着
创建它的版本;版本不一致会报 ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF);
- 从源码/GitHub 安装:
dsh plugin --profile web add -w github:<你的用户名>/dsh-attention-health;
- 卸载:
dsh plugin --profile web remove -w dsh-attention-health。
装完必须重启 DSH(插件行不会热加载;浏览器半只需刷新页面)。
本机实测:dsh-node.err.log 出现 [attention-health] projection "attentionHealth" registered,
且页面启动图里出现 <包名>/client.js。
关于本项目
由 AI 主导开发——需求、验收标准与真实场景测试由作者提出,代码、测试与文档由 AI 在长程会话中编写,
并经过多轮独立审查与真实数据校准(670 项自动化测试;阈值来自 2,244 个真实思考块与 79 个会话实测)。
它不是随手生成的玩具,也不是专业团队打磨的产品。
维护状态:个人项目,维护能力有限;欢迎 issue 与 PR,但不承诺 SLA。
这是什么
三个能力:
- 实时思考守卫(2026-09-16 新增)—— 在生成过程中逐帧检测思考的复读 / 空转,
命中就立刻中止本轮并提示原因。这是本项目唯一会主动干预的能力。
- 会话健康自检 —— 从会话日志机械推断注意力退化风险
- 交接文档生成 —— 从会话日志机械提炼出可直接交给新会话的结构化 Markdown
除守卫外,其余能力都不调用任何模型;守卫本身也只做字符串判据,不额外发起模型请求。
核心设计立场:要交接的时刻,正是模型已经不可靠的时刻。所以交接文档绝不能靠 LLM 写——让退化的模型总结它退化的原因,产出必然是垃圾。会话日志是 append-only 的事件溯源记录,数据本身完好,与模型状态无关。
与生态插件的分工(2026-09-16 定位收缩)
DSH 生态里已有更成熟的上下文洞察工具,本插件刻意不重复它们:
| 你想要的 | 用哪个 |
|---|
| 上下文占用 / 构成 / 趋势、逐请求浏览、成本估算(元) | dsh-context —— 它读官方 token-meter 投影,比本插件自算的更准更细 |
| 一键导出交接文档(LLM 总结式,交互更直接) | dsh-handoff-button 等 |
| 按规模 / 成本 / 缓存判断"要不要新开" | dsh-context-compass 等(已覆盖,UI 更完整) |
| 按内容退化信号(思考打转 / 输出复读 / 守卫命中)判断"要不要交接" + 零模型交接文档 | 本插件(生态里没有替代) |
所以它只做生态不做的三件事:
- 退化 / 异常检测 —— 思考打转、输出复读、符号堆砌(真实事故:单思考块 53.5 万字符、
同行重复 69,667 次 ≈ 一次 ¥1.02)。
- 由检测得出的行动建议 —— 继续 / 交接。生态里的会话健康工具(如
dsh-context-compass)
主要看规模 / 成本;本插件看的是内容退化(思考打转 / 输出复读 / 守卫命中),
并据此主动给出"该交接了"的结论。
- 零模型交接材料 —— 交接时刻正是模型不可靠的时刻,所以这份材料绝不用 LLM 写。
这与"LLM 总结式"竞品是不同取舍,不是单纯的优劣。
成本明细只在结论是交接时作为依据显示,其余情况交给 dsh-context。
用法
# 健康自检:当前工作目录的最新会话
node handoff.mjs --health
# 健康自检:指定会话(支持 ID 前缀)
node handoff.mjs d346d3e8 --health
# 生成交接文档到文件
node handoff.mjs -o HANDOFF.md
# 指定会话 + 限制规模
node handoff.mjs 6938bea6 --max-turns 30 --max-requests 20 -o HANDOFF.md
# 列出会话
node handoff.mjs --list
# 生成 + 打印解析统计
node handoff.mjs --stats -o HANDOFF.md
# 汇总历史交接记录(阈值回溯素材,零 token;见下文「校准留痕」)
node handoff.mjs --calibration
--health 输出示例(2026-09-15 实测,对象是本机真实发生过的退化事故会话):
会话 :session-SAMPLE-9f44ebc9-a5fd-c8e9fc6ed02d
健康评分 :50 / 100 — 上下文偏大
上下文规模 :353,386 token
占声明窗口 :35.3%(窗口 1,000,000)
有效占用 :58.9%(有效窗口 600,000,系数 0.6)
思考异常 :1 次(打转 1 / 其他 0),最长同一行重复 69,667 次
发现:
- 已过有效窗口一半(评分 −25)
- 思考过程出现打转:1 次(最长同一行重复 69,667 次)(评分 −25)
上面是 2026-09-16 边界设定后的真实输出。同一条会话在旧系数(0.5)下是 30 分 / 上下文过大,
现在 50 分 / 上下文偏大 —— 因为有效窗口从 500K 变成 600K,分母变大、判定变宽。
这是有意的:Chroma 的 Context Rot 研究给的是「声明窗口的 50%~60%」(见下文第 3 节),
0.5 取的是区间下端、0.6 取上端,同时让"何时劝交接"不再早到离谱(见「双口径」一节)。
那次事故:模型在思考过程里把「好。」重复了 69,667 次,reasoningTokens 打满 256,000
才停 —— 而同一轮的最终回复完全正常,所以只看输出的旧判据一个字都没报。
现在思考退化单列一行,并且参与评分(见下文「退化检测」)。
等级文案(正常 / 留意上下文 / 上下文偏大 / 上下文过大)只描述上下文规模,
不含行动建议;要不要交接,看交接文档里的「维度 A 质量(异常信号)」与命中率成本。
评分为启发式指标(阈值内部校准、未做回溯验证):看同一会话的趋势可以,不代表精确测量。
生成的交接文档包含什么
| 章节 | 内容 | 数据来源 |
|---|
| 0. 为什么要交接 | 健康评分、上下文规模、风险信号 | usage + 事件分析 |
| 1. 会话标识 | ID、标题、cwd、preset、模型、窗口、时长 | header + request/context |
| 2. 当前目标 | goal 的 objective / phase / 轮次 | goal/change |
| 3. 用户请求主线 | 全部人类输入(含问答回答) | user/message + ask_user_question 结果 |
| 4. 各轮进度概要 | 每轮的请求、结果、工具数、错误数、结束原因 | turn/* + assistant/message |
| 5. 涉及的文件 | 路径 + 操作类型与次数 | tool/call 的 arguments |
| 6. 待办事项 | checkbox 列表 | todo/write |
| 7. 遇到的错误 | 工具失败 + 轮次异常结束 | tool/result.isError + turn/end.reason |
| 8. 工作量统计 | 轮次、步骤、工具分布、token | 全量事件 |
会话内一键交接(UI 按钮,2026-09-15 新增)
会话里健康提示条展开后有一个「复制交接内容」按钮,点一下:
- 浏览器请求 host 半注册的本地路由
GET /attention-health/handoff?sessionId=…;
- host 半用纯规则折叠该会话的事件流(活会话读内存、冷会话读压缩日志),
产出精简交接 Markdown —— 不调用任何模型、零 token;
- 内容直接进剪贴板,界面提示复制了多少字符 / 多少行,粘贴到新会话即可。
实测:当前会话 3ms 出结果、4516 字符;最大会话(9745 事件)也是 4ms 级别。
它是怎么实现的(接口都查证过)
| 环节 | 用的接口 | 说明 |
|---|
| 生成内容 | host 半 webServer.register({kind:'prefix', path:'/attention-health', handler}) | 原生 (req,res) 通道。官方 @Remote/ctx.remote 需要 typert 生成链,本机无源码 checkout 无法构建,故不用 |
| 读会话事件 | 活会话 ctx.sessions.get(id).snapshotEvents();冷会话 ctx.sessionQuery.readSession(id) | 活会话优先,拿到的是内存中最新内容 |
| 复制剪贴板 | navigator.clipboard.writeText,兜底 document.execCommand('copy') | 两条都失败时明确报错,不假装成功 |
⚠️ 注册路由必须用 ctx.inject(['webServer'], cb),不能写成 ctx.get('webServer'):
profile 的加载条目是并行初始化的,插件常常在 webserver 之前跑完 apply,
那一刻 ctx.get 只能拿到 undefined,路由就悄悄丢了(浏览器点按钮报 HTTP 404)。
这个坑真实踩过,test/plugin-test.mjs 里有一项专门守着它(mock 的 get('webServer')
故意返回 undefined,只有走 inject 才能注册成功)。
为什么不用"自动新开会话并预填"
曾经实现过:客户端 uiWorkspace.startSession() 新开会话 + 新会话 slot props 的
inputActions.setDraft(text) 写入草稿。这两个接口本身确实存在(都是官方 contract),
但实测客户端 ctx.get('uiWorkspace') 在 apply 阶段同样拿不到服务(与 host 侧同一个
时序问题),功能不可用。按用户决定改为剪贴板方案:不依赖任何服务时序、一步到位、零副作用。
失败时不会静默降级:按钮旁会显示具体原因(后端错误 / 剪贴板被拒等)。
交接内容结构(2026-09-15 提炼质量增强)
文档头之后(「给接手方」操作指引 → 零模型声明 → 隐私提示 → 生成时间 / 数据来源):
| 小节 | 内容要点 |
|---|
| 为什么交接(健康度) | 评分、上下文规模、有效占用,并附评分机制说明(分数只由单会话上下文规模驱动、与时间无关;质量维度需伴随退化信号才给交接结论:≥70% 有效 + 退化 → 建议交接、≥90% 有效 + 退化 → 立即新开会话;只有规模大、无退化则交给压缩/成本维度) |
| 会话 | ID、最新标题(含曾用标题)、cwd、preset、模型、时间跨度 |
| 最新目标 | 优先最新 goal/change;无 goal 时取最近一条用户请求;首尾差异大时并列「最初目标 / 最新目标」并提示以最新为准 |
| 用户请求主线 | 超长正文(>320 字)自动压缩成摘要,原文进附录 B |
| 关键决策与未决问题 | 从助手文本与问答回答里抽句,去重、按时间排序、标注来源轮次与角色。两级词表:主表是 25 个取舍词(根因 / 结论 / 决定 / 改为 / 放弃 / 必须 / 风险…);当整场会话主表命中不足 3 条时,自动用通用结论词(发现 / 推荐 / 因此 / 最优 / 汇总 / 合计)兜底补抽 —— 咨询、报表、生活对话里的数字结论(如"散件合计约 11900 元")靠这一级补回。文档里会写明本会话是否启用了兜底 |
| 各轮进度 | 最近若干轮的请求 / 结果 / 工具 / 错误 / 结束原因 |
| 涉及的文件 | 文件工具清单(精确)+「可能涉及(来自命令)」(从 pwsh/bash 文本启发式挑出的写文件动作) |
| 待办 | 最后一次 todo/write |
| 遇到的错误 | 按错误码聚合:× N、出现轮次,以及"第 X 轮之后未再出现"或"最近一轮仍出现(未解决)" |
| 下一步 | 规则推导(未闭合轮次、未解决错误、进行中待办…) |
| 统计 | 总量 + 各轮上下文增长表(增量 / 输出 token / 工具结果字符 / 主要来源),并列出增长最快的轮次及成因判定(大工具输出 vs 模型长输出) |
| 附录 A | 最近 1~2 轮完整原文(不截断,总长上限 20,000 字符,超出在尾部截断并标注);自动跳过"继续 / 好了"这类指令型轮次,并在标题注明跳过了哪几轮 |
| 附录 B | 超长用户请求原文(上限:完整档 12,000 字符;精简档只展开第 1 条 ≈900 字符,超出截断并标注) |
全部为本地规则:零模型调用、零 token、不联网。
压缩成本 / 交接时机(双口径,2026-09-16 边界设定)
两个维度各用一个坐标系,运行时并列展示、各自标注口径,互不换算:
| 维度 | 口径 | 阈值 |
|---|
| A 质量 | 有效窗口(0.6×声明) | 规模且退化才动手:≥90% 有效 且检出退化信号(重复调用 / 输出退化 / 思考打转 / 思考异常)→ 立即新开;≥70% 有效 且检出退化信号 → 建议交接;只有规模大、无退化 → 本维度不给结论(交给 B) |
| B 压缩 | 声明窗口(与官方线同坐标) | <50% 继续;5065% 轻度提示;6580% 推荐手动 /compact(带成本/收益);≥80%(或进入 80% 线前方 5 个百分点预警带)→ 现在手动 /compact(否则官方即将自动压缩) |
覆盖规则:退化信号优先于压缩;压缩后仍 >60% 声明、或已发生 ≥2 次模型摘要 → 建议交接。
为什么质量维度要加「且退化」这个前提(2026-09-16 用扫描矩阵实测出来的):
旧规则里"有效 ≥90% → 立即新开"只看规模,而 90% 有效 = 声明 45% —— 早于官方线(80% 声明)。
结果是声明 45%~100% 全区间都被 drivenBy=quality 的 handoff 顶替,
那条「现在手动 /compact,否则官方即将自动压缩」的建议一次都没出现过(官方线分支永远不可达)。
而"上下文大"本身不是故障信号,真正不可救的是退化(压缩只腾空间、不修退化);
反过来没有退化,也就没必要为了规模换会话。
为什么压缩档位改锚声明窗口:锚在有效窗口(0.5×声明)时,声明 40% 起就全部落进 urgent,
紧急档失去区分度;更要命的是它必须与质量维度共用坐标,质量维度一旦只看规模就会遮蔽整条压缩曲线。
推迟压缩(2026-09-16 用户反馈后新增):档位只说明"风险在升高",该不该现在动手还要看
「回本能否在官方自动压缩线到来之前兑现」—— 因为官方压缩到线时先做零 token 的无损裁剪
(prune),只有不够才调模型做有损摘要;而手动 /compact 是立刻调模型、立刻付全价。
于是满足任一条件就不催压缩,改为「可以继续(暂不必压缩)」:
- 距官方线还有 ≥30 轮(现在压没意义);
- 回本轮数 > 距官方线轮数(收益兑现不了);
- 估不出轮数时,占声明窗口 <60%(保守代理)。
官方线分支排在推迟规则之前:到了官方线就不再谈"值不值"—— 那是控制权问题。
手动压缩与自动压缩都要调模型、都有损,区别只在谁定时机、以及之前能否先用无损 prune 争取空间。
(代理线定为 60 而不是 50:50 正好等于档位起点,会让这条代理判据永远不可达,本轮踩过。)
同时修正两句不实文案:不再出现"有效窗口 ≥80% 但离官方线还远,却说否则官方即将自动压缩";
推荐档不再说"先压缩再继续"(档位本意是"阶段结束点执行")。
- 同时展示:声明窗口、有效窗口、当前上下文、距官方压缩线余量(含"还能撑几轮")、缓存命中/未命中
- 成本模型:两套账并列 ——
- token 当量(相对量,便于横向比较):A 继续 = 上下文×(命中率×
cacheFactor(0.02)+未命中率×1) 每轮;
B 先压缩 = 被压缩历史×缓存系数 + 摘要输出×全价 + 缓存重建(1.5 轮),之后 压缩后上下文×同系数 每轮;
C 机械交接 = 交接文档实测长度(两遍渲染回填,不是固定值);
- 真实价格(元)(绝对量,用于决策):DeepSeek flash 空闲价 命中 0.02 / 未命中 1 / 输出 4(元每百万 token)。
新开会话分段计费:首轮 = 跨会话公共前缀(系统提示 + 工具 schema,实测首轮命中率中位数 76%)
加权 + 交接文档按未命中价;第 2 轮起首轮输入已落盘为缓存前缀单元 → 按命中价。
判定 =「交接回本轮数 + 1 轮余量 < 预计剩余轮数」。
(2026-09-18 修正:旧口径"新开会话一律按未命中价、
新开总额 × 1.5 < 继续总额"
对任何窗口长度都无解 —— 实测 76 个会话里旧判定命中 0 个,成本维度等于被钉死。)
- 边界:只提示、不自动压缩、不改官方 compaction 配置;压缩是续命(有损),机械交接是无损事实底座
架构
handoff.mjs CLI 入口(--list / --health / -o / --stats)
⚠️ 渲染**直接复用** lib/handoff.js 的 buildHandoff
lib/ ← 2026-09-18 标准包化:仓库根 == npm 包根,全部源码都在这里
zstd-frames.mjs Zstandard 多帧扫描与解压
session-log.mjs 会话定位、代际选择、事件解析
extract.mjs 【已弃用】早期的第二套折叠/渲染实现,仅供测试交叉验证
detect.mjs 【离线侧】扫日志后**交给插件的同一套判据与评分**(不再自持实现)
—— 下面是运行时(host + UI)的两半,也是产物质量的**唯一来源** ——
index.js host 半:attentionHealth projection + 交接生成路由
handoff.js barrel:只转发下面 6 个模块的导出(CLI 与插件都 import 这个路径)
handoff-core.js 常量 / 评分 / token 口径(不依赖任何兄弟模块)
handoff-text.js 文本工具(截断 / 清洗 / 切句 / 决策句抽取)
handoff-judge.js 退化判据(全项目唯一实现)
handoff-fold.js 事件流折叠(冷会话日志 → 结构化事实)
handoff-plan.js 计划推导(评分 → 继续 / 压缩 / 交接)
handoff-render.js Markdown 渲染(交接文档)
history.js 交接历史留痕(`--calibration` 的素材;纯本地、零 token)
guard.js 实时思考守卫(复读 / 空转即中止本轮)
guard-log.js 守卫现场落档(尾部 500 字,可切隐私模式)
prices.js 价格数据与峰谷档位(可被 prices.json 覆盖)
jsonl.js JSONL 追加 + 512 KB 轮转
client.js UI 半:健康提示条 + 复制交接内容(浏览器 bundle)
cordis.patch.yml 本包作为 bundle 的挂载行(一行同时装入 host 与 UI 两面)
包形态(2026-09-18 标准包化):package.json 声明 main: lib/index.js、
exports["./client"]: ./lib/client.js、dsh.bundle.patch: ./cordis.patch.yml、
dsh.client.platform: web —— 与生态里同为双面的 dsh-context 完全同形,
所以别人可以 dsh plugin --profile web add dsh-attention-health 直接装。
浏览器半的模块 id 必须等于包名(dsh-client-modules 用解析出的包名当模块身份)。
files 白名单只放 lib/ + handoff.mjs + cordis.patch.yml + README.md + LICENSE,
开发档案(DEPLOYMENT-NOTES / HANDOFF.md / work/ / test/ / *.ps1)不进包。
漂移防护(2026-09-15 全量复核 N-1 / N-5):CLI 与插件曾各持一套渲染实现,
第二批修复的 12 项只落到了插件里,于是同一会话会产出两种质量的文档
(表格错位、附录 B 无上限、"最新目标"可能仍是"继续")。
现在 handoff.mjs 直接 import { buildHandoff } from './lib/handoff.js' ——
一处修改、两处生效,漂移在结构上不可能再发生;lib/extract.mjs 的渲染层已标记弃用。
判据与评分同理:唯一实现是 handoff.js 的 replyDegradeReasons() / scanReasoning() /
scoreContextHealth(),lib/detect.mjs 只负责扫日志、然后调用它们
(plugin-test 会逐会话比对热/冷两条路径的 token 数与评分)。
2026-09-18 D3:handoff.js 已按「判据 / 折叠 / 计划 / 渲染」拆成 6 个模块,
对外仍是同一个 barrel(导出 17 个名字,一个不多一个不少),
所以上面这些"唯一实现"的说法全部不变 —— 变的只是各自有了独立的文件边界,
改判据时不必再在 3,656 行里找依赖。
关键技术陷阱(都已实测确认)
1. 会话日志是多帧 zstd,不能用 Node 内置 API 直接解
- ❌
zlib.zstdDecompressSync(buffer) 只解第一帧(435KB 文件只解出 207 字节)
- ❌
zlib.createZstdDecompress() 流式报 ZSTD_error_prefix_unknown
- ✅ 必须自己走帧边界再逐帧解压 ——
scanZstdFrames() 移植自 DSH 自带的
@deepseek-ai/dsh-session-persistence-jsonl(MIT),保证与官方实现一致
实测:71/71 个会话解析成功,3 秒完成,0 帧失败。
2. usage.inputTokens 是未命中缓存的输入,不是上下文规模
这是最容易踩的坑。实测某个长会话:
inputTokens: 142 ← 只有 142!
cacheReadTokens: 258944
totalTokens: 259196 ← 真实上下文规模
用 inputTokens 会把上下文低估三个数量级。 必须用 totalTokens
(= inputTokens + cacheReadTokens + outputTokens,已实测验证)。
3. 模型声明的窗口 ≠ 可靠工作区间
用户环境里 contextWindow 被配成 1,000,000。但 Chroma 的 Context Rot 研究显示,
宣称百万窗口的模型在声明窗口的 50%~60% 处就已明显退化。
因此评分基于「有效窗口 = 声明窗口 × 0.6」,而占声明窗口的百分比仍然如实呈现 ——
事实与判断分开,避免误导。系数的唯一来源是 lib/handoff.js 的
COMPACT_DEFAULTS.effectiveWindowRatio(lib/detect.mjs 那份与它保持同值)。
4. 用户对 ask_user_question 的回答不走 user/message
它作为工具结果返回:{"answers":[{id, selected, custom}]}。
只统计 user/message 会漏掉这些交互(实测某会话 9 次人类输入中有 3 次是这样来的)。
5. user/message 的 source.kind 必须过滤
实测分布:user × 6、agent-message × 3、subagent-settled × 3、
agent-instructions × 1、plugin × 1、goal × 1。只有 kind === 'user' 是人类输入。
6. 磁盘上存在多个代际,必须只读最高版本
session.jsonl.zstd(v0) / session.v2.jsonl.zstd / session.v3.jsonl.zstd。
v0 的词汇表不同(assistant/chunk、reasoning-chunks 等),且用 seq0/time0 字段名。
实测分布:v0 × 38、v2 × 3、v3 × 30。
7. todo 投影会在每轮开始被清空
DSH 的 dsh-tool-todo 在 turn/start 时把投影重置为 null。所以必须自己折叠日志,
取最后一个 turn/start 之后的最后一条 todo/write。
已知限制(诚实声明)
- 文件修改清单不完整:只覆盖经 DSH 文件工具(
str_replace_editor / write / edit)
完成的修改。通过 pwsh / bash 直接写文件的操作不会出现在日志中。
- "关键决策与未决问题"是启发式抽取(已实现,不是"没做")。日志里没有"决策"事件,
只能按关键词从助手文本与问答回答里抽句。词表刻意偏"取舍 / 结论"语境(宁可少抽也不要噪音),
所以非工程领域可能漏抽;漏掉的内容由「用户请求主线」与附录原文兜底。
- 压缩过的会话评分有上限:经历 1 次模型摘要 → 最高 79 分、2 次 → 65、≥3 次 → 50。
压缩是"续命"(有损),不该把分数送回满分(那会把"续命"记成"治愈")。
- 重复工具调用检测过于保守:只做精确匹配(同工具 + 同参数)。
实测 71 个会话中该信号从未触发(因为 DSH 自带的
repeat-tool-reminder
会在 3 次时提醒模型,模型通常会改)。近似重复(改了个路径)检测不到。
- 退化检测是启发式的,分两类信号(判据的实测依据见下一节):
- 输出退化:代码块围栏不闭合、同行复读、段落逐字重复、符号堆砌(表格框线字符已排除,
且先剥掉代码、要求命中串以非 ASCII 符号为主 —— 见下文二次复核)。
原「中英严重混杂」判据已删除 —— 全量 25 次命中、25/25 全是误报。
- 思考退化:行重复率 ≥0.7 且行数 ≥30(打转)、单块 ≥10 万字符(异常长)、
usage.reasoningTokens ≥ 16,000(真实计量,与字符数无关)。
- 立场不变:宁可漏报也不误报(误报会让用户忽略提示,比不提示更糟)。
- 评分是启发式指标:阈值与扣分力度是内部校准出来的,没有做回溯验证,
用途是比较同一会话的变化趋势,不是精确测量 —— 交接文档与界面都写明了这一点
(阈值校准留痕见下一节)。
contextWindow 依赖日志里的 request/context 事件。若会话从未记录该事件,
无法计算占用百分比。
- 验证范围是单机、单 DSH 版本(2026-09-20 补):全部阈值与判据是在一台机器、
dsh 0.1.5-rc.2 + cordis 4.0.2 上、用 79 个真实会话标定与回归的。换机器 / 换模型 /
官方改契约后,结论可能不再成立 —— 遇到不符请带着会话日志提 issue。
- 成本口径是估算,不是账单:单价来自官方公开价目表(可被
prices.json 覆盖),
「每轮」按实测平均请求数换算;官方调价或改缓存规则后需要更新价表。
隐私与威胁模型(2026-09-18 补)
插件全程本地:零模型调用、零 token、不联网、不启动子进程。但它确实会写盘,
位置与上限如下(全部在 $DSH_HOME\attention-health\,可用 DSH_HOME 环境变量整体改道):
| 文件 | 内容 | 上限 |
|---|
handoff-history.jsonl | 每次交接的一行统计(评分、建议、成本口径,不含对话原文) | 512 KB 后轮转为 .1.jsonl,共两份 |
guard-trips.jsonl | 守卫命中的种类、理由、detail,以及当时的思考原文尾部 | 同上 |
prices.json | 可选的价格覆盖表(没有就用插件内置价) | 手工维护,无轮转 |
守卫现场(唯一会落"原始文本"的地方):
- 默认保留思考原文尾部 500 字 —— 这是为了"误杀要能事后复核":
守卫会中断正在进行的生成,抓错了必须能回看当时到底贴了什么(
dsh-node.err.log
每次重启覆盖,靠不住)。实际至今真实命中 0 次、该文件为空;
- 想完全不落原文:设
DSH_ATTENTION_HEALTH_GUARD_TAIL=0,改为只存哈希 + 总长度 +
首尾各 40 字特征(足够人工认出是哪一次,但不含完整正文);
- 想更短:设成任意非负整数(如
120)即保留尾部 120 字;非法值回落到 500。
清理入口:直接删掉 $DSH_HOME\attention-health\ 目录即可(没有索引、没有外部副本,
删了就是删了);插件下次用到时会自己重建。交接历史丢了只影响阈值回溯分析,不影响功能。
威胁模型:插件的 HTTP 路由 GET /attention-health/handoff 是本机通道,把守条件为
四层同时成立 —— Host 是 loopback、sec-fetch-site 非 cross-site、Origin(若带)与 Host 同源、
以及连接来源 remoteAddress 是 loopback。
- 不校验 token:官方
isTrustedApiRequest 同样不校验(token 校验在 browser-auth 层,
值存在模块私有的 PROCESS_LAUNCH_TOKENS WeakMap 里,插件拿不到),所以这里不发明新机制;
remoteAddress 是唯一不可伪造的一层(Host 头可以伪造),当前只绑 127.0.0.1 时它无副作用,
将来若开放局域网,它能挡住"外部伪造 Host"这一类;
- 由此得出的边界:能访问这条路由的本机进程,本来就能直接读
~/.dsh/sessions
(交接文档的全部素材都来自那里),所以这条路不额外扩大暴露面。
退化检测与校准留痕(2026-09-15 复核 N-5 / N-6)
判据按实测数据重做
旧判据在全量 73 会话 / 4,185 条回复 / 3,491 个思考块上的实测表现:
| 旧判据 | 真实触发 | 处置 |
|---|
| 中英严重混杂(全篇中文占比 5%~25%) | 25 次,25/25 全是误报 | 删除 |
| 长段落逐字重复(>60 字) | 1 次 | 阈值降到 25 字(中文一句话常 25~50 字) |
| 围栏不闭合 / 同行复读 / 符号堆砌 | 0 次 | 被全局 length < 80 门槛挡住 → 门槛删除 |
误报的代价是具体的:规则 5 每条扣 6 分、最多扣 30 分,还会让提示条显示"输出异常 N 条" ——
而误报会让用户忽略提示,比不提示更糟(这是插件自己写下的立场)。
另外「符号堆砌」原来会把 Unicode 边框表格(╔══╦══╗)判成退化,现在排除了表格框线字符;
命中原因现在会带进提示条与文档(如"输出异常 2 条(符号堆砌 ×2)"),用户能自己判断提示可不可信。
二次复核:符号堆砌在思考里 100% 是误报(2026-09-15 深夜)
N-5 排除了框线字符,但漏掉了更大的误报源:代码本身。全量实测
(70 会话 / 3,638 个思考块 + 2,271 个回复块):
| 判据 | 思考侧命中 | 回复侧命中 | 人工判定 |
|---|
| 围栏不闭合 | 0 | 0 | 从未触发(安全网) |
| 同行复读 | 0 | 0 | 从未触发(安全网) |
| 段落复读 | 0 | 1 | 真实重复(同一段代码块逐字出现两次) |
| 符号堆砌 | 21 | 1 | 22/22 全部落在代码里 |
| 思考打转 | 1 | — | 真事故 |
| 思考异常长 | 1 | — | 真事故 |
命中的串清一色是模型正在思考正则和代码:
/^[{}()[\];,.<>|=+\-*/\\_#`~^&%$@!?:"']+$/
`^\*\*(.+)\*\*$`
"((?:[^"\\]|\\.)*)"
('|---|---|---|---|');
也就是说,旧判据等于因为模型思考正则表达式而扣它 12 分(reasoningFlags 每条 −4、上限 −12)。
修复两条:先 stripCodeBlocks() 剥掉围栏代码块与行内代码;再要求命中串以非 ASCII 符号为主
(真实乱码 / 卡图必然是非 ASCII,而长串 ASCII 标点实测 100% 是正则或代码)。
复验:思考侧 21 → 2、回复侧 1 → 0。残留 2 条的形态是 ◆◇■□▲△▼▽…,
来自一个"正在写符号堆砌测试样本"的会话 —— 文本上无法与真乱码区分,属可接受的止损。
受此修复影响,3 个历史会话曾被错误多扣 12 分、另外 3 个多扣 4 分。
最大盲区:思考过程
旧版只读 type === 'text',思考块一个字都没检查。而实测思考量是最终回复的 7.9 倍
(3,491 块 / 592 万字符 vs 2,284 块 / 75 万字符),用户描述的退化现象恰恰是
"对话长了以后思考过程夹杂一堆无用的东西和符号"。真实事故:
会话 :session-SAMPLE-bfd2b142(本机)
单个思考块:535,355 字符 / 116,152 行 / 只有 56 个唯一行(重复率 0.9995)
最高频行 :「好。」×69,667、「执行。」×23,214、「(执行)」×23,205
usage :reasoningTokens = 256,000(输出预算被打满)
旧判据 :**正常**(漏检) 新判据:思考打转 ×1 → 评分 30/100
新增两条思考判据(阈值由该事故 + 全量 3,491 个思考块零误报共同定出):
| 判据 | 条件 | 阈值敏感性(实测) |
|---|
thinkLoop 思考打转 | 行数 ≥30 且行重复率 ≥0.7 | 0.6/0.7/0.8/0.9 都只命中那 1 个事故块;0.5 会多带 2 个正常代码块 |
thinkHuge 思考异常长 | 单块 ≥10 万字符 | 实测最长正常块 44,249,事故块 535,355 |
thinkBudget 思考预算打满 | usage.reasoningTokens ≥16,000(真实计量) | 3,534 条带该字段的消息 p50=269 / p90=1,648 / p99=4,237,事故那条 256,000 → 语料上零误报 |
thinkBudget 与 thinkHuge 不是重复:后者只看单个块的字符数,
遇到"多个中等块加起来烧掉预算"就会漏(每块都不到 10 万字符);
thinkBudget 按消息级真实 token 判定,且不依赖思考正文是否落盘(该字段 81% 的消息都有)。
思考退化参与评分:打转 −25/次(上限 −50)、其他异常 −4/次(上限 −12)——
比输出退化(−6/条,上限 −30)重,因为一次打转就是几万 token 的输出预算被烧掉。
校准留痕(N-6)
复核指出:所有阈值(30/50/65/70/80/90、扣分力度、0.6 系数)都是内部校准出来的,
校准目标是"分数与等级自洽",没有做过预测准确性验证。最低成本的改进是留痕:
- 每次生成交接文档(UI 按钮 / CLI)都会往
$DSH_HOME\attention-health\handoff-history.jsonl 追加一行:
时间 / 会话 / 评分 / 等级 / 有效占用 / 压缩次数 / 退化信号 / 文档字符数;
node handoff.mjs --calibration 汇总这些记录(评分分布、有效占用中位数、最近 10 次);
- 零 token、纯本地、只追加,写失败绝不影响交接生成。
攒够样本后就能回答复核提的那个问题 —— "评分 55 分时交接,新会话实际能撑多久" ——
用真实数据反推阈值。
现状与后续
✅ 阶段 1:离线工具(已完成)
- zstd 多帧解析、会话定位、代际选择
- 交接文档机械提炼(8 个章节)
- 健康度检测(上下文占用 / 重复调用 / 输出退化)
- CLI 三模式(
--list / --health / 生成文档)
- 验证:71/71 会话解析成功,3 秒,0 错误
✅ 阶段 2:host 插件(已部署并生效)
实现:lib/index.js。2026-09-20 门 3 起,线上形态是标准插件包:
$DSH_HOME\profiles\web\node_modules\dsh-attention-health(由 dsh plugin add 装入,
层栈记在 profile 的 dsh.profile.bundles)。旧形态(profiles\web\attention-health\ +
profiles\node_modules\attention-health-ui\ 两个手拷目录 + patch 里两条 insert 行)
已退役但保留在磁盘上,作为回退线(快照见 <TOOLS>\DSH\留档\attention-health-发布前快照-20260920\)。
⚠️ 开发时的关键差别:改 lib/ 不会自动同步到线上。改完要
.\install-package.ps1(打包 + 安装)再重启;自测用 .\test-all.ps1(自动指向线上那份)。
这两个脚本随仓库公开;deploy.ps1 / restart-attention-health.ps1 是作者本机的开发工具
(内含本机 DSH 安装布局),不随仓库公开。
- 函数形态插件:
export { apply, inject, name },inject: ['sessionProjections']
- 注册
attentionHealth projection(ctx.sessionProjections.register(def)),客户端零代码自动同步
apply(state, event) 纯同步折叠,无关事件返回同一引用(严格遵守投影契约)
- 检测数据全部来自已入日志的事件,零 token
- 算法与
lib/detect.mjs 刻意保持一致,用测试保证不漂移
验证(五套自测合计 486 项全绿,2026-09-16):
| 测试 | 规模 | 覆盖 |
|---|
test/plugin-test.mjs | 84 项 | 加载 / 注册 / schema 契约(含 wire 键集合逐键比对);折叠契约(无关事件返回同一引用、null 安全);6 个真实会话与离线版 token 数 / 评分 / 等级 / 退化计数逐一致;边界(缺 usage 不崩、正确使用 totalTokens、100% 有效占用判为 critical);畸形输入加固回归(null 事件、contextWindow 0/负/NaN、usage NaN/Infinity 均不得产出非法 state);推迟压缩规则;有损压缩的评分封顶;热/冷两条路径的退化判据逐项一致(含思考打转) |
test/ui-build-test.mjs | 100 项 | 客户端包契约、各 health 取值的渲染、"整条提示条只允许一个行动建议"、展开区排版(每项只出现一次、不出现未渲染的 Markdown)、思考异常行与命中原因、"为什么这么建议"与三方案成本对照、缺数据时不许编造结论、host 未重启(缺新字段)时的向后兼容、结论说"别压"时不得出现省钱暗示、回本偏慢的语境、启发式标注 |
test/handoff-test.mjs | 278 项 | 提炼正确性、渲染完整性、边界健壮性、性能、两个输出档位、压缩不该"治愈"评分、退化判据的正/反例(含真实事故形态、旧误报样本、代码里的正则不算退化)、思考预算真实计量判据、上下文规模单一实现(缺 totalTokens 时三项相加、忽略 NaN/Infinity)、缺数据时"继续"与"无法判断"分开 + 界面依据 FINAL_NOTES(无 Markdown、不复述结论)、推迟压缩(回本 vs 官方线)、压缩次数覆盖规则的成本闸门(摘要 ≥2 但钱不支持新开时不得再劝交接;缓存真崩时仍须劝)、边界可达性守卫(网格扫描:四个档位 / 官方线分支 / 结构不变量 / 常量间结构关系)、文档侧不得给出"每轮省"暗示、分级词表、校准留痕、输出体积(附录上限不变式 + 确定性 fixture) |
test/guard-test.mjs | 13 项 | 实时思考守卫(正反例并重):正常推理 / 100 行各不相同 / 短行反复 / 同关键词多句 —— 均不得触发;同行连续 8 次 / 行内片段复读 / 窗口空转 / 真实事故形态 —— 必须抓住;enabled=false 与畸形输入安全;性能(20000 delta ≈ 80 万字符 < 2 秒) |
test/crosscheck-test.mjs | 11 项 | 多方面求证:74 个会话在热路径投影 / 冷路径日志折叠 / 离线 CLI 三条实现上逐项一致;全量不变量(score 区间、level 与阈值一致、计数非负、封顶生效、schema 与 view 可序列化);27 例畸形输入不得抛异常;性能(无 O(n²) 迹象、最大真实会话折叠 < 2 秒)。加载已部署副本,所以顺带验证"源码改了是否真的部署了" |
测试过程中抓到并修复了两个真实 bug:
wire.view 直接返回 state 会被 viewSchema 的 .strict() 拒绝
(ZodError: unrecognized_keys ["lastCallKey","callStreak"])—— 若直接挂载,DSH 每次发布都会报错
- 评分与等级阈值不匹配:100% 有效占用只判到
warn。已校准扣分力度
(≥90% → −75 → critical),并同步修正 lib/detect.mjs
✅ 已加载生效:新增/更新插件行不会热加载,必须重启 DSH(HANDOFF.md 第 2.1 节实测)。
本机已验证:dsh-node.err.log 出现 [attention-health] projection "attentionHealth" registered。
重启与自动验证用 restart-attention-health.ps1(14 项检查,结果写
<TOOLS>\DSH\临时\restart-result.txt)。
日志按 [RUN <id>] BEGIN / [RUN <id>] END 配对读:只有 BEGIN 没有 END = 这次没跑完,
紧挨着的最后一条日志就是中断位置(不再需要猜"是卡死了还是没输出")。
-VerifyOnly 可只验证当前状态、不重启(同步执行,不会掐断会话);
完整说明见 DEPLOYMENT-NOTES-20260915.md 第 21 节。
✅ 阶段 3:Web UI 提示(已实现并注册进页面)
采用了原「出路 1」:手写浏览器半产物(lib\client.js),不依赖 tsdown 构建链。
本机无源码 checkout,但宿主加载客户端 bundle 的契约很简单(window.__ModuleLoader__.load),
且所需依赖(react、@deepseek-ai/dsh-client-ui-primitives 等)全在页面基座 seed 内,
所以手写可行 —— 已实测进页面插件批次并正常渲染。
- 位置:
conversation.composer.dock(composer 卡下方常驻条目),id: attention-health,
与既有 ui-chat 的 StatsPills(id: stats)并存互不覆盖
- 渲染条件:
shouldNotify(level !== 'ok' 或最终建议不是"继续")。
早期只看 level,于是"score 92/ok 但建议交接"时整条提示条连同复制按钮一起消失
- 健康时常驻一个极轻入口(一行淡字「复制交接内容」),不再是"正常时完全不占空间" ——
用户可能在任何时候想主动交接,而不是只在被提示时
- 形态:彩色圆点 + 唯一行动建议(双维度最终结论)+ 评分(有损压缩过则附「有损压缩 N 次」,
检出思考退化时附橙色「思考异常 N 次」);
点击展开:上下文规模 / 维度A 质量(有效窗口)/ 维度B 上下文占用(声明窗口分档)/
为什么这么建议 / 三方案每轮成本 / 评分依据 / 启发式说明;
退化原因带次数(如"输出异常 2 条(符号堆砌 ×2)"、"思考异常 1 次(思考打转)")
- 为什么这么建议(2026-09-16 新增):显示 host 给出的一句话决策依据
(如"已发生 3 次有损摘要,反复压缩收益递减""退化信号优先于压缩")——
它解释的是决策逻辑,不复述占用率,避免同一信息渲染两遍
- 三方案每轮成本(2026-09-16 新增):
继续 X / 先压缩 Y(有损) / 机械交接 Z(无损),
附「压缩每轮省 S,约 N 轮回本」与缓存命中率(成本折扣的依据)。
三个数都是相对 token 当量,不是钱 —— 让"权衡"本身可见,而不是替用户下结论
- 缺数据时如实说"无法判断"(2026-09-16 边界修复):没有窗口或没有用量时,
规模显示"未知"、两个维度显示"无法判断(缺窗口或用量数据)"、成本显示"未知"。
旧版会渲染成 "0 token · 有效占用 未知" 并给出 "可继续 / 继续" 这种编造出来的结论
- 展开区底部两个按钮:复制交接内容(精简档,默认)与复制完整版
- 等级文案只描述规模(留意上下文 / 上下文偏大 / 上下文过大),不含任何行动建议;
行动建议的唯一来源是 host 的双维度结论(
compactAdviceText)
- 安装方式(2026-09-20 起):与 host 半同一个包 ——
$DSH_HOME\profiles\web\node_modules\dsh-attention-health\lib\client.js,
由包内 cordis.patch.yml 的一行 insert 同时挂载(dsh.client.platform: web +
exports["./client"] 让外壳自己把它变成浏览器 bundle;模块 id 必须等于包名)。
旧形态(单独的 attention-health-ui 包 + patch 里第二行)已退役,见「阶段 2」的说明。
另注:DSH 自带的 ContextMeter 已经在 composer 旁显示占用率,但它不在任何 slot 中、
也未从包导出,外部插件无法替换它,且它只有中性色、没有健康语义 —— 这正是本插件提示条的价值空间。
相关文档
DEPLOYMENT-NOTES-20260915.md —— 部署与排障:一键部署/重启脚本用法(第 8 节)、
两个历史故障的根因与修复、四个真实坑的记录与修法(第 9 节)
HANDOFF.md —— 完整的框架调研结论(需求、环境、技术结论、坑与红线、待验证清单)