跳到主要内容

RAG Agent TypeScript 模板

这篇文档只做一件事:

把 RAG Agent 的最小工程骨架整理成一份可以直接复制的 TypeScript 模板。

如果已经看过:

但真正自己动手时还是会卡在这些问题上:

  • retrieval / rerank / read / synthesis 到底怎么接
  • state 里应该怎样存 evidence 和 grounding
  • 什么时候该继续检索,什么时候该收束输出
  • 第一版模板应该保留哪些最小边界

这一页就是给你直接复制、再按需替换的。

1. 适合什么时候用这个模板

这个模板适合下面这类系统:

  • 企业内部知识助手
  • 文档调研助手
  • 合规 / 流程 /制度类知识问答
  • 需要带出处输出的技术分析助手

它比较适合下面这种状态:

  • 你已经不满足于单轮 RAG
  • 你需要系统判断“还要不要继续查”
  • 你希望最终答案不是只看起来流畅,而是真的有依据
  • 先做一个轻量版本,不想一开始就接重框架

如果现在只是做:

  • FAQ
  • 单轮知识问答
  • top-k 检索后一次回答

那普通 RAG 往往更合适,不必马上上 RAG Agent

2. 完整 TypeScript 模板代码

下面这份模板刻意保持为:

单文件、最小依赖、完整主线。

可以先整段复制,再替换后面标出来的几个位置。

// rag-agent-template.ts

type SearchHit = {
id: string;
title: string;
source: string;
snippet: string;
score: number;
};

type Evidence = {
hitId: string;
title: string;
source: string;
claim: string;
snippet: string;
confidence: number;
};

type ToolResult<T> = {
ok: boolean;
data?: T;
error?: string;
};

type ToolDefinition<TArgs, TResult> = {
name: string;
description: string;
run: (args: TArgs) => Promise<ToolResult<TResult>>;
};

type RagState = {
goal: string;
currentStep: number;
maxSteps: number;
queriesTried: string[];
retrievedHits: SearchHit[];
evidence: Evidence[];
gaps: string[];
notes: string[];
groundedAnswer: string | null;
done: boolean;
};

type RagDecision =
| {
type: "retrieve";
query: string;
reason: string;
}
| {
type: "read";
hitId: string;
reason: string;
}
| {
type: "finish";
reason: string;
answer: string;
};

function createInitialState(goal: string): RagState {
return {
goal,
currentStep: 0,
maxSteps: 6,
queriesTried: [],
retrievedHits: [],
evidence: [],
gaps: [],
notes: [],
groundedAnswer: null,
done: false,
};
}

const searchTool: ToolDefinition<{ query: string }, SearchHit[]> = {
name: "search_knowledge_base",
description: "Searches a knowledge base for candidate material",
async run(args) {
return {
ok: true,
data: [
{
id: "doc-1",
title: "Internal Design Doc",
source: "kb://design-doc",
snippet: `Summary for query: ${args.query}`,
score: 0.91,
},
{
id: "doc-2",
title: "Incident Review",
source: "kb://incident-review",
snippet: "Related operational context",
score: 0.74,
},
],
};
},
};

const readTool: ToolDefinition<{ hitId: string }, { content: string }> = {
name: "read_document",
description: "Reads a selected document in more detail",
async run(args) {
return {
ok: true,
data: {
content: `Detailed content for ${args.hitId}. It contains useful grounded evidence.`,
},
};
},
};

function logStep(message: string, extra?: unknown) {
const now = new Date().toISOString();
if (extra === undefined) {
console.log(`[${now}] ${message}`);
return;
}
console.log(`[${now}] ${message}`, extra);
}

function rerankHits(hits: SearchHit[]): SearchHit[] {
return [...hits].sort((a, b) => b.score - a.score);
}

function extractEvidence(hit: SearchHit, content: string): Evidence {
return {
hitId: hit.id,
title: hit.title,
source: hit.source,
claim: `Claim derived from ${hit.title}`,
snippet: content.slice(0, 180),
confidence: hit.score,
};
}

async function decideNextAction(state: RagState): Promise<RagDecision> {
if (state.currentStep === 0) {
return {
type: "retrieve",
query: state.goal,
reason: "Need broad candidate material first",
};
}

if (state.evidence.length === 0 && state.retrievedHits.length > 0) {
return {
type: "read",
hitId: state.retrievedHits[0].id,
reason: "Need at least one grounded piece of evidence",
};
}

if (state.evidence.length < 2 && state.currentStep < state.maxSteps - 1) {
return {
type: "retrieve",
query: `${state.goal} risks limitations`,
reason: "Need more coverage before synthesis",
};
}

return {
type: "finish",
reason: "Enough grounded evidence collected",
answer: buildGroundedAnswer(state),
};
}

