周末肝了一波!DeepSeek+RAG+Function Calling 全栈 AI 助手保姆级实战(附源码+避坑指南)


写在前面

最近在折腾 AI 应用开发,发现网上的教程要么只讲概念没有代码,要么只贴代码不解释原理,而且几乎没人把 Function CallingRAG 放在一个项目里完整落地。

于是花了一个周末,从零搭了一个全栈 AI 助手,顺手把踩过的坑都记了下来。这篇文章就带你一步步复现。

📦 完整源码GitHub:lgx-ai(求 Star ⭐)

🧪 最终效果:一个带界面的 AI 助手,支持普通聊天 / 检索私有文档回答 / 调用天气 API 工具


一、这个项目能干什么

先看看最终效果,再决定要不要往下读:

模式能力应用场景
普通聊天多轮对话、流式输出通用问答
查询资料库(RAG)检索你的私有文档再回答项目文档问答、知识库搜索
工具调用(Function Calling)AI 主动调用天气 API、发通知智能助手、自动化 Agent

架构一览:

Vue3 前端(8080) ──proxy──▶ Express 后端(3000) ──┬── DeepSeek API(对话)
                                                 ├── ChromaDB + Ollama(RAG 检索)
                                                 └── 天气 API / 通知 (Function Calling)

下面正式开始,每一步都附带完整代码 + 原理解析 + 踩坑记录


二、环境准备:版本很关键!

在开始之前,先统一环境,版本不匹配是最大的坑

我的开发环境

依赖版本说明
Node.js20.19+ 或 22.12+前端 Vue 项目要求
openai^6.41.0DeepSeek 兼容 OpenAI SDK
express^5.2.1后端框架
chromadb^3.4.3向量数据库 Node SDK
@chroma-core/ollama^0.1.8Ollama embedding 桥接
Vue^3.5.32前端框架
ant-design-x-vue^1.6.0AI 对话 UI 组件库
vite^8.0.8构建工具
Python3.10+ChromaDB 服务端需要

外部服务

服务用途获取方式
DeepSeek API Key大模型调用platform.deepseek.com 注册即送
Ollama本地 embedding 模型ollama.com 下载安装
ChromaDB Server向量数据库Python 启动,见下方
天气 API(可选)Function Calling 示例tianqiapi.com 免费申请

初始化项目

# 根目录
npm init -y
npm install openai express readline-sync

# 前端
cd ai-web && npm install && cd ..

# ChromaDB 子项目
cd chromadb && npm install && cd ..

三、第一步:封装 DeepSeek API 调用

3.1 为什么选择 DeepSeek?

几个关键原因:

  • API 完全兼容 OpenAI SDK,迁移成本为零
  • 价格便宜,日常开发调试不心疼
  • 中文能力强,生成质量不输 GPT-4
  • 支持 Function Calling,工具调用开箱即用

3.2 初始化 OpenAI 客户端

// ai-learning/call.js
const { OpenAI } = require('openai');
const fs = require('fs');
const path = require('path');

// 从文件读取 API Key(不要硬编码!)
const apiKey = fs.readFileSync(
    path.join(__dirname, '..', 'api-key.txt'), 'utf-8'
).trim();

const openai = new OpenAI({
    baseURL: 'https://api.deepseek.com',  // 关键:指向 DeepSeek
    apiKey: apiKey,
});

⚠️ 踩坑记录 #1baseURL 一定不要漏掉 https://,否则会报 Connection error。另外不要加 /v1 后缀,SDK 自动拼接。

3.3 封装非流式调用

