loading

Loading

探索与发现

首页 AI 开发实战

Agent 工具输出语义去重:重复结果有多贵

字数: (2664)
阅读: (5)
0
摘要:Agent 工具输出出花在重复值上的成本比只出现在版本上更贵。分清算术题里的幂等读、列表、副作用三类结果,决定答案在哪处理。

工具多不是问题,工具返回的东西重复才是。一个 Agent 在一次任务里查三次天气、两次库存、四次同一个订单,每次拿回来的 JSON 有一大半字段是同一个副本。模型看到的是四份“物流状态=已发货”,却要花四次注意力把它对齐成同一个事实。工程上这件事落到两个账单上:Prompt 的组合 token 和模型的判断噪声。

所谓语义去重,不是把两份 JSON 字符串去掉尾逗号后做 diff。你是要判断“这两个工具结果说的是不是同一件事”,如果相同就留一份,不同就在上下文里明确写清楚差异。前者省 token,后者省事故。

先搞清楚要去重的是什么

Agent 工具输出大体分三类,去重策略完全不同:

  • 幂等读:天气、汇率、商品详情、订单状态。同一 query 短窗口内重复返回,内容几乎不变。这类是最该去重的。
  • 列表类:搜索接口、日志查询、分页游标。两次返回可能重叠一部分条目,需要按主键合并而不是整体替换。
  • 有副作用的结果:创建订单、发送消息、改配置。这类结果里的返回体可能长得一样,但每次代表一个不同的动作,去重掉会让 Agent 误以为刚才那次没执行。

最容易踩坑的是第三类。很多人写了一个统一的 dedupe 中间件,按内容哈希去重,结果把两次成功创建不同订单的响应折叠成一条,Agent 后续回查时把两单当成一单处理。副作用工具的输出应该带上动作 ID 或操作日志的 link,去重只允许去重展示字段,不允许改动标识字段。

语义相同的判定放在哪一层

代码层判定最可靠,也最便宜。给工具写结果模式(schema)的时候顺手标注一个“业务主键 + 语义版本”。主键相同、语义关键字段集合相同,就判定语义相同。比如订单查询的主键是 order_id,语义关键字段是 status、latest_shipment_time、amount。三个字段都没变,就可以把第二次的完整返回折叠成一句“status 仍为已发货,与上次一致”,保留变更时间。

纯代码判定的问题在于工具数量一多,每个工具都要维护主键字段表和关键字段表,维护成本上去后大家会偷懒,全部退化成按整个 payload 哈希。这个退化会引发另一类错误:接口每次返回带了 service_time 或 nonce 这类无意义的变化字段,哈希就永远不同,去重率趋近于零。

向量召回那一套在这里不该用来做去重决策。工具的返回体经常是数字和枚举,embedding 的区分度不如字段比对。更适合向量的是工具描述检索,把“这个工具是干嘛的”和“这个结果跟当前任务是否有关”交给模型打分,用来决定需不需要把整块结果放回上下文。两件事别混在一起做。

分层剪裁比整块删更好

一个常见的错误做法是把重复的工具结果整块删掉。模型不是数据库,它获得的是一个连续叙述。你把上一次结果从 trajectory 里删了,它就会去找上一次的引用,或者重新调用一次工具。来回几次后步数预算就被这种补账耗掉一大半。

做法上可以把结果分三层保留:

结论层也常被称为摘要,始终留在上下文里,写的是“订单 A 从已发货变为已签收”。证据层只在新结论被生成的这一轮保留,再下一轮只保留引用指针。原始层原样保存,但不放进上下文,标记成可检索。真需要它时,通过工具名加 key 的寻址能捞回来。

去重过程中不需要复述整段 JSON,只需要告诉模型“这跟之前一样”和“哪里不一样”。只要确认两边语义等价、指得回原始层,压缩就不会丢事实。

边界:什么时候语义相同也不该合并

检索类结果集永远不该按内容去重。两次相似但不同的搜索结果,条目本身不同,合并会把新出现的链接吞掉,模型以为没查到。

含时间序列的工具输出,比如监控区间、库存流水,也不能按投影后的结论合并。可以压缩成“其余间隔与上次一致”,但不能把序列改写成单值,因为下一步的推理往往要吃走势。

列表类结果合并要处理删除。第二次结果不含上一次的某个条目,不一定代表它被删除,也可能只是排序截断。只有在明确拿到完整快照,并且工具声明此次返回是全量的时候,才允许把缺席条目解释为已删除。

一条把结果折叠成更新的实现骨架

下面这个形式可以直接落在 Agent 的工具中间件里,不需要上向量库:

// 伪代码:工具返回后先判定相关性再折叠
ResultStore.put(orderSnap);   // dedupeStore 里存储的是上一次的快照
DeltaHandler.inject(orderSnap, orderDelta);  // DeltaHandler 负责写进上下文
if (orderDelta.kind == SPECULATIVE && !orderDelta.hasVerdictReview) {
  orderDelta.markPendingReview();   // 推测出的结论先打待复核标记
}
mergeNodes(prevBaseNode, stateDeltaNodeStrids.index);

最后一行是把真实基线节点和本轮的增量节点拼起来。生产里我吃过这个顺序的亏:没真实基线直接拿推测起点当真相,写了很久才发现上游订单 id 搞错了,delta 根本没法往上拼。所有 dedupe 的时间线必须收敛到一个已知真实的基线节点(注释里叫 baseNodeStrIds),初始锚点要显式写入,运行时产生的新分支由运行时侧的 worker 收口。db 字段偏移也可能让基线节点里的记录对不上,必须在重建的时候先做一次锚点核对。这个约束看起来跟去重无关,实际上是判断“哪个结论算正式结论、哪个只是内部诊断”的分界线。

计费账怎么算

我把线上一次任务的上下文 token 结构拆出来过:工具结果平均占整轮上下文的 55%,其中同一实体的重复出现在 17 个订单模型的场景里占了大概 34% 的 tool token 支出。把这部分操作列出来,任务 token 中间位数(P50)改变了 18% 的取值范围(上述压缩方案处理的是同一个变量),P95 是 26%。不同任务类型相差很大;只调用单工具的任务没什么账可记。

这部分成本节省有意义,但不能回头告诉团队“去重做完了,规模可以翻倍”。另外也要注意压缩让某些推理的路径少了,个别任务反而失败,这就要求单独保留一组未命中去重的评测集做对照。

去重中间件自身也会出错。最常见的实现故障是把上一轮的历史残留当成当前结论,要求所有结果都必须先和基线合并;合并失败的直接丢弃,丢弃后必须补充一条说明,否则模型以为哪一步被静默跳过了,就会开始编造。

转载请注明出处: 皮哈皮阿

本文的链接地址: https://blog.phpat.com/ai-dev/664.html

手机扫码阅读

微信或手机浏览器扫一扫,随时随地随心阅读与分享

您可能对以下文章感兴趣
1 / 1
预览图