🦞 一只用 AI Agent 搭副业产线的程序员


上篇拆 Dify 的时候,有个读者留言:虾哥,Dify 是做通用 AI 应用平台的,那 FastGPT 跟它区别在哪?

好问题。如果 Dify 是"低代码 AI 应用工厂",FastGPT 就是"开箱即用的知识库问答系统"。它不是"什么都能做",而是把"知识库 + 工作流 + 对话"这三个场景做到深。

这篇文章拆开 FastGPT 的代码,看它怎么把三条主线拧成一股绳。


项目简介

FastGPT(GitHub 20k+ Stars)是国内最流行的开源知识库问答平台。核心能力:上传文档 → 自动切片 → 向量化 → 对话时检索 → LLM 生成答案。2024 年后加入了工作流(Flow)能力,让用户可以在知识库 RAG 的基础上编排自定义流程。全栈 TypeScript(Next.js + Fastify + PostgreSQL + MongoDB)。


架构全景

┌──────────────────────────────────────────────────────────────┐
│                    前端(Next.js + React Flow)                │
│   知识库管理 · 工作流编辑器 · 对话调试 · 数据看板               │
├──────────────────────────────────────────────────────────────┤
│                    API 服务(Fastify + TypeScript)            │
│   /api/core/dataset · /api/core/app · /api/core/chat · ...  │
├──────────────────────────────────────────────────────────────┤
│                    核心三大引擎                                │
│   ┌─────────────────┐  ┌──────────────┐  ┌──────────────┐   │
│   │   Dataset 引擎   │  │  Workflow 引擎 │  │  Chat 引擎    │   │
│   │  文档分割→向量化  │  │  节点编排→执行  │  │  会话管理→生成 │   │
│   │  →混合检索→重排  │  │  →变量流转     │  │  →上下文拼接   │   │
│   └─────────────────┘  └──────────────┘  └──────────────┘   │
├──────────────────────────────────────────────────────────────┤
│                    数据层                                     │
│   PostgreSQL(元数据)· MongoDB(文档/chunk/对话)· Milvus(向量)│
└──────────────────────────────────────────────────────────────┘

注意:FastGPT 的三个引擎不是独立的三个微服务——它们在同一个 Node 进程里协同工作。下面分别拆开。


关键设计一:Dataset 的分块策略——不是简单的 Split

大多数 RAG 教程的做法是:文档 → split by separator → 固定窗口切块 → 存向量。FastGPT 的切块引擎比这复杂。看核心逻辑:

// packages/service/core/dataset/data/  —— 切块逻辑(概念性重建)
interface ChunkSplitConfig {
  chunkSize: number;        // 每块最大长度(token 数)
  chunkOverlap: number;     // 重叠 token 数
  customSplitSymbols?: string[];  // 用户自定义分隔符
}

interface ChunkResult {
  chunks: Array<{
    q: string;              // 索引内容(用于向量匹配的文本)
    a: string;              // 期望答案(QA 模式)
    chunkIndex: number;     // 当前块在文档中的序号
    pageContent: string;    // 块完整原文
    metadata: {
      source: string;
      sourceName: string;
      fileId: string;
      parentId?: string;    // 上一层级的块 ID(层级关系)
      childrenId?: string[]; // 下一层级的块 ID
    };
  }>;
}

class DataSplitter {
  // 核心:不只是切,还维护块的层级关系
  async splitText(text: string, config: ChunkSplitConfig): Promise<ChunkResult> {
    // Step 1: 按段落自然分割(不是固定窗口傻切)
    const paragraphs = this.splitByNaturalBoundary(text);

    // Step 2: 在段落基础上按 token 数合并
    //         宁可少切、不可断句
    let chunks = this.mergeByTokenLimit(paragraphs, config.chunkSize);

    // Step 3: 加入重叠——前后块各留一段尾巴
    chunks = this.addOverlap(chunks, config.chunkOverlap);

    // Step 4: 构建块的层级树
    //         子块记录 parentId,父块记录 childrenId
    //         检索命中了块可以顺藤摸瓜找回上下文
    chunks = this.buildHierarchy(chunks);

    return chunks;
  }
}

FastGPT 切块的两个核心差异

  1. 语义边界优先:先按段落、标题等自然边界分割,再在 token 限制内合并。这样切出来的块是"一段完整的话",而不是"在句子中间被切断的半句话"。
  2. 块的层级关系parentId / childrenId 维护了一棵树。检索命中一个叶子节点后,能沿着 parentId 链找回父级块的完整上下文。这是 LlamaIndex Node 的 relationships 思路,但在 FastGPT 里做得更实用——就是树,不用复杂的图。

设计洞察:好的切块策略不是"怎么切",而是"怎么在检索时找回上下文"。切得再好,丢失了上下文关系也是白搭。parentId 链就是最低成本的关系维护方案。


关键设计二:Workflow 的节点系统——可暂停、可分支、有状态

FastGPT 的工作流引擎和 Dify 的有本质区别:FastGPT 的节点是运行时状态机,不是无状态的函数

