项目实战Ollama使用大模型

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_VRAMOLLAMA_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

  1. Ollama 监听内网,禁用公网访问
  2. Nginx 配置 HTTPS + SSE 透传
  3. 对话记录脱敏后入库(避免敏感信息泄漏)
  4. 模型版本锁定(Modelfile指定具体tag)
  5. 磁盘预留50GB(模型+向量库+日志)
  6. Grafana 阈值告警(显存、响应时间、错误率)

总结

从Ollama原生API到完整落地一个企业智能问答项目,关键不在"能不能跑起来",而在工程化:流式传输、上下文管理、知识库切分、并发控制、监控埋点。把这些细节处理好,本地大模型就能真正在生产环境稳定运行,而不再只是个玩具。