一篇关于隔离占位的设计复盘:正文可以不可读,系统却不能因此假装什么都没发生。

1. “已隔离”不是一个完整的结果⌗

假设一个 agent 调用外部 API,随后得到的工具消息无法继续送进模型。框架把正文替换成一句“结果已隔离”。

这句话避免了正文再次进入上下文,却留下一道危险的选择题:刚才的操作到底发生了没有?

框架实际知道的事不能由此推出的事
有一条工具结果记录工具执行成功了
结果正文不可送模工具执行失败了
一条事件已经送达事件里的任务已经完成了
模型拒绝了本轮输入某一条被隔离消息就是拒答原因

这里有三个不同的维度:消息是否送达、正文是否可读、操作是否成功。 把它们压成一个“失败”,模型就可能再做一次;压成一个“已完成”,模型又可能直接收工。

我的判断是,这类占位不只是提示文案,而是 agent 继续决策时的一部分协议。它必须告诉模型哪些事实还成立,以及哪些结论现在没有证据。

本文依据一次落地的源码与测试展开;下文文件路径均相对源码仓库根目录。测试用来说明规格与覆盖范围,不代表已经统计过生产环境里的误重试率。

2. 不恢复正文,恢复判断边界⌗

旧版工具占位只有隔离事实。新版 quarantineToolStub(src/runtime/replay-assembly.ts)增加了四类信息:

信息用途
拒答 provider 与拒答类目说明限制来自哪里,而不是把它包装成工具错误
原始消息 ID保留审计定位能力
成败与具体触发内容均不能确定防止把输入拒答误作执行判定、因果判定
后续行动边界只核对独立状态;不要自动重试有副作用的操作,也不要读回隔离正文

这里最重要的不是多了一个 ID,而是那句“不能据此判断工具是否执行成功”。

分支的两次提交体现了这个修正:第一次已经意识到“裸占位”不够,第二次又收回了对执行状态的过度断言。工具结果行可以承载传输错误;拥有一条结果记录,并不能证明远端动作执行完毕。

用一个纯假设的情形说明:远端任务已经启动,但返回途中连接中断。模型后来只能看见隔离占位。此时再发一次启动请求,可能制造第二个任务;直接宣告成功,也可能是在编造。正确的下一步,是查询能独立确认该任务状态的信息。如果没有这种信息,结论就是未知。

未知不是等待模型补完的空格,而是需要原样传递的状态。

3. 给来源指针,不给绕回正文的捷径⌗

跨线程事件还有一层困难:接收方往往连该去哪里核对都不知道。

src/runtime/refusal-quarantine.ts 的通用中性渲染器,为没有专属重渲染器的事件补上来源信息。eventOrigin 按顺序查找这些元数据字段:

sourceChannelKey
    ↓ 缺失时
fromThreadId
    ↓ 缺失时
taskThreadId
    ↓ 缺失时
ownerThreadId

找到第一个非空字符串就作为来源指针;找不到则不虚构来源。替换事件同时保留事件种类、原始消息定位,以及“事件已送达,但不能认定它就是拒答原因”的说明。

这不是让 agent 沿着指针把正文再读回来。新版明确要求:只核对独立状态;状态不可读时报告未知。

两条路线看起来只差一步,语义却完全不同:

隔离占位 → 找到原始消息 → 重新读取被隔离正文    ×

隔离占位 → 知道来源 → 查询独立状态
                           ├─ 有证据:按证据继续
                           └─ 无证据:结果未知

原始消息 ID 服务于追溯,不是一次额外的读取授权。来源指针服务于定位,也不凭空增加接收方的访问权限。

这是我认为它比单纯“增加报错详情”更有价值的地方:它恢复的是可用信息的边界,而不是设法恢复被拿走的信息。

4. 一句文案,要在几条链路上保持同一个意思⌗

如果只改当前回合的显示文本,历史重放或记忆抽取仍可能把正文带回来。这次改动没有把占位当成一条临时 UI 提示。

工具结果:保留行,替换送模视图⌗

规划隔离时,工具行被标记为 stub,生成的占位进入 modelContent,完整送模视图冻结进 modelView。src/memory/quarantine.ts 写入这两个字段,原始内容仍保存在记录中。

