基于Django的智能客服系统源码解析:从关键词匹配到WebSocket升级
2026/9/16 14:26:03 网站建设 项目流程

简介:这是一份基于Django框架与Python开发的智能客服系统完整源码包,主要面向计算机、人工智能、通信工程等专业的在校学生和毕业设计者,也可用于课程大作业或项目初期立项演示。压缩包共556个文件,约2.75MB,以318个Python源码文件为主干,配合89个JavaScript文件、21个CSS样式文件,以及SVG、PNG等图片素材,覆盖后端逻辑、前端交互与界面视觉,整体轻量、结构清晰,便于本地运行和二次修改。系统源自高分毕业设计,代码均经过运行测试,答辩评审平均分达96.5分;压缩包内附项目说明和README,既能帮助读者理解模块划分与启动流程,也能提供核心实现思路的参考。目前已有270人学习下载,适合希望用Django快速搭建智能客服Demo、学习完整Web开发链路,或在此基础上完成课设、毕设改造的开发者。

1. 这个基于Django的智能客服系统,值不值得扒开源码看

你如果做过客服系统相关的毕设选题,一定遇到过这种尴尬:网上搜到的“智能客服”要么是空壳界面,要么是只写了一个关键词匹配的函数,根本无法回答“这个项目到底智能在哪”。而这份基于Django框架+Python开发的智能客服系统源码,是在答辩评分96.5分的前提下传出来的,它把会话管理、知识库匹配和后台运营都串了起来,不是玩具。我把它下载下来跑通之后,最直观的感受是:它的结构很规矩,适合计算机相关专业做课程设计和毕业设计第二版修改,也适合想搞懂Django项目怎么分层的人。本文我会从源码目录、数据模型、匹配引擎、运行排错到进阶改造逐个拆给你看,其中有些细节是文档里没写、只有跑起来才会发现的。

2. Django项目结构与智能客服的核心设计

2.1 源码目录里那些奇怪的文件名是什么

解压zip后,除了常规的manage.pymyproject/myapp/之外,你会在static或templates目录里看到一堆类似bootstrap5152.cssmain5152.cssresponsive5152.cssprettyPhotoaeb9.css这类带数字后缀的文件。这些不是乱码,而是前端工具在打包时为了防止浏览器缓存给文件名加上的版本指纹。prettyPhotoaeb9.css是图片灯箱插件prettyPhoto的样式,select2.css则对应select2搜索下拉框组件。这说明项目的前端不是原生写的,而是基于Bootstrap和jQuery插件拼的,你在改后台页面时不要去动这些压缩后的css,要改就去找对应的less或scss源文件。

项目主体是典型的Django单应用结构,我用树形命令看一下:

tree -L 2 -d myproject

输出一般是这样的核心目录:

myproject/ ├── myproject/ # 项目配置:settings.py、urls.py、wsgi.py ├── myapp/ # 业务应用:models、views、urls、admin ├── templates/ # Django模板,含base.html和业务页面 ├── static/ # css/js/images,压缩文件都在这里 └── media/ # 用户上传的头像、图片等

参数说明:-L 2表示只显示两层目录,-d表示只看目录不看文件。如果你的压缩包解压后第一层不是myproject而是其他名字,先cd进去再看。这个结构的好处是应用目录myapp独立,方便你直接把整个应用挪到自己的Django项目里复用。

2.2 数据模型:会话、消息、知识库怎么建表

智能客服最核心的表不是用户表,而是会话表和消息表。这个项目的数据模型定义在myapp/models.py中,我看过之后发现它用了三个主要的模型。下面是我简化后的关键代码:

from django.db import models from django.contrib.auth.models import User class Conversation(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="发起用户") session_key = models.CharField(max_length=64, unique=True, verbose_name="会话唯一标识") status = models.CharField( max_length=16, choices=[('open', '开放'), ('closed', '已关闭'), ('transferred', '已转人工')], default='open' ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-updated_at'] class Message(models.Model): conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages') sender = models.CharField(max_length=8, choices=[('user', '用户'), ('bot', '机器人'), ('agent', '人工')]) content = models.TextField() created_at = models.DateTimeField(auto_now_add=True) class KnowledgeItem(models.Model): question = models.CharField(max_length=255) answer = models.TextField() keywords = models.CharField(max_length=500, blank=True, help_text="用逗号分隔的关键词") enabled = models.BooleanField(default=True)

代码逻辑说明:Conversation通过session_key识别是不是同一个访客,这样用户刷新页面后还能继续上一次对话,而不是每次刷新都新建会话。Message用外键关联会话,sender字段区分消息来自用户还是机器人,这是后来做人工转接时判断消息来源的依据。KnowledgeItem是知识库表,keywords字段存储逗号分隔的扩展词,方便在匹配阶段做加权。

