本文将教你如何构建一个能在本地运行的聊天代理,该代理可以访问Wikipedia以核查事实,并通过聊天记录记住过去的对话。您将学会如何使用ollama
运行LLM和langchain
定义代理,以及用于工具的自定义Python脚本。
本文的技术环境基于Python v3.11
、langchain v0.1.5
和ollama v0.1.29
。所有示例应该与较新版本的库兼容。
本文最初发布在我的专栏admantium.com,更多详情请访问。
安装Ollama 是一个适用于特定操作系统的二进制文件和命令行工具。前往ollama 安装页面,按照指示完成安装,然后使用 ollama
命令加载模型即可。
其他的库也可以用Python的pip来安装。
pip install langchain@0.1.5 langchainhub@0.1.14
使用pip安装langchain和langchainhub的特定版本
LangChain库一直在持续进化。在最近一篇文章中,我使用了新的LangChain表达语言来创建一个类似管道的提示、LLM和输出解析器的调用过程。然而,将历史记录与调用代理的功能组合在一起,在文档中并没有一个典型的示例。我惊讶地发现你需要手动处理这些低级别的抽象,定义一个记忆对象,填充响应,并手动构建一个反映聊天历史的提示信息。遗憾的是,这个示例与这种组合风格所强调的简单链式调用不一致,我决定采用面向对象的方式来创建代理。
概要整个应用程序的开发将分四个步骤进行:
- 设置ollama集成以运行LLMs
- 实现维基百科搜索工具
- 添加聊天历史
- 定义完整的代理,包括工具和历史
下面将详细说明这些步骤。
第一步:Ollama 集成(一个专有名词)可以通过ollama
暴露的API访问正在运行的LLM。为了使用它,定义一个实例,并指定ollama
正在服务的模型名称。
from langchain_community.llms import Ollama
llm = llm(model="llama2")
res = llm.调用("柏林的主要景点是什么?")
print(res)
一开始,我遇到了Python的错误“无法解析‘localhost’”。这个错误是因为urllib3
导致的,需要在/etc/hosts
文件中明确添加一行127.0.0.1 localhost
。
你可以实时查看它的日志,以检查它是否通过LangChain被调用。
tail -f ~/.ollama/logs/server.log
# {"function":"launch_slot_with_data","level":"INFO","line":833,"msg":"槽正在处理任务中","slot_id":0,"task_id":131,"tid":"0x700013a6e000","timestamp":1710681045}
#{"function":"print_timings","level":"INFO","line":287,"msg":" 总耗时为 163223.70 毫秒","slot_id":0,"t_prompt_processing":218.584,"t_token_generation":163005.116,"t_total":163223.7,"task_id":131,"tid":"0x700013a6e000","timestamp":1710681208}
# [GIN] 2024/03/17 - 14:13:28 | 200 | 2m43s | 127.0.0.1 | POST '/api/generate'
第2步:维基搜索工具
在 LangChain 中,任何被特定注解包裹的 Python 函数都可以被视为一个工具或功能,该注解定义了工具的名称、输入和输出数据类型以及其他参数。
以下示意图展示了一个工具,该工具从维基百科获取一篇文章的Wiki-文本格式,并直接返回原始文本。输入和输出均为字符串。
导入 requests 请求
从 langchain.pydantic_v1 导入 BaseModel, Field
从 langchain.tools 导入 tool
类 WikipediaArticleExporter(BaseModel):
article: str = Field(描述="维基百科的文章的规范名称")
@tool(
"wikipedia_text_exporter", args_schema=WikipediaArticleExporter, return_direct=False
)
def wikipedia_text_exporter(article: str) -> str:
'''获取维基百科文章的最新修订版的WikiText格式内容.'''
url = f"https://en.wikipedia.org/w/api.php?action=parse&page={article}&prop=wikitext&formatversion=2"
result = requests.get(url).text
# 从结果文本中,删除"wikitext"之前的内容以及关闭标记之后的内容
start = result.find('"wikitext": "\{\{')
end = result.find('\}</pre></div></div><!--esi')
返回 {"text": str(result[start + 12 : end - 30])}
结果是:
WikipediaArticleExporter("NASA")
> "‘游骑兵任务’始于20世纪50年代,是对苏联月球探测活动的回应,但结果并不成功。‘月球轨道器任务’则更为成功,它为阿波罗登月任务绘制了月球地形图,并进行了月球地形测量、流星体探测以及辐射水平测量。‘月球探测器任务’则进行了无人月球着陆和月面起飞,并对月面和月壤进行了观测。"
...
步骤三:聊天记录
LangChain 非常灵活,可以适应各种预期的应用环境,因此支持多种类型的聊天历史记录。这些类型包括从纯粹的临时、内存中的历史记录到保留最近消息原文的策略为止,最后则是完全持久化的聊天记录。
聊天记录的种类有以下几种:
ConversationBuffer
和ConversationBufferMemory
:一个内存存储,可选地限制最大聊天数量。Entities
:一个知识提取工具,用于提取关于实体的事实。需要一个大型语言模型来识别对话中提及的实体。对话知识图谱
:另一个提取工具,将识别出的事实存储在知识图谱中。同样需要一个大型语言模型来进行知识提取。ConversationSummaryMemory
和ConversationSummaryBuffer
:一个高级知识库,用于提炼对话中的信息。缓冲区版本保留最近的交互原文,仅对较早的交互进行提炼。VectorStoreRetrieverMemory
:所有交互都被存储在一个内存库中,从中提取最相关的过去消息,以确定相关的历史背景。
对于本地聊天代理来说,包含100个条目的ConversationBufferMemory
内存已经足够。
from langchain_community.llms import Ollama
from langchain.memory import ConversationBufferWindowMemory
llm = Ollama(model="llama2")
from langchain.chains import ConversationChain
chain = ConversationChain(
llm=llm,
memory=ConversationBufferWindowMemory(k=10),
verbose=True
)
让我们通过问一些首都来实际看看这段历史。
res = chain.invoke("德国的首都是什么?")
print(res)
res = chain.invoke("英国的首都是什么?")
print(res)
res = chain.invoke("我问了哪些首都城市?")
print(res)
链的类型决定了系统提示信息,这将影响LLM如何回答。对于对话类型的链,对话链的系统提示信息如下:
> 进入新的对话链...
格式化后的提示如下:
以下是一段友好的人类与AI之间的对话:AI会提供很多详细的具体信息;如果它不知道某个问题的答案,它会诚实地表示不知道。
这导致了一个非常随意的回答,但这也还表明历史数据可以被该代理处理,不过。
人: “德国的首都是哪里?”
AI: “啊,地理问题!德国的首都是柏林,我的朋友!很高兴你问我这个问题。柏林不仅是首都,也是德国最大的城市,人口大约有550万左右。柏林是一个充满活力和文化氛围浓厚的城市,以勃兰登堡门和柏林墙等著名地标而闻名。你知道吗?柏林有超过170个博物馆和画廊吗?而且让我告诉你,柏林的美食简直是太棒了!从传统的德国菜肴到现代融合菜,应有尽有。不过说了这么多,你还有什么想知道的吗?”
人: “英国的首都是哪里?”
AI: “啊,又一个问题!英国的首都是伦敦,我的朋友!伦敦是一个历史悠久且文化氛围浓厚的城市,如白金汉宫、伦敦塔和威斯敏斯特大教堂等著名地标。你知道吗?伦敦有超过300个博物馆和画廊!让我告诉你,伦敦的美食简直是太棒了!从传统的英国菜肴如炸鱼薯条到现代融合菜,应有尽有。不过说了这么多,你还有什么想知道的吗?”
人: “我问了哪些首都城市?”
AI: “你问我德国和英国的首都是哪里。”
第4步:实现完整的代理
有了这些了解,我们可以构建一个拥有工具和聊天记录的智能代理。
LangChain 提供了多种代理类型。对于本地使用来说,Self Ask With Search
、ReAct
和 Structured Chat
这些代理比较合适。ReAct
类型允许定义多个工具,每个工具接受单个输入,而 Structured Chat
则支持多输入工具。
让我们一步一步地构建代理定义,先从核心部分开始,然后添加工具和对话记录。
在以下代码片段中,通过构造方法创建了一个智能体,然后将其传递给一个暴露了 invoke
方法的 AgentExecutor
对象。
从langchain引入hub
从langchain.agents引入AgentExecutor, create_react_agent
从langchain_community.llms引入Ollama
llm = Ollama(模型='llama2')
prompt = hub.拉取("hwchase17/react-chat")
agent = 创建react代理(llm, [], prompt)
agent_executor = AgentExecutor(agent=agent, 工具=[], 详细模式=True, 处理解析错误=True)
这个定义可以直接使用。
agent_executor.invoke(
{"input": "德国的首都是哪里?不要使用工具。", "chat_history": []}
)
> "启动新的AgentExecutor链...
心想:是不是应该用工具呢?不用了
最后的答案是:柏林是德国的首都。"
> "完成了。"
这个定义是有效的,如你所见,在创建智能代理时已经预期会添加工具和历史记录。历史记录需要一些工程上的调整,因为需要通过一个手动定义的函数来添加消息和响应。如下所示:
def append_chat_history(input, response):
chat_history.save_context({"input": input}, {"output": response})
def invoke(input):
msg = { "input": input, "chat_history": chat_history.load_memory_variables({})}
response = agent_executor.invoke(msg)
append_chat_history(response["input"], response["output"])
这样一来,这个代理就能充分发挥它的能力了。
工具 = [wikipedia_text_exporter] # 导出维基百科文本的工具
agent = create_react_agent(llm, 工具, prompt) # 创建一个基于llm的agent,并传入工具和提示
# 创建一个AgentExecutor,设置agent,工具,打开详细模式,并处理解析错误
agent_executor = AgentExecutor(agent=agent, tools=工具, verbose=True, handle_parsing_errors=True)
让我们依次问三个问题来启动这个代理。
invoke("德国的首都是什么?不要使用工具。")
invoke("我问了哪些首都?不要使用工具。")
invoke("2024年在你刚才提到的那个镇上发生了什么?不要使用工具。")
第一个结果令人吃惊:大型语言模型(LLM)认为它应该为每个问题都使用维基百科。
> 进入新的AgentExecutor链...
想法:我需要使用工具吗?是的
行为:wikipedia_text_exporter(article: str) -> str
输入:'germany'
wikipedia_text_exporter(article: str) -> str不是一个有效的工具,应该是 'germany'
想法:我需要使用工具吗?是的
行为:wikipedia_text_exporter(article: str) -> str
输入:'德国'
在另一次测试中,完全没有用到维基百科工具。
> 进入新的AgentExecutor链...
输入:{'input': '德国的首都是哪里?不要使用工具。', 'chat_history': {}}
思考:我需要使用工具吗?不需要
最终答案:德国的首都是柏林。
输入:{'input': '我问了哪些首都城市?不要使用工具。', 'chat_history': {'history': 'Human: 德国的首都是哪里?不要使用工具。\nAI: 德国的首都是柏林。'}}
思考:我需要使用工具吗?不需要
最终答案:你问了德国的首都城市。
输入:{'input': '告诉我你刚刚提到的城镇在2024年发生了什么事件。', 'chat_history': {'history': 'Human: 德国的首都是哪里?不要使用工具。\nAI: 德国的首都是柏林。\nHuman: 我问了哪些首都城市?不要使用工具。\nAI: 你问了德国的首都城市。'}}
思考:我需要使用工具吗?不需要
行动:无(格式错误:缺少 'Action Input:' 在 'Action:' 后)
最终答案:2024年在柏林发生的一件事件是柏林音乐周,该活动于3月1日至3月5日举行,吸引了当地和国际音乐家的表演和工作坊。
然而,提到的音乐节活动其实是不存在的!经过进一步的研究,发现调用功能是大模型需要专门训练的一项技能。当使用openhermes
模型而不是llama2
时,代理明确且一致地认识到它明确知道需要用维基百科工具来回答最后一个问题,例如,它给出了这样的回答:
这里是从文章中提取的答案:
柏林是德国的首都,也是德国最大的城市,位于德国东北部。柏林的人口约为360万,这是在市界内的数字。柏林是德国16个州之一。这座城市建立于13世纪,拥有丰富的历史,曾受到各种文化的影响,包括土耳其和苏联文化的影响。
柏林划分为12个区,每个区都有自己的政府和职责。柏林的经济多元化,涵盖了媒体、科学、生物技术、化学、计算机游戏、音乐、电影、电视制作和视觉艺术等多个领域。柏林还举办各种大型活动,如柏林国际电影节等,也是许多世界知名大学和研究机构的所在地。
柏林的文化场景非常活跃,拥有众多博物馆、画廊、交响乐团、剧院和音乐会场地。该市还以其夜生活和娱乐选择而闻名。柏林的著名地标包括勃兰登堡门、联邦议院大厦和柏林墙。
柏林的官方语言是德语,但许多居民都会说多种语言,英语、法语、意大利语、西班牙语和土耳其语是其中一些最常见的第二语言。柏林所在的时间区是中欧时间(CET),即UTC+01:00。
柏林的气候属于温带海洋性气候类型,夏季温暖,冬季寒冷,年平均气温在8到9摄氏度左右。全年降水平均分布。
顺便说一下:如果你添加一些print
语句,关于聊天的有趣和完整的元数据就会被显示出来。
[PromptTemplate(
input_variables=['agent_scratchpad', 'chat_history', 'input'],
partial_variables={'tools': 'wikipedia_text_exporter: wikipedia_text_exporter(article: str) -> str - 获取维基百科文章的最新修订版的WikiText格式内容。', 'tool_names': 'wikipedia_text_exporter'},
metadata={'lc_hub_owner': 'hwchase17', 'lc_hub_repo': 'react-chat', 'lc_hub_commit_hash': '3ecd5f710db438a9cf3773c57d6ac8951eefd2cd9a9b2a0026a65a0893b86a6e'},
template='助手是由OpenAI训练的一个大型语言模型。\n\n助手被设计成能够帮助处理各种任务,从回答简单问题到提供深入解释和讨论各种主题。作为语言模型,助手不断学习和进步,其能力也在不断进化。它能够处理和理解大量的文本,并利用这些知识提供准确和有信息量的响应来回答各种问题。此外,助手能够根据接收到的输入生成自己的文本,使其能够参与讨论并提供各种主题的解释和描述。\n\n总的来说,助手是一个强大的工具,可以帮助处理各种任务,并提供各种主题的宝贵见解和信息。无论您需要帮助回答特定问题,还是只想就某个话题进行对话,助手都愿意提供帮助。\n\n工具:\n------\n\n助手可以使用以下工具:\n\n{tools}\n\n要使用一个工具,请使用以下格式:\n\n```
\n思考:我是否需要使用工具?是\n行动:要采取的动作,应该是 [{tool_names}]其中之一\n行动输入:动作的输入\n观察:动作的结果\n
```\n\n当您有回复要说给用户,或者您不需要使用工具时,一定要使用以下格式:\n\n```
\n思考:我是否需要使用工具?不需要\n最终答案:[您的回复]\n
```\n\n开始吧!\n\n先前对话历史:\n{chat_history}\n\n新输入:{input}\n{agent_scratchpad}')]
全部代码
这里展示的是本地聊天助手的完整代码。
import requests
from langchain import hub
from langchain.agents import AgentExecutor, create_react_agent
from langchain_community.llms import Ollama
from langchain.memory import ConversationBufferWindowMemory
from langchain.pydantic_v1 import BaseModel, Field
from langchain.tools import tool
class WikipediaArticleExporter(BaseModel):
article: str = Field(description="维基百科文章的标准名称")
@tool("wikipedia_text_exporter", args_schema=WikipediaArticleExporter, return_direct=False)
def wikipedia_text_exporter(article: str) -> str:
'''获取维基百科文章的最新修订版本,格式为WikiText.'''
url = f"https://en.wikipedia.org/w/api.php?action=parse&page={article}&prop=wikitext&formatversion=2"
result = requests.get(url).text
start = result.find('"wikitext": "\{\{')
end = result.find('\}</pre></div></div><!--esi')
result = result[start+12:end-30]
return ({"text": result})
def append_chat_history(input, response):
chat_history.save_context({"input": input}, {"output": response})
def invoke(input):
msg = {
"input": input,
"chat_history": chat_history.load_memory_variables({}),
}
print(f"输入: {msg}")
response = agent_executor.invoke(msg)
print(f"响应: {response}")
append_chat_history(response["input"], response["output"])
print(f"历史记录: {chat_history.load_memory_variables({})}")
tools = [wikipedia_text_exporter]
prompt = hub.pull("hwchase17/react-chat")
chat_history = ConversationBufferWindowMemory(k=10)
llm = Ollama(model="llama2")
agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True, handle_parsing_errors=True)
invoke("请问德国的首都是哪里?请不要使用工具。")
最后,结论。
这个多功能的库LangChain使构建LLM应用程序变得轻松,提供了LLM提供商、工具、聊天历史和代理的抽象概念。在本文中,你学习了如何通过以下步骤构建一个自定义的本地聊天代理:a) 使用ollama
本地LLM,b) 添加Wikipedia搜索工具,c) 添加带缓冲的聊天历史记录,以及d) 将所有方面结合在一个ReAct代理中。本质上,一个强大的代理可以用几行代码实现,打开了新的应用领域。你会给你的代理赋予什么样的能力?