Skip to content

如何设计一个面向 SDK 的 RAG Runtime ​

在前面的内容里,我们已经完成了 core、indexing 和 adapters 的设计与实现,RAG SDK 也具备了离线索引构建和第三方生态适配的基础能力。

但如果一个 RAG SDK 只解决“怎么建索引”,却没有解决“怎么组织一次完整的在线查询链路”,那它依然是不完整的。真正让用户感知到效果的,不是文档如何被切分和入库,而是一次问题输入之后,系统如何完成检索前处理、检索、检索后处理以及生成,并最终返回结构化结果。

所以,runtime 包要解决的,不是某一个具体模型或向量库的接入问题,而是在线查询流程本身的编排问题。

本文就来讨论,在一个面向 SDK 的 RAG 体系中,runtime 应该处在什么位置,它应该抽象哪些核心能力,以及它又该如何和 adapters 协同工作。

1. runtime 在整个 RAG SDK 里的职责定位 ​

在整个 RAG SDK 里,runtime 的位置可以理解为在线查询编排层。

如果把整个系统拆开来看,这几个包分别解决的是不同的问题:

  • core:定义共享领域契约
  • indexing:负责离线索引构建
  • adapters:负责对接外部生态
  • runtime:负责组织在线查询链路

这几个包并不是平级堆功能,而是分别服务于 RAG 的不同阶段。其中:

  • indexing 关心的是文档如何被加载、切分、增强、向量化并写入存储,它解决的是“索引怎么建”的问题;
  • runtime 面对的则是用户已经发起查询之后,系统如何组织后续流程,它解决的是“索引怎么用”的问题。

从职责边界来看,runtime 不应该承担具体第三方实现。它不应该直接依赖某个 LangChain retriever,也不应该把 OpenAI、Chroma、Pinecone 之类的对象直接写死在内部。否则它就会从“编排层”退化成“某个技术栈的封装层”。

对于 SDK 来说,这种退化是危险的。因为 SDK 最优先要保证的是抽象稳定,而不是尽早绑定某一种实现方案。也正因为如此,runtime 最重要的职责,并不是提供多少默认能力,而是定义一套稳定的在线链路结构。

换句话说,runtime 负责的是:

  • 查询输入如何进入系统
  • 各阶段结果如何在链路中流转
  • 最终结果如何被统一组织和返回

但它不负责:

  • 文档处理和离线建库
  • 第三方 SDK 的适配细节
  • 某个具体厂商方案的绑定实现

这几个职责要么已经在 indexing 中解决,要么应该交给 adapters 去承接。

如果用一句话概括 runtime 的定位,那就是:

它不是底层能力的实现层,而是在线查询流程的编排层。

只有先把这一点说清楚,后面再去讨论接口抽象、默认实现,以及它和 adapters 的协作关系,才不会跑偏。

2. 在 SDK 里,runtime 应该如何抽象 ​

既然 runtime 的定位是在线查询编排层,那它的设计重点就不是“把功能都写进去”,而是把查询链路拆成稳定、可替换的抽象。对 SDK 来说,抽象是否清晰,比默认实现是否复杂更重要。因为一旦边界不清楚,后续的 query rewrite、rerank、budget trim、prompt 组装,都会被塞进某一个大而全的类里,最后整个 runtime 就会失控。

从这个角度看,runtime 最核心的抽象其实只有四个,分别对应查询链路中的四个阶段:

  • QueryPreprocessor
  • RuntimeRetriever
  • RetrievalPostprocessor
  • RuntimeGenerator

它们分别负责不同的事情。

QueryPreprocessor 负责把用户输入转成可检索的请求对象。这里不只是简单地把字符串包一层,而是可以承接 query rewrite、route、filters、topK、budget、rerank policy 等运行时参数。也就是说,到了 retrieval 阶段,系统拿到的已经不再是原始输入,而是一份更明确的查询请求。

RuntimeRetriever 负责执行召回。它的职责很单纯,就是接收一份检索请求,返回候选结果。这里返回的也不应该只是裸 Chunk[],而应该是结构化的候选对象,比如附带 score、route、strategy、sourceId、hierarchyPath 等信息。因为对于后续的后处理来说,这些候选级别的信息是有价值的。

