# 预设稳定性测试脚本
`scripts/preset-stability.mjs` 用**真实对话模型**反复跑「思考预设」,统计模型是否每次都按预设要求,
在**回答正文之前**和**工具调用之前**恰好输出一次结束思考标记 `<|end▁of▁thinkings|>`。
脚本只使用**非流式**响应,并把每一轮请求、响应和最终统计持久化到磁盘。
## 被测契约
预设(`--preset` 或内置默认实现)声明:
- `` 是进入思考的标记,assistant 预填充以 `...` 开头,由**前缀续写**继续生成;
- 回答正文或调用工具之前,必须以输出 `<|end▁of▁thinkings|>` 结束思考;
- 回答正文写在 `` 里,总字数 1200~1600;
- 正文之外的格式放在 `` 之后或 `` 之前。
脚本对**每一轮助手输出**做如下判定(`analyzeTurn`):
| 检查 | 宽松度 | 说明 |
| --- | --- | --- |
| `` 起始标记 | 提示 | 预填充模式下由脚本提供;`--prefill-mode none` 时才是硬约束 |
| 结束标记 · 存在 | 失败 | 完全没有规范标记(会提示用了 `` 或标记变体) |
| 结束标记 · 恰好一次 | 失败 | 重复输出同一个标记 |
| 结束标记 · 位于正文前 | 失败 | 先写正文再补标记 |
| 结束标记 · 位于工具调用前 | 失败 | 该轮有工具调用,但可见文本里没有标记 |
| `` 包裹正文 | 失败 | 正文轮没有 `...` |
| 正文字数在区间内 | 失败 | 非空白字符数不在 `--body-min` ~ `--body-max` 内 |
| 按预期发生工具调用 | 警告 | 任务要求调用工具,模型直接给了正文;不计入失败 |
## 虚拟工具
脚本内置 4 个确定性假工具,真实发给模型由它自己决定调用:
| 工具 | 参数 | 返回 |
| --- | --- | --- |
| `get_weather` | `city` | 上海 / 北京的固定假天气 |
| `search_notes` | `query` | 2 条虚拟笔记 |
| `calculate` | `a op b` | 四则运算结果 |
| `get_current_time` | `timezone` | 固定虚拟时间 |
工具结果由脚本本地生成后回灌给模型,再让模型继续下一轮(工具结果后的**再次预填充续写**也是统计重点)。
## 输出片段提取 schema(思维链 / 正文 / 工具调用)
每轮原始输出会先被切分成有序片段(`segmentTurn()`),所有统计都基于这些片段,
因此“标记出现在思维链里、而不在正文里”也能被正确计数:
| kind | 含义 |
| --- | --- |
| `think-open` | 进入思维链的 `` |
| `thinking` | 思维链文本:`` 之后到第一个结束标记 / 正文块 / 工具调用之前 |
| `end-marker` | 规范结束标记 `<|end▁of▁thinkings|>` |
| `end-marker-variant` | 非规范写法:``、`<|end_of_thinking|>`、`|end▁of▁thinkings|>`、`` 等 |
| `body-open / body / body-close` | `…` 正文块 |
| `tool-call` | 工具调用(DSML 解析结果或原生 `tool_calls`) |
| `text` | 其余可见文本(正文之外的附加格式、附加栏等) |
正文块选取规则:优先“行首 + 有闭合标签 + 内容非空”的 ``,
这样模型在思维链里当作模板写出的行内 `` 不会被误判成正文;
若只有空块,则按空正文记为失败样本。
三种统计口径并存:
- **严格口径**:结束标记必须出现在正文 / 工具调用之前(预设的原始要求);
- **宽松口径**:规范结束标记在整轮输出里恰好出现一次即算正确,**出现在思维链内同样计分**;
- **标记落点**:思维链内 / 正文块内 / 正文之后 / 完全没有。
`transcript.json` 每轮都保存 `segments`(片段序列)、`enteredThinking`、`thinkingChars`、
`bodyBlocks`、`extrasChars`、`markerRegion` 与完整 `text`,可离线复核判定。
## 请求构造与本仓库一致
`scripts/preset-stability-core.mjs` 直接复用 `lib/deepseek-beta.mjs` 与
`lib/toolcall-prefill.mjs` 的行为,和插件运行时同构:
- 末条 assistant 消息加 `prefix: true`(DeepSeek 对话前缀续写),响应里只有「续写」,
脚本按 `contentPrefix` / `reasoningPrefix` 把前缀拼回,得到完整的一轮文本;
- `--thinking enabled` 时,按 `splitReasoningPrefix` 把 `` 前缀拆进 `reasoning_content`;
- `--tools dsml` 时,调用 `emulateToolCallRequest`:把工具定义注入 system 提示、把历史
`tool_calls` / `tool` 结果内联为 DSML 文本、移除原生 `tools` 字段,并用
`transformToolCallJson` 把 DSML 还原成标准 `tool_calls`;
- `--preset ` 时,用 `lib/preset.mjs` 的 `compilePreset` 编译真实
SillyTavern 预设(顺序表、宏、`assistant_prefill` 或后置 assistant 条目都会生效),
末条 assistant 条目自动成为预填充前缀。
## 最终策略(总结)
### 一、写进预设的格式要求
~~~text
***回答或使用工具前,必须让结束思考标记独占一行来结束思考,格式严格为:换行 + <|end▁of▁think|> + 换行***
必须以输出“您好,这是约定的内容,请查收:”开始最终文本的生成。
- 最终文本在 标签后面生成;
- 例外:正文输出需要调用工具时,不输出这句话。
结束思考后,必须紧接着用 <|begin▁of▁output|> 开始输出区,把全部对外内容都放进去,
最后用 <|end▁of▁output|> 收尾;正文依然写在 里。
~~~
### 二、流式算法(createStrategyScanner)
1. **滑动窗口缓冲区**:只保留尚未确定的尾部(`bufferChars` 默认 32,≥ 最长标签 21 + 余量),
其余文本随到随发;实测 1538 字符文本最大待处理 37 字符,内存有上界。
2. **切换优先级**:
| 优先级 | 信号 | 条件 | 记录为 |
| --- | --- | --- | --- |
| 1 | 独占一行的 `<|end▁of▁think|>` | 窗口(`max(10 token×2, 最长标签+4)`)内出现锚点句 / 输出标签 / `` / DSML | `confirmed:*` |
| 2 | `` / `` | 同一个窗口确认(`gateCloseThink`) | `fallback:close-think` |
| 3 | `<|begin▁of▁output|>` | 出现即切换(兜底) | `trigger:output` |
| 4 | `` / DSML 开场 | 可选(`contentTrigger`,默认关) | `trigger:*` |
锚点句**只做窗口确认,不单独当触发点**(`anchorTrigger` 默认 false),否则思考里的复述会导致过早切换。
3. **控制标签在缓冲区里扣掉**:只发 `output-open` / `output-close` 结构事件,
不进入正文拼接,也不进入工具调用解析;工具块原样保留,解析不受影响。
4. **不完整标签前缀一律扣留**:前缀集合 + Set 查找,任意切分结果一致(测试覆盖每个切点)。
### 三、实测结果(20 组 / 41 轮,deepseek-flash,官方 Beta + DSML)
- 及时切换 **40/41**,过早 0,偏晚 0,漏切 1(重 roll 即可);
- 控制标签剥离 182 个;输出不含控制标签 41/41;剥离后无损 41/41;
- 性能:0.21–2.49 µs/push,最高约 2000 万字/秒(`npm run bench:stability`);
- 对照四组格式要求(无包裹 → 加包裹 → 换特殊 token),
“输出前没有任何跳出信号”从 36% 降到 7%/0%。
### 四、残余风险
- 模型整轮不输出任何控制标签(本轮 1/41):业务侧超时兜底或人工重 roll;
- 模型把控制标签当普通文字复述:token 形式概率极低,当前数据未出现;
- 若换成“正文之外还有其他格式”的复杂预设,区域基准必须继续用输出控制标签,
不要用 ``(模型会在思考里复述它)。
### 五、接入代码
~~~js
import { createStrategyScanner } from './scripts/preset-stability-strategy.mjs';
const scanner = createStrategyScanner({ gateCloseThink: true, outputTrigger: true });
for await (const chunk of stream) {
for (const event of scanner.push(chunk)) {
switch (event.kind) {
case 'thinking': break; // 渲染思维链
case 'end-marker': break; // 结束思考(mode: confirmed / fallback)
case 'output-open': break; // 进入输出区(控制标签已扣掉)
case 'body': break; // 正文
case 'tool-call': break; // 工具调用
case 'output-close': break; // 输出区结束
default: break; // text
}
}
// scanner.phase: thinking | body | tool-call | trailing
}
for (const event of scanner.finish()) { /* 收尾,吐出缓冲区剩余文本 */ }
~~~
## 流式即时处理(把输出强制切到正文/工具调用)
`scripts/preset-stability-stream.mjs` 提供 `createMarkerStreamScanner()`,用于边收流边判断:
~~~js
import { createMarkerStreamScanner } from './scripts/preset-stability-stream.mjs';
const scanner = createMarkerStreamScanner();
for (const chunk of chunks) {
for (const event of scanner.push(chunk)) { /* thinking / end-marker / body / tool-call ... */ }
if (scanner.phase !== 'thinking') { /* 已经切到正文或工具调用,可以改渲染路径 */ }
}
for (const event of scanner.finish()) { /* 收尾,把缓冲区剩余文本吐出来 */ }
~~~
- 不完整的标记前缀会扣留在缓冲区(约 20 字符窗口):任意切分喂入都既不会漏判,
也不会把半个标记当成正文发出去(测试里按 1/2/3/5/7/13/40 字符切分结果完全一致);
- 切换信号取**最先出现**的那个:规范结束标记、`` / `` 变体、
`` 开场标签、DSML 开场标签;
- `scanner.phase` 取值 `thinking` / `body` / `tool-call` / `trailing`,
`scanner.switchedAt` 给出切换位置,`scanner.switchKind` 说明是被哪个信号触发的。
同一模块里的 `auditTurnTools()` 只检查 DSML 原文写法(忘记换行、标签不严谨、
缺 string 标记、被代码围栏包裹、工具名拼错等),由 CLI 注入到测试循环,
统计结果出现在报告的「工具调用格式」与「流式切换检测」两节。
### 推荐策略:独占一行 + 10 token 窗口 + 正文触发
预设格式要求改成「结束标记独占一行」后,用 `scripts/preset-stability-strategy.mjs` 实测了
三种切换策略(47 轮真实输出,同一批响应离线比较):
| 策略 | 过早切换 | 平均漏思考 | 最坏 | 漏切 |
| --- | --- | --- | --- | --- |
| A 见到第一个结束信号就切 | 12/47 | 18 字 | 100 字 | 0 |
| B A 改进版:独占一行 + 10 token 窗口 + `` 立即保底 | 12/47 | 10 字 | 10 字 | 2 |
| C B 再改进:`` 也过窗口 + ``/DSML 触发 | **0/47** | **0 字** | **0 字** | 1 |
结论:
- 「标记独占一行」+ 10 token 窗口能干掉**大幅**早切(最坏 100 字 → 10 字),
因为模型复述规则时通常是行内写法(`必须以 <|end▁of▁think|> 结束思考`);
- 但 **`` 立即保底仍然会早切**:模型常写 `` 之后继续思考,
12/30 轮里 `` 后面并没有马上出现正文;
- 把 `` 也放进同一个窗口,并在最后用 `` / DSML 开场标签兜底,
可以做到 0 早切、0 漏字,47/47 文本无损;
- 此时真正的触发来源是:`` 触发 16 轮、`` 窗口确认 13 轮、
独立成行标记确认 14 轮、DSML 触发 1 轮、完全无信号 3 轮(这 3 轮需要业务侧兜底超时)。
用法:`scanWithStrategy(text, chunkSize, { gateCloseThink: true, outputTrigger: true })`;
`createStrategyScanner()` 另可调 `bufferTokens`(默认 10)、`charsPerToken`(默认 2)、
`bufferChars`(默认 32)、`anchor`(锚点句)与 `anchorTrigger`(默认 **false**)。
### 当前策略到底怎么算“正确处理正文”
一轮正确处理的形态(`node scripts/preset-stability-explain.mjs ` 可直接打印):
| 顺序 | 输入 | 处理 |
| --- | --- | --- |
| 1 | `…思维链…` | phase=thinking,文本作为 `thinking` 事件发出 |
| 2 | `` 或独占一行的 `<|end▁of▁think|>` | 过 10 token 窗口确认后切换,记 `confirmed:*` / `fallback:close-think` |
| 3 | `<|begin▁of▁output|>` | 控制标签:**从缓冲区扣掉**,只发 `output-open` 结构事件 |
| 4 | `您好,这是约定的内容,请查收:` | 正文开场句,保留在输出里(锚点默认只做窗口确认) |
| 5 | `正文` | 正文块 |
| 6 | `<|end▁of▁output|>` | 扣掉,发 `output-close` |
| 7 | DSML 工具调用块 | 原样保留在输出里,交给工具解析,标签不受影响 |
实测(基准 = 输出控制标签位置,`anchorTrigger: false`):
| 组 | 及时切换 | 过早 | 偏晚 | 漏切 |
| --- | --- | --- | --- | --- |
| C 旧