ساخت چت‌ بات با استفاده از LangChain

ساخت چت‌ بات با استفاده از LangChain

chatbot, langchain
preview

در این پست یک مثال از نحوه طراحی و پیاده‌سازی یک چت‌بات با استفاده از یک LLM را بررسی خواهیم کرد. این چت‌بات قادر به انجام مکالمه و به خاطر سپاری تعاملات قبلی است. در نظر داشته باشید که برای تولید مکالمات پیچیده تر میتوانید از قابلیت های عامل (Agent) و RAG که از طریق پکیج LangChain قابل دسترس هستند استفاده کنید.

آماده سازی محیط #

برای این آموزش به langchain-core و langgraph نیاز خواهیم داشت:

1pip install langchain langchain-core langgraph>0.2.27 langchain_openai

یا با استفاده از Conda:

1conda install langchain langchain-core langgraph>0.2.27 langchain_openai -c conda-forge

برای جزئیات بیشتر، به مستندات راهنمای نصب مراجعه کنید.

LangSmith #

بسیاری از برنامه‌هایی که با LangChain می‌سازید شامل مراحل متعددی با فراخوانی‌های متعدد LLM هستند. با پیچیده‌تر شدن این برنامه‌ها، توانایی بررسی دقیق آنچه در زنجیره یا عامل (Agent) شما اتفاق می‌افتد بسیار مهم می‌شود. بهترین راه برای انجام این کار استفاده از LangSmith است.

پس از ثبت‌نام در لینک بالا، مطمئن شوید که متغیرهای محیطی خود را برای شروع Tracing تنظیم کرده‌اید:

1export LANGCHAIN_TRACING_V2="true"
2export LANGCHAIN_API_KEY="..."

یا، اگر در یک نوت‌بوک هستید، می‌توانید آنها را به این صورت تنظیم کنید:

1import os
2
3os.environ["LANGCHAIN_TRACING_V2"] = "true"
4os.environ["LANGCHAIN_API_KEY"] = "..."

پیاده‌سازی #

ابتدا می‌خواهیم یاد بگیریم که چگونه از یک مدل زبانی به تنهایی برای پیاده سازی چت‌بات استفاده کنیم. LangChain از مدل‌های زبانی مختلفی پشتیبانی می‌کند که می‌توانید به صورت متناوب از آنها استفاده کنید - مدل مورد نظر خود را در زیر انتخاب کنید!

برای اجرای کدهای زیر ابتدا باید یک کلید API را از طریق پنل کاربری گیلاس تولید کنید. برای این کار ابتدا یک حساب کاربری جدید بسازید یا اگر صاحب حساب کاربری هستید وارد پنل کاربری خود شوید. سپس، به صفحه کلید API بروید و با کلیک روی دکمه “ساخت کلید API” یک کلید جدید برای دسترسی به Gilas API بسازید.
1from langchain_openai import ChatOpenAI
2
3model = ChatOpenAI(
4    api_key="GILAS_API_KEY",
5    base_url="https://api.gilas.io/v1/",
6    model="gpt-4o-mini")

بیایید ابتدا مدل را مستقیماً استفاده کنیم. ChatModelها نمونه‌هایی از “Runnables” LangChain هستند، به این معنی که یک رابط استاندارد برای تعامل با آنها ارائه می‌دهند. برای فراخوانی ساده مدل، می‌توانیم لیستی از پیام‌ها را به متود .invoke ارسال کنیم.

1from langchain_core.messages import HumanMessage
2
3model.invoke([HumanMessage(content="Hi! I'm Bob")])
1AIMessage(content='Hi Bob! How can I assist you today?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 11, 'total_tokens': 21, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_1bb46167f9', 'finish_reason': 'stop', 'logprobs': None}, id='run-149994c0-d958-49bb-9a9d-df911baea29f-0', usage_metadata={'input_tokens': 11, 'output_tokens': 10, 'total_tokens': 21})

مدل به تنهایی هیچ درکی از state ندارد یا به عبارت دیگر هر درخواست به مدل به صورت state-less پردازش می‌شود. به عنوان مثال، اگر یک سوال پیگیری بپرسید:

