Fork me on GitHub

分类 大模型 下的文章

基于结构化数据的文档问答

利用大模型打造文档问答系统对于个人和企业来说都是一个非常重要的应用场景,也是各大公司争相推出的基于大模型的落地产品之一,同时,在开源领域,文档问答也是非常火热,涌现出了一大批与之相关的开源项目,比如:QuivrPrivateGPTdocument.aiFastGPTDocsGPT 等等。我在 使用 Embedding 技术打造本地知识库助手 这篇笔记中介绍了文档问答的基本原理,通过 OpenAI 的 Embedding 接口实现了一个最简单的本地知识库助手,并在 大模型应用开发框架 LangChain 学习笔记 这篇笔记中通过 LangChain 的 RetrievalQA 再次实现了基于文档的问答,还介绍了四种处理大文档的方法(Stuff Refine MapReduceMapRerank)。

大抵来说,这类文档问答系统基本上都是基于 Embedding 和向量数据库来实现的,首先将文档分割成片段保存在向量库中,然后拿着用户的问题在向量库中检索,检索出来的结果是和用户问题最接近的文档片段,最后再将这些片段和用户问题一起交给大模型进行总结和提炼,从而给出用户问题的答案。在这个过程中,向量数据库是最关键的一环,这也是前一段时间向量数据库火得飞起的原因。

不过,并不是所有的知识库都是以文档的形式存在的,还有很多结构化的知识散落在企业的各种数据源中,数据源可能是 MySQL、Mongo 等数据库,也可能是 CSV、Excel 等表格,还可能是 Neo4j、Nebula 等图谱数据库。如果要针对这些知识进行问答,Embedding 基本上就派不上用场了,所以我们还得想另外的解决方案,这篇文章将针对这种场景做一番粗略的研究。

基本思路

我们知道,几乎每种数据库都提供了对应的查询方法,比如可以使用 SQL 查询 MySQL,使用 VBA 查询 Excel,使用 Cipher 查询 Neo4j 等等。那么很自然的一种想法是,如果能将用户的问题转换为查询语句,就可以先对数据库进行查询得到结果,这和从向量数据库中查询文档是类似的,再将查询出来的结果丢给大模型,就可以回答用户的问题了:

db-qa.png

那么问题来了,如何将用户的问题转换为查询语句呢?毋庸置疑,当然是让大模型来帮忙。

准备数据

首先,我们创建一个测试数据库,然后创建一个简单的学生表,包含学生的姓名、学号、性别等信息:

/*!40101 SET NAMES utf8 */;

CREATE DATABASE IF NOT EXISTS `demo` DEFAULT CHARSET utf8 COLLATE utf8_general_ci;

USE `demo`;