function buildGroundedAnswer(state: RagState): string {
const evidenceLines = state.evidence
.map((item, index) => `${index + 1}. ${item.claim} (${item.source})`)
.join("\n");

return [
`Goal: ${state.goal}`,
"Conclusion: This answer is grounded in the retrieved evidence below.",
evidenceLines || "No evidence collected.",
state.gaps.length > 0 ? `Open gaps: ${state.gaps.join("; ")}` : "Open gaps: none",
].join("\n\n");
}

async function runRagAgent(goal: string) {
let state = createInitialState(goal);

while (!state.done && state.currentStep < state.maxSteps) {
const decision = await decideNextAction(state);

logStep("rag_decision", decision);

if (decision.type === "finish") {
state = {
...state,
groundedAnswer: decision.answer,
done: true,
};
break;
}

if (decision.type === "retrieve") {
const result = await searchTool.run({ query: decision.query });

if (!result.ok || !result.data) {
state.gaps.push(`Search failed for query: ${decision.query}`);
} else {
state.queriesTried.push(decision.query);
state.retrievedHits = rerankHits(result.data);
state.notes.push(`Retrieved ${result.data.length} hits`);
}
}

if (decision.type === "read") {
const hit = state.retrievedHits.find((item) => item.id === decision.hitId);
const result = await readTool.run({ hitId: decision.hitId });

if (!hit || !result.ok || !result.data) {
state.gaps.push(`Read failed for hit: ${decision.hitId}`);
} else {
const evidence = extractEvidence(hit, result.data.content);
state.evidence.push(evidence);
state.notes.push(`Added grounded evidence from ${hit.title}`);
}
}

state.currentStep += 1;
}

if (!state.groundedAnswer) {
state.groundedAnswer = buildGroundedAnswer(state);
}

return state;
}

async function main() {
const state = await runRagAgent(
"Judge whether this framework fits an internal knowledge assistant use case"
);

console.log("\n=== FINAL ANSWER ===");
console.log(state.groundedAnswer);
}

main().catch((error) => {
console.error("RAG agent failed", error);
process.exit(1);
});

3. 这份模板里优先替换的几个位置

3.1 goal

第一版最好把目标收窄成:

  • 一个明确业务场景
  • 一个明确知识域
  • 一个明确输出目标

不要一上来就是:

帮我分析所有相关问题

3.2 searchTool

这里后面可以接成真实的:

  • 向量库检索
  • BM25 / keyword 检索
  • hybrid search
  • 带 metadata filter 的知识库检索

第一版不要急着把检索做成 5 种策略并存。

3.3 readTool

这个位置很关键,因为它代表:

命中一个 chunk 以后,系统是否会进一步读上下文。

这里后面可以替换成:

  • 读取完整文档
  • 读取章节上下文
  • 拉取相邻 chunk
  • 读取某个知识条目的完整正文

3.4 state.evidence

这个字段是 RAG Agent 和普通 RAG 很不一样的地方。

你要尽量存成:

  • claim
  • source
  • snippet
  • confidence

而不是只存一段大文本。

3.5 decideNextAction

这一层后面的常见升级方向是:

  • 把硬编码决策替换成模型输出结构化决策
  • 让模型判断是继续检索、改 query、还是开始总结

4. 这份模板怎么扩成真实项目

准备把它从单文件模板扩成真实项目时,可以按这个顺序往下扩:

  1. 先把 searchTool 接到真实知识库
  2. 再把 readTool 接到真实文档读取
  3. 再把 decideNextAction 改成模型决策
  4. 再加最小评估样本和 trace 记录
  5. 最后再考虑 rerank、hybrid search、缓存和权限

可以直接记成:

先让系统真的有依据,再考虑把依据找得更聪明。

5. 第一版不要急着加什么

这份模板第一版不建议马上补这些:

  • 多 Agent 协作
  • 长期记忆
  • 很复杂的 query planning
  • 太多检索后处理层
  • 自动化的深度反思链

因为 RAG Agent 第一版最重要的不是“聪明”,而是:

  • 检索边界清楚
  • 证据结构清楚
  • 停止条件清楚
  • 最终输出真的有依据

6. 一句话用法

如果只想尽快开始:

  1. 先复制整份模板
  2. 替换 goal
  3. 接上真实 searchTool
  4. 接上真实 readTool
  5. 先跑通 “检索 -> 阅读 -> 存证据 -> grounded answer”

做到这一步,你就已经从普通 RAG 走到了真正有 Agent 味道的 RAG Agent 第一版。