曦远 (DealiAxy)一份私人的文字刊物

StarBlog
返回文章列表折页手记阅读全文
Django

Implementing WebSocket Communication with Django-Channels for Large Language Model Dialogue

This is just a simple demo. In actual production, many other issues need to be considered. This article merely opens up a discussion on the application of channels, and the blog will be continuously updated with new research findings in the future.

曦远 (DealiAxy)约 35 分钟
目录16 个章节

Foreword

I've been working on this large model project recently, and I chose Django as the framework (although many large model applications now use FastAPI, I'm already accustomed to Django).

Previously, when using AspNetCore as the backend, I tried various approaches such as Blazor Server and WebAPI SSE (Server Sent Event) to implement large model dialogues. Currently, it seems SSE is more widely used, and ChatGPT also employs this method.

Since entering the 3.x era, Django has started supporting asynchronous programming, making SSE implementation possible (though I found it a bit tricky in my testing). Ultimately, I decided to use WebSocket for implementation, which in the Django ecosystem relies on the Channels library. (It seems FastAPI can directly support SSE and WebSocket.)

First, Look at the Effect

Display a chat interface

Chat Interface

I also created a conversation history page.

Conversation History Page

About Django-Channels

Here is the introduction from the official website: https://channels.readthedocs.io/en/latest/introduction.html

Channels wraps Django’s native asynchronous view support, allowing Django projects to handle not only HTTP, but protocols that require long-running connections too - WebSockets, MQTT, chatbots, amateur radio, and more.

OK, Channels extends Django from a pure synchronous HTTP framework to handle asynchronous protocols like WebSocket. Originally, Django was based on WSGI. Channels uses the ASGI-based Daphne server, and not only can it use WebSocket, but it can also support newer technologies like HTTP/2.

Several related concepts:

  • Channels: Persistent connections, such as WebSocket, used for real-time data transfer.
  • Consumers: Asynchronous functions that handle incoming events, similar to Django views but designed specifically for asynchronous operations.
  • Routing: Similar to Django's URL routing system, Channels uses routing to decide how to distribute a given WebSocket connection or message to the corresponding Consumer.

Compared to traditional Django request handling, Django Channels allows developers to use asynchronous programming patterns, which is particularly advantageous for handling long-running connections or applications requiring a large number of concurrent connections. This architectural change brings higher performance and a better user experience, enabling Django to better adapt to the needs of modern internet applications.

By introducing Channels, Django is no longer just a request/response web framework; it has become a truly full-featured framework capable of handling multiple network protocols and long-running connections. This allows Django developers to build richer and more dynamic applications without leaving the familiar environment.

Use Cases

First, let's introduce the usage scenario.

This demo project's backend uses StarAI and LangChain to call LLMs to obtain answers, then communicates with the frontend via WebSocket. For the frontend, I chose React + Tailwind.

Installation

Take the DjangoStarter project as an example (using pdm as the package manager)

pdm add channels[daphne]

Then modify src/config/settings/components/common.py

Add daphne to the registered apps, making sure to put it at the beginning.

# Application Definition
INSTALLED_APPS: Tuple[str, ...] = (
    'daphne',
)

When using runserver later, daphne will run in place of Django's built-in server.

Next, channels also requires a channel layer. This allows multiple consumer instances to communicate with each other and with other parts of Django. This layer can be set to Redis.

pdm add channels_redis

Configuration

ASGI

OK, next, I need to modify src/config/asgi.py

import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

django_asgi_app = get_asgi_application()

from apps.chat.routing import websocket_urlpatterns

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    # Just HTTP for now. (We can add other protocols later.)
    "websocket": AllowedHostsOriginValidator(
        AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
    ),
})

In addition to the configuration mentioned in the official documentation, I have already included the routing for the chat application here.

Since this demo project only has this one app using WebSocket, I directly use the routing from the chat app as the root URLRouter here.

If there were multiple WebSocket apps, it could be modified as needed.

channel layer

Modify the src/config/settings/components/channels.py file

from config.settings.components.common import DOCKER

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("redis" if DOCKER else "127.0.0.1", 6379)],
        },
    },
}

The other configurations need not be detailed further; everything is pre-configured in DjangoStarter.

Writing Backend Code

