构建合同自动撤销Agent的实践揭示:记忆检索容易,但约束记忆何时能影响决策才是真正的工程难题,解决方案是架构约束而非更好的prompt。
这是有经验的人能问出的最有价值的问题之一。
律师记得某个条款曾经引发过麻烦。会计记得某家供应商在取消之后仍在持续扣费。工程师记得某次部署在极其相似的条件下失败了。
所以当我为 Reaper——一个负责自动续费取消的 Agent——添加记忆功能时,我以为检索是最简单的部分。
把当前条款嵌入向量。搜索相似条款。展示最匹配的结果。
但决定记忆允许做什么,花了长得多的时间。
因为一个能记住过往决策的 Agent,也可能继承过往的错误。更糟糕的是,它能把相似性变成权威:
这条款和我们之前拦截的那条有 97% 的相似度,所以我也拦截这条。
这听起来很智能。在一个处理合同的系统里,这危险地接近于迷信。
解决方案不是更好的 prompt engineering,而是架构。
假设一份合同每月费用 540 美元,除非在 term 结束前 60 天发出通知,否则自动续费一年。
错过这个截止日期,小小的管理失误就会变成 6,480 美元的错误。
Reaper 的职责就是防止这种失败。它读取续费条款,计算截止日期,等待通知窗口期到来,请求一个人类签名,发送通知,之后再检查账单是否停止。
但模型不被允许仅仅因为答案听起来有信心就安排一个义务。
Gemini 提出截止日期。一个独立的确定性引擎——正则表达式和日历算术,内部不含任何语言模型——独立推导出日期。
决策被刻意设计得枯燥无趣:
model_deadline = model.read(clause)
engine_deadline = date_engine.derive(clause)
if engine_deadline is ambiguous:
block("The clause cannot be derived safely")
elif model_deadline != engine_deadline:
block("The two readings disagree")
else:
schedule(engine_deadline)
这个关卡已经捕获过一次真实的一天之差的模型错误。在合同工作中,"只有一天"可能意味着再来一年的费用。
它也会拒绝如下条款:
written notice no later than sixty (90) days before renewal
模型可以编造一个看似合理的解释。Reaper 不能。矛盾本身就成为结果。
这就是最初的信任边界:模型可以提出建议;确定性代码决定该建议是否足够安全以执行。
然后我添加了判例记忆,差点削弱了它。
新的记忆系统存储条款形态及其处理结果:
确定性关卡是接受还是拦截了该条款?
供应商是否停止了扣费?
是否开启了争议?
合同要求的是电子邮件、挂号信、认证邮件还是门户?
这条记录是一个真实的义务还是一个标记为测试fixture的条目?
每个条款都使用 gemini-embedding-001 进行嵌入,作为 768 维向量存储在 BigQuery 中。在摄入时,BigQuery 原生的 VECTOR_SEARCH 能找到措辞不同但语义相似的条款。
例如,一个新条款可能类似于:
一条被拦截的矛盾条款;
一条供应商认可的成功取消;
一份通知发出后供应商仍然扣费的有效通知;或者
一份合同——其中电子邮件只是礼貌副本,因为需要的是挂号信。
这是有价值的上下文。
但也可能污染当前的决策。
假设当前条款清楚写着"六十(60)天",但一条高度相似的先前条款写的是"六十(90)天"并被拦截了。如果在关卡规则之前把判例输入模型,旧的矛盾就能锚定新的解释。
系统开始通过类比做决定,而不是从面前的文档出发做决定。
所以 Reaper 遵循一个严格的顺序规则:
当前合同
↓
确定性关卡
↓
裁决已固定
↓
判例查询
↓
报告的咨询性上下文
记忆在裁决之后才到达。
一个测试使这个边界可执行:即使是一个 97% 相似的被拦截判例,也不能把一个干净的条款从 MATCH 改为 BLOCKED。
一条几乎相同的条款之前被拦截,是因为其文字与数字不一致。这是过往历史,不是裁决。
但当前条款保留从自身文本获得的结果。
最后那句话——"这是过往历史,不是裁决"——不仅仅是界面文案。它描述的是权限模型。
一个装满文本的向量数据库可以回答:
机构记忆应该回答:
这个区别改变了 BigQuery 的 schema。
Reaper 存储条款以及结构化的结果字段:关卡裁决、通知方式、最终状态、账单是否停止、是否开启争议,以及当存在时的最终收据哈希。
嵌入向量找到语义邻居。结构化字段解释为什么这些邻居重要。
这让系统能够区分两份续费措辞几乎相同的合同:
一份取消成功,账单停止了。
另一份正确取消,但供应商再次扣费,义务进入了争议。
文本相似。但操作教训不同。
这个模式不只适用于合同。如果你在为支持 Agent 构建记忆,存储提议的解决方案是否真的有效。对于部署 Agent,存储发布是否被回滚。对于催收 Agent,存储承诺的付款是否到账。
没有结果的记忆检索只是回忆,不是学习。
Reaper 首先使用一个带标签的合成条款形态语料库,以便记忆系统可以 reproducible 地测试。
这些行永远不能伪装成客户历史。
因此每个判例都带有来源标签,如 fixture 或真实 ledger 支持的义务。一个种子示例可能演示需要挂号信,但产品不能暗示某个真实客户之前遇到过那个供应商。
这听起来像元数据家务事。但它实际上是一个信任边界。
一旦检索到的文本被压缩成一句话,溯源就很容易消失。"之前有类似的合同被拦截了"与"这种形态的合成测试条款预期会被拦截"含义截然不同。
如果你的 Agent 混合了示例、模拟、生成数据和生产历史,溯源必须跟随记忆而不能留在一个被遗忘的 README 里。
还有另一个诱人的失败模式:
try:
matches = search_memory(clause)
except Exception:
matches = []
现在"BigQuery 不可用"和"没有相似的判例存在"看起来一模一样。
Reaper 的查询是开放失败的。如果表被禁用、过期、不可达,或者嵌入调用失败,合同裁决保持不变。但证据链记录了记忆不可用及其原因。
系统区分:
记忆已被查询但没有匹配通过阈值;
无法创建嵌入向量;以及
无法连接到 BigQuery。
一个不可用的咨询系统不应该阻止一个有效的取消。但它也不应该悄悄假装已检查过历史。
失败透明性是记忆设计的一部分。
Reaper 在一个只追加的 SHA-256 哈希链中记录每个重大操作:提取、判例查询、关卡裁决、自主唤醒、批准、投递、发票验证和争议创建。
但更有意思的收据描述的是 Agent 没有做什么。
邮箱管道首先扫描头部。未能通过准入规则的消息保持未打开状态,这些拒绝被记录。如果系统拒绝检查来自无关发送者的银行对账单,拒绝本身就成为克制的证据。
同样的原则适用于判例:
记忆不能改变关卡;
失败的检索不能变成"没有历史";
fixture 数据不能变成客户历史;以及
原始向量不需要复制到操作证据账本中。
一个可信的 Agent 应该能够证明它做了什么,也能够证明它刻意拒绝了哪些权力。
合同取消也暴露了一个不那么光鲜的 Agent 问题:等待。
取消 Agent 可能今天读取一份文档,六个月后才需要执行。它不应该消耗 token、保持进程开放或依赖某人记得重启一个对话。
Reaper 持久化义务然后睡眠。当通知窗口打开时,它唤醒并写入一张收据,显示是日历时间——而不是有人按"继续"——触发了这个操作。
然后它在真正需要权威的节点暂停:签署和发送通知。
这个暂停在进程重启后依然存活,因为调用和恢复指针存在于持久化状态而不是 RAM 中。杀死服务器不会制造新的批准或丢失旧的批准。
这引出了一个更广泛的原则:
人类批准应该是一个持久的状态转换,而不是一个对话瞬间。
如果批准在进程死亡时消失,那它从来就不是工作流的一部分。它只是演示的一部分。
在构建了日期关卡、持久化批准、证据链和 BigQuery 判例记忆之后,这些是我会带入另一个 Agentic 系统的规则:
分离提议与授权。 模型可以推荐一个动作而不拥有执行它的权限。
在咨询性检索之前运行确定性检查。 记忆应该解释一个决策、警告历史、或帮助人类——而不是重写从当前输入已建立的事实。
存储结果与记忆配对。 相似的文本只有在配以实际发生了什么时才有价值。
保留溯源和失败状态。 "Fixture"、"生产历史"、"无匹配"和"搜索不可用"是四个不同的事实。
测试边界,而不仅仅是 happy path。 最重要的测试不是记忆是否返回了匹配。而是一个令人警觉的匹配是否能不当改变裁决。
我们经常通过列举 Agent 能做什么来描述一个有能力的 Agent。
我变得更感兴趣的是相反的列表:
模型不能自行安排日期。
矛盾的条款不能通过置信度来解决。
相似的历史不能否决当前证据。
缺失的查询不能假装是空结果。
进程重启不能擦除待处理的人类决策。
未读的消息不能悄悄变成已读。
那片负空间才是信任的来源。
记忆让 Agent 更有经验。边界阻止那些经验变成偏见。
确定性不是自主性的对立面。
它是让自主性可辩护的东西。
Reaper 是我参加 All Things Agentic Hackathon 的个人作品。
Repository: https://github.com/N-45div/Reaper Live project: https://reaper-sxxs.onrender.com