async function askDeepSeek(ask, messages = [], retries = 3) {
    if (messages.length === 0) {
        messages.push({ role: "user", content: ask });
    }

    try {
        const completion = await openai.chat.completions.create({
            messages: messages,
            model: "deepseek-v4-pro",
            thinking: { "type": "enabled" },     // 开启推理模式
            reasoning_effort: "high",             // 高推理强度
            stream: false,
        });
        return completion.choices[0].message.content;
    } catch (error) {
        console.error('API 调用失败:', error.message);
        if (retries > 0) {
            console.log(`重试中,剩余 ${retries - 1} 次...`);
            // 重试前等待,避免频率限制
            await new Promise(r => setTimeout(r, 1000));
            return await askDeepSeek(ask, messages, retries - 1);
        }
        return "抱歉,服务暂时不可用,请稍后再试。";
    }
}

💡 为什么要加重试机制? DeepSeek API 有并发限制,高频调用时偶尔返回 429/503。加 3 次重试 + 1 秒间隔可以有效提升稳定性。

3.4 封装流式调用(重点)

流式调用是实现"打字机效果"的核心,也是前后端通信的关键设计:

async function askDeepSeekStream(ask, msgRecCb, messages = [], retries = 3) {
    if (messages.length === 0) {
        messages.push({ role: "user", content: ask });
    }

    try {
        const stream = await openai.chat.completions.create({
            messages: messages,
            model: "deepseek-v4-pro",
            thinking: { "type": "disabled" },  // 流式模式建议关闭 thinking
            stream: true,
        });

        let fullContent = '';
        let reasoningContent = '';

        for await (const chunk of stream) {
            const delta = chunk.choices[0]?.delta;
            const finishReason = chunk.choices[0]?.finish_reason;

            // 普通文本内容
            if (delta?.content) {
                fullContent += delta.content;
                if (typeof msgRecCb === 'function') {
                    msgRecCb(delta.content, false);  // false = 还未结束
                }
            }

            // DeepSeek 特有的推理内容(思考过程)
            if (delta?.reasoning_content) {
                reasoningContent += delta.reasoning_content;
                process.stdout.write(delta.reasoning_content);
            }

            // finishReason 各值的含义
            // "stop"            — 正常结束
            // "length"          — 达到 max_tokens 上限被截断
            // "content_filter"  — 触发了安全过滤
            // "tool_calls"      — 模型要调用工具
            if (finishReason) {
                if (typeof msgRecCb === 'function') {
                    msgRecCb('', true);  // true = 流结束信号
                }
            }
        }

        return fullContent;
    } catch (error) {
        console.error('流式调用失败:', error.message);
        if (retries > 0) {
            await new Promise(r => setTimeout(r, 1000));
            return await askDeepSeekStream(ask, msgRecCb, messages, retries - 1);
        }
        return "抱歉,服务暂时不可用。";
    }
}

// 导出
exports.askDeepSeek = askDeepSeek;
exports.askDeepSeekStream = askDeepSeekStream;
exports.openai = openai;

⚠️ 踩坑记录 #2:流式模式下如果开启 thinkingdelta.content 在前几个 chunk 可能是 undefined。建议流式对话时关闭 thinking(设为 "disabled"),否则前端会看到空白停顿。

⚠️ 踩坑记录 #3finishReasonnull 时表示流还在传输,不要delta.content === undefined 判断结束,必须用 finishReason

3.5 命令行聊天测试

// ai-learning/index.js
try {
    require('child_process').execSync('chcp 65001', { stdio: 'pipe' });
} catch (e) {}

const { askDeepSeekStream } = require('./call');
const readline = require('readline-sync');

const messages = [];

async function main() {
    console.log('===== DeepSeek 命令行聊天 =====');
    console.log('输入 "exit" 退出\n');

    while (true) {
        const ask = readline.question('你: ');
        if (ask === 'exit') {
            console.log('再见!');
            break;
        }

        messages.push({ role: 'user', content: ask });
        process.stdout.write('AI: ');

        let response = '';
        await askDeepSeekStream(ask, (chunk, isFinish) => {
            process.stdout.write(chunk);
            response += chunk;
            if (isFinish) {
                process.stdout.write('\n\n');
                messages.push({ role: 'assistant', content: response });
            }
        }, messages);
    }
}