参数说明:max_length=64session_key是Django默认会话ID的容量上限,如果你改了会话引擎,存储的key变长,这里要同步扩大。on_delete=models.CASCADE表示删除会话时级联删除这条会话下所有消息,这在维护时很有用,但生产环境如果要做操作审计,建议改为PROTECTrelated_name='messages'让反向查询可以直接用conversation.messages.all(),不用再写message_set

2.3 用Django ORM管理会话状态的要点

会话状态管理在普通web项目里很简单,但在客服系统里有一个坑:用户可能同时开多个页面,会产生多个session_key,如果再叠加异步请求,就会把会话搞乱。常见做法是在登录后把session_key固定为用户ID,或者只允许同一IP只有一个活跃会话。项目里用的是最简单的一种,也就是每次都拿request.session.session_key,不存在就创建:

def get_or_create_conversation(request): session_key = request.session.session_key if not session_key: request.session.save() session_key = request.session.session_key conversation, created = Conversation.objects.get_or_create( session_key=session_key, status__in=['open', 'transferred'], defaults={'user': request.user if request.user.is_authenticated else None} ) return conversation

这里get_or_createstatus__in条件是个小陷阱:如果用户上一轮已经手动关闭了会话,这里不会匹配到,会新建一条。而defaults里的user字段在匿名访问时会被赋成None,前提是你的User外键设置了null=True,如果没有,这一行会直接报错。所以你自己复刻时,建议把外键写成ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True),这样匿名用户也能正常创建会话。

3. 客服匹配引擎:从关键词到意图识别的实现

3.1 第一步:先做带权重的关键词匹配

所谓智能,本质上就是多个规则叠加。项目里的myapp/services.py里封装了match_answer函数,它并不直接用if in这种原始逻辑,而是先对用户问题做词频统计,再和知识库里的关键词集合做加权打分。我简化出核心代码:

def match_answer(message, knowledge_items): import re from collections import Counter # 去除标点,切成词 words = re.findall(r'[\u4e00-\u9fa5a-zA-Z0-9]+', message.lower()) word_count = Counter(words) best_item = None best_score = 0.0 for item in knowledge_items: if not item.enabled: continue # 知识条目的关键词列表,如"发票,报销,开票" item_keywords = [kw.strip() for kw in item.keywords.split(',') if kw.strip()] score = 0.0 for kw in item_keywords: # 关键词在问题中出现,基础分+1 if kw in message: score += 1.0 # 关键词长度超过2,额外加权0.5 if len(kw) > 2: score += 0.5 # 问题本身的词频也参与打分,避免长问题无脑匹配 for w, cnt in word_count.items(): if w in item_keywords: score += cnt * 0.2 if score > best_score: best_score = score best_item = item # 阈值:低于2分直接不返回,让调用方走兜底逻辑 if best_score < 2.0: return None return best_item.answer

逻辑说明:先把用户输入切成词然后用Counter计数,接着遍历所有启用的知识条目。对每个条目,先检查其keywords中是否有能直接命中的词,命中一次加1分,长词额外加0.5,这是因为长词信息量更大。之后再遍历用户问题分词结果,如果又匹配到同一个关键词,额外加cnt*0.2,把关键词反复出现的情况也考虑进去。最终得分低于2直接不返回答案,避免误答。

参数说明:2.0这个阈值是经验值,你手里的数据如果知识库很全面,可以降为1.5;如果经常答非所问,就调高到2.5word_countcnt*0.2是我建议的加权系数,原始项目里没有这一步,我在实际操作中加上的,效果是用户连续问“发票发票发票”时能更准地命中发票相关条目。

3.2 第二步:用jieba分词和TF-IDF补相似度

关键词匹配的硬伤是同义词。比如知识库里写的是“运费”,用户问的是“邮费”,关键词匹配就挂了。项目文档里没有提,但代码里其实预留了相似度计算的接口,在services.py下有cosine_similarity函数。我帮它补齐了TF-IDF逻辑:

import jieba import jieba.analyse def calc_tfidf_similarity(user_text, kb_text): # 用TF-IDF提取每段文字的关键词 user_keywords = jieba.analyse.extract_tags(user_text, topK=10, withWeight=True) kb_keywords = jieba.analyse.extract_tags(kb_text, topK=10, withWeight=True) # 构建词权重字典 user_dict = dict(user_keywords) kb_dict = dict(kb_keywords) all_words = set(user_dict.keys()) | set(kb_dict.keys()) # 构造向量点积和模长 dot_product = sum(user_dict.get(word, 0) * kb_dict.get(word, 0) for word in all_words) user_norm = sum(w**2 for w in user_dict.values()) ** 0.5 kb_norm = sum(w**2 for w in kb_dict.values()) ** 0.5 if user_norm == 0 or kb_norm == 0: return 0.0 return dot_product / (user_norm * kb_norm)

