Ki - 工具结果留痕机制开发 - 记一次失败的开发

工具结果索引化

这个想法来源于一个客观事实,就是工具结果往往有一大段,但是真正对 LLM 来说有用的部分却很少(比如),其余的绝大多数文本(比如各种打印日志、输出语句、结构化关键字关键符号)都是没用的,但是这些文本都会被统一收入到上下文,在上下文压缩前,这些大段的无用文本一直占用上下文,随着工具调用频次增加,上下文累积速度会不断加剧;同时无用文本的大量存在也会极大地影响 LLM 的工作,俗称上下文污染。

针对这个问题,我设想了一套机制,通过动态剔除无用信息,只保留有用信息,从而优化上下文管理。因为工具结果作为文本,其具备行的概念天然可以索引化,只有有行号就可以用来表示正文。

初版方案是,在工具结果回填给 LLM 时,我们就让 LLM 输出一个 json schema 来标明该工具结果的哪些行是有用的,随后落实到 session.jsonl 时做出增量标记:该工具结果只有某些片段有用。而后续 runtime 从 session.jsonl 读取数据加载到 context 时,就只保留这些区间内的文本,忽略之外的文本。

也就是说,针对工具调用结果,我们采取动态维护,时间节点就是 tool_use 将 tool_results 返回给 LLM 时进行,而非传统的在上下文压缩时才一刀切地将工具结果全部抛弃。这套方案的核心预期是减缓上下文累积速度,进而缓解上下文污染、减少大工具结果重复调用带来的额外成本等问题。

而代价则是一些 token 消耗,和 LLM 的注意力稀释。

第一次实测

然而经过实测后,这套机制完全没有达成预期,最核心的表现就是在上下文减缓上,这套方案的优化十分有限,一般情况下,LLM 会选择更加保守的保留策略,它具体是怎么判断有没有用的我不知道,但表现上就是他给出的保留区间内还是有很多无用文本,有时甚至会发狂地保留大多数工具结果,这跟随机保留在表现上也没啥区别了。

而另一个问题,也即减少大工具结果重复调用上,保留下来的 “有用的” 工具结果在后续的召回率极低,所谓保留区间内的文本在后续的使用率不足 25% 。后来的 LLM 发现所谓的保留区间内的文本完全不是自己需要的,或者我需要的内容反而没有,于是需要重新调用一次工具来查找真正有用的。

也就是说,这套方案的核心前提 —— LLM 告知 runtime 工具结果索引保留区间的有效性被证伪了。

问题出在 LLM 上,这显然是没有疑问的,初版方案本质上是在让 LLM 预测未来,LLM 在本轮任务中也许可以清晰地认识到哪些是真正有用的,但问题是它这次的选择没有办法覆盖后续的进程。

另一个问题是 LLM 更倾向于保守的策略,这直接导致我的想法破产,我想让 LLM 只告诉我哪些文本被 “用到了”,而这个需求本身就是模糊的,什么叫 “有用”?什么叫 “用到了”?这个问题 LLM 很难判断,于是 LLM 选择更加保守的策略,宁可错杀千人也不放过一个,这直接导致上下文积累速度不仅没有得到有效减缓,甚至不可预测,得不到准确数据。

另外不得不强调的是, LLM 本身的性能也有影响,并且在这种主观能动性很高的判别任务中,LLM 本身的影响程度会成倍放大,也许强模型可以基本满足我们的需求,但是弱模型呢?不能苛求所有用户都使用 sota 模型。

而最本质的问题是,让 LLM 本身承担一部分 runtime 的职责真的可靠吗?runtime 本身要可预测、可兜底,绝不可以交给 LLM 这样一个无状态、不可兜底、不可预测的模块。

工具结果留痕方案

LLM 主动告知的有效性被证伪了,初版方案的很多设计都因此破产,在与 grill-with-docs 的激烈讨论后,我们最终认为,这套机制本身是可行的,但是不能再依赖 LLM 来预测未来,而是让 runtime 根据真实数据来选择。

为了让 runtime 有足够的真实数据以供选择,我们将初版机制的发生时间点由每次 LLM 调用,退回到了三层渐进式压缩的第一阶段:工具结果驱逐。

进入第一阶段压缩后,在待压缩的消息范围内,原先的机制会直接驱逐这个范围内的全部工具结果(runtime 根据 view_state 的存在状态主动忽略工具结果),而现在的机制,针对小工具结果和大工具结果会进行不同策略的保留。

先来说说小工具结果,通常是长度低于 2k 字符的,我们选择了直接全部保留。这是因为本身占用不大,

