对话模式

我们先了解一下我们是如何与AI的,在了解原理后才能更好地理解代码

可以看到,要实现多轮对话,我们要一次性将多个消息发给LLM

多轮对话

简单实现

我们可以通过控制messages消息列表简单地实现多轮对话,我们来做个前后差异对比

不使用多轮消息列表

1
2
3
4
5
6
7
8
9
10
11
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser

model = ChatOpenAI(model="gpt-4o-mini")

parser = StrOutputParser()

chain = model | parser

print(chain.invoke("你好,我是supdriver,请记住我的名字"))
print(chain.invoke("你还记得我的名字吗"))

输出如下

1
2
你好,supdriver!很高兴认识你。有什么我可以帮助你的吗?
抱歉,我无法记住之前的对话或用户的个人信息。如果你愿意,可以告诉我你的名字。

果然LLM并不能记住之前的对话,但如果我像上面的模式图一样,把之前的对话和回复都加入消息列表会怎么样呢

使用消息列表存储所有消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain_core.messages import HumanMessage,AIMessage

model = ChatOpenAI(model="gpt-4o-mini")

parser = StrOutputParser()

chain = model | parser

messages = [
HumanMessage("你好,我是supdriver,请记住我的名字")
]
ai_msg = chain.invoke(messages)
print(ai_msg)
messages.append(AIMessage(ai_msg))
messages.append(HumanMessage("你还记得我的名字吗"))
ai_msg = chain.invoke(messages)
print(ai_msg)

输出如下

1
2
你好,supdriver!很高兴认识你。我会尽量记住你的名字。如果你有什么想聊的,随时告诉我!
当然,supdriver!你有什么想聊的或者需要帮助的呢?

可以看到,我们使用消息列表存储AI消息后,我们在后序的对话都有前面的记录。

但是这样实现就好了吗?显然不行,一个是操作太麻烦,还有一个点就是随着对话次数增加,消息列表会越来越大,造成对话响应变慢,每次对话token消耗越来越多等问题。

使用内存缓存

LangChain老版本的解决方案中,有使用RunnableWithMessageHistory消息历史类来包装另一个Runnable实例并为其管理历史消息的方法。这种方法依赖额外的数据结构实例来存储,尽管这种方式已经过时,我们也可以简单看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain_core.messages import HumanMessage,AIMessage
from langchain_core.chat_history import BaseChatMessageHistory,InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory

storage = {}
# session_id用于区分不同的对话
def get_sesstion_history(session_id:str)->BaseChatMessageHistory:
if session_id not in storage:
storage[session_id] = InMemoryChatMessageHistory();
return storage[session_id]

model = ChatOpenAI(model="gpt-4o-mini")
parser = StrOutputParser()

chain = model | parser

# 包装model
history_model = RunnableWithMessageHistory(chain,get_sesstion_history)

config = {"configurable": {"session_id": "1"}}

result = history_model.invoke(
[HumanMessage("你好我是supdriver,请记住我的名字")],
config=config
)
print(result)
result = history_model.invoke(
[HumanMessage("还记得我的名字吗")],
config=config
)
print(result)

输出如下

1
2
3
D:\program_software\AnaConda\envs\langChainP13\python.exe D:\codes\code_pycharm\langChainTool\round.py 
你好,supdriver!很高兴认识你。如果你有任何问题或需要帮助,随时告诉我!
当然记得,你是supdriver!有什么我可以帮你的吗?

管理历史消息

由上面的对论对话实现,我们也发现了重点在于管理历史消息,因此我们单开一段写管理历史消息

管理上下文窗口

针对之前提到的消息列表过大的问题,我们采用上下文窗口的方式,通过限制窗口大小,来限制对模型的输入。

上下⽂窗⼝可以理解为模型的“短
期⼯作记忆区”,即LLM在⼀次处理请求时,所能查看和处理的最⼤Token数量,它包含了:

  • ⽤⼾的输⼊
  • ⼤模型的输出
  • 有时还包括系统指令(SystemMessage)和对话历史。