Using channels is straightforward. As mentioned earlier, we only need to implement the logic for the consumer and then configure the routing.

Let's get started.

consumer

Create the src/apps/chat/consumers.py file.

Identity authentication, using large language models to generate responses, and chat history management are all handled here.

Let's show the code first, with explanations to follow.

import asyncio
import json

from asgiref.sync import async_to_sync
from channels.db import database_sync_to_async
from channels.generic.websocket import WebsocketConsumer, AsyncWebsocketConsumer

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage

from .models import Conversation, Message

class LlmConsumer(AsyncWebsocketConsumer):
    def __init__(self, *args, **kwargs):
        super().__init__(args, kwargs)
        self.chat_id = None
        self.conversation = None

    @database_sync_to_async
    def get_conversation(self, pk, user):
        obj, _ = Conversation.objects.get_or_create(id=pk, user=user)
        return obj

    @database_sync_to_async
    def add_message(self, role: str, content: str):
        return Message.objects.create(
            conversation=self.conversation,
            role=role,
            content=content,
        )

    async def connect(self):
        self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
        await self.accept()

        # Check if the user is logged in
        user = self.scope["user"]
        if not user.is_authenticated:
            await self.close(code=4001, reason='Authentication required. Please log in again.')
            return
        else:
            self.conversation = await self.get_conversation(self.chat_id, user)

    async def disconnect(self, close_code):
        ...

    async def receive(self, text_data=None, bytes_data=None):
        text_data_json: dict = json.loads(text_data)
        history: list = text_data_json.get("history")

        user = self.scope["user"]
        if not user.is_authenticated:
            reason = 'Authentication required. Please log in again.'
            await self.send(text_data=json.dumps({"message": reason}))
            await asyncio.sleep(0.1)
            await self.close(code=4001, reason=reason)
            return

        await self.add_message(**history[-1])

        llm = ChatOpenAI(model="gpt4")
        resp = llm.stream(
            [
                *[
                    HumanMessage(content=e['content']) if e['role'] == 'user' else AIMessage(content=e['content'])
                    for e in history
                ],
            ]
        )

        message_chunks = []
        for chunk in resp:
            message_chunks.append(chunk.content)
            await self.send(text_data=json.dumps({"message": ''.join(message_chunks)}))
            await asyncio.sleep(0.1)

        await self.add_message('ai', ''.join(message_chunks))

Workflow

  • After the frontend initiates the WebSocket connection, the code in the connect method is executed. If authentication is successful, it calls self.accept() to accept the connection.
  • When a message is received from the frontend, the receive method is automatically executed. After processing, it calls self.send to send information back.

Authentication

Although AuthMiddlewareStack is already configured in asgi.py, authentication logic still needs to be handled manually in the code.

The purpose of this middleware is to convert the authorization information from the header into self.scope["user"] within the consumer.

Key Points

  • Consumers come in both synchronous and asynchronous versions. Here, I am using the asynchronous version AsyncWebsocketConsumer.
  • To access the Django ORM within an async consumer, you must use the database_sync_to_async decorator.
  • In the connect method, if authentication fails, self.close is called to close the connection. The code parameter cannot conflict with HTTP Status Codes. I initially used 401, but the frontend received a different code. Using the custom code 4001 allowed it to be received correctly. However, the reason parameter was never received, possibly due to the browser's WebSocket implementation?
  • In the receive method, when streaming the content generated by the large language model to the client, you must include await asyncio.sleep inside the for loop to introduce a small delay. This allows time for the WebSocket to send the message to the client; otherwise, all generated content would be sent only after it is fully generated, losing the streaming effect.
  • In the receive method, after receiving a message, it first checks if the user is currently logged in (or if the login session has expired, which is equivalent to being logged out). If the user is not logged in, it sends the message "Authentication required. Please log in again." to the client and then closes the connection.

Routing

After writing the consumer, configure the routing.

Edit the file src/apps/chat/routing.py

from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r"ws/chat/demo/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
    re_path(r"ws/chat/llm/(?P<chat_id>\w+)/$", consumers.LlmConsumer.as_asgi()),
]

Note here that you must use re_path and not path (as stated in the official documentation).

Client-Side Development