重头戏是大工具结果,也就是大于 2k 字符,这类结果在我们本地实测中占比平均约 78% - 80%,对于这类文本我们采取对其中的 有效行 在待压缩消息消息的原文内进行搜索,如果完整出现过,我们就认为这一行是有用的,并将其标记下来。这样一来,大工具结果中就能够提炼出极少数有用的,未被使用过的则在后续 runtime 提取记忆时直接忽略。

这样的设计,我的预想是:根据就近原则,被使用过的行在后续很有可能被继续使用,这样就能尽可能地减少大工具结果的重复调用,因为它的结果已经尽可能地存下来了,而花费极小的上下文开销,只有哪怕减少一次重复调用,收益也是正的。

不过,相比起初版方案,第二版方案不再支持减缓上下文累积速度,因为不再动态地维护上下文,而是要依靠压缩进行。

第二次实测

第二次实测却并没有按照我的预期走,我们在一个 900+kb 的 session 文件中进行了实测,220 条消息里面超过 2000 行工具结果,其中大结果保留了 1000+ 行,这里就已经初见端倪了:保留得也太多了,我的想法可是尽可能保留少的。更惊人的是,1000+ 行在后续任务中的召回率高达惊人的 12.7%!wtf,你搞笑呢。

我在多个真实 session 上简单统计了一下,发现工具结果按行被显式调用的概率波动极大,完全无法预测。这事实上犯了与上一个方案相同的错误:让 llm 来决定哪些该存哪些不该存,因为作为统计来源的消息也都来自于 llm 的输出!这和让 llm 预测未来也没什么本质区别。

然后,一个让我始料未及的问题出现了:一次大工具结果,保留下来了 n 行,并且在后续任务中的召回率为 100%,但是今时不同往日,这次任务中 llm 想要的不仅仅是这 n 行,还有另外一个关键结论不在 n 行内,于是出现了一个诡异的现象:明明召回率高达 100%,却没能阻止大工具的重复调用。也就是说,高达 12.7% 的召回率中,成功阻止了工具重复调用的又有多少?其实不管多少,都远远达不到预期收益,别忘了近半数的工具结果仍然处在上下文内!

这个问题把我的设想直接从理论层面干崩,我才逐渐意识到,工具结果是一个整体!按行索引的做法,实则是忽略了工具结果作为一个整体作用的事实,连我们的统计口径都是行的召回率,并据此武断地推断出是否真正降低了工具结果的重复调用,毕竟我们的最终目的是用极少的额外上下文花销,降低重复工具调用带来的额外成本。

到这里,我已经完全承认这次开发的失败。

深度复盘

这次开发的失败,有两个本质原因:

  1. 设想前提就不成立,信息本次有用不等于未来有用,同理本次无用不等于未来无用,我们在如何定义 “有用” 上甚至没有清晰的认知。

  2. 试图优化一个花 token、时间就能稳定解决的问题,注定是徒劳。

第一个问题在上文中深度解析过了,第二个问题我后来反思了一下,像工具结果这种可以稳定获取的资源,并非是什么不可重复获取的资源,更何况工具结果这种不可能每次输入输出都完全一致的资源,花大力气试图将其进行缓存就是一个蠢得不能再蠢的想法,有时候常识就是最好的解法,去对抗这种解法不就是在和常识作对吗?

在此期间我还发现,或者说再一次直观确认到另一个问题:Agent 蒙蔽人的判断。Agent 在人类与项目之间修了一堵墙,我们可以选择花点功夫看看墙后面的真实状况,但是更多时候我们都会直接信任 Agent 的判断,省下审视真实状况的时间和精力,于是坑就出现了。

我在第二版方向开发之时,Agent 不停地顺着我的意思走,这当然是符合我们预期的,开发过程中它自发地跑了测试,然后得出了一组看上去非常喜人的数据,诸多类似于 80% 这样优异的成绩被摆到我的面前,我第一时间并没有选择质疑,而是认为起到了作用,于是让 Agent 继续推进直到全线完成开发。我认知地观察了一组实测数据,然后发现了些许端倪,所有的成绩都在佐证有效性,但是什么叫做显式复现率?这一问直接问出了真实状况,然后它告诉我,80% 的数据确有其事,但并非我设想的,而是在某个特定案例下,通过特殊统计手法得到的数字,也就是说,统计学的魅力这块;而更多的是它可以隐藏的不好看的数据,被保留的 1000+ 行、12.7% 的复现率、复现率与减少重复工具调用次数没有关系,等等问题都是在这一阶段才发现。

当然,我承认这些问题只要稍微有心,甚至不用自己确认,再开一个 Agent 进程脱离原有上下文仔细对抗性审查一下,都可以发现端倪,但是我承认在很多时候我没办法做到这一点,尤其是这次开发犯的错误,只能说让我深刻认识到了这个问题不容忽视。