文章目录
前言
本文主要是继续深挖Tongyi
类,并进一步探究详细的流程。个人理解不够全面,能够为大家给出的解释有限。
导入Tongyi类
Tongyi
类是langchain_community.llms
中的一个类。实际上,这个类是在langchain_community.llms
文件夹下tongyi.py
中的一个类,只不过因为langchain_community.llms
文件夹下的__init__.py
文件追加了一个方法:
python
def _import_tongyi() -> Type[BaseLLM]:
from langchain_community.llms.tongyi import Tongyi
return Tongyi
最终,在一个包含了无数个if-else
的__getattr__
方法中,会根据传入name
的值判断执到底执行哪一个大模型的import
方法。
这个意思就是说,我们假设现在新开发了一个大模型,叫做Ninedays
(就当这个叫做九天吧),并存入ninedays.py
。我们想要导入这个Ninedays
大模型,也就可以通过from langchain_community.llms import Ninedays
导入。
导入的过程将首先经过__init__.py
方法中的__getattr__
方法,用于访问没有直接定义出来的数据。此时,在__getattr__
方法中增加:
python
if name == "Ninedays":
from langchain_community.llms.ninedays import Ninedays
这个意思就是,我经过__getattr__
访问到了Ninedayes
这个name
,并且通过大量的if-else
查询到了这个执行条件,于是开始导入大模型。
这种方法是一种懒加载的实现方法,非常方便。
配置Tongyi类
在langchain_community.llms
文件夹下的tongyi.py
文件中,里面有这么几个属性:
python
client: Any #: :meta private:
model_name: str = "qwen-plus"
"""Model name to use."""
model_kwargs: Dict[str, Any] = Field(default_factory=dict)
top_p: float = 0.8
"""Total probability mass of tokens to consider at each step."""
dashscope_api_key: Optional[str] = None
"""Dashscope api key provide by Alibaba Cloud."""
streaming: bool = False
"""Whether to stream the results or not."""
max_retries: int = 10
"""Maximum number of retries to make when generating."""
其中:
client
并不确定是什么,没有相关定义,但是会按照llm.client.call
执行,其中llm
是Tongyi
类的实例。model_name
是qwen-plus
,表示默认的模型名称。model_kwargs
是空字典,表示默认的模型参数。top_p
是0.8,是model_kwargs
中的top_p
参数。dashscope_api_key
是通义千问的api-key
。streaming
表示最终的输出是否是流式输出。max_retries
是10,表示最多允许的重试次数。
我们初始化的过程中,往往也是直接自定义这些参数:
python
llm = Tongyi(
dashscope_api_key="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
)
读取api-key
读取的方式有很多种,包括yaml
配置、xml
配置、txt
配置、properties
配置乃至数据库配置等等,Python
也为每一种配置都有特定的工具库,非常方便。下面仅介绍3种推荐配置方法。
os配置
在Tongyi
初始化的过程中,会执行一个validate_environment
方法,将检查环境是否满足要求:
python
@root_validator()
def validate_environment(cls, values: Dict) -> Dict:
"""Validate that api key and python package exists in environment."""
values["dashscope_api_key"] = get_from_dict_or_env(
values, "dashscope_api_key", "DASHSCOPE_API_KEY"
)
try:
import dashscope
except ImportError:
raise ImportError(
"Could not import dashscope python package. "
"Please install it with `pip install dashscope`."
)
try:
values["client"] = dashscope.Generation
except AttributeError:
raise ValueError(
"`dashscope` has no `Generation` attribute, this is likely "
"due to an old version of the dashscope package. Try upgrading it "
"with `pip install --upgrade dashscope`."
)
return values
首先一上来就是执行get_from_dict_or_env
,这个方法将会在values
中获取dashscope_api_key
,如果获取不到,则从os.environ
中获取DASHSCOPE_API_KEY
。
既然知道这个,那就好办了:
python
import os
os.environ["DASHSCOPE_API_KEY"] = "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
这样Tongyi
就知道你的api-key
了。
env配置
当然,方法是有很多的。除了单纯的使用os
显性地配置以外,官方提出,我们最好不要在代码中明码显示我们的api-key
,这将会带来一定的风险。
当然,官方也推荐了一种方法:使用.env
文件存储api-key
。
比较取巧的是,.env
文件可以通过load_dotenv
加载:
python
from dotenv import load_dotenv
load_dotenv()
同样也非常方便。
P.S. :我不太确定是在哪里出了问题,现在
.env
配置失效了,只能使用os.environ
的方式配置。
streamlit配置
我们在OpenAI
的教程中能够更多地看到streamlit
的存在。主要是因为,streamlit
能够非常方便地给出一个demo
界面,从而为用户更快地测试与迭代。
当然,streamlit
也有独特的部分,比如streamlit
能够自动的将api-key
存储在secrets.toml
文件中,从而实现api-key
的隐藏。
secrets.toml
文件样例为:
toml
DASHSCOPE_API_KEY = "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
P.S. :需要注意的是,
toml
文件必须要为字符串增加双引号,否则将会抛出编码错误。
为了读取api-key
,streamlit
提供了secrets
成员变量,其最初定义是这样的:
python
class Secrets(Mapping[str, Any]):
"""A dict-like class that stores secrets.
Parses secrets.toml on-demand. Cannot be externally mutated.
Safe to use from multiple threads.
"""
def __init__(self, file_paths: list[str]):
# Our secrets dict.
self._secrets: Mapping[str, Any] | None = None
self._lock = threading.RLock()
self._file_watchers_installed = False
self._file_paths = file_paths
self.file_change_listener = Signal(
doc="Emitted when a `secrets.toml` file has been changed."
)
可以看到,secrets
表现类似Java
中的ConcurrentHashMap
一般,既有字典的一部分,又有线程安全的一部分。
而在定义secrets
的过程中,通过上锁的方式,在streamlit
的生命周期中实现了单例模式:
python
# 根据配置文件`secrets.toml`获取锁
SECRETS_FILE_LOCS: Final[list[str]] = [
file_util.get_streamlit_file_path("secrets.toml"),
# NOTE: The order here is important! Project-level secrets should overwrite global
# secrets.
file_util.get_project_streamlit_file_path("secrets.toml"),
]
# 创建单例
secrets_singleton: Final = Secrets(SECRETS_FILE_LOCS)
# streamlit的成员对象
secrets = _secrets_singleton
这也就意味着,无论我们使用多少个Agent
协同进行,都将读取同一个api-key
。
最终在使用的时候,也就能够直接这么做:
python
import streamlit as st
llm = Tongyi(
dashscope_api_key = st.secrets["DASHSCOPE_API_KEY"]
)
同样也是非常的方便。
PromptTemplate
当我们构造了Tongyi
实例之后,我们就需要完善一个prompt
模板了,在实际使用中,就会用到PromptTempate
。这是个啥呢?是langchain_core.prompts
中的一个类。在他的构造函数中,他接受这么几个参数:
template
-> 模板,也就是我们规定其中固定的部分,然后留下灵活填入的部分,叫做模板。例如:Hello, {name}
,这串字符串就是模板,需要灵活填入的就是这个name
;- input_variables -> 输入变量,输入变量是一个列表,无论输入一个两个都是一个列表,按照模板中需要填入的变量顺序进行匹配
- partial_variables -> 预置变量,输入变量是一个
dict
字典,键是变量名,值是变量值。这个参数主要是为了将变量值直接预先填入prompt
模板中,而不像input_variables
需要渲染。 - template_format -> 模板格式,默认是
f-string
,但是也可以是jinja2
,还可以是mustache
。 - validate_template -> 是否验证模板,默认是
True
P.S. :关于
partial_variables
,这个参数是langchain
提供的一个功能,可以预置变量值,而不用每次都渲染。举个简单的例子:
pythontemplate_str = """ Hello, {name}. Welcome to {place}. Today's weather is {weather}. """
其中,假设
place = "Wonderland"
。如果place
是partial_variables
,那么langchain
会自动将place
的值填入模板中。在PromptTemplate
渲染的过程中,我们渲染的并不是 字符串"Hello, {name}. Welcome to {place}. Today's weather is {weather}."
,而是字符串:
pythontemplate_str = """ Hello, {name}. Welcome to Wonderland. Today's weather is {weather}. """
P.P.S. :关于模板,需要说明的是,
langchain
默认模板为f-string
模板,并强烈建议不要使用非受信的jinja2
模板。这主要是因为jinja2
的功能过于强大,它可以在prompt
中执行Python
代码。举个比较简单的例子:
pythontemplate_str = """ Hello, {{ name }} {% if 2 + 2 == 4 %} 2 + 2 is indeed 4 {% endif %} {% set dangerous_code = '__import__("os").system("ls")' %} {{ eval(dangerous_code) }} """
看到了吗,
jinja2
模板能够直接执行Python
代码,从而直接执行了linux
的ls
命令。如果说写jinja2
模板的人将更具备攻击性一点,则可以将服务器中的数据直接全部传出去,或者植入挖矿程序等。
每次使用的时候都这么构造的话,会不会太麻烦了?没关系,官方已经给你想好了。默认情况下,PromptTemplate
将使用f-string
模板,所以可以直接这么写:
python
prompt = PromptTemplate.from_template("Hello, {name}")
P.S.: :直接用
PromptTemplate
构造函数与PromptTemplate.from_template
构造基本没有区别,from_template
只是为了方便使用者仅需要考虑输入f-string
模板的prompt
而设计的。
当然,你可能会疑惑,这样子设置,他怎么知道自己的input_variables
呢?答案就在源码中的get_template_variables
方法,它能够将传递的prompt
模板中的变量解析出来,并返回一个经过排序的列表。
最终,PromptTemplate
无论是使用from_template
还是直接使用构造函数,最终都会返回一个PromptTemplate
对象。
LLMChain
LLMChain
的构造函数主要接受两个参数,一个是LLM
实例,一个是prompt
模板。就像这样:
python
llm_chain = LLMChain(llm=llm, prompt=prompt)
本质上,LLMChain
在获得LLM
实例与prompt
模板后,会通过LLMChain
的_call
方法执行:
python
def _call(
self,
inputs: Dict[str, Any],
run_manager: Optional[CallbackManagerForChainRun] = None,
) -> Dict[str, str]:
response = self.generate([inputs], run_manager=run_manager)
return self.create_outputs(response)[0]
这里主要调用了两个部分,分别是generate
跟create_outputs
两个。
generate
主要是通过调用prep_prompt
方法构造提示,然后根据llm
实例的类型生成具体的LLMResult
类。
最终,也就由LLMChain
给出结果。
更换掉LLMChain(可选)
比较尴尬的是,LLMChain
有一个注解(或者说你比较喜欢叫他装饰器也行):
python
@deprecated(
since="0.1.17",
alternative="RunnableSequence, e.g., `prompt | llm`",
removal="0.3.0",
)
从 0.1.17 0.1.17 0.1.17开始支持,从 0.3.0 0.3.0 0.3.0开始取消支持。如果有需要的话,可以提前修改为TransformChain
。
修改起来也还算比较简单。原来我们使用:
python
llm_chain = LLMChain(llm=llm, prompt=prompt)
需要修改的也就只有这里,PromptTemplate
没变,StuffDocumentsChain
也没变,变的只有构造chain
的方法:
python
llm_chain = TransformChain(
llm=llm,
input_prompt=prompt,
output_key="text",
transform = lambda x: {"text": x}
)
当然,上面这是一个偷懒的方法。实际上,我们需要额外定义一个func
方法,然后在构造函数中定义transform = func
。
其中:
llm
没有变化,还是以前的llm
对象;input_prompt
还是以前的PromptTemplate
对象,只是名字改了;output_key
是llm
的输出字段名,这个是新东西,最终标记输出内容的键;transform
,原名transform_cb
,transform
只是transform_cb
的一个别名,为该参数赋值的时候是都可以使用的。transform
是一个callback
函数,将prompt
的输出包装为字典,键需要与output_key
一致,内容可以原样输出,也可以稍做处理;
当然,考虑到目前最新的版本只有 0.2.1 0.2.1 0.2.1,如果并没有考虑升级事项,LLMChain
是完全可以直接使用的。但如果考虑到长期维护,可能就需要更换为TransformChain
。
StuffDocumentsChain
StuffDocumentsChain
的构造函数主要接受两个参数,一个是LLMChain
实例,一个是document_prompt
模板中代表植入文本的变量:
python
stf_chain = StuffDocumentsChain(
llm_chain = LLMChain(
llm=Tongyi(),
prompt=prompt,
verbose=True,
memory=memory
),
document_variable_name="text"
)
与PromptTemplate
类似,通过document_variable_name
将文件内容添加到prompt
模板中。
这里面需要注意的是,document_variable_name
需要与prompt
中指定的植入文本的内容一致。
比如,在最开始的时候,我们定义的prompt
模板是:
python
prompt = """"
{human_input} {chat_history} {text}
"""
那么我们就需要将document_variable_name
设置为text
。
当然,你可能在某些教程上看到是这样的:
python
stf_chain = StuffDocumentsChain(
llm_chain = LLMChain(
llm=Tongyi(),
prompt=prompt,
verbose=True,
memory=memory,
output_key="text"
),
document_variable_name="text"
)
这个时候,出现了 2 2 2个text
!是不是意味着这两个text
必须要对应起来呢?
还记得上文提到的TransformChain
吗?一样的,output_key
实际上仅代表输出时候的键值,而document_variable_name
代表prompt
模板中需要植入文本的变量,前者是输出的变量,后者是输入的变量,这两者在意义上是完全不一致的。
使用StuffDocumentsChain
包装了prompt
后,就需要交付大模型并生成解答了。
目前,使用的方法是run
方法:
python
response = stf_chain.run(
human_input=message,
chat_history="",
input_documents=load_documents("http://120.26.106.143:5000/disease")
)
在这里,必要的字段包括三个部分,分别是hunman_input
、chat_history
和input_documents
。这三个部分都比较好理解,分别是用户最新一次输入、聊天记录以及输入文件内容。
其中,human_input
与input_documents
就是原样输入,不需要额外处理。
而chat_history
需要一段数组,说明以往的交互历史。如果明码传输的话,需要这么做:
python
chat_history = [
{"role": "user", "content": "我想了解一下糖尿病的症状。"},
{"role": "assistant", "content": "糖尿病的主要症状包括频尿、口渴、饥饿感增加及体重下降等。"},
{"role": "user", "content": "那它的治疗方法呢?"},
{"role": "assistant", "content": "治疗方法通常涉及饮食控制、规律运动、监测血糖以及可能需要的药物治疗。"}
]
如果你使用的是langchain
的ConversationBufferMemory
,并且利用streamlit
的session_state
作为全局变量存储,那么可以直接利用起来:
python
# 初始化会话状态
if "messages" not in st.session_state:
st.session_state.messages = [{
"role": "assistant",
"content": "你好,我是医学千问机器人GraphAcademy。有什么需要帮忙的吗?"
}]
# 获取交互历史 - 针对多线程场景的懒加载提供`if`容错
chat_history = st.session_state.messages if st.session_state.messages else ""
ConversationBufferMemory
ConversationBufferMemory
是langchain
提供的一个会话记忆类,用于存储会话历史。ConversationBufferMemory
继承自BaseChatMemory
类,而BaseChatMemory
中有一个chat_memory
变量,其类型通过Field
方法的default_factory
参数指定了默认类型InMemoryChatMessageHistory
。看来找到到答案了。继续追溯,发现InMemoryChatMessageHistory
拥有成员变量messages
,同样通过Field
方法的default_factory
参数指定了默认类型为list
。
到头来,ConversationBufferMemory
能够存储历史对话信息,究其根源就是因为它本身就是一个列表。也正是上文提到的,使用列表逐行存储历史信息。而追加存储信息也是触发了InMemoryChatMessageHistory
类中的add_message
方法。
那么,这个数组里面能够存储什么东西呢?按照ConversationBufferMemory
中get_buffer_string
方法的定义,我们可以看到这么一些内容:
python
def get_buffer_string(
messages: Sequence[BaseMessage], human_prefix: str = "Human", ai_prefix: str = "AI"
) -> str:
"""Convert a sequence of Messages to strings and concatenate them into one string.
Args:
messages: Messages to be converted to strings.
human_prefix: The prefix to prepend to contents of HumanMessages.
ai_prefix: THe prefix to prepend to contents of AIMessages.
Returns:
A single string concatenation of all input messages.
Example:
.. code-block:: python
from langchain_core import AIMessage, HumanMessage
messages = [
HumanMessage(content="Hi, how are you?"),
AIMessage(content="Good, how are you?"),
]
get_buffer_string(messages)
# -> "Human: Hi, how are you?\nAI: Good, how are you?"
"""
string_messages = []
for m in messages:
if isinstance(m, HumanMessage):
role = human_prefix
elif isinstance(m, AIMessage):
role = ai_prefix
elif isinstance(m, SystemMessage):
role = "System"
elif isinstance(m, FunctionMessage):
role = "Function"
elif isinstance(m, ToolMessage):
role = "Tool"
elif isinstance(m, ChatMessage):
role = m.role
else:
raise ValueError(f"Got unsupported message type: {m}")
message = f"{role}: {m.content}"
if isinstance(m, AIMessage) and "function_call" in m.additional_kwargs:
message += f"{m.additional_kwargs['function_call']}"
string_messages.append(message)
return "\n".join(string_messages)
意思就是,如果ConversationBufferMemory
如果在某一时刻需要将存储的内容输出出来,就会把存储的HumanMessage
、AIMessage
、SystemMessage
、FunctionMessage
、ToolMessage
以及ChatMessage
中的角色提取出来,然后拼接在一起,而其他类型就会报错说类型不受支持。
所以,ConversationBufferMemory
也就只能够存储HumanMessage
、AIMessage
、SystemMessage
、FunctionMessage
、ToolMessage
以及ChatMessage
在内的一条或者多条信息。这些也都是langchain
为我们提供的消息接口,用于封装各种各样的消息。
这些消息最终也将通过memory
参数传入LLMChain
中,在后续植入prompt
知识库的时候,也会进一步通过chat_history
输入StuffDocumentsChain
中。
其实,比较神奇的是,虽然比较推荐传入ConversationBufferMemory
,其实无论是LLMChain
的memory
还是StuffDocumentsChain
的chat_history
,直接传入数组也都是一点问题没有的。因为本质上都是数组,只需要保证里面的内容是使用langchain
所提供的各种Message
对象即可。