看不见结果,不等于可以再做一次

一篇关于隔离占位的设计复盘:正文可以不可读,系统却不能因此假装什么都没发生。
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 成本和误操作下降幅度的对照实验,不应把取舍写成已经量化的收益。
最终值得留下的原则很简单:
隔离改变的是模型能看见什么,不是世界已经发生了什么。
一个好的占位应当保留来源,承认结果不可用,阻止无依据的成功判断和失败判断。实在查不到,就停在未知。比起一句很利落的“失败了,再试一次”,这不够漂亮,但至少不会替世界改口。