RetrievalPostprocessor 负责候选结果的整理。这一层的作用往往最容易被低估,但实际上很多 RAG 系统的问题都出在这里。候选拿到之后,还要做筛选、阈值过滤、预算裁剪、上下文组装,有时候还要补充 selection trace 或 debug 信息。所以在 SDK 里,这一层必须独立存在,而不能隐含在 retriever 或 generator 里。

RuntimeGenerator 则负责最终生成。它接收的是已经完成前面处理的输入,包括有效 query、最终 chunks,以及可选的 promptContext,然后返回结构化的生成结果。这样做的好处是,生成器只关心如何基于上下文回答,而不用再承担 query rewrite 或 rerank 之类的职责。

如果用一段伪代码来表示,这套抽象大概是这样:

ts
const runtime = createRuntime({
  preprocessor,
  retriever,
  postprocessor,
  generator,
});

const result = await runtime.run({
  query: "什么是 runtime?",
});

这里的关键点不在于 createRuntime() 这个函数本身,而在于它表达了一种非常清晰的组合方式:runtime 不是一个写死流程的大对象,而是由四个阶段组合出来的可复用实例。

这也是为什么我认为 runtime 最好提供两个公共入口:

  • createRuntime()
  • runtime.run()

createRuntime() 负责组装,runtime.run() 负责执行。

这样做比导出一堆散函数更适合 SDK。因为调用方真正需要的,不是每次手工串联四个阶段,而是创建一个可以长期复用、边界明确的 runtime 实例。

除了阶段接口,结果结构也很重要。对 SDK 来说,返回值不能只有一个 answer,否则运行时过程就彻底丢失了。一个更合理的返回结果,至少应该包含这些信息:

ts
type RuntimeResult = {
  answer: string;
  chunks: Chunk[];
  originalQuery: Query;
  effectiveQuery: Query;
  retrievalMetadata?: Record<string, JsonValue>;
  postRetrievalMetadata?: Record<string, JsonValue>;
  generationMetadata?: Record<string, JsonValue>;
  debug?: RuntimeDebugInfo;
};

这里有几个字段是很关键的:

  • originalQuery:表示用户原始输入
  • effectiveQuery:表示真正参与检索的 query
  • chunks:表示最终参与回答的上下文
  • debug:用于调试、评测和后续观测

这不是在“堆字段”,而是在保留运行时链路中的必要信息。如果没有这些字段,后续一旦需要做问题定位,就只能回到业务代码里猜测:到底是 query 改写有问题,还是 retrieval 召回有问题,还是 post-retrieval 过滤过头了。

同样的道理,错误边界也不应该只是一个普通异常。runtime 最好能带上明确的阶段信息,比如:

ts
type RuntimeStage =
  | "pre-retrieval"
  | "retrieval"
  | "post-retrieval"
  | "generation";

这样一来,运行时抛出的错误就不只是“失败了”,而是能明确告诉调用方:失败发生在哪一段。这对 SDK 来说很重要,因为它面对的不是单一业务代码,而是各种不同的接入方。错误如果没有阶段语义,定位成本会非常高。

最后还需要强调一点:默认实现应当尽量轻。比如 NoopQueryPreprocessor 和 PassthroughRetrievalPostprocessor 这类默认件,本质上只是为了让最小链路能跑通,而不是为了替用户做复杂策略决策。SDK 提供默认实现是为了降低接入门槛,不是为了输出唯一正确工作流。

所以,从抽象设计上看,runtime 最重要的不是“能力多”,而是三件事:

  • 阶段边界清楚
  • 组合方式稳定
  • 输入输出结构完整

只有这样,runtime 才能真正成为一个可扩展、可替换、可调试的在线查询编排层,而不是一个随着功能增加不断膨胀的运行时工具箱。

3. runtime 和 adapters 应该如何协作 ​

当 runtime 的职责被确定为“在线查询编排层”之后,一个很自然的问题就是:既然它只负责流程,那具体实现放在哪里?答案就是 adapters。

这也是整个 RAG SDK 分层里很关键的一点:runtime 负责定义抽象,adapters 负责提供具体生态实现。

