先问大家一个问题,在很多应用程序中,不管是开发或者使用,其实我们都是用大模型在做响应,但是如果出现了一些场景,需要和其他API平台或者数据库交互,该怎么办呢?
这里我们就可以用到工具,使用工具调用来请求和特定架构下匹配的模型的响应。
大家看一张图:
这张图就充分展示了大模型与工具之间的数据字段是怎么样交互的,当然这只是抽象出来的概念字段,实际参数远远不止这些。
工具的执行流程
在 LangGraph
中,工具怎么来创建和执行呢,下面就是工具执行的步骤:
1)工具创建:使用 @tool 装饰器创建工具。工具是函数与其模式之间的关联。
(2)工具绑定:工具需要连接到支持工具调用的模型。这使模型能够了解该工具以及该工具所需的关联输入模式。
(3) 工具调用:在适当的时候,模型可以决定调用工具并确保其响应符合工具的输入模式。
(4) 工具执行:可以使用模型提供的参数来执行工具。
有一张详细的图大家看一下:
具体的代码如下:
# Tool creation
tools = [my_tool]
# Tool binding
model_with_tools = model.bind_tools(tools)
# Tool calling
response = model_with_tools.invoke(user_input)
上面就是使用工具调用的大致工作流程。创建的工具作为列表传递给 .bind_tools()
方法。这个模型可以像往常一样调用。如果进行工具调用,模型的响应将包含工具调用参数。工具调用参数可以直接传递给工具。
下面我们拆分开来分别探索。
工具创建
创建工具的推荐方法是使用 @tool
装饰器。
from langchain_core.tools import tool
@tool
def multiply(a: int, b: int) -> int:
"""Multiply a and b."""
return a * b
使用方式就是:
multiply.invoke({"a": 2, "b": 3})
print(multiply.name) # multiply
print(multiply.description) # Multiply two numbers.
print(multiply.args)
下面是打印出的结果:
# {
# 'type': 'object',
# 'properties': {'a': {'type': 'integer'}, 'b': {'type': 'integer'}},
# 'required': ['a', 'b']
# }
这里有个细节,如果我们使用预构建的 LangChain
或LangGraph
组件(例如 create_react_agent
),可能不需要直接与工具交互,也就是省掉这一步。但是,在构建自定义 LangGraph
工作流程时,我们必需要直接使用自定义工具。
如果我们希望我们的工具能够区分消息内容和其他工件,我们需要在定义我们的工具时指定 response_format="content_and_artifact"
并确保我们返回一个(内容和工件)的元组,代码如下:
import random
from typing import List, Tuple
from langchain_core.tools import tool
@tool(response_format="content_and_artifact")
def generate_random_ints(min: int, max: int, size: int) -> Tuple[str, List[int]]:
"""Generate size random ints in the range [min, max]."""
array = [random.randint(min, max) for _ in range(size)]
content = f"Successfully generated array of {size} random ints in [{min}, {max}]."
return content, array
结合模型一起使用:
import getpass
import os
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini")
llm_with_tools = llm.bind_tools([generate_random_ints])
ai_msg = llm_with_tools.invoke("generate 6 positive ints less than 25")
print(ai_msg.tool_calls)
得到下面内容:
[{'name': 'generate_random_ints',
'args': {'min': 1, 'max': 24, 'size': 6},
'id': 'toolu_01EtALY3Wz1DVYhv1TLvZGvE',
'type': 'tool_call'}]
然后开始调用工具:
generate_random_ints.invoke(ai_msg.tool_calls[0])
得到:
ToolMessage(content='Successfully generated array of 6 random ints in [1, 24].', name='generate_random_ints', tool_call_id='toolu_01EtALY3Wz1DVYhv1TLvZGvE', artifact=[2, 20, 23, 8, 1, 15])
如如果我们只传入工具调用参数,我们只会返回内容:
generate_random_ints.invoke(ai_msg.tool_calls[0]["args"])
# 'Successfully generated array of 6 random ints in [1, 24].'
有时候在某些情况下,某些参数需要在运行时传递给工具,但不应由模型本身生成。为此,我们使用 InjectedToolArg 注释,它允许从工具的架构中隐藏某些参数。假如一个工具需要在运行时动态注入user_id,则可以按以下方式构造它::
from langchain_core.tools import tool, InjectedToolArg
@tool
def user_specific_tool(input_data: str, user_id: InjectedToolArg) -> str:
"""Tool that processes input data."""
return f"User {user_id} processed {input_data}"
user_specific_tool.invoke({"input_data": "hello!", "user_id": user_id})
如果我们需要从工具内访问 RunnableConfig 对象。可以通过在工具的函数签名中使用 RunnableConfig 注释来完成添加 :
from langchain_core.runnables import RunnableConfig
@tool
async def some_func(..., config: RunnableConfig) -> ...:
"""Tool that does something."""
# todo something with config
...
await some_func.ainvoke(..., config={"configurable": {"value": "some_value"}})
工具绑定和调用
上面代码块可能大家已经看到了,下面这种方式就是工具绑定:
llm_with_tools = llm.bind_tools([generate_random_ints])
下面我们来看工具调用图:
工具调用的一个关键原则是模型根据输入的相关性决定何时使用工具。模型并不总是需要调用工具。例如,给定不相关的输入,模型将不会调用该工具。
比如:
result = llm_with_tools.invoke("Hello world!")
结果将是一个 AIMessage,其中包含模型的自然语言对你的响应。但是,如果我们传递与该工具相关的输入,模型应该选择调用它:
result = llm_with_tools.invoke("What is 2 multiplied by 3?")
和以前一样,输出 result 将是 AIMessage 。但是,如果调用该工具,result 将具有 tool_calls 属性。此属性包括执行该工具所需的所有内容,包括工具名称和输入参数:
result.tool_calls
{'name': 'multiply', 'args': {'a': 2, 'b': 3}, 'id': 'xxx', 'type': 'tool_call'}
最后工具的执行,因为工具实现了 Runnable 接口,这意味着可以直接调用它们(例如 tool.invoke(args) )。LangGraph 提供预构建的组件(例如 ToolNode ),这些组件通常会代表用户调用该工具。
简单的例子
前面讲了关于工具的基本的一些用法,现在我们结合开头的问题,给大家带来了实际例子。 在实际应用中,这些工具调用通常会导致函数的回调或保存某些信息。比如执行 SQL 的工具调用,然后由该工具运行,或者调用工具来生成摘要,然后将其保存到图形的状态中。大致的交互流程还是这几种:
- 批准工具调用并继续
- 手动修改工具调用然后继续
- 提供自然语言反馈,然后将其传递回代理
我们可以使用 interrupt() 函数在 LangGraph 中实现这些。 interrupt 允许我们停止图形执行以收集用户的输入并使用收集的输入继续执行:
def human_review_node(state) -> Command[Literal["call_llm", "run_tool"]]:
human_review = interrupt(
{
"question": "Is this correct?",
# Surface tool calls for review
"tool_call": tool_call
}
)
review_action, review_data = human_review
# 批准工具继续
if review_action == "continue":
return Command(goto="run_tool")
# 手动修改工具调用,然后继续
elif review_action == "update":
...
updated_msg = get_updated_msg(review_data)
return Command(goto="run_tool", update={"messages": [updated_message]})
# 提供自然语言的响应,然后将其传回给agent
elif review_action == "feedback":
...
feedback_msg = get_feedback_msg(review_data)
return Command(goto="call_llm", update={"messages": [feedback_msg]})
下面让我们设置一个非常简单的图表来实现这一点。首先,我们通过会议决定采取什么行动。然后我们进入一个人类节点。该节点实际上不执行任何操作 ,我们的想法是我们在此节点之前中断,然后对状态应用任何更新。之后,我们检查状态并路由回到 LLM 或正确的工具。
from typing_extensions import TypedDict, Literal
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command, interrupt
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import AIMessage
from IPython.display import Image, display
from dotenv import load_dotenv
load_dotenv()
@tool
def weather_search(city: str):
"""Search for the weather"""
print("----")
print(f"Searching for: {city}")
print("----")
return "Sunny!"
model = ChatOpenAI(model="gpt-4o-mini").bind_tools(
[weather_search]
)
class State(MessagesState):
"""Simple state."""
def call_llm(state):
return {"messages": [model.invoke(state["messages"])]}
def human_review_node(state) -> Command[Literal["call_llm", "run_tool"]]:
last_message = state["messages"][-1]
tool_call = last_message.tool_calls[-1]
# 这是我们将通过 Command(resume=<human_review>) 传入的值
human_review = interrupt(
{
"question": "Is this correct?",
# 审查工具
"tool_call": tool_call,
}
)
review_action = human_review["action"]
review_data = human_review.get("data")
# 如果批准 回调 tool
if review_action == "continue":
return Command(goto="run_tool")
# 更新 AI message 和回调 tools
elif review_action == "update":
updated_message = {
"role": "ai",
"content": last_message.content,
"tool_calls": [
{
"id": tool_call["id"],
"name": tool_call["name"],
# 这是人类提供的更新
"args": review_data,
}
],
# 这里比较重要,这需要与我们替换的消息id相同!否则,它将显示为单独的消息e
"id": last_message.id,
}
return Command(goto="run_tool", update={"messages": [updated_message]})
# 向 LLM 提供反馈
elif review_action == "feedback":
# 我们将反馈消息添加为 ToolMessage 以保留消息历史记录中的正确顺序(带有工具调用的 AI 消息后面需要跟工具调用消息)
tool_message = {
"role": "tool",
# 这是我们的自然语言反馈
"content": review_data,
"name": tool_call["name"],
"tool_call_id": tool_call["id"],
}
return Command(goto="call_llm", update={"messages": [tool_message]})
def run_tool(state):
new_messages = []
tools = {"weather_search": weather_search}
tool_calls = state["messages"][-1].tool_calls
for tool_call in tool_calls:
tool = tools[tool_call["name"]]
result = tool.invoke(tool_call["args"])
new_messages.append(
{
"role": "tool",
"name": tool_call["name"],
"content": result,
"tool_call_id": tool_call["id"],
}
)
return {"messages": new_messages}
def route_after_llm(state) -> Literal[END, "human_review_node"]:
if len(state["messages"][-1].tool_calls) == 0:
return END
else:
return "human_review_node"
builder = StateGraph(State)
builder.add_node(call_llm)
builder.add_node(run_tool)
builder.add_node(human_review_node)
builder.add_edge(START, "call_llm")
builder.add_conditional_edges("call_llm", route_after_llm)
builder.add_edge("run_tool", "call_llm")
memory = MemorySaver()
graph = builder.compile(checkpointer=memory)
display(Image(graph.get_graph().draw_mermaid_png()))
得到下面的graph:
我们看一个不需要人类介入批准的例子(因为没有调用任何工具,这个由模型根据我们的问题决定):
initial_input = {"messages": [{"role": "user", "content": "hi!"}]}
thread = {"configurable": {"thread_id": "1"}}
for event in graph.stream(initial_input, thread, stream_mode="updates"):
print(event)
print("\n")
结果:
{'call_llm': {'messages': [AIMessage(content="Hello! I'm here to help you. I can assist you with checking the weather in different cities using the weather search tool. Would you like to know the weather for a specific city? Just let me know which city you're interested in!", additional_kwargs={}, response_metadata={'id': 'msg_01XHvA3ZWpsq4PdyiruWFLBs', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 374, 'output_tokens': 52}}, id='run-c3ff5fea-0135-4d66-8ec1-f8ed6a88356b-0', usage_metadata={'input_tokens': 374, 'output_tokens': 52, 'total_tokens': 426, 'input_token_details': {}})]}}
这个时候如果我们检查状态,我们可以看到它已经完成。 接下来让我们看看批准工具调用是什么样子的:
initial_input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]}
thread = {"configurable": {"thread_id": "2"}}
for event in graph.stream(initial_input, thread, stream_mode="updates"):
print(event)
print("\n")
得到结果
{'call_llm': {'messages': [AIMessage(content=[{'text': "I'll help you check the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_01Kn67GmQAA3BEF1cfYdNW3c', 'input': {'city': 'sf'}, 'name': 'weather_search', 'type': 'tool_use'}], additional_kwargs={}, response_metadata={'id': 'msg_013eJXUAEA2ANvYLkDUQFRPo', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'input_tokens': 379, 'output_tokens': 65}}, id='run-e8174b94-f681-4688-967f-a32295412f91-0', tool_calls=[{'name': 'weather_search', 'args': {'city': 'sf'}, 'id': 'toolu_01Kn67GmQAA3BEF1cfYdNW3c', 'type': 'tool_call'}], usage_metadata={'input_tokens': 379, 'output_tokens': 65, 'total_tokens': 444, 'input_token_details': {}})]}}
{'__interrupt__': (Interrupt(value={'question': 'Is this correct?', 'tool_call': {'name': 'weather_search', 'args': {'city': 'sf'}, 'id': 'toolu_01Kn67GmQAA3BEF1cfYdNW3c', 'type': 'tool_call'}}, resumable=True, ns=['human_review_node:be252162-5b29-0a98-1ed2-c807c1fc64c6'], when='during'),)}
如果我们现在检查,我们可以看到它正在等待人工审核,就是在 human_review_node 节点中断了:
print("Pending Executions!")
print(graph.get_state(thread).next)
# Pending Executions!
# ('human_review_node',)
要批准工具调用,我们可以直接继续执行线程而不进行任何更新。为所以,我们需要让 human_review_node 节点知道使用什么值,human_review 是我们在节点内部定义的变量。我们可以通过调用 graph 图来提供这个值,接收Command(resume=<human_review>) 的 输入。由于我们正在批准工具调用,因此我们将提供 resume 的值 {"action": "continue"} 让路由导航到 run_tool node 节点:
for event in graph.stream(
# provide value
Command(resume={"action": "continue"}),
thread,
stream_mode="updates",
):
print(event)
print("\n")
结果
{'human_review_node': None}
----
Searching for: sf
----
{'run_tool': {'messages': [{'role': 'tool', 'name': 'weather_search', 'content': 'Sunny!', 'tool_call_id': 'toolu_01Kn67GmQAA3BEF1cfYdNW3c'}]}}
{'call_llm': {'messages': [AIMessage(content="According to the search, it's sunny in San Francisco today!", additional_kwargs={}, response_metadata={'id': 'msg_01FJTbC8oK5fkD73rUBmAtUx', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 457, 'output_tokens': 17}}, id='run-c21af72d-3cc5-4b74-bb7c-fbeb8f88bd6d-0', usage_metadata={'input_tokens': 457, 'output_tokens': 17, 'total_tokens': 474, 'input_token_details': {}})]}}
现在如果我们要修改工具的调用呢。例如。更改一些参数,然后再执行该工具。
# Input
initial_input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]}
# Thread
thread = {"configurable": {"thread_id": "3"}}
# Run the graph until the first interruption
for event in graph.stream(initial_input, thread, stream_mode="updates"):
print(event)
print("\n")
结果
{'call_llm': {'messages': [AIMessage(content=[{'text': "I'll help you check the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_013eUXow3jwM6eekcDJdrjDa', 'input': {'city': 'sf'}, 'name': 'weather_search', 'type': 'tool_use'}], additional_kwargs={}, response_metadata={'id': 'msg_013ruFpCRNZKX3cDeBAH8rEb', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'input_tokens': 379, 'output_tokens': 65}}, id='run-13df3982-ce6d-4fe2-9e5c-ea6ce30a63e4-0', tool_calls=[{'name': 'weather_search', 'args': {'city': 'sf'}, 'id': 'toolu_013eUXow3jwM6eekcDJdrjDa', 'type': 'tool_call'}], usage_metadata={'input_tokens': 379, 'output_tokens': 65, 'total_tokens': 444, 'input_token_details': {}})]}}
{'__interrupt__': (Interrupt(value={'question': 'Is this correct?', 'tool_call': {'name': 'weather_search', 'args': {'city': 'sf'}, 'id': 'toolu_013eUXow3jwM6eekcDJdrjDa', 'type': 'tool_call'}}, resumable=True, ns=['human_review_node:da717c23-60a0-2a1a-45de-cac5cff308bb'], when='during'),)}
查看状态,等待人类的批准,
print("Pending Executions!")
print(graph.get_state(thread).next)
# Pending Executions!
# ('human_review_node',)
我们将使用这种回复方式,接收 Command {"action": "update", "data": }的值. 这会将现有工具调用与用户提供的工具调用参数相结合,并使用新工具调用更新现有 AI 消息,然后导航到包含更新的 AI 消息的 run_tool 节点并继续执行
for event in graph.stream(
Command(resume={"action": "update", "data": {"city": "San Francisco, USA"}}),
thread,
stream_mode="updates",
):
print(event)
print("\n")
{'human_review_node': {'messages': [{'role': 'ai', 'content': [{'text': "I'll help you check the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_013eUXow3jwM6eekcDJdrjDa', 'input': {'city': 'sf'}, 'name': 'weather_search', 'type': 'tool_use'}], 'tool_calls': [{'id': 'toolu_013eUXow3jwM6eekcDJdrjDa', 'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}}], 'id': 'run-13df3982-ce6d-4fe2-9e5c-ea6ce30a63e4-0'}]}}
----
Searching for: San Francisco, USA
----
{'run_tool': {'messages': [{'role': 'tool', 'name': 'weather_search', 'content': 'Sunny!', 'tool_call_id': 'toolu_013eUXow3jwM6eekcDJdrjDa'}]}}
{'call_llm': {'messages': [AIMessage(content="According to the search, it's sunny in San Francisco right now!", additional_kwargs={}, response_metadata={'id': 'msg_01QssVtxXPqr8NWjYjTaiHqN', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 460, 'output_tokens': 18}}, id='run-8ab865c8-cc9e-4300-8e1d-9eb673e8445c-0', usage_metadata={'input_tokens': 460, 'output_tokens': 18, 'total_tokens': 478, 'input_token_details': {}})]}}
有时候,我们可能不想执行工具调用,也可能不想要求用户手动去修改工具调用。在这种情况下,从用户那里获得自然语言反馈可能会更好。然后,我们可以插入用户的反馈作为工具调用的模拟结果。有多种方法可以做到这一点:
- 我们可以向状态添加一条新消息(表示工具调用的“结果”)
- 我们可以向状态添加两条新消息 ,一条代表工具调用中的“错误”,另一条代表反馈的 HumanMessage。
这两者的相似之处在于它们都涉及向状态添加消息。主要区别在于 human_review_node 之后的逻辑以及它如何处理不同类型的消息。 我们添加一个表示反馈的工具调用:
initial_input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]}
thread = {"configurable": {"thread_id": "4"}}
for event in graph.stream(initial_input, thread, stream_mode="updates"):
print(event)
print("\n")
{'call_llm': {'messages': [AIMessage(content=[{'text': "I'll help you check the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_01QxXNTCasnNLQCGAiVoNUBe', 'input': {'city': 'sf'}, 'name': 'weather_search', 'type': 'tool_use'}], additional_kwargs={}, response_metadata={'id': 'msg_01DjwkVxgfqT2K329rGkycx6', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'input_tokens': 379, 'output_tokens': 65}}, id='run-c57bee36-9f5f-4d2e-85df-758b56d3cc05-0', tool_calls=[{'name': 'weather_search', 'args': {'city': 'sf'}, 'id': 'toolu_01QxXNTCasnNLQCGAiVoNUBe', 'type': 'tool_call'}], usage_metadata={'input_tokens': 379, 'output_tokens': 65, 'total_tokens': 444, 'input_token_details': {}})]}}
{'__interrupt__': (Interrupt(value={'question': 'Is this correct?', 'tool_call': {'name': 'weather_search', 'args': {'city': 'sf'}, 'id': 'toolu_01QxXNTCasnNLQCGAiVoNUBe', 'type': 'tool_call'}}, resumable=True, ns=['human_review_node:47a3f541-b630-5f8a-32d7-5a44826d99da'], when='during'),)}
查看状态:
print("Pending Executions!")
print(graph.get_state(thread).next)
# Pending Executions!
# ('human_review_node',)
然后继续:
for event in graph.stream(
Command(
resume={
"action": "feedback",
"data": "User requested changes: use <city, country> format for location",
}
),
thread,
stream_mode="updates",
):
print(event)
print("\n")
{'human_review_node': {'messages': [{'role': 'tool', 'content': 'User requested changes: use <city, country> format for location', 'name': 'weather_search', 'tool_call_id': 'toolu_01QxXNTCasnNLQCGAiVoNUBe'}]}}
{'call_llm': {'messages': [AIMessage(content=[{'text': 'Let me try again with the full city name.', 'type': 'text'}, {'id': 'toolu_01WBGTKBWusaPNZYJi5LKmeQ', 'input': {'city': 'San Francisco, USA'}, 'name': 'weather_search', 'type': 'tool_use'}], additional_kwargs={}, response_metadata={'id': 'msg_0141KCdx6KhJmWXyYwAYGvmj', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'input_tokens': 468, 'output_tokens': 68}}, id='run-60c8267a-52c7-4b6e-87ca-16aa3bd6266b-0', tool_calls=[{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01WBGTKBWusaPNZYJi5LKmeQ', 'type': 'tool_call'}], usage_metadata={'input_tokens': 468, 'output_tokens': 68, 'total_tokens': 536, 'input_token_details': {}})]}}
{'__interrupt__': (Interrupt(value={'question': 'Is this correct?', 'tool_call': {'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01WBGTKBWusaPNZYJi5LKmeQ', 'type': 'tool_call'}}, resumable=True, ns=['human_review_node:621fc4a9-bbf1-9a99-f50b-3bf91675234e'], when='during'),)}
现在我们可以看到遇到了另一个中断 ,因为它返回到模型并得到了关于调用内容的全新预测。现在让我们批准这一点并继续。
print("Pending Executions!")
print(graph.get_state(thread).next)
Pending Executions!
('human_review_node',)
for event in graph.stream(
Command(resume={"action": "continue"}), thread, stream_mode="updates"
):
print(event)
print("\n")
结果:
{'human_review_node': None}
----
Searching for: San Francisco, USA
----
{'run_tool': {'messages': [{'role': 'tool', 'name': 'weather_search', 'content': 'Sunny!', 'tool_call_id': 'toolu_01WBGTKBWusaPNZYJi5LKmeQ'}]}}
{'call_llm': {'messages': [AIMessage(content='The weather in San Francisco is sunny!', additional_kwargs={}, response_metadata={'id': 'msg_01JrfZd8SYyH51Q8rhZuaC3W', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 549, 'output_tokens': 12}}, id='run-09a198b2-79fa-484d-9d9d-f12432978488-0', usage_metadata={'input_tokens': 549, 'output_tokens': 12, 'total_tokens': 561, 'input_token_details': {}})]}}
至此,我们所有的关于用户的批准和工具的回调使用就完整的实现了,代码有点长,大家耐心的慢慢的读每一段,会有意想不到的收获哦! 要是有不懂的地方可以评论区留言,博主收到会及时回复。