Skip to content

项目线 D | 全栈自托管 AI Agent — 零 API 依赖的企业知识问答系统

这条项目线是项目线 A 的自托管平行版本。如果说 A 是"用云端 API 快速跑通完整链路",D 就是"把同一套架构搬到你自己可控的环境里,做到数据不出域、零外部 API 调用"。两条线形成云端/本地双版本对照,既能独立展示,也能合在一起讲一个更完整的技术选型故事。

1. 项目定位

1.1 项目目标

构建一个完全运行在本地或私有环境的知识问答 Agent。核心价值不是"能跑起来",而是:

  • 零 API 费用:推理、Embedding、检索全链路本地运行,边际成本接近零。
  • 数据完全不出域:所有文档内容、用户查询、模型推理都不经过任何第三方网络。
  • 可在内网离线运行:断网也不影响核心功能。

1.2 与项目线 A 的关系

项目线 A 使用 OpenAI GPT-4o + 云端 Embedding + 云端向量库构建知识问答系统。本项目用开源模型 + 本地 Embedding + 本地向量库实现相同功能。业务逻辑层几乎一样,差异全在基础设施层。

这种"相同架构 + 不同基础设施"的对照关系,能帮助你深入理解:哪些能力是 Agent 的核心逻辑,哪些只是外部服务的接入方式。它也是面试中一个很强的叙事结构——"我做过云端版本,也做过自托管版本,我知道它们的 trade-off。"

1.3 适合场景

  • 金融合规:客户数据和内部文档不能离开内网。
  • 医疗数据保护:患者信息和临床指南需要严格的数据边界。
  • 政务内网:物理隔离环境中需要智能问答能力。
  • 个人知识库:不想把笔记和私人文档发给第三方 API。

2. 项目边界

2.1 包含范围

  • ✅ 本地模型部署与推理(Ollama + Qwen3-8B)。
  • ✅ 本地 Embedding 生成(BGE-M3)。
  • ✅ 本地向量数据库(ChromaDB)。
  • ✅ 完整 RAG 管道(文档处理 → 分块 → Embedding → 检索 → 生成)。
  • ✅ Tool Calling 集成。
  • ✅ 前端 UI(Next.js + Vercel AI SDK)。
  • ✅ Docker Compose 一键部署。

2.2 不含范围

  • ⚠️ 模型微调:本项目不包含,但完成后可参考 专题 12 模型微调入门 进一步定制模型。
  • ❌ 分布式推理和多节点调度。
  • ❌ GPU 集群管理与编排。

2.3 前置要求

  • 已安装 Ollama(参考专题 11 第 2.1 节)。
  • 硬件满足以下最低条件之一:
    • NVIDIA GPU:8GB+ 显存(RTX 3060 及以上)。
    • Apple Silicon Mac:16GB+ 统一内存(M2/M3/M4 均可)。
    • 纯 CPU 环境:可运行但推理速度较慢,仅建议用于验证功能。

3. 技术栈

层级云端版(项目 A)自托管版(本项目)
推理OpenAI GPT-4oOllama + Qwen3-8B
EmbeddingOpenAI text-embedding-3BGE-M3 via Ollama
向量库Pinecone / WeaviateChromaDB
后端Next.js API RoutesNext.js API Routes
前端React + Vercel AI SDKReact + Vercel AI SDK
部署Vercel + 云服务Docker Compose 本地

后端和前端层完全相同,这不是巧合——它是刻意的架构设计。通过 OpenAI 兼容 API 和环境变量切换,业务代码在两个版本之间零修改复用。这个设计思路在专题 11 第 8 节有详细说明。

4. 能力地图

本项目覆盖以下主线章节的实践能力:

能力对应章节本项目实践
上下文工程专题 1RAG 检索上下文组装、系统提示词设计
模型选型与调用专题 11 第 3 节Qwen3-8B 选型、量化方案决策
本地推理引擎专题 11 第 2 节Ollama 部署与 OpenAI 兼容 API
Embedding 自托管专题 11 第 6 节BGE-M3 本地 Embedding 管道
向量数据库专题 11 第 7 节ChromaDB 存储与检索
Tool Calling专题 11 第 11 节 Step 6本地模型工具调用与格式处理
Agent UI专题 4流式输出、模型状态面板、引用展示
可观测性专题 5健康检查、推理延迟监控
成本与性能专题 6零 API 成本、量化降级策略
前端工具链专题 9Next.js + Vercel AI SDK 集成

