给本地的AI大模型增加模型降级、查询历史、RAG定制文档等功能

实现过程

我现在大模型的pod里已经下载了一个llama 70B这个模型权重文件,这个权重文件非常大,大约要40G左右。我这个测试机不是GPU而是一个普通的CPU服务器(32核128G内存),所以当我向AI提问的时候,它需要把这个llama 70B从磁盘里读出来,加载到这128G的内存里,然后CPU在内存拿到数据然后得出答案返回,这一步还是耗时很久的,于是我这次加上如下功能:

  1. 如果llama 70B在300秒不能返回,就降级使用qwen 2.5 7B模型,这个qwen 2.5 7B模型比 70B 模型小好多,不到5GB。当然答案质量也会下降。
  2. llama 70B尽量常驻在内存里,反正我这个服务器有128G内存呢,剩余70G内存也够用了。就不用来回加载折腾了。
  3. 在web页面上增加一个搜索功能,这样我可以查询到历史的询问记录,不用鼠标来回滚轮一个一个去翻找曾经的对话记录了。因为只是内部使用,我这里选型用了sqlite3,非常轻量,生产环境的话可以改用mysql,去单独再给mysql起一个pod。
  4. 我们内部有一些定制的运维文档,让AI可以搜索到这些文档,可以根据历史的文档更有针对性的给出建议。

这里多说一下RAG,RAG是AI增加知识面的一个手段,方法比较简单,就是“把内容让AI能够搜到,AI本身并不背知识,而是通过‘翻书’,然后用自己的语言来把‘翻书’的结果来透出”。

于是乎,我的backend.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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
from flask import Flask, render_template, request, jsonify
import requests
import sqlite3
import datetime
import os
import urllib.parse
import re

app = Flask(__name__)

# --- 配置中心 ---
DB_PATH = "/app/data/chat_history.db"
KNOWLEDGE_DIR = "/app/data/knowledge/" # 这个文档是专门用来放内部文档的。
PRIMARY_MODEL = "llama3:70b"
BACKUP_MODEL = "qwen2.5:7b"
OLLAMA_API = "http://ollama-svc:11434/api/generate"


def init_db():
with sqlite3.connect(DB_PATH) as conn:
conn.execute('''CREATE TABLE IF NOT EXISTS history
(id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt TEXT, response TEXT, model TEXT, time TEXT)''')
conn.execute("CREATE INDEX IF NOT EXISTS idx_prompt ON history(prompt)")

# RAG 检索逻辑
def get_relevant_context_v2(user_query, top_n=2):
"""
全目录动态扫描:支持万级文件,自动计算相关性评分
"""
context = ""
if not os.path.exists(KNOWLEDGE_DIR):
print(f" [DEBUG] 路径不存在: {KNOWLEDGE_DIR}")
return context

# 预处理用户提问:转小写并提取关键词
query_words = set(re.findall(r'\w+', user_query.lower())) # 比如我问的是“TNS抽帧预案”,这里会拆成“TNS”、“抽帧”、“预案”这些关键词
if not query_words:
return context

scored_docs = []
try:
for filename in os.listdir(KNOWLEDGE_DIR):
if filename.endswith(".txt") or filename.endswith(".md"):
file_path = os.path.join(KNOWLEDGE_DIR, filename) # 遍历目录下所有文件,在这里死磕
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
content_lower = content.lower()
file_lower = filename.lower()

# 评分逻辑:文件名命中权重为 5,内容命中权重为 1
score = sum(5 for word in query_words if word in file_lower) # 每当题目提到了关键词,就加1分
score += sum(1 for word in query_words if word in content_lower) # 每当文章内容提到了关键词,就加1分,分数越高说明这个文件是需要的东西。

if score > 0:
scored_docs.append({
"filename": filename,
"content": content[:1500], # 限制长度,防止模型上下文溢出
"score": score
})
except Exception as e:
print(f" [DEBUG] 检索异常: {e}")