CREATE TABLE IF NOT EXISTS `students`(
   `id` INT UNSIGNED AUTO_INCREMENT,
   `no` VARCHAR(100) NOT NULL,
   `name` VARCHAR(100) NOT NULL,
   `sex` INT NULL,
   `birthday` DATE NULL,
   PRIMARY KEY ( `id` )
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_general_ci;

接着插入 10 条测试数据:

INSERT INTO `students` (`no`, `name`, `sex`, `birthday`) VALUES
('202301030001', '张启文', 1, '2015-04-14'),
('202301030002', '李金玉', 2, '2015-06-28'),
('202301030003', '王海红', 2, '2015-07-01'),
('202301030004', '王可可', 2, '2015-04-03'),
('202301030005', '郑丽英', 2, '2015-10-19'),
('202301030006', '张海华', 1, '2015-01-04'),
('202301030007', '文奇', 1, '2015-11-03'),
('202301030008', '孙然', 1, '2014-12-29'),
('202301030009', '周军', 1, '2015-07-15'),
('202301030010', '罗国华', 1, '2015-08-01');

然后将上面的初始化 SQL 语句放在 init 目录下,通过下面的命令启动 MySQL 数据库:

$ docker run -d -p 3306:3306 --name mysql \
    -v $PWD/init:/docker-entrypoint-initdb.d \
    -e MYSQL_ROOT_PASSWORD=123456 \
    mysql:5.7

将用户问题转为 SQL

接下来,我们尝试一下让大模型将用户问题转换为 SQL 语句。实际上,这被称之为 Text-to-SQL,有很多研究人员对这个课题进行过探讨和研究,Nitarshan Rajkumar 等人在 Evaluating the Text-to-SQL Capabilities of Large Language Models 这篇论文中对各种提示语的效果进行了对比测试,他们发现,当在提示语中使用 CREATE TABLE 来描述数据库表结构时,模型的效果最好。所以我们构造如下的提示语:

from langchain.llms import OpenAI
from langchain.prompts import PromptTemplate

llm = OpenAI(temperature=0.9)

prompt = PromptTemplate.from_template("""根据下面的数据库表结构,生成一条 SQL 查询语句来回答用户的问题:

{schema}

用户问题:{question}
SQL 查询语句:""")

def text_to_sql(schema, question):
    text = prompt.format(schema=schema, question=question)
    response = llm.predict(text)
    return response

这个提示语非常直白,直接将数据库表结构和用户问题丢给大模型,让其生成一条 SQL 查询语句。使用几个简单的问题测试下,发现效果还可以:

schema = "CREATE TABLE ..."
question = "王可可的学号是多少?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT no FROM students WHERE name = '王可可';
question = "王可可今年多大?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT YEAR(CURRENT_DATE) - YEAR(birthday) FROM students WHERE NAME='王可可';
question = "王可可和孙然谁的年龄大?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT NAME, YEAR(CURDATE())-YEAR(birthday) AS age
# FROM students
# WHERE NAME IN ("王可可", "孙然")
# ORDER BY age DESC LIMIT 1;

不过,当我们的字段有特殊含义时,生成的 SQL 语句就不对了,比如这里我们使用 sex=1 表示男生,sex=2 表示女生,但是 ChatGPT 生成 SQL 的时候,认为 sex=0 表示女生:

question = "班上一共有多少个女生?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT COUNT(*) FROM students WHERE sex=0;

为了让大模型知道字段的确切含义,我们可以在数据库表结构中给字段加上注释:

schema = """CREATE TABLE IF NOT EXISTS `students`(
   `id` INT UNSIGNED AUTO_INCREMENT,
   `no` VARCHAR(100) NOT NULL,
   `name` VARCHAR(100) NOT NULL,
   `sex` INT NULL COMMENT '1表示男生,2表示女生',
   `birthday` DATE NULL,
   PRIMARY KEY ( `id` )
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_general_ci;
"""

这样生成的 SQL 语句就没问题了:

question = "班上一共有多少个女生?"
sql = text_to_sql(schema=schema, question=question)
print(sql)

# SELECT COUNT(*) FROM students WHERE sex=2;

根据 Nitarshan Rajkumar 等人的研究,我们还可以对提示语做进一步的优化,比如:

  • 给表结构进行更详细的说明;
  • 在表结构后面加几条具有代表性的示例数据;
  • 增加几个用户问题和对应的 SQL 查询的例子;
  • 使用向量数据库,根据用户问题动态查询相关的 SQL 查询的例子;

执行 SQL

得到 SQL 语句之后,接下来,我们就可以查询数据库了。在 Python 里操作 MySQL 数据库,有两个库经常被人提到:

这两个库的区别在于:PyMySQL 是使用纯 Python 实现的,使用起来简单方便,可以直接通过 pip install PyMySQL 进行安装;mysqlclient 其实就是 Python 3 版本的 MySQLdb,它是基于 C 扩展模块实现的,需要针对不同平台进行编译安装,但正因为此,mysqlclient 的速度非常快,在正式项目中推荐使用它。

这里我们就使用 mysqlclient 来执行 SQL,首先安装它:

$ sudo apt-get install python3-dev default-libmysqlclient-dev build-essential pkg-config
$ pip3 install mysqlclient

然后连接数据库执行 SQL 语句,它的用法和 MySQLdb 几乎完全兼容:

import MySQLdb

def execute_sql(sql):
    result = ''
    db = MySQLdb.connect("192.168.1.44", "root", "123456", "demo", charset='utf8' )
    cursor = db.cursor()
    try:
        cursor.execute(sql)
        results = cursor.fetchall()
        for row in results:
            result += ' '.join(str(x) for x in row) + '\n'
    except:
        print("Error: unable to fetch data")
    db.close()
    return result

注意,大模型生成的 SQL 可能会对数据库造成破坏,所以在生产环境一定要做好安全防护,比如:使用只读的账号,限制返回结果的条数,等等。

回答用户问题

拿到 SQL 语句的执行结果之后,我们就可以再次组织下提示语,让大模型以自然语言的形式来回答用户的问题:

prompt_qa = PromptTemplate.from_template("""根据下面的数据库表结构,SQL 查询语句和结果,以自然语言回答用户的问题:

{schema}

用户问题:{question}
SQL 查询语句:{query}
SQL 查询结果:{result}
回答:""")

def qa(schema, question):
    query = text_to_sql(schema=schema, question=question)
    print(query)
    result = execute_sql(query)
    text = prompt_qa.format(schema=schema, question=question, query=query, result=result)
    response = llm.predict(text)
    return response

测试效果如下:

schema = "CREATE TABLE ..."
question = "王可可的学号是多少?"
answer = qa(schema=schema, question=question)
print(answer)

# 王可可的学号是202301030004。

LangChain

上面的步骤我们也可以使用 LangChain 来实现。

使用 SQLDatabase 获取数据库表结构信息

在 LangChain 的最新版本中,引入了 SQLDatabase 类可以方便地获取数据库表结构信息。我们首先安装 LangChain 的最新版本:

在写这篇博客时,最新版本是 0.0.324,LangChain 的版本更新很快,请随时关注官方文档。

$ pip3 install langchain==0.0.324

然后使用 SQLDatabase.from_uri() 初始化一个 SQLDatabase 实例,由于 SQLDatabase 是基于 SQLAlchemy 实现的,所以参数格式和 SQLAlchemy 的 create_engine 是一致的:

from langchain.utilities import SQLDatabase

db = SQLDatabase.from_uri("mysql+pymysql://root:123456@192.168.1.45:3306/demo?charset=utf8")

然后我们就可以使用 get_table_info() 方法来获取表结构信息:

print(db.get_table_info())

默认情况下该方法会返回数据库中所有表的信息,可以通过 table_names 参数指定只返回某个表的信息:

print(db.get_table_info(table_names=["students"]))

也可以在 SQLDatabase.from_uri() 时通过 include_tables 参数指定:

from langchain.utilities import SQLDatabase

db = SQLDatabase.from_uri("mysql+pymysql://root:123456@192.168.1.45:3306/demo?charset=utf8", include_tables=["students"])

查询结果如下:

CREATE TABLE students (
        id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, 
        no VARCHAR(100) NOT NULL, 
        name VARCHAR(100) NOT NULL, 
        sex INTEGER(11) COMMENT '1表示男生,2表示女生', 
        birthday DATE, 
        PRIMARY KEY (id)
)DEFAULT CHARSET=utf8 ENGINE=InnoDB

/*
3 rows from students table:
id      no      name    sex     birthday
1       202301030001    张启文  1       2015-04-14
2       202301030002    李金玉  2       2015-06-28
3       202301030003    王海红  2       2015-07-01
*/

可以看出 SQLDatabase 不仅查询了表的结构信息,还将表中前三条数据一并返回了,用于组装提示语。

使用 create_sql_query_chain 转换 SQL 语句

接下来我们将用户问题转换为 SQL 语句。LangChain 提供了一个 Chain 来做这个事,这个 Chain 并没有具体的名字,但是我们可以使用 create_sql_query_chain 来创建它:

from langchain.chat_models import ChatOpenAI
from langchain.chains import create_sql_query_chain

chain = create_sql_query_chain(ChatOpenAI(temperature=0), db)

create_sql_query_chain 的第一个参数是大模型,第二个参数是上一节创建的 SQLDatabase,注意这里大模型的参数 temperature=0,因为我们希望大模型生成的 SQL 语句越固定越好,而不是随机变化。

得到 Chain 之后,就可以调用 chain.invoke() 将用户问题转换为 SQL 语句:

response = chain.invoke({"question": "班上一共有多少个女生?"})
print(response)

# SELECT COUNT(*) AS total_female_students
# FROM students
# WHERE sex = 2

其实,create_sql_query_chain 还有第三个参数,用于设置提示语,不设置的话将使用下面的默认提示语:

PROMPT_SUFFIX = """Only use the following tables:
{table_info}

Question: {input}"""

_mysql_prompt = """You are a MySQL expert. Given an input question, first create a syntactically correct MySQL query to run, then look at the results of the query and return the answer to the input question.
Unless the user specifies in the question a specific number of examples to obtain, query for at most {top_k} results using the LIMIT clause as per MySQL. You can order the results to return the most informative data in the database.
Never query for all columns from a table. You must query only the columns that are needed to answer the question. Wrap each column name in backticks (`) to denote them as delimited identifiers.
Pay attention to use only the column names you can see in the tables below. Be careful to not query for columns that do not exist. Also, pay attention to which column is in which table.
Pay attention to use CURDATE() function to get the current date, if the question involves "today".

Use the following format:

Question: Question here
SQLQuery: SQL Query to run
SQLResult: Result of the SQLQuery
Answer: Final answer here

"""

MYSQL_PROMPT = PromptTemplate(
    input_variables=["input", "table_info", "top_k"],
    template=_mysql_prompt + PROMPT_SUFFIX,
)

执行 SQL 语句并回答用户问题

得到 SQL 语句之后,我们就可以通过 SQLDatabase 运行它:

result = db.run(response)
print(result)

# [(4,)]

然后再重新组织提示语,让大模型以自然语言的形式对用户问题进行回答,跟上面类似,此处略过。

使用 SQLDatabaseChain 实现数据库问答

不过 LangChain 提供了更方便的方式实现数据库问答,那就是 SQLDatabaseChain,可以将上面几个步骤合而为一。不过 SQLDatabaseChain 目前还处于实验阶段,我们需要先安装 langchain_experimental

$ pip3 install langchain_experimental==0.0.32

然后就可以使用 SQLDatabaseChain 来回答用户问题了:

from langchain.utilities import SQLDatabase
from langchain.llms import OpenAI
from langchain_experimental.sql import SQLDatabaseChain

db = SQLDatabase.from_uri("mysql+pymysql://root:123456@192.168.1.45:3306/demo?charset=utf8")
llm = OpenAI(temperature=0, verbose=True)
db_chain = SQLDatabaseChain.from_llm(llm, db, verbose=True)

response = db_chain.run("班上一共有多少个女生?")
print(response)

我们通过 verbose=True 参数让 SQLDatabaseChain 输出执行的详细过程,结果如下:

> Entering new SQLDatabaseChain chain...
班上一共有多少个女生?
SQLQuery:SELECT COUNT(*) FROM students WHERE sex = 2;
SQLResult: [(4,)]
Answer:班上一共有4个女生。
> Finished chain.
班上一共有4个女生。

注意:SQLDatabaseChain 会一次性查询出数据库中所有的表结构,然后丢给大模型生成 SQL,当数据库中表较多时,生成效果可能并不好,这时最好手工指定使用哪些表,再生成 SQL,或者使用 SQLDatabaseSequentialChain,它第一步会让大模型确定该使用哪些表,然后再调用 SQLDatabaseChain

使用 SQL Agent 实现数据库问答

大模型应用开发框架 LangChain 学习笔记(二) 这篇笔记中,我们学习了 LangChain 的 Agent 功能,借助 ReAct 提示工程或 OpenAI 的 Function Calling 技术,可以让大模型具有推理和使用外部工具的能力。很显然,如果我们将数据库相关的操作都定义成一个个的工具,那么通过 LangChain Agent 应该也可以实现数据库问答功能。

LangChain 将数据库相关的操作封装在 SQLDatabaseToolkit 工具集中,我们可以直接使用:

from langchain.agents.agent_toolkits import SQLDatabaseToolkit
from langchain.sql_database import SQLDatabase
from langchain.llms.openai import OpenAI

db = SQLDatabase.from_uri("mysql+pymysql://root:123456@192.168.1.45:3306/demo?charset=utf8")
toolkit = SQLDatabaseToolkit(db=db, llm=OpenAI(temperature=0))

这个工具集中实际上包含了四个工具:

工具名工具类工具说明
sql_db_list_tablesListSQLDatabaseTool查询数据库中所有表名
sql_db_schemaInfoSQLDatabaseTool根据表名查询表结构信息和示例数据
sql_db_queryQuerySQLDataBaseTool执行 SQL 返回执行结果
sql_db_query_checkerQuerySQLCheckerTool使用大模型分析 SQL 语句是否正确

另外,LangChain 还提供了 create_sql_agent 方法用于快速创建一个用于处理数据库的 Agent:

from langchain.agents import create_sql_agent
from langchain.agents.agent_types import AgentType

agent_executor = create_sql_agent(
    llm=OpenAI(temperature=0),
    toolkit=toolkit,
    verbose=True,
    agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
)

其中 agent_type 只能选 ZERO_SHOT_REACT_DESCRIPTIONOPENAI_FUNCTIONS 这两种,实际上就对应着 ZeroShotAgentOpenAIFunctionsAgent,在使用上和其他的 Agent 并无二致:

response = agent_executor.run("班上一共有多少个女生?")
print(response)

执行结果如下:

> Entering new AgentExecutor chain...
Thought: I should query the database to get the answer.
Action: sql_db_list_tables
Action Input: ""
Observation: students
Thought: I should query the schema of the students table.
Action: sql_db_schema
Action Input: "students"
Observation: 
CREATE TABLE students (
        id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, 
        no VARCHAR(100) NOT NULL, 
        name VARCHAR(100) NOT NULL, 
        sex INTEGER(11) COMMENT '1表示男生,2表示女生', 
        birthday DATE, 
        PRIMARY KEY (id)
)DEFAULT CHARSET=utf8 ENGINE=InnoDB

/*
3 rows from students table:
id      no      name    sex     birthday
1       202301030001    张启文  1       2015-04-14
2       202301030002    李金玉  2       2015-06-28
3       202301030003    王海红  2       2015-07-01
*/
Thought: I should query the database to get the number of female students.
Action: sql_db_query
Action Input: SELECT COUNT(*) FROM students WHERE sex = 2
Observation: [(4,)]
Thought: I now know the final answer.
Final Answer: 班上一共有4个女生。

> Finished chain.
班上一共有4个女生。

可以看出整个执行过程非常流畅,首先获取数据库中的表,然后查询表结构,接着生成 SQL 语句并执行,最后得到用户问题的答案。

使用 SQL Agent 比 SQLDatabaseChain 要灵活的多,我们不仅可以实现数据库问答,还可以实现一些其他功能,比如 SQL 生成,SQL 校验,SQL 解释和优化,生成数据库描述,等等,我们还可以根据需要在工具集中添加自己的工具,扩展出更丰富的功能。

LangChain 的 这篇文档中 就给出了两个拓展工具集的例子,我觉得很有参考意义:

  • Including dynamic few-shot examples:这个例子将一些用户问题和对应的 SQL 示例存储到向量数据库中,然后创建一个额外的名为 sql_get_similar_examples 的工具用于从向量库中获取类似示例,并将提示语修改为:先从向量库中查找类似的示例,判断示例能否构造出回答用户问题的 SQL 语句,如果能,直接通过示例构造出 SQL 语句,如果不能,则通过查询数据库的表结构来构造;
  • Finding and correcting misspellings for proper nouns:这也是一个很实用的示例,用户在提问时往往会输入一些错别字,特别是人物名称、公司名称或地址信息等专有名词,比如将 张晓红今年多大? 写成了 张小红今年多大?,这时直接搜索数据库肯定是搜不出结果的。在这个例子中,首先将数据库中所有艺人名称和专辑名称存储到向量数据库中,然后创建了一个额外的名为 name_search 的工具用于从向量库中获取近似的名称,并将提示语修改为:如果用户问题设计到专有名词,首先搜索向量库判断名称的拼写是否有误,如果拼写有误,要使用正确的名称构造 SQL 语句来回答用户的问题。

一些开源项目

目前市面上已经诞生了大量基于结构化数据的问答产品,比如 酷表ExcelSheet+Julius AI 等,它们通过聊天的方式来操控 Excel 或 Google Sheet 表格,还有 AI QueryAI2sql 等,它们将自然语言转化为可以执行的 SQL 语句,让所有数据库小白也可以做数据分析。

在开源社区,类似的项目也是百花齐放,比如 sql-translatortextSQLsqlchatChat2DBDB-GPT 等等,其中热度最高的当属 Chat2DB 和 DB-GPT 这两个开源项目。

Chat2DB

Chat2DB 是一款智能的数据库客户端软件,和 Navicat、DBeaver 相比,Chat2DB 集成了 AIGC 的能力,能够将自然语言转换成 SQL,也可以将 SQL 翻译成自然语言,或对 SQL 提出优化建议;此外,Chat2DB 还集成了报表能力,用户可以用对话的形式进行数据统计和分析。

Chat2DB 提供了 Windows、Mac、Linux 等平台的安装包,也支持以 Web 形式进行部署,我们直接通过官方镜像安装:

$ docker run --name=chat2db -ti -p 10824:10824 chat2db/chat2db:latest

启动成功后,访问 http://localhost:10824 会进入 Chat2DB 的登录页面,默认的用户名和密码是 chat2db/chat2db,登录成功后,添加数据库连接,然后可以创建新的表,查看表结构,查看表中数据,等等,这和传统的数据库客户端软件并无二致:

chat2db-console.png

和传统的数据库客户端软件相比,Chat2DB 最重要的一点区别在于,用户可以在控制台中输入自然语言,比如像下面这样,输入 查询王可可的学号 并回车,这时会自动生成 SQL 语句,点击执行按钮就可以得到结果:

chat2db-generate-sql-select.png

通过这种方式来管理数据库让人感觉很自然,即使数据库小白也能对数据库进行各种操作,比如要创建一个表:

chat2db-generate-sql-create-table.png

插入一些测试数据:

chat2db-generate-sql-insert.png

遇到不懂的 SQL 语句,用户还可以对 SQL 语句进行解释和优化,整个体验可以说是非常流畅,另外,Chat2DB 还支持通过自然语言的方式生成报表,比如柱状图、折线图、饼图等,便于用户进行数据统计和分析:

chat2db-dashboard.png

默认情况下,Chat2DB 使用的是 Chat2DB AI 接口,关注官方公众号后就可以免费使用,我们也可以切换成 OpenAI 或 AzureAI 接口,或使用自己部署的大模型接口,具体内容请参考官方提供的 ChatGLM-6Bsqlcoder 的部署方法。

DB-GPT

DB-GPT 是一款基于知识库的问答产品,它同时支持结构化和非结构化数据的问答,支持生成报表,还支持自定义插件,在交互形式上和 ChatGPT 类似。它的一大特点是支持海量的模型管理,包括开源模型和 API 接口,并支持模型的自动化微调。

DB-GPT 的侧重点在于私有化,它强调数据的隐私安全,可以做到整个系统都不依赖于外部环境,完全避免了数据泄露的风险,是一款真正意义上的本地知识库问答产品。它集成了常见的开源大模型和向量数据库,因此,在部署上复杂一点,而且对硬件的要求也要苛刻一点。不过对于哪些没有条件部署大模型的用户来说,DB-GPT 也支持直接 使用 OpenAI 或 Bard 等接口

DB-GPT 支持 从源码安装从 Docker 镜像安装,不过官方提供的 Docker 镜像缺少 openai 等依赖,需要我们手工安装,所以不建议直接启动,而是先通过 bash 进入容器做一些准备工作:

$ docker run --rm -ti -p 5000:5000 eosphorosai/dbgpt:latest bash

安装 openai 依赖:

# pip3 install openai

然后设置一些环境变量,让 DB-GPT 使用 OpenAI 的 Completions 和 Embedding 接口:

# export LLM_MODEL=chatgpt_proxyllm
# export PROXY_SERVER_URL=https://api.openai.com/v1/chat/completions
# export PROXY_API_KEY=sk-xx
# export EMBEDDING_MODEL=proxy_openai
# export proxy_openai_proxy_server_url=https://api.openai.com/v1
# export proxy_openai_proxy_api_key=sk-xxx

如果由于网络原因导致 OpenAI 接口无法访问,还需要配置代理(注意先安装 pysocks 依赖):

# pip3 install pysocks
# export https_proxy=socks5://192.168.1.45:7890
# export http_proxy=socks5://192.168.1.45:7890

一切准备就绪后,启动 DB-GPT server:

# python3 pilot/server/dbgpt_server.py

等待服务启动成功,访问 http://localhost:5000/ 即可进入 DB-GPT 的首页:

dbgpt-home.png

DB-GPT 支持几种类型的聊天功能:

  • Chat Data
  • Chat Excel
  • Chat DB
  • Chat Knowledge
  • Dashboard
  • Agent Chat

Chat DB & Chat Data & Dashboard

Chat DB、Chat Data 和 Dashboard 这三个功能都是基于数据库的问答,要使用它们,首先需要在 数据库管理 页面添加数据库。Chat DB 会根据数据库和表的结构信息帮助用户编写 SQL 语句:

dbgpt-chat-db.png

Chat Data 不仅会生成 SQL 语句,还会自动执行并得到结果:

dbgpt-chat-data.png

Dashboard 则比 Chat Data 更进一步,它会生成 SQL 语句,执行得到结果,并生成相应的图表:

dbgpt-chat-dashboard.png

Chat Excel

Chat Excel 功能依赖 openpyxl 库,需要提前安装:

# pip3 install openpyxl

然后就可以上传 Excel 文件,对其进行分析,并回答用户问题:

dbgpt-chat-excel.png

其他功能

DB-GPT 除了支持结构化数据的问答,也支持非结构化数据的问答,Chat Knowledge 实现的就是这样的功能。要使用它,首先需要在 知识库管理 页面添加知识,DB-GPT 支持从文本,URL 或各种文档中导入:

dbgpt-chat-kb.png

然后在 Chat Knowledge 页面就可以选择知识库进行问答了。

DB-GPT 还支持插件功能,你可以从 DB-GPT-Plugins 下载插件,也可以 编写自己的插件并上传,而且 DB-GPT 兼容 Auto-GPT 的插件 接口,原则上,所有的 Auto-GPT 插件都可以在这里使用:

dbgpt-chat-plugins.png

然后在 Agent Chat 页面,就可以像 ChatGPT Plus 一样,选择插件进行问答了。

另外,DB-GPT 的模型管理功能也很强大,不仅支持像 OpenAI 或 Bard 这样的大模型代理接口,还集成了大量的开源大模型,而且在 DB-GPT-Hub 项目中还提供了大量的数据集、工具和文档,让我们可以对这些大模型进行微调,实现更强大的 Text-to-SQL 能力。

参考

更多

基于其他结构化数据源的文档问答

Neo4j

Elasticsearch

CSV

Excel

基于半结构化和多模数据源的文档问答

学习 LCEL

在 LangChain 中,我们还可以通过 LCEL(LangChain Expression Language) 来简化 Chain 的创建,比如对数据库进行问答,官方有一个示例,可以用下面这样的管道式语法来写:

full_chain = (
    RunnablePassthrough.assign(query=sql_response)
    | RunnablePassthrough.assign(
        schema=get_schema,
        response=lambda x: db.run(x["query"]),
    )
    | prompt_response
    | model
)
扫描二维码,在手机上阅读!

大模型应用开发框架 LangChain 学习笔记(二)

上一篇笔记 中,我们学习了 LangChain 中的一些基础概念:使用 LLMsChatModels 实现基本的聊天功能,使用 PromptTemplate 组装提示语,使用 Document loadersDocument transformersText embedding modelsVector storesRetrievers 实现文档问答;然后,我们又学习了 LangChain 的精髓 Chain,以及 Chain 的三大特性:使用 Memory 实现 Chain 的记忆功能,使用 RetrievalQA 组合多个 Chain 再次实现文档问答,使用 Callbacks 对 Chain 进行调试;最后,我们学习了四个基础 Chain:LLMChainTransformChainSequentialChainRouterChain,使用这四个 Chain 可以组装出更复杂的流程,其中 RouterChainMultiPromptChain 为我们提出了一种新的思路,使用大模型来决策 Chain 的调用链路,可以动态地解决用户问题;更进一步我们想到,大模型不仅可以动态地选择调用 Chain,也可以动态地选择调用外部的函数,而且使用一些提示语技巧,可以让大模型变成一个推理引擎,这便是 Agent

OpenAI 的插件功能

在学习 LangChain 的 Agent 之前,我们先来学习一下 OpenAI 的插件功能,这可以让我们对 Agent 的基本概念和工作原理有一个更深入的了解。

ChatGPT Plugins

2023 年 3 月 23 日,OpenAI 重磅推出 ChatGPT Plugins 功能,引起了全球用户的热议。众所周知,GPT-3.5 是使用 2021 年之前的历史数据训练出来的大模型,所以它无法回答关于最新新闻和事件的问题,比如你问它今天是星期几,它只能让你自己去查日历:

day-of-the-week.png

不仅如此,ChatGPT 在处理数学问题时也表现不佳,而且在回答问题时可能会捏造事实,胡说八道;另一方面,虽然 ChatGPT 非常强大,但它终究只是一个聊天机器,如果要让它成为真正的私人助理,它还得帮助用户去做一些事情,解放用户的双手。引入插件功能后,就使得 ChatGPT 具备了这两个重要的能力:

  • 访问互联网:可以实时检索最新的信息以回答用户问题,比如调用搜索引擎接口,获取和用户问题相关的新闻和事件;也可以访问用户的私有数据,比如公司内部的文档,个人笔记等,这样通过插件也可以实现文档问答;
  • 执行任务:可以了解用户的意图,代替用户去执行任务,比如调用一些三方服务的接口订机票订酒店等;

暂时只有 GPT-4 才支持插件功能,所以要体验插件功能得有个 ChatGPT Plus 账号。截止目前为止,OpenAI 的插件市场中已经开放了近千个插件,如果我们想让 ChatGPT 回答今天是星期几,可以开启其中的 Wolfram 插件:

chatgpt-4-plugins.png

Wolfram|Alpha 是一个神奇的网站,建立于 2009 年,它是一个智能搜索引擎,它上知天文下知地理,可以回答关于数学、物理、化学、生命科学、计算机科学、历史、地理、音乐、文化、天气、时间等等方面的问题,它的愿景是 Making the world's knowledge computable,让世界的知识皆可计算。Wolfram 插件就是通过调用 Wolfram|Alpha 的接口来实现的,开启 Wolfram 插件后,ChatGPT 就能准确回答我们的问题了:

day-of-the-week-with-plugins.png

从对话的结果中可以看到 ChatGPT 使用了 Wolfram 插件,展开插件的调用详情还可以看到调用的请求和响应:

wolfram-detail.png

结合插件功能,ChatGPT 不再是一个简单的聊天对话框了,它有了一个真正的生态环境,网上有这样一个比喻,如果说 ChatGPT 是 AI 时代的 iPhone,那么插件就是 ChatGPT 的 App Store,我觉得这个比喻非常贴切。通过插件机制,ChatGPT 可以连接成千上万的第三方应用,向各个行业渗透,带给我们无限的想象力。

开发自己的插件

目前 ChatGPT 的插件功能仍然处于 beta 版本,OpenAI 还没有完全开放插件的开发功能,如果想要体验开发 ChatGPT 插件的流程,需要先 加入等待列表

开发插件的步骤大致如下:

  1. ChatGPT 插件其实就是标准的 Web 服务,可以使用任意的编程语言开发,开发好插件服务之后,将其部署到你的域名下;
  2. 准备一个清单文件 .well-known/ai-plugin.json 放在你的域名下,清单文件中包含了插件的名称、描述、认证信息、以及所有插件接口的信息等;
  3. 在 ChatGPT 的插件中心选择 Develop your own plugin,并填上你的插件地址;
  4. 开启新会话时,先选择并激活你的插件,然后就可以聊天了;如果 ChatGPT 认为用户问题需要调用你的插件(取决于插件和接口的描述),就会调用你在插件中定义的接口;

其中前两步应该是开发者最为关心的部分,官网提供了一个入门示例供我们参考,这个示例是一个简单的 TODO List 插件,可以让 ChatGPT 访问并操作我们的 TODO List 服务,我们就以这个例子来学习如何开发一个 ChatGPT 插件。

首先我们使用 Python 语言开发好 TODO List 服务,支持 TODO List 的增删改查。

然后准备一个插件的清单文件,对我们的插件进行一番描述,这个清单文件的名字必须是 ai-plugin.json,并放在你的域名的 .well-known 路径下,比如 https://your-domain.com/.well-known/ai-plugin.json。文件的内容如下:

{
    "schema_version": "v1",
    "name_for_human": "TODO List",
    "name_for_model": "todo",
    "description_for_human": "Manage your TODO list. You can add, remove and view your TODOs.",
    "description_for_model": "Help the user with managing a TODO list. You can add, remove and view your TODOs.",
    "auth": {
        "type": "none"
    },
    "api": {
        "type": "openapi",
        "url": "http://localhost:3333/openapi.yaml"
    },
    "logo_url": "http://localhost:3333/logo.png",
    "contact_email": "support@example.com",
    "legal_info_url": "http://www.example.com/legal"
}

清单中有些信息是用于展示在 OpenAI 的插件市场的,比如 name_for_humandescription_for_humanlogo_urlcontact_emaillegal_info_url 等,有些信息是要送给 ChatGPT 的,比如 name_for_modeldescription_for_modelapi 等;送给 ChatGPT 的信息需要仔细填写,确保 ChatGPT 能理解你这个插件的用途,这样 ChatGPT 才会在对话过程中根据需要调用你的插件。

然后我们还需要准备插件的接口定义文件,要让 ChatGPT 知道你的插件都有哪些接口,每个接口的作用是什么,以及每个接口的入参和出参是什么。一般使用 OpenAPI 规范 来定义插件的接口,下面是一个简单的示例,定义了一个 getTodos 接口用于获取所有的 TODO List:

openapi: 3.0.1
info:
  title: TODO Plugin
  description: A plugin that allows the user to create and manage a TODO list using ChatGPT.
  version: 'v1'
servers:
  - url: http://localhost:3333
paths:
  /todos:
    get:
      operationId: getTodos
      summary: Get the list of todos
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/getTodosResponse'
components:
  schemas:
    getTodosResponse:
      type: object
      properties:
        todos:
          type: array
          items:
            type: string
          description: The list of todos.

一切准备就绪后,就可以在 ChatGPT 的插件中心填上你的插件地址并调试了。

除了入门示例,官网还提供了一些其他的 插件示例,其中 Chatgpt Retrieval Plugin 是一个完整而复杂的例子,对我们开发真实的插件非常有参考价值。

当然,还有很多插件的内容没有介绍,比如 插件的最佳实践用户认证 等,更多信息可以参考 OpenAI 的插件手册

Function Calling

尽管 ChatGPT 的插件功能非常强大,但是它只能在 ChatGPT 页面中使用,这可能是出于 OpenAI 的私心,OpenAI 的野心很大,它对 ChatGPT 的定位,就是希望将其做成整个互联网的入口,其他的应用都对接到 ChatGPT 的生态中来。不过很显然,这种脑洞大开的想法有点太过超前了,其他的互联网厂商也不傻,谁都知道流量入口的重要性,怎么会轻易将自己的应用入口交给其他人呢?对于其他的互联网厂商来说,他们更希望将 ChatGPT 的能力(包括插件能力)集成到自己的应用中来。

2023 年 6 月 13 日,这种想法变成了可能,这一天,OpenAI 对 GPT 模型进行了一项重大更新,推出了 Function Calling 功能,在 Chat Completions API 中添加了新的函数调用能力,帮助开发者通过 API 的方式实现类似于 ChatGPT 插件的数据交互能力。

基于 ChatGPT 实现一个划词翻译 Chrome 插件 这篇笔记中,我们已经学习过 OpenAI 的 Chat Completions API,感兴趣的同学可以复习下。

使用 Function Calling 回答日期问题

更新后的 Chat Completions API 中添加了一个 functions 参数,用于定义可用的函数,就像在 ChatGPT 中开启插件一样,这里的函数就相当于插件,对于每一个函数,我们需要定义它的名称、描述以及参数信息,如下:

completion = openai.ChatCompletion.create(
    temperature=0.7,
    model="gpt-3.5-turbo",
    messages=[
        {'role': 'user', 'content': "今天是星期几?"},
    ],
    functions=[
        {
          "name": "get_current_date",
          "description": "获取今天的日期信息,包括几月几号和星期几",
          "parameters": {
              "type": "object",
              "properties": {}
          }
        }
    ],
    function_call="auto",
)
print(completion)

在上面的例子中,我们定义了一个名为 get_current_date() 的函数,用于获取今天的日期和星期信息,这个函数我们要提前实现好:

def get_current_date(args):
    import datetime
    today = datetime.date.today()
    weekday = today.weekday()
    weeekdays = ['一','二','三','四','五','六','日']
    return '今天是' + str(today) + ', ' + '星期' + weeekdays[weekday]

当 GPT 无法回答关于日期的问题时,就会自动地选择调用这个函数来进一步获取信息,Chat Completions API 的响应结果如下:

{
  "id": "chatcmpl-7pQO7iJ3WeggIYZ5CnLc88ZxMgMMV",
  "object": "chat.completion",
  "created": 1692490519,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "function_call": {
          "name": "get_current_date",
          "arguments": "{}"
        }
      },
      "finish_reason": "function_call"
    }
  ],
  "usage": {
    "prompt_tokens": 63,
    "completion_tokens": 8,
    "total_tokens": 71
  }
}