这样做把“记录存在”与“模型可读取正文”分开了。

事件:排除原行,追加中性替换事件⌗

原事件采用 excluded 形态,设置 modelVisible: false;替换事件保留语义元数据,并通过 reRendered.of 记录对应关系。旧的冻结视图、隔离标记等字段不会直接复制过去,否则替换消息可能继承一个已经失效的送模状态。

现有隔离事务还限制了改写范围:只处理本代、尚未被接受的消息;对已经进入被接受历史的行拒绝改写。替换不是重新编辑过去。

记忆抽取:也不能成为旁路⌗

src/memory/extraction.ts 中,excluded 行不参与抽取,stub 行只提供简短的隔离占位。它不会因为主对话看不到正文,就把正文转交给另一个抽取模型。

这些机制共同维持一条约束:原文被保存,不等于原文还能从另一条自动处理路径重新进入模型。

5. 不能拿一句正确的开头,给错误正文通行⌗

动态占位带来另一个问题:既然每条占位都包含不同的 provider、类目和消息 ID,守卫怎么判断它是否合法?

最省事的答案是匹配前缀。可它会接受这样的形态:

合法的隔离提示 + 本来不该出现的正文

最终实现没有停在前缀检查。isQuarantineToolStub 先提取字段,再调用同一个生成函数重建标准占位,要求输入与生成结果完整相等。旧版固定占位则以精确字符串匹配兼容。

检查方式能挡住什么留下什么问题
只匹配开头完全无关的文本合法前缀后可以夹带任意内容
提取字段后完整重建比较非标准后缀、正文拼接不等于验证字段本身的来源真实性

src/memory/message-gate.ts 的一致性检查使用这个识别函数,防止带着 stub 标记的工具行仍保留任意送模正文。

但不要把格式校验吹成权限系统:它证明文本符合模板,不证明消息 ID 一定指向正确对象,也不证明 provider 元数据不可伪造。那些仍依赖上游记录与访问边界。

6. 测试证明了什么,没证明什么⌗

这次新增的占位测试集中在 src/runtime/refusal-quarantine.test.ts。另外两组测试分别覆盖数据库形态与送模集成行为。

测试覆盖断言要点
通用跨线程事件替换文本不含原正文,保留 provider、类目、来源与消息定位
工具结果占位不带原工具正文,包含拒答信息与原消息指针
传输错误场景不声称执行完毕,明确成败未知并提醒不要自动重试副作用操作
占位完整性占位后追加原文会被识别为非法
消息一致性接受标准新占位与旧固定占位,拒绝原始工具文本和错误角色
集成路径再次发给模型的请求不含原 payload、包含占位,而存储记录仍保留原文

这些是确定性的输出与存储断言。它们没有证明每个模型收到占位后都能正确决策,更没有证明系统已经实现了副作用操作的 exactly-once 执行。

这里尤其要分清两种“重试”:

  • 重试模型请求:去掉不可送模的正文后,让模型继续推理。
  • 重做外部操作:再次调用会改变状态的工具。

这次机制允许前者继续,不意味着给后者发了许可证。

7. 这不是完整的重试协议,但补上了关键的一句⌗

这项设计仍有明确边界。

第一,“不要自动重试”是送给模型的指令,不是执行层硬约束。如果重复操作的代价很高,仍需要工具自己的幂等键、操作记录或可查询状态。占位文本不能代替它们。

第二,来源元数据可能不全,来源状态也可能不可访问。这次改动没有制造一个万能状态查询接口。它选择保留未知,而不是用猜测维持流程顺滑。

第三,本次增强的是工具占位与通用事件中性渲染路径;注册了专属重渲染器的事件仍走自己的实现,不能据此声称所有事件都有相同的信息密度。

第四,更完整的占位会占用更多上下文。我的判断是,说明成败未知与行动边界比只留一个错误码更值得,但材料里没有 token 成本和误操作下降幅度的对照实验,不应把取舍写成已经量化的收益。

最终值得留下的原则很简单:

隔离改变的是模型能看见什么,不是世界已经发生了什么。

一个好的占位应当保留来源,承认结果不可用,阻止无依据的成功判断和失败判断。实在查不到,就停在未知。比起一句很利落的“失败了,再试一次”,这不够漂亮,但至少不会替世界改口。