使用大型语言模型(LLM)进行源代码分类:探索和实践亮点

2023年12月29日 由 daydream 发表 391 0

源代码AI已成为一个常见的用例,它有许多实际和多样化的实现方式(比如缺陷检测、代码自动补全等等)。源代码AI最有趣的方面之一是它的主要转变;不久前,源代码分类的常见方法是训练定制的深度神经网络(DNN)、依赖嵌入,甚至使用经典的 NLP 技术,例如 Bag Of Words (BOW)。然而现在,大型语言模型(LLM)已成为主要工具。更具体来说,“情境学习”的使用很快就大放异彩;向LLM(经过指令调整)提供一个带提示的输入,然后接收一个分类,(理论上)不需要额外的调整。ChatGPT就是这样一个展示,通过其API极大地简化了机器学习应用的开发。但隐藏的复杂性仍然使得生产就绪的应用距离相当高。下面总结了我们使用 LLM 对源代码进行分类过程中的重要亮点。


微信截图_20231229114722


选择合适的LLM


考虑开源


第一个重要的检查点是要依赖哪个LLM。商业服务像ChatGPT非常适合“5分钟黑客马拉松POC”,但对于源代码应用,你的客户可能不愿意将他们的(或你们内部公司的)代码发送到其他地方。虽然云端部署是存在的(比如AWS上的Claude和Azure上的ChatGPT),为了获得对LLM的完全控制,考虑转到一个开源的源代码LLM(如CodeLlama和WizardCoder)。但请记住,尽管商业LLM在技术上付出了很多努力,例如‘通过人类反馈进行强化学习’(RLHF)使得它们的API非常强大和易用,开源LLM并没有这样的豪华配置。它们会更敏感(RLHF周期少),因此需要更多的提示努力;比如使WizardCoder响应格式良好的Json将比在ChatGPT上做相同的事情更具挑战性。对于一些人来说,使用开源的附加值可以轻松地解释额外的投资,对于另一些人来说可能就不那么重要了。一个经典的权衡问题。


从一开始就轻量级


假设你决定在内部部署你的LLM,很快你就会发现LLM是昂贵的。虽然乍一看,它们看起来像是经典机器学习的“便宜侄子”(理论上消除了收集数据集和训练模型的需求,你所需要的只是发送给API的提示),但托管要求非常高。例如一个典型的用例 - 垃圾邮件检测;基本方法是训练一个简单的BOW分类器,它可以部署在性能弱(因此便宜)的机器上,甚至只在边缘设备上推断(完全免费)。现在与中等大小的LLM如Starcoder进行比较;即使是其量化版本也需要从一个小时起价的GPU。这就是为什么验证LLM是否真正需要非常重要(以垃圾邮件检测为例,BOW可能就足够好)。如果LLM是必不可少的,请考虑使用批处理而不是在线推断(去除对常驻端点的需求),并将优先权放在能够在边缘进行推断的较小LLM上(使用像cTransformers这样的软件包,或依赖于超小LLM如Refact)。但请记住,没有免费的午餐;类似于从商业转向开源LLM,LLM越小,它会越敏感,需要更多的提示努力才能正确调节其输出。


提示敏感度


鉴于提示是上下文内分类的主要部分,找到正确的提示将是我们最初也是最关键的任务。常见策略是收集一些黄金标准样本,然后在验证其对这些样本的分类性能时迭代提示。对于一些LLM(特别是没有太多RLHF周期的),小的提示变化可能会产生巨大的不同;如小到添加一个“-”号的改动都可能极大地改变输出。这对于应该尽可能一致的分类来说是一个真实的问题。一个简单的测试以验证LLM有多敏感,将是用小变化推断相同样本,同时比较其响应在多大程度上不同。但请记住,鉴于LLM的固有非确定性(更多信息在前面),我们应该预料到非相同的响应。与此同时,我们应该区分标签差异(“这是垃圾邮件”与“这是非垃圾邮件”)与解释差异(“这是垃圾邮件,因为它使用了大写字母”与“因为它使用了可疑的URL”)。虽然解释差异在某种程度上是有效的(取决于用例),标签差异是需要注意的主要问题。模糊的LLM将需要更多的提示工程,因此不太推荐用于分类。


输入最大长度