main();

⚠️ 踩坑记录 #4:Windows 终端中文乱码?文件开头加 chcp 65001 强制切换 UTF-8 编码。Mac/Linux 不需要。

测试一下:

===== DeepSeek 命令行聊天 =====
输入 "exit" 退出

你: 介绍一下 Node.js 事件循环
AI: Node.js 的事件循环是其异步非阻塞 I/O 模型的核心...

四、第二步:Function Calling——让 AI 学会"动手"

4.1 Function Calling 原理

一句话解释:告诉模型你有哪些工具可用,它自己判断要不要调用、调用哪个、传什么参数

流程如下:

用户提问 → 模型分析 → 需要外部数据?
                         ├── 是 → 返回 functionName + 参数
                         │         ↓
                         │    你的代码执行函数
                         │         ↓
                         │    把结果再交给模型总结
                         │         ↓
                         │    返回最终回答
                         └── 否 → 直接返回回答

4.2 定义工具(Function Definitions)

// function-calling/index.js
const { openai, askDeepSeekStream } = require('../ai-learning/call');
const weatherApi = require('../weather-secret.json');

// 实际执行的函数
const fakeFunctions = {
    get_weather: async (city) => {
        const url = `http://pddfps.tianqiapi.com/api?unescape=1&version=v63` +
            `&appid=${weatherApi.appid}&appsecret=${weatherApi.appsecret}` +
            `&city=${encodeURIComponent(city)}`;
        const res = await fetch(url);
        const data = await res.json();
        return JSON.stringify(data);
    },
    send_notification(title, message) {
        console.log(`[通知] ${title}: ${message}`);
        return `通知已发送: ${title} - ${message}`;
    },
    get_time: () => {
        return new Date().toLocaleString();
    }
};

// 告诉模型有哪些工具
const functionDefinitions = [
    {
        type: "function",
        function: {
            name: "get_weather",
            description: "获取指定城市的天气信息",
            parameters: {
                type: "object",
                properties: {
                    city: {
                        type: "string",
                        description: "城市名称,如北京、上海"
                    }
                },
                required: ["city"]
            }
        }
    },
    {
        type: "function",
        function: {
            name: "send_notification",
            description: "发送通知到用户手机",
            parameters: {
                type: "object",
                properties: {
                    title: { type: "string", description: "通知标题" },
                    message: { type: "string", description: "通知内容" }
                },
                required: ["title", "message"]
            }
        }
    }
];

💡 关键设计descriptionparameters.description 写得越清晰,模型判断越准确。如果描述模糊(比如只写"获取天气"),模型可能不知道该传什么参数。

4.3 带工具调用的对话函数

async function askWithFunctions(userMessage) {
    const response = await openai.chat.completions.create({
        model: "deepseek-v4-pro",
        messages: [{ role: "user", content: userMessage }],
        tools: functionDefinitions,
        tool_choice: "auto"  // 让模型自己判断要不要调工具
    });

    const message = response.choices[0].message;

    if (message.tool_calls) {
        const toolCall = message.tool_calls[0];
        const functionName = toolCall.function.name;
        const args = JSON.parse(toolCall.function.arguments);
        console.log(`🔧 模型决定调用: ${functionName}(${JSON.stringify(args)})`);
        return { functionName, arguments: args };
    } else {
        return { text: message.content };
    }
}

⚠️ 踩坑记录 #5tool_choice: "auto" 时,模型对于聊天类问题也会正常回答,不会强行调用工具。如果设置成 "required",模型会每次都尝试调用工具——连"你好"都会随便编一个工具调用来应付。

4.4 调度执行——“工具调用的后半段”