可以看到接口返回的 message.content 是空,反而多了一个 function_call 字段,这就说明 GPT 无法回答我们的问题,希望调用某个外部函数。为了方便我们调用外部函数,GPT 非常贴心地将函数名和参数都准备好了,我们只需要使用 globals().get() 拿到函数,再使用 json.loads() 拿到参数,然后直接调用即可:

function_call = completion.choices[0].message.function_call
function = globals().get(function_call.name)
args = json.loads(function_call.arguments)
result = function(args)
print(result)

拿到函数调用的结果之后,我们再一次调用 Chat Completions API,这一次我们将函数调用的结果和用户的问题一起放在 messages 中,注意将它的 role 设置为 function

completion = openai.ChatCompletion.create(
    temperature=0.7,
    model="gpt-3.5-turbo",
    messages=[
        {'role': 'user', 'content': "今天是星期几?"},
        {'role': 'function', 'name': 'get_current_date', 'content': "今天是2023-08-20, 星期日"},
    ],
)
print(completion)

这样 GPT 就能成功回答我们的问题了:

{
  "id": "chatcmpl-7pQklbWnMyVFvK73YbWXMybVsOTJe",
  "object": "chat.completion",
  "created": 1692491923,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "今天是星期日。"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 83,
    "completion_tokens": 8,
    "total_tokens": 91
  }
}

多轮 Function Calling

有时候,只靠一个函数解决不了用户的问题,比如用户问 明天合肥的天气怎么样?,那么 GPT 首先需要知道明天的日期,然后再根据日期查询合肥的天气,所以我们要定义两个函数:

functions = [
    {
        "name": "get_current_date",
        "description": "获取今天的日期信息,包括几月几号和星期几",
        "parameters": {
            "type": "object",
            "properties": {}
        }
    },
    {
        "name": "get_weather_info",
        "description": "获取某个城市某一天的天气信息",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "城市名",
                },
                "date": {
                    "type": "string",
                    "description": "日期,格式为 yyyy-MM-dd",
                },
            },
            "required": ["city", "date"],
        }
    }
]

第一次调用 Chat Completions API 时,传入用户的问题:

messages=[
    {'role': 'user', 'content': "明天合肥的天气怎么样?"},
],

接口返回了一个 function_call,希望我们去调用 get_current_date() 函数:

"function_call": {
    "name": "get_current_date",
    "arguments": "{}"
}

然后我们调用 get_current_date() 函数得到今天的日期,再次调用 Chat Completions API 时,传入函数的调用结果:

messages=[
    {'role': 'user', 'content': "明天合肥的天气怎么样?"},
    {'role': 'function', 'name': 'get_current_date', 'content': "今天是2023-08-20, 星期日"},
],

接口再次返回了一个 function_call,希望我们去调用 get_weather_info() 函数:

"function_call": {
    "name": "get_weather_info",
    "arguments": "{\n  \"city\": \"合肥\",\n  \"date\": \"2023-08-21\"\n}"
}

注意这里的 date 参数,上面我们通过 get_current_date() 得到今天的日期是 2023-08-20,而用户问的是明天合肥的天气,GPT 非常聪明地推导出明天的日期是 2023-08-21,可以说是非常优秀了,我们直接使用 GPT 准备好的参数调用 get_weather_info() 即可获得明天合肥的天气,再次调用 Chat Completions API:

messages=[
    {'role': 'user', 'content': "明天合肥的天气怎么样?"},
    {'role': 'function', 'name': 'get_current_date', 'content': "今天是2023-08-20, 星期日"},
    {'role': 'function', 'name': 'get_weather_info', 'content': "雷阵雨,33/24℃,北风转西北风"},
],

通过不断的调用 Function Calling,最后准确地对用户的问题作出了回答:

明天合肥的天气预报为雷阵雨,最高温度为33℃,最低温度为24℃,风向将从北风转为西北风。请注意防雷阵雨的天气情况。

除了能不断地返回 function_call 并调用函数外,GPT 还会主动尝试补充函数的参数。有时候,用户的问题不完整,缺少了函数的某个参数,比如用户问 明天的天气怎么样?,这时 GPT 并不知道用户所在的城市,它就会问 请问您所在的城市是哪里?,等待用户回答之后,才返回 get_weather_info() 函数以及对应的参数。

学习 LangChain Agent

学习完 OpenAI 的插件机制之后,我们再来学习 LangChain 的 Agent 就会发现有很多概念是相通的。我们从官方文档中的一个入门示例开始。

快速入门

我们知道,大模型虽然擅长推理,但是却不擅长算术和计数,比如问它单词 hello 是由几个字母组成的,它就有可能胡编乱造,我们可以定义一个函数 get_word_length() 帮助大模型来回答关于单词长度的问题。

入门示例的代码如下:

from langchain.chat_models import ChatOpenAI
from langchain.agents import tool
from langchain.schema import SystemMessage
from langchain.agents import OpenAIFunctionsAgent
from langchain.agents import AgentExecutor

# llm
llm = ChatOpenAI(temperature=0)

# tools
@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

tools = [get_word_length]

# prompt
system_message = SystemMessage(
    content="You are very powerful assistant, but bad at calculating lengths of words."
)
prompt = OpenAIFunctionsAgent.create_prompt(system_message=system_message)

# create an agent
agent = OpenAIFunctionsAgent(llm=llm, tools=tools, prompt=prompt)

# create an agent executor
agent_executor = AgentExecutor(agent=agent, tools=tools)

# run the agent executor
result = agent_executor.run("how many letters in the word 'hello'?")
print(result)

从上面的代码中我们可以注意到 Agent 有这么几个重要的概念:

  • Tools - 希望被 Agent 执行的函数,被称为 工具,类似于 OpenAI 的插件,我们需要尽可能地描述清楚每个工具的功能,以便 Agent 能选择合适的工具;
  • Agent - 经常被翻译成 代理,类似于 OpenAI 的 Function Calling 机制,可以帮我们理解用户的问题,然后从给定的工具集中选择能解决用户问题的工具,并交给 Agent Executor 执行;
  • Agent Executor - Agent 执行器,它本质上是一个 Chain,所以可以和其他的 Chain 或 Agent Executor 进行组合;它会递归地调用 Agent 获取下一步的动作,并执行 Agent 中定义的工具,直到 Agent 认为问题已经解决,则递归结束,下面是整个过程的伪代码:
next_action = agent.get_action(...)
while next_action != AgentFinish:
    observation = run(next_action)
    next_action = agent.get_action(..., next_action, observation)
return next_action

LangChain Agent 进阶

下面深入学习 LangChain Agent 的这几个概念。

使用工具

在入门示例中,我们使用 @tool 装饰器定义了一个工具:

@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

工具的名称默认为方法名,工具的描述为方法的 doc_string,工具方法支持多个参数:

@tool
def get_word_length(word: str, excluding_hyphen: bool) -> int:
    """Returns the length of a word."""
    if excluding_hyphen:
        return len(word.replace('-', ''))
    else:
        return len(word)

当工具方法有多个参数时,参数的描述就很重要,我们可以通过 args_schema 来传入一个 BaseModel,这是 Pydantic 中用于定义数据模型的基类:

class WordLengthSchema(BaseModel):
    word: str = Field(description = "the word to be calculating")
    excluding_hyphen: bool = Field(description = "excluding the hyphen or not, default to false")

@tool(args_schema = WordLengthSchema)
def get_word_length(word: str, excluding_hyphen: bool) -> int:
    """Returns the length of a word."""
    if excluding_hyphen:
        return len(word.replace('-', ''))
    else:
        return len(word)

LangChain 的代码中大量使用了 Pydantic 库,它提供了一种简单而强大的方式来验证和解析输入数据,并将其转换为类型安全的 Python 对象。

除了使用 @tool 装饰器,官方还提供了另外两种方式来定义工具。第一种是使用 Tool.from_function()

Tool.from_function(
    func=get_word_length,
    name="get_word_length",
    description="Returns the length of a word."
)

不过这个方法只支持接受一个字符串输入和一个字符串输出,如果工具方法有多个参数,必须得使用 StructuredTool.from_function()

StructuredTool.from_function(
    func=get_word_length,
    name="get_word_length",
    description="Returns the length of a word."
)

同样,我们可以通过 args_schema 来传入一个 BaseModel 对方法的参数进行描述:

StructuredTool.from_function(
    func=get_word_length,
    name="get_word_length",
    description="Returns the length of a word.",
    args_schema=WordLengthSchema
)

实际上查看 LangChain 的源码你就会发现,@tool 装饰器就是通过 Tool.from_function()StructuredTool.from_function() 来实现的。

第二种定义工具的方法是直接继承 BaseTool 类:

class WordLengthTool(BaseTool):
    name = "get_word_length"
    description = "Returns the length of a word."

    def _run(
        self, word: str, run_manager: Optional[CallbackManagerForToolRun] = None
    ) -> str:
        """Use the tool."""
        return len(word)

    async def _arun(
        self, word: str, run_manager: Optional[AsyncCallbackManagerForToolRun] = None
    ) -> str:
        """Use the tool asynchronously."""
        raise NotImplementedError("get_word_length does not support async")

当工具方法有多个参数时,我们就在 _run 方法上定义多个参数,同时使用 args_schema 对多个参数进行描述:

class WordLengthTool(BaseTool):
    name = "get_word_length"
    description = "Returns the length of a word."
    args_schema: Type[WordLengthSchema] = WordLengthSchema

    def _run(
        self, word: str, excluding_hyphen: bool = False, run_manager: Optional[CallbackManagerForToolRun] = None
    ) -> str:
        """Use the tool."""
        if excluding_hyphen:
            return len(word.replace('-', ''))
        else:
            return len(word)

除了自己定义工具,LangChain 还内置了一些常用的工具,我们可以直接使用 load_tools() 来加载:

from langchain.agents import load_tools

tools = load_tools(["serpapi"])

可以从 load_tools.py 源码中找到支持的工具列表。

Agent 类型

有些同学可能已经注意到,在示例代码中我们使用了 OpenAIFunctionsAgent,我们也可以使用 initialize_agent() 方法简化 OpenAIFunctionsAgent 的创建过程:

from langchain.chat_models import ChatOpenAI
from langchain.agents import tool
from langchain.agents import initialize_agent
from langchain.agents import AgentType

# llm
llm = ChatOpenAI(temperature=0)

# tools
@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

tools = [get_word_length]

# create an agent executor
agent_executor = initialize_agent(tools, llm, agent=AgentType.OPENAI_FUNCTIONS, verbose=True)

# run the agent executor
result = agent_executor.run("how many letters in the word 'weekly-practice'?")
print(result)

很显然,这个 Agent 是基于 OpenAI 的 Function Calling 实现的,它通过 format_tool_to_openai_function() 将 LangChain 的工具转换为 OpenAI 的 functions 参数。但是 Function Calling 机制只有 OpenAI 的接口才支持,而 LangChain 面对的是各种大模型,并不是所有的大模型都支持 Function Calling 机制,这是要专门训练的,所以 LangChain 的 Agent 还需要支持一种更通用的实现机制。根据所使用的策略,LangChain 支持 多种不同的 Agent 类型,其中最通用,也是目前最流行的 Agent 是基于 ReAct 的 Agent。

Zero-shot ReAct Agent

ReAct 这个词出自一篇论文 ReAct: Synergizing Reasoning and Acting in Language Models,它是由 ReasonAct 两个词组合而成,表示一种将 推理行动 与大模型相结合的通用范式:

react.png

传统的 Reason Only 型应用(如 Chain-of-Thought Prompting)具备很强的语言能力,擅长通用文本的逻辑推断,但由于不会和外部环境交互,因此它的认知非常受限;而传统的 Act Only 型应用(如 WebGPTSayCanACT-1)能和外界进行交互,解决某类特定问题,但它的行为逻辑较简单,不具备通用的推理能力。

ReAct 的思想,旨在将这两种应用的优势结合起来。针对一个复杂问题,首先使用大模型的推理能力制定出解决该问题的行动计划,这好比人的大脑,可以对问题进行分析思考;然后使用行动能力与外部源(例如知识库或环境)进行交互,以获取额外信息,这好比人的五官和手脚,可以感知世界,并执行动作;大模型对行动的结果进行跟踪,并不断地更新行动计划,直到问题被解决。通过这种模式,我们能基于大模型构建更为强大的 AI 应用,大名鼎鼎的 Auto-GPT 项目就是基于 ReAct 模式实现的。

LangChain 基于 ReAct 思想实现了一些 Agent,其中最简单的就是 Zero-shot ReAct Agent,我们将上面的 AgentType.OPENAI_FUNCTIONS 替换成 AgentType.ZERO_SHOT_REACT_DESCRIPTION 即可:

agent_executor = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, verbose=True)

执行结果如下:

> Entering new AgentExecutor chain...
I should use the get_word_length tool to find the length of the word.
Action: get_word_length
Action Input: 'weekly-practice'
Observation: 17
Thought:The word 'weekly-practice' has 17 letters.
Final Answer: 17

> Finished chain.
17

从输出结果可以一窥 Agent 的思考过程,包括三个部分:Thought 是由大模型生成的想法,是执行行动的依据;Action 是指大模型判断本次需要执行的具体动作;Observation 是执行动作后从外部获取的信息。

可以看到这个 Agent 没有 OpenAI 那么智能,它在计算单词长度时没有去掉左右的引号。

为了展示 Agent 思维链的强大之处,我们可以输入一个更复杂的问题:

Calculate the length of the word 'weekly-practice' times the word 'aneasystone'?

要回答这个问题,Agent 必须先计算两个单词的长度,然后将两个长度相乘得到乘积,所以我们要在工具箱中加一个用于计算的工具,可以直接使用 LangChain 内置的 llm-math 工具:

@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

tools = load_tools(["llm-math"], llm=llm)
tools.append(get_word_length)

运行结果如下:

> Entering new AgentExecutor chain...
We need to calculate the length of the word 'weekly-practice' and the length of the word 'aneasystone', and then multiply them together.
Action: get_word_length
Action Input: 'weekly-practice'
Observation: 17
Thought:We have the length of the word 'weekly-practice'. Now we need to find the length of the word 'aneasystone'.
Action: get_word_length
Action Input: 'aneasystone'
Observation: 13
Thought:We have the lengths of both words. Now we can multiply them together to get the final answer.
Action: Calculator
Action Input: 17 * 13
Observation: Answer: 221
Thought:I now know the final answer.
Final Answer: The length of the word 'weekly-practice' times the word 'aneasystone' is 221.

> Finished chain.
The length of the word 'weekly-practice' times the word 'aneasystone' is 221.

针对最简单的 Zero-shot ReAct Agent,LangChain 提供了三个实现:

  • ZERO_SHOT_REACT_DESCRIPTION - 使用 LLMs 实现;
  • CHAT_ZERO_SHOT_REACT_DESCRIPTION - 使用 ChatModels 实现;
  • STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION - 上面两种 Agent 使用的工具都只支持输入简单的字符串,而这种 Agent 通过 args_schema 来生成工具的输入,支持在工具中使用多个参数;

Conversational ReAct Agent

和 Chain 一样,也可以给 Agent 增加记忆功能,默认情况下,Zero-shot ReAct Agent 是不具有记忆功能的,不过我们可以通过 agent_kwargs 参数修改 Agent 让其具备记忆功能。我们可以使用下面的技巧将 Agent 所使用的 Prompt 打印出来看看:

print(agent_executor.agent.llm_chain.prompt.template)

输出结果如下:

Answer the following questions as best you can. You have access to the following tools:

Calculator: Useful for when you need to answer questions about math.
get_word_length: get_word_length(word: str) -> int - Returns the length of a word.

Use the following format:

Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [Calculator, get_word_length]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question

Begin!

Question: {input}
Thought:{agent_scratchpad}

这个 Prompt 就是实现 ReAct 的核心,它实际上包括了三个部分。第一部分称为前缀(prefix),可以在这里加一些通用的提示语,并列出大模型可以使用的工具名称和描述:

Answer the following questions as best you can. You have access to the following tools:

Calculator: Useful for when you need to answer questions about math.
get_word_length: get_word_length(word: str) -> int - Returns the length of a word.

第二部分称为格式化指令(format_instructions),内容如下:

Use the following format:

Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [Calculator, get_word_length]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question

这段指令让大模型必须按照 Thought/Action/Action Input/Observation 这种特定的格式来回答我们的问题,然后我们将 Observation 设置为停止词(Stop Word)。如果大模型返回的结果中有 ActionAction Input,就说明大模型需要调用外部工具获取进一步的信息,于是我们就去执行该工具,并将执行结果放在 Observation 中,接着再次调用大模型,这样我们每执行一次,就能得到大模型的一次思考过程,直到大模型返回 Final Answer 为止,此时我们就得到了最终结果。

第三部分称为后缀(suffix),包括两个占位符,{input} 表示用户输入的问题,{agent_scratchpad} 表示 Agent 每一步思考的过程,可以让 Agent 继续思考下去:

Begin!

Question: {input}
Thought:{agent_scratchpad}

可以看到上面的 Prompt 中并没有考虑历史会话,如果要让 Agent 具备记忆功能,我们必须在 Prompt 中加上历史会话内容,我们将 prefix 修改成下面这样:

Have a conversation with a human, answering the following questions as best you can. You have access to the following tools:

明确地指出这是一次和人类的会话。然后将 suffix 修改为:


Begin!"

{chat_history}
Question: {input}
{agent_scratchpad}

我们在里面加上了 {chat_history} 占位符表示历史会话记录。由于引入了新的占位符,所以在 input_variables 中也需要加上 chat_history 变量,这个变量的内容会被 ConversationBufferMemory 自动替换,修改后的代码如下:

from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory(memory_key="chat_history")

# create an agent executor
agent_executor = initialize_agent(
    tools, 
    llm, 
    agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, 
    verbose=True,
    memory=memory,
    agent_kwargs={
        "prefix": prefix,
        "suffix": suffix,
        "input_variables": ["input", "chat_history", "agent_scratchpad"],
    }
)

至此我们这个 Agent 就具备记忆功能了,可以将上面那个复杂问题拆分成三个小问题分别问它:

result = agent_executor.run("how many letters in the word 'weekly-practice'?")
print(result)
result = agent_executor.run("how many letters in the word 'hello-world'?")
print(result)
result = agent_executor.run("what is the product of results above?")
print(result)

执行结果如下,可以看出在回答第三个问题时它记住了上面两轮对话的内容:

> Entering new AgentExecutor chain...
Thought: I can use the get_word_length tool to find the length of the word 'weekly-practice'.
Action: get_word_length
Action Input: 'weekly-practice'
Observation: 17
Thought:I now know the final answer
Final Answer: The word 'weekly-practice' has 17 letters.

> Finished chain.
The word 'weekly-practice' has 17 letters.


> Entering new AgentExecutor chain...
Thought: I need to find the length of the word 'hello-world'.
Action: get_word_length
Action Input: 'hello-world'
Observation: 13
Thought:The word 'hello-world' has 13 letters.
Final Answer: The word 'hello-world' has 13 letters.

> Finished chain.
The word 'hello-world' has 13 letters.


> Entering new AgentExecutor chain...
Thought: I need to calculate the product of the results above.
Action: Calculator
Action Input: 17 * 13
Observation: Answer: 221
Thought:I now know the final answer.
Final Answer: The product of the results above is 221.

> Finished chain.
The product of the results above is 221.

像上面这样给 Agent 增加记忆实在是太繁琐了,LangChain 于是内置了两种 Conversational ReAct Agent 来简化这个过程:

  • CONVERSATIONAL_REACT_DESCRIPTION - 使用 LLMs 实现;
  • CHAT_CONVERSATIONAL_REACT_DESCRIPTION - 使用 ChatModels 实现;

使用 Conversational ReAct Agent 要简单得多,我们只需要准备一个 memory 参数即可:

# memory
memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)

# create an agent executor
agent_executor = initialize_agent(
    tools, 
    llm, 
    agent=AgentType.CHAT_CONVERSATIONAL_REACT_DESCRIPTION, 
    verbose=True,
    memory=memory,
)

ReAct DocStore Agent

和 Zero-shot ReAct Agent 一样,ReAct DocStore Agent 也是一个基于 ReAct 框架实现的 Agent。事实上,ReAct DocStore Agent 才是 ReAct 这篇论文 的标准实现,这个 Agent 必须包含两个指定工具:Search 用于调用 DocStore 搜索相关文档,Lookup 用于从搜索的文档中查询关键词信息。

Zero-shot ReAct Agent 更像是一个通用的 MRKL 系统,MRKL 的全称是模块化推理、知识和语言系统,它是一种模块化的神经符号架构,结合了大型语言模型、外部知识源和离散推理,它最初 由 AI21 Labs 提出,并实现了 Jurassic-X,对 MRKL 感兴趣的同学可以参考 这篇博客

LangChain 目前貌似只实现了 Wikipedia 和 InMemory 两个 DocStore,下面的例子中我们使用 Wikipedia 来进行搜索:

from langchain import Wikipedia
from langchain.agents import Tool
from langchain.agents.react.base import DocstoreExplorer

docstore = DocstoreExplorer(Wikipedia())
tools = [
    Tool(
        name="Search",
        func=docstore.search,
        description="useful for when you need to ask with search",
    ),
    Tool(
        name="Lookup",
        func=docstore.lookup,
        description="useful for when you need to ask with lookup",
    ),
]

然后创建一个类型为 AgentType.REACT_DOCSTORE 的 Agent,并提出一个问题:谁是当今美国总统?

agent_executor = initialize_agent(tools, llm, agent=AgentType.REACT_DOCSTORE, verbose=True)

result = agent_executor.run("Who is the current president of the United States?")
print(result)

运行结果如下:

> Entering new AgentExecutor chain...
Thought: I need to find out who the current president of the United States is.
Action: Search[current president of the United States]
Observation: The president of the United States (POTUS) is the head of state and head of government of the United States... 
Joe Biden is the 46th and current president of the United States, having assumed office on January 20, 2021.
Thought:Joe Biden is the current president of the United States.
Action: Finish[Joe Biden]

> Finished chain.
Joe Biden

Self-Ask Agent

Self-Ask Agent 是另一种基于 ReAct 框架的 Agent,它直接使用搜索引擎作为唯一的工具,工具名称必须叫做 Intermediate Answer,一般使用 Google 搜索来实现;Self-Ask 的原理来自于 这篇论文,通过下面的提示语技巧让大模型以 Follow up/Intermediate answer 这种固定的格式回答用户问题:

Question: Who lived longer, Muhammad Ali or Alan Turing?
Are follow up questions needed here: Yes.
Follow up: How old was Muhammad Ali when he died?
Intermediate answer: Muhammad Ali was 74 years old when he died.
Follow up: How old was Alan Turing when he died?
Intermediate answer: Alan Turing was 41 years old when he died.
So the final answer is: Muhammad Ali

其中 Follow up 是我们需要搜索的内容,它类似于 Zero-shot ReAct Agent 里的 Action/Action Input,由于直接使用搜索引擎,所以不需要让大模型决定使用哪个工具和参数;Intermediate answer 是搜索的结果,它类似于 Zero-shot ReAct Agent 里的 Observation;经过不断的搜索,大模型最终得到问题的答案。

下面是使用 Self-Ask Agent 的示例,首先通过 SerpAPI 定义一个名为 Intermediate Answer 的工具:

search = SerpAPIWrapper()
tools = [
    Tool(
        name="Intermediate Answer",
        func=search.run,
        description="useful for when you need to ask with search",
    )
]

然后创建一个类型为 AgentType.SELF_ASK_WITH_SEARCH 的 Agent,并提出一个问题:当今美国总统的出生日期是什么时候?

agent_executor = initialize_agent(tools, llm, agent=AgentType.SELF_ASK_WITH_SEARCH, verbose=True)

result = agent_executor.run("When is the current president of the United States born?")
print(result)

运行结果如下:

> Entering new AgentExecutor chain...
Yes.
Follow up: Who is the current president of the United States?
Intermediate answer: Joe Biden
Follow up: When was Joe Biden born?
Intermediate answer: November 20, 1942
So the final answer is: November 20, 1942

> Finished chain.
November 20, 1942

OpenAI Functions Agent

上面的几种 Agent 都是基于 ReAct 框架实现的,这虽然是一种比较通用的解决方案,但是当我们使用 OpenAI 时,OpenAI Functions Agent 才是我们的最佳选择,因为 ReAct 归根结底是基于提示工程的,执行结果有着很大的不确定性,OpenAI 的 Function Calling 机制相对来说要更加可靠。

在入门示例中,我们已经用到了 OpenAI Functions Agent 类型,这一节我们将学习另一种类型 OpenAI Multi Functions Agent

OpenAI Functions Agent 的缺点是每次只能返回一个工具,比如我们的问题是 合肥和上海今天的天气怎么样?,OpenAI Functions Agent 第一次会返回一个函数调用 get_weather_info(city='合肥'),然后我们需要再调用一次,第二次又会返回一个函数调用 get_weather_info(city='上海'),最后 OpenAI 对两个结果进行总结得到最终答案。

很显然,这两次调用是可以并行处理的,如果 Agent 能一次性返回两次调用,这将大大提高我们的执行效率,这就是提出 OpenAI Multi Functions Agent 的初衷。

为了对比这两种 Agent 的区别,我们可以使用下面的技巧开启 LangChain 的调试模式:

import langchain

langchain.debug = True

然后使用同一个问题对两个 Agent 进行提问:

Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?

OpenAI Functions Agent 的运行结果如下:

[chain/start] [1:chain:AgentExecutor] Entering Chain run with input:
{
  "input": "Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?"
}
[llm/start] [1:chain:AgentExecutor > 2:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?"
  ]
}
[llm/end] [1:chain:AgentExecutor > 2:llm:ChatOpenAI] [1.08s] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "",
            "additional_kwargs": {
              "function_call": {
                "name": "get_word_length",
                "arguments": "{\n  \"word\": \"weekly-practice\"\n}"
              }
            }
          }
...
}
[tool/start] [1:chain:AgentExecutor > 3:tool:get_word_length] Entering Tool run with input:
"{'word': 'weekly-practice'}"
[tool/end] [1:chain:AgentExecutor > 3:tool:get_word_length] [0.217ms] Exiting Tool run with output:
"15"
[llm/start] [1:chain:AgentExecutor > 4:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?\nAI: {'name': 'get_word_length', 'arguments': '{\\n  \"word\": \"weekly-practice\"\\n}'}\nFunction: 15"
  ]
}
[llm/end] [1:chain:AgentExecutor > 4:llm:ChatOpenAI] [649.318ms] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "",
            "additional_kwargs": {
              "function_call": {
                "name": "get_word_length",
                "arguments": "{\n  \"word\": \"aneasystone\"\n}"
              }
            }
          }
...
}
[tool/start] [1:chain:AgentExecutor > 5:tool:get_word_length] Entering Tool run with input:
"{'word': 'aneasystone'}"
[tool/end] [1:chain:AgentExecutor > 5:tool:get_word_length] [0.148ms] Exiting Tool run with output:
"11"
[llm/start] [1:chain:AgentExecutor > 6:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?\nAI: {'name': 'get_word_length', 'arguments': '{\\n  \"word\": \"weekly-practice\"\\n}'}\nFunction: 15\nAI: {'name': 'get_word_length', 'arguments': '{\\n  \"word\": \"aneasystone\"\\n}'}\nFunction: 11"
  ]
}
[llm/end] [1:chain:AgentExecutor > 6:llm:ChatOpenAI] [1.66s] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "The word 'weekly-practice' has a length of 15 characters, while the word 'aneasystone' has a length of 11 characters.",
            "additional_kwargs": {}
          }
...
}
[chain/end] [1:chain:AgentExecutor] [3.39s] Exiting Chain run with output:
{
  "output": "The word 'weekly-practice' has a length of 15 characters, while the word 'aneasystone' has a length of 11 characters."
}
The word 'weekly-practice' has a length of 15 characters, while the word 'aneasystone' has a length of 11 characters.

可以看到 OpenAI Functions Agent 调了两次大模型,第一次大模型返回 get_word_length 函数计算单词 weekly-practice 的长度,第二次再次返回 get_word_length 函数计算单词 aneasystone 的长度。

而 OpenAI Multi Functions Agent 的执行结果如下:

[chain/start] [1:chain:AgentExecutor] Entering Chain run with input:
{
  "input": "Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?"
}
[llm/start] [1:chain:AgentExecutor > 2:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?"
  ]
}
[llm/end] [1:chain:AgentExecutor > 2:llm:ChatOpenAI] [3.26s] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "",
            "additional_kwargs": {
              "function_call": {
                "name": "tool_selection",
                "arguments": "{\n  \"actions\": [\n    {\n      \"action_name\": \"get_word_length\",\n      \"action\": {\n        \"word\": \"weekly-practice\"\n      }\n    },\n    {\n      \"action_name\": \"get_word_length\",\n      \"action\": {\n        \"word\": \"aneasystone\"\n      }\n    }\n  ]\n}"
              }
            }
          }
...
}
[tool/start] [1:chain:AgentExecutor > 3:tool:get_word_length] Entering Tool run with input:
"{'word': 'weekly-practice'}"
[tool/end] [1:chain:AgentExecutor > 3:tool:get_word_length] [0.303ms] Exiting Tool run with output:
"15"
[tool/start] [1:chain:AgentExecutor > 4:tool:get_word_length] Entering Tool run with input:
"{'word': 'aneasystone'}"
[tool/end] [1:chain:AgentExecutor > 4:tool:get_word_length] [0.229ms] Exiting Tool run with output:
"11"
[llm/start] [1:chain:AgentExecutor > 5:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: You are a helpful AI assistant.\nHuman: Calculate the length of the word 'weekly-practice' and the word 'aneasystone'?\nAI: {'name': 'tool_selection', 'arguments': '{\\n  \"actions\": [\\n    {\\n      \"action_name\": \"get_word_length\",\\n      \"action\": {\\n        \"word\": \"weekly-practice\"\\n      }\\n    },\\n    {\\n      \"action_name\": \"get_word_length\",\\n      \"action\": {\\n        \"word\": \"aneasystone\"\\n      }\\n    }\\n  ]\\n}'}\nFunction: 15\nAI: {'name': 'tool_selection', 'arguments': '{\\n  \"actions\": [\\n    {\\n      \"action_name\": \"get_word_length\",\\n      \"action\": {\\n        \"word\": \"weekly-practice\"\\n      }\\n    },\\n    {\\n      \"action_name\": \"get_word_length\",\\n      \"action\": {\\n        \"word\": \"aneasystone\"\\n      }\\n    }\\n  ]\\n}'}\nFunction: 11"
  ]
}
[llm/end] [1:chain:AgentExecutor > 5:llm:ChatOpenAI] [1.02s] Exiting LLM run with output:
{
...
          "kwargs": {
            "content": "The word 'weekly-practice' has a length of 15 characters, and the word 'aneasystone' has a length of 11 characters.",
            "additional_kwargs": {}
          }
...
}
[chain/end] [1:chain:AgentExecutor] [4.29s] Exiting Chain run with output:
{
  "output": "The word 'weekly-practice' has a length of 15 characters, and the word 'aneasystone' has a length of 11 characters."
}
The word 'weekly-practice' has a length of 15 characters, and the word 'aneasystone' has a length of 11 characters.