1model.invoke([HumanMessage(content="What's my name?")])
1AIMessage(content="I'm sorry, but I don't have access to personal information about individuals unless you've shared it with me in this conversation. How can I assist you today?", additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 30, 'prompt_tokens': 11, 'total_tokens': 41, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_1bb46167f9', 'finish_reason': 'stop', 'logprobs': None}, id='run-0ecab57c-728d-4fd1-845c-394a62df8e13-0', usage_metadata={'input_tokens': 11, 'output_tokens': 30, 'total_tokens': 41})

می‌توانیم ببینیم که چت بات پیغام‌های قبلی را در نظر نمی‌گیرد و نمی‌تواند به سوال پاسخ دهد. برای حل این مشکل، باید کل تاریخچه مکالمه را به مدل منتقل کنیم. بیایید ببینیم وقتی این کار را انجام می‌دهیم چه اتفاقی می‌افتد:

1from langchain_core.messages import AIMessage
2
3model.invoke(
4    [
5        HumanMessage(content="Hi! I'm Bob"),
6        AIMessage(content="Hello Bob! How can I assist you today?"),
7        HumanMessage(content="What's my name?"),
8    ]
9)
1AIMessage(content='Your name is Bob! How can I help you today?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 33, 'total_tokens': 45, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_1bb46167f9', 'finish_reason': 'stop', 'logprobs': None}, id='run-c164c5a1-d85f-46ee-ba8a-bb511cfb0e51-0', usage_metadata={'input_tokens': 33, 'output_tokens': 12, 'total_tokens': 45})

و اکنون می‌توانیم ببینیم که پاسخ خوبی دریافت می‌کنیم! حال سوال این است که چگونه می‌توانیم انتقال مکالمات قبلی به چت بات را به بهترین شکل پیاده‌سازی کنیم؟

نگهداری پیام #

پکیج LangGraph یک لایه حافظه داخلی پیاده‌سازی می‌کند که آن را برای برنامه‌های چت که باید مکالمات قبلی را به خاطر بسپارند، ایده‌آل می‌کند. استفاده از LangGraph به ما امکان می‌دهد تا تاریخچه پیام‌ها را به طور خودکار حفظ کنیم که باعث تسهیل توسعه چت بات هامی‌شود.

پکیج LangGraph با یک چک‌پوینتر ساده در حافظه ارائه می‌شود که در زیر از آن استفاده می‌کنیم. برای جزئیات بیشتر، از جمله نحوه استفاده از بک‌اندهای مختلف (مانند SQLite یا Postgres) به مستندات آن مراجعه کنید.

 1from langgraph.checkpoint.memory import MemorySaver
 2from langgraph.graph import START, MessagesState, StateGraph
 3
 4# Define a new graph
 5workflow = StateGraph(state_schema=MessagesState)
 6
 7
 8# Define the function that calls the model
 9def call_model(state: MessagesState):
10    response = model.invoke(state["messages"])
11    return {"messages": response}
12
13
14# Define the (single) node in the graph
15workflow.add_edge(START, "model")
16workflow.add_node("model", call_model)
17
18# Add memory
19memory = MemorySaver()
20app = workflow.compile(checkpointer=memory)

اکنون باید یک config ایجاد کنیم که هر بار به runnable منتقل شود. این پیکربندی شامل اطلاعاتی است که مستقیماً بخشی از ورودی نیست، اما همچنان مفید است. در این مورد، می‌خواهیم یک thread_id را تعریف کنیم که به صورت زیر است:

1config = {"configurable": {"thread_id": "abc123"}}

این کار به چت بات این امکان را می‌دهد تا چندین مکالمه‌ی همزمان را به طور جداگانه مدیریت کند.

سپس می‌توانیم برنامه را فراخوانی کنیم:

1query = "Hi! I'm Bob."
2
3input_messages = [HumanMessage(query)]
4output = app.invoke({"messages": input_messages}, config)
5output["messages"][-1].pretty_print()  # output contains all messages in state
1Ai Message:
2
3Hi Bob! How can I assist you today?
1query = "What's my name?"
2
3input_messages = [HumanMessage(query)]
4output = app.invoke({"messages": input_messages}, config)
5output["messages"][-1].pretty_print()
1Ai Message:
2
3Your name is Bob! How can I help you today?

