Simple LLM + MCP + RAG:不用框架搭一个增强型指定知识库 Agent
git: https://github.com/fengnovo/simple-llm-mcp-rag-agent
最近整理了一个 simple-llm-mcp-rag-agent。它没有用 LangChain、LlamaIndex、CrewAI 或 AutoGen,而是直接用 TypeScript 把一个最小版 Augmented LLM 串了起来。
它想验证的事情很简单:如果一个大模型既能读取本地知识,又能调用外部工具,还能把结果保存成文件,那么最小的工程结构应该长什么样?
这套代码最后跑出来的任务大概是:
从 knowledge 目录里找到 Kamren 相关资料
-> 注入给模型当上下文
-> 让模型总结并创作故事
-> 通过 filesystem MCP 把结果保存到 output/Kamren.md先用大白话理解
普通 LLM 像一个很会聊天的人,但它有几个天然限制:它不知道你本地文件里的资料,也不能自己访问工具,更不能直接在你的磁盘上写文件。
这个项目就是给 LLM 补三件装备:
大白话说:RAG 负责“翻资料”,LLM 负责“动脑子”,MCP 负责“伸手干活”,Agent 负责“让它们按顺序配合”。
项目整体链路
入口在 src/index.ts。它做了三件事:
- 创建输出目录
output。 - 从
knowledge目录读取所有 Markdown 文件,做向量检索,取出 top 3 相关资料。 - 创建 Agent,把 fetch MCP、filesystem MCP、检索上下文和任务一起交给模型。
整体流程可以画成这样:
这里最关键的不是“保存 Kamren 的故事”,而是这套链路把上下文、模型和工具打通了。任务换成“阅读网页并总结”“查询本地资料再写报告”“根据文档生成文件”,主结构都不用大改。
几个核心模块
代码可以拆成五块:
如果把它看成一台机器,index.ts 是开关,EmbeddingRetriever + VectorStore 是资料检索器,ChatOpenAI 是模型适配器,MCPClient 是工具插座,Agent 是总控。
RAG:先把相关资料找出来
这个项目里的 RAG 很轻量,没有切 chunk、没有持久化向量库、没有 rerank,也没有复杂 loader。它就是:
读取 knowledge 目录每个文件
-> 每个文件整体 embedding
-> 存到内存 VectorStore
-> 把任务也 embedding
-> 用余弦相似度找 top 3
-> 拼成 context 注入给模型流程对应到代码是:
大白话说,向量检索不是“关键词搜索”,而是把问题和资料都变成一串数字,看它们在语义空间里方向像不像。方向越接近,说明越相关。
VectorStore 里的余弦相似度就是这个意思:
private cosineSimilarity(vecA: number[], vecB: number[]): number {
const dotProduct = vecA.reduce((sum, a, idx) => sum + a * vecB[idx], 0)
const normA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0))
const normB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0))
return dotProduct / (normA * normB)
}这里适合做教学和原型验证。真正产品化时,通常还要补文档切片、向量持久化、增量索引、metadata 过滤和重排。
MCP:把外部能力变成模型工具
项目里配置了两个 MCP Server:
const fetchMCP = new MCPClient("mcp-server-fetch", "uvx", ["mcp-server-fetch"])
const fileMCP = new MCPClient(
"mcp-server-file",
"npx",
["-y", "@modelcontextprotocol/server-filesystem", outPath]
)这两个工具的职责不一样:
MCPClient 的工作也很清楚:
这就是 MCP 的价值:模型不用知道“文件系统工具怎么启动、协议怎么通信”,它只看到一个结构化工具。真正执行工具的是你的程序。
ChatOpenAI:把 MCP 工具转成模型能理解的 tools
ChatOpenAI 做了三件核心事情。
第一,维护 messages。构造时如果有 systemPrompt 就放 system 消息,如果有 RAG context 就放 user 消息。真正任务进来时,再把 prompt 追加到 messages。
第二,发起流式 Chat Completions:
const stream = await this.llm.chat.completions.create({
model: this.model,
messages: this.messages,
stream: true,
tools: this.getToolsDefinition(),
})第三,拼接流式 tool call。因为流式返回时,工具名和参数可能被拆成很多 delta,所以代码里用 toolCallChunk.index 找到当前工具调用,把 id、function.name、function.arguments 一段段拼起来。
这块很容易被忽略,但它是流式 tool calling 的关键:
工具定义转换也很直接:MCP 的 inputSchema 会变成 OpenAI tools 的 parameters。
private getToolsDefinition() {
return this.tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
},
}))
}Agent Loop:模型说要用工具,就真的去用
Agent.invoke() 是这个项目最像 Agent 的地方。它不是只问一次模型就结束,而是会进入一个循环:
用更直白的话说:
你问模型一个任务
-> 模型说:我要调用写文件工具
-> Agent 帮它调用
-> 工具返回:文件已写入
-> Agent 把结果告诉模型
-> 模型继续判断还要不要调用工具
-> 不需要了,就输出最终回答这个循环就是“增强型 LLM”和普通聊天的分水岭。普通聊天只会回答;Agent 会在回答过程中行动。
一次任务完整跑起来是什么样
以当前 TASK 为例:
const name = "Kamren"
const TASK = `
告诉我${name}的信息,先从我给你的context中找到相关信息,总结后创作一个关于她的故事
把故事和她的基本信息保存到${outPath}/${name}.md,输出一个漂亮md文件
`完整运行链路可以这样看:
这里的一个好处是,模型拿到的不是全部知识库,而是和任务最相关的几篇资料。这样上下文更短,也更聚焦。
这个实现刻意保持简单
这个项目的价值不是功能多,而是边界清楚:
这种拆法适合学习 Agent 底层机制。你能看到每一步消息怎么进出、工具怎么注册、结果怎么回填,而不是被框架封装吞掉。
现在还缺什么
如果要从 demo 走向更稳的工程形态,后面可以继续补:
- 文档切片,而不是一个文件整体 embedding。
- 向量库持久化,比如 SQLite、Postgres pgvector 或专门的向量数据库。
- embedding 缓存,避免每次启动都重新嵌入 knowledge。
- tool call 参数校验,避免
JSON.parse(arguments)失败直接中断。 - 工具调用超时、重试和权限边界。
- 更清晰的 system prompt,约束模型如何使用 context 和工具。
- 日志结构化,记录每次检索命中的文档、分数、工具调用和最终输出。
不过作为一个极简实现,它已经把 Augmented LLM 最核心的骨架搭出来了:
RAG 提供上下文
LLM 负责推理生成
MCP 提供工具能力
Agent Loop 把工具结果重新喂回模型把这四件事看明白,再去用 LangChain 或其他 Agent 框架,就不会只是在调 API,而是知道框架背后到底帮你做了哪些事。
最后更新:2026-03-18