所以实际上在使用上下文窗口技术后,我们有如下结论

  • 输⼊=系统消息+窗口内对话历史+最新⽤⼾问题
  • 对于模型来说,并不真正“记忆”,⽽是每次都将窗口内的上下⽂重新输⼊

这里我们使用trim_messages接口对历史消息列表削减为指定的token限制或者消息条数限制

首先我们先准备一段长对话,然后再继续做限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage,AIMessage,SystemMessage
from langchain_core.output_parsers import StrOutputParser

model = ChatOpenAI(model="gpt-4o-mini")

parser = StrOutputParser()

messages = [
SystemMessage("你的名字是曼巴,当我说man!时,你要回复我 man! ,当我说其它内容时,正常聊天"),
HumanMessage("你好,我的名字是supdriver,请记住我的名字"),
AIMessage("你好,supdriver!很高兴认识你。我会记住你的名字。有什么我可以帮助你的吗?"),
HumanMessage("man!"),
AIMessage("man!"),
HumanMessage("你觉得24这个数字怎么样,请简短的回答"),
AIMessage("24是一个很特别的数字,它在许多领域都有重要意义,比如时间、数学和文化。"),
HumanMessage("还记得我是谁吗?")
]

print(model.invoke(messages))

我们生成了一段长对话,并硬编码了到代码中,我们现在看一下它的输出是什么(有手动添加回车使其更易读)

1
2
3
4
5
6
7
8
9
content='当然,你是supdriver!有什么想聊的呢?' 

additional_kwargs={'refusal': None}

response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 143, 'total_tokens': 155, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_provider': 'openai', 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_560af6e559', 'id': 'chatcmpl-CfGDGH0eDbdFjR7HnhH1Zsk7Rs0Aq', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}

id='lc_run--c2ad89e3-5338-445c-8648-c0d291824c66-0'

usage_metadata={'input_tokens': 143, 'output_tokens': 12, 'total_tokens': 155, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}

从输出我们我们可以看到。content的内容说明LLM还认识我们,而usage_metadata中的input_tokens则表示了我们输入了143个token数,接下来我们按照一定规则限制窗口大小看看会怎么样

基于Token数限制

我们使用trimmer_message函数来创建一个裁剪消息列表的Runnable实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage,AIMessage,SystemMessage,trim_messages
from langchain_core.output_parsers import StrOutputParser

model = ChatOpenAI(model="gpt-4o-mini")

parser = StrOutputParser()

messages = [
SystemMessage("你的名字是曼巴,当我说man!时,你要回复我 man! ,当我说其它内容时,正常聊天"),
HumanMessage("你好,我的名字是supdriver,请记住我的名字"),
AIMessage("你好,supdriver!很高兴认识你。我会记住你的名字。有什么我可以帮助你的吗?"),
HumanMessage("man!"),
AIMessage("man!"),
HumanMessage("你觉得24这个数字怎么样,请简短的回答"),
AIMessage("24是一个很特别的数字,它在许多领域都有重要意义,比如时间、数学和文化。"),
HumanMessage("还记得我是谁吗?")
]

trimmer = trim_messages(
max_tokens=100,
strategy="last", # 策略:last为默认值,保留最新消息; first则是保留最早消息
token_counter=model, # 传入一个函数或者模型作为计算token的标准
include_system=True, # 是否始终保留系统消息
allow_partial=False, # 是否允许拆分消息
start_on="human", # 指定修剪后第一条消息的类型
)

chain = trimmer | model

print(chain.invoke(messages))

我们来看看输出如何

1
2
3
4
5
6
7
content='你是曼巴!有什么我可以帮助你的吗?' 
additional_kwargs={'refusal': None}
response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 86, 'total_tokens': 98, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_provider': 'openai', 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_560af6e559', 'id': 'chatcmpl-CfGToJPaoeXCQttPGen1rYJWbjlMt', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}
id='lc_run--793979c0-03fc-4fcb-bc28-cc5849639182-0'

usage_metadata={'input_tokens': 86, 'output_tokens': 12, 'total_tokens': 98, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}