5. 系统架构

整个系统由五个组件组成,通过 Docker 内部网络通信:

用户浏览器


┌─────────────────────────────────────────┐
│  Next.js 应用(前端 + API Routes)        │  :3000
│  ├─ 前端 UI:流式对话、引用展示、状态面板   │
│  └─ API Routes:RAG 编排、Tool Calling    │
└────────────┬──────────────┬──────────────┘
             │              │
     推理请求 │              │ Embedding / 检索
             ▼              ▼
      ┌────────────┐  ┌────────────┐
      │  Ollama     │  │  ChromaDB  │
      │  Qwen3:8b   │  │  向量存储   │
      │  BGE-M3     │  │            │
      │  :11434     │  │  :8000     │
      └────────────┘  └────────────┘

数据流:

  1. 用户在前端输入问题。
  2. API Route 调用 Ollama BGE-M3 生成问题的 Embedding。
  3. 用 Embedding 在 ChromaDB 中检索相关文档片段。
  4. 将检索结果作为上下文,连同用户问题一起发给 Ollama Qwen3:8b。
  5. 模型生成回答,通过流式响应返回前端。
  6. 前端逐 token 渲染回答,展示引用来源。

6. 前端工程师的核心职责

本项目不是"后端搞定了,前端接个接口"。前端工程师在以下环节承担关键角色:

6.1 Ollama 服务集成与健康检查

在应用启动时和运行中,需要持续检查 Ollama 服务和模型状态。这不同于云端 API——本地服务可能未启动、模型可能未拉取、GPU 可能被其他进程占用。

6.2 流式响应对接

Ollama 的流式响应格式与 OpenAI 略有差异。通过 Vercel AI SDK 的 createOpenAI 适配器可以抹平差异,但需要注意本地模型的首 token 延迟通常更高(模型需要加载到显存),前端需要设计更好的加载状态来应对这个差异。

6.3 Embedding 管道编排

文档导入时需要调用本地 Embedding 模型逐条生成向量,这个过程比云端 API 慢(没有批量并发优势),前端需要展示导入进度和错误处理。

6.4 前端 UI 特有需求

除了标准的聊天界面,自托管版本还需要:

  • 模型状态显示:当前加载的模型、显存占用、推理速度。
  • 本地/云端切换开关:一键切换推理后端,方便对比效果。
  • 服务健康面板:Ollama、ChromaDB 的连接状态。

6.5 Docker Compose 编排与一键部署

前端工程师需要理解 Docker Compose 的服务编排,确保前端应用能正确连接到 Ollama 和 ChromaDB 容器。部署脚本和健康检查也是前端工程师的交付物之一。

7. 分阶段实施计划

Phase 1:环境搭建(1-2 天)

目标:所有基础服务运行正常。

  1. 安装 Ollama,拉取 Qwen3:8b 和 BGE-M3 模型。
  2. Docker 启动 ChromaDB。
  3. 验证各服务健康状态。

验证脚本:

typescript
async function checkHealth() {
  const checks = {
    ollama: false,
    model: false,
    embedding: false,
    chromadb: false,
  };

  // Ollama 服务
  try {
    const res = await fetch('http://localhost:11434/api/tags');
    if (res.ok) {
      checks.ollama = true;
      const { models } = await res.json();
      checks.model = models.some((m: { name: string }) => m.name.includes('qwen3'));
      checks.embedding = models.some((m: { name: string }) => m.name.includes('bge-m3'));
    }
  } catch {
    console.error('Ollama 服务未启动');
  }

  // ChromaDB
  try {
    const res = await fetch('http://localhost:8000/api/v1/heartbeat');
    checks.chromadb = res.ok;
  } catch {
    console.error('ChromaDB 未启动');
  }

  console.table(checks);
  const allPassed = Object.values(checks).every(Boolean);
  console.log(allPassed ? '✅ 所有服务就绪' : '❌ 部分服务未就绪');
  return checks;
}

Phase 2:RAG 管道(2-3 天)

目标:文档导入、Embedding 生成、向量检索全链路跑通。

  1. 文档读取与分块——按标题和段落分割,保留元数据。
  2. 本地 Embedding 生成——调用 Ollama BGE-M3 逐条生成向量。
  3. 向量存储——写入 ChromaDB。
  4. 检索测试——验证 top-K 召回质量。

