dsh-entry-shaper
把上下文压缩从出口挪到入口:元素在被首次发送之前定型,此后永不改写 ⇒ 出网 payload 的前缀保持单调 ⇒ 不再产生断点税地缩小上下文。
状态:实验性 · 默认关闭 · 测试 82/82 · Node ≥ 20 · MIT
⚠️ 这句话的确切边界(避免过度承诺):
- "零断点税"指的是本插件自身不引入断点 —— 它不改已发送的内容、也不在 wire 上做 有状态变换。它不能消除别处产生的断点:官方自动压缩、缓存 TTL 过期、 换 provider、手工改配置都会照旧打断前缀(见§ 实测数据与 §1b)。
- 前提是确定性:纯 op 天然满足;不可证明为纯的 op(
exec/http/inline.code/注入函数) 靠内容哈希缓存兜住。若你把它关掉(cache: false)而实现又不确定,前缀就会抖动 ✗。- 当前默认只塑形推理一类,其余类别保持
keep⇒ 省幅上限由推理在 payload 中的占比决定 (实测 −21% ~ −47%:推理占比低的会话接近下限,长推理会话接近上限)。
📌 来源声明
本插件的全部源码(
index.js、测试、基准、文档与工具脚本)完全由 DeepSeek-V4.1-Flash 生成,人类只负责提出需求、做出取舍决策与验收。
角色 承担者 需求、取舍决策、验收 @MaudieHakimi 代码生成(全部源码与文档) DeepSeek-V4.1-Flash 这意味着:
- 代码里保留了大量"为什么这么做"的注释(含踩过的坑与实测数字)—— 那是生成过程中 真正得到的结论,不是事后补的说明;
- 数字都尽量给可复现的取证方式(
bench/、tools/、以及 harness 源码出处), 因为这个项目里出现过若干次"看起来对、实测否掉"的判断(见CHANGELOG.md的撤回记录);- 若发现任何事实性错误、过时描述或与代码不符的文档,请当作 bug 报告 —— 那属于需要修的东西。
实测(本机真实会话 + 真实 API,最新一次):
| 指标 | 实测值 |
|---|---|
| 出网 payload 缩减 | −46.6%(插件自证日志 proxy-shape,长会话 03/04 两天分别 −40.1% / −46.6%) |
| 缓存命中率 | 98.4% ~ 99.6%(9 天 2739 步的长会话 / 7 天 1085 步的会话) |
| 正常回合的未命中 | 1,148 ~ 4,140 token(≈ 每步新增量 ⇒ 零断点) |
| 正常回合成本 | $0.019 ~ $0.038 / 回合 |
取证方法见§ 实测数据;日志与界面永远保留原文。
目录
- 问题:出口侧的小步压缩,在命中价极低的 provider 上是净亏的
- 做法:在入口定型
- 它是什么 / 不是什么
- 官方栈已经做了什么(先读这个)
- 安装
- 配置
- 三种挂载方式
- 统一协议:一切外部塑形都走同一套 I/O 约定
- 栈护栏
- 实测数据
- 现状与限制
- 故障排查
- 文档
- 开发
- 许可
问题:出口侧的小步压缩,在命中价极低的 provider 上是净亏的
这一节只针对三种具体做法,不是泛指"压缩都不好":
| 维度 | 会亏的做法 | 不会亏的做法 |
|---|---|---|
| 改哪里 | 改已发送过的历史("出口"压缩)⇒ 打断前缀缓存 | 改尚未发送的内容("入口"定型)⇒ 前缀从未包含旧形态 |
| 一次削多少 | 每次只削一小块(自动压缩按 step 触发,常见 3–9 万 token/笔) | 一次打到底(如手动 /compact:753K → 23.7K) |
| 按什么价折算 | 省下的是命中价 token(便宜 50 倍) | 省掉的是未命中价 token(全价) |
三者同时成立才会亏 —— 缺任何一条都可能反而是赚的(下表里手动 /compact 就是反例)。
亏损的机制
DSH 的 compaction-basic 在出口改历史:把已发送的区间换成摘要检查点。每一刀都会让
改动点之后的缓存作废 ⇒ 下一次请求从该点起整份按全价重算(本文称"断点税")。
而它的收益只有"实际削掉的那部分"(按命中价计)⇒ 于是:
收益 ≈ 削掉的 token × 命中价
断点税 = 压缩后整份 payload × 未命中价
雪上加霜的是它的区间选择(packages/compaction/compaction-basic/src/region.ts:98-133):
区间头锚定 —— 从最早的表面节点起、切到"保留尾部"之前 ⇒ 检查点落在头部
⇒ 它后面的一切都作废。所以削得越少越亏。
本机实测(一个 2739 步的长会话,见 docs/OFFICIAL-STACK.md)
| 做法 | 削掉的 token | 断点税(全价重算) | 比值 | 判定 |
|---|---|---|---|---|
compaction-basic 自动压缩 109 笔合计 |
3,021,022 | 6,282,630 | 0.48 : 1 | ✗ 净亏 2 倍 |
| 其中单笔最差 | 64,891 | 547,342 | 0.12 : 1 | ✗ 亏 8 倍 |
人类手动 /compact(一次打到底) |
729,434 | 6,176 | 118 : 1 | ✓ 大赚 |
同一套代码、同一个会话,只因"一次削多少"不同,结论相反 —— 这正是上表第二行的意思。
做法:在入口定型
会话是一根只进不出的栈。元素在首次回传之前定型,前缀里就从来没出现过它的旧形态:
请求 n : [S][T][U][1'][2']…[n-1']
请求 n+1 : [S][T][U][1'][2']…[n-1'][n']
└──────── 逐字相同的前缀(全命中)────────┘ └ 新增 ┘
本插件的代理通道把这件事放在 wire 层:拿 deriveMessages() 产出的消息数组,
按类别跑 op 链,再交给上游 provider。会话与日志完全不动,只有发出去的那一份被塑形。
它是什么 / 不是什么
| ✅ 它是 | ❌ 它不是 |
|---|---|
代理 provider:llm.registerAdapter() 注册一个新路由,转调上游 |
不接管 compaction 服务(可与官方 dsh-compaction-basic、dsh-context-slimmer 共存) |
| 原文永远在 append-only 日志里;界面与审计读原文 | 不改写已发送的内容(那是"出口压缩"的领域,要付断点税) |
| 塑形逐消息确定:纯 op,或不可证明为纯的 op 走内容哈希缓存 ⇒ 同一条旧消息每次出网形态一致 ⇒ 前缀稳定 | 在代理通道上不自己发 HTTP(见下)—— 它把请求交回 llm 服务,凭据仍由服务按上游 id 解析 ⇒ 不需要新 API key |
| 失败即放行(fail-open):任何异常只写日志,请求照常发 | 日志不记录消息内容(只记字符数、策略名与计数) |
可选:exec / http / inline 三种 op 可用于"你自己接入的塑形" |
默认不做脱敏(无内置密钥/隐私识别规则;脱敏需你在 redact 槽位注入) |
⚠️ 最后两行的边界不要混淆:代理通道本身不发 HTTP(它复用 llm 服务), 但你显式配置
httpop 时它会POST到你指定的 endpoint;配exec时会起子进程。 两者都只在你配置之后才发生(详见SECURITY.md的"它会访问什么")。
官方栈已经做了什么(先读这个)
DSH 自带栈里已经有一个确定性免费剪枝器和一个摘要压缩器。 装本插件之前请先确认你要的是 本插件的空位:
| 组件 | 触发条件 | 代价 | 模型视角 |
|---|---|---|---|
tool-result-pruner(默认开) |
单条 tool result > thresholdChars(8192) |
零(纯函数、逐条独立 ⇒ 不破前缀) | 中段换成剪枝标记,保留头 4096 + 尾 1024 |
compaction-basic(默认开) |
prompt 达 thresholdRatio × 窗口(0.8),保留 retainRatio(0.16) |
一次断点(全价重算) | 老历史被 LLM 摘要取代(不可逆) |
| 本插件 | 每次出网 | 零(前缀单调) | 按类别塑形后的版本;原文仍在日志里 |
空位只有三处:① 推理内容(官方剪枝器完全不碰,而它是日志里最大宗的 payload:
本机 40,916 条 reasoning-chunks vs 20,570 条 assistant/chunk);
② 按类别的策略(官方只有一个全局字符阈值);③ 保留原文的 wire 级塑形(压缩不可逆)。
细节与出处:docs/OFFICIAL-STACK.md。
安装
# 路径换成你放本包的位置
dsh plugin --profile web add "file:D:\plugins\dsh-entry-shaper"
或手动:把目录复制进 <profile>\node_modules\,并把 dsh-entry-shaper 加进 profile
package.json 的 dependencies **与 dsh.profile.bundles**(后者决定它是否被加载;
dsh.bundle.patch 必须仍指向本包的 cordis.patch.yml,否则启动即抛错)。
配置
配置一律放在 ~/.dsh-entry-shaper.config.json,不要写进 cordis.patch.yml。
原因:patch 里含 config: 行会让市场无法热挂载(dshmarket/lib/hot.js:295),
只能重启生效;热配置文件则由本插件按 mtime 热读 ⇒ 改配置不用重启 ✓。
{
"enabled": true, // 总开关(任何类别都不动就设 false)
"shapeReasoning": true, // 是否塑形推理这一类(旧名 dropReasoning 仍兼容)
"policy": { // 按类别各给一条 op 序列(文本 → 文本 的纯函数链)
"reasoning": { "ops": [{ "op": "extract", "maxChars": 240 }] },
"toolResult": { "ops": [{ "op": "keep" }] },
"toolArgs": { "ops": [{ "op": "keep" }] },
"assistantText": { "ops": [{ "op": "keep" }] }
},
"cache": { "enabled": true, "path": "~/.dsh-entry-shaper.cache.jsonl", "maxBytes": 8388608 },
"proxy": { "enabled": true, "provider": "deepseek-shaped", "upstream": "deepseek-official" },
"opTimeoutMs": 8000,
"logPath": "~/.dsh-entry-shaper.log"
}
打开代理只需改这里(proxy.enabled: true),本插件会惰性注册该路由 —— 不必重挂载、
不必重启 ✓。随后把会话或默认 provider 换成 proxy.provider(默认 deepseek-shaped)即生效。
⚠️ 路径里不要写
~。配置文件里的路径是原样使用的(没有 shell 展开), 写"logPath": "~/.dsh-entry-shaper.log"会在当前目录下建一个名叫~的目录 ✗。 省略这些键(推荐,默认值已经是正确的绝对路径),或写绝对路径 (如"D:/logs/shaper.log")。
免重启的三个开关
| 想做什么 | 怎么做 | 生效 |
|---|---|---|
| 全部停手(原样转发) | 建空文件 ~/.dsh-entry-shaper.OFF |
立刻,不用重启 |
| 只停某一类 | 把该类别的 ops 改成 [{ "op": "keep" }] |
下一次请求 |
| 完全卸载 | 市场里关掉插件(或从 dsh.profile.bundles 移除) |
立刻(热挂载) |
⚠️ 改配置会改变前缀形态 ⇒ 下一次请求整份按全价重算一次(约 $0.2)。 所以请一次定好,别频繁来回调。
三种挂载方式
| 方式 | 命令 / 操作 | 需要重启吗 | 说明 |
|---|---|---|---|
| profile bundle | dsh plugin --profile web add "file:…" 或手改 profile package.json |
需要 | 最稳,随 profile 长期加载 |
| 市场热挂载 | 在市场插件页点开关 | 不需要 | 要求 patch 是纯 insert(本包已满足);窗口期不影响已有会话 |
| 直接 mount | 手工在一个 cordis 上下文里 ctx.plugin(module) |
不需要 | 用于实验 |
热挂载的两条硬限制(实测):
- 不刷新模块缓存 —— 重挂载只新建实例,代码改动仍需重启;
- 热挂载条目的 id 是
mkt-<原id>,与 patch 层的- id: <原id>不是同一个条目 ⇒ patch 层的disabled: true管不住热挂载实例。
统一协议:一切外部塑形都走同一套 I/O 约定
op 名只表示"传输方式",不表示"这是 .py / 这是 LLM"。任何语言、任何命令、任何 OpenAI 兼容端点,都通过同一套信封与返回规则接入 —— 这是本插件唯一的扩展接口。
传输(transport)
┌──────────────────┬──────────────────┬─────────────────┐
exec │ 本地进程 │ http │ 任何 HTTP │ inline │ 进程内 │
│ python/node/pwsh │ │ 端点 │ │ 纯算法 │
│ /你的 exe │ │ │ │ │
└──────────────────┴──────────────────┴─────────────────┘
↕ 数据格式(protocol)
json │ text │ lines │ argv (exec 用;http 用 json/text)
输入信封(每次调用必送)
{ "text": "<待塑形文本>", "kind": "reasoning|toolResult|toolArgs|assistantText",
"meta": { }, "op": "exec", "protocol": 1 }
protocol: 1 是协议版本号 —— 你的脚本可以据此判断该怎么解析。
返回值(三种形态都接受,按序判定)
| # | 你输出什么 | 插件怎么理解 |
|---|---|---|
| 1 | JSON 对象含字符串 text |
用这个 text |
| 2 | JSON 对象含 "text": null |
整块删除 |
| 3 | 其它任何字符串 | 取原文(trim 后) |
失败只跳过该 op(退出码≠0 / 超时 / 输出空 / 抛错)⇒ 保留上一步结果。
exec:任何命令
{ "op": "exec", "command": "python", "script": "D:/shaper/mine.py" }
{ "op": "exec", "command": "node", "args": ["D:/shaper/mine.mjs"] }
{ "op": "exec", "command": "pwsh", "args": ["-File", "D:/shaper/mine.ps1"] }
{ "op": "exec", "command": "D:/tools/shaper.exe", "args": ["--mode", "fast"] }
| 参数 | 默认 | 说明 |
|---|---|---|
command |
必填 | 可执行文件(python / node / pwsh / 你的 exe) |
script |
— | 便捷写法:作为第一个参数。args 里的 {} 会被替换成它 |
args |
[] |
参数数组;{text} 会被替换成待塑形文本(仅 argv 协议需要) |
protocol |
json |
输入格式,见下 |
cwd / env |
— | 工作目录 / 追加的环境变量(与 process.env 合并) |
timeoutMs |
全局值 | 超时即 kill 并跳过 |
protocol |
stdin 收到 | 适合 |
|---|---|---|
json |
一行信封 JSON | 新写的脚本(推荐) |
text |
纯文本 | 只想要原文的现成工具 |
lines |
第一行 = kind,其余 = 文本 |
shell 友好的轻量写法 |
argv |
不写 stdin,文本作为最后一个命令行参数 | 不接受 stdin 的现成程序(注意命令行长度上限) |
⚠️ Windows 上的 Python 中文乱码:Python 往管道写非 ASCII 时默认用系统代码页 (GBK/cp936),插件按 utf8 解码 ⇒ 中文变
????。插件已替python自动设好PYTHONIOENCODING=utf-8(你在env里给同名键即可覆盖),但脚本里自己写一句sys.stdout.reconfigure(encoding="utf-8")更稳妥。注意python -X utf8不能解决 这个问题(那条只管源码与文件,不管管道)。
http:任何 OpenAI 兼容端点
// 标准 OpenAI 形状:system 提示词 + 待塑形文本作为 user 消息
{ "op": "http", "baseURL": "https://api.deepseek.com", "model": "deepseek-flash",
"system": "压成 3 行要点,保留路径与数字。", "maxTokens": 200 }
// 追加一条微调指令(作为第二条 system 消息)
{ "op": "http", "url": "https://gateway/v1/chat/completions", "system": "…", "instruction": "再简短些" }
// 非 OpenAI 形状的服务:body 整包覆盖({{text}} 会被替换成待塑形文本)
{ "op": "http", "url": "https://my.example/shaper", "body": { "input": "{{text}}", "mode": "fast" } }
| 参数 | 默认 | 说明 |
|---|---|---|
url |
— | 完整端点(给了它就原样使用) |
baseURL |
ctx.llm.baseURL → DeepSeek 官方 |
自动补 /chat/completions |
system |
内置中文提示词 | 系统提示词(旧名 prompt 仍可用) |
instruction |
— | 追加的第二条 system 消息(微调用) |
apiKey |
ctx.llm.apiKey → DEEPSEEK_API_KEY |
Bearer 令牌 |
model / maxTokens / temperature / headers / method |
— | 常规请求控制 |
body |
— | 整包覆盖请求体(接非 OpenAI 形状时用) |
protocol |
json |
text ⇒ 响应体当纯文本 |
响应解析(json 协议)按序尝试:choices[0].message.content → text → output → content
→ data → 响应原文。所以大多数兼容服务不用改配置就能用。
inline:进程内算法(不必写脚本、不必起进程)
{ "op": "inline", "use": "firstLine", "maxLines": 3 } // 只留前 3 行
{ "op": "inline", "use": "jsonField", "field": "a.b.c" } // 从 JSON 里取字段
{ "op": "inline", "use": "regex", "pattern": "id=(\\d+)", "template": "#$1" }
{ "op": "inline", "use": "code", "code": "return text.split('\\n').slice(-5).join('\\n')" }
use: "code" 的 code 是函数体源码字符串(可用 text / op / ctx,return 结果)
⇒ 能写进热配置文件 ⇒ 改算法不用重启 ✓。它无法被证明是纯函数,因此总是走缓存。
纯 op:不改结构,只挑内容
| op | 参数 | 说明 |
|---|---|---|
keep |
— | 原样(默认) |
drop |
— | 整块删除(推理块例外:内核退回短桩,见下) |
stub |
— | 用 stubTemplate,默认 [reasoning omitted: {chars} chars] |
extract |
maxChars(240)marker(true) |
只留决策句;marker:false 去掉省略前缀 |
head / tail |
chars(512) |
保留前 / 后 N 字符 |
truncate |
maxChars(512) |
首尾各半 + …(省略 N 字符)… |
每个 op 都可以带 timeoutMs(覆盖超时)与 cache: false(非纯 op 别关)。
复制即用的配置片段
// ① 默认:推理只留决策句(最省,也最有损)
"reasoning": { "ops": [{ "op": "extract", "maxChars": 240 }] }
// ② 保守:保留推理的真实开头(不按句式挑,模型更容易接上)
"reasoning": { "ops": [{ "op": "head", "chars": 800 }] }
// ③ 两道闸:先抽取再兜底截断
"reasoning": { "ops": [{ "op": "extract", "maxChars": 400 }, { "op": "truncate", "maxChars": 300 }] }
// ④ 完全不动推理
"reasoning": { "ops": [{ "op": "keep" }] }
// ⑤ 工具结果只留首尾(注意官方 tool-result-pruner 已在做,别重复)
"toolResult": { "ops": [{ "op": "truncate", "maxChars": 1024 }] }
// ⑥ 你自己的脚本(任何语言)
"reasoning": { "ops": [{ "op": "exec", "command": "python", "script": "D:/shaper/mine.py" }] }
// ⑦ 让 LLM 来压
"reasoning": { "ops": [{ "op": "http", "system": "压成 3 行要点,保留路径与数字。", "maxTokens": 200 }] }
// ⑧ 进程内自定义算法(免重启)
"reasoning": { "ops": [{ "op": "inline", "use": "code", "code": "return text.split('\\n').slice(-5).join('\\n')" }] }
旧名(仍可用,但不建议新写)
python 是 exec 的别名(预填 command: "python"),summarize 是 http 的别名
(prompt 即 system)。两者走同一套实现 ⇒ 缓存与失败语义完全一致。
它们存在的唯一理由是兼容旧配置;新配置请直接用 exec / http。
op 可任意链式组合,可以是 async,统一受 opTimeoutMs(默认 8s)约束:超时或抛错
⇒ 只跳过该 op、保留上一步结果(fail-open)—— 最坏情况是"没省到",绝不会挂住会话。
未知 op 名同样被跳过(拼错名字不会让整条链失效)。
链式组合:七条性质(全部实测)
线性可组合性的意思是:每个 op 都是 文本 → 文本(或 → null),
前一个的返回值就是后一个的入参,而 op 的来源完全无关。内核里只有一个载体 ——
applyOps 的局部变量 out:
let out = text
for (const op of ops) {
const produced = await withTimeout(fn(out, op, ctx), op.timeoutMs ?? ctx.opTimeoutMs ?? 8000)
out = produced
if (out === null || out === undefined) return null // ① 立即终止整条链
}
return out
| # | 性质 | 实测 |
|---|---|---|
| 1 | 线性:前一个的输出即后一个的输入 | 'a-b-c' 经 [replace(-→+), toUpperCase, replace(+→|)] ⇒ "A|B+C" |
| 2 | 异构混排:纯函数 / 本地进程 / HTTP / 进程内 可插在任何位置 | [head, exec(python), http, inline.regex, truncate] ⇒ "原始文本[llm]尾巴" |
| 3 | 逐 op fail-open:一个坏掉不毁整条链 | [A, exec(不存在的命令), AB, ABC] ⇒ "ABC"(日志:op exec 失败,跳过) |
| 4 | 逐 op 超时:慢 op 被跳过,其余照常 | [http(挂起, timeoutMs:50), inline] ⇒ 后续 op 的结果正常返回 |
| 5 | 提前终止:返回 null ⇒ 整条链立即结束 |
[stub, drop, 不应出现] ⇒ null(第三个 op 根本没执行) |
| 6 | 逐 op 缓存:纯 op 不缓存,其余各自缓存 | 同一输入跑两次,结果逐字节相同且真实 HTTP 只调用 1 次 ⇒ 这就是前缀稳定的根据 |
| 7 | 逐 op 覆盖:同 op 名可在链上多次出现、各带参数 | [head(6), tail(3), truncate(30)] 作用于 一二三四五六七八九十 ⇒ "四五六" |
timeoutMs / cache 也可以每个 op 单独写。
最实用的一条结论:换实现不动架构。 同一个位置可以是纯函数、子进程、本地 LLM 或云端 ——
policy 里只改那一个对象。例如把默认的决策句抽取升级为"本地 LLM 语义压缩":
// 之前
"reasoning": { "ops": [{ "op": "extract", "maxChars": 240 }] }
// 之后(其余一切照旧:缓存、超时、fail-open、代理链路)
"reasoning": { "ops": [{ "op": "http", "baseURL": "http://127.0.0.1:11434/v1",
"model": "qwen2.5:7b", "system": "压成 3 行要点。", "timeoutMs": 3000 }] }
⚠️ 两个实测发现的细节:
inline.regex默认只替换第一处,要全局替换需显式加"flags": "g";- 直接调用导出的
applyOps(不经apply())时,inline.code需要你自备ctx.compileInline(插件运行时由apply()提供)。
上面七条可以自己跑一遍复现:node examples/compose-demo.mjs(会真实启动一个 python
子进程;无法启动时 fail-open 跳过该 op,其余性质照常验证)。
接自己的算法:三条路怎么选
| 方式 | 写法 | 能热改吗 | 适合 |
|---|---|---|---|
| 组合内置 op | [{ "op": "extract" }, { "op": "truncate" }] |
✅ | 大多数场景,先用这个 |
exec 外部脚本 |
任何语言,读 stdin / 写 stdout | ✅ | 任意算法;可单独调试 |
inline.code |
函数体源码写在配置里 | ✅ | 不想起进程的小逻辑 |
config.ops 注入函数 |
patch 里写 config.ops |
❌ 需重启 | 需要进程内状态/依赖时 |
⚠️ 注入函数会牺牲热挂载:patch 出现
config:行 ⇒dshmarket/lib/hot.js:295判定为"含配置行"⇒ 本插件失去热挂载能力。同一件事能用前三条做就别用第四条。ctx.llm/ctx.redact/ctx.fetch/ctx.spawn同理(函数与凭据写不进 JSON)。⚠️ 自定义 op 也会进缓存:无法判断你的实现是否纯,而"结果漂移 ⇒ 前缀改变 ⇒ 付断点税" 比"多占几 KB 磁盘"贵得多。不同实现请用不同的 op 名字 —— 缓存键里只有名字与参数。
完整配置文件见 examples/config.full.json,
接入自定义算法的详解见 examples/custom-op.md。
非纯 op 必须缓存(前缀稳定性的前提)
python / summarize 不保证可复现(temperature 0 也不保证:批处理、MoE 路由、服务端版本、
并发都可能变)。同一条旧消息两次出网算出不同结果 ⇒ 前缀改变 ⇒ 付断点税。
所以非纯 op 的结果按 sha256(配置指纹 + op 参数 + 输入文本) 缓存:
| 性质 | 行为 |
|---|---|
| 改 prompt / model / 端点 | 键变化 ⇒ 自动失效 ✓ |
| 重启 DSH | 仍命中 ✓(启动时把 JSONL 读进内存)⇒ 重启不打断前缀 |
| 存了什么 | 只存摘要,不存原文 ✓ |
一条硬约束(踩过)
推理块永远不会被整块删除。 thinking 模式下,当报文以 role:'tool' 结果结尾时缺少
reasoning_content 会被 API 拒绝:
400 The `reasoning_content` in the thinking mode must be passed back to the API.
换成短桩则 200 ⇒ API 要的是字段在,不是内容全。所以内核在链把它删空时会退回短桩
(test/shaping.test.mjs 有对应用例)。
栈护栏
| # | 护栏 | 实现 |
|---|---|---|
| 1 | 只处理原始追加事件 | surfaceOp !== 'append' 一律跳过 |
| 2 | 替换范围恰为自己 | { op: 'replace', start: seq, end: seq } ⇒ 从不触碰更早的 seq |
| 3 | 元素若已不在当前表面(被别人替换/被压缩覆盖)⇒ 放弃 | session.surface.nodes 成员检查 |
| 4 | 异步 op 超时即跳过,并量"追加 → 定型"延迟 | opTimeoutMs + lagWarnMs |
代理通道不依赖 1–4(它不碰会话),但继承"确定性"这一条:纯 op 天然确定, 非纯 op 靠缓存兜住 ⇒ 同一输入永远同一形态。
实测数据
全部为本机真实会话 / 真实 API 的实测。
1. wire 级塑形:−40.1%,缓存命中 99.3%
用 dsh-llm-tap 抓到的真实出网请求,把外层(deepseek-shaped,塑形前)与
内层(deepseek-official,真正出网)按 callId 配对(messageCount 完全相同 ⇒ 同一次调用):
| 字符数 | |
|---|---|
| 外层(塑形前) | 12,814,907 |
| 内层(塑形后) | 7,678,485 |
| 省 | 5,136,422(40.1%),逐对 39.6%–40.4% |
同一会话的 assistant/message.usage:命中 99.3%(如 hit 191,872 / miss 1,270),
且命中量每步按上一步新增量平稳递增 ⇒ 前缀在延长、没有被重建 ✓
折成钱:省下的是命中价 token($0.02/M)⇒ 按 193K token/步 估算约 $0.0015/步, 300 步的长会话约 $0.46(≈3 元)。不大,但是白拿的 —— 而且与轨迹长度成正比。
对照:一次断点付的是整份 payload 全价($1/M)。所以"省 40% 的命中价"和"招来一次断点" 完全不是一个量级 —— 这也是为什么前缀单调比"省得多"更重要。
1b. 成本结构实测:钱到底花在哪(2026-09 多会话审计)
这部分与本插件无关,但决定了它值不值 —— 实测自本机 9 天/2739 步(759,991,919 输入) 与 7 天/1085 步(380,897,683 输入)两个长会话:
| 事实 | 实测 |
|---|---|
| 成本 ≈ 步数 × payload | 单回合 18 步 × 峰值 payload 507,255 = 输入 9,061,476($0.67);其余回合同样吻合 |
| 未命中只占输入 ~1.6%,却占 ~57% 的钱 | 命中价 / 未命中价 = $0.02 / $1(相差 50 倍) |
| 最大单项支出是"断点税" | 2739 步会话:182 次断点、断点税占未命中的 87%($10.59 / 总 $27.08) |
| 断点主因是"隔太久回来"(缓存 TTL) | 间隔 1060 min → miss 497,394;间隔 7158 min → miss 266,259;而间隔 0.1 min 的步 miss 仅 ~500–1,400 |
间隔 vs 平均命中率(同一会话 2694 步全样本,实测):
| 距上一步 | 样本 | 平均命中率 |
|---|---|---|
<1 min |
2501 | 96.7% |
1–3 min |
118 | 94.6% |
3–6 min |
46 | 90.0% |
6–15 min |
13 | 66.2% |
>15 min |
17 | 63.2% |
⇒ "隔一会儿回来"的罚金 = 整份 payload 全价,而这正是本插件能间接帮上忙的地方: payload 减半 ⇒ 同样一次隔夜回来的罚金也同比减少约 46% ✓。
另外两个与插件无关、但会瞬间把账单打穿的放大源(实测踩过):
| 放大源 | 机制 | 实测 |
|---|---|---|
搜索 web_search |
一次请求 = 服务端最多 5 个模型回合(max_uses 默认 5),结果轮间累积;这些轮次的 usage 不进会话日志 ⇒ 本地账本里是零 |
9 个会话 7 分钟内共 221 次搜索,平台账单比本地日志多 ≈ $5 |
| 子代理递归扇出 | 主会话开子代理 → 子代理又各自开子代理,前缀完全不共享(缓存零复用) | 1 → 3 → 9 个并行会话,全部挤在 7 分钟内 |
这两条的启示:本插件不会造成任何放大(它只在出网前做纯函数变换、不发请求), 但它省的量级也远小于"一次搜索风暴" —— 排障时先看这两项,再看压缩/缓存。
2. 离线基准(可复现)
固定轨迹重放:真实会话里同一轮内连续 12 次工具调用当剧本,两臂逐轮发前缀请求。
| 轮次 | payload 字符(full → stub) | 省 |
|---|---|---|
| 1 | 3,809 → 3,204 | 15.9% |
| 4 | 33,769 → 21,484 | 36.4% |
| 8 | 76,720 → 36,938 | 51.9% |
| 12 | 100,893 → 51,617 | 48.8% |
命令:node bench/replay.mjs <session.jsonl.zstd> --dry
3. 行为分歧(真实历史,同一前缀发两次)
| 档位 | 纯桩 stub |
决策抽取 extract |
|---|---|---|
| 动作类型(工具 vs 直接答复) | 12/17(71%) | 15/17(88%) |
| 同名工具 | 2/17(12%) | 6/17(35%) |
| 完全一致 | 0/17 | 0/17 |
⇒ extract 每档都 ≥ stub,体积只多 1.8 个百分点 ⇒ extract 支配 stub(默认值)。
N=17 的配对样本不足以做强断言。
4. agentic 基准(多轮工具调用 + 确定性判分)
8 个短任务 + 3 个长轨迹任务(初始文件 + 提示 + 检查器):
| 臂 | 成功率 | 轮数 | 输入 token |
|---|---|---|---|
full(短任务 8 个) |
8/8 | 35 | 31,580 |
stub(短任务 8 个) |
8/8 | 37 | 33,322 |
full(长轨迹 3 个) |
3/3 | 21 | 55,092 |
stub(长轨迹 3 个) |
3/3 | 21 | 49,074 |
质量未观察到退化;成本列不可信(两臂会跑出不同的命令与输出,未命中被"这轮干了什么" 污染)⇒ 成本请看固定轨迹重放。
⚠️ 在线成本 A/B 不可信:曾跑出"−77%",但反序重跑后另一臂得到完全相同的数字 ⇒ 那是"谁第二个跑谁几乎全命中"的缓存复用假象,已撤回。
现状与限制
- 默认关闭:本包默认
enabled: false,需要你确认可接受"模型只看得到推理精要"这一点; - 推理是有损的:
extract只留决策句(默认 240 字符预算),探索与试错过程不进 payload ⇒ 模型可能在长轨迹上重复劳动。界面与日志永远保留原文,所以审计不受影响; 觉得太紧就换head(保留真实开头)或放宽maxChars(代价:省幅下降); - 默认只塑形推理:
toolResult/toolArgs/assistantText默认keep。 官方tool-result-pruner已在做工具结果剪枝,先别重复造轮子; - 任务偏短:agentic 基准的任务规模仍小于真实长会话,长轨迹任务(15–30 轮 + 大体量 工具输出)还在补;
- 基准边界:行为分歧测的是"是否被改变",不是"是否更好"。
故障排查
| 现象 | 原因 / 处理 |
|---|---|
| 模型选择器里看不到代理 | ① 之前踩过:listModels() 未把 provider 改盖成本路由 id ⇒ 整组被判 INVALID_CATALOG 而不显示(已修 0.9.3);② 重启后的短暂窗口:ctx.get('llm') 在 apply() 时尚未就绪,提供方要等注册成功才出现(1.0.1 起启动期主动重试,日志有 proxy 等待 llm 服务就绪 / WARN …仍未注册成功)。自查:POST /api/llm.models 看 failures;选择器目录按会话缓存,刷新页面再看 |
日志有 proxy 注册失败:adapter.xxx is not a function |
适配器缺运行时要求的成员。运行时调用 providerInfo / providerRetryPolicy / listModels / resolveModel / prepareCall / stream 全部(packages/llm/llm/src/index.ts)。已修(0.9.2) |
| 热挂载后行为没变 | 热挂载不刷新模块缓存 ⇒ 代码改动必须重启;只有配置与开关能热改 |
| 看得到省了多少吗 | 日志里 proxy-shape #n chars=A→B(累计 …)(前 3 次 + 之后每 20 次一行) |
| 想验证出网内容 | 装 dsh-llm-tap 看 ~/.dsh-llm-tap/llm-tap.jsonl,或读本插件日志的字符统计 |
| 账单突然打穿 | 先怀疑搜索与子代理扇出,不是本插件:web_search 一次请求 = 服务端最多 5 个模型回合(max_uses 默认 5)且这些轮次的 usage 不进会话日志;子代理又会各自开子代理 ⇒ 并行前缀不共享、缓存零复用。见§ 1b |
| 突然行为异常 | 建 ~/.dsh-entry-shaper.OFF 立停(原样转发),再排查 |
文档
| 文档 | 内容 |
|---|---|
docs/OFFICIAL-STACK.md |
官方三层(pruner / compaction / command-compact)的位置与阈值、一次 /compact 的实测账单、本插件的空位 |
docs/PIPELINE.md |
工作管道:总图、时序(含 harness 源码出处)、元素生命周期、降级路径、验收观测点 |
docs/PROCESSING.md |
处理层规范:统一协议、op 目录、里程碑与被否掉的方案 |
examples/custom-op.md |
接入自定义算法的五条路径 + 函数契约 + 热挂载取舍表 |
tools/README.md |
会话体检与排障脚本(11 个,只读、零依赖):命中率/断点/成本/搜索与子代理扇出定位 |
CHANGELOG.md |
变更记录(含被基准否掉的方案与踩过的坑) |
SECURITY.md |
边界与失败模式(含"本插件不做脱敏"这一明确边界) |
CONTRIBUTING.md |
改动前必读(6 条硬规则 + 发版前验证清单) |
开发
node test/run-all.mjs # 单测(81 例,无需 DSH 运行时)
node test/shaping.test.mjs # 同上(直接跑,沙箱友好)
node bench/replay.mjs "<session.jsonl.zstd>" --dry # 固定轨迹重放
node bench/divergence.mjs "<session.jsonl.zstd>" --cases=20 # 行为分歧
node bench/agentic/runner.mjs --max-rounds=20 # agentic 基准(需真实 shell)
node --test test/在受限沙箱下会因spawn EPERM(运行器要管道)失败 —— 那是沙箱限制,不是测试问题;本地用node test/shaping.test.mjs,CI 用node --test。 基准与检查器需要能捕获子进程输出的真实 shell;可用DSH_BENCH_SHELL指定。同理:
execop 在受限沙箱下也会遇到spawn EPERM(fail-open 会跳过该 op, 表现为"没省到"而不是报错)—— 那是沙箱限制,不是插件问题。
体检自己的会话(只读、零依赖)
node tools/session-health.mjs "$env:USERPROFILE\.dsh\sessions\--D-DSH~0020Desktop--\<会话>\session.jsonl.zstd"
node tools/recount-miss.mjs "$env:USERPROFILE\.dsh\sessions" # 多时间口径 + 逐小时
node tools/today-turns.mjs "$env:USERPROFILE\.dsh\sessions" # 按回合看成本
node tools/today-tools.mjs "$env:USERPROFILE\.dsh\sessions" # 哪个工具把上下文撑大
全部用途见 tools/README.md。账单异常时先跑这几个,
再怀疑本插件 —— 实测最可能的原因是 web_search(服务端多回合、usage 不进日志)
与子代理递归扇出,两者量级都远大于本插件省下的量。
许可
MIT,见 LICENSE。
No comments yet. Be the first to write one.