const askWithTools = (msg, cb) => {
    askWithFunctions(msg).then(async res => {
        if (res.functionName === 'get_weather') {
            // 1. 执行真实 API 调用获取天气数据
            const weatherData = await fakeFunctions.get_weather(res.arguments.city);

            // 2. 把天气数据作为上下文,让模型生成自然语言回答
            const prompt = `请基于以下资料回答问题。
提问:${msg}
资料:${weatherData}
要求:
1. 回答基于资料内容
2. 如果资料中没有相关信息,明确说明
3. 回答简洁准确`;

            await askDeepSeekStream(prompt, cb, []);

        } else if (res.functionName === 'send_notification') {
            const r = fakeFunctions.send_notification(
                res.arguments.title,
                res.arguments.message
            );
            cb(r, true);  // 通知类直接返回结果

        } else if (!res.functionName) {
            cb(res.text, true);  // 普通回答,直接返回
        }
    });
};

exports.askWithTools = askWithTools;

💡 为什么"先调工具 → 再让模型总结"? 这是一个两段式设计:

  • 第一段:模型决定调用哪个工具并提取参数(相当于"大脑决策")
  • 第二段:我们把工具执行结果喂回去,让模型用自然语言组织回答(相当于"嘴巴说话")

如果不做第二段,用户看到的就是一串 JSON 天气数据,谁也不爱看。


五、第三步:RAG——基于私有文档的智能问答(核心)

5.1 什么是 RAG?为什么要做?

RAG = Retrieval Augmented Generation(检索增强生成)

大模型有两个固有问题:

  1. 知识截止日期——训练数据只到某个时间点
  2. 幻觉(Hallucination)——不知道的事情会胡编乱造

RAG 的思路是:先检索你提供的文档,找到最相关的片段,拼到 prompt 里再让模型回答

5.2 为什么选 ChromaDB + Ollama?

对比维度ChromaDBPineconeWeaviateMilvus
部署难度⭐ 一行命令⭐⭐⭐ 需注册⭐⭐ Docker⭐⭐⭐ 资源大户
成本免费有免费额度免费版受限免费
Node SDK✅ 原生⭐ 一般
适合场景个人/小项目生产级中等规模企业级

Ollama 跑本地 embedding 不需要 API Key,离线可用,省钱省心。

5.3 启动外部服务

# 1. 拉取 Ollama embedding 模型
ollama pull nomic-embed-text

# 2. 启动 ChromaDB 服务(用 Python)
cd chromadb/chromadb-server
pip install chromadb
python main.py
# 服务运行在 http://localhost:8000

⚠️ 踩坑记录 #6chromadb npm 包的默认端口是 8000,但它的 @chroma-core/default-embed 默认 embedding 模型需要另外下载。我踩了这个坑,后来改用 @chroma-core/ollama 桥接本地 Ollama,一举两得。

⚠️ 踩坑记录 #7:ChromaDB 创建 collection 时必须指定 embeddingFunction,否则后续 query 会报 embedding function not found,因为它会尝试用默认 embedding 函数(可能未配置)。

5.4 文档向量化导入

// chromadb/batch-import.js
const fs = require('fs');
const path = require('path');
const { ChromaClient } = require('chromadb');
const { OllamaEmbeddingFunction } = require('@chroma-core/ollama');

// Ollama embedding — 本地免费
const embedder = new OllamaEmbeddingFunction({
    model: "nomic-embed-text",
    baseUrl: "http://localhost:11434"
});

const client = new ChromaClient({
    host: "localhost",
    port: 8000
});

const COLLECTION_NAME = 'my_notes';