可以看到 OpenAI Multi Functions Agent 调了一次大模型,返回了一个叫做 tool_selection 的工具,这个函数是 LangChain 特意构造的,它的参数是我们定义的多个工具,这样就使得 OpenAI 一次返回多个工具供我们调用。

Plan and execute Agent

上面所介绍的所有 Agent,本质上其实都是一样的:给定一组工具,然后大模型根据用户的输入一步一步地选择工具来执行,每一步的结果都用于决定下一步操作,直到问题被解决,像这种逐步执行的 Agent 被称为 Action Agent,它比较适合小型任务;如果要处理需要保持长期目标的复杂任务,使用 Action Agent 经常会出现推理跑偏的问题,这时就轮到 Plan and execute Agent 上场了。

Plan and execute Agent 会提前对问题制定好完整的执行计划,然后在不更新计划的情况下逐步执行,即先把用户的问题拆解成多个子任务,然后再执行各个子任务,直到用户的问题完全被解决:

agent.png

我们可以动态的选择工具或 Chain 来解决这些子任务,但更好的做法是使用 Action Agent,这样我们可以将 Action Agent 的动态性和 Plan and execute Agent 的计划能力相结合,对复杂问题的解决效果更好。

Plan and execute Agent 的思想来自开源项目 BabyAGI,相比于 Action Agent 所使用的 Chain-of-Thought Prompting,它所使用的是 Plan-and-Solve Prompting,感兴趣的同学可以阅读 Plan-and-Solve Prompting 的论文

和之前的 Action Agent 一样,在使用 Plan and execute Agent 之前我们需要提前准备好工具列表,这里我们准备两个工具,一个搜索,一个计算:

from langchain.llms import OpenAI
from langchain import SerpAPIWrapper
from langchain.agents.tools import Tool
from langchain import LLMMathChain

search = SerpAPIWrapper()
llm_math_chain = LLMMathChain.from_llm(llm=OpenAI(temperature=0), verbose=True)
tools = [
    Tool(
        name = "Search",
        func=search.run,
        description="useful for when you need to answer questions about current events"
    ),
    Tool(
        name="Calculator",
        func=llm_math_chain.run,
        description="useful for when you need to answer questions about math"
    ),
]

接下来创建 Plan and execute Agent,它主要包括两个部分:plannerexecutorplanner 用于制定计划,将任务划分成若干个子任务,executor 用于执行计划,处理子任务,其中 executor 可以使用 Chain 或上面的任意 Action Agent 来实现。Plan and execute Agent 目前还是 LangChain 的实验功能,相应的实现在 langchain.experimental 模块下:

from langchain.chat_models import ChatOpenAI
from langchain.experimental.plan_and_execute import PlanAndExecute, load_agent_executor, load_chat_planner

model = ChatOpenAI(temperature=0)
planner = load_chat_planner(model)
executor = load_agent_executor(model, tools, verbose=True)
agent = PlanAndExecute(planner=planner, executor=executor, verbose=True)
agent.run("Who is Leo DiCaprio's girlfriend? What is her current age raised to the 0.43 power?")

运行结果的第一步可以看到整个任务的执行计划:

> Entering new PlanAndExecute chain...
steps=[
  Step(value="Search for information about Leo DiCaprio's girlfriend."), 
  Step(value='Find her current age.'), 
  Step(value='Calculate her current age raised to the 0.43 power.'), 
  Step(value="Given the above steps taken, respond to the user's original question.\n\n")]

然后是每个子任务的执行结果,首先是搜索 Leo DiCaprio's girlfriend

> Entering new AgentExecutor chain...
Thought: I can use the Search tool to find information about Leo DiCaprio's girlfriend.

Action:
```{"action": "Search", "action_input": "Leo DiCaprio girlfriend"}```
Observation: Blake Lively and DiCaprio are believed to have enjoyed a whirlwind five-month romance in 2011. The pair were seen on a yacht together in Cannes, ...
Thought:Based on my search, it seems that Blake Lively was one of Leo DiCaprio's girlfriends. They were believed to have had a five-month romance in 2011 and were seen together on a yacht in Cannes.

> Finished chain.
*****

Step: Search for information about Leo DiCaprio's girlfriend.

Response: Based on my search, it seems that Blake Lively was one of Leo DiCaprio's girlfriends. They were believed to have had a five-month romance in 2011 and were seen together on a yacht in Cannes.

搜到的这个新闻有点老了,得到结果是 Blake Lively,然后计算她的年龄:

> Entering new AgentExecutor chain...
Thought: To find Blake Lively's current age, I can calculate it based on her birthdate. I will use the Calculator tool to subtract her birth year from the current year.

Action:
{"action": "Calculator", "action_input": "2022 - 1987"}

> Entering new LLMMathChain chain...
2022 - 1987

...numexpr.evaluate("2022 - 1987")...

Answer: 35
> Finished chain.

Observation: Answer: 35
Thought:Blake Lively's current age is 35.

> Finished chain.
*****

Step: Find her current age.

Response: Blake Lively's current age is 35.

然后使用数学工具计算年龄的 0.43 次方:

> Entering new AgentExecutor chain...
Thought: To calculate Blake Lively's current age raised to the 0.43 power, I can use a calculator tool.

Action:
{"action": "Calculator", "action_input": "35^0.43"}

> Entering new LLMMathChain chain...
35**0.43

...numexpr.evaluate("35**0.43")...

Answer: 4.612636795281377
> Finished chain.

Observation: Answer: 4.612636795281377
Thought:To calculate Blake Lively's current age raised to the 0.43 power, I used a calculator tool and the result is 4.612636795281377.

Action:
{"action": "Final Answer", "action_input": "Blake Lively's current age raised to the 0.43 power is approximately 4.6126."}


> Finished chain.
*****

Step: Calculate her current age raised to the 0.43 power.

Response: Blake Lively's current age raised to the 0.43 power is approximately 4.6126.

最后得到结果,润色后输出:

> Entering new AgentExecutor chain...
Action:
{
  "action": "Final Answer",
  "action_input": "Blake Lively's current age raised to the 0.43 power is approximately 4.6126."
}

> Finished chain.
*****

Step: Given the above steps taken, respond to the user's original question.

Response: Action:
{
  "action": "Final Answer",
  "action_input": "Blake Lively's current age raised to the 0.43 power is approximately 4.6126."
}
> Finished chain.

总结

在这篇笔记中,我们首先学习了 OpenAI 的插件和 Function Calling 机制,然后再对 Agent 的基本概念做了一个大概的了解,最后详细地学习了 LangChain 中不同的 Agent 类型,包括 Zero-shot ReAct Agent、Conversational ReAct Agent、ReAct DocStore Agent、Self-Ask Agent、OpenAI Functions Agent 和 Plan and execute Agent,这些 Agent 在使用上虽然大同小异,但每一种 Agent 都代表一种解决问题的思路,它们使用了不同的提示语技术:Chain-of-Thought Prompting 和 Plan-and-Solve Prompting。

从这里我们也可以看出提示语的重要性,由此也诞生了一门新的学科:提示工程(Prompt Engineering),这门学科专门研究如何开发和优化提示词,将大模型用于各种应用场景,提高大模型处理复杂任务的能力。LangChain 中内置了大量的提示词,我们将在下一篇笔记中继续学习它。

参考

更多

AI Agents

OpenAI Functions Chain

自从 OpenAI 推出 Function Calling 功能之后,LangChain 就积极地将其应用到自己的项目中,在 LangChain 的文档中,可以看到很多 OpenAI Functions 的身影。除了这篇笔记所介绍的 OpenAI Functions AgentOpenAI Multi Functions Agent,OpenAI Functions 还可以用在 Chain 中,OpenAI Functions Chain 可以用来生成结构化的输出,在信息提取、文本标注等场景下非常有用。

扫描二维码,在手机上阅读!

大模型应用开发框架 LangChain 学习笔记

一场关于大模型的战役正在全世界激烈地上演着,国内外的各大科技巨头和研究机构纷纷投入到这场战役中,光是写名字就能罗列出一大串,比如国外的有 OpenAI 的 GPT-4,Meta 的 LLaMa,Stanford University 的 Alpaca,Google 的 LaMDAPaLM 2,Anthropic 的 Claude,Databricks 的 Dolly,国内的有百度的 文心,阿里的 通义,科大讯飞的 星火,华为的 盘古,复旦大学的 MOSS,智谱 AI 的 ChatGLM 等等等等。

一时间大模型如百花齐放,百鸟争鸣,并在向各个行业领域渗透,让人感觉通用人工智能仿佛就在眼前。基于大模型开发的应用和产品也如雨后春笋,让人目不暇接,每天都有很多新奇的应用和产品问世,有的可以充当你的朋友配你聊天解闷,有的可以充当你的老师帮你学习答疑,有的可以帮你写文章编故事,有的可以帮你写代码改 BUG,大模型的崛起正影响着我们生活中的方方面面。

正是在这样的背景下,为了方便和统一基于大模型的应用开发,一批大模型应用开发框架横空出世,LangChain 就是其中最流行的一个。

快速开始

正如前文所述,LangChain 是一个基于大语言模型(LLM)的应用程序开发框架,它提供了一整套工具、组件和接口,简化了创建大模型应用程序的过程,方便开发者使用语言模型实现各种复杂的任务,比如聊天机器人、文档问答、各种基于 Prompt 的助手等。根据 官网的介绍,它可以让你的应用变得 Data-awareAgentic

  • Data-aware:也就是数据感知,可以将语言模型和其他来源的数据进行连接,比如让语言模型针对指定文档回答问题;
  • Agentic:可以让语言模型和它所处的环境进行交互,实现类似代理机器人的功能,帮助用户完成指定任务;

LangChain 在 GitHub 上有着异乎寻常的热度,截止目前为止,星星数高达 55k,而且它的更新非常频繁,隔几天就会发一个新版本,有时甚至一天发好几个版本,所以学习的时候最好以官方文档为准,网络上有很多资料都过时了(包括我的这篇笔记)。

LangChain 提供了 PythonJavaScript 两个版本的 SDK,这里我主要使用 Python 版本的,在我写这篇笔记的时候,最新的版本为 0.0.238,使用下面的命令安装:

$ pip install langchain==0.0.238

注意:Python 版本需要在 3.8.1 及以上,如果低于这个版本,只能安装 langchain==0.0.27

另外要注意的是,这个命令只会安装 LangChain 的基础包,这或许并没有什么用,因为 LangChain 最有价值的地方在于它能和各种各样的语言模型、数据存储、外部工具等进行交互,比如如果我们需要使用 OpenAI,则需要手动安装:

$ pip install openai

也可以在安装 LangChain 时指定安装可选依赖包:

$ pip install langchain[openai]==0.0.238

或者使用下面的命令一次性安装所有的可选依赖包(不过很多依赖可能会用不上):

$ pip install langchain[all]==0.0.238

LangChain 支持的可选依赖包有:

llms = ["anthropic", "clarifai", "cohere", "openai", "openllm", "openlm", "nlpcloud", "huggingface_hub", ... ]
qdrant = ["qdrant-client"]
openai = ["openai", "tiktoken"]
text_helpers = ["chardet"]
clarifai = ["clarifai"]
cohere = ["cohere"]
docarray = ["docarray"]
embeddings = ["sentence-transformers"]
javascript = ["esprima"]
azure = [ ... ]
all = [ ... ]

可以在项目的 pyproject.toml 文件中查看依赖包详情。

入门示例:LLMs vs. ChatModels

我们首先从一个简单的例子开始:

from langchain.llms import OpenAI

llm = OpenAI(temperature=0.9)
response = llm.predict("给水果店取一个名字")
print(response)

# 果舞时光

LangChain 集成了许多流行的语言模型,并提供了一套统一的接口方便开发者直接使用,比如在上面的例子中,我们引入了 OpenAI 这个 LLM,然后调用 llm.predict() 方法让语言模型完成后续内容的生成。如果用户想使用其他语言模型,只需要将上面的 OpenAI 换成其他的即可,比如流行的 Anthropic 的 Claude 2,或者 Google 的 PaLM 2 等,这里 可以找到 LangChain 目前支持的所有语言模型接口。

回到上面的例子,llm.predict() 方法实际上调用的是 OpenAI 的 Completions 接口,这个接口的作用是给定一个提示语,让 AI 生成后续内容;我们知道,除了 Completions,OpenAI 还提供了一个 Chat 接口,也可以用于生成后续内容,而且比 Completions 更强大,可以给定一系列对话内容,让 AI 生成后续的回复,从而实现类似 ChatGPT 的聊天功能。

官方推荐使用 Chat 替换 Completions 接口,在后续的 OpenAI 版本中,Completions 接口可能会被弃用。

因此,LangChain 也提供 Chat 接口:

from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage

chat = ChatOpenAI(temperature=0.9)
response = chat.predict_messages([
    HumanMessage(content="窗前明月光,下一句是什么?"),
])
print(response.content)

# 疑是地上霜。

和上面的 llm.predict() 方法比起来,chat.predict_messages() 方法可以接受一个数组,这也意味着 Chat 接口可以带上下文信息,实现聊天的效果:

from langchain.chat_models import ChatOpenAI
from langchain.schema import AIMessage, HumanMessage, SystemMessage

chat = ChatOpenAI(temperature=0.9)
response = chat.predict_messages([
    SystemMessage(content="你是一个诗词助手,帮助用户回答诗词方面的问题"),    
    HumanMessage(content="窗前明月光,下一句是什么?"),
    AIMessage(content="疑是地上霜。"),
    HumanMessage(content="这是谁的诗?"),
])
print(response.content)

# 这是李白的《静夜思》。

另外,Chat 接口也提供了一个 chat.predict() 方法,可以实现和 llm.predict() 一样的效果:

from langchain.chat_models import ChatOpenAI

chat = ChatOpenAI(temperature=0.9)
response = chat.predict("给水果店取一个名字")
print(response)

# 果香居

实现翻译助手:PromptTemplate

基于 ChatGPT 实现一个划词翻译 Chrome 插件 这篇笔记中,我们通过提示语技术实现了一个非常简单的划词翻译 Chrome 插件,其中的翻译功能我们也可以使用 LangChain 来完成,当然,使用 LLMsChatModels 都可以。

使用 LLMs 实现翻译助手:

from langchain.llms import OpenAI

llm = OpenAI(temperature=0.9)
response = llm.predict("将下面的句子翻译成英文:今天的天气真不错")
print(response)

# The weather is really nice today.

使用 ChatModels 实现翻译助手:

from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage, SystemMessage

chat = ChatOpenAI(temperature=0.9)
response = chat.predict_messages([
    SystemMessage(content="你是一个翻译助手,可以将中文翻译成英文。"),
    HumanMessage(content="今天的天气真不错"),
])
print(response.content)

# The weather is really nice today.

观察上面的代码可以发现,输入参数都具备一个固定的模式,为此,LangChain 提供了一个 PromptTemplate 类来方便我们构造提示语模板:

from langchain.llms import OpenAI
from langchain.prompts import PromptTemplate

prompt = PromptTemplate.from_template("将下面的句子翻译成英文:{sentence}")
text = prompt.format(sentence="今天的天气真不错")

llm = OpenAI(temperature=0.9)
response = llm.predict(text)
print(response)

# Today's weather is really great.

其实 PromptTemplate 默认实现就是 Python 的 f-strings,只不过它提供了一种抽象,还可以支持其他的模板实现,比如 jinja2 模板引擎

对于 ChatModels,LangChain 也提供了相应的 ChatPromptTemplate,只不过使用起来要稍微繁琐一点:

from langchain.chat_models import ChatOpenAI
from langchain.prompts.chat import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate

system_message_prompt = SystemMessagePromptTemplate.from_template(
    "你是一个翻译助手,可以将{input_language}翻译成{output_language}。")
human_message_prompt = HumanMessagePromptTemplate.from_template("{text}")
chat_prompt = ChatPromptTemplate.from_messages([system_message_prompt, human_message_prompt])
messages = chat_prompt.format_messages(input_language="中文", output_language="英文", text="今天的天气真不错")

chat = ChatOpenAI(temperature=0.9)
response = chat.predict_messages(messages)
print(response.content)