# 按分数降序排列,取最相关的 top_n 个文档
scored_docs.sort(key=lambda x: x['score'], reverse=True) # 然后对分数进行排序。
selected_docs = scored_docs[:top_n]

if selected_docs:
context = "\n".join([f"--- 参考文档: {d['filename']} ---\n{d['content']}" for d in selected_docs]) # 因为AI上下文有限,就给他top N的文档然后弄成一个字符串,AI通过这个字符串组织语言回答你。
print(f" [DEBUG] RAG 命中 {len(selected_docs)} 个文档,最高分: {selected_docs[0]['score']}")

return context


@app.route('/')
def index():
return render_template('index.html')

@app.route('/aigpt_api')
def aigpt_api():
raw_prompt = request.args.get('prompt', '')
user_prompt = urllib.parse.unquote(raw_prompt)

if not user_prompt:
return jsonify({"response": "内容不能为空"})

# 调用 V2 自动化检索
knowledge_context = get_relevant_context_v2(user_prompt)

# 构造增强 Prompt
if knowledge_context:
final_prompt = (
f"你是一位资深 SRE 专家。请严格参考以下内部文档回答用户问题。\n\n"
f"【内部参考信息】:\n{knowledge_context}\n\n"
f"【用户问题】: {user_prompt}\n"
f"请用中文详细回答。"
)
else:
# 如果没搜到,就按通用 AI 回答
final_prompt = f"你是一位资深 SRE 专家,请用中文回答:{user_prompt}"

# 模型请求逻辑(包含降级策略)
final_response = ""
used_model = ""

for model in [PRIMARY_MODEL, BACKUP_MODEL]:
try:
print(f" [DEBUG] 尝试调用模型: {model}")
r = requests.post(OLLAMA_API, json={
"model": model,
"prompt": final_prompt,
"stream": False,
"keep_alive": -1
}, timeout=(5, 300))

if r.status_code == 200:
used_model = model
final_response = r.json().get('response', '')
break
except Exception as e:
print(f" [DEBUG] 模型 {model} 异常: {e}")
continue

if not used_model:
return jsonify({"response": "后端推理服务连接失败"}), 500