async function importNotes() {
    // 1. 先删除旧集合(重新导入)
    try {
        await client.deleteCollection({ name: COLLECTION_NAME });
        console.log('已删除旧集合');
    } catch (e) {
        // 第一次运行没有集合,忽略错误
    }

    // 2. 创建集合,指定余弦相似度
    const collection = await client.createCollection({
        name: COLLECTION_NAME,
        embeddingFunction: embedder,
        metadata: { "hnsw:space": "cosine" }  // 余弦相似度
    });

    // 3. 读取文档
    const notesDir = path.join(__dirname, 'notes');
    const files = fs.readdirSync(notesDir)
        .filter(f => f.endsWith('.txt') || f.endsWith('.md'));

    const documents = [];
    const ids = [];
    const metadatas = [];

    files.forEach((file, idx) => {
        const content = fs.readFileSync(
            path.join(notesDir, file), 'utf-8'
        );
        // 超长文档截断——embedding 模型有 token 限制
        const truncated = content.length > 1000
            ? content.slice(0, 1000)
            : content;

        documents.push(truncated);
        ids.push(`note_${idx}`);
        metadatas.push({
            filename: file,
            length: content.length
        });
    });

    // 4. 批量写入向量库
    await collection.add({ ids, documents, metadatas });
    console.log(`✅ 已导入 ${documents.length} 篇文档`);
}

importNotes();

⚠️ 踩坑记录 #8nomic-embed-text 模型的上下文窗口大约 8192 token,如果你的单篇文档超过这个长度,embedding 会失败。所以必须做截断处理。更好的做法是分块(chunking)——把长文档按 500-1000 字切分成多个 chunk,每个 chunk 单独存。这个项目为了演示简单直接截断前 1000 字。

⚠️ 踩坑记录 #9hnsw:space 一定要设置成 "cosine",默认是 "l2"(欧几里得距离)。用余弦相似度时 query 返回的 distance 越小越相似,1 - distance 才是真正的相似度分数。两种空间的数值范围完全不同,混用会导致相似度过滤失效。

5.5 RAG 查询核心逻辑

// chromadb/index.js
const { ChromaClient } = require('chromadb');
const { OllamaEmbeddingFunction } = require('@chroma-core/ollama');

const embedder = new OllamaEmbeddingFunction({
    model: "nomic-embed-text",
    baseUrl: "http://localhost:11434"
});

const client = new ChromaClient({
    host: "localhost",
    port: 8000
});

const COLLECTION_NAME = 'my_notes';
const SIMILARITY_THRESHOLD = 0.6;  // 可调整:0.3~0.8

exports.rgaQuery = async (question) => {
    const collection = await client.getCollection({
        name: COLLECTION_NAME,
        embeddingFunction: embedder
    });

    // 检索 top 3 最相关文档
    const results = await collection.query({
        queryTexts: [question],
        nResults: 3,
        include: ["documents", "metadatas", "distances"]
    });

    // 相似度过滤
    const validDocs = [];
    const validSources = [];

    for (let i = 0; i < results.documents[0].length; i++) {
        const distance = results.distances[0][i];      // 余弦距离
        const similarity = 1 - distance;                 // 转相似度
        const doc = results.documents[0][i];
        const source = results.metadatas[0][i].filename;

        console.log(`📊 [${source}] 距离:${distance.toFixed(4)} 相似度:${similarity.toFixed(4)}`);

        if (similarity > SIMILARITY_THRESHOLD) {
            validDocs.push(doc);
            validSources.push(source);
        }
    }

    // 无相关文档
    if (validDocs.length === 0) {
        return {
            prompt: "抱歉,我的资料库中没有找到与您问题相关的信息。",
            sources: []
        };
    }

    // 构造 prompt
    const contextText = validDocs
        .map((doc, i) => `[${i + 1}] ${doc}`)
        .join('\n\n');

    const prompt = `请基于以下资料回答问题。如果资料信息不完整,请基于已有信息合理回答。

资料:
${contextText}

问题:${question}

要求:
1. 回答要基于资料内容
2. 如果资料中没有相关信息,明确说"资料中未找到相关信息"
3. 回答简洁准确`;

    return { prompt, sources: validSources };
};

5.6 关于相似度阈值的调参经验

阈值效果适用场景
0.3几乎不过滤,什么文档都返回不推荐,噪音太多
0.5过滤掉明显不相关的文档文档量大时可用
0.6平衡点,推荐通用场景
0.7严格过滤,只返回高度相关文档质量要求高时
0.8+太严格,可能什么都搜不到仅在精准匹配场景使用

