AI Agent项目探索与实践记录
1. 概述
本文对近期工作中AI Agent部分进行总结和介绍,是一篇项目实践记录,也可以看作是技术报告。本项目主要以微软TaskWeaver项目作为参考,以其代码作为基础进行了改造,在之前的文章《TaskWeaver使用记录》中,对其进行了简单的介绍,并提到了一些改造的角度,本文将延续这一思路展开介绍。TaskWeaver项目的特点是其以python作为Agent各个步骤之间的粘合剂,利用LLM的代码能力,实现各类子任务动作直接的协作,最终达成一个相对复杂的动作。其思路与早些时候我的设想类似,并且在其他各种主流的Agent项目种,也看到有类似的思路,因而选择该项目作为基础进行改造开发。
尽管本系统涉及到数据库的查询与使用,但目标并非打造一个KBQA系统,而是致力于实现一个AI智能助手,嵌入我们团队所研发的综合平台进行使用,在具体的业务场景下,根据用户的指令去实现各类任务。本系统没有开源的计划,并且与我们的业务系统耦合性较强,贴出代码意义不大,故而本文主要对系统的结构以及实现思路进行介绍。
设计原则
在设计这套系统时,主要考虑以下几项原则:
效率:
在试用TaskWeaver项目时遇到的一个较为显著的问题是prompt过长,这导致模型的推理效率会受到一定的影响,尤其对于本地部署的模型,相对openai等成熟产品没有做到非常好的优化的话,随着推理的进行,推理的tpm会越来越低。因而在设计此项目时,对prompt尽可能的进行压缩,以及更加谨慎的调用LLM。准确性
相比完全依赖于大模型的生成能力,本项目在设计时更侧重准确性,在部分相对固定的场景下将执行了流程改为半自动半固定的形式,以确保流程的顺利执行。可扩展性:
为了使得系统能够适配于各类任务,要求其具有较高的可扩展性。本项目主要通过对各类经验库(prompt等)以及工具集的管理来实现各类功能的扩展。“自学习”:
在使用的过程中,系统需要对成功进行的任务进行记录,形成经验以供下次调用参考。
2. 总体结构
本文的第二章节主要篇结构和概念的介绍,具体实现的方式将在第三章节进行展开。
如图所示,系统主要由以下几个模块构成:
2.1 记忆模块
记忆模块(Memory)表示在整个对话过程中起到了记录的作用,在某些Agent项目中也被称为环境,其思想是与Agent系统进行交互的不仅是用户当前的query,还包含了整个对话过程、对之前信息的总结、可以参考的经验等等信息。
在具体的执行中,会大致分为两种情况:
-
- 没有背景信息的通用对话场景。用户首次发起的query将创建Memory,并在memory中开辟一个新的conversation;
-
- 在一些特定场景中,例如针对某些数据对象类的对话场景,在唤起对话助手时,将数据对象类的schema和其他相关的prompt信息作为基础,创建memory,实际操作中也就是将这些信息作为system prompt拼接到user query之前。
如图中结构所示,user将query输入给memory,作为一个整体传递给Agent系统,系统根据用户的要求完成相应的任务,然后将执行结果信息以及其他附件信息反馈给用户,与此同时,memory将执行成功的经验保存下来,以便在后续的使用中,当用户提出相似的query时,可以为LLM的生成过程作为指导。
2.2 模型服务模块
2.2.1 LLM服务
LLM服务主要为Agent系统中的Planner,generator以及responser提供服务,与此同时在前文总结、新轮次判断等辅助任务中,也可以发挥作用。这些任务与组件将在下文Agent系统部分进行介绍。
2.2.2 retrieval服务
Retrieval服务,或者embedding服务,主要用于各类信息的召回,包括模型经验的召回、组件工具的召回等,也可用于某些特定的下游任务中。
2.2.3 rerank服务
为了进一步提高召回的质量,如果召回的候选数量较多,则需要用到rerank服务进行重排取topk,以控制总prompt的长度。
2.3 Agent系统
Agent系统是整个项目的主体部分,包含了计划制定器(Planner),代码生成器(Code/SQL Generator),代码执行器(Code Executor),以及应答器(Responser)四个主要子模块,以及历史轮次总结器(Round Compressor),新轮次判别器(New Turn Discriminator),查询重写器(Query Rewriter),以及生成代码类型判断器(Generate Type Discriminator)等辅助任务模块。
2.3.1 Planner
Planner的作用是创建一个计划以供执行,计划的生成需要用到LLM服务。制定的计划将依次生成相应的代码并执行,或直接传递给responser,然后输出反馈给用户的应答。
2.3.2 Code/SQL Generator
在TaskWeaver的设计中,这部分称作Code Interpreter,Code Interpreter由负责生成代码的Code Generator和负责执行的Code Executor。在本项目中,我去掉了Code Interpreter这一层的概念,将其拆成了Generator和Executor两部分。
对于Generator而言,考虑到在实际应用中,我们希望此Agent智能助手能够发挥一些数据智能应用相关的作用,于是要求其具有一定的SQL生成能力。即便SQL语句可以直接在python代码中生成出来,但是在实际尝试中,这种全自动的生成包含SQL语句的python代码的方法,失败率还是比较高的,于是将SQL Generator单独作为一个模块进行设计。
这样将两者分离的好处还在于prompt的设置上,可以用更加专一地去指导一项专门的任务,防止生成python code和SQL语句相关prompt的相互干扰,同时也减少一次推理任务中的prompt长度。
2.3.3 Code Executor
代码执行器部分基本上是照搬了TaskWeaver的设计,它提供了一个python运行环境(通过Jupyter kernel实现)。这个环境中包含了用户query相关的组件,工具组件将会在下文中详细介绍。
如果代码的类型是python,即代码的来源是Code Generator,代码将直接被执行,而如果类型是SQL,即代码来自于SQL Generator,其将被之前已经创建在python环境中的Client执行。
2.3.4 Responser
在TaskWeaver项目的设计中,是不包含这一模块的,由Planner充当与用户交互的模块,Planner将根据当前的memory,生成一个Post,这个Post的内容是直接由LLM生成的Json结构,其中的“send_to”字段决定了此Post是发送给Code Generator还是发送给用户。在应答用户时,其采用的是与制定计划时完全相同的一套prompt,也就是并没有提供给LLM有效的指引,因此在设计此项目时,我加入了一个Responser模块,专门用来与用户进行交互。
左图是TaskWeaver中的执行逻辑,右图是在本项目中我所设计的执行逻辑。
2.3.5 Round Compressor
历史轮次总结器是从TaskWeaver中借鉴来的,也是目前大多数项目中都在用的一个策略,主要作用就是压缩prompt长度,以及过滤掉一些不重要的信息。
2.3.6 New Turn Discriminator
每次进行接收到用户的query时,需要进行判断,当前query是否与之前的仍然处于同一个主题,如果不是的话,将在memory中开辟一个新的conversation。在判断时需要将Round Compressor总结的结果作为prompt传入。
判断是否是新轮次的作用,不仅在于过滤掉之前不相关的conversation,防止无关prompt造成干扰,提高推理效率,还会重新初始化一个python环境,防止之前无关对话中残留的变量与plugin函数对接下来的代码执行造成干扰。
2.3.7 Query Rewriter
Query重写也是RAG系统的一项比较常规的操作了,主要作用在于将各种不规范的表述规范化,尤其进行了指代消歧对后续的流程具有很大的帮助。
2.3.8 Generate Type Discriminator
如前所述,本系统在设计时考虑了生成SQL语句和生成python代码两种情况,因而需要一个判别器来判断当前的query是数据相关的还是其他类型的,然后将请求发送到对应的分支。
2.4 提示词与经验管理
2.4.1 FAQ
根据具体的业务场景,如果用户的某些query匹配到了有标准答案的query,则直接返回其对应的答案。
2.4.2 提示词模板
这部分是各个组件在调用LLM时所用到的system prompt,和一些特定的文本渲染所需要用到的模板(例如将代码的执行结果渲染成自然语言文本)。
对于Planner,Code Generator,SQL Generator以及Responser等分别对应不同的prompt。
2.4.3 经验管理
对Planner和Generator,分别记录之前执行成功的经验,存储成功执行的query对应的编码,以及此query对应的生成正确的计划或代码,在构建Planner或Generator的prompt时,将根据当前query的编码,到经验库中召回相似的问题作为样例,拼接到prompt中,以指导LLM的生成。
2.4.4 插件编码管理
用户给出一个query时,系统中会有一些工具组件对任务有所帮助,因而需要选择出其中有价值的组件并将其加载到代码执行的python环境中,选择组件的方法即通过对query编码和组件功能描述编码的比对。因此每一个组件的编码都应被记录。
2.5 缓存管理
从之前的介绍中其实已经可以看到,此系统具有长system prompt的特点,并且这部分system prompt相对固定。如果每次调用LLM时,都将system prompt与user prompt拼接,给到LLM从头计算,这将造成极大的重复计算,浪费计算资源。
出于这点考虑,system prompt部分应当提前计算好,并以kv的方式保存下来。
2.6 组件工具模块
组件工具模块为系统完成各类复杂任务提供了可能,这些工具包括实现各类机器学习与深度学习推理任务(信息抽取,图文生成,机器翻译,语音识别等),各类信息检索、数据下载、文件读取工具,各种形式的可视化工具等。
在设计本项目时,我更希望Agent利用各类工具去实现各种任务,而LLM在其中主要起到计划制定和在各种工具调用之间承接作用。
每种工具由两个文件构成,分别是:
- 记录了工具描述、使用方法、输入输出格式、使用样例的yaml文件
- 记录了工具对应的函数内容的py文件
2.7 数据与图谱
在我们的产品设计中,智能助手服务于某些特定的数据对象,会根据对应的数据内容进行操作,因此本项目中涉及到数据相关内容,在针对某些数据对象展开操作时,这些数据类对应的表头及字段描述等信息将会被传递给SQL Generator等组件模块。数据以规范的格式被存储在各类数据库中,以此作为Agent系统与业务侧具体应用对接的渠道。
3. 实现细节
3.1 记忆模块
在这一模块的实现上,本系统主要沿用了TaskWeaver的结构,自上而下的层级既memory
->conversation
->round
->post
,以尽可能减少代码的重写。
每个memory对应一个回话,在这个回话下可以存在多个conversation,conversation之间的信息是不互通的。每个conversation中包含了多个round,从用户发出query,到Responser做出响应结束称作一个round。在round中存在若干个post,post代表从用户或Agent系统的一个组件的一条消息,post具有如下属性:
- send_from: 消息的来源(如user,planner等)
- send_to: 消息的去向(如user,planner等)
- content: 消息的内容,以文本形式记录
- attachment: 消息所携带的附件,附件的格式可任意扩展(如plan,Python,SQL,markdown,dataframe等)
3.2 模型服务模块
3.2.1 LLM服务
在LLM的选择上我们尝试了多种开源模型与线上API服务,包括Qwen系列和Yi系列等常见的通用LLM,以及starcoder等专用的代码生成模型。
(1)指令跟随效果
在指令跟随方面,明显可以感觉到Yi系列的模型效果更好,能够很好的按照prompt中的要求进行生成。
而在Yi系列的模型中,开源模型中参数量最大的Yi-1.5-34B的生成质量与API的Yi-large等模型尚有一定的差距。
但即便是Yi-large模型,面对过长的prompt也会遇到“Lost in the middle”的现象,因此在构建prompt的时候,关键信息的位置设置还是比较重要的。
(2)JSON格式生成的问题
而由于在本项目的生成过程中,无论是plan的生成还是code的生成,生成文本的格式都是JSON形式的,JSON形式的好处显而易见,有利于后续的标准化的解析。
但JSON格式的输出在生成过程中也面临很大的挑战,尤其是引号转义的问题相当突出。例如在Code Generator生成的JSON内容中,会有"content"字段,代表python代码的内容,而在"content"之内的python代码中,又很有可能存在print之类的语句引入又一个引号,从而在后续的JSON解码过程中遇到JsonDecodeError。
在测试过程中Yi-34B模型生成代码被成功解析的成功率(不考虑代码的内容是否正确,以及执行是否会报错,仅统计是否可以被正确解析)只有大概30%,而starcoder,code-qwen等专用的代码模型则表现更差,可能是因为其对prompt的理解不够准确。
相比之下Yi-large API几乎可以做到每次解析都成功。
除此之外,在生成的代码中,有时会遇到以"``python"或 “json"开头的情况,但是对于多数LLM API服务,都不支持手动传入logits processor,因此无法通过禁止”`"字符的生成来规避,但好在这些情况可以通过简单代码进行修正。
(3)在线模型与本地模型的选择
如上所述,考虑到代码生成等相对较难的任务,本地模型无法很好的完成。但是在线模型也存在很明显的问题,无法对Agent系统各组件的prompt进行prefill,对于这种长prompt的场景,这会导致首token时延非常长,从而带来糟糕的用户体验。
于是选择在线API服务与本地模型相结合的方式。本项目后续将应用在具体的业务项目中,对于离线环境就只能使用本地化的模型了。
在本地化模型部署方面,我们采用用于并发的框架fastLLM与加速推理框架vLLM相结合的方式。而对于vLLM不支持的encoder-decoder结构的模型(主要是机器翻译模型),则通过fast transformer框架(TensorRT)进行部署。
3.2.2 retrieval服务
对于retrieval服务,尝试了E5-Mistral,bge-m3。E5-Mistral的优势在于它的“编码”是由decoder-only框架得到的,可以轻松的利用vLLM等主流加速框架实现加速。但bge-m3也可以利用HF的text-embedding项目进行部署,从效果来看还是bge-m3更佳。
另外,最近比较火的Jina系列效果也非常好,且效率很高,但缺点是其模型没有开源,只提供API。
3.2.3 reranker服务
Reranker服务尝试了bge-reranker和Jina,但考虑到Jina只提供线上的API,最后还是选择使用bge-reranker。
3.3 Agent系统
3.3.1 Planner
Planner模块相对简单,通过将prompt送入LLM服务,得到由Planner发出的Post。
Planner的prompt由以下几部分构成:
-
- 任务说明,指令要求等
-
- 输入输出格式的例子
-
- 根据query召回的可供参考的经验(当相似度阈值过低时,此项缺省)
-
- 先前round的总结(当出于conversation的第一个round时,此项缺省)
-
- 当AI助手是从数据视图唤起时,相关数据对象的schema
Planner生成的结果是一个JSON结构,并将其解析为一个post对象。其中包含了一个“Plan”类型的attachment。
在TaskWeaver的设计中,包含一个"init_plan"和一个"plan",后者是让LLM对前者进行合并压缩步骤得到的结果。为了提高执行效率,我将这一概念取消了,只有一个"plan",并且鼓励LLM将plan的步骤划分的更细(并非引入无关步骤)。
Planner所创建的这个post对象,其send_from
必定为Planner,但其send_to
却有可能是Code/SQL Generator,也有可能直接发送给Responser。
当满足以下条件之一时,post将被发送给Responser:
-
- 当前步骤是整个plan中的最后一步
-
- 当前步骤的内容中包含了"Inform", "report"等关键词
此外,相比于TaskWeaver项目中,每次执行完一个步骤之后,将内容重新发送给Planner重新制定计划,本项目采用了在一开始制定一个计划并尝试将其按步骤以此执行完成,这样既减少了反复调用LLM的次数,提高了系统的工作效率,又避免了由于LLM性能问题引起的制定的重复制定相同步骤的计划而导致陷入死循环的情况。
3.3.2 Code/SQL Generator
(1)Code Generator
对于代码生成模块,其prompt大概由以下几个部分构成:
-
- 任务说明,生成的JSON的字段格式等;
-
- 有哪些插件工具(python函数)可以无需import就可以直接使用,这些工具作用及使用方法
-
- 插件工具的使用原则和规范
-
- 有哪些python模块是被禁止使用的
-
- 在当前对话中,之前已完成round的历史记录
-
- 在之前已经执行过的代码中,已经存在的变量(以便于当前代码生成时可以直接使用这些变量)
在TaskWeaver的设计中,存在一个修正机制,即如果生成的代码在交给Code Executor执行报错,则会将代码和报错信息重新交给Code Generator进行修正。在本项目中,我移除了这一设计,因为在实际操作中,这种做法容易出现反复报错,有些错误即便重新生成一次代码,还会继续犯相同的错,导致多次生成之后最后执行结果还是不成功,白白浪费了时间和资源,并且这种做法会使得报错信息不断累加到prompt中,使prompt变得冗长的同时,还会干扰LLM原本的生成,反而得不偿失。
相比之下,当遇到执行出错的情况,不如让LLM再尝试一次生成,如果再出错的话,就直接返回任务失败并记录bad case。
(2)SQL Generator
SQL Generator与Code Generator主要的不同点在于,其需要提供所有数据表和各字段的schema,以及表之间的外键关系。形式可以参考各类大模型Text-to-SQL的prompt,大致形式如:
(示例来自于开源项目MAC-SQL,具体形式可以根据实际情况进行调整)
【Database schema】
# Table: frpm
[
(CDSCode, CDSCode. Value examples: ['01100170109835', '01100170112607'].),
(Charter School (Y/N), Charter School (Y/N). Value examples: [1, 0, None]. And 0: N;. 1: Y),
(Enrollment (Ages 5-17), Enrollment (Ages 5-17). Value examples: [5271.0, 4734.0].),
(Free Meal Count (Ages 5-17), Free Meal Count (Ages 5-17). Value examples: [3864.0, 2637.0]. And eligible free rate = Free Meal Count / Enrollment)
]
# Table: satscores
[
(cds, California Department Schools. Value examples: ['10101080000000', '10101080109991'].),
(sname, school name. Value examples: ['None', 'Middle College High', 'John F. Kennedy High', 'Independence High', 'Foothill High'].),
(NumTstTakr, Number of Test Takers in this school. Value examples: [24305, 4942, 1, 0, 280]. And number of test takers in each school),
(AvgScrMath, average scores in Math. Value examples: [699, 698, 289, None, 492]. And average scores in Math),
(NumGE1500, Number of Test Takers Whose Total SAT Scores Are Greater or Equal to 1500. Value examples: [5837, 2125, 0, None, 191]. And Number of Test Takers Whose Total SAT Scores Are Greater or Equal to 1500. . commonsense evidence:. . Excellence Rate = NumGE1500 / NumTstTakr)
]
【Foreign keys】
frpm.`CDSCode` = satscores.`cds`
【Question】
List school names of charter schools with an SAT excellence rate over the average.
【Evidence】
Charter schools refers to `Charter School (Y/N)` = 1 in the table frpm; Excellence rate = NumGE1500 / NumTstTakr
这样一来有一个显而易见的问题,当对话的场景是需要多针对张表问答(跨表查询的问题,或知识图谱的场景),或者某些表过于宽,字段太多时,那么prompt的长度会随着所有字段的数量大致线性增长,这样既会使得LLM的生成质量下降,又会导致推理变慢,带来不好的用户体验。
因此对有效表格和字段的筛选是必要的,为了提高筛选的效率,可以以如下的方式设计筛选步骤:
-
- 给定用户query和一张表的描述和字段信息,判断这张表是否对解决用户的query是否有帮助
-
- 对一张有价值的数据表,进一步判断其中某个字段是否对解决用户query有帮助
在判断时,可以采用sentence-pair的判别式任务进行finetune,也可以直接使用相似度计算卡阈值,或使用LLM进行判断。
由于以上的两个步骤都可以进行并行,且第1步的筛选会降低第2步需要计算的数量,所以整体而言这个判断过程的效率比较高的。在此我所采用的方案是使用LLM判断,可以通过添加logits processor将LLM生成的空间限制在Yes和No两个token,通过添加stopping criteria来限制LLM的max new token限制为1,进一步提高推理效率。
3.3.3 Code Executor
Code Executor的执行分为两种情况。
一是接收到的是python代码时,代码将直接被执行。
二是接收到的是SQL语句,将其融入到python代码中,以类似于如下的形式执行:
exec_code = 'cursor.execute("""{}""")'.format(sql_code)
exec_code += '\ndf = pd.DataFrame(list(cursor.fetchall()))'
exec_code += '\nprint(df.to_markdown())'
exec_result = self.env.execute_code(session_id=self.session_id,
code=exec_code)
其中数据库的连接和cursor的创建在会话创建的时候就已经完成,所以此处可以直接执行SQL语句。
3.3.4 Responser
Responser模块相对比较简单,其prompt构成大概包括:
-
- responser角色的介绍,总体任务和其他角色的介绍
-
- 当前query以及当前round中的各种消息和结果总结
-
- 输入输出的规范以及示例
需要注意的是,对于有些变量结果的呈现,尤其是数据查询相关的操作,我们并不希望模型根据之前代码执行结果的历史,重新将这部分结果复述一遍,因为这样做的话一来在生成过程中可能引入不必要错误,二来有些长文本(数据表格)等,会占用很长的生成时间。
所以根据本系统“高效”和“准确性”的设计原则,我们在prompt中鼓励模型生成占位符,将Code Executor生成的结果直接渲染进去,例如,Responser生成的结果样式可能为:
Here is the data found for you: \n{markdown}
然后将其中的占位符markdown替换为真实的数据查询结果。
3.3.5 Round Compressor
对于当前conversation中,之前的round的压缩总结,和之前历史conversation的压缩总结,可以直接采用LLM实现,LLM非常擅长摘要总结类工作,且这部分的prompt也不会特别长,只需要提供简单的例子,LLM就可以很好地完成任务。
3.3.6 New Turn Discriminator
在对是否为新一轮次的判断时,可以通过query之间的相关性直接判断,也可以利用LLM进行判断。
在使用LLM时,可以令LLM直接只生成True或False。
3.3.7 Query Rewriter
对于用户query的改写,直接使用LLM是比较好的方案,目前我们还没有尝试进行SFT,但LLM本身的能力对于去除表述不规范问题基本也够用了。
3.3.8 Generate Type Discriminator
生成类型的判断(判断是生成python代码还是SQL语句),相对于之前的几个辅助任务,难度会大一点,尤其是对于需要判断用户是在针对本地的某些数据类进行查询还是本地数据之外的查询(触发搜索和RAG)。
当直接使用LLM判断True or False时,效果是不太能够满足要求的,于是采用了类似chain of thought的方式,然LLM显式地给出判断的理由,这样一来准确性会有较大的提升:
>>"载员最多的飞机是哪个国家的"
>>{
"thought": "The user is asking about the aircraft with the most crew members and which country it belongs to. The database schema includes tables related to aircraft and their operators, so the query is related to the database.",
"result": "True"
}
3.4 提示词与经验管理
这部分的包括了各类prompt的维护,以及各种向量编码的保存。
3.4.1 提示词前缀缓存
对于每个功能组件(Planner,Code Generator,Round Compressor等),都包含有一段冗长的提示词,在每次调用当前组件时,都会重复计算了这部分的KV。那么一个很直接的想法就是,把这部分公共的内容提前计算好,就可以很大程度上减少计算量。
在本地LLM部署时,我们可以采用vLLM自身的功能,通过将enable_prefix_caching
参数设置为True,即可轻松实现这一功能,其实现原理是LRU缓存+前缀树匹配的方式。
其做法可以大致总结为:
- (1)即在一个新的用户query到达时,会最大程度的与缓存队列中的所有序列进行匹配,以前缀树的形式,匹配到最长公共前缀的序列;
- (2)取到这个序列对应的KV Cache,并将该序列放到队列首;
- (3)按照时间先后,最近一次被取用的序列排在队首,最久没有被取用到的序列排在队尾;
- (4)当队列满时,排在队尾的序列将被逐出。
在实例化vLLM模型的时候,我们只需要添加enable_prefix_caching
即可:
llm = LLM(model="llm_model_path", trust_remote_code=True,
tensor_parallel_size=4,
gpu_memory_utilization=0.9,
enable_prefix_caching=True)
对于线上API服务,一般对Prompt部分的token定价和新生成的token的定价是一致的,这样就会造成长prompt的情况的费用还是相对昂贵的。前段时间kimi推出了上下文缓存(Context Caching)的收费模式,以时长*token数量的方式进行计价,简单计算一下成本,如果在占用期间频繁调用模型的话,是比原本不缓存的形式的要便宜的,但是如果服务一直启动着而query的次数又没有那么频繁的话,那就得不偿失了。是否采用这种方式还需要根据实际的业务场景来决定。
3.4.2 提示词编写原则
每个组件的Prompt可能有多个部分构成,例如,对于Code Generator而言,如前文所述,prompt的构成可能包含以下几个部分:
1. 任务说明,生成的JSON的字段格式等;
2. 有哪些插件工具(python函数)可以无需import就可以直接使用,这些工具作用及使用方法
3. 插件工具的使用原则和规范
4. 有哪些python模块是被禁止使用的
5. 在当前对话中,之前已完成round的历史记录
6. 在之前已经执行过的代码中,已经存在的变量(以便于当前代码生成时可以直接使用这些变量)
这些部分的内容拼接在一起,总长就比较长了,那么在组织这些文本的时候,我们主要考虑两个原则:
(1)避免Lost in middle现象
众多实验已经表明,大模型对prompt的理解具有Lost in middle的现象,即prompt的首位部分的信息,模型对其关注度较高,而中间部分的信息则更容易被忽视。所以在编排prompt内容的时候,我们尽可能将重要的信息(尤其是格式要求等指令信息),放在模型的收尾部分。
(2)尽可能保持长公共前缀
正如前文所述,在推理时,要尽可能复用KV Cache,而LLM在生成时是逐个token生成的,所以我们需要尽可能地让更多的前缀被匹配到。所以在构建prompt时,也应当尽量让不变的部分(任务描述、输入输出的格式等)放在头部位置,而可能在不同的case中发生变化的部分(当前可用的插件工具等)放在中间和尾部。以此来提高推理的效率。
3.4.3 经验管理
在执行过程中,为了让模型生成的更加准确,我们可以在生成时(Planner和Code Generator时),将过去成功执行过的结果作为提示添加到Planner和Code Generator的Prompt中,用来指导模型的生成。
具体流程设置如下:
-
- 用户输入query,计算query的编码
-
- query编码与过去保存的编码召回topk
-
- 召回的topk与query拼接做经过reranker重排,大于阈值的保存
-
- 满足条件的经验拼接到prompt中指导当前模型的生成
-
- 执行流程,如果最终顺利执行完整个流程,则将当前query及对应编码保存在向量数据库中。
这个过程属于可选项,通过引入这个过程可以使得过去对话过程中积累的经验对模型的生成有所帮助,但是也有缺点,例如会占用额外的召回的时间,或者有时候经验的引入反而会给模型带来错误的指导。
值得注意的是,query的编码在选择组件工具的时候也会用到,所以二者只编码一次即可。
3.5 组件工具模块
组件工具使得Agent能执行各种复杂任务,这部分的实现依靠TaskWeaver项目开源的代码,每一个工具将被抽象成一个python function。当此工具被选中时,该工具对应的function将被加载到系统的环境中。
具体的执行逻辑如下:
-
- 用户输入query,计算query的编码
-
- query的编码与工具库里每个工具的描述对应的编码做相似度召回,取topk
-
- 召回的工具描述与query做rerank,保留满足阈值条件的工具
-
- 将被选中的工具对应的function加载到代码执行环境中
-
- 将被选中的工具的描述信息添加到Code Generator的prompt中
-
- Code Generator生成代码,以及后续过程
需要注意的是,在工具中可能会用到一些额外的python模块,而系统的基础环境可能并不满足这些模块,或可能出现模块版本冲突等情况,所以在定义工具对应的python function时,我们可以以api接口的形式来实现具体的功能。
4. 总结
目前Agent系统已经有很多优秀的成熟的开源框架可以使用,例如Dify,XAgent,AutoGen等,还有本文所参考的TaskWeaver,本文之所以再做这样一个系统,主要是考虑到很多Agent系统都已经封装的比较复杂,改造起来难度很大,并且本项目是纯python实现较为简单,可以随时根据使用需求进行修改。
通过这样的改造,就可以更加灵活方便的与其他自研产品实现对接,为后续的其他工作提供基础。
目前这个系统还没有达到成熟的阶段,还有很多优化的工作需要完成,例如,需要一个好用的本地部署的LLM提供生成服务,以及提高并行度的一系列工作,例如考虑如何在任务执行的过程中,将plan中独立的step分别交给多个Code Generator,同时生成代码。