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. 这份模板怎么扩成真实项目
准备把它从单文件模板扩成真实项目时,可以按这个顺序往下扩:
- 先把
searchTool接到真实知识库 - 再把
readTool接到真实文档读取 - 再把
decideNextAction改成模型决策 - 再加最小评估样本和 trace 记录
- 最后再考虑 rerank、hybrid search、缓存和权限
可以直接记成:
先让系统真的有依据,再考虑把依据找得更聪明。
5. 第一版不要急着加什么
这份模板第一版不建议马上补这些:
- 多 Agent 协作
- 长期记忆
- 很复杂的 query planning
- 太多检索后处理层
- 自动化的深度反思链
因为 RAG Agent 第一版最重要的不是“聪明”,而是:
- 检索边界清楚
- 证据结构清楚
- 停止条件清楚
- 最终输出真的有依据
6. 一句话用法
如果只想尽快开始:
- 先复制整份模板
- 替换
goal - 接上真实
searchTool - 接上真实
readTool - 先跑通 “检索 -> 阅读 -> 存证据 -> grounded answer”
做到这一步,你就已经从普通 RAG 走到了真正有 Agent 味道的 RAG Agent 第一版。