💡 我的调试经验:先不加阈值跑几次查询,观察控制台输出的相似度分布,再决定阈值。如果大部分查询的 top3 相似度都在 0.5-0.7 之间,阈值设 0.6 最合理。


六、第四步:Express 后端——三种能力打包成 API

// server/server.js
const express = require('express');
const { askDeepSeekStream } = require('../ai-learning/call');
const { askWithTools } = require('../function-calling/index.js');
const { rgaQuery } = require('../chromadb/index');

const app = express();
app.use(express.json());

// ===== 接口 1:普通聊天(流式) =====
app.post('/api/chat', async (req, res) => {
    const { ask, message } = req.body;
    await askDeepSeekStream(ask, (chunk, isFinish) => {
        res.write(chunk);
        if (isFinish) res.end();
    }, message);
});

// ===== 接口 2:RAG 检索增强(流式) =====
app.post('/api/chat/rga', async (req, res) => {
    const { ask } = req.body;
    const r = await rgaQuery(ask);

    if (r.sources.length === 0) {
        // 没找到相关文档,直接返回提示
        res.send(r.prompt);
        return;
    }

    await askDeepSeekStream(r.prompt, (chunk, isFinish) => {
        res.write(chunk);
        if (isFinish) {
            // 流结束时附上参考文档列表
            res.write(`\n\n📚 参考文档:${r.sources.join(', ')}`);
            res.end();
        }
    }, []);
});

// ===== 接口 3:Function Calling(流式) =====
app.post('/api/chat/tools', async (req, res) => {
    const { ask } = req.body;
    askWithTools(ask, (chunk, isFinish) => {
        res.write(chunk);
        if (isFinish) res.end();
    });
});

app.listen(3000, () => {
    console.log('🚀 后端服务运行在 http://localhost:3000');
});

⚠️ 踩坑记录 #10:Express 5.x 中 express.json() 是内置的,不需要再装 body-parser。如果你用的是 Express 4.x,需要 npm install body-parser

⚠️ 踩坑记录 #11:流式接口中 res.write() 后必须等流结束再 res.end()不要res.write() 之后立刻 res.end(),否则前端只能收到第一个 chunk。


七、第五步:Vue 3 前端——漂亮的对话界面

7.1 技术选型

用途
Vue 3 + Composition API响应式框架
TypeScript类型安全
Vite极速构建
Ant Design X VueAI 对话专用组件(Conversations、BubbleList、Sender)
Pinia状态管理
Vue Router路由

💡 为什么选 Ant Design X Vue? 它直接提供了 BubbleList(聊天气泡列表)、Sender(输入框)、Conversations(会话列表)三个组件,十分钟就能搭出 ChatGPT 级别的 UI。如果手写,光消息气泡的样式就得调半天。

7.2 核心代码:流式聊天的前端实现

<!-- ai-web/src/pages/index.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
  Conversations,
  BubbleList,
  Sender,
} from 'ant-design-x-vue'
import type { Conversation } from 'ant-design-x-vue'

// ========== 类型定义 ==========
type MsgStatus = 'local' | 'loading' | 'success' | 'error'

interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  content: string
  status: MsgStatus
}

type ChatMode = 'chat' | 'rga' | 'tools'

let msgId = 0

// ========== 会话管理 ==========
let conversationId = 3
const conversations = ref<Conversation[]>([
  { key: '1', label: '对话 1' },
])
const activeKey = ref('1')

// 每个会话独立的消息历史
const messagesMap = ref<Record<string, ChatMessage[]>>({
  '1': [],
})

// 每个会话独立的聊天模式
const chatModeMap = ref<Record<string, ChatMode>>({
  '1': 'chat',
})

// 模式选项
const chatModeOptions: { key: ChatMode; label: string }[] = [
  { key: 'chat', label: '普通聊天' },
  { key: 'rga', label: '查询资料库' },
  { key: 'tools', label: '工具调用' },
]