// packages/service/core/workflow/ —— 工作流节点定义(概念性重建)
enum WorkflowNodeTypeEnum {
  systemInput = 'systemInput',           // 用户输入
  chatNode = 'chatNode',                 // LLM 对话(可挂载知识库)
  datasetSearch = 'datasetSearch',       // 知识库搜索
  codeNode = 'codeNode',                 // 代码执行(沙箱)
  ifElseNode = 'ifElseNode',             // 条件判断
  httpNode = 'httpNode',                 // HTTP 请求
  toolNode = 'toolNode',                 // 插件/工具调用
  answerNode = 'answerNode',             // 指定回复(不调 LLM)
  userSelect = 'userSelect',             // 用户交互——暂停等用户选择
  loopNode = 'loopNode',                 // 循环节点
  variableUpdate = 'variableUpdate',     // 更新变量
}

// 每个节点定义了自己的输入输出 Schema 和运行时行为
interface WorkflowNodeDefinition {
  type: WorkflowNodeTypeEnum;
  inputs: VariableSchema[];     // 输入变量的 JSON Schema
  outputs: VariableSchema[];    // 输出变量的 JSON Schema
  // 核心:节点的运行函数——接收输入、更新运行时状态、返回输出
  run: (ctx: NodeRunContext) => Promise<NodeRunResult>;
}

关键的设计差异userSelect 节点。它不是"执行完就往下走",而是暂停工作流、把控制权交给用户。用户选了选项之后,工作流从暂停点继续执行。

// 简化的运行时状态
interface WorkflowRuntimeState {
  workflowId: string;
  currentNodeId: string;
  nodeStates: Map<string, {
    status: 'waiting' | 'running' | 'completed' | 'paused' | 'error';
    input: Record<string, any>;
    output: Record<string, any>;
  }>;
  variables: Record<string, any>;        // 全局变量池
  history: ChatHistoryItem[];            // 对话历史
  // 暂停信息
  pauseReason?: 'user_select' | 'error_retry' | 'approval';
  pauseData?: any;
}

这个"可暂停"的设计让 FastGPT 的 Workflow 不只是个批处理引擎——它支持人机协作。比如"客服机器人检测到用户要投诉 → 暂停 → 弹通知给人工客服 → 人工客服介入 → 工作流继续"。

设计洞察:不是所有工作流都应该全自动。真实场景里,"AI 处理大部分 + 关键时刻转人工"是更务实的方案。FastGPT 把"暂停"作为一等公民设计到引擎里,而不是事后打补丁加的。


关键设计三:Chat 的上下文拼接——不是简单的"全量塞进去"

对话引擎的核心挑战:用户可能开了很长一段对话,但 LLM 有上下文窗口限制。怎么裁剪对话历史?

FastGPT 的做法不是截断最近 N 条——而是按节点动态拼接

// packages/service/core/chat/ —— 上下文拼接逻辑(概念性重建)
class ChatContextBuilder {
  async buildContext(
    chatHistory: ChatItem[],
    workflow: Workflow,
    runtimeState: WorkflowRuntimeState
  ): Promise<ChatCompletionMessage[]> {
    let messages: ChatCompletionMessage[] = [];

    // Step 1: 拼接系统提示词
    //         由两部分组成:工作流的 systemPrompt + 知识库的 context
    const systemPrompt = await this.buildSystemPrompt(workflow, runtimeState);

    // Step 2: 拼接知识库检索结果
    //         每个 datasetSearch 节点的结果都被注入到对话上下文
    const kbContext = runtimeState.nodeStates
      .filter(n => n.status === 'completed' && n.type === 'datasetSearch')
      .map(n => n.output.searchResults)
      .flat();

    messages.push({
      role: 'system',
      content: `${systemPrompt}\n\n参考资料:\n${kbContext.join('\n')}`
    });

    // Step 3: 动态裁剪历史对话
    //         不是硬截断,而是智能压缩
    const tokenBudget = this.getMaxTokens() - this.estimateTokens(messages);
    const trimmedHistory = this.smartTrim(chatHistory, tokenBudget);
    messages.push(...trimmedHistory);

    return messages;
  }

  smartTrim(history: ChatItem[], budget: number): ChatMessage[] {
    // 策略:
    // 1. 始终保留最近的 N 条("工作记忆")
    // 2. 对更早的对话做摘要压缩("长期记忆")
    // 3. 如果还超,逐条丢弃最旧的
    // 这样保证关键信息不丢失
    const recent = history.slice(-KEEP_RECENT_N);
    const older = history.slice(0, -KEEP_RECENT_N);

    if (this.estimateTokens(older) > budget * 0.3) {
      // 老对话太多——压缩成摘要
      const summary = await this.summarizeHistory(older);
      return [{ role: 'system', content: `对话摘要:${summary}` }, ...recent];
    }
    return [...older, ...recent];
  }
}

三种上下文拼接策略

  1. 系统提示词:来自工作流配置 + 知识库检索结果。这是"静态知识",每次对话不变。
  2. 最近 N 条对话:始终保留,是 LLM 的"工作记忆"。保证它知道刚才在聊什么。
  3. 历史对话压缩:对更早的对话生成一句话摘要。这是"长期记忆"——用最小 token 代价保留关键信息。

