文字聊天、SSE、记忆、工具调用与 RAG 一、目标 本节实现 AI 角色的文字聊天能力,并逐步扩展为具备上下文、记忆、工具调用和知识库检索的 Agent。
核心内容包括:
消息数据库 Message;
LangGraph 对话流程;
SSE 流式输出;
前端流式请求封装;
聊天历史加载;
系统提示词;
短期记忆;
长期记忆;
Function Calling / Tool Calling;
LanceDB + Embedding + RAG。
二、聊天数据模型 2.1 Friend 与 Message 的关系 1 2 UserProfile 1 ─── n Friend n ─── 1 Character Friend 1 ─── n Message
含义:
一个用户可以和多个角色建立好友关系;
一个角色也可以被多个用户聊天;
每个 Friend 保存某个用户与某个角色之间的独立聊天记录;
Message 归属于某个 Friend。
2.2 Message 模型 1 2 3 4 5 6 7 8 9 class Message (models.Model): friend = models.ForeignKey(Friend, on_delete=models.CASCADE) user_message = models.TextField(max_length=500 ) input = models.TextField(max_length=10000 ) output = models.TextField(max_length=500 ) input_tokens = models.IntegerField(default=0 ) output_tokens = models.IntegerField(default=0 ) total_tokens = models.IntegerField(default=0 ) create_time = models.DateTimeField(default=now)
字段说明:
字段
说明
friend
当前对话属于哪段用户-角色关系
user_message
用户原始输入
input
实际传给大模型的上下文,包括系统提示词、历史消息等
output
AI 回复
input_tokens
输入 token 数
output_tokens
输出 token 数
total_tokens
总 token 数
保存 input 便于:
调试模型为什么这样回答;
分析提示词是否正确;
排查上下文记忆是否拼接错误;
做 token 成本统计。
三、安装依赖与环境变量 3.1 后端依赖 1 pip install langgraph langchain langchain-openai python-dotenv
后续 RAG 还需要:
1 pip install lancedb langchain-community tantivy ipython
3.2 环境变量 创建:
内容示例:
1 2 API_KEY="你的 API Key" API_BASE="https://dashscope.aliyuncs.com/compatible-mode/v1"
在 settings.py 中加载:
1 2 3 from dotenv import load_dotenvload_dotenv()
3.3 不要提交 .env .gitignore:
如果已经被 Git 跟踪:
1 git rm --cached -f backend/.env
四、LangGraph 聊天图 4.1 基础图结构
agent 节点负责调用大模型。
4.2 graph.py 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 35 36 37 import osfrom typing import TypedDict, Annotated, Sequence from langchain_core.messages import BaseMessagefrom langchain_openai import ChatOpenAIfrom langgraph.constants import START, ENDfrom langgraph.graph import add_messages, StateGraphclass ChatGraph : @staticmethod def create_app (): llm = ChatOpenAI( model='deepseek-v3.2' , openai_api_key=os.getenv('API_KEY' ), openai_api_base=os.getenv('API_BASE' ), streaming=True , model_kwargs={ 'stream_options' : { 'include_usage' : True , } }, ) class AgentState (TypedDict ): messages: Annotated[Sequence [BaseMessage], add_messages] def model_call (state: AgentState ) -> AgentState: res = llm.invoke(state['messages' ]) return {'messages' : [res]} graph = StateGraph(AgentState) graph.add_node('agent' , model_call) graph.add_edge(START, 'agent' ) graph.add_edge('agent' , END) return graph.compile ()
4.3 add_messages 的作用 add_messages 表示当节点返回新消息时,不覆盖旧消息,而是追加到消息列表后面。
五、SSE 流式聊天接口 5.1 为什么需要 SSE? 普通 HTTP:
SSE:
1 请求 → 模型生成一个片段 → 推给前端 → 继续生成 → 继续推送
优点:
用户可以立刻看到回复;
适合大模型“打字机式”输出;
基于 HTTP,比 WebSocket 更轻量。
5.2 SSE 数据格式 1 2 3 data: {"content": "你好"}\n\n data: {"content": "呀"}\n\n data: [DONE]\n\n
每段数据必须:
5.3 DRF SSE Renderer DRF 默认会尝试把返回内容渲染成 JSON,因此需要定义一个简单渲染器。
1 2 3 4 5 6 7 8 9 from rest_framework.renderers import BaseRendererclass SSERenderer (BaseRenderer ): media_type = 'text/event-stream' format = 'txt' def render (self, data, accepted_media_type=None , renderer_context=None ): return data
5.4 chat.py 核心结构 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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 import jsonfrom django.http import StreamingHttpResponsefrom langchain_core.messages import HumanMessage, BaseMessageChunkfrom rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework.permissions import IsAuthenticatedfrom web.models.friend import Friend, Messagefrom web.views.friend.message.chat.graph import ChatGraphclass MessageChatView (APIView ): permission_classes = [IsAuthenticated] renderer_classes = [SSERenderer] def post (self, request ): friend_id = request.data['friend_id' ] message = request.data['message' ].strip() if not message: return Response({'result' : '消息不得为空' }) friends = Friend.objects.filter (pk=friend_id, me__user=request.user) if not friends.exists(): return Response({'result' : '好友不存在' }) friend = friends.first() app = ChatGraph.create_app() inputs = {'messages' : [HumanMessage(message)]} def event_stream (): full_output = '' full_usage = {} for msg, metadata in app.stream(inputs, stream_mode='messages' ): if isinstance (msg, BaseMessageChunk): if msg.content: full_output += msg.content yield f"data: {json.dumps({'content' : msg.content} , ensure_ascii=False)}\n\n" if hasattr (msg, 'usage_metadata' ) and msg.usage_metadata: full_usage = msg.usage_metadata yield 'data: [DONE]\n\n' Message.objects.create( friend=friend, user_message=message[:500 ], input =json.dumps([m.model_dump() for m in inputs['messages' ]], ensure_ascii=False )[:10000 ], output=full_output[:500 ], input_tokens=full_usage.get('input_tokens' , 0 ), output_tokens=full_usage.get('output_tokens' , 0 ), total_tokens=full_usage.get('total_tokens' , 0 ), ) response = StreamingHttpResponse(event_stream(), content_type='text/event-stream' ) response['Cache-Control' ] = 'no-cache' response['X-Accel-Buffering' ] = 'no' return response
5.5 yield 的作用 yield 会让函数变成生成器,每次 yield 一段内容给客户端,函数暂停;下一次继续从暂停处执行。
这非常适合 SSE:
1 2 3 4 def event_stream (): yield 'data: 第一段\n\n' yield 'data: 第二段\n\n' yield 'data: [DONE]\n\n'
六、前端流式请求封装 6.1 安装依赖 1 npm install @microsoft/fetch-event-source
6.2 streamApi.js 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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 import { fetchEventSource } from '@microsoft/fetch-event-source' import { useUserStore } from '@/stores/user.js' import api from './api.js' const BASE_URL = 'http://127.0.0.1:8000' export default async function streamApi (url, options = {} ) { const userStore = useUserStore () const startFetch = async ( ) => { return await fetchEventSource (BASE_URL + url, { method : options.method || 'POST' , headers : { 'Content-Type' : 'application/json' , Authorization : `Bearer ${userStore.accessToken} ` , ...options.headers , }, body : JSON .stringify (options.body || {}), openWhenHidden : true , async onopen (response ) { if (response.status === 401 ) { await api.post ('/api/user/account/refresh_token/' , {}) throw new Error ('TOKEN_REFRESHED' ) } if (!response.ok || !response.headers .get ('content-type' )?.includes ('text/event-stream' )) { throw new Error (`请求失败: ${response.status} ` ) } }, onmessage (msg ) { if (msg.data === '[DONE]' ) { options.onmessage ?.('' , true ) return } const json = JSON .parse (msg.data ) options.onmessage ?.(json, false ) }, onerror (err ) { if (err.message === 'TOKEN_REFRESHED' ) { return startFetch () } options.onerror ?.(err) throw err }, }) } return await startFetch () }
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 35 36 const emit = defineEmits (['pushBackMessage' , 'addToLastMessage' ])let isProcessing = false async function handleSend ( ) { if (isProcessing) return const content = message.value .trim () if (!content) return isProcessing = true message.value = '' emit ('pushBackMessage' , { role : 'user' , content, id : crypto.randomUUID () }) emit ('pushBackMessage' , { role : 'ai' , content : '' , id : crypto.randomUUID () }) try { await streamApi ('/api/friend/message/chat/' , { body : { friend_id : props.friendID , message : content, }, onmessage (data, isDone ) { if (isDone) { isProcessing = false } else if (data.content ) { emit ('addToLastMessage' , data.content ) } }, onerror ( ) { isProcessing = false }, }) } catch (err) { isProcessing = false } }
七、聊天历史加载 7.1 后端接口 1 GET /api/friend/message/get_history/?friend_id=1&last_message_id=0
为什么不用 items_count?
因为当前 AI 回复可能还没写入数据库,前端会比后端多一条正在流式生成的消息。使用 last_message_id 更稳定。
7.2 get_history.py 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 from rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework.permissions import IsAuthenticatedfrom web.models.friend import Messageclass GetHistoryView (APIView ): permission_classes = [IsAuthenticated] def get (self, request ): last_message_id = int (request.query_params.get('last_message_id' , 0 )) friend_id = int (request.query_params.get('friend_id' )) queryset = Message.objects.filter (friend_id=friend_id, friend__me__user=request.user) if last_message_id > 0 : queryset = queryset.filter (pk__lt=last_message_id) message_raw = queryset.order_by('-id' )[:10 ] messages = [] for m in message_raw: messages.append({ 'id' : m.id , 'user_message' : m.user_message, 'output' : m.output, }) return Response({'result' : 'success' , 'messages' : messages})
7.3 前端 ChatField 状态 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const history = ref ([])const chatHistoryRef = useTemplateRef ('chat-history-ref' )function handlePushBackMessage (msg ) { history.value .push (msg) chatHistoryRef.value ?.scrollToBottom () } function handleAddToLastMessage (delta ) { history.value .at (-1 ).content += delta chatHistoryRef.value ?.scrollToBottom () } function handlePushFrontMessage (msg ) { history.value .unshift (msg) }
7.4 ChatHistory 顶部加载更多 逻辑:
顶部放一个哨兵;
哨兵进入聊天滚动容器可视区域;
请求更早的消息;
使用 unshift 插入到历史最前面;
保持滚动位置,避免加载后视图跳动。
关键位置保持:
1 2 3 4 5 6 7 const oldHeight = scrollRef.value .scrollHeight const oldTop = scrollRef.value .scrollTop await nextTick ()scrollRef.value .scrollTop = oldTop + scrollRef.value .scrollHeight - oldHeight
八、系统提示词与短期记忆 8.1 SystemPrompt 模型 1 2 3 4 5 6 class SystemPrompt (models.Model): title = models.CharField(max_length=100 ) order_number = models.IntegerField(default=0 ) prompts = models.TextField(max_length=10000 ) create_time = models.DateTimeField(default=now) update_time = models.DateTimeField(default=now)
好处:
可以在后台直接调整提示词;
不需要修改代码;
多条提示词可按 order_number 拼接。
8.2 添加系统提示词 1 2 3 4 5 6 7 8 9 10 11 12 13 14 from langchain_core.messages import SystemMessagedef add_system_prompt (state, friend ): msgs = state['messages' ] system_prompts = SystemPrompt.objects.filter (title='回复' ).order_by('order_number' ) prompt = '' for sp in system_prompts: prompt += sp.prompts prompt += f'\n【角色性格】\n{friend.character.profile} \n' prompt += f'\n【长期记忆】\n{friend.memory} \n' return {'messages' : [SystemMessage(prompt)] + msgs}
8.3 添加近期对话 1 2 3 4 5 6 7 8 9 10 11 12 13 14 from langchain_core.messages import HumanMessage, AIMessagedef add_recent_message (state, friend ): msgs = state['messages' ] message_raw = list (Message.objects.filter (friend=friend).order_by('-id' )[:10 ]) message_raw.reverse() messages = [] for m in message_raw: messages.append(HumanMessage(m.user_message)) messages.append(AIMessage(m.output)) return {'messages' : msgs[:1 ] + messages + msgs[-1 :]}
结构:
1 2 3 4 5 6 SystemMessage 历史用户消息 1 历史 AI 回复 1 历史用户消息 2 历史 AI 回复 2 当前用户消息
九、长期记忆 9.1 为什么需要长期记忆? 短期记忆只能放最近几轮对话,受 token 限制。长期记忆用于摘要用户偏好和重要信息,保存在 Friend.memory 字段中。
9.2 MemoryGraph 1 2 3 4 5 6 7 8 9 class MemoryGraph : @staticmethod def create_app (): llm = ChatOpenAI( model='deepseek-v3.2' , openai_api_key=os.getenv('API_KEY' ), openai_api_base=os.getenv('API_BASE' ), )
9.3 更新记忆 1 2 3 4 5 6 7 8 9 10 11 12 def update_memory (friend ): app = MemoryGraph.create_app() inputs = { 'messages' : [ create_system_message(), create_human_message(friend), ] } res = app.invoke(inputs) friend.memory = res['messages' ][-1 ].content friend.update_time = now() friend.save()
9.4 触发时机 1 2 if Message.objects.filter (friend=friend).count() % 10 == 0 : update_memory(friend)
建议生产环境中将记忆更新放入后台任务,避免阻塞当前聊天请求。
Tool Calling 允许大模型在需要外部能力时调用函数,例如:
查询当前时间;
查询天气;
访问知识库;
调用业务数据库;
执行计算。
10.2 get_time 工具 1 2 3 4 5 6 7 8 from langchain_core.tools import toolfrom django.utils.timezone import localtime, now@tool def get_time () -> str : """当需要查询精确时间时,调用此函数,返回格式为:[年-月-日 时:分:秒]""" return localtime(now()).strftime('%Y-%m-%d %H:%M:%S' )
工具函数必须有清晰 docstring,因为模型会根据描述判断何时调用。
10.3 LangGraph 工具节点 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from langgraph.prebuilt import ToolNodellm = ChatOpenAI(...).bind_tools(tools) tool_node = ToolNode(tools) graph.add_node('agent' , model_call) graph.add_node('tools' , tool_node) def should_continue (state ): last_message = state['messages' ][-1 ] if last_message.tool_calls: return 'tools' return 'end' graph.add_conditional_edges('agent' , should_continue, { 'tools' : 'tools' , 'end' : END, }) graph.add_edge('tools' , 'agent' )
流程:
1 2 3 4 5 6 7 8 9 用户问题 ↓ agent 判断是否需要工具 ↓ 是 ToolNode 执行工具 ↓ agent 基于工具结果生成回答 ↓ END
十一、RAG 知识库 11.1 RAG 是什么? RAG(Retrieval-Augmented Generation)即检索增强生成。
流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 原始文档 ↓ 切分 Chunk Embedding 向量化 ↓ 存入向量数据库 ↓ 用户提问 ↓ 检索相似文档片段 ↓ 把片段交给大模型 ↓ 生成有依据的回答
11.2 安装 LanceDB 1 pip install lancedb langchain-community tantivy
11.3 自定义 Embedding 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from langchain_core.embeddings import Embeddingsfrom openai import OpenAIclass CustomEmbeddings (Embeddings ): def __init__ (self ): import os self .client = OpenAI( api_key=os.getenv('API_KEY' ), base_url=os.getenv('API_BASE' ), ) def embed_documents (self, texts ): response = self .client.embeddings.create( model='text-embedding-v4' , input =texts, dimensions=1024 , ) return [data.embedding for data in response.data] def embed_query (self, text ): return self .embed_documents([text])[0 ]
11.4 插入文档 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 from langchain_community.document_loaders import TextLoaderfrom langchain_text_splitters import RecursiveCharacterTextSplitterfrom langchain_community.vectorstores import LanceDBimport lancedbdef insert_documents (): loader = TextLoader('./web/documents/data.txt' , encoding='utf-8' ) documents = loader.load() text_splitter = RecursiveCharacterTextSplitter(chunk_size=500 , chunk_overlap=100 ) texts = text_splitter.split_documents(documents) embeddings = CustomEmbeddings() db = lancedb.connect('./web/documents/lancedb_storage' ) LanceDB.from_documents( documents=texts, embedding=embeddings, connection=db, table_name='my_knowledge_base' , mode='overwrite' , )
11.5 知识库检索工具 1 2 3 4 5 6 7 8 9 10 11 12 13 @tool def search_knowledge_base (query: str ) -> str : """当用户查询项目知识库相关信息时,调用此函数,输入为查询问题,输出为查询结果。""" db = lancedb.connect('./web/documents/lancedb_storage' ) embeddings = CustomEmbeddings() vector_db = LanceDB( connection=db, embedding=embeddings, table_name='my_knowledge_base' , ) docs = vector_db.similarity_search(query, k=3 ) context = '\n\n' .join([f'内容片段 {i + 1 } :\n{doc.page_content} ' for i, doc in enumerate (docs)]) return f'从知识库中找到以下相关信息:\n\n{context} '
十二、SSE 与 WebSocket 对比
对比项
SSE
WebSocket
通信方向
服务器到客户端单向
双向
协议
HTTP
独立协议升级
自动重连
浏览器原生支持
需要自己实现
复杂度
低
较高
适合场景
大模型流式文本、通知
即时通讯、实时游戏
本项目文字流式输出使用 SSE 更合适,因为 AI 回复主要是服务端向客户端推送。
十三、易错点总结
SSE 每条消息要以 data: 开头,并以 \n\n 结束;
流结束要发送 data: [DONE]\n\n;
StreamingHttpResponse 要设置 content_type='text/event-stream';
Nginx 部署时要关闭缓冲:X-Accel-Buffering: no;
前端解析 [DONE] 时不要再 JSON.parse;
handlePushFrontMessage 用 unshift,不是 shift;
加载历史时要用 last_message_id,避免和前端临时消息错位;
聊天气泡头像要用 :src,不要写成字符串 src="character.photo";
工具函数必须写清楚 docstring;
.env 和知识库数据不应提交到 Git。
十四、小结 本节实现了 AgentTalk 的核心 AI 聊天能力:
后端用 LangGraph 封装对话流程;
使用 SSE 实现大模型流式输出;
前端使用 fetch-event-source 接收流;
消息保存到 Message 表;
历史记录支持向上滚动加载;
系统提示词、短期历史、长期记忆共同构成上下文;
Tool Calling 让模型能调用外部函数;
RAG 让模型能查阅项目私有知识库。
十五、新手版:一次聊天到底发生了什么 用户在聊天框输入一句话,例如:
完整流程是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 前端 InputField ↓ 把用户消息加入 history ↓ 再加入一条空的 AI 消息 ↓ 调用 streamApi 发送 POST 请求 ↓ 后端 MessageChatView 接收 friend_id 和 message ↓ 检查这个 friend 是否属于当前用户 ↓ 拼接系统提示词、角色人设、历史消息、长期记忆 ↓ 交给 LangGraph ↓ LangGraph 调用大模型 ↓ 大模型一边生成,后端一边 yield SSE ↓ 前端每收到一个片段,就追加到最后一条 AI 消息 ↓ 生成结束后,后端保存 Message 到数据库
新手要特别注意:
前端先显示用户消息,是为了让界面立刻响应;
AI 消息一开始是空字符串;
后端每返回一点内容,前端就把它追加到最后一条 AI 消息;
最后数据库保存的是完整的一轮对话。
十六、用生活类比理解 SSE 普通 HTTP 像点外卖:
SSE 像服务员不断上菜:
1 先上一盘凉菜 → 再上一盘热菜 → 再上一碗汤 → 最后说上完了
大模型聊天适合 SSE,因为模型生成文本需要时间。如果等完整结果一次性返回,用户会觉得卡住了。
SSE 每次返回一小段:
1 2 3 data: {"content": "你"}\n\n data: {"content": "好"}\n\n data: [DONE]\n\n
十七、跟做步骤:实现文字聊天 17.1 后端步骤
创建 Message 模型;
执行数据库迁移;
安装 LangGraph 和 LangChain;
配置 .env 保存 API Key;
写 graph.py 封装大模型调用;
写 chat.py 接收前端消息;
使用 StreamingHttpResponse 返回 SSE;
在 urls.py 中添加聊天路由;
发送一条消息测试。
17.2 前端步骤
创建 ChatField.vue;
创建 InputField.vue;
创建 ChatHistory.vue;
创建 Message.vue;
安装 @microsoft/fetch-event-source;
写 streamApi.js;
用户发送消息时先更新本地 history;
流式接收后端内容并追加显示。
17.3 如何验证 打开浏览器 Network:
点击角色卡片;
发送消息;
找到 /api/friend/message/chat/ 请求;
查看 Response 是否持续出现 data:;
最后是否出现 [DONE];
数据库 Message 表是否新增记录。
十八、新手重点:为什么要有 Friend 如果只用 Character 保存聊天记录,会出现问题:
1 2 用户 A 和角色小明聊天 用户 B 也和角色小明聊天
如果没有 Friend,两个人的聊天记录就可能混在一起。
所以需要:
1 2 用户 A + 角色小明 = Friend 1 用户 B + 角色小明 = Friend 2
每个 Friend 都有自己的:
十九、新手重点:系统提示词是什么 系统提示词就是给 AI 的“角色说明书”。
例如:
1 你是一个温柔、耐心的 AI 助手。你说话要简洁,避免攻击性语言。
在本项目中,系统提示词由几部分组成:
1 2 3 4 5 数据库中的通用提示词 + 角色人设 profile + 长期记忆 friend.memory
这样同一个大模型就可以表现成不同角色。
二十、新手重点:短期记忆和长期记忆 20.1 短期记忆 短期记忆就是最近几轮聊天。
作用:让 AI 知道刚刚聊了什么。
缺点:不能无限放,因为 token 有限制。
20.2 长期记忆 长期记忆是对多轮聊天的总结。
例如:
1 用户喜欢 Python,正在学习 Django 和 Vue,对语音模块比较感兴趣。
长期记忆保存在 Friend.memory 中,下次聊天还可以继续使用。
20.3 类比
短期记忆:你刚刚说过的话;
长期记忆:我对你长期偏好的总结。
Tool Calling 可以理解为 AI 会“请外援”。
比如用户问:
大模型自己不一定知道当前时间,于是调用工具:
1 2 def get_time (): return 当前时间
拿到结果后再组织语言回答。
21.2 RAG RAG 可以理解为“开卷考试”。
普通大模型只靠训练时学到的知识回答。
RAG 的流程是:
1 2 3 4 5 6 7 用户提问 ↓ 先去知识库找相关资料 ↓ 把资料和问题一起交给大模型 ↓ 大模型基于资料回答
适合解决:
模型知识过时;
项目私有文档模型不知道;
需要答案有依据。
二十二、常见报错排查 22.1 前端收不到流式数据 检查:
后端是否使用 StreamingHttpResponse;
响应头是否是 text/event-stream;
每条数据是否以 data: 开头;
每条数据是否以 \n\n 结尾;
前端是否使用 fetchEventSource;
Network 中 Response 是否持续变化。
22.2 前端 JSON.parse 报错 常见原因:收到 [DONE] 后还尝试 JSON.parse('[DONE]')。
正确逻辑:
1 2 3 4 if (msg.data === '[DONE]' ) { options.onmessage ('' , true ) return }
22.3 聊天记录顺序反了 后端通常按 -id 取最近 10 条,取出后需要反转成从旧到新。
1 2 message_raw = list (Message.objects.filter (friend=friend).order_by('-id' )[:10 ]) message_raw.reverse()
22.4 AI 回复没有上下文 检查:
是否调用了 add_system_prompt;
是否调用了 add_recent_message;
Message 表是否真的有历史记录;
拼接后的 inputs['messages'] 是否正确。
22.5 工具调用不生效 检查:
工具函数是否加了 @tool;
工具函数是否有清晰 docstring;
是否执行了 .bind_tools(tools);
LangGraph 是否加了 ToolNode;
条件边是否能进入 tools 节点。
二十三、本节复习问题
为什么聊天接口要使用 SSE?
yield 在流式响应中起什么作用?
前端为什么要先插入一条空 AI 消息?
Friend 和 Message 的关系是什么?
系统提示词由哪些内容组成?
短期记忆和长期记忆有什么区别?
Tool Calling 解决了什么问题?
RAG 为什么像“开卷考试”?
为什么知识库需要先做 Embedding?
如果聊天一直 401,应该检查哪些地方?
二十四、核心接口完整请求 / 响应示例 24.1 加载聊天历史接口 1 2 GET /api/friend/message/history/?friend_id=5&items_count=0 Authorization : Bearer <access_token>
成功响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { "result" : "success" , "messages" : [ { "id" : 101 , "role" : "user" , "content" : "你好" , "created_at" : "2026-07-05 10:00:00" } , { "id" : 102 , "role" : "assistant" , "content" : "你好,我是你的 AI 助手。" , "created_at" : "2026-07-05 10:00:02" } ] , "has_more" : true }
24.2 发送文字聊天接口 1 2 3 4 POST /api/friend/message/chat/ Authorization : Bearer <access_token>Content-Type : application/jsonAccept : text/event-stream
请求体:
1 2 3 4 { "friend_id" : 5 , "message" : "请解释一下 Django 的模型是什么" }
SSE 响应示例:
1 2 3 4 5 6 7 data: {"content": "Django"} data: {"content": " 的模型"} data: {"content": "可以理解为数据库表的 Python 表示。"} data: [DONE]
24.3 更新长期记忆接口 如果项目中把记忆更新拆成独立接口,可以设计为:
1 2 3 POST /api/friend/memory/update/ Authorization : Bearer <access_token>Content-Type : application/json
请求体:
1 2 3 4 { "friend_id" : 5 , "memory" : "用户正在学习 Django 和 Vue,喜欢通俗解释。" }
成功响应:
1 2 3 4 { "result" : "success" , "memory" : "用户正在学习 Django 和 Vue,喜欢通俗解释。" }
二十五、关键代码逐行解释示例 25.1 后端 SSE 返回 1 2 3 4 5 6 def event_stream (): for chunk in graph.stream(inputs): content = chunk.get('content' , '' ) if content: yield f"data: {json.dumps({'content' : content} )}\n\n" yield "data: [DONE]\n\n"
逐行解释:
代码
新手解释
def event_stream():
定义一个生成器函数,用来一点一点返回数据
for chunk in graph.stream(inputs):
从大模型流程中不断取出新生成的片段
content = chunk.get(...)
取出当前片段中的文本内容
if content:
空内容不发送,避免前端收到无意义数据
yield f"data: ...\n\n"
按 SSE 格式返回一段数据
yield "data: [DONE]\n\n"
告诉前端本次流式输出结束
25.2 前端接收 SSE 1 2 3 4 5 6 7 8 9 10 fetchEventSource (url, { method : 'POST' , headers, body : JSON .stringify (data), onmessage (event ) { if (event.data === '[DONE]' ) return const payload = JSON .parse (event.data ) history.value [history.value .length - 1 ].content += payload.content }, })
逐行解释:
代码
新手解释
fetchEventSource(url, {...})
发起一个能接收 SSE 的请求
method: 'POST'
聊天消息通过 POST 提交
headers
携带 Token 和 Content-Type
body
把 friend_id 和 message 转成 JSON 字符串
onmessage(event)
每收到一段 SSE 数据就执行一次
event.data === '[DONE]'
如果后端说结束,就不再解析 JSON
JSON.parse(event.data)
把后端返回的字符串转成对象
+= payload.content
把新片段追加到最后一条 AI 消息
二十六、Vue 组件数据传递图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 Character.vue │ 点击角色卡片 ↓ Friend API 返回 friend_id ↓ ChatField.vue │ 保存当前 friend 和聊天框状态 ├── ChatHistory.vue │ │ 显示 messages │ └── Message.vue │ 显示单条 user / assistant 气泡 │ └── InputField.vue │ 用户输入文字 ↓ emit send-message ↓ ChatField.vue 更新 history ↓ streamApi.js ↓ 后端 MessageChatView
二十七、后端聊天视图处理流程 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 收到 POST /message/chat/ ↓ IsAuthenticated 检查登录 ↓ 读取 friend_id 和 message ↓ 查询 Friend,必须满足 user=request.user ↓ 保存用户消息或准备保存 ↓ 读取角色 profile、系统提示词、短期历史、长期记忆 ↓ 组装 messages 输入 ↓ 调用 LangGraph ↓ 不断 yield SSE 文本片段 ↓ 模型输出结束 ↓ 保存 assistant 完整回复 ↓ 必要时更新长期记忆 ↓ 发送 [DONE]
二十八、小练习与自测答案 练习 1 问题:为什么流式聊天的响应不是普通 JSON?
答案:普通 JSON 需要等全部内容生成完才能返回。SSE 可以边生成边返回,让用户更快看到回复。
练习 2 问题:为什么前端把 AI 消息放在 history 最后一项?
答案:后端流式返回的是一段一段文本。前端每收到一段,都追加到最后一条 AI 消息中,最终拼成完整回复。
练习 3 问题:为什么后端查询 Friend 时必须限制当前用户?
答案:防止用户传入别人的 friend_id 读取或写入别人的聊天记录。这是权限安全检查。