// 当前模式 → 对应 API 地址
const apiUrl = computed<string>(() => {
  const map: Record<ChatMode, string> = {
    chat: '/api/chat',
    rga: '/api/chat/rga',
    tools: '/api/chat/tools',
  }
  return map[currentChatMode.value]
})

// ========== 流式发送消息 ==========
const isSubmitting = computed(() =>
  currentMessages.value.some(m => m.status === 'loading')
)

const handleSubmit = async (value: string) => {
  if (!value.trim() || isSubmitting.value) return

  // 1. 添加用户消息
  currentMessages.value = [
    ...currentMessages.value,
    { id: String(++msgId), role: 'user', content: value, status: 'local' },
  ]

  const aiMsgId = String(++msgId)

  // 2. 发送请求
  const response = await fetch(apiUrl.value, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message: currentMessages.value,
      ask: value
    })
  })

  // 3. 添加占位的 AI 消息
  currentMessages.value = [
    ...currentMessages.value,
    { id: aiMsgId, role: 'assistant', content: '', status: 'loading' },
  ]

  // 4. 流式读取后端响应
  const reader = response.body!.getReader()
  const decoder = new TextDecoder()

  while (true) {
    const { done, value: chunk } = await reader.read()
    if (done) break
    if (chunk) {
      const text = decoder.decode(chunk, { stream: true })
      // 实时追加内容 → 打字机效果
      currentMessages.value = currentMessages.value.map(m =>
        m.id === aiMsgId
          ? { ...m, content: m.content + text }
          : m
      )
    }
  }

  // 5. 标记完成
  currentMessages.value = currentMessages.value.map(m =>
    m.id === aiMsgId ? { ...m, status: 'success' } : m
  )
}
</script>

⚠️ 踩坑记录 #12TextDecoder.decode(chunk, { stream: true }) 中的 { stream: true } 参数非常重要。不加这个参数,多字节字符(如中文)刚好被截断在 chunk 边界时会出现乱码。加了之后解码器会缓存不完整的字节序列,等下一个 chunk 拼完整再输出。

⚠️ 踩坑记录 #13:Vue 的 ref 数组更新时,直接 .push() 不会触发响应式更新。必须用 整个数组替换 的方式:arr.value = [...arr.value, newItem]

7.3 Vite 代理配置(解决跨域)

// ai-web/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import components from 'unplugin-vue-components/vite'
import { AntDesignXVueResolver } from 'ant-design-x-vue/resolver'

export default defineConfig({
  plugins: [
    vue(),
    components({
      resolvers: [AntDesignXVueResolver()]  // 自动按需导入组件
    }),
  ],
  server: {
    port: 8080,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',  // 后端地址
        changeOrigin: true,
      },
    },
  },
})

💡 为什么用代理而不是 CORS? 生产环境中前后端通常部署在不同域名下,但开发阶段用 Vite proxy 最省事。而且 fetch 请求可以直接写 /api/chat 而不是 http://localhost:3000/api/chat,部署时不用改代码。


八、启动运行

# ===== 1. 安装所有依赖 =====
npm install
cd ai-web && npm install && cd ..
cd chromadb && npm install && cd ..

# ===== 2. 配置 API Key =====
# 在项目根目录创建 api-key.txt,写入 DeepSeek API Key
echo "sk-your-api-key" > api-key.txt

# ===== 3. 启动 ChromaDB(RAG 需要) =====
ollama pull nomic-embed-text
cd chromadb/chromadb-server
pip install chromadb
python main.py                    # 终端 1

# ===== 4. 导入文档到向量库 =====
cd ../..
node chromadb/batch-import.js      # 终端 2

# ===== 5. 启动后端 =====
node server/server.js              # 终端 3

# ===== 6. 启动前端 =====
cd ai-web && npm run dev           # 终端 4

