Files
writing-agent/plans/preset-message-array.md
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 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:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00

164 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 预设 = 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。