- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
164 lines
10 KiB
Markdown
164 lines
10 KiB
Markdown
# 预设 = message[] 骨架:真多轮装配
|
||
|
||
> 状态:**已实现**(788 测试全绿)。剩:实测缓存收益、滑窗恢复策略、`{{charPrompt}}` 一族。
|
||
|
||
## Context
|
||
|
||
**问题**:现在每条 LLM 请求实际只有「N 条预设 system 条目 + 1 条巨大 user 文本」。原因是两处拍平叠加:
|
||
|
||
1. `src/worker/executor.ts:868` 交给预设层的任务消息只有 `[{system: 契约}, {user: assembleWorkerContext(...)}]`,历史被 `formatHistorySegment` 拼成一段带 `## 用户 / ## 助手` 的字符串。
|
||
2. `src/preset/play-frame.ts` 的 `frameMessagesWithPreset()` 只有 body ≥2 且末条是 user 时才切出历史袋 —— 实际永远只有 1 条,于是 `chatHistory` 洞**恒空**,整包上下文落进 `currentTurn`。
|
||
|
||
后果:
|
||
- **成本**:历史 blob 每轮整体变化,前缀在 `currentTurn` 处断,前 N-1 轮重发全价。对上 `docs/briefs/prompt-cache-audit-2026-10-07.md` §5「未打折的是几乎每次的首填大包」。
|
||
- **表达**:模型没有 assistant 轮可模仿节奏;`injection_depth` 无意义;`squash` 的 `semi/strict` 在给拍平打补丁。
|
||
|
||
**目标**:预设回归它本来的职责 —— 提供 `message[]` 的结构指导;内容(我们自己的上下文)按预设的洞折进去;历史是真多轮。
|
||
|
||
---
|
||
|
||
## 确认后的模型(用户要确认的那一条)
|
||
|
||
> 预设 = 「最后发出去的 messages 长什么样」的声明式骨架;宏在装配期展开;运行时只负责把每块内容填进骨架留的洞里,绝不追加在骨架之外。
|
||
|
||
### 预设负责
|
||
|
||
| 职责 | 载体 |
|
||
|---|---|
|
||
| 有哪些条目、什么角色(system / user / assistant) | `prompts[].role` |
|
||
| 谁参与本次请求 | `prompt_order[].enabled` + `prompts[].enabled` |
|
||
| 装配顺序(谁在前、谁在后、谁进历史深处) | `prompt_order[].orderIndex`,未来 `injection.depth` |
|
||
| 正文里的宏(`{{user}}` 等) | `prompts[].content`,装配期展开 |
|
||
| 生成参数 | 顶层 `temperature/top_p/...` |
|
||
| 发出前整形要求(squash) | `messagePolicy.squash` |
|
||
|
||
### 运行时负责(只填洞,不加料)
|
||
|
||
5 个自有洞 + 1 个共用洞:
|
||
|
||
| identifier | 填入内容 | 形态 |
|
||
|---|---|---|
|
||
| `worldBookBefore` | 历史之前的上下文块(worker 契约、角色卡、设计资料) | 一段 system 文本 |
|
||
| `chatHistory` | `对话.历史` | **真多轮 message[]** |
|
||
| `worldBookAfter` | 历史之后、本轮之前(变量 / 真值 / 本轮状态) | 一段 system 文本 |
|
||
| `currentTurn` | `用户.最新输入` | 一条 user |
|
||
| `postTurn` | 本轮之后的插入(本步任务、提示) | 一段 system 文本 |
|
||
|
||
### 宏(装配期展开,`src/directives/macros.ts`)
|
||
|
||
`{{// 注释}}`、`{{trim}}`、`{{user}}`、`{{char}}`、`{{lastUserMessage}}`、`{{getvar::k}}`、`{{setvar::k::v}}`、`{{addvar::k::v}}`、`{{roll:1d6}}`。
|
||
未知宏**原样保留**(与酒馆一致),并在导入诊断 `diagnostics.macros[].known=false` 里点名。
|
||
作用域:单次装配一份 Map,不落盘、不跨轮。
|
||
|
||
### 目标形态(游玩一轮)
|
||
|
||
```text
|
||
system 预设条目:main / 文风 / 破限(ST 历史前)
|
||
system worldBookBefore:worker 契约 + 角色卡 + 设计资料(史前块)
|
||
system worldBookAfter:变量.当前 / 真值(史后块)
|
||
assistant 开场白 ← chatHistory 洞展开
|
||
user 「我推门进去」 ← chatHistory 洞展开
|
||
assistant 「门后是雨……」
|
||
user 「我抬头」 ← chatHistory 洞展开
|
||
user currentTurn:用户.最新输入
|
||
system postTurn:本步任务 / 提示(若有)
|
||
```
|
||
|
||
前 6 条是稳定前缀 → 可命中缓存;只有末尾随轮次变化。
|
||
|
||
---
|
||
|
||
## 当前代码状态(已落地)
|
||
|
||
| 文件 | 改动 |
|
||
|---|---|
|
||
| `src/skills/dialogue-history.ts` | `parseHistoryMessages()`:`## 用户` / `【用户】` / `用户:` → message[];`CURRENT_TURN_TAG` / `isCurrentTurnRef` 迁入 |
|
||
| `src/skills/context-segments.ts` | `assembleWorkerContextSections()` + `workerContextToBags()`;`assembleWorkerContext()` 保留为 `.flat` 包装 |
|
||
| `src/preset/world-info-pack.ts` | `history: string → ChatMessage[]`;`chatHistory` 不再字符串填洞 |
|
||
| `src/preset/assembler.ts` | 历史洞展开多条;`PRESET_HISTORY_MARK` / `splitPresetHistory()`;`injection_position=1` 深度插入 |
|
||
| `src/preset/play-frame.ts` | 不再把正文拍平成 `【用户】/【助手】`;`shapeFramedMessages()`:历史上只用「合并相邻同角色」 |
|
||
| `src/llm/preset-wrapper.ts` | `options.presetPack` 通道;squash 只整形历史前后两段;tool 循环尾原样接在后面 |
|
||
| `src/llm/client.ts` | `CompleteOptions.presetPack`(不进请求体) |
|
||
| `src/worker/executor.ts` | 含 `对话.历史` 的 worker 走 `presetPack`(含 gm harness) |
|
||
| `src/runtime/driver.ts` / `src/worker/gm-harness.ts` | 透传 `presetPack` |
|
||
| `src/direct-chat/turn.ts` | `priorMessages` 直传真多轮;种子前情无块头时进 `worldBookBefore` |
|
||
| `src/preset/probe.ts` | 历史解析 + `presetPack` |
|
||
| `src/directives/macros.ts` / `expand.ts` | 新增 `{{persona}}` → `@人设` |
|
||
| `src/types/preset.ts` | `DEFAULT_PRESET_SQUASH = "strict"` |
|
||
| `src/dictate/history-window.ts` | 缺省改为不限(-1),先让前缀能命中 |
|
||
|
||
## 已定决策
|
||
|
||
| 问题 | 结论 |
|
||
|---|---|
|
||
| 预设没写 squash | 缺省 `strict`(兼容性优先);单个预设可自行写;设置页仍可覆盖 |
|
||
| 酒馆真实 squash 语义 | 核过源码:`squash_system_messages` 是 bool(默认 false)= 合并相邻无 name 的 system;`merge`/`strict` 属于另一个设置 `custom_prompt_post_processing` |
|
||
| 历史区要不要参与整形 | 参与,但只用「合并相邻同角色」:用户连发两条会合并,`injection_depth` 的 system 不被折掉 |
|
||
| `injection_depth` | 已实现:depth 0 = 历史末尾,同深度按 `injection_order` 升序 |
|
||
| 创作期 worker | 不特殊化;含历史 ref 的真多轮,纯数据 worker 保持单包 |
|
||
| `{{persona}}` | 接 `@人设`(当前用户角色详细人设) |
|
||
| `{{charPrompt}}` 一族 | 不补:我们靠世界书建角色卡,未知宏原样保留 + 导入报告点名 |
|
||
| 变量 / 真值 | 不动:继续当 `worldBookAfter` 的文本段 |
|
||
| 创作期历史滑窗 | 暂时关闭(不限) |
|
||
|
||
---
|
||
|
||
## Approach
|
||
|
||
1. **先修好编译**:`context-segments.ts` 补回 `export function assembleWorkerContext(params): string { return assembleWorkerContextSections(params).flat; }`。旧调用点零改动,扁平输出与改动前逐字一致(`chunks.map(text) + tail`,`"\n\n---\n\n"` 分隔)。
|
||
2. **worker 路径接上真多轮**:`executor.ts` 里当 `worker.contextSegments` 含历史 ref 时,用 `workerContextToBags(sections)` 建 `WorldInfoPack`,把它作为 `presetPack` 传给三条请求路径(`completeStructured` / `completeOpts` 的 `complete`|`completeStream` / `runGmHarness` 暂不传)。`messages` 保持现状(trace 与 harness 仍可用)。
|
||
3. **保证不重复、不丢块**:`workerContextToBags` 已把「史前块 / 历史 / 史后块 / 本轮 / 尾块」分好;历史只从 `chatHistory` 洞出一次。
|
||
4. **squash 保护**:`shapeFramedMessages()` 只对历史区前后两段整形(已实现)。
|
||
5. **测试**:改 `play-frame.test.ts` 的 `pack.history` 期望(现在应展开成多条);新增「历史洞展开成多轮」「chatHistory 不再恒空」「squash 不动历史」三组用例。
|
||
6. **文档**:更新 `docs/preset-format.md` §4(洞 + 装配规则 + 宏)、`docs/context-assembly.md` §2/§9(历史是 message 序列)。
|
||
|
||
### 不在本轮(建议单独排)
|
||
|
||
- `injection_position=1` / `injection_depth` 深度插入(真多轮之后才有意义)。
|
||
- 历史滑窗策略:`projection=summary`(最近 20 段)在块级滑动时会整体位移、击穿前缀缓存。需要「按块裁剪 + 仅在超阈值截断」。
|
||
- 主世界层 `runGmHarness` 的多轮化(它是 tool loop,前缀已在循环内复用)。
|
||
|
||
---
|
||
|
||
## Files to modify
|
||
|
||
- `src/skills/context-segments.ts` — 补 `assembleWorkerContext` 包装(关键:让编译先过)
|
||
- `src/worker/executor.ts` — `presetPack` 接线
|
||
- `tests/play-frame.test.ts`、`tests/direct-chat.test.ts`、`tests/world-info-pack.test.ts`、`tests/preset-probe.test.ts` — 期望更新
|
||
- `docs/preset-format.md`、`docs/context-assembly.md`
|
||
|
||
## Reuse(已存在,别重写)
|
||
|
||
| 用途 | 位置 |
|
||
|---|---|
|
||
| 历史文本 ↔ 结构 | `src/skills/dialogue-history.ts`:`parseHistoryMessages()`(新)、`projectDialogueHistory()`、`appendDialogueHistoryTurn()` |
|
||
| 分袋 | `src/skills/context-segments.ts`:`workerContextToBags()`(新)、`renderContextSegmentBody()` |
|
||
| 洞词汇表 / 扩展 order | `src/preset/markers.ts`:`applyExtendedMarkers()`、`MARKER_DEFS` |
|
||
| 装配 | `src/preset/assembler.ts`、`src/preset/play-frame.ts` |
|
||
| 整形 | `src/llm/message-squash.ts` + `shapeFramedMessages()` |
|
||
| 宏 | `src/directives/macros.ts` + `src/directives/expand.ts` |
|
||
|
||
## Steps
|
||
|
||
- [x] 补 `assembleWorkerContext()` 包装
|
||
- [x] `executor.ts` 含历史 ref 时构造 pack 并作为 `presetPack` 传出(含 gm harness)
|
||
- [x] `{{persona}}` 宏
|
||
- [x] `injection_depth` 深度插入
|
||
- [x] squash 缺省 `strict`,且不碰历史区
|
||
- [x] 创作期历史滑窗暂时不限
|
||
- [x] 测试:历史解析、深度插入、squash 保护、`{{persona}}`、缺省 strict(788 全绿)
|
||
- [x] 文档:`docs/preset-format.md`、`docs/context-assembly.md`
|
||
- [x] 滑窗恢复:块级裁剪 + 滞回(超过 2n 才裁到 n),前缀每 n 轮才断一次
|
||
- [x] 缓存报告:`npm run stats:cache`(`src/cli/cache-report.ts`),按「组内首次 / 组内后续」切命中率
|
||
- [ ] 实测:改后用 `npm run stats:cache -- --days 3` 对照修复前的基线(首次 8.6%,后续 20.7%)
|
||
|
||
## Verification
|
||
|
||
1. `npx vitest run` —— 788 全绿(已达成)。
|
||
2. `npx tsc --noEmit` —— 本仓库有**既有**报错(`dictate/round.ts`、`log.ts`、`phase-runtime.ts` 未用变量与 `skillPackRoot`、`session-manager.ts`),本次未新增。
|
||
3. 预设试跑窗口:`assemblePresetProbe()` 输出应是 `system… / system… / assistant… / user… / user(turn)`,而不是一条大 user。
|
||
4. 成本侧:`npm run stats:cache`(推荐 `--days 3`)。看两件事:
|
||
- `worker:world-simulator` / `direct-chat` 的「组内后续」命中率是否明显高于「组内首次」;
|
||
- 整体是否高于修复前基线(最近 30 天:首次 8.6%、后续 20.7%)。
|
||
基线出处:`docs/briefs/prompt-cache-audit-2026-10-07.md` §3。
|