# The weather today is really good.

实现知识库助手:Data connection

使用 Embedding 技术打造本地知识库助手 这篇笔记中,我们通过 OpenAI 的 Embedding 接口和开源向量数据库 Qdrant 实现了一个非常简单的知识库助手。在上面的介绍中我们提到,LangChain 的一大特点就是数据感知,可以将语言模型和其他来源的数据进行连接,所以知识库助手正是 LangChain 最常见的用例之一,这一节我们就使用 LangChain 来重新实现它。

LangChain 将实现知识库助手的过程拆分成了几个模块,可以自由组合使用,这几个模块是:

  • Document loaders - 用于从不同的来源加载文档;
  • Document transformers - 对文档进行处理,比如转换为不同的格式,对大文档分片,去除冗余文档,等等;
  • Text embedding models - 通过 Embedding 模型将文本转换为向量;
  • Vector stores - 将文档保存到向量数据库,或从向量数据库中检索文档;
  • Retrievers - 用于检索文档,这是比向量数据库更高一级的抽象,不仅仅限于从向量数据库中检索,可以扩充更多的检索来源;

data-connection.jpg

读取文档

TextLoader 是最简单的读取文档的方法,它可以处理大多数的纯文本,比如 txtmd 文件:

from langchain.document_loaders import TextLoader

loader = TextLoader("./kb.txt")
raw_documents = loader.load()
print(raw_documents)

LangChain 还提供了一些其他格式的文档读取方法,比如 JSONHTMLCSVWordPPTPDF 等,也可以加载其他来源的文档,比如通过 URLLoader 抓取网页内容,通过 WikipediaLoader 获取维基百科的内容等,还可以使用 DirectoryLoader 同时读取整个目录的文档。

文档分割

使用 TextLoader 加载得到的是原始文档,有时候我们还需要对原始文档进行处理,最常见的一种处理方式是文档分割。由于大模型的输入存在上下文窗口的限制,所以我们不能直接将一个几百兆的文档丢给大模型,而是将大文档分割成一个个小分片,然后通过 Embedding 技术查询与用户问题最相关的几个分片丢给大模型。

最简单的文档分割器是 CharacterTextSplitter,它默认使用分隔符 \n\n 来分割文档,我们也可以修改为使用 \n 来分割:

from langchain.text_splitter import CharacterTextSplitter
text_splitter = CharacterTextSplitter(        
    separator = "\n",
    chunk_size = 0,
    chunk_overlap  = 0,
    length_function = len,
)
documents = text_splitter.split_documents(raw_documents)
print(documents)

CharacterTextSplitter 可以通过 chunk_size 参数控制每个分片的大小,默认情况下分片大小为 4000,这也意味着如果有长度不足 4000 的分片会递归地和其他分片合并成一个分片;由于我这里的测试文档比较小,总共都没有 4000,所以按 \n 分割完,每个分片又会被合并成一个大分片了,所以我这里将 chunk_size 设置为 0,这样就不会自动合并。

另一个需要注意的是,这里的分片大小是根据 length_function 来计算的,默认使用的是 len() 函数,也就是根据字符的长度来分割;但是大模型的上下文窗口限制一般是通过 token 来计算的,这和长度有一些细微的区别,所以如果要确保分割后的分片能准确的适配大模型,我们就需要 通过 token 来分割文档,比如 OpenAI 的 tiktoken,Hugging Face 的 GPT2TokenizerFast 等。OpenAI 的这篇文档 介绍了什么是 token 以及如何计算它,感兴趣的同学可以参考之。

不过在一般情况下,如果对上下文窗口的控制不需要那么严格,按长度分割也就足够了。

Embedding 与向量库

有了分割后的文档分片,接下来,我们就可以对每个分片计算 Embedding 了:

from langchain.embeddings.openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings()
doc_result = embeddings.embed_documents(['你好', '再见'])
print(doc_result)

这里我们使用的是 OpenAI 的 Embedding 接口,除此之外,LangChain 还集成了很多 其他的 Embedding 接口,比如 Cohere、SentenceTransformer 等。不过一般情况下我们不会像上面这样单独计算 Embedding,而是和向量数据库结合使用:

from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.vectorstores import Qdrant

qdrant = Qdrant.from_documents(
    documents,
    OpenAIEmbeddings(),
    url="127.0.0.1:6333",
    prefer_grpc=False,
    collection_name="my_documents",
)

这里我们仍然使用 Qdrant 来做向量数据库,Qdrant.from_documents() 方法会自动根据文档列表计算 Embedding 并存储到 Qdrant 中。除了 Qdrant,LangChain 也集成了很多 其他的向量数据库,比如 Chroma、FAISS、Milvus、PGVector 等。

再接下来,我们通过 qdrant.similarity_search() 方法从向量数据库中搜索出和用户问题最接近的文本片段:

query = "小明家的宠物狗叫什么名字?"
found_docs = qdrant.similarity_search(query)
print(found_docs)

后面的步骤就和 使用 Embedding 技术打造本地知识库助手 这篇笔记中一样,准备一段提示词模板,向 ChatGPT 提问就可以了。

LangChain 的精髓:Chain

通过上面的快速开始,我们学习了 LangChain 的基本用法,从几个例子下来,或许有人觉得 LangChain 也没什么特别的,只是一个集成了大量的 LLM、Embedding、向量库的 SDK 而已,我一开始也是这样的感觉,直到学习 Chain 这个概念的时候,才明白这时才算是真正地进入 LangChain 的大门。

LLMChain 开始

LLMChain 是 LangChain 中最基础的 Chain,它接受两个参数:一个是 LLM,另一个是提供给 LLM 的 Prompt 模板:

from langchain import PromptTemplate, OpenAI, LLMChain

llm = OpenAI(temperature=0.9)
prompt = PromptTemplate.from_template("将下面的句子翻译成英文:{sentence}")

llm_chain = LLMChain(
    llm = llm, 
    prompt = prompt
)
result = llm_chain("今天的天气真不错")
print(result['text'])

其中,LLM 参数同时支持 LLMsChatModels,运行效果和上面的入门示例是一样的。

在 LangChain 中,Chain 的特别之处在于,它的每一个参数都被称为 Component,Chain 由一系列的 Component 组合在一起以完成特定任务,Component 也可以是另一个 Chain,通过封装和组合,形成一个更复杂的调用链,从而创建出更强大的应用程序。

上面的例子中,有一点值得注意的是,我们在 Prompt 中定义了一个占位符 {sentence},但是在调用 Chain 的时候并没有明确指定该占位符的值,LangChain 是怎么知道要将我们的输入替换掉这个占位符的呢?实际上,每个 Chain 里都包含了两个很重要的属性:input_keysoutput_keys,用于表示这个 Chain 的输入和输出,我们查看 LLMChain 的源码,它的 input_keys 实现如下:

    @property
    def input_keys(self) -> List[str]:
        return self.prompt.input_variables

所以 LLMChain 的入参就是 Prompt 的 input_variables,而 PromptTemplate.from_template() 其实是 PromptTemplate 的简写,下面这行代码:

prompt = PromptTemplate.from_template("将下面的句子翻译成英文:{sentence}")

等价于下面这行代码:

prompt = PromptTemplate(
    input_variables=['sentence'],
    template = "将下面的句子翻译成英文:{sentence}"
)

所以 LLMChain 的入参也就是 sentence 参数。

而下面这行代码:

result = llm_chain("今天的天气真不错")

是下面这行代码的简写:

result = llm_chain({'sentence': "今天的天气真不错"})

当参数比较简单时,LangChain 会自动将传入的参数转换成它需要的 Dict 形式,一旦我们在 Prompt 中定义了多个参数,那么这种简写就不行了,就得在调用的时候明确指定参数:

from langchain import PromptTemplate, OpenAI, LLMChain

llm = OpenAI(temperature=0.9)
prompt = PromptTemplate(
    input_variables=['lang', 'sentence'],
    template = "将下面的句子翻译成{lang}:{sentence}"
)

llm_chain = LLMChain(
    llm = llm, 
    prompt = prompt
)
result = llm_chain({"lang": "日语", "sentence": "今天的天气真不错"})
print(result['text'])

# 今日の天気は本当に良いです。

我们从 LangChain 的源码中可以更深入的看下 Chain 的类定义,如下:

class Chain(BaseModel, ABC):

    def __call__(
        self,
        inputs: Union[Dict[str, Any], Any],
        return_only_outputs: bool = False,
        callbacks: Callbacks = None,
        ...
    ) -> Dict[str, Any]:
        ...

这说明 Chain 的本质其实就是根据一个 Dict 输入,得到一个 Dict 输出而已。只不过 Chain 为我们还提供了三个特性:

  • Stateful: 内置 Memory 记忆功能,使得每个 Chain 都是有状态的;
  • Composable: 具备高度的可组合性,我们可以将 Chain 和其他的组件,或者其他的 Chain 进行组合;
  • Observable: 支持向 Chain 传入 Callbacks 回调执行额外功能,从而实现可观测性,如日志、监控等;

使用 Memory 实现记忆功能

Chain 的第一个特征就是有状态,也就是说,它能够记住会话的历史,这是通过 Memory 模块来实现的,最简单的 Memory 是 ConversationBufferMemory,它通过一个内存变量保存会话记忆。

在使用 Memory 时,我们的 Prompt 需要做一些调整,我们要在 Prompt 中加上历史会话的内容,比如下面这样:

template = """
下面是一段人类和人工智能之间的友好对话。

当前对话内容:
{history}
Human: {input}
AI:"""
prompt = PromptTemplate(input_variables=["history", "input"], template=template)

其中 {history} 这个占位符的内容就由 Memory 来负责填充,我们只需要将 Memory 设置到 LLMChain 中,这个填充操作将由框架自动完成:

llm = OpenAI(temperature=0.9)
memory = ConversationBufferMemory()

llm_chain = LLMChain(
    llm = llm, 
    prompt = prompt,
    memory = memory
)

result = llm_chain("窗前明月光,下一句是什么?")
print(result['text'])

result = llm_chain("这是谁的诗?")
print(result['text'])

对于 ChatModels,我们可以使用 ChatPromptTemplate 来构造 Prompt:

prompt = ChatPromptTemplate(
    messages=[
        SystemMessagePromptTemplate.from_template("你是一个聊天助手,和人类进行聊天。"),
        MessagesPlaceholder(variable_name="history"),
        HumanMessagePromptTemplate.from_template("{input}")
    ]
)

占位符填充的基本逻辑是一样的,不过在初始化 Memory 的时候记得指定 return_messages=True,让 Memory 使用 Message 列表来填充占位符,而不是默认的字符串:

chat = ChatOpenAI(temperature=0.9)
memory = ConversationBufferMemory(return_messages=True)

llm_chain = LLMChain(
    llm = chat, 
    prompt = prompt,
    memory = memory
)

result = llm_chain("窗前明月光,下一句是什么?")
print(result['text'])

result = llm_chain("这是谁的诗?")
print(result['text'])

为了简化 Prompt 的写法,LangChain 基于 LLMChain 实现了 ConversationChain,使用起来更简单:

from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory

chat = ChatOpenAI(temperature=0.9)
memory = ConversationBufferMemory()

conversation = ConversationChain(
    llm = chat,
    memory = memory
)

result = conversation.run("窗前明月光,下一句是什么?")
print(result)

result = conversation.run("这是谁的诗?")
print(result)

除了 ConversationBufferMemory,LangChain 还提供了很多其他的 Memory 实现:

  • ConversationBufferWindowMemory - 基于内存的记忆,只保留最近 K 次会话;
  • ConversationTokenBufferMemory - 基于内存的记忆,根据 token 数来限制保留最近的会话;
  • ConversationEntityMemory - 从会话中提取实体,并在记忆中构建关于实体的知识;
  • ConversationKGMemory - 基于知识图来重建记忆;
  • ConversationSummaryMemory - 对历史会话进行总结,减小记忆大小;
  • ConversationSummaryBufferMemory - 既保留最近 K 次会话,也对历史会话进行总结;
  • VectorStoreRetrieverMemory - 通过向量数据库保存记忆;

这些 Memory 在长对话场景,或需要对记忆进行持久化时非常有用。内置的 Memory 大多数都是基于内存实现的,LangChain 还集成了很多第三方的库,可以实现记忆的持久化,比如 Sqlite、Mongo、Postgre 等。

再聊文档问答:RetrievalQA

在上面的实现知识库助手一节,我们学习了各种加载文档的方式,以及如何对大文档进行分片,并使用 OpenAI 计算文档的 Embedding,最后保存到向量数据库 Qdrant 中。一旦文档库准备就绪,接下来就可以对它进行问答了,LangChain 也贴心地提供了很多关于文档问答的 Chain 方便我们使用,比如下面最常用的 RetrievalQA

from langchain.chains import RetrievalQA
from langchain.llms import OpenAI

qa = RetrievalQA.from_chain_type(llm=OpenAI(), chain_type="stuff", retriever=qdrant.as_retriever())
query = "小明家的宠物狗比小红家的大几岁?"
result = qa.run(query)
print(result)

# 毛毛比大白大两岁,毛毛今年3岁,大白今年1岁。

RetrievalQA 充分体现了 Chain 的可组合性,它实际上是一个复合 Chain,真正实现文档问答的 Chain 是它内部封装的 CombineDocumentsChainRetrievalQA 的作用是通过 Retrievers 获取和用户问题相关的文档,然后丢给内部的 CombineDocumentsChain 来处理,而 CombineDocumentsChain 又是一个复合 Chain,它会将用户问题和相关文档组合成提示语,丢到内部的 LLMChain 处理最终得到输出。

但总的来说,实现文档问答最核心的还是 RetrievalQA 内部使用的 CombineDocumentsChain,实际上除了 文档问答,针对文档的使用场景还有 生成文档摘要提取文档信息,等等,这些利用大模型处理文档的场景中,最难解决的问题是如何对大文档进行拆分和组合,而解决方案也是多种多样的,LangChain 提供的 CombineDocumentsChain 是一个抽象类,根据不同的解决方案它包含了四个实现类:

StuffDocumentsChain

StuffDocumentsChain 是最简单的文档处理 Chain,单词 stuff 的意思是 填充,正如它的名字所示,StuffDocumentsChain 将所有的文档内容连同用户的问题一起全部填充进 Prompt,然后丢给大模型。这种 Chain 在处理比较小的文档时非常有用。在上面的例子中,我们通过 RetrievalQA.from_chain_type(chain_type="stuff") 生成了 RetrievalQA,内部就是使用了 StuffDocumentsChain。它的示意图如下所示:

stuff-documents.jpg

RefineDocumentsChain

refine 的含义是 提炼RefineDocumentsChain 表示从分片的文档中一步一步的提炼出用户问题的答案,这个 Chain 在处理大文档时非常有用,可以保证每一步的提炼都不超出大模型的上下文限制。比如我们有一个很大的 PDF 文件,我们要生成它的文档摘要,我们可以先生成第一个分片的摘要,然后将第一个分片的摘要和第二个分片丢给大模型生成前两个分片的摘要,再将前两个分片的摘要和第三个分片丢给大模型生成前三个分片的摘要,依次类推,通过不断地对大模型的答案进行更新,最终生成全部分片的摘要。整个提炼的过程如下:

refine-documents.jpg

RefineDocumentsChain 相比于 StuffDocumentsChain,会产生更多的大模型调用,而且对于有些场景,它的效果可能很差,比如当文档中经常出现相互交叉的引用时,或者需要从多个文档中获取非常详细的信息时。

MapReduceDocumentsChain

MapReduceDocumentsChainRefineDocumentsChain 一样,都适合处理一些大文档,它的处理过程可以分为两步:MapReduce。比如我们有一个很大的 PDF 文件,我们要生成它的文档摘要,我们可以先将文档分成小的分片,让大模型挨个生成每个分片的摘要,这一步称为 Map;然后再将每个分片的摘要合起来生成汇总的摘要,这一步称为 Reduce;如果所有分片的摘要合起来超过了大模型的限制,那么我们需要对合起来的摘要再次分片,然后再次递归地执行 MapReduce 这个过程:

map-reduce-documents.jpg

MapRerankDocumentsChain

MapRerankDocumentsChain 一般适合于大文档的问答场景,它的第一步和 MapReduceDocumentsChain 类似,都是遍历所有的文档分片,挨个向大模型提问,只不过它的 Prompt 有一点特殊,它不仅要求大模型针对用户的问题给出一个答案,而且还要求大模型为其答案的确定性给出一个分数;得到每个答案的分数后,MapRerankDocumentsChain 的第二步就可以按分数排序,给出分数最高的答案:

map-rerank-documents.jpg

对 Chain 进行调试

当 LangChain 的输出结果有问题时,开发者就需要 对 Chain 进行调试,特别是当组合的 Chain 非常多时,LangChain 的输出结果往往非常不可控。最简单的方法是在 Chain 上加上 verbose=True 参数,这时 LangChain 会将执行的中间步骤打印出来,比如上面的 使用 Memory 实现记忆功能 一节中的例子,加上调试信息后,第一次输出结果如下:

> Entering new LLMChain chain...
Prompt after formatting:
System: 你是一个聊天助手,和人类进行聊天。
Human: 窗前明月光,下一句是什么?

> Finished chain.
疑是地上霜。

第二次的输出结果如下:

> Entering new LLMChain chain...
Prompt after formatting:
System: 你是一个聊天助手,和人类进行聊天。
Human: 窗前明月光,下一句是什么?
AI: 疑是地上霜。
Human: 这是谁的诗?

> Finished chain.
这是《静夜思》的开头,是中国唐代诗人李白创作的。

可以很方便的看出 Memory 模块确实起作用了,它将历史会话自动拼接在 Prompt 中了。

当一个 Chain 组合了其他 Chain 的时候,比如上面的 RetrievalQA,这时我们不仅要给 Chain 加上 verbose=True 参数,注意还要通过 chain_type_kwargs 为内部的 Chain 也加上该参数:

qa = RetrievalQA.from_chain_type(
    llm=OpenAI(), 
    chain_type="stuff", 
    retriever=qdrant.as_retriever(), 
    verbose=True, 
    chain_type_kwargs={'verbose': True})

输出来的结果类似下面这样:

> Entering new RetrievalQA chain...

> Entering new StuffDocumentsChain chain...

> Entering new LLMChain chain...
Prompt after formatting:
Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.

小明家有一条宠物狗,叫毛毛,这是他爸从北京带回来的,它今年3岁了。

小红家也有一条宠物狗,叫大白,非常听话,它今年才1岁呢。

小红的好朋友叫小明,他们是同班同学。

小华是小明的幼儿园同学,从小就欺负他。

Question: 小明家的宠物狗比小红家的大几岁?
Helpful Answer:

> Finished chain.

> Finished chain.

> Finished chain.
 毛毛比大白大2岁。

从输出的结果我们不仅可以看到各个 Chain 的调用链路,而且还可以看到 StuffDocumentsChain 所使用的提示语,以及 retriever 检索出来的内容,这对我们调试 Chain 非常有帮助。

另外,Callbacks 是 LangChain 提供的另一种调试机制,它提供了比 verbose=True 更好的扩展性,用户可以基于这个机制实现自定义的监控或日志功能。LangChain 内置了很多的 Callbacks,这些 Callbacks 都实现自 CallbackHandler 接口,比如最常用的 StdOutCallbackHandler,它将日志打印到控制台,参数 verbose=True 就是通过它实现的;或者 FileCallbackHandler 可以将日志记录到文件中;LangChain 还 集成了很多第三方工具,比如 StreamlitCallbackHandler 可以将日志以交互形式输出到 Streamlit 应用中。

用户如果要实现自己的 Callbacks,可以直接继承基类 BaseCallbackHandler 即可,下面是一个简单的例子:

class MyCustomHandler(BaseCallbackHandler):

    def on_llm_start(
        self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any
    ) -> Any:
        """Run when LLM starts running."""
        print(f"on_llm_start: {serialized} {prompts}")

    def on_llm_end(self, response: LLMResult, **kwargs: Any) -> Any:
        """Run when LLM ends running."""
        print(f"on_llm_end: {response}")

    def on_llm_error(
        self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any
    ) -> Any:
        """Run when LLM errors."""
        print(f"on_llm_error: {error}")

    def on_chain_start(
        self, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any
    ) -> Any:
        """Run when chain starts running."""
        print(f"on_chain_start: {serialized} {inputs}")

    def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> Any:
        """Run when chain ends running."""
        print(f"on_chain_end: {outputs}")

    def on_chain_error(
        self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any
    ) -> Any:
        """Run when chain errors."""
        print(f"on_chain_error: {error}")

    def on_text(self, text: str, **kwargs: Any) -> Any:
        """Run on arbitrary text."""
        print(f"on_text: {text}")

在这个例子中,我们对 LLM 和 Chain 进行了监控,我们需要将这个 Callback 分别传递到 LLM 和 Chain 中:

callback = MyCustomHandler()

llm = OpenAI(temperature=0.9, callbacks=[callback])
prompt = PromptTemplate.from_template("将下面的句子翻译成英文:{sentence}")

llm_chain = LLMChain(
    llm = llm, 
    prompt = prompt,
    callbacks=[callback]
)
result = llm_chain("今天的天气真不错")
print(result['text'])

LangChain 会在调用 LLM 和 Chain 开始和结束的时候回调我们的 Callback,输出结果大致如下:

on_chain_start: <...略>
on_text: Prompt after formatting:
将下面的句子翻译成英文:今天的天气真不错
on_llm_start: <...略>
on_llm_end: <...略>
on_chain_end: {'text': '\n\nThe weather today is really nice.'}

从 Chain 到 Agent

通过上一节的学习,我们掌握了 Chain 的基本概念和用法,对 Chain 的三大特性也有了一个大概的认识,关于 Chain 还有很多高级主题等着我们去探索,比如 Chain 的异步调用实现自定义的 ChainChain 的序列化和反序列化从 LangChainHub 加载 Chain,等等。

此外,除了上一节学习的 LLMChainConversationChainRetrievalQA 以及各种 CombineDocumentsChain,LangChain 还提供了很多其他的 Chain 供用户使用,其中有四个是最基础的:

  • LLMChain - 这个 Chain 在前面已经学习过,它主要是围绕着大模型添加一些额外的功能,比如 ConversationChainCombineDocumentsChain 都是基于 LLMChain 实现的;
  • TransformChain - 这个 Chain 主要用于参数转换,这在对多个 Chain 进行组合时会很有用,我们可以使用 TransformChain 将上一个 Chain 的输出参数转换为下一个 Chain 的输入参数;
  • SequentialChain - 可以将多个 Chain 组合起来并按照顺序分别执行;
  • RouterChain - 这是一种很特别的 Chain,当你有多个 Chain 且不知道该使用哪个 Chain 处理用户请求时,可以使用它;首先,你给每个 Chain 取一个名字,然后给它们分别写一段描述,然后让大模型来决定该使用哪个 Chain 处理用户请求,这就被称之为 路由(route)

在这四个基础 Chain 中,RouterChain 是最复杂的一个,而且它一般不单独使用,而是和 MultiPromptChainMultiRetrievalQAChain 一起使用,类似于下面这样:

chain = MultiPromptChain(
    router_chain=router_chain,
    destination_chains=destination_chains,
    default_chain=default_chain,
    verbose=True,
)

MultiPromptChain 中,我们给定了多个目标 Chains,然后使用 RouterChain 来选取一个最适合处理用户请求的 Chain,如果不存在,则使用一个默认的 Chain,这种思路为我们打开了一扇解决问题的新大门,如果我们让大模型选择的不是 Chain,而是一个函数,或是一个外部接口,那么我们就可以通过大模型做出更多的动作,完成更多的任务,这不就是 OpenAI 的 Function Calling 功能吗?

实际上,LangChain 也提供了 create_structured_output_chain()create_openai_fn_chain() 等方法来创建 OpenAI Functions Chain,只不过 OpenAI 的 Function Calling 归根结底仍然只是一个 LLMChain,它只能返回要使用的函数和参数,并没有真正地调用它,如果要将大模型的输出和函数执行真正联系起来,这就得 Agents 出马了。

Agents 是 LangChain 里一个非常重要的主题,我们将在下一篇笔记中继续学习它。

本文所有的源码可以 参考这里

参考

LangChain 官方资料

LangChain 项目

LangChain 教程

LangChain 可视化

LlamaIndex 参考资料

其他

扫描二维码,在手机上阅读!

使用 Embedding 技术打造本地知识库助手

基于不同的提示语,可以让 ChatGPT 实现各种不同的功能,比如在 基于 ChatGPT 实现一个划词翻译 Chrome 插件 这篇文章中,我基于 ChatGPT 实现了一个翻译助手,OpenAI 官方的 Examples 页面 也列出了提示语的更多示例,展示了 ChatGPT 在问答、翻译、文本总结、代码生成、推理等各方面的能力。

尽管 ChatGPT 的表现非常亮眼,但是它也有其局限性,由于它是基于互联网上公开的资料训练的,所以它只能回答公开领域的知识的问题,比如你问它是谁发明了空调,第一个登月的人是谁,它都能回答得头头是道,但是对于一些私有领域的知识,比如你问它张三家的宠物狗叫什么名字,它就鞭长莫及了。

Fine tuning vs. Embedding

打造私有领域的知识库助手对于企业和个人来说是一个非常重要的应用场景,可以实现个性化定制化的问答效果,要实现这个功能,一般有两种不同的方式:Fine tuningEmbedding。Fine tuning 又被称为微调,它可以在不改动预训练模型的基础上,对特定任务进一步训练,以适应特定数据和要求。OpenAI Cookbook 中有一个 基于 Fine tuning 实现 QA 的例子,不过官方已经不推荐使用这种方式来做知识问答任务,因为 Fine tuning 更适合于学习新任务或新模式,而不是接受新信息,比如我们可以使用 Fine tuning 让模型按特定的语气或风格来回答问题,或者让模型按固定的格式来回答问题。

相对应的,Embedding 更适合知识问答任务,而且 Embedding 技术还顺便解决了大模型的一个问题,那就是上下文限制,比如 OpenAI 的 GPT-3.5 模型,它的限制在 4k - 16k 个 token,就算是 GPT-4 模型,最多也只支持 32k 个 token,所以,如果你的知识库内容长度超出了限制,我们就不能直接让 ChatGPT 对其进行总结并回答问题。通过 Embedding 技术,我们可以使用语义搜索来快速找到相关的文档,然后只将相关的文档内容注入到大模型的上下文窗口中,让模型来生成特定问题的答案,从而解决大模型的限制问题。这种做法比 Fine tuning 速度更快,而且不需要训练,使用上也更灵活。

Embedding 也被称为嵌入,它是一种数据表征的方式,最早可以追溯到 1986 年 Hinton 的论文 《Learning distributed representations of concepts》,他在论文中提出了分布式表示(Distributed Representation)的概念,这个概念后来被人们称为词向量或词嵌入(Word Embedding),使用它可以将单词表示成一个数字向量,同时可以保证相关或相似的词在距离上很接近。Embedding 技术发展到今天,已经可以将任意对象向量化,包括文本、图像甚至音视频,在搜索和推荐等业务中有着广泛的应用。

知道了 Embedding 技术,我们就可以理解如何使用 Embedding 实现本地知识问答了,整个处理流程如下图所示:

qa-with-embedding.png

构建本地知识库

假设我们有一个本地知识库,这可能是某个产品的使用手册,或者某个公司的内部文档,又或者是你自己的一些私人资料,我们希望 ChatGPT 能够回答关于这些本地知识的问题。根据上面的流程图,我们首先需要对我们的知识库进行 Embedding 处理,将知识库中的所有文档向量化,这里其实涉及两个问题:

  1. 如何计算每个文档的向量?
  2. 如何存储每个文档的向量?

如何计算文档的向量?

对此,前辈大佬们提出了很多种不同的解决方案,比如 Word2vec、GloVe、FastText、ELMo、BERT、GPT 等等,不过这些都是干巴巴的论文和算法,对我们这种普通用户来说,可以直接使用一些训练好的模型。开源项目 Sentence-Transformers 是一个很好的选择,它封装了 大量可用的预训练模型;另外开源项目 Towhee 不仅支持大量的 Embedding 模型,而且还提供了其他常用的 AI 流水线的实现,这里是它支持的 Embedding 模型列表;不过在本地跑 Embedding 模型对机器有一定的门槛要求,我们也可以直接使用一些公开的 Embedding 服务,比如 OpenAI 提供的 /v1/embeddings 接口,它使用的 text-embedding-ada-002 模型是 OpenAI 目前提供的效果最好的第二代 Embedding 模型,相比于第一代的 davincicuriebabbage 等模型,效果更好,价格更便宜。我们这里就直接使用该接口生成任意文本的向量。使用 OpenAI 的 Python SDK 调用该接口如下:

import os
import openai

openai.api_key = os.getenv("OPENAI_API_KEY")

text_string = "sample text"
model_id = "text-embedding-ada-002"

embedding = openai.Embedding.create(input=text_string, model=model_id)['data'][0]['embedding']
print(embedding)

输出的是一个长度为 1536 的数组,也可以说是一个 1536 维的向量:

[
    -0.0022714741062372923, 
    0.009765749797224998, 
    -0.018565727397799492,
    ...
     0.0037550802808254957, 
     -0.004177606198936701
]

如何存储文档的向量?

第二个问题是计算出来的向量该如何存储?实际上,自从大模型兴起之后,Embedding 和向量数据库就变成了当前 AI 领域炙手可热的话题,一时间,涌出了很多专注于向量数据库的公司或项目,比如 PineconeWeaviateQdrantChromaMilvus 等,很多老牌数据库厂商也纷纷加入向量数据库的阵营,比如 ElasticSearchCassandraPostgresRedisMongo 等。

我们这里使用 Qdrant 作为示例,首先通过 Docker 在本地启动 Qdrant 服务:

$ docker run -p 6333:6333 -v $(pwd)/data:/qdrant/storage qdrant/qdrant

然后通过下面的代码创建一个名为 kb 的向量库:

from qdrant_client import QdrantClient
from qdrant_client.http.models import Distance, VectorParams

client = QdrantClient("127.0.0.1", port=6333)
client.recreate_collection(
    collection_name='kb',
    vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)

注意我们指定向量维度为 1536,这是 OpenAI Embedding 输出的维度,另外指定距离为 COSINE,它决定如何度量向量之间的相似度,这个我们后面再讲。

作为示例,我们将一个文件 kb.txt 作为本地知识库,文件中的每一行代表一条知识,比如:

小红的好朋友叫小明,他们是同班同学。
小明家有一条宠物狗,叫毛毛,这是他爸从北京带回来的。
小红家也有一条宠物狗,叫大白,非常听话。
小华是小明的幼儿园同学,从小就欺负他。

然后我们读取该知识库文件,依次计算每一行的向量,并将其保存在 kb 库中:

with open('kb.txt', 'r', encoding='utf-8') as f:
    for index, line in enumerate(tqdm.tqdm(f.readlines())):
        embedding = to_embedding(line)
        client.upsert(
            collection_name='kb',
            wait=True,
            points=[
                PointStruct(id=index+1, vector=embedding, payload={"text": line}),
            ],
        )

在保存向量时,可以通过 payload 带上一些元数据,比如这里将原始的文本内容和向量一起保存,这样可以方便后面检索时知道向量和原始文本的对应关系。

至此我们的本地知识库就初始化好了。

实现本地知识问答助手

构建好知识库之后,我们就可以基于知识库实现本地知识库助手了。接下来要解决的问题也有两个:

  1. 如何从知识库中检索出和问题最相关的文档?
  2. 如何让 ChatGPT 回答关于知识库的问题?

如何检索知识?

传统的检索方式是关键词检索,比如 Elasticsearch 通过分词和倒排索引,让我们可以快速搜出含有关键词的文档,再通过一定的排序规则,比如文档中包含的关键词越多得分越高,就可以检索出和问题最相关的文档。不过这种方式面临非常多的问题,比如一个关键词可能有很多同义词,用户搜 “番茄” 时可能就搜不到包含 “西红柿” 的文档。另外,我们还可能需要对用户的输入进行纠错,对用户进行意图识别,不同语言的处理等等,或者用户希望搜索图片、音频或视频,这时传统的检索方式就捉襟见肘了。

我们上面已经介绍过 Embedding 技术,通过 Embedding 技术可以将任何事物表示成一个向量,而且最牛的地方是,它可以保证相关或相似的事物在距离上很接近,或者说有着相似的语义,这就给我们提供了一种新的检索方式:语义搜索(Semantic Search)。

所以想要搜索出和用户问题最相关的文档,我们可以先将用户问题通过 Embedding 转换为向量,然后在上面构建好的知识库中搜索和问题向量最接近的向量及对应的文档即可。那么怎么计算两个向量之间的距离呢?其实我们在上面创建库时已经使用过 COSINE,除了 COSINE,Qdrant 还支持使用 EUCLIDDOT 等方法:

  • COSINE - 余弦相似度,计算两个向量之间的夹角,夹角越小,向量之间越相似;
  • EUCLID - 欧几里得距离,计算两个向量之间的直线距离,距离越近,向量之间越相似;这种度量方法简单直接,在 2 维或 3 维等低维空间表现得很好,但是在高维空间,每个向量之间的欧几里得距离都很靠近,无法起到度量相似性的作用;
  • DOT - 点积相似度,点积是两个向量的长度与它们夹角余弦的积,点积越大,向量之间就越相似。

在机器学习和数据挖掘中还有很多度量距离的方法:

  • 闵可夫斯基距离
  • 曼哈顿距离
  • 切比雪夫距离
  • 马氏距离
  • 皮尔逊相关系数
  • 汉明距离
  • 杰卡德相似系数
  • 编辑距离
  • DTW 距离
  • KL 散度

不过这些度量方法在语义搜索中用的不多,感兴趣的同学可以进一步了解之。