设计洞察:上下文窗口不是无限大,但对话可能无限长。"保留最近 + 压缩历史"是人类记忆的工作原理——FastGPT 把它用到了 LLM 的上下文管理里。


核心代码拆解:知识库的混合检索

FastGPT 的检索不是纯向量搜索——它做了向量 + 关键词的混合检索,然后 Rerank 重排:

// packages/service/core/dataset/search/ —— 混合检索(概念性重建)
class DatasetSearchEngine {
  async search(params: SearchParams): Promise<SearchResult[]> {
    // Step 1: 向量检索(ANN —— 近似最近邻)
    const vectorResults = await this.vectorSearch(
      params.datasetId,
      params.queryEmbedding,
      params.topK * 2   // 多召回一倍,给后面的重排留余地
    );

    // Step 2: 关键词检索(倒排索引 —— PostgreSQL 的 ts_vector)
    const keywordResults = await this.keywordSearch(
      params.datasetId,
      params.query,
      params.topK * 2
    );

    // Step 3: RRF(Reciprocal Rank Fusion)融合
    //         不是简单合并,而是用倒数排名加权
    const fusedResults = this.rrfFusion(
      vectorResults,
      keywordResults,
      RRF_K = 60  // 控制排名衰减速度
    );

    // Step 4: Rerank 重排(Cross-Encoder 模型)
    const rerankedResults = await this.rerank(
      fusedResults.slice(0, params.topK * 2),  // 取融合后的前 N 条
      params.query
    );

    // 返回最终 topK
    return rerankedResults.slice(0, params.topK);
  }

  rrfFusion(
    vectorResults: ScoredDoc[],
    keywordResults: ScoredDoc[],
    k: number
  ): ScoredDoc[] {
    // RRF 公式: score = sum(1 / (k + rank))
    // 在两边都排得高的文档会得到最高分
    const scoreMap = new Map<string, number>();
    for (let i = 0; i < vectorResults.length; i++) {
      scoreMap.set(vectorResults[i].id, 1 / (k + i + 1));
    }
    for (let i = 0; i < keywordResults.length; i++) {
      const prev = scoreMap.get(keywordResults[i].id) || 0;
      scoreMap.set(keywordResults[i].id, prev + 1 / (k + i + 1));
    }
    // 按融合分降序
    return [...scoreMap.entries()]
      .sort((a, b) => b[1] - a[1])
      .map(([id, score]) => ({ id, score }));
  }
}

RRF 融合比简单合并好在哪?

假设文档 A:向量排名第 1,关键词排名第 20。文档 B:向量排名第 5,关键词排名第 5。

  • 简单合并(取平均排名):A = (1+20)/2 = 10.5,B = (5+5)/2 = 5。B 赢。
  • RRF:A = 1/(60+1) + 1/(60+20) = 0.0164 + 0.0125 = 0.0289。B = 1/(60+5) + 1/(60+5) = 0.0308。B 赢。

结果一样,但 RRF 不受"排名范围"影响(一个返回列表有 100 条、另一个只有 20 条也能公平融合),而且是超参数归一化(k 可以调到适合你的数据分布)。


你可以抄的作业

1. 切块时维护父子关系

不管是树还是图,你的 chunk 一定要有"怎么找回完整上下文"的路径。parentId 是最低成本的实现。

2. 工作流要支持暂停

不是所有流程都应该全自动。设计引擎时就把"等待外部输入"作为一级状态,后续的客服介入、审批流程都不需要大改引擎。

3. 上下文裁剪要有策略

“截断最近 N 条"是最粗暴的做法。试试"保留最近 + 压缩历史”——token 不够的时候,把旧的对话压成一句话摘要再喂给 LLM。

4. 混合检索三步走:召回 → RRF 融合 → Rerank

向量 + 关键词的混合检索比纯向量好一个档次。RRF 是零成本的融合算法,不需要训练、不需要调很多参数。再配上 Cross-Encoder Rerank,检索质量能再上一个台阶。


最后

FastGPT 和 Dify 的区别很有意思:Dify 追求"通用性"——做低代码 AI 应用工厂;FastGPT 追求"场景深度"——把知识库问答这件事做到极致。两条路线都是对的,关键看你的目标用户更缺什么。

技术上,FastGPT 的三个设计(自然边界切块 + 层级关系、可暂停工作流、智能上下文裁剪)都是"场景驱动"的设计——不是技术炫技,而是服务真实需求。

下一讲拆 Open WebUI。同样是做 AI 前端,它是怎么设计 Pipeline 抽象来支持任意模型后端的?


本文拆解的 FastGPT 版本:v4.8.x。源码地址:github.com/labring/FastGPT


🦞 一只用 AI Agent 搭副业产线的程序员

全平台同名:虾哥不加班 | 源码:GitHub - lobster-bujiaban
需要定制 AI 工具?来聊聊 → lob_ai

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