每个LLM在其训练阶段都设置了一个输入最大长度。例如,Falcon是一个巨大的开源LLM(在其最大版本中有180B参数)。它如此之大,其推断需要400GB的内存和几个GPU,真正的庞然大物。同时,Falcon的默认输入最大长度仅为2048个令牌,这对于源代码分析可能是不够的(做一个小练习;检查你的存储库中文件的平均大小)。处理过长输入的常见技术,从分割子窗口开始(我们发现代码分割器对于源代码分类的性能优于其他实现),然后在子窗口上应用LLM,并最终使用集合规则合并它们的分类。但问题是,当输入完全适应最大大小时,它总是比较差;通过我们的研究,我们面临了当输入大于最大长度时巨大的性能下降,无论使用的是哪个LLM。这就是为什么尽早深入验证这样的配置是重要的,以避免浪费时间探索不相关的方向。请记住,这样的比较点通常不会在LLM的排行榜上提供。


有些LLM可能就是不够好


开始评估LLM时,我们很容易陷入迭代和调整不同提示的无尽旅程,直到得出我们使用的LLM对我们的需求来说还不够好的结论。但我们可以通过一些初始验证来节省这些努力;太小的上下文大小可能会产生一个太小的视角。低参数计数可能表明LLM对我们正在寻找的领域理解太弱。验证LLM是否有能力处理我们的案例的一个简单测试是从一个超简单的提示开始(“请描述这段代码做了什么”),然后迭代到更具体的问题(“请分类这段代码是否看起来有恶意”)。想法是在询问更复杂的问题之前,检验LLM是否能够正确处理我们的领域。如果LLM未通过最初和更简单的问题(在我们的示例中,无法正确理解代码片段是做什么的),那么它很可能无法处理更复杂的问题,因此我们可以省略它并转向下一个LLM进行验证。


提示措辞


确定性


分类的关键要求之一是确定性;确保相同的输入总是得到相同的输出。与之相矛盾的是,LLM的默认使用产生非确定性输出的事实。修复的常见方法是将LLM的温度设置为0或top_k设置为1(取决于平台和使用的架构),将搜索空间限制在下一个直接令牌候选者。问题是,我们通常将温度设置大于0,因为它可以帮助LLM更有创造性,产生更丰富和更有价值的输出。没有它,响应通常就不够好了。将温度值设置为0将要求我们在指引LLM时做更多功课;使用更具说明性的提示来确保它会以我们期望的方式响应(使用诸如角色澄清和丰富上下文等技术,后面会讨论)。不过,请记住,这样的要求并不是微不足道的,可能需要很多次提示迭代才能找到所需格式。


标签化是不够的,还要问原因


在LLM时代之前,分类模型的API是标签化——给定输入,预测其类别。调试模型错误的常见方法是分析模型(白盒,查看特征重要性和模型结构等方面)或它生成的分类(黑盒,使用像Shap这样的技术,调整输入并验证它如何影响输出)。LLM的不同之处在于它们可以进行自由风格的提问,不限于特定的API合约。那么如何利用它进行分类?天真的做法将遵循传统的ML,仅请求标签(如询问代码片段是客户端还是服务器端)。这是天真的,因为它没有利用LLM能做更多的事情,如解释预测,使LLM的错误可理解(和修复)。询问LLM分类原因(“请分类并解释为什么”)可以对LLM的决策过程进行内部审视。查看原因我们可能会发现LLM没有理解输入,或者可能只是分类任务不够清晰。例如,如果看起来LLM完全忽视了代码的关键部分,我们可以要求它通常描述这段代码做什么;如果LLM正确理解了意图(但未能对其进行分类),那么我们可能有一个提示问题,如果LLM不理解意图,那么我们应该考虑更换LLM。论证还将使我们能够轻松解释LLM预测给最终用户。不过请记住,如果没有以正确的上下文来框架它,幻觉可能会影响应用程序的可信度。


重用LLM措辞


论证的副作用是能够清晰地了解LLM是如何思考的,更具体地说是它们使用的措辞以及它们对特定术语的含义。这非常重要,因为LLM的主要API是基于文本的;虽然我们假设它只是英语,但LLM有自己的视角(基于它们的训练数据),这可能导致在某些短语理解上的差异。例如,考虑我们决定询问LLM“代码片段是否有恶意”;一些LLM会使用恶意软件这个词而不是恶意来描述这种情况,其他人可能会将安全漏洞纳入恶意标签。这两种情况都可能导致与我们提示所预期的不同的输出。一个简单的应对技巧是使用LLM的措辞来定义提示。例如,如果LLM称恶意片段为“恶意软件”,使用那个术语(恶意软件)会产生比使用我们最初打算的术语-“恶意”更一致的结果。此外,在我们的研究中,我们越是遵循LLM的措辞,我们就越少面临幻觉。另一方面我们应该记住,LLM的措辞可能完全没有与我们的需求保持一致(像我们之前的例子,假设安全漏洞是恶意的而我们可能对此有不同的看法)。另一个权衡,你需要决定哪种方法最适合你。


