5.文字聊天、SSE、记忆、工具调用与 RAG

5.文字聊天、SSE、记忆、工具调用与 RAG

Lionlnuc
Lionlnuc Lv4

文字聊天、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 数

2.3 为什么要保存 input?

保存 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
backend/.env

内容示例:

1
2
API_KEY="你的 API Key"
API_BASE="https://dashscope.aliyuncs.com/compatible-mode/v1"

在 settings.py 中加载:

1
2
3
from dotenv import load_dotenv

load_dotenv()

3.3 不要提交 .env

.gitignore:

1
*.env

如果已经被 Git 跟踪:

1
git rm --cached -f backend/.env

四、LangGraph 聊天图

4.1 基础图结构

1
START → agent → END

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 os
from typing import TypedDict, Annotated, Sequence

from langchain_core.messages import BaseMessage
from langchain_openai import ChatOpenAI
from langgraph.constants import START, END
from langgraph.graph import add_messages, StateGraph


class 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:

1
请求 → 等模型完整生成 → 一次性返回

SSE:

1
请求 → 模型生成一个片段 → 推给前端 → 继续生成 → 继续推送

优点:

  • 用户可以立刻看到回复;
  • 适合大模型“打字机式”输出;
  • 基于 HTTP,比 WebSocket 更轻量。

5.2 SSE 数据格式

1
2
3
data: {"content": "你好"}\n\n
data: {"content": "呀"}\n\n
data: [DONE]\n\n

每段数据必须:

  • 以 data: 开头;
  • 以两个换行结尾。

5.3 DRF SSE Renderer

DRF 默认会尝试把返回内容渲染成 JSON,因此需要定义一个简单渲染器。

1
2
3
4
5
6
7
8
9
from rest_framework.renderers import BaseRenderer


class 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 json
from django.http import StreamingHttpResponse
from langchain_core.messages import HumanMessage, BaseMessageChunk
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated

from web.models.friend import Friend, Message
from web.views.friend.message.chat.graph import ChatGraph


class 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()
}

6.3 InputField.vue 发送消息

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 APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from web.models.friend import Message


class 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 顶部加载更多

逻辑:

  1. 顶部放一个哨兵;
  2. 哨兵进入聊天滚动容器可视区域;
  3. 请求更早的消息;
  4. 使用 unshift 插入到历史最前面;
  5. 保持滚动位置,避免加载后视图跳动。

关键位置保持:

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 SystemMessage


def 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, AIMessage


def 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'),
)
# START -> agent -> END

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.1 什么是 Tool Calling?

Tool Calling 允许大模型在需要外部能力时调用函数,例如:

  • 查询当前时间;
  • 查询天气;
  • 访问知识库;
  • 调用业务数据库;
  • 执行计算。

10.2 get_time 工具

1
2
3
4
5
6
7
8
from langchain_core.tools import tool
from 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 ToolNode

llm = 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 Embeddings
from openai import OpenAI


class 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 TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import LanceDB
import lancedb


def 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 回复主要是服务端向客户端推送。


十三、易错点总结

  1. SSE 每条消息要以 data: 开头,并以 \n\n 结束;
  2. 流结束要发送 data: [DONE]\n\n;
  3. StreamingHttpResponse 要设置 content_type='text/event-stream';
  4. Nginx 部署时要关闭缓冲:X-Accel-Buffering: no;
  5. 前端解析 [DONE] 时不要再 JSON.parse;
  6. handlePushFrontMessage 用 unshift,不是 shift;
  7. 加载历史时要用 last_message_id,避免和前端临时消息错位;
  8. 聊天气泡头像要用 :src,不要写成字符串 src="character.photo";
  9. 工具函数必须写清楚 docstring;
  10. .env 和知识库数据不应提交到 Git。

十四、小结

本节实现了 AgentTalk 的核心 AI 聊天能力:

  • 后端用 LangGraph 封装对话流程;
  • 使用 SSE 实现大模型流式输出;
  • 前端使用 fetch-event-source 接收流;
  • 消息保存到 Message 表;
  • 历史记录支持向上滚动加载;
  • 系统提示词、短期历史、长期记忆共同构成上下文;
  • Tool Calling 让模型能调用外部函数;
  • RAG 让模型能查阅项目私有知识库。

十五、新手版:一次聊天到底发生了什么