Alright, the backend part is done. Now let's write the client-side code.

I chose React to implement the client.

I'll skip the irrelevant code and just paste the key parts directly.

function App() {
  const [prompt, setPrompt] = useState('')
  const [messages, setMessages] = useState([])
  const [connectState, setConnectState] = useState(3)
  const [conversation, setConversation] = useState()

  const chatSocket = useRef(null)
  const messagesRef = useRef(null)
  const reLoginModalRef = useRef(null)

  React.useEffect(() => {
    openWebSocket()
    getConversation()

    return () => {
      chatSocket.current.close();
    }
  }, [])

  React.useEffect(() => {
    // Automatically scroll to the bottom of the message container
    messagesRef.current.scrollIntoView({behavior: 'smooth'})
  }, [messages]);

  const getConversation = () => {
    // ... Code for fetching chat history omitted
  }

  const openWebSocket = () => {
    setConnectState(0)
    chatSocket.current = new WebSocket(`ws://${window.location.host}/ws/chat/llm/${ConversationId}/`)
    chatSocket.current.onopen = function (e) {
      console.log('WebSocket connection established', e)
      setConnectState(chatSocket.current.readyState)
    };

    chatSocket.current.onmessage = function (e) {
      const data = JSON.parse(e.data)
      setMessages(prevMessages => {
        if (prevMessages[prevMessages.length - 1].role === 'ai')
          return [
            ...prevMessages.slice(0, -1), {role: 'ai', content: data.message}
          ]
        else
          return [
            ...prevMessages, {role: 'ai', content: data.message}
          ]
      })
    };

    chatSocket.current.onclose = function (e) {
      console.error('WebSocket connection lost. Chat socket closed unexpectedly.', e)
      setConnectState(chatSocket.current.readyState)
      if (e.code === 4001) {
        // Show re-login dialog
        new Modal(reLoginModalRef.current, options).show()
      }
    };
  }

  const sendMessage = () => {
    if (prompt.length === 0) return

    const newMessages = [...messages, {
      role: 'user', content: prompt
    }]
    setMessages(newMessages)
    setPrompt('')
    chatSocket.current.send(JSON.stringify({
      'history': newMessages
    }))
  }
}

The frontend code is pretty straightforward, nothing special to say.

I used messagesRef.current.scrollIntoView({behavior: 'smooth'}) to implement automatic scrolling to the bottom of the messages. When I was developing AIHub with Blazor before, I think I used a different implementation; I don't remember it being this one.

But this method is pretty smooth (丝滑).

Writing the WebSocket address directly in here doesn't feel very elegant, and having to change it to wss:// for deployment later is also a bit of a hassle. I should look into if there's a more elegant way to implement this.

Summary

This is just a simple demo; in practice, production environments require consideration of many more issues. This article aims to introduce the application of channels, and future blog posts will be updated as new research findings emerge~

_EOF_

Implementing WebSocket Communication with Django-Channels for Large Language Model Dialogue

本文作者:
程序设计实验室
版权声明:
本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

相关文章

DjangoRestFramework框架三种分页功能的实现 - 在DjangoStarter项目模板中封装

前言 继续Django后端开发系列文章。刚好遇到一个分页的需求,就记录一下。 Django作为一个“全家桶”型的框架,本身啥都有,分页组件也是有的,但默认的分页组件没有对API开发做优化,所以DjangoRestFramework这个专门写API的框架又把Django的分页组件包装了一层,集成在viewsets里的时候会更方便。 不过我们不可能一直用viewsets,有一部分API还是要用自由度更

2022-08-26 · Django · 约 1 分钟

Django+Taro项目实现企业微信登录

前言 还是最近在做的一个小项目,后端用的是Django搭配RestFramework做接口,前端第一次尝试用京东开源的Taro框架来做多端(目前需要做用于企业微信的H5端和微信小程序) 本文记录一下企业微信登录的流程,上周看文档看得头晕晕的,其实很简单,封装好了就几行代码的事~ 两种方式 一种是先拼接好登录要用的地址authorize_url,回调地址设置成h5应用的网页入口,然后把authori

2022-04-08 · Django · 约 1 分钟

讨论

共 0 条

良言一句三冬暖,恶语伤人六月寒。

加载评论中…