在语义搜索中,用的最多的是余弦相似度。当计算出用户问题的向量之后,我们就可以遍历知识库中的所有向量,依次计算每个向量和问题向量之间的距离,然后按距离排序,取距离最近的几条数据,就能得到和用户问题最相似的文档了。

不过很显然这里存在一个问题,如果知识库中的向量比较多,这种暴力检索的方法就会非常耗时。为了加快检索向量库的速度,人们提出了很多种 ANN(Approximate Nearest Neighbor,相似最近邻)算法,算法的基本思路是通过对全局向量空间进行分割,将其分割成很多小的子空间,在搜索的时候,通过某种方式,快速锁定在某一个或某几个子空间,然后在这些子空间里做遍历。可以粗略地将这些子空间认为是向量数据库的索引。常见的 ANN 算法可以分为三大类:基于树的方法、基于哈希的方法、基于矢量量化的方法,比如 Annoy、KD 树、LSH(局部敏感哈希)、PQ(乘积量化)、HNSW 等。

Qdrant 针对负载数据和向量数据使用了不同的 索引策略,其中对向量数据的索引使用的就是 HNSW 算法。通过 client.search() 搜索和问题向量最接近的 N 个向量:

question = '小明家的宠物狗叫什么名字?'
search_results = client.search(
    collection_name='kb',
    query_vector=to_embedding(question),
    limit=3,
    search_params={"exact": False, "hnsw_ef": 128}
)

搜索出来的结果类似于下面这样,不仅包含了和问题最接近的文档,而且还有对应的相似度得分:

[
    ScoredPoint(id=2, version=1, score=0.91996545, payload={'text': '小明家有一条宠物狗,叫毛毛,这是他爸从北京带回来的。\n'}, vector=None), 
    ScoredPoint(id=3, version=2, score=0.8796822, payload={'text': '小红家也有一条宠物狗,叫大白,非常听话。\n'}, vector=None), 
    ScoredPoint(id=1, version=0, score=0.869504, payload={'text': '小红的好朋友叫小明,他们是同班同学。\n'}, vector=None)
]

如何向 ChatGPT 提问?

经过上面的三步,我们的本地知识库助手其实就已经完成 90% 了,下面是最后一步,我们需要将搜出来的文档和用户问题重新组装并丢给 ChatGPT,下面是一个简单的 Prompt 模板:

你是一个知识库助手,你将根据我提供的知识库内容来回答问题
已知有知识库内容如下:
1. 小明家有一条宠物狗,叫毛毛,这是他爸从北京带回来的。
2. 小红家也有一条宠物狗,叫大白,非常听话。
3. 小红的好朋友叫小明,他们是同班同学。
请根据知识库回答以下问题:小明家的宠物狗叫什么名字?

组装好 Prompt 之后,通过 openai.ChatCompletion.create() 调用 ChatGPT 接口:

completion = openai.ChatCompletion.create(
    temperature=0.7,
    model="gpt-3.5-turbo",
    messages=format_prompt(question, search_results),
)
print(completion.choices[0].message.content)

得到 ChatGPT 的回复:

小明家的宠物狗叫毛毛。

总结

本文首先从 Fine tuning 和 Embedding 技术聊起,介绍了如何通过这些技术实现本地知识库助手;然后通过学习 OpenAI 的 Embedding 接口和开源向量数据库 Qdrant,从零开始实现了一个非常简单的知识库助手程序,程序的完整源码 在这里。源码实现得非常粗糙,只是对技术原理的简单验证,如果要实现一个真实可用的知识库助手,还有很多其他问题值得考虑,比如如何支持更多类型的文档(pdf、word、excel),如何支持对数据库中的数据进行问答,如何针对大文档进行拆分,等等。

参考

更多

类似项目

扫描二维码,在手机上阅读!

基于 ChatGPT 实现一个划词翻译 Chrome 插件

去年 11 月,美国的 OpenAI 公司推出了 ChatGPT 产品,它在发布后的 5 天内用户数就突破了 100 万,两个月后月活用户突破了 1 个亿,成为至今为止人类历史上用户数增长最快的消费级应用。ChatGPT 之所以能在全球范围内火出天际,不仅是因为它能以逼近自然语言的能力和人类对话,而且可以根据不同的提示语解决各种不同场景下的问题,它的推理能力、归纳能力、以及多轮对话能力都让世人惊叹不已,让实现通用人工智能(AGI,Artificial General Intelligence)变成为了现实,也意味着一种新型的人机交互接口由此诞生,这为更智能的 AI 产品提供了无限可能。

很快,OpenAI 推出了相应的 API 接口,所有人都可以基于这套 API 快速实现一个类似 ChatGPT 这样的产品,当然,聊天对话只是这套 API 的基本能力,OpenAI 官方网站有一个 Examples 页面,展示了结合不同的提示语 OpenAI API 在更多场景下的应用:

chatgpt-examples.png

OpenAI API 快速入门

OpenAI 提供了很多和 AI 相关的接口,如下:

  • Models - 用于列出所有可用的模型;
  • Completions - 给定一个提示语,让 AI 生成后续内容;
  • Chat - 给定一系列对话内容,让 AI 生成对应的回复,使用这个接口就可以实现类似 ChatGPT 的功能;
  • Edits - 给定一个提示语和一条指令,AI 将对提示语进行相应的修改,比如常见的语法纠错场景;
  • Images - 用于根据提示语生成图片,或对图片进行编辑,可以实现类似于 Stable DiffusionMidjourney 这样的 AI 绘画应用,这个接口使用的是 OpenAI 的图片生成模型 DALL·E
  • Embeddings - 用于获取一个给定文本的向量表示,我们可以将结果保存到一个向量数据库中,一般用于搜索、推荐、分类、聚类等任务;
  • Audio - 提供了语音转文本的功能,使用了 OpenAI 的 Whisper 模型;
  • Files - 文件管理类接口,便于用户上传自己的文件进行 Fine-tuning;
  • Fine-tunes - 用于管理你的 Fine-tuning 任务,详细内容可参考 Fine-tuning 教程
  • Moderations - 用于判断给定的提示语是否违反 OpenAI 的内容政策;

关于 API 的详细内容可以参考官方的 API referenceDocumentation

其中,CompletionsChatEdits 这三个接口都可以用于对话任务,Completions 主要解决的是补全问题,也就是说用户给出一段话,模型可以按照提示语续写后面的内容;Chat 用于处理聊天任务,它显式的定义了 systemuserassistant 三个角色,方便维护对话的语境信息和多轮对话的历史记录;Edit 主要用于对用户的输入进行修改和纠正。

要调用 OpenAI 的 API 接口,必须先创建你的 API Keys,然后请求时像下面这样带上 Authorization 头即可:

Authorization: Bearer OPENAI_API_KEY

下面是直接使用 curl 调用 Chat 接口的示例:

$ curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
     "model": "gpt-3.5-turbo",
     "messages": [{"role": "user", "content": "你好!"}],
     "temperature": 0.7
   }'

我们可以得到类似下面的回复:

{
  "id": "chatcmpl-7LgiOhYPcGGwoBcEPQmQ2LaO2pObn",
  "object": "chat.completion",
  "created": 1685403440,
  "model": "gpt-3.5-turbo-0301",
  "usage": {
    "prompt_tokens": 11,
    "completion_tokens": 18,
    "total_tokens": 29
  },
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "你好!有什么我可以为您效劳的吗?"
      },
      "finish_reason": "stop",
      "index": 0
    }
  ]
}

如果你无法访问 OpenAI 的接口,或者没有 OpenAI 的 API Keys,网上也有很多免费的方法,比如 chatanywhere/GPT_API_free

OpenAI 官方提供了 Python 和 Node.js 的 SDK 方便我们在代码中调用 OpenAI 接口,下面是使用 Node.js 调用 Completions 的示例:

import { Configuration, OpenAIApi } from "openai";

const configuration = new Configuration({
    apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

const response = await openai.createCompletion({
    "model": "text-davinci-003",
    "prompt": "你好!",
    "max_tokens": 100,
    "temperature": 0
});
console.log(response.data);

由于 SDK 底层使用了 axios 库发请求,所以我们还可以对 axios 进行配置,比如像下面这样设置代理:

const response = await openai.createCompletion({
    "model": "text-davinci-003",
    "prompt": "你好!",
    "max_tokens": 100,
    "temperature": 0
}, {
    proxy: false,
    httpAgent: new HttpsProxyAgent(process.env.HTTP_PROXY),
    httpsAgent: new HttpsProxyAgent(process.env.HTTP_PROXY)
});

使用 OpenAI API 实现翻译功能

从上面的例子可以看出,OpenAI 提供的 CompletionsChat 只是一套用于对话任务的接口,并没有提供翻译接口,但由于它的对话已经初步具备 AGI 的能力,所以我们可以通过特定的提示语让它实现我们想要的功能。官方的 Examples 页面有一个 English to other languages 的例子,展示了如何通过提示语技术将英语翻译成法语、西班牙语和日语,我们只需要稍微修改下提示语,就可以实现英译中的功能:

async function translate(text) {

    const prompt = `Translate this into Simplified Chinese:\n\n${text}\n\n`
    
    const openai = createOpenAiClient();
    const response = await openai.createCompletion({
        "model": "text-davinci-003",
        "prompt": prompt,
        "max_tokens": 100,
        "temperature": 0
    }, createAxiosOptions());
    return response.data.choices[0].text
}

上面我们使用了 Translate this into Simplified Chinese: 这样的提示语,这个提示语既简单又直白,但是翻译效果却非常的不错,我们随便将一段官方文档丢给它:

console.log(await translate("The OpenAI API can be applied to virtually any task that involves understanding or generating natural language, code, or images."));

OpenAI API 可以应用于几乎任何涉及理解或生成自然语言、代码或图像的任务。

看上去,翻译的效果不亚于 Google 翻译,而且更神奇的是,由于这里的提示语并没有明确输入的文本是什么,也就意味着,我们可以将其他任何语言丢给它:

console.log(await translate("どの部屋が利用可能ですか?"));

这些房间可以用吗?

这样我们就得到了一个通用中文翻译接口。

Chrome 插件快速入门

我在很久以前写过一篇关于 Chrome 插件的博客,我的第一个 Chrome 扩展:Search-faster,不过当时 Chrome 扩展还是 V2 版本,现在 Chrome 扩展已经发展到 V3 版本了,并且 V2 版本不再支持,于是我决定将 Chrome 扩展的开发文档 重温一遍。

一个简单的例子

每个 Chrome 插件都需要有一个 manifest.json 清单文件,我们创建一个空目录,并在该目录下创建一个最简单的 manifest.json 文件:

{
  "name": "Chrome Extension Sample",
  "version": "1.0.0",
  "manifest_version": 3,
  "description": "Chrome Extension Sample"
}

这时,一个最简单的 Chrome 插件其实就已经准备好了。我们打开 Chrome 的 管理扩展程序 页面 chrome://extensions/,启用开发者模式,然后点击 “加载已解压的扩展程序”,选择刚刚创建的那个目录就可以加载我们编写的插件了:

chrome-extension-sample.png

只不过这个插件还没什么用,如果要添加实用的功能,还得添加这些比较重要的字段:

  • background:背景页通常是 Javascript 脚本,在扩展进程中一直保持运行,它有时也被称为 后台脚本,它是一个集中式的事件处理器,用于处理各种扩展事件,它不能访问页面上的 DOM,但是可以和 content_scriptsaction 之间进行通信;在 V2 版本中,background 可以定义为 scriptspage,但是在 V3 版本中已经废弃,V3 版本中统一定义为 service_worker
  • content_scripts:内容脚本可以让我们在 Web 页面上运行我们自定义的 Javascript 脚本,通过它我们可以访问或操作 Web 页面上的 DOM 元素,从而实现和 Web 页面的交互;内容脚本运行在一个独立的上下文环境中,类似于沙盒技术,这样不仅可以确保安全性,而且不会导致页面上的脚本冲突;
  • action:在 V2 版本中,Chrome 扩展有 browser_actionpage_action 两种表现形式,但是在 V3 版本中,它们被统一合并到 action 字段中了;用于当用户点击浏览器右上角的扩展图标时弹出一个 popup 页面或触发某些动作;
  • options_page:当你的扩展参数比较多时,可以制作一个单独的选项页面对你的扩展进行配置;

接下来,我们在 manifest.json 文件中加上 action 字段:

  "action": {
    "default_popup": "popup.html"
  }

然后,编写一个简单的 popup.html 页面,比如直接使用 iframe 嵌入我的博客:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <iframe src="https://www.aneasystone.com" frameborder="0" style="width: 400px;height:580px;"></iframe>
    </body>
</html>

修改完成后,点击扩展上的刷新按钮,将会重新加载扩展:

chrome-extension-sample-reload.png

这样当我们点击扩展图标时,就能弹出我的博客页面了:

chrome-extension-sample-popup.png

如果我们把页面换成 ChatGPT 的页面,那么一个 ChatGPT 的 Chrome 插件就做好了:

chrome-extension-chatgpt.png

在嵌入 ChatGPT 页面时发现,每次打开扩展都会跳转到登录页面,后来参考 kazuki-sf/ChatGPT_Extension 这里的做法解决了:在 manifest.json 中添加 content_scripts 字段,内容脚本非常简单,只需要一句 "use strict"; 即可。

注意并不是所有的页面都可以通过 iframe 嵌入,比如当我们嵌入 Google 时就会报错:www.google.com 拒绝了我们的连接请求,这是因为 Google 在响应头中添加了 X-Frame-Options: SAMEORIGIN 这样的选项,不允许被嵌入在非同源的 iframe 中。

实现划词翻译功能

我们现在已经学习了如何使用 OpenAI 接口实现翻译功能,也学习了 Chrome 扩展的基本知识,接下来就可以实现划词翻译功能了。

首先我们需要监听用户在页面上的划词动作以及所划的词是什么,这可以通过监听鼠标的 onmouseup 事件来实现。根据前面一节的学习我们知道,内容脚本可以让我们在 Web 页面上运行我们自定义的 Javascript 脚本,从而实现和 Web 页面的交互,所以我们在 manifest.json 中添加 content_scripts 字段:

  "content_scripts": [
    {
      "matches": ["*://*/*"],
      "js": ["content_script.js"]
    }
  ]

content_script.js 文件的内容很简单:

window.onmouseup = function (e) {

    // 非左键,不处理
    if (e.button != 0) {
        return;
    }
    
    // 未选中文本,不处理
    let text = window.getSelection().toString().trim()
    if (!text) {
        return;
    }

    // 翻译选中文本
    let translateText = translate(text)

    // 在鼠标位置显示翻译结果
    show(e.pageX, e.pageY, text, translateText)
}

可以看到实现划词翻译的整体脉络已经非常清晰了,后续的工作就是调用 OpenAI 的接口翻译文本,以及在鼠标位置将翻译结果显示出来。先看看如何实现翻译文本:

async function translate(text) {
    const prompt = `Translate this into Simplified Chinese:\n\n${text}\n\n`
    const body = {
        "model": "text-davinci-003",
        "prompt": prompt,
        "max_tokens": 100,
        "temperature": 0
    }
    const options = {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + OPENAI_API_KEY,
        },
        body: JSON.stringify(body),
    }
    const response = await fetch('https://api.openai.com/v1/completions', options)
    const json = await response.json()
    return json.choices[0].text
}

这里直接使用我们之前所用的提示语,只不过将发请求的 axios 换成了 fetchfetch 是浏览器自带的发请求的 API,但是不能在 Node.js 环境中使用。

接下来我们需要将翻译后的文本显示出来:

function show(x, y, text, translateText) {
    let container = document.createElement('div')
    container.innerHTML = `
    <header>翻译<span class="close">X</span></header>
    <main>
      <div class="source">
        <div class="title">原文</div>
        <div class="content">${text}</div>
      </div>
      <div class="dest">
        <div class="title">简体中文</div>
        <div class="content">${translateText}</div>
      </div>
    </main>
    `
    container.classList.add('translate-panel')
    container.classList.add('show')
    container.style.left = x + 'px'
    container.style.top = y + 'px'
    document.body.appendChild(container)

    let close = container.querySelector('.close')
    close.onclick = () => {
        container.classList.remove('show')
    }
}

我们先通过 document.createElement() 创建一个 div 元素,然后将其 innerHTML 赋值为提前准备好的一段 HTML 模版,并将原文和翻译后的中文放在里面,接着使用 container.classList.add()container.style 设置它的样式以及显示位置,最后通过 document.body.appendChild() 将这个 div 元素添加到当前页面中。实现之后的效果如下图所示:

chrome-extension-translate.png

至此,一个简单的划词翻译 Chrome 插件就开发好了,开发过程中参考了 CaTmmao/chrome-extension-translate 的部分实现,在此表示感谢。

当然这个扩展还有很多需要优化的地方,比如 OpenAI 的 API Keys 是写死在代码里的,可以做一个选项页对其进行配置;另外在选择文本时要等待一段时间才显示出翻译的文本,中间没有任何提示,这里的交互也可以优化一下;还可以为扩展添加右键菜单,进行一些其他操作;有兴趣的朋友可以自己继续尝试改进。

本文所有代码 在这里

参考

扫描二维码,在手机上阅读!