用户在聊天框输入一句话,例如:

1
你好,你是谁?

完整流程是:

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 像点外卖:

1
下单 → 等所有菜做好 → 一次性送到

SSE 像服务员不断上菜:

1
先上一盘凉菜 → 再上一盘热菜 → 再上一碗汤 → 最后说上完了

大模型聊天适合 SSE,因为模型生成文本需要时间。如果等完整结果一次性返回,用户会觉得卡住了。

SSE 每次返回一小段:

1
2
3
data: {"content": "你"}\n\n
data: {"content": "好"}\n\n
data: [DONE]\n\n

十七、跟做步骤:实现文字聊天

17.1 后端步骤

  1. 创建 Message 模型;
  2. 执行数据库迁移;
  3. 安装 LangGraph 和 LangChain;
  4. 配置 .env 保存 API Key;
  5. 写 graph.py 封装大模型调用;
  6. 写 chat.py 接收前端消息;
  7. 使用 StreamingHttpResponse 返回 SSE;
  8. 在 urls.py 中添加聊天路由;
  9. 发送一条消息测试。

17.2 前端步骤

  1. 创建 ChatField.vue;
  2. 创建 InputField.vue;
  3. 创建 ChatHistory.vue;
  4. 创建 Message.vue;
  5. 安装 @microsoft/fetch-event-source;
  6. 写 streamApi.js;
  7. 用户发送消息时先更新本地 history;
  8. 流式接收后端内容并追加显示。

17.3 如何验证

打开浏览器 Network:

  1. 点击角色卡片;
  2. 发送消息;
  3. 找到 /api/friend/message/chat/ 请求;
  4. 查看 Response 是否持续出现 data:;
  5. 最后是否出现 [DONE];
  6. 数据库 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 和 RAG

21.1 Tool Calling

Tool Calling 可以理解为 AI 会“请外援”。

比如用户问:

1
现在几点?

大模型自己不一定知道当前时间,于是调用工具:

1
2
def get_time():
return 当前时间

拿到结果后再组织语言回答。

21.2 RAG

RAG 可以理解为“开卷考试”。

普通大模型只靠训练时学到的知识回答。

RAG 的流程是:

1
2
3
4
5
6
7
用户提问
↓
先去知识库找相关资料
↓
把资料和问题一起交给大模型
↓
大模型基于资料回答

适合解决:

  • 模型知识过时;
  • 项目私有文档模型不知道;
  • 需要答案有依据。

二十二、常见报错排查

22.1 前端收不到流式数据

检查:

  1. 后端是否使用 StreamingHttpResponse;
  2. 响应头是否是 text/event-stream;
  3. 每条数据是否以 data: 开头;
  4. 每条数据是否以 \n\n 结尾;
  5. 前端是否使用 fetchEventSource;
  6. 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 回复没有上下文

检查:

  1. 是否调用了 add_system_prompt;
  2. 是否调用了 add_recent_message;
  3. Message 表是否真的有历史记录;
  4. 拼接后的 inputs['messages'] 是否正确。

22.5 工具调用不生效

检查:

  1. 工具函数是否加了 @tool;
  2. 工具函数是否有清晰 docstring;
  3. 是否执行了 .bind_tools(tools);
  4. LangGraph 是否加了 ToolNode;
  5. 条件边是否能进入 tools 节点。

二十三、本节复习问题

  1. 为什么聊天接口要使用 SSE?
  2. yield 在流式响应中起什么作用?
  3. 前端为什么要先插入一条空 AI 消息?
  4. Friend 和 Message 的关系是什么?
  5. 系统提示词由哪些内容组成?
  6. 短期记忆和长期记忆有什么区别?
  7. Tool Calling 解决了什么问题?
  8. RAG 为什么像“开卷考试”?
  9. 为什么知识库需要先做 Embedding?
  10. 如果聊天一直 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/json
Accept: 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 读取或写入别人的聊天记录。这是权限安全检查。

  • 标题: 5.文字聊天、SSE、记忆、工具调用与 RAG
  • 作者: Lionlnuc
  • 创建于 : 2026-07-12 00:00:00
  • 更新于 : 2026-07-12 00:00:00
  • 链接: https://lionlnuc.github.io/2026/07/12/AgentTalk/5. 文字聊天/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论
目录
5.文字聊天、SSE、记忆、工具调用与 RAG