核心代码参考专题 11 第 7.3 节的完整 RAG 链路示例。

Phase 3:Agent 核心(2-3 天)

目标:基于本地模型的对话、Tool Calling、上下文管理全部可用。

  1. 对话链路——通过 OpenAI 兼容 API 接入 Qwen3:8b。
  2. Tool Calling——定义工具 schema,处理模型返回的工具调用请求。
  3. 上下文管理——历史消息裁剪、系统提示词优化。

本地模型的 Tool Calling 稳定性不如云端模型,需要增加格式校验和重试逻辑。具体处理方式见第 10 节。

Phase 4:前端集成(1-2 天)

目标:完整的交互界面上线。

  1. Next.js + Vercel AI SDK 对接 Ollama。
  2. 流式 UI 与 Markdown 渲染。
  3. 模型状态监控面板。
  4. 引用来源展示。

Phase 5:部署与优化(1 天)

目标:一键部署、性能可接受。

  1. Docker Compose 配置。
  2. 性能调优:量化方案选择、num_ctx 上下文窗口调整、num_gpu 层数分配。
  3. 部署文档编写。

8. 关键代码片段

8.1 Ollama 连接配置——环境变量控制云端/本地切换

这是整个项目最关键的设计:通过环境变量切换推理后端,业务代码无需任何修改。

typescript
import { createOpenAI } from '@ai-sdk/openai';

function createModelProvider() {
  const isLocal = process.env.AI_PROVIDER === 'local';

  return createOpenAI({
    baseURL: isLocal
      ? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1'
      : 'https://api.openai.com/v1',
    apiKey: isLocal ? 'ollama' : process.env.OPENAI_API_KEY!,
  });
}

export const ai = createModelProvider();

export const MODEL_NAME = process.env.AI_PROVIDER === 'local'
  ? 'qwen3:8b'
  : 'gpt-4o';
bash
# .env.local — 本地模式
AI_PROVIDER=local
OLLAMA_BASE_URL=http://localhost:11434/v1

# .env.production — 云端模式
AI_PROVIDER=cloud
OPENAI_API_KEY=sk-xxx

8.2 本地 Embedding 生成 + ChromaDB 存储

typescript
import { ChromaClient } from 'chromadb';

const chroma = new ChromaClient({
  path: process.env.CHROMA_URL ?? 'http://localhost:8000',
});

async function embed(text: string): Promise<number[]> {
  const res = await fetch(
    `${process.env.OLLAMA_BASE_URL?.replace('/v1', '') ?? 'http://localhost:11434'}/api/embeddings`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ model: 'bge-m3', prompt: text }),
    },
  );
  const data = await res.json();
  return data.embedding;
}

interface DocChunk {
  id: string;
  text: string;
  metadata: { source: string; title: string };
}

async function indexDocuments(docs: DocChunk[]) {
  const collection = await chroma.getOrCreateCollection({ name: 'knowledge' });

  for (const doc of docs) {
    const embedding = await embed(doc.text);
    await collection.add({
      ids: [doc.id],
      documents: [doc.text],
      embeddings: [embedding],
      metadatas: [doc.metadata],
    });
  }

  console.log(`已索引 ${docs.length} 条文档`);
}

8.3 RAG 检索 + 本地模型生成

typescript
import { streamText } from 'ai';
import { ai, MODEL_NAME } from './config';

async function retrieve(query: string, topK = 3) {
  const queryEmbedding = await embed(query);
  const collection = await chroma.getCollection({ name: 'knowledge' });

  const results = await collection.query({
    queryEmbeddings: [queryEmbedding],
    nResults: topK,
  });

  return {
    documents: results.documents?.[0] ?? [],
    metadatas: results.metadatas?.[0] ?? [],
  };
}

export async function POST(req: Request) {
  const { messages } = await req.json();
  const lastMessage = messages[messages.length - 1].content;

  const { documents, metadatas } = await retrieve(lastMessage);
  const context = documents
    .map((doc, i) => `[来源: ${(metadatas[i] as { title?: string })?.title ?? '未知'}]\n${doc}`)
    .join('\n---\n');

  const result = streamText({
    model: ai(MODEL_NAME),
    system: `你是一个企业知识问答助手。基于以下参考资料回答问题,每个回答必须注明引用来源。如果资料不足以回答,明确说明"根据现有资料无法确认"。

参考资料:
${context}`,
    messages,
  });

  return result.toDataStreamResponse();
}