打开 http://localhost:8080,就能看到:

  • 左侧:会话列表(可新增/切换会话)
  • 右上:消息气泡(流式逐字输出)
  • 右下:输入框 + 三个模式切换按钮

九、架构全景图

┌──────────────────────────────────────────────────────┐
│                   Vue 3 Frontend (:8080)              │
│                                                       │
│  ┌───────────┐  ┌─────────────┐  ┌────────────────┐  │
│  │ 普通聊天   │  │ 查询资料库   │  │   工具调用      │  │
│  │ /api/chat │  │/api/chat/rga│  │ /api/chat/tools│  │
│  └─────┬─────┘  └──────┬──────┘  └───────┬────────┘  │
└────────┼───────────────┼─────────────────┼───────────┘
         │               │                 │
         └───────────────┼─────────────────┘
                         │  Vite proxy
         ┌───────────────▼─────────────────────────────┐
         │            Express Backend (:3000)           │
         │                                              │
         │  POST /api/chat       → askDeepSeekStream() │
         │  POST /api/chat/rga   → rgaQuery() → LLM   │
         │  POST /api/chat/tools → askWithFunctions()  │
         └──┬──────────────┬──────────────────┬────────┘
            │              │                  │
            ▼              ▼                  ▼
     DeepSeek API    ChromaDB         天气/通知 API
     (云端大模型)   + Ollama          (外部工具)
                   (本地向量检索)

十、常见报错排查速查表

报错信息原因解决
Connection error 调用 DeepSeekbaseURL 写错或网络不通检查 https:// 前缀,不要加 /v1
401 UnauthorizedAPI Key 无效或过期重新生成 Key,确保 api-key.txt 没有多余空格
429 Too Many Requests调用频率超限加重试机制 + 降低并发
embedding function not foundChromaDB collection 创建时未指定 embeddingFunction创建和查询时都要传入 embeddingFunction
Failed to fetch 前端访问后端跨域或端口不对检查 Vite proxy 配置,确保后端在 3000 端口
Ollama model not found没拉取模型ollama pull nomic-embed-text
ChromaDB 连接被拒服务未启动python main.py 启动 ChromaDB
流式输出中文乱码TextDecoder 未设置 stream 模式decoder.decode(chunk, { stream: true })
Vue 界面不更新直接 push 到 ref 数组arr.value = [...arr.value, item] 替换

十一、进阶扩展方向

方向实现思路
对话持久化消息历史写入 SQLite / Redis
文档分块策略用 LangChain 的 RecursiveCharacterTextSplitter 做语义分块
多轮工具调用模型返回 tool_calls 后再调一次,支持多步推理
用户认证JWT + 中间件校验
流式 SSE用 Server-Sent Events 替代 fetch stream,更标准
Docker 部署前后端 + ChromaDB 容器化,一键启动
多模型支持抽象 Provider 层,支持切换 DeepSeek / OpenAI / 本地模型

写在最后

这篇文章从 API 封装 → Function Calling → RAG → 后端 → 前端,完整展示了如何用纯 JavaScript 技术栈搭建一个全栈 AI 助手。

三个关键收获:

  1. DeepSeek API 完全兼容 OpenAI SDK——切换成本几乎为零,国内开发者首选
  2. Ollama + ChromaDB 让本地向量检索不再高不可攀——免费、离线、一行命令启动
  3. Function Calling 是 AI Agent 的入口——掌握了它,你就可以让 AI"动手干活"而不只是"动嘴聊天"

如果你也在学习 AI 应用开发,希望这篇万字长文能帮你少走弯路。源码已经在 GitHub 开源,欢迎 Star ⭐ + Fork 🍴

如果遇到问题,欢迎在评论区留言交流,我看到都会回复!


📦 源码地址:GitHub:lgx-ai

📅 发表于 2026-06-09

🏷️ 标签:DeepSeek RAG Function Calling AI全栈 ChromaDB Ollama Vue3 Node.js

更多推荐