# 结果持久化
try:
with sqlite3.connect(DB_PATH) as conn:
conn.execute("INSERT INTO history (prompt, response, model, time) VALUES (?, ?, ?, ?)",
(user_prompt, final_response, used_model,
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
except:
pass

# 加上前端标识
tag = "🚀 [70B+RAG]" if used_model == PRIMARY_MODEL else "💡 [Qwen]"
return jsonify({"response": f"{tag}\n\n{final_response}"})

@app.route('/search')
def search():
query = request.args.get('q', '')
with sqlite3.connect(DB_PATH) as conn:
cursor = conn.execute("SELECT prompt, response, time FROM history WHERE prompt LIKE ? OR response LIKE ? ORDER BY id DESC",
(f'%{query}%', f'%{query}%'))
results = [{"prompt": r[0], "response": r[1], "time": r[2]} for r in cursor.fetchall()]
return jsonify(results)

if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000)

Dockerfile如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Dockerfile 
FROM docker.m.daocloud.io/library/python:3.9-slim

RUN pip install --no-cache-dir flask prometheus_client requests
RUN mkdir -p /app/data

# 创建工作目录
WORKDIR /app


# 把当前目录下所有东西(包含 templates 文件夹)都拷贝进去
COPY . .

# 运行 app
CMD ["python", "backend.py"]

启动的 ai-pipeline.yaml如下:

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
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-backend
spec:
replicas: 1 # 这里建议是1,因为sqlite3能力比较烂,不能接受多写,比如A在查询并让sqlite3写入,B这时候就不能写入了,所以生产环境就是要用mysql,自己玩的环境就用sqlite3,然后1个pod确保不会多个写入源。
selector:
matchLabels:
app: ai-backend
template:
metadata:
labels:
app: ai-backend
spec:
containers:
- name: backend
image: ai-backend:v11
imagePullPolicy: Never
ports:
- containerPort: 5000
- containerPort: 8000
volumeMounts:
- name: data-storage #这里外挂一个路径
mountPath: /app/data
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "1"
memory: "2Gi"
volumes:
- name: data-storage
persistentVolumeClaim:
claimName: ollama-storage-pvc


---
apiVersion: v1
kind: Service
metadata:
name: ai-backend-svc
spec:
selector:
app: ai-backend
ports:
- protocol: TCP
port: 80
targetPort: 5000

对应的ollama-pvc.yaml如下:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ollama-storage-pvc
spec:
storageClassName: local-path
accessModes:
- ReadWriteOnce # 单节点读写,适合这种模型推理场景
resources:
requests:
storage: 50Gi

对应的前端index.html如下:

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SRE AIOps 智能终端 - 70B/Qwen HA 架构</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
/* 滚轮样式优化 (仅 Webkit 内核) */
#chat-box::-webkit-scrollbar, #search-results::-webkit-scrollbar { width: 4px; }
#chat-box::-webkit-scrollbar-thumb, #search-results::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }

/* AI 思考中气泡的闪烁效果 */
@keyframes blink { 50% { opacity: 0.5; } }
.blinking { animation: blink 1s linear infinite; }
</style>
</head>
<body class="bg-slate-100 h-screen flex flex-col font-sans overflow-hidden">

<header class="bg-slate-900 text-white p-3 shadow-xl flex items-center justify-between">
<div class="flex items-center space-x-3">
<h1 class="font-mono text-xl tracking-wider text-green-400">AIOPS-CORE >_</h1>
</div>
<div class="text-xs text-slate-400 font-mono">
Host: K3s Cluster | PVC: 500GB Ready | Node: 32C/128G
</div>
</header>

<div class="flex-1 flex overflow-hidden">

<div class="flex-1 flex flex-col bg-slate-50 border-r border-slate-200">
<div id="chat-box" class="flex-1 overflow-y-auto p-6 space-y-4">
<div class="flex justify-start">
<div class="bg-white p-3 rounded-lg shadow-sm border border-slate-200 max-w-[80%]">
你好,大哥!**Llama3 70B** 和 **Qwen** 已就绪,500G 盘已就绪。请输入运维指令或开始对话:
</div>
</div>
</div>

<div class="p-4 bg-white border-t border-slate-300">
<div class="max-w-4xl mx-auto flex space-x-2">
<input id="user-input" type="text"
class="flex-1 border-2 border-slate-200 rounded-lg px-4 py-2 focus:border-blue-500 outline-none transition"
placeholder="输入运维问题、K8S日志、架构需求..."
onkeyup="if(event.keyCode==13) send()"> <button onclick="send()"
class="bg-blue-600 text-white px-8 py-2 rounded-lg font-bold hover:bg-blue-700 transition active:scale-95">
发送
</button>
</div>
</div>
</div>

<aside class="w-80 flex flex-col p-4 bg-white border-l border-slate-300">

<div class="mb-6 p-4 border border-slate-200 rounded-lg bg-slate-50">
<h3 class="font-bold text-slate-700 mb-3 text-sm">📡 模型状态 (KeepAlive: -1)</h3>
<div class="space-y-2 text-sm font-mono">
<div class="flex items-center justify-between">
<span>Llama3-70B (Primary)</span>
<div id="status-70b" class="w-3 h-3 rounded-full bg-green-500 blinking shadow-md"></div>
</div>
<div class="flex items-center justify-between">
<span>Qwen-7B (Fallback)</span>
<div id="status-qwen" class="w-3 h-3 rounded-full bg-slate-300"></div>
</div>
</div>
</div>

<div class="flex-1 flex flex-col">
<h3 class="font-bold text-slate-700 mb-3 text-sm">🔍 历史全文本检索 (SQLite)</h3>
<div class="flex space-x-1 mb-2">
<input id="search-input" type="text"
class="flex-1 text-sm border-2 border-slate-200 rounded px-2 py-1 focus:border-green-500 outline-none"
placeholder="搜索关键词 (如 PVC, Error)...">
<button onclick="searchHistory()"
class="bg-slate-700 text-white text-xs px-3 rounded hover:bg-black transition">
搜索
</button>
</div>
<div id="search-results" class="flex-1 overflow-y-auto space-y-2 text-xs">
</div>
</div>

</aside>

</div>

<script>
// 初始化 Marked.js 配置
marked.setOptions({
sanitize: true, // 启用消毒,防止 XSS
breaks: true // 启用换行符
});

// 统一添加消息气泡到聊天框
function appendMessage(role, content) {
const box = document.getElementById('chat-box');
const div = document.createElement('div');
// 用户在右,AI 在左
div.className = role === 'user' ? "flex justify-end" : "flex justify-start";

// AI 气泡带上不同背景色区分
const bgColor = role === 'user' ? "bg-blue-500 text-white" : "bg-white border border-slate-200";

div.innerHTML = `<div class="${bgColor} p-3 rounded-lg shadow-sm max-w-[80%] text-sm">
${role === 'user' ? content : marked.parse(content)}
</div>`;
box.appendChild(div);
// 自动滚动到底部
box.scrollTop = box.scrollHeight;
}

// 发送消息核心逻辑
async function send() {
const input = document.getElementById('user-input');
const prompt = input.value.trim();
if (!prompt) return;

// 1. 用户消息立即上墙
appendMessage('user', prompt);
input.value = ''; // 清空输入框

// 2. 创建一个“AI 思考中”的临时气泡
const box = document.getElementById('chat-box');
const aiMsg = document.createElement('div');
aiMsg.className = "flex justify-start italic text-slate-400 blinking";
aiMsg.innerHTML = `<div class="bg-slate-50 p-3 rounded-lg border border-dashed border-slate-300">AIOps 大脑正在思考中...</div>`;
box.appendChild(aiMsg);
box.scrollTop = box.scrollHeight;

try {
// 3. 请求 Flask 后端接口
const res = await fetch('/aigpt_api?prompt=' + encodeURIComponent(prompt));
const data = await res.json();

// 4. 移除“思考中”,渲染正式回复
box.removeChild(aiMsg);
// 此时 data.response 已经带上了 SRE 的标记标签
appendMessage('ai', data.response);

// 5. 更新状态灯(作为 SRE,咱们得实时监控)
updateStatusLights(data.response);

} catch (e) {
// 错误处理
aiMsg.innerHTML = `<div class="bg-red-50 text-red-600 p-3 rounded border border-red-300">❌ 连接后端异常或超时。</div>`;
aiMsg.classList.remove('blinking', 'text-slate-400');
}
}

// 全文本搜索逻辑
async function searchHistory() {
const q = document.getElementById('search-input').value.trim();
const resultsBox = document.getElementById('search-results');
if (!q) { alert("请输入搜索关键词"); return; }

resultsBox.innerHTML = `<div class="text-center text-slate-400 py-4">正在检索...</div>`;

try {
// 请求 Flask 搜索接口
const res = await fetch('/search?q=' + encodeURIComponent(q));
const data = await res.json();

// 清空旧结果,准备新渲染
resultsBox.innerHTML = '';
if (data.length === 0) {
resultsBox.innerHTML = `<div class="text-center text-slate-400 py-4">--- 未找到相关记录 ---</div>`;
return;
}

// 遍历结果,生成小卡片
data.forEach(item => {
const div = document.createElement('div');
div.className = "p-2 mb-2 bg-yellow-50 rounded border border-yellow-200 text-xs";
div.innerHTML = `<div class='text-slate-500 text-[10px] mb-1'>${item.time} | 搜索命中</div>
<b class="text-yellow-800">问:</b>${item.prompt}<br>
<b class="text-yellow-800">答:</b>${marked.parse(item.response)}`;
resultsBox.appendChild(div);
});

} catch (e) {
resultsBox.innerHTML = `<div class="text-center text-red-500 py-4">❌ 搜索接口故障</div>`;
}
}

// 状态灯更新逻辑
function updateStatusLights(response) {
const light70b = document.getElementById('status-70b');
const lightQwen = document.getElementById('status-qwen');

if (response.includes('[70B]')) {
// 70B 强力驱动
light70b.classList.add('bg-green-500', 'blinking');
light70b.classList.remove('bg-slate-300');
lightQwen.classList.add('bg-slate-300');
lightQwen.classList.remove('bg-green-500', 'blinking');
} else if (response.includes('[Qwen]')) {
// 70B 挂掉,Qwen 接管
light70b.classList.add('bg-slate-300');
light70b.classList.remove('bg-green-500', 'blinking');
lightQwen.classList.add('bg-green-500', 'blinking');
lightQwen.classList.remove('bg-slate-300');
}
}
</script>
</body>
</html>

AI对应的ollama-deployment.yaml如下:

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
# ollama-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama
spec:
replicas: 1
selector:
matchLabels:
app: ollama
template:
metadata:
labels:
app: ollama
spec:
containers:
- name: ollama
image: docker.m.daocloud.io/ollama/ollama:latest
ports:
- containerPort: 11434
volumeMounts:
- name: ollama-models
mountPath: /root/.ollama
resources:
requests:
cpu: "8"
memory: "42Gi"
limits:
cpu: "24"
memory: "64Gi"
volumes:
- name: ollama-models
persistentVolumeClaim:
claimName: ollama-storage-pvc
---
apiVersion: v1
kind: Service
metadata:
name: ollama-svc
spec:
selector:
app: ollama
ports:
- port: 11434
targetPort: 11434

然后打包部署即可。效果如下:

迭代时候 的一些坑

我有时候懒得升级镜像版本,比如一个版本是v10,我修改了backend.py之后,还是用v10这个版本去build镜像,我以为这样新的v10镜像就会替换旧的v10镜像,然后搭配kubectl rollout restart deployment XXX来滚动更新,但是后来发现更新了后还是没变化,后来通过kubectl get deployment -o wide发现,镜像其实没有更新。

也可以通过kubectl exec -it pod名称 -- cat 变化的文件路径来看是不是镜像真的发生了变化。

rollout restart的本质逻辑是:给deployment 的pod 模版增加一个时间戳。k8s发现模板多了一个戳发生了变化,就决定滚动更新。新的pod去拿镜像启动,但是发现本地的镜像里是我的ai-pipeline.yamlimagePullPolicy: Never的配置,这个配置导致k8s只会在本地找镜像,但是镜像名字和tag(v10)又没变,那么k8s再次启动pod的时候,它发现本地已经有一个v10的镜像了(Containerd 用 镜像 ID(digest) 区分镜像,而不是 tag。虽然 tag 还是 v10,但新构建的镜像 ID 已经变了,但 K3s 可能不会自动刷新),这样它就不去检查镜像的内容,而是直接复用旧的镜像层。所以等于没更新。

所以生产环境最稳妥的方法还是:

  1. 打包镜像然后修改tag成v11,或者加一个时间戳标签:docker build -t ai-backend:v10-$(date +%Y%m%d%H%M)
  2. 修改ai-pipeline.yaml里的镜像版本是v11。
  3. 通过执行kubectl apply -f ai-pipeline.yaml or kubectl set image deployment/ai-backend ai-backend=ai-backend:v10-202603211530来更新版本,这样出了问题直接回滚。
  4. 修改 imagePullPolicyAlways,K8s 每次都会去仓库拉最新的,即使 tag 相同。

所以养成好习惯,改了代码就一定要改版本号。

感谢你请我喝咖啡~

Welcome to my other publishing channels