usage_metadata可以看到我们的输入被限制到了100以下,从content可以看到LLM甚至认错人了,误把系统消息内对它的命名当成了用户的名字。

其实我们也可以打印看看哪些消息被裁掉了,哪些还剩下

1
print(trimmer.invoke(messages))
1
2
3
[SystemMessage(content='你的名字是曼巴,当我说man!时,你要回复我 man! ,当我说其它内容时,正常聊天', additional_kwargs={}, response_metadata={}),
HumanMessage(content='你觉得24这个数字怎么样,请简短的回答', additional_kwargs={}, response_metadata={}), AIMessage(content='24是一个很特别的数字,它在许多领域都有重要意义,比如时间、数学和文化。', additional_kwargs={}, response_metadata={}),
HumanMessage(content='还记得我是谁吗?', additional_kwargs={}, response_metadata={})]

可以看到它裁掉了好几条消息

基于消息数的限制

其实是上面的方式的一种特例,即把所谓token的计算方式改成使用len函数,这样max_tokens表示的意义就是最大消息数了,我们来演示一下

1
2
3
4
5
6
7
8
9
10
11
12
trimmer = trim_messages(
max_tokens=5,
token_counter=len,
strategy="last", # 策略:last为默认值,保留最新消息; first则是保留最早消息
include_system=True, # 是否始终保留系统消息
allow_partial=False, # 是否允许拆分消息
start_on="human", # 指定修剪后第一条消息的类型
)

chain = trimmer | model

print(trimmer.invoke(messages))
1
2
3
4
5
6
7
8
[SystemMessage(content='你的名字是曼巴,当我说man!时,你要回复我 man! ,当我说其它内容时,正常聊天', additional_kwargs={}, response_metadata={}), 

HumanMessage(content='你觉得24这个数字怎么样,请简短的回答', additional_kwargs={}, response_metadata={}),

AIMessage(content='24是一个很特别的数字,它在许多领域都有重要意义,比如时间、数学和文化。', additional_kwargs={}, response_metadata={}),

HumanMessage(content='还记得我是谁吗?', additional_kwargs={}, response_metadata={})]

可以看到裁剪完之后数量没有超过五条,并且以HumanMessage开头

消息过滤

除了限制输入消息的大小,我们还可以按照一定的规则筛选消息列表,取出它的子集,这里使用filter_messages,接口如下

1
2
3
4
5
6
7
8
9
10
11
12
@_runnable_support
def filter_messages(
messages: Iterable[MessageLikeRepresentation] | PromptValue,
*,
include_names: Sequence[str] | None = None,
exclude_names: Sequence[str] | None = None,
include_types: Sequence[str | type[BaseMessage]] | None = None,
exclude_types: Sequence[str | type[BaseMessage]] | None = None,
include_ids: Sequence[str] | None = None,
exclude_ids: Sequence[str] | None = None,
exclude_tool_calls: Sequence[str] | bool | None = None,
) -> list[BaseMessage]:

消息合并

有时候消息列表会出现同种消息类型连一起的情况(见下面示例),有些模型不支持这样的消息结构,需要合并处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage,SystemMessage,merge_message_runs
from langchain_core.output_parsers import StrOutputParser

model = ChatOpenAI(model="gpt-4o-mini")

parser = StrOutputParser()

messages = [
SystemMessage("你的名字是曼巴,当我说man!时,你要回复我 man! "),
SystemMessage("当我说其它内容时,正常聊天"),
HumanMessage("你好"),
HumanMessage("我的名字是supdriver"),
HumanMessage("请记住我的名字"),
]

merged_msg = merge_message_runs(messages)

print(merged_msg)
1
2
[SystemMessage(content='你的名字是曼巴,当我说man!时,你要回复我 man! \n当我说其它内容时,正常聊天', additional_kwargs={}, response_metadata={}), 
HumanMessage(content='你好\n我的名字是supdriver\n请记住我的名字', additional_kwargs={}, response_metadata={})]

可以看到这个函数把多个消息合并到同一个里面去了,用的是换行符连接