如果没有这层分工,runtime 很容易一步步膨胀。比如一开始只是想支持一个默认 retriever,后来又想顺手支持一个默认 chat model,再后来又要处理某个向量库、某个模型提供方、某种特定 prompt 结构。这样继续下去,runtime 就会逐渐变成一个绑定具体技术栈的大包,抽象层的边界也会越来越模糊。

所以更合理的方式是:

  • runtime 定义 RuntimeRetriever、RuntimeGenerator 这类查询期抽象
  • adapters 提供这些抽象在具体生态中的默认实现

例如,在当前的设计里:

  • RuntimeRetriever 可以对应 LangChainRuntimeRetrieverAdapter
  • RuntimeGenerator 可以对应 LangChainRuntimeGeneratorAdapter

这样做有几个明显好处。

首先,runtime 可以保持足够稳定。

它只关心查询请求怎么流转、候选结果怎么组织、最终回答怎么返回,而不需要知道底层到底是 LangChain、原生 SDK,还是业务方自定义实现。这样一来,runtime 的抽象不会随着第三方生态变化而频繁震荡。

其次,adapters 可以继续承接“默认实现”这件事。

对于 SDK 来说,完全没有默认件不现实,因为接入成本会太高;但这些默认件如果直接写进 runtime,又会污染边界。所以最好的做法就是:默认实现可以有,但应当放在 adapters。

这意味着调用方既可以直接使用你提供的默认实现:

ts
const runtime = createDefaultRuntime({
  retriever: new LangChainRuntimeRetrieverAdapter(...),
  generator: new LangChainRuntimeGeneratorAdapter(...),
});

也可以只借用 runtime 的抽象,然后自己替换其中某一层:

ts
const runtime = createRuntime({
  preprocessor,
  retriever: customRetriever,
  postprocessor,
  generator: langChainGenerator,
});

这种组合方式,正是 SDK 设计里最重要的能力之一。因为真正的业务接入方,几乎都不会百分之百接受“官方默认工作流”。有的人想保留自己的 retrieval,有的人想自己拼 prompt,有的人只想复用你定义好的 runtime pipeline。只有把抽象层和适配层拆开,调用方才有这种自由度。

从工程边界上看,这种协作关系也更清晰:

  • runtime 不应该直接依赖某个 LangChain 类型
  • adapters 可以依赖 @rag-sdk/runtime 的查询契约
  • 第三方生态变化时,优先修改 adapter,而不是修改 runtime 主包

换句话说,runtime 是标准接口,adapters 是接口实现。前者要尽量稳定,后者可以根据生态变化逐步扩展。

所以,runtime 和 adapters 最理想的协作方式,不是互相侵入,而是各自守住边界:

  • runtime 定义在线查询流程
  • adapters 提供面向具体生态的接入件
  • 业务方在两者之间自由组合

这也是为什么我一直强调,在一个面向 SDK 的 RAG 体系里,indexing、runtime、adapters 三者缺一不可:

  • indexing 解决索引构建
  • runtime 解决在线查询编排
  • adapters 解决外部生态接入

只有这三层各自独立、又能彼此协作,整个 SDK 的边界才会真正稳定下来。

4. 开发者如何使用 runtime ​

对于开发者来说,runtime 的接入方式其实并不复杂。前面几章已经把它的职责和边界讲清楚了,所以在实际使用时,可以把它理解成一层标准化的在线查询入口。开发者要做的,不是自己重新拼一遍查询链路,而是根据项目复杂度,选择合适的接入方式。

最简单的方式,是直接使用 createDefaultRuntime()。这种方式适合已经有 retriever 和 generator,只想先把一条最小查询链路跑通的场景。因为默认情况下,runtime 已经提供了两个最轻量的默认件:

  • NoopQueryPreprocessor
  • PassthroughRetrievalPostprocessor

前者会直接把原始 query 转成标准 RetrievalRequest,后者会把 retrieval 阶段返回的候选结果整理成最终可用于生成的 chunks。这样一来,开发者只需要关心检索和生成本身。

ts
import { createDefaultRuntime } from "@rag-sdk/runtime";