عالی! چت‌بات ما اکنون چیزهایی درباره ما به خاطر می‌آورد. اگر پیکربندی را تغییر دهیم تا به یک thread_id متفاوت اشاره کند، می‌توانیم ببینیم که مکالمه را تازه شروع می‌کند.

1config = {"configurable": {"thread_id": "abc234"}}
2
3input_messages = [HumanMessage(query)]
4output = app.invoke({"messages": input_messages}, config)
5output["messages"][-1].pretty_print()
1Ai Message:
2
3I'm sorry, but I don't have access to personal information about you unless you provide it. How can I assist you today?

با این حال، چون مکالمه‌ی قبلی را در حافظ نگه داشته ایم٬ همیشه می‌توانیم به آن برگردیم.

1config = {"configurable": {"thread_id": "abc123"}}
2
3input_messages = [HumanMessage(query)]
4output = app.invoke({"messages": input_messages}, config)
5output["messages"][-1].pretty_print()
1Ai Message:
2
3Your name is Bob! If there's anything else you'd like to discuss or ask, feel free!

پس به این طریق می‌توانیم چت‌باتی داشته باشیم که با بسیاری از کاربران مکالمه دارد و تاریخچه‌ی هر مکالمه را به طور جداگانه به خاطر دارد!

برای پشتیبانی از async، تابع call_model را تبدیل به یک تابع async کنید و هنگام فراخوانی برنامه از .ainvoke استفاده کنید:

 1# Async function for node:
 2async def call_model(state: MessagesState):
 3    response = await model.ainvoke(state["messages"])
 4    return {"messages": response}
 5
 6
 7# Define graph as before:
 8workflow = StateGraph(state_schema=MessagesState)
 9workflow.add_edge(START, "model")
10workflow.add_node("model", call_model)
11app = workflow.compile(checkpointer=MemorySaver())
12
13# Async invocation:
14output = await app.ainvoke({"messages": input_messages}, config)
15output["messages"][-1].pretty_print()

در حال حاضر، تمام کاری که انجام داده‌ایم اضافه کردن یک لایه حافظه‌ی ساده به مدل است. حال می‌توانیم با اضافه کردن یک قالب پراپمت، مکالمات را پیچیده‌تر و شخصی‌تر کنیم.

قالب‌های پراپمت #

قالب‌های پراپمت به تبدیل اطلاعات خام کاربر به فرمتی که LLM می‌تواند با آن کار کند کمک می‌کنند. در حال حاضر ورودی خام کاربر فقط یک پیام است که ما به LLM ارسال می‌کنیم. بیایید اکنون آن را کمی پیچیده‌تر کنیم. ابتدا، می‌خواهیم یک پیام سیستمی با دستورالعمل‌های سفارشی را به مدل اضافه کنیم (اما همچنان پیام‌ها را به عنوان ورودی بپذیریم). سپس، ورودی‌های بیشتری را در کنار پیام‌ها به مدل خواهیم افزود.

برای اضافه کردن یک پیام سیستمی، یک ChatPromptTemplate ایجاد خواهیم کرد و از MessagesPlaceholder برای ارسال همه پیام‌ها استفاده می‌کنیم.

 1from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
 2
 3prompt = ChatPromptTemplate.from_messages(
 4    [
 5        (
 6            "system",
 7            "You talk like a pirate. Answer all questions to the best of your ability.",
 8        ),
 9        MessagesPlaceholder(variable_name="messages"),
10    ]
11)

اکنون می‌توانیم برنامه خود را به‌روزرسانی کنیم تا از این قالب استفاده کند:

 1workflow = StateGraph(state_schema=MessagesState)
 2
 3
 4def call_model(state: MessagesState):
 5    # change-start
 6    chain = prompt | model
 7    response = chain.invoke(state)
 8    # change-end
 9    return {"messages": response}
10
11
12workflow.add_edge(START, "model")
13workflow.add_node("model", call_model)
14
15memory = MemorySaver()
16app = workflow.compile(checkpointer=memory)

حال برنامه را به همان روش قبلی فراخوانی می‌کنیم:

1config = {"configurable": {"thread_id": "abc345"}}
2query = "Hi! I'm Jim."
3
4input_messages = [HumanMessage(query)]
5output = app.invoke({"messages": input_messages}, config)
6output["messages"][-1].pretty_print()
1Ai Message:
2
3Ahoy there, Jim! What brings ye to these treacherous waters today? Be ye seekin’ treasure, tales, or perhaps a bit o’ knowledge? Speak up, matey!
1query = "What is my name?"
2
3input_messages = [HumanMessage(query)]
4output = app.invoke({"messages": input_messages}, config)
5output["messages"][-1].pretty_print()
Ai Message:

Ye be callin' yerself Jim, if I be hearin' ye correctly! A fine name for a scallywag such as yerself! What else can I do fer ye, me hearty?

عالی! بیایید اکنون پراپمت خود را کمی پیچیده‌تر کنیم. فرض کنیم که قالب پراپمت اکنون به این شکل است:

1prompt = ChatPromptTemplate.from_messages(
2    [
3        (
4            "system",
5            "You are a helpful assistant. Answer all questions to the best of your ability in {language}.",
6        ),
7        MessagesPlaceholder(variable_name="messages"),
8    ]
9)

توجه داشته باشید که یک ورودی جدید language به پراپمت اضافه کرده‌ایم. برنامه ما اکنون دو پارامتر دارد - ورودی messages و language. باید وضعیت برنامه خود را به‌روزرسانی کنیم تا این را منعکس کند:

 1from typing import Sequence
 2
 3from langchain_core.messages import BaseMessage
 4from langgraph.graph.message import add_messages
 5from typing_extensions import Annotated, TypedDict
 6
 7
 8class State(TypedDict):
 9    messages: Annotated[Sequence[BaseMessage], add_messages]
10    language: str
11
12
13workflow = StateGraph(state_schema=State)
14
15
16def call_model(state: State):
17    chain = prompt | model
18    response = chain.invoke(state)
19    return {"messages": [response]}
20
21
22workflow.add_edge(START, "model")
23workflow.add_node("model", call_model)
24
25memory = MemorySaver()
26app = workflow.compile(checkpointer=memory)
 1config = {"configurable": {"thread_id": "abc456"}}
 2query = "Hi! I'm Bob."
 3language = "Farsi"
 4
 5input_messages = [HumanMessage(query)]
 6output = app.invoke(
 7    {"messages": input_messages, "language": language},
 8    config,
 9)
10output["messages"][-1].pretty_print()
1Ai Message:
2
3سلام باب! چه کمکی میتونم بهت بکنم؟

برای کمک به درک آنچه در داخل اتفاق می‌افتد، به این LangSmith trace نگاهی بیندازید.

مدیریت تاریخچه مکالمه #

یکی از مفاهیم مهم در ساخت چت‌بات‌ها، نحوه مدیریت تاریخچه مکالمه است. اگر تاریخچه‌ی مکالمات مدیریت نشود، لیست پیام‌ها به طور نامحدود رشد می‌کند و ممکن است پنجره زمینه (context window) LLM را پر کند. بنابراین، مهم است که مرحله‌ای اضافه کنید که اندازه پیام‌هایی که ارسال می‌کنید را محدود کند.

مهم است که این کار را قبل از ساخت قالب پراپمت و بعد از بارگذاری پیام‌های قبلی از تاریخچه پیام انجام دهید.

می‌توانیم این کار را با اضافه کردن یک مرحله ساده در جلوی پراپمت انجام دهیم که کلید messages را تغییر دهد و سپس زنجیره جدید را در کلاس تاریخچه پیام wrap کنیم.