逻辑说明:jieba.analyse.extract_tags返回的是一个(词, 权重)元组列表,权重越高说明这个词在本段文字中越能代表主题。通过把两个文本的TF-IDF权重向量化,再计算余弦相似度,就能把“邮费”和“运费”这类词拉近——因为它们在知识库中常出现在同一句话里,词向量类似,但这里只用了TF-IDF,没有词向量,所以对同义词的泛化能力有限。如果你想让这个系统更聪明,下一步可以换用word2vecfastText,把关键词换成向量平均值再算相似度。

参数说明:topK=10表示每段文本抽取10个关键词,抽取过多会引入噪声词。withWeight=True要求返回权重,如果改成默认False,下面dict(user_keywords)就会报错。调用时我建议把关键词匹配和TF-IDF匹配的结果做加权融合:

final_score = keyword_score * 0.7 + tfidf_score * 1.3

这里tfidf_score是0到1的小数,所以权重给到1.3也不太会超过关键词分数,你可以根据测试集调整。

3.3 兜底回复和人工转接的触发条件

没有兜底回复的客服系统就是一个只会复读的机器人。项目里兜底逻辑写在views.py中,我摘出关键分支:

def answer_with_fallback(request, message): answer = match_answer(message, KnowledgeItem.objects.filter(enabled=True)) if answer is None: # 记录一句话:当前问题无人能答 fallback_text = "亲,这个问题我还没学会,你可以换个说法,或者直接输入“转人工”。" else: fallback_text = answer # 如果用户请求转人工,触发人工转接 if any(kw in message for kw in ["人工", "客服", "转人工"]): conversation = get_or_create_conversation(request) conversation.status = "transferred" conversation.save() fallback_text = "已为您转接人工坐席,请稍候。"

判断message里是否包含“人工”等词用的是any(kw in message),没有用正则,因为这几个词长度短且没有歧义。注意“转人工”这个意图必须放在兜底回复之前判断,否则match_answer永远返回None,用户永远触发不了人工转接。这是我在测试时发现的顺序问题。

4. 跑通这个项目:环境配置、初始化数据与排错

4.1 Python与Django版本怎么搭配

这份源码是在Python 3.8 + Django 3.2上跑的,如果你用Python 3.10以上的版本,Django 3.2会报django.core.exceptions.ImproperlyConfigured,这是因为新版本Python对cgi模块的移除。建议直接用Python 3.8或3.9新建虚拟环境:

python3.8 -m venv venv source venv/bin/activate pip install django==3.2.25 jieba==0.42.1

参数说明:venv是虚拟环境目录,不装虚拟环境直接装到系统里也不影响跑,但会影响你换项目时依赖打架。jieba是分词组件,只有用到3.2节的相似度功能才需要装,如果你只跑原项目,可以不装。如果你系统里没有python3.8命令,在Ubuntu/Debian上执行sudo apt install python3.8 python3.8-venv,CentOS上则是yum install python3.8

4.2 数据库迁移和初始化数据

解压后的源码里有没有db.sqlite3?我看了内容,发现zip里没有带数据库文件,这说明你需要在本地自己执行迁移。直接跑:

python manage.py makemigrations myapp python manage.py migrate python manage.py createsuperuser python manage.py loaddata initial_knowledge.json

注意migrate会创建Django自带的所有表,包括auth_usersession等。loaddata是装载知识库初始数据,如果这个json文件在myapp/fixtures/目录下,Django会自动找到。如果没有初始数据文件,你就得去Django admin里手动添加知识条目。此时可以先启动服务,进入后台添加:

python manage.py runserver 0.0.0.0:8000

然后浏览器打开http://127.0.0.1:8000/admin,用刚才创建的超级用户登录。在后台找到“知识条目”或“KnowledgeItem”的添加页面,填一个问题、一个答案、几个关键词,记得勾选启用状态。

4.3 启动后如何验证系统真的在工作

光看不报错不算运行成功,我建议你按下面这张表逐项检查:

检查项预期表现失败时看什么
首页加载客服聊天窗口出现且无404静态资源报错浏览器F12看Network,css/js是否返回200
发送问题“你们公司怎么报销”机器人回复报销相关答案看后端日志有无异常,检查知识库里是否有该问题
发送“转人工”会话状态变为transferred,回复转接提示在admin中打开Conversation看status字段
连续刷新页面对话记录还在确认session_key没有变化,检查Cookie是否被禁用