const runtime = createDefaultRuntime({
  retriever: {
    async retrieve(request) {
      return {
        candidates: [
          {
            chunk: {
              id: "chunk-1",
              content: `retrieved for: ${request.effectiveQuery.query}`,
            },
            score: 0.95,
          },
        ],
      };
    },
  },
  generator: {
    async generate({ request, chunks }) {
      return {
        answer: `${request.effectiveQuery.query} -> ${chunks[0]?.content ?? "no chunk"}`,
      };
    },
  },
});

const result = await runtime.run({
  query: "什么是 runtime?",
});

这种方式的优点是接入成本很低,尤其适合最小验证、教学演示,或者业务方已经有现成检索器和生成器时的快速集成。

如果项目需要更强的可控性,就应该使用 createRuntime()。它允许开发者显式传入四个阶段的实现,也就是:

  • preprocessor
  • retriever
  • postprocessor
  • generator

这种方式适合下面这些场景:

  • 需要做 query rewrite
  • 需要根据 query 决定 route 或 filters
  • 需要在 retrieval 后做 score threshold、budget trim 或 prompt context 组装
  • 需要自定义生成输入结构

示意代码如下:

ts
import { createRuntime } from "@rag-sdk/runtime";

const runtime = createRuntime({
  preprocessor,
  retriever,
  postprocessor,
  generator,
});

const result = await runtime.run({
  query: "runtime 是怎么工作的?",
});

在这种模式下,runtime 就不再只是一个默认工作流,而是真正成为一个可组合、可替换的编排层。开发者可以把自己的业务策略精确地放到对应阶段里,而不是全部堆进 retriever 或 generator。

除了最小接入和完全自定义之外,实际项目里更常见的情况,是开发者已经在使用某个现成生态,比如 LangChain。这个时候,就没有必要自己从头实现 runtime 阶段,而是应该直接复用 adapters 提供的默认接入件。

例如,在当前设计下,就可以直接把 LangChain 的 retriever 和 chat model 接到 runtime 里:

ts
import { NoopQueryPreprocessor, createDefaultRuntime } from "@rag-sdk/runtime";
import {
  createLangChainBaseRetrieverRuntimeAdapter,
  createLangChainChatModelRuntimeGenerator,
} from "@rag-sdk/adapters";

const runtime = createDefaultRuntime({
  preprocessor: new NoopQueryPreprocessor({
    filters: {
      sourceIds: ["docs/runtime"],
    },
    rerank: {
      minScore: 0.6,
    },
    budget: {
      maxChunks: 1,
    },
  }),
  retriever: createLangChainBaseRetrieverRuntimeAdapter({
    retriever: langchainRetriever,
  }),
  generator: createLangChainChatModelRuntimeGenerator({
    model: chatModel,
    systemPrompt: "请仅基于检索结果回答问题。",
  }),
});

这种写法很好地体现了前面提到的分层协作关系:

  • runtime 负责流程
  • adapters 负责生态适配
  • 开发者负责按项目需要进行组合

也就是说,开发者在使用这个 SDK 时,并不需要自己重复造一套查询链路。对于简单场景,可以用默认 runtime 快速跑通;对于复杂场景,可以按四阶段抽象做精细化控制;如果项目已经依赖某个外部生态,还可以直接借助 adapters 完成集成。

从接入体验上看,这其实也是 runtime 设计的意义所在。它不是要替开发者写死唯一工作流,而是把在线查询过程组织成一个标准化入口,让最简单的场景足够容易,让复杂场景也有稳定的扩展方式。这样,SDK 才既能降低接入门槛,又不会限制真实项目的演进空间。

5. 写在最后 ​

到这里,runtime 包的职责、抽象方式以及和 adapters 的协作关系,基本就比较清楚了。对于一个面向 SDK 的 RAG 体系来说,indexing 解决的是离线索引构建问题,runtime 解决的是在线查询链路组织问题,而 adapters 解决的,则是外部生态接入问题。它们各自独立,但又必须协同工作,整个 SDK 的结构才能真正稳定下来。

从这个角度看,runtime 的价值并不在于提供了多少默认能力,而在于它把原本容易散落在业务代码里的查询流程,收敛成了一套标准、可替换、可组合的运行时抽象。这样一来,开发者既可以用最小方式快速跑通链路,也可以在后续项目演进中逐步替换其中的任意阶段,而不必推翻整体结构。这也是我们在设计 runtime 时最希望做到的一点。

markdown

-EOF-