提示词能热改,历史不能跟着改口

题记:可编辑提示词不只是一个文本框,它还需要回答“谁用、何时生效、过去算什么”。
1. 保存成功,不等于语义清楚⌗
给 Agent 加一个提示词编辑器很容易:读文件、显示文本框、保存文件。再加上热加载,似乎就齐活了。
真正麻烦的是保存之后。
同一个系统接了多个 provider,修改应该影响谁?一个回合正在调用工具,下一次模型调用要不要立刻换规则?几天后回看历史,看到的是当时的行为说明,还是今天覆盖上去的新版本?
如果这些问题没有答案,热加载只是让变化发生得更快,没有让变化变得更清楚。
这次设计把一段名为 authorization 的行为说明,从公共身份提示词中拆出,做成按 provider 选择的文件变体。但值得讨论的不是多了一个目录,而是它同时补齐了三个坐标:
| 坐标 | 机制 |
|---|---|
| 谁使用 | provider 配置引用一个命名变体 |
| 何时生效 | 回合边界读取文件 |
| 如何记录变化 | 渲染后的说明参与上下文分段指纹 |
没有第三项,前两项仍可能制造一种很难排查的错觉:会话好像一直遵循同一套规则,其实规则已经换过。
2. 拆的是配置责任,不是把整份提示词复制几遍⌗
实现没有为每个模型复制一份完整的身份说明。公共身份文本继续留在原位置,可变的行为说明进入独立目录:
公共身份提示词 ──────────────────────┐
│
provider.authorization │
│ │
▼ ▼
prompts/authorization/<name>.md → 回合上下文
│
└── 未配置名称时使用 standard
ProviderConfig 新增的是一个可选字符串字段:
authorization?: string
它存的是文件名主体,不是正文,也不是一套嵌套规则语言。编辑器负责管理文本,provider 配置负责选择文本,运行时负责消费文本。
这种分工有两个实际好处。
第一,多个 provider 可以共享一个变体,而不必复制全文。需要差异时,再显式选择另一个变体。
第二,公共身份说明不必为了局部差异不断长出条件分支。否则最后很容易变成一篇“如果你是这个模型就读这一段”的大型说明书——程序已经知道当前 provider,却还要让模型在自然语言里重新判断一次。
不过,独立配置不等于独立权限。这里的 authorization 是交给模型阅读的行为说明,不是操作系统权限、工具执行许可或确认门本身。把文字拆层,并不能靠文字获得工具层没有授予的能力。
这次改动也不是把全部边界规则移出公共提示词:对应测试明确检查,公共文本仍保留既有的边界段落。文件分层与强制执行,是两个不同问题。
3. 热加载最重要的参数,是生效时刻⌗
加载函数很短。以下摘录省去路径函数,只保留核心逻辑:
async function loadAuthorizationSection(name = 'standard') {
const text = await readFile(authorizationFile(name), 'utf8')
return `# Authorization (${name})\n\n${text}`
}
运行时在回合准备阶段调用它,将结果放入会话上下文,再进入上下文分段解析。
这里没有给授权说明新增文件监听器,也没有等待监听器的防抖窗口。下一回合直接读文件,所以文件编辑与删除都会在下一次读取时体现。
这是一种很务实的热加载定义:
不承诺保存后改写正在执行的回合;承诺下一回合重新读取。
它让一个回合拥有稳定的输入基线。若一个抽象任务先请求模型、再执行工具、再把工具结果交回模型,行为说明不应仅仅因为中途有人按了保存,就在同一轮工作里悄悄换掉。
代价也不神秘:每个回合增加一次文件读取,修改不是即时中断信号。这适合回合级配置;不能把它宣传成紧急撤销机制。
“热”不必等于“任何时刻都能变”。有明确的生效边界,比刷新速度更重要。
4. 指纹必须包含名字,不只是正文⌗
渲染结果不仅包含正文,还包含变体名。指纹对整个渲染字符串计算 SHA-256,取前 24 个十六进制字符。
这意味着下面两个抽象配置,即便正文相同,也不被当作同一个上下文状态:
# Authorization (standard)
相同正文
# Authorization (variant-x)
相同正文
为什么不去重?因为“目前文字一样”与“选择的是同一份配置”不是一回事。
切换变体是一项配置选择。两个文件今天相同,明天可以分别编辑;只对裸正文计算指纹,会抹去这次选择的边界。authorization.test.ts 专门断言:相同正文、不同变体,指纹不同。
另一方面,这里也没有做语义归一化。空白或排版变化同样可能改变指纹。它记录的是实际渲染文本,而不是猜测两个版本是否“意思差不多”。对于需要解释历史的系统,这种保守很容易理解。
指纹负责判断变化,完整文本负责留下证据。只存一个 hash,而不保存当时的正文,仍然无法回答旧回合究竟看到了什么。
5. 新规则开新段,旧消息不重新编写⌗
系统原有的 context segment 可以理解为一段稳定的提示词基线:它有描述语义状态的 key,也保存完整的身份文本与会话上下文文本。
本次改动把 authorization 指纹加入 key,并在数据库中增加对应字段。
核心决策如下:
本回合读取并渲染说明
│
▼
计算“变体名 + 正文”的指纹
│
▼
与最新上下文段比较
│
├── key 与工具面均未变 → 使用已保存的基线
│
└── 授权说明指纹变化 → 创建新段,保存当前基线
授权说明导致的变化会记录为 key:authorization。因此,变化不是靠日志里某句“文件已更新”来推测,而是进入了会话状态本身。
hot-turn.test.ts 用连续回合规定了这条时间线:
| 回合 | 操作 | 测试断言 |
|---|---|---|
| 1 | 未指定变体 | 上下文含默认说明 |
| 2 | provider 的选择改为 x | 新建第 2 段,原因为 key:authorization |
| 3 | 不作修改 | 仍然使用第 2 段 |
| 4 | 修改 x 的正文 | 新建第 3 段,使用新正文与新指纹 |
| 5 | 删除 x 文件 | 回合报错,没有发起第 5 次模型调用 |
第 4 回合还有一个关键断言:此前消息作为新请求的历史前缀,序列化结果保持不变。
这里要区分两件事。新回合当然使用新规则,模型也可能因此改变对旧对话的理解;系统不可能阻止这种认知变化。它保证的是更窄、也更可检验的事情:不把旧消息改写成仿佛从一开始就发生在新配置之下,并保存新旧配置的分段基线。
这不是完整的确定性重放。模型版本、外部结果与采样等因素仍会影响输出。但至少,解释历史时不必拿今天的文件冒充昨天的输入。
6. 默认值与静默替换,差别很大⌗
这个实现对缺失文件的处理尤其值得保留。
未填写 authorization 时使用 standard,属于明确的默认语义。显式填写一个不存在的变体,则在配置校验时拒绝;已引用的文件后来被删除,下一回合读取失败。
它没有这样做:
指定文件丢失 → 悄悄改用 standard → 继续执行
这种兜底看起来提高了可用性,却把“配置不可用”改写成了“允许替换行为说明”。两者不是同一种故障处理。
控制台接口也落实了相近的区分:创建新变体使用排他写入,已有同名文件时返回 409,不把“新建”偷偷变成“覆盖”。名称限制为小写字母、数字和连字符;配置保存时检查引用的文件存在。
这些检查保护的是文件操作与引用关系,不判断正文是否合理,更不证明模型一定服从正文。测试能说明变体被选中、文本进入上下文、变化触发新段;不能据此宣称跨模型行为已经一致。
还有一个容易忽略的实现细节:授权说明接入了热文档提交流程,但运行时直接读取磁盘文件。因此,保存、通过测试并提交、运行时读到,是不同状态。控制台会报告“已保存但未提交”的情况;不能把提交校验误读为运行时只消费已提交文本的事务屏障。
7. 比拆文件更重要的,是给变化一个位置⌗
这个设计相较于“编辑一份全局提示词,然后重新加载”的进步,不在于更复杂,而在于责任更明确:
- 公共身份说明承载共享内容。
- provider 引用说明该用哪个变体。
- 回合边界定义修改何时生效。
- 上下文指纹识别变化。
- 分段基线保存变化前后的实际文本。
它也没有消灭配置管理的代价。共享同一变体的多个 provider 会一起受编辑影响;变体越多,维护者越需要知道谁引用了谁;按 provider 选择并不能自动解决任务级差异。
但这些代价已经是显式配置的代价,而不是全局文本悄悄漂移的代价。
可编辑 Agent 的一个基础要求是:不仅能回答“现在让模型读什么”,还要能回答“这一次为什么读的是它”。
提示词可以热改。历史不应该跟着改口。
代码依据:落地版本 ad5bb35 的 src/config/authorization.ts、src/llm/config-store.ts、src/runtime/turn-runner.ts、src/storage/context-segments.ts、控制台内容接口与编辑器,以及授权说明、接口和回合热加载测试。本文的测试描述是源码中的断言规格,不是另行运行所得的性能或模型行为评测。