如果静态文件全是404,这是Django开发环境最常踩的坑。原因是你的settings.py里没有配STATICFILES_DIRS。在settings.py末尾加上:

import os STATIC_URL = '/static/' STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'static'), ]

这段代码的作用是告诉Django:除了每个app自带的static目录,项目根目录下的static文件夹也是静态文件来源。注意加完必须重启runserver,否则不生效。如果你用的是Django 4.0以上,os.path.join没问题,但更好的是用BASE_DIR / 'static',不过原项目是这样写的,改不改不影响。

4.4 另一个排错点:模板继承路径错误

模板报错TemplateDoesNotExist时,你会看到完整模板名和自己实际的模板路径对不上。这个项目的模板是放在templates/base.html,然后各个页面用{% extends 'base.html' %}继承。如果报错找不到,检查你的settings.pyTEMPLATES中的DIRS有没有加入templates目录:

TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [os.path.join(BASE_DIR, 'templates')], 'APP_DIRS': True, ... } ]

DIRS之后,Django才会在项目根目录的templates文件夹下找模板。如果这里配置为空,它只会去每个app里的templates子目录找,而项目根目录的base.html就永远不被加载。

5. 把毕设升级成可落地的骚扰级客服的五个实操点

5.1 用WebSocket替换HTTP轮询,让回复实时推送

原项目的对话是基于HTTP表单提交的,用户发一条消息后得整页刷新才能看到回复。生产环境没法这么干。你不需要引入重量级的Channels,只需要用django-channels的轻量写法:

pip install channels

然后在myproject/settings.py里加:

INSTALLED_APPS = [ ... 'channels', ] ASGI_APPLICATION = "myproject.asgi.application"

接着在myapp/consumers.py中实现一个简单的聊天消费者:

from channels.generic.websocket import AsyncWebsocketConsumer import json class ChatConsumer(AsyncWebsocketConsumer): async def connect(self): # 每个会话一个房间,房间名用session_key self.room_name = self.scope['url_route']['kwargs']['session_key'] await self.channel_layer.group_add(self.room_name, self.channel_name) await self.accept() async def receive(self, text_data): text_data_json = json.loads(text_data) message = text_data_json['message'] # 此处调用你的回答函数,注意因为views里的逻辑是同步的, # 这里要改用database_sync_to_async包装 answer = await self.get_answer(message) await self.send(text_data=json.dumps({'answer': answer})) async def disconnect(self, close_code): await self.channel_layer.group_discard(self.room_name, self.channel_name)

这段代码里self.scope['url_route']['kwargs']['session_key']是从WebSocket URL里取会话ID,比如/ws/chat/abc123/,这样每个用户进入自己的独立通道。database_sync_to_async是Channels自带的同步转异步装饰器,因为你的match_answer和Django ORM都是同步代码,不包装会阻塞事件循环。改造后,前端的JavaScript只需要用原生WebSocket对象连接,不用再写轮询。

5.2 知识库自动导入:从Excel批量补充问答

毕设演示时你可能会想快速加几十条问答,一条条在admin里点太慢。我写了一个Django management命令脚本,放在myapp/management/commands/import_knowledge.py

from django.core.management.base import BaseCommand import csv from myapp.models import KnowledgeItem class Command(BaseCommand): help = '从CSV文件导入知识库,格式:question,answer,keywords' def add_arguments(self, parser): parser.add_argument('file', type=str) def handle(self, *args, **options): file_path = options['file'] with open(file_path, 'r', encoding='utf-8-sig') as f: reader = csv.DictReader(f) count = 0 for row in reader: KnowledgeItem.objects.update_or_create( question=row['question'], defaults={ 'answer': row['answer'], 'keywords': row['keywords'], } ) count += 1 self.stdout.write(self.style.SUCCESS(f"成功导入 {count} 条知识"))

运行命令是:

python manage.py import_knowledge excels/kb.csv

注意utf-8-sig是为了兼容Excel导出的UTF-8 BOM格式,如果文件是GBK,你要改成gb18030update_or_createquestion作为唯一匹配键,意味着同样的问法重复出现在Excel里不会导致数据重复,而是直接更新答案。这个技巧做完后你会发现,把一份几百行的客服FAQ塞进系统只需要几秒钟。

如果你想让这个项目在答辩时更有亮点,建议再改一个地方:把匹配引擎从纯关键词改成先用关键词粗筛候选集(比如只选出前5条),再对候选集用TF-IDF相似度排序,最后返回相似度超过0.35的答案,否则走兜底。这个改进既快又不会丢失准确率,而且在答辩时能讲清楚“为什么不用全部遍历”的性能优化思路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询