8.4 前端流式 UI + 模型状态面板

typescript
'use client';
import { useChat } from '@ai-sdk/react';
import { useEffect, useState } from 'react';

interface ServiceStatus {
  ollama: boolean;
  model: boolean;
  embedding: boolean;
  chromadb: boolean;
}

function useServiceHealth() {
  const [status, setStatus] = useState<ServiceStatus>({
    ollama: false, model: false, embedding: false, chromadb: false,
  });

  useEffect(() => {
    async function check() {
      const res = await fetch('/api/health');
      if (res.ok) setStatus(await res.json());
    }
    check();
    const id = setInterval(check, 30_000);
    return () => clearInterval(id);
  }, []);

  return status;
}

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat();
  const health = useServiceHealth();
  const allHealthy = Object.values(health).every(Boolean);

  return (
    <div style={{ maxWidth: 720, margin: '0 auto', padding: 20 }}>
      <header style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
        <h1>自托管知识问答</h1>
        <span style={{ color: allHealthy ? 'green' : 'red' }}>
          {allHealthy ? '● 所有服务正常' : '○ 部分服务异常'}
        </span>
      </header>

      <div>
        {messages.map(m => (
          <div key={m.id} style={{ margin: '12px 0' }}>
            <strong>{m.role === 'user' ? '你' : 'AI'}:</strong>
            <span>{m.content}</span>
          </div>
        ))}
        {isLoading && <div style={{ color: '#888' }}>思考中...</div>}
      </div>

      <form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8, marginTop: 16 }}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder={allHealthy ? '输入问题...' : '服务未就绪,请检查状态'}
          disabled={isLoading || !allHealthy}
          style={{ flex: 1, padding: 8 }}
        />
        <button type="submit" disabled={isLoading || !allHealthy}>
          发送
        </button>
      </form>
    </div>
  );
}

9. 验收指标

指标阈值测量方式
首次响应延迟< 2s(8B Q4 模型,GPU 推理)从请求发出到首 token 返回的时间
知识问答准确率> 80%(20 题测试集)人工标注 + 引用匹配
Docker Compose 启动时间< 5 分钟(不含模型下载)docker compose up 到所有健康检查通过
全链路零外部 API 调用100%网络抓包验证无外发请求
Embedding 单条延迟< 200ms(GPU)Ollama API 响应时间
流式输出 token 速率> 15 tokens/s(8B Q4,GPU)生成 200 token 回答的平均速率

10. 常见坑与解决方案

10.1 Ollama 模型下载慢

默认从海外源下载,几 GB 的模型文件在国内网络下容易超时。

解决:设置 Ollama 镜像源,或从 HuggingFace 镜像站(hf-mirror.com)手动下载 GGUF 文件后通过 Modelfile 本地导入。详细步骤见专题 11 第 5.4 节。

10.2 显存不足 OOM

8B 模型的 FP16 需要约 16GB 显存,超出 8GB 显卡的容量。

解决:使用 Q4_K_M 量化版本(约 4-5GB 显存),或降级到更小的模型。Apple Silicon 可以利用统一内存运行更大的模型。量化选型参考专题 11 第 5.3 节的量化级别对比表。

10.3 Tool Calling 解析错误

本地模型(尤其是小参数量模型)在 Tool Calling 的格式稳定性上不如 GPT-4o。可能出现 JSON 格式错误、参数缺失等问题。

解决:

  • 选用 Qwen3 或 Mistral 等 Tool Calling 支持成熟的模型。
  • 在 Prompt 中给出清晰的工具调用示例。
  • 增加输出校验:尝试 JSON.parse,失败则重试。
  • 设置重试上限(建议 2-3 次),超限则 fallback 到直接回答。

10.4 ChromaDB 连接超时

Docker Compose 环境中,应用容器可能在 ChromaDB 完全启动前就尝试连接。

解决:在 Docker Compose 中使用 depends_on 配合健康检查,或在应用启动逻辑中加入连接重试:

typescript
async function waitForChroma(maxRetries = 10, interval = 2000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const res = await fetch(`${process.env.CHROMA_URL}/api/v1/heartbeat`);
      if (res.ok) return;
    } catch {
      // 连接失败,等待后重试
    }
    await new Promise(r => setTimeout(r, interval));
  }
  throw new Error('ChromaDB 连接超时');
}

