Ollama已经把"本地运行大模型"这件事变得非常简单,但当它真正落地到一个完整项目里时,仍然有许多工程细节需要处理:如何设计后端服务、如何让前端优雅地拿到流式回复、如何把私有知识库接入、如何在生产环境做并发与监控。本文以一个"企业内部智能问答助手"项目为蓝本,完整走一遍从需求到上线的实战流程。
一、项目背景与需求
1. 业务场景
公司内部文档分散在Wiki、Confluence、共享盘多个地方,新员工常常不知道去哪找资料。我们希望搭建一个智能问答助手,员工提问后系统能基于内部文档给出可信答案,并给出引用来源。
2. 核心需求
- 问答能力:基于本地大模型进行多轮对话,回答控制在5秒内首字输出
- 知识库:接入企业内部文档,答案需附带引用来源
- 多用户:支持并发会话,每个用户独立的上下文
- 数据安全:模型与数据完全在内网运行,禁止外传
- 可观测:记录问答日志、Token消耗、响应耗时
二、技术选型与架构设计
1. 技术选型
| 模块 | 技术 | 说明 |
|---|---|---|
| 本地模型 | Ollama + Qwen2.5 14B | 中文能力强,14B兼顾效果与速度 |
| 后端服务 | Node.js + Express | 轻量、流式响应友好 |
| 向量化 | bge-m3 (Ollama) | 中文向量模型,效果好 |
| 向量数据库 | Chroma | 本地部署,易上手 |
| 前端 | Vue3 + Vite | 组件化,SSE接收流式回复 |
| 日志监控 | Prometheus + Grafana | 统计Token、QPS、响应耗时 |
2. 整体架构
用户浏览器
│ SSE
▼
[Nginx 反向代理] ──► [Node.js 服务]
│
┌─────────────┼─────────────┐
▼ ▼ ▼
[Ollama API] [Chroma] [业务数据库]
(Chat+Embed) (向量检索) (会话/日志)
三、环境搭建
1. Ollama准备
提前规划好GPU资源,推荐配置:
- 对话模型:
qwen2.5:14b(约9GB显存) - Embedding模型:
bge-m3(约1.2GB显存) - 设置
OLLAMA_MAX_VRAM与OLLAMA_NUM_PARALLEL=4提升吞吐
# 拉取模型
ollama pull qwen2.5:14b
ollama pull bge-m3
# 修改监听地址,允许内网访问
export OLLAMA_HOST=0.0.0.0:11434
systemctl restart ollama
2. 项目初始化
mkdir ai-assistant && cd ai-assistant
npm init -y
npm install express cors axios chromadb dotenv
npm install -D nodemon
目录结构:
ai-assistant/
├─ server/
│ ├─ app.js # Express入口
│ ├─ routes/chat.js # 对话路由
│ ├─ routes/knowledge.js # 知识库管理
│ ├─ lib/ollama.js # Ollama封装
│ ├─ lib/vector.js # 向量库封装
│ └─ lib/prompt.js # 提示词模板
├─ web/ # Vue前端
└─ .env
四、后端实现
1. Ollama调用封装
// server/lib/ollama.js
const axios = require('axios');
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
// 流式对话
async function chatStream({ model, messages, onToken }) {
const res = await axios({
method: 'post',
url: `${OLLAMA_URL}/api/chat`,
data: { model, messages, stream: true },
responseType: 'stream',
});
for await (const chunk of res.data) {
const text = chunk.toString();
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
const obj = JSON.parse(line);
if (obj.message?.content) {
onToken(obj.message.content);
}
if (obj.done) return;
}
}
}
// 向量化
async function embed(text) {
const { data } = await axios.post(`${OLLAMA_URL}/api/embeddings`, {
model: 'bge-m3',
prompt: text,
});
return data.embedding;
}
module.exports = { chatStream, embed };
2. 流式对话路由
使用 SSE(Server-Sent Events)把 Ollama 的流式输出透传给前端:
// server/routes/chat.js
const express = require('express');
const router = express.Router();
const { chatStream } = require('../lib/ollama');
const { retrieveContext } = require('../lib/vector');
const { buildPrompt } = require('../lib/prompt');
router.post('/stream', async (req, res) => {
const { sessionId, question } = req.body;
// SSE 头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// 1. 检索知识库
const context = await retrieveContext(question);
// 2. 组装提示词
const messages = buildPrompt(question, context);
// 3. 调用模型并流式输出
await chatStream({
model: 'qwen2.5:14b',
messages,
onToken: (token) => {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
},
});
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
} catch (err) {
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
}
});
module.exports = router;
3. 知识库向量检索
// server/lib/vector.js
const { ChromaClient } = require('chromadb');
const { embed } = require('./ollama');
const client = new ChromaClient({ path: 'http://localhost:8000' });
let collection;
async function getCollection() {
if (!collection) {
collection = await client.getOrCreateCollection({ name: 'docs' });
}
return collection;
}
// 文档入库
async function indexDocument(id, text, metadata = {}) {
const col = await getCollection();
const embedding = await embed(text);
await col.add({ ids: [id], embeddings: [embedding], documents: [text], metadatas: [metadata] });
}
// 检索
async function retrieveContext(query, topK = 3) {
const col = await getCollection();
const queryEmbedding = await embed(query);
const result = await col.query({ queryEmbeddings: [queryEmbedding], nResults: topK });
return result.documents[0].map((doc, i) => ({
content: doc,
source: result.metadatas[0][i],
}));
}
module.exports = { indexDocument, retrieveContext };
4. 提示词模板
// server/lib/prompt.js
function buildPrompt(question, context = []) {
const contextText = context
.map((c, i) => `[${i + 1}] 来源:${c.source?.title || '未知'}\n${c.content}`)
.join('\n\n');
return [
{
role: 'system',
content: `你是企业内部知识助手。请根据下方参考资料回答问题,并在答案末尾标注引用序号,如 [1]。
如果资料中没有答案,请直接说明"暂无相关资料",不要编造。
参考资料:
${contextText}`,
},
{ role: 'user', content: question },
];
}
module.exports = { buildPrompt };
五、前端实现
1. Vue对话组件
<template>
<div class="chat">
<div v-for="m in messages" :key="m.id" class="msg" :class="m.role">
<strong>{{ m.role === 'user' ? '我' : '助手' }}</strong>
<div v-html="renderMarkdown(m.content)"></div>
</div>
<div v-if="loading" class="typing">助手正在输入…</div>
<textarea v-model="input" @keydown.enter.exact.prevent="send" placeholder="提问…"></textarea>
</div>
</template>
<script setup>
import { ref } from 'vue';
const messages = ref([]);
const input = ref('');
const loading = ref(false);
async function send() {
const text = input.value.trim();
if (!text) return;
messages.value.push({ id: Date.now(), role: 'user', content: text });
input.value = '';
loading.value = true;
const assistantMsg = { id: Date.now() + 1, role: 'assistant', content: '' };
messages.value.push(assistantMsg);
const res = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: 'demo', question: text }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop();
for (const line of lines) {
const data = line.replace(/^data: /, '');
if (!data) continue;
const obj = JSON.parse(data);
if (obj.token) assistantMsg.content += obj.token;
if (obj.done) loading.value = false;
}
}
}
function renderMarkdown(text) {
// 简单渲染:转义 + 换行,可换 marked
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/\[(\d+)\]/g, '<sup>[$1]</sup>')
.replace(/\n/g, '<br>');
}
</script>
六、RAG知识库落地
1. 文档切分
长文档直接做向量化效果差,按"语义块"切分。推荐做法:
- 按标题层级切分(H1/H2/H3)
- 每块300-500字
- 块之间保留50字重叠,避免切断上下文
2. 入库脚本
// server/scripts/ingest.js
const fs = require('fs');
const path = require('path');
const { indexDocument } = require('../lib/vector');
function chunkText(text, size = 400, overlap = 50) {
const chunks = [];
for (let i = 0; i < text.length; i += size - overlap) {
chunks.push(text.slice(i, i + size));
if (i + size >= text.length) break;
}
return chunks;
}
async function ingestDir(dir) {
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
for (const file of files) {
const text = fs.readFileSync(path.join(dir, file), 'utf-8');
const chunks = chunkText(text);
for (let i = 0; i < chunks.length; i++) {
await indexDocument(`${file}-${i}`, chunks[i], { title: file, chunk: i });
}
console.log(`${file} 已入库 ${chunks.length} 块`);
}
}
ingestDir('./docs').then(() => process.exit(0));
七、性能与并发优化
1. Ollama侧
- 并发数:
OLLAMA_NUM_PARALLEL=4,按GPU显存调整 - 常驻显存:
OLLAMA_KEEP_ALIVE=30m,避免频繁加载 - 上下文长度:
num_ctx=8192,通过 Modelfile 设置
2. 服务侧
- 请求队列:当并发超过Ollama承载能力时入队列,避免雪崩
- 会话缓存:历史上下文存Redis,不必每次重传
- 答案缓存:相同问题直接返回缓存结果
八、监控与日志
1. 埋点指标
- 首字耗时(TTFT)
- 整轮回答耗时
- 输入/输出Token数
- 检索命中率
2. 日志结构
{
"sessionId": "abc123",
"question": "如何申请年假?",
"contextCount": 3,
"tokensIn": 820,
"tokensOut": 156,
"ttft": 480,
"totalTime": 3200,
"model": "qwen2.5:14b"
}
九、常见坑与排查
| 现象 | 原因 | 解决 |
|---|---|---|
| 首字很慢(>5秒) | 模型未常驻显存 | 设置 OLLAMA_KEEP_ALIVE |
| 回答胡编乱造 | 提示词未约束 | System Prompt强调"无资料则说不知道" |
| SSE被Nginx缓冲 | 默认proxy_buffering on | 加 proxy_buffering off |
| 显存溢出 | 并发太多 | 降低 OLLAMA_NUM_PARALLEL 或换小模型 |
| 引用编号错乱 | 前端没渲染sup | 用 marked + 自定义renderer |
十、上线Checklist
- Ollama 监听内网,禁用公网访问
- Nginx 配置 HTTPS + SSE 透传
- 对话记录脱敏后入库(避免敏感信息泄漏)
- 模型版本锁定(Modelfile指定具体tag)
- 磁盘预留50GB(模型+向量库+日志)
- Grafana 阈值告警(显存、响应时间、错误率)
总结
从Ollama原生API到完整落地一个企业智能问答项目,关键不在"能不能跑起来",而在工程化:流式传输、上下文管理、知识库切分、并发控制、监控埋点。把这些细节处理好,本地大模型就能真正在生产环境稳定运行,而不再只是个玩具。