پکیج LangChain با چندین تابع کمکی داخلی برای مدیریت لیست پیام‌ها ارائه می‌شود. در این مورد، از تابع trim_messages برای کاهش تعداد پیام‌هایی که به مدل ارسال می‌کنیم، استفاده خواهیم کرد. این ابزار به ما اجازه می‌دهد تا مشخص کنیم که چند توکن از حافظه را می‌خواهیم نگه داریم. همچنین پارامترهای دیگری مانند اینکه آیا می‌خواهیم پیام سیستمی را همیشه نگه داریم و آیا اجازه پیام‌های جزئی را بدهیم یا خیر نیز قابل تنظیم هستند.

 1from langchain_core.messages import SystemMessage, trim_messages
 2
 3trimmer = trim_messages(
 4    max_tokens=65,
 5    strategy="last",
 6    token_counter=model,
 7    include_system=True,
 8    allow_partial=False,
 9    start_on="human",
10)
11
12messages = [
13    SystemMessage(content="you're a good assistant"),
14    HumanMessage(content="hi! I'm bob"),
15    AIMessage(content="hi!"),
16    HumanMessage(content="I like vanilla ice cream"),
17    AIMessage(content="nice"),
18    HumanMessage(content="whats 2 + 2"),
19    AIMessage(content="4"),
20    HumanMessage(content="thanks"),
21    AIMessage(content="no problem!"),
22    HumanMessage(content="having fun?"),
23    AIMessage(content="yes!"),
24]
25
26trimmer.invoke(messages)

برای استفاده از آن در زنجیره خود، فقط باید قبل از ارسال ورودی messages به پراپمت، از trimmer استفاده کنیم.

 1workflow = StateGraph(state_schema=State)
 2
 3
 4def call_model(state: State):
 5    chain = prompt | model
 6    # change-start
 7    trimmed_messages = trimmer.invoke(state["messages"])
 8    response = chain.invoke(
 9        {"messages": trimmed_messages, "language": state["language"]}
10    )
11    # change-end
12    return {"messages": [response]}
13
14
15workflow.add_edge(START, "model")
16workflow.add_node("model", call_model)
17
18memory = MemorySaver()
19app = workflow.compile(checkpointer=memory)

اکنون اگر از مدل نام خود را بپرسیم جواب را نمی‌داند زیرا آن بخش از تاریخچه چت را برش داده‌ایم:

 1config = {"configurable": {"thread_id": "abc567"}}
 2query = "What is my name?"
 3language = "English"
 4
 5input_messages = messages + [HumanMessage(query)]
 6output = app.invoke(
 7    {"messages": input_messages, "language": language},
 8    config,
 9)
10output["messages"][-1].pretty_print()
1Ai Message:
2
3I don't know your name. If you'd like to share it, feel free!

اما اگر درباره اطلاعاتی که در چند پیام آخر بین ما و مدل رد و بدل شده است سوالی بپرسیم، مدل آن را به خاطر می‌آورد:

 1config = {"configurable": {"thread_id": "abc678"}}
 2query = "What math problem did I ask?"
 3language = "English"
 4
 5input_messages = messages + [HumanMessage(query)]
 6output = app.invoke(
 7    {"messages": input_messages, "language": language},
 8    config,
 9)
10output["messages"][-1].pretty_print()
1Ai Message:
2
3You asked what 2 + 2 equals.

اگر به LangSmith trace نگاهی بیندازید، می‌توانید دقیقاً ببینید که در پشت پرده چه اتفاقی می‌افتد.

استریم #

اکنون یک چت‌بات کاربردی داریم. با این حال، یکی از ملاحظات بسیار مهم UX برای برنامه‌های چت‌بات، استریم است. گاهی اوقات ممکن است مدتی طول بکشد تا LLMها پاسخ را تولید کنند، بنابراین برای بهبود تجربه کاربر، یکی از کارهایی که اکثر برنامه‌ها انجام می‌دهند این است که هر توکن را به محض تولید استریم کنند. این به کاربر اجازه می‌دهد تا تولید پاسخ را سریعتر ببیند.

به طور پیش‌فرض، .stream در برنامه LangGraph ما مراحل برنامه را استریم می‌کند. تنظیم stream_mode="messages" به ما اجازه می‌دهد تا توکن‌های خروجی را به جای آن استریم کنیم:

 1config = {"configurable": {"thread_id": "abc789"}}
 2query = "Hi I'm Todd, please tell me a joke."
 3language = "English"
 4
 5input_messages = [HumanMessage(query)]
 6for chunk, metadata in app.stream(
 7    {"messages": input_messages, "language": language},
 8    config,
 9    stream_mode="messages",
10):
11    if isinstance(chunk, AIMessage):  # Filter to just model responses
12        print(chunk.content, end="|")
1|Hi| Todd|!| Here|’s| a| joke| for| you|:
2
3|Why| did| the| scare|crow| win| an| award|?
4
5|Because| he| was| outstanding| in| his| field|!||