嗨,你好,这里是Linze。
标题有一点 clickbait,但是没关系
其实我最近一直在做 Agent 上下文相关的思考。然后越做我越发现,很多问题在工具返回结果的那一刻就已经开始了。
比如说,Agent 想要查清一个错误为什么会产生并试图修正,它就要去调用自己有的各种工具。工具把文件和资料返回后,里面可能会包含重试逻辑、认证、日志、序列化和各种辅助函数相关的逻辑。
但真正影响答案的,可能就只有几个判断条件对不对?它读了那么多内容,实际上只有一点点需要放到上下文里。你可能在网络上查了非常多的资料,但需要的只有一点点。你没办法去约束 MCP 工具自己的规范,它们丢进上下文的内容确实会比较多,相应地也会比较复杂。
其实在这种情况下,你只需要让工具去调出它需要的内容。这也是我最近在探索的方向,就是在它调用工具的过程中,去增加一次有明确目的的筛选
给模型一个当前的问题,再给它一份原始的工具调用,然后它只需要在工具调用里面去高光它需要的内容,其他东西我们全部都可以省略掉。
那这样的话,其实你就会发现我们可以省非常非常多的上下文(针对这些无关的内容),而且可以以一个比较好的方式去提高模型的注意力。
先看一个简化的例子。
def can_retry(status, replayable):
if not replayable:
return False
return status == 429 or status >= 500
如果问题是"遇到 500 时会不会重试",你去让 Agent 用 grep 搜 500,很容易找到最后一行。但你会发现它前面其实还有一个条件:请求必须能够重发,程序才会符合最终的判断。
在传统的检索过程中,通常会出现两种情况:
- 先漏搜,之后再补搜,占用一个多余的回合。
- 一次性把太多东西塞进上下文,导致模型没办法很好地注意到关键内容。
如果把整个文件都交给模型,或者让模型用自己原有训练的方式进行丰富,确实可以避免这次遗漏,但依然会让上下文包含大量和当前问题无关的实现。
每次都采用这种方法的话,无论是 Human 还是 Agent,就不得不去越来越多的材料里重新筛选有用和无用的部分。这样对模型来说目标并不明确,就相当于以前 prompt 没写明确导致 LLMs性能下降一样
其实我们的 Agent 读文件,只是为了了解重试条件。那我们就可以设计一个环节,让它只围绕重试条件去复制/过滤返回的内容。
比如:
- 它下一步想了解退避时间,那就只需要单独去看计算延迟的部分
- 它要开始 explore 重复执行的风险,那就只需要把“请求是否可以重放”、“调用方能否设置幂等”等状态信息找回来,对吧?
其实上下文在大部分情况下,因为你每次工具调用都是有明确目的的,那么最终的结果就是:尽可能让每一个回合都只获取自己需要的内容。
所以你只需要保证进入上下文的内容根据它的条件进行变化,整体表现其实就会相对来说更好
OK,那我们现在已经有了我们的目的,对吧?
接下来我们的问题就只是:怎么样让模型去学会做这个筛选?也就是说,我们选什么模型去做这个筛选?
其实你会发现,我刚讲的这个东西非常简单,因为它只是把相关的内容留下来。这听起来可能比较容易,但实际开始训练的时候,你还是会有一些模糊,对吧?
我们还是回到刚刚的重试逻辑。
比如我们刚刚的那段例子,最后一行直接出现了 500,它当然是相关的对吧?但是前面的 replayable 没有出现这个词,它只是决定了后面的代码有没有机会执行。
我们其实希望模型能够理解:每一行虽然没有直接回答问题,但依然可能是答案成立的前提。如果把它删除,剩下的内容就可能会让人产生误解。
在这种情况下,单纯去做关键词匹配肯定是不太够的,对吧?我们需要让模型结合原文和问题,去判断哪些内容共同构成了答案,这也是highlight 想达成的一个目的
那么我们现在可以开始了
首先我们要先有 dataset。因为要训练这样的模型,肯定得先有数据,再去考虑怎么训练。
这个数据都需要包含什么:
- 一个具体问题
- 一份原始内容
- 这段内容里需要保留的部分
虽然看着很简单,但数据从哪里来?有没有纯粹的 dataset 可以直接利用?我现在去找的话其实没有,公开的倒是有一个,但质量不太好,所以不推荐给大家。
这里面最容易出问题的反而就是那个"具体的问题"。举个例子:比如有个工具调用是读取某文件的 20 到 80 行,它只会说明你去拿了哪一类材料,却没说你想从中知道什么。如果直接拿工具调用的方法去当训练目标,标注模型其实什么也学不到,反而会觉得这几十行都是用户要求读取的,于是全部保留;这样训练出来的模型就什么都不删,毫无意义。
所以我们最终把标注拆成了两步:
- 让 LLM 通过任务意图和资料去生成一个具体的信息需求。比如把"读取这个文件"转化成"这个请求在什么条件下会被重试"。
- 让标注模型去看当前的问题和材料,筛选出相关、能保留的行。
我们选的 teacher 模型是 Kimi(主要是 NVIDIA 提供了免费额度,可以直接拿来练;当然我找的是中转站,因为可以多并发调用,这个大家千万不要学)。
我们大概标注了有 10 万对数据。这不算多,但相对来说,对于实验意图足够了。
在此期间,我们最需要注意的是:不要为了删而删。
我肯定不希望给标注模型一个固定的硬性要求,比如一定要删 80% 或 90%,这是不合时宜的。在大部分任务中,有些问题确实只需要几行,但有些问题需要把一整个逻辑全部看完。如果你只是为了凑压缩率(比如压到 90%、98%、99%),反而把条件、错误处理和变量定义等相关内容全部删掉,最后得到的训练标签就会有问题。
我们的标注要求主要是留下足够理解当前问题的内容。如果不确定删掉某一行会不会丢失相关含义,就倾向于保留。在重设的例子里,这样的要求会让前面的判断条件有理由留下来。
还有一种情况:比如原文注释说“以后需要在这里增加校验”,但代码里实际没有实现,那么生成的问题就不能写成“这段代码怎样完成校验”。如果 teacher 已经在问题里假设了一个不存在的行为,哪怕后面的标注再认真,也只是围绕错误前提在做无用功。因此,生成的问题要尽量落到材料已经说明的事实、行为和条件中。
举个例子:我们语料里有一段 48 行的 JavaScript 测试代码,问题是“用户使用通配符时应该产生什么样的 URL,以及回调会检查返回账号上的哪些属性”。标注模型其实只保留了 4 行,下面把比较长的断言换行展示一下
9 case '/accounts?username=chariz*':
26 username: 'chariz*'
35 test.equals(account.get('username'),
'charizard');
36 test.equals(account.get('type'),
Enum_AccountTypes.MEMBER);
你会发现这几行就能够说明输入参数、预期的 URL,以及返回账号的用户名和类型,对吧?
相对来说,原文里相关的错误分支和清理逻辑,就完全没有被这次标注选中。如果把问题换成"测试结果在哪里销毁",留下的内容就会发生变化,对不对?
这就是我们希望模型学会的:面对同一段原文,在不同的问题下给出不一样的选择。对,就是这个样子
除此之外,标注模型返回的是行范围,但我们训练时需要把它对应到 token 上。
我的做法是:
- 保留原文
- 先把选中的行转换成字符范围
- 利用 tokenize 返回的 offset,判断每个 token 是否覆盖了应该保留的内容
问题本身和输入模板不参与这部分计算。这一段听起来可能像普通的数据处理,但实际上原文的换行、Unicode 字符以及 token 的边界都需要做对齐。不然 teacher 明明选对了一行,数据处理时却把标签贴在旁边,模型最后学到的东西就会蛮奇怪的。
除此之外,我们还对训练集和验证集之间的重复做了处理,包括已经解析的仓库身份、分组、原文和问题相关的信息,也都留在了审查中。
最终筛选结束后,我们得到的数据是 10 万份训练样本和 5000 份验证样本。大部分都是代码语料(主要也是 dataset 现在代码语料比较多),剩下的内容则是 agent 的工具输出。这些在公开数据集里其实蛮少的,所以我标注起来也很困难:
• 训练集里应该只有 5000 多条 agent 工具样本
• 验证集里只有 127 条
所以现在最直接、最明显的,其实还只是代码相关的这些内容范畴
说来惭愧,其实我们也只是在巨人的肩膀上更进了一步。
我们是在已有任务感知筛选模型的基础上继续微调的。底座用的是千问 3 Reranker 0.6B,融合不同层的表示后,再通过压缩头产生 token 的保留和删除分数;训练目标里使用了 CRF 来学习保留和删除的标签序列。微调时,我们放开了最后两层 backbone、融合模块和压缩头,既希望保留已有模型读代码的能力,同时尽可能向我们的标注要求对齐。它其实要学会的,只是面对具体问题时原文中有哪些内容可以留下。
模型内部虽然可以给 token 打分,但最终给调用方的内容绝不能按 token 碎片直接拼接。举个例子:原文是 “128 days”,如果开头 “1” 的得分较低被删掉,剩下的内容就变成了 “28 days”;输出虽然看着依然通顺,但含义已经完全变了。
所以我们目前采用的是完整行输出:将 token 的分数聚合成行分数,选中某一行就保留整行文字,被删除的部分则用省略标记表示,以此尽量避免行内被误剪裁的问题。至于多行之间的条件依赖关系,目前还是希望模型在训练中能尽量识别出来,不过现在处理得还不算好,后续我们可能需要探索一些新的方案来解决。
训练过程中我们其实还有一个精度问题,但是这个我们略过不谈。
最终讲一下效果吧:我们大概训练了两个 epochs, F1达到了 79.2%;原模型的 F1 其实是 62.45%,我们大概提升了 16.39 分。
在这个结果里,变化其实很明显:60% 左右在生产环境里根本没法用,可能会漏掉很多东西;但到了 80%,甚至如果把阈值调得更高一点,可以达到 93% 以上,这就已经完全可用了。
事实上,我们这个数字衡量的就是一致程度,它也能说明我们确实在这个筛选任务里做得比起始模型好了不少
相应来说,现在我也把这个东西应用到了我们现在的 Agent 上。
在什么都不干、保证相同结果的情况下,我们大概能达到 50% 的 token 节省。它可以帮我们节省大量无关上下文的读取,并且让模型保持更高的性能。
但严格来说,现在其实还是不能到实际应用的场景,我们可能还要再针对这个条件去做进一步的探索。
这就是目前的情况,希望能跟大家分享一下,就这样
以上
只是在巨人的肩膀上做进一步的探索
Credit