10.5 本地模型回答质量不如云端

8B 模型在复杂推理和长文本理解上确实不如 GPT-4o。

解决:通过 RAG 增强上下文弥补模型能力差距;通过更精确的 Prompt 约束输出格式;对少数关键场景保留 fallback 到云端的能力(通过第 8.1 节的环境变量切换实现)。

11. 与项目线 A 的对照学习建议

11.1 推荐学习路径

建议先完成项目线 A 的云端版本,再做本项目。原因:

  • 云端版本上手更快,能让你先理解 RAG 和 Agent 的核心逻辑。
  • 自托管版本有额外的环境搭建和调优成本,提前熟悉业务逻辑能让你专注于基础设施差异。

11.2 重点体会的差异

维度云端版本(项目 A)自托管版本(本项目)
启动速度注册 API Key 即可开始需要安装 Ollama、拉取模型、启动 Docker
推理质量GPT-4o 顶级能力8B 模型有差距,需要 RAG + Prompt 补偿
首 token 延迟~200ms(网络延迟为主)~500ms-1s(模型加载和推理为主)
运行成本按 token 计费,持续增长硬件一次性投入,边际成本接近零
数据隐私数据经过 OpenAI 服务器数据完全本地
Tool Calling 稳定性中等,需要格式校验和重试
运维负担零(API 服务商负责)需要管理 Ollama 服务、模型版本、GPU 资源

11.3 输出建议

完成两个版本后,建议撰写一份 trade-off 分析文档,重点覆盖:

  • 成本模型对比(按月请求量估算)。
  • 质量差距量化(同一测试集的准确率对比)。
  • 适用场景总结(什么时候选云端、什么时候选本地、什么时候混合部署)。

这份文档本身就是一个很好的面试材料。

12. 面试表达模板

自建经验介绍

"我独立搭建过完全自托管的知识问答 Agent,使用 Ollama + Qwen3 + BGE-M3 + ChromaDB,全链路零外部 API 调用,数据完全不出域。系统通过 Docker Compose 一键部署,在断网环境下也能正常运行。"

与云端版本的对比

"相比云端版本,自托管版本在数据隐私和长期成本上有明显优势。但本地 8B 模型在复杂推理和 Tool Calling 稳定性上不如 GPT-4o,我通过 RAG 增强上下文、Prompt 约束输出格式、增加格式校验和重试机制来弥补这些差距。"

架构设计

"我在设计时保持了 baseURL 可配置的架构,业务代码在云端和本地版本之间零修改复用。这样团队可以在开发阶段用云端 API 快速验证,生产环境切到本地模型满足合规要求。这个设计让我们的迁移成本几乎为零。"

诚实说明局限

"本地模型和云端模型之间确实有能力差距,尤其在复杂推理场景。但在我们的业务场景里,80% 的知识问答用 8B 模型加 RAG 就够了。剩下 20% 需要强推理的场景,系统支持按需 fallback 到云端 API。"

13. 阶段性交付物清单

阶段交付物验收标准
Phase 1环境搭建文档 + 健康检查脚本脚本输出全部 ✅
Phase 2RAG 管道代码 + 检索测试结果20 条测试查询的 top-3 召回率 > 70%
Phase 3Agent 核心代码 + Tool Calling 演示工具调用成功率 > 80%
Phase 4前端应用 + 录屏演示流式输出正常、状态面板可用
Phase 5Docker Compose 配置 + 部署文档新机器一键部署 5 分钟内完成

14. 验收清单

  • [ ] Ollama 本地运行 Qwen3 模型,API 响应正常。
  • [ ] BGE-M3 本地 Embedding 正常工作,单条延迟 < 200ms。
  • [ ] ChromaDB 存储与检索功能正常,支持 top-K 查询。
  • [ ] RAG 全链路零外部 API 调用(网络抓包验证)。
  • [ ] 前端流式输出正常,首 token 延迟 < 2s。
  • [ ] 模型状态监控面板可用,展示服务健康状态。
  • [ ] 本地/云端切换通过环境变量实现,业务代码无需修改。
  • [ ] Docker Compose 一键部署成功,所有服务自动启动。
  • [ ] 20 题测试集准确率 > 80%。
  • [ ] 与云端版本的 trade-off 分析文档完成。