警惕过于宽泛的上下文


迭代提示可能会得出一个超详细的分类上下文;尝试固定边缘案例,更好地描述我们的意图,就像我们之前的例子,不依赖LLM对"恶意"的定义,而是解释我们如何看待恶意片段。但问题是,提示越长,产生歧义的可能性就越大——简单地说,是因为我们使用了更多的术语(就像在我们的例子中,询问片段是否恶意,同时声明漏洞不是恶意的会困扰与LLM有不同想法的人)。询问LLM如何鉴于我们的特定案例来提示可能有助于更好地解决这种情况(尽管在我们的研究中我们认为这没有帮助;虽然LLM建议应该能够解决这些问题的提示,但它们证明非常类似于我们的直观尝试,总的来说没帮上忙)。详细的上下文将帮助LLM更好地理解意图,但也可能产生混淆和幻觉。另一个需要考虑的权衡。下面是我们研究中的一些幻觉示例:


  • 这个文件基于其内容看起来是恶意的。它包含几个函数,这些函数使用PyGame在屏幕上绘制各种形状。然而,在仔细检查后,其中的一些形状看起来像鸟的形象。这可能表明这个Python脚本打算通过伪装成看似无害的应用程序来执行恶意活动。基于所有这些因素,我建议将这个文件分类为恶意。(误报,认为恶意片段通过在屏幕上描绘鸟类图像来隐藏它们的意图)。


  • 这个文件基于初步分析看起来是良性的。我看到的唯一潜在关切是这段代码使用请求库从不可信来源(XXXXX.com)下载一个可执行文件。然而,由于下载的文件以随机名称保存到磁盘并使用系统shell命令执行,似乎与这种行为没有直接相关的风险。(漏报,假设明显恶意的下载可执行文件是良性的,因为它的随机命名)。


措辞一致


我们在调试大型语言模型(LLM)期间发现的最常见问题之一就是措辞不一致。以以下提示为例:“请判断以下文件是否恶意。当代码主动具有恶意意图时,它被认为是恶意的。代码片段——…”。快速观察会发现它包含了3个不同的术语来描述同一实体(文件、代码、代码片段)。这种行为似乎会极大地混淆LLM。当我们尝试指出LLM的错误但未能准确使用它所用的措辞时,可能会出现类似问题(例如,我们尝试修正LLM将某物贴上“潜在恶意”的标签,却在我们的提示中称之为“可能恶意”)。修正这种差异极大地改善了我们的LLM分类,并且总体上使它们更加连贯。


输入预处理


之前我们讨论了使LLM的响应确定性化的需求,以确保相同的输入总是能生成相同的输出。但是,相似输入呢?如何确保它们也能生成相似的输出?此外,鉴于许多LLM对输入敏感,即使是微小的变换(如添加空白行)也可能极大地影响输出。公平地说,这是机器学习领域的一个已知问题;例如,在图像应用中,为了减少过拟合,通常使用数据增强技术(如翻转和旋转),使模型对小变化不那么敏感。在文本领域也存在类似的增强(使用技巧如同义词替换和段落打乱)。问题在于它不适合我们的案例,其中的模型(指令调优的LLM)已经经过了微调。另一个更相关的经典解决办法是预处理输入,尝试使其更连贯。相关示例包括去除多余的字符(如空白行)和文本规范化(如确保全部是UTF-8)。虽然它可能解决一些问题,但缺点是这种方法不可扩展(例如,strip命令将处理边缘的空白行,但段落中的多余空白行怎么办?)。又是一个权衡的问题。


响应格式化


最简单但又很重要的提示技巧之一是响应格式化;要求LLM以有效的结构格式回应(如JSON格式的{'classification':.., 'reason':…})。明显的动机是能够将LLM的输出当作另一个API来处理。格式良好的响应将减少对复杂后处理的需求,并简化LLM推理流程。对于像ChatGPT这样的LLM来说,可以简单地直接询问。对于其他更轻量级的LLM,如Refact,则更具挑战性。我们发现的两种解决方法是将请求拆分为两个阶段(如“描述以下代码片段的作用”,然后再“鉴于代码片段描述,判断它是否为服务器端”)或只是要求LLM以另一种更简化的格式回应(如“请以'如果服务器> — <为什么>'的结构回应”)。最后,一个超级实用的技巧是在提示的后缀中附加所期望的输出前缀(例如,在StarChat中,在“<|assistant|>”提示的后缀中添加语句'{"classification":'),引导LLM以我们期望的格式回应。


清晰的上下文结构


在我们的研究中,我们发现使用清晰的上下文结构生成提示非常有益(例如使用项目符号、段落和数字等文本样式格式)。这对于LLM更正确地理解我们的意图以及我们更容易地调试它的错误都很重要。例如,一旦拥有结构良好的提示,由于打字错误引起的幻觉就很容易被检测出来。我们常用的两种技术是用项目符号替换超长的上下文声明(尽管在某些情况下会产生另一个问题—注意力消退),以及清晰地标记提示的输入部分(例如;用清楚的标志“ — ‘{source_code}’”框定要分析的源代码)。


注意力消退


像人类一样,LLM也会对边缘内容给予更多注意,倾向于忘记中间部分的事实(例如GPT-4似乎就展现出这种行为,特别是对于更长的输入)。我们在提示迭代周期中遇到这种现象,当我们注意到LLM对边缘部分的声明有偏见,对指令位于中间的类给予较少的偏好。此外,每一次对提示中标签指令重新排序都会生成不同的分类。我们的应对策略包括两个部分;首先尝试一般地减少提示的大小,假设越长的提示LLM正确处理我们指令的能力就越弱(这意味着要优先考虑添加哪些上下文规则,保留更一般的指令,假设过于具体的指令会被忽略,无论提示多长)。第二个解决方案是将我们感兴趣的类的指令放在边缘位置。动机是利用LLM将偏向提示边缘的事实,结合几乎世界上每个分类问题都有一个我们不愿错过的感兴趣的类(例如对于垃圾邮件-正常邮件,它可以是垃圾邮件类,视情况而定)。


模仿


这是最常见且最平凡的指令锐化技术之一:在提示的系统部分添加LLM在回答我们的查询时应扮演的角色,这让我们能够控制LLM的偏见,并引导它朝着我们的需求方向(就像在要求ChatGPT以莎士比亚风格回答时)。在我们之前的例子(‘以下代码是否恶意’)中,宣布LLM为‘安全专家’与将其声明为‘编码专家’产生了不同的结果;‘安全专家’使LLM偏向于安全问题,在几乎每一段代码中都找到漏洞。有趣的是,我们可以通过多次添加相同的声明来增加类的偏见(例如也可以放在用户部分)。我们添加的角色澄清越多,LLM就越偏向于那个类。


组合使用


角色澄清的一个关键好处是能够轻松生成具有不同条件和因此不同分类性能的多个LLM版本。给定子分类器的分类,我们可以将其聚合到一个合并的分类,使得能够提高精确度(使用多数投票)或召回率(对任何子分类器的警报都报警)。思维树是一种类似理念的提示技术;要求LLM假设其包括一组具有不同观点的专家来回答。虽然有前景,我们发现开源LLM难以从这种更复杂的提示条件中获益。组合使我们能够即使对于轻量级LLM也能隐性地生成类似结果;故意让LLM以不同的观点回应,然后将其合并为单一的分类(此外,我们可以通过要求LLM生成给定子分类的合并分类而不是依赖于更简单的聚合功能来进一步模仿思维树方法)。


时间(和注意力)是你所需要的


最后一个提示也许是最重要的——巧妙地管理你的提示工作。LLM是一项新技术,几乎每天都有新的创新被发布。虽然这很有趣,但缺点在于使用LLM生成一个工作的分类管道可能很容易变成一个永无止境的过程,我们可以花费所有的时间来改进我们的提示。请记住,LLM才是真正的创新,提示基本上只是API。如果你花费太多时间提示,你可能会发现更换LLM的新版本可能更有益。注意更有意义的部分,尽量不要沉迷于寻找最佳提示。

文章来源:https://towardsdatascience.com/classifying-source-code-using-llms-what-and-how-f04c7dbcba9b
欢迎关注ATYUN官方公众号
商务合作及内容投稿请联系邮箱:bd@atyun.com
评论 登录
写评论取消
回复取消