LLM、RAG、MCP与Agent四层架构解析:从概念到最小代码实现 简介这份代码包面向AI应用开发者和技术学习者针对大模型技术栈中LLM、MCP、RAG、Agent四个概念边界模糊、关系容易混淆的问题提供了一套轻量的可视化解析方案。资源共3个文件压缩包仅6KB核心文件index.html以可视化页面呈现结构图和对比表同时演示IDE Agent从意图识别、工具调用到任务执行的完整流程另含.inscode交互式代码环境配置与.gitignore工程规范。通过页面可直观掌握各组件定位LLM提供语言理解与生成基础能力RAG通过检索外部知识增强模型输出MCP为模型与外部工具提供标准化接口Agent则将三者整合为可规划执行任务的智能系统。已有105人学习适合入门AI应用开发、需要快速厘清主流AI组件协作方式的开发者。1. LLM、MCP、RAG、Agent 关系解析先分清四个词再看代码怎么串先给一个反直觉的结论LLM、MCP、RAG、Agent 这四个词放在一起时最容易被当成四种并列的技术选型但实际它们是四层完全不同的东西。LLM 是引擎RAG 是给引擎递资料的管线MCP 是引擎调用外部工具的统一协议Agent 才是那个决定“下一步做什么”的执行框架。很多人把 RAG 和 Agent 对立起来或者以为 MCP 能替代 RAG项目评审聊到一半就翻车。这个标题里的[项目代码]更可能是一份把这四者串起来的最小示例工程。适合谁看正在设计智能问答系统的工程师想给现有业务流程加 Agent 的团队以及被“要不要上 RAG”卡住选型的人。2. 四个词的本质LLM 是引擎RAG 是资料管线MCP 是工具协议Agent 是决策流程2.1 LLM所有能力的起点也是所有幻觉的来源很多项目把 LLM 当成知识库用问它“我们公司报销流程是什么”然后抱怨回答不准。这不是模型的错是用法错了。LLM 的本质是“给定上文预测下一个 token”它没有内置一份你们公司的制度也不会承认自己不知道。它能做的是根据你塞进上下文里的内容做推理、改写、归纳。上下文给得越准输出越稳。所以使用 LLM 的第一原则是不要问它脑子里有没有而是把需要的资料先放到它的眼前。第二原则是注意生成参数。同样一个模型temperature 从 0 调到 0.8输出风格会从“照着事实回答”滑向“自由发挥”。做问答、工具调用、数据抽取时我会把 temperature 压在 0~0.3做文案改写、头脑风暴时才放到 0.7 以上。还有一个常被忽略的选型判断如果一个任务单次调用就能完成没有必要上 Agent。比如“把这段英文翻译成中文”一次 LLM 调用加一个 system prompt 就够了。复杂的是那些需要多轮往返的任务先查资料再决定要不要调接口拿到结果后还要继续推理。这种任务才需要后面的 RAG、MCP 和 Agent 一起上。2.2 RAG把知识塞进上下文而不是塞进参数RAGRetrieval-Augmented Generation检索增强生成解决的是一个非常具体的问题让 LLM 在回答时能引用外部资料。典型流程是把文档切块、做向量化、存入向量库收到问题后把问题也向量化召回最相关的几个块再送进 LLM 的上下文。为什么不用微调因为微调适合改变模型的“行为习惯”不适合塞知识。一份文档今天改了价格明天改了流程你不可能为每次变更重新训练一轮。RAG 的优点是资料更新只需要重灌文档来源可追溯回答时可以标注“根据某文档第几节”。这些都是生产环境很看重的。但这套管线真正的瓶颈在召回。top_k 设太小相关资料漏了模型再聪明也答不出来设太大无关内容混进上下文反而干扰生成。chunk_size 也要权衡切大了一块里塞了很多主题召回命中但噪音多切小了上下文碎片化语义不连贯。我的习惯是从 chunk_size 512、top_k 3 开始跑看检索命中的样例再调。2.3 MCP把“工具调用”从各自实现变成统一协议MCPModel Context Protocol模型上下文协议解决的是另一个维度的问题当 Agent 需要调外部工具时每个项目都自己实现一遍“工具列表、参数校验、结果回传”换一个场景就得重写。MCP 把这套交互标准化了。按照协议角色分三块Host 是运行 Agent 的地方Client 负责与某个 Server 建立会话Server 则负责暴露工具。一个 MCP Server 可以向 Host 声明自己有哪些工具每个工具的参数是什么Agent 想调用时通过协议发起请求Server 执行并返回结果。传输上常见两种stdio 适合本地子进程HTTP 加 SSE 适合服务端独立部署的场景。工具代码可以在独立进程里升级、发版Agent 侧不用改。要特别说明一个边界MCP 不负责“检索资料”也不负责“生成回答”它只是工具调用层的规范。RAG 是资料进上下文MCP 是工具结果进上下文两者是互补关系不是替代关系。一个 RAG 系统完全可以不用 MCP但当你要让 Agent 同时能查库存、看行情、读数据库时没有一个统一协议代码会很快乱成浆糊。2.4 Agent决定“下一步做什么”的那层循环Agent 与一次 LLM 调用的区别在于它有一个循环。每次生成后它会检查结果问自己任务完成了吗还缺什么信息需不需要再调一次工具如果还缺就把新的信息追加进上下文再生成一次。直到模型认为可以输出了或者达到预设的最大轮次才停下来。设计这个循环时要区分两种场景步骤完全固定的流程用代码写死 pipeline 比用 Agent 更便宜也更可靠只有步骤不确定、需要根据中间结果动态决策的任务Agent 才值得上。比如“帮我写一个脚本并跑起来看结果”它可能要编辑、执行、报错、再修改循环次数不可预知这才需要 Agent。工程上Agent 的骨架非常朴素一个 while 循环、一个计数器、一个退出条件。难点在退出条件的判断和中间结果的回填。很多初步尝试的人把循环写得像递归跑着跑着上下文爆掉或者卡死在“再试一次”。后面的避坑章节会专门说这个。3. 四个组件怎么套在一起从单次调用到工具编排的分层架构3.1 最常见的关系误读RAG 不是 Agent 的平级方案网上的对比图经常把 RAG 和 Agent 画成两个筐让读者二选一。这是最大的误导。RAG 是一种检索增强手段Agent 是任务执行框架。Agent 可以把 RAG 当成自己的一个工具当它判断问题需要外部资料时就触发检索把结果拿回来再用。也可以不用 RAG直接调 MCP 工具。MCP 又是独立的一层它是工具与 Agent 之间的连接协议。所以正确的关系是LLM 在最底层提供推理能力RAG 和 MCP 工具都是“能力供给方”一个给资料一个给实时数据Agent 在上层做路由和编排。拿一个具体场景说用户问“帮我看看某只股票最近涨跌顺便分析一下公司基本面”。Agent 先判断要调行情工具拿到价格再判断要检索一份研报两个结果都回来后才交给 LLM 生成结论。这里 RAG 和 MCP 并行工作没有谁替代谁。3.2 一条主线请求进来后消息在四个组件之间怎么流动把一次完整问答拆开看消息流动是这样的用户问题进入 Agent 主循环主循环先做一次意图判断按意图决定要不要检索、要不要调工具可能两者都要RAG 召回的资料和工具返回的结果统统一股脑拼进上下文LLM 基于完整上下文生成回答Agent 检查回答是否满足条件不满足就再走一轮。组件职责对照放在工程评审里非常有用组件职责输入输出LLM推理与生成messages 上下文文本或结构化动作RAG外部知识注入用户问题相关资料片段MCP工具调用标准化工具名与参数工具执行结果Agent决策与循环控制用户目标最终回复或动作序列这套链路里最容易出错的是第 3 步拼接顺序和内容没有约束。检索结果和工具结果都是字符串一旦某一边超长或格式混乱LLM 就分不清哪段是资料、哪段是工具输出要么答非所问要么开始编。我一般会在拼接时给不同来源加明确标记比如“资料区”和“工具结果区”并在 system prompt 里写清楚优先权。3.3 项目代码里通常怎么组织目录如果一份标题带[项目代码]的材料真的想让人跑起来目录组织一般可以遵循四层边界。我自己的惯用结构是这样最外层是入口脚本和配置文件负责读参数、组装依赖第二层是服务封装包括 LLM 客户端、向量库客户端、各业务工具第三层是协议适配把工具封装成 MCP 风格的注册表第四层是 Agent 本体只写循环逻辑不碰具体业务。这个分层的好处是换模型只改服务封装加工具只改协议适配调循环逻辑不碰工具代码。常见的反面案例是把所有代码写进两个文件model 调用、业务逻辑、工具函数混在一起看着能跑加一个需求就崩。就算只有几百行的最小工程我也建议至少分开 config、adapter、tools、agent 四个目录读者顺着目录结构就能理解作者想表达的关系。另外提醒一点模型服务的地址、密钥、超时时间这些配置不要散落在各个文件里。我见过一份示例代码把模型地址写在三个地方换环境时漏改一处浪费一下午。统一放 config 文件入口脚本启动时读取并传给各个模块是最省心的做法。4. 最小可运行示例用 150 行代码把四个组件串成一个问答闭环4.1 先写 LLM 抽象层把模型服务封装成一个可替换的黑匣子四个组件里LLM 是最容易替换的。只要把调用封装成一个函数后续换成任何模型服务改动都限定在一个文件里。下面是一个最小封装模型服务对外暴露 chat/completions 风格的端点import requests class LLMClient: def __init__(self, endpoint: str, model: str, temperature: float 0.2): self.endpoint endpoint.rstrip(/) self.model model self.temperature temperature def complete(self, messages: list[dict]) - str: payload { model: self.model, messages: messages, temperature: self.temperature, } resp requests.post(self.endpoint /v1/chat/completions, jsonpayload, timeout30) resp.raise_for_status() # 返回结构一般是 choices[0].message.content data resp.json() return data[choices][0][message][content]这段代码只做一件事把 messages 发出去返回模型生成的文本。endpoint 与 model 不写死在代码里从配置传入。timeout 设为 30 秒避免模型服务卡住时整个 Agent 跟着挂。temperature 默认 0.2面向问答与工具调用场景。如果你用这个封装做创意生成再单独建一个实例把温度调高不要让同一个客户端承担两种气质。4.2 再写 RAG 管线切块、向量化、召回三步走完整的 RAG 涉及文本切块、embedding 模型、向量数据库第一版代码不必全上。先用一个类把流程占住召回实现用最朴素的词袋向量加余弦相似度数据放内存方便读者直接复制运行import numpy as np class MiniRAG: def __init__(self, docs: list[str]): self.docs docs # 演示环境里用内置 hash 构造稀疏向量 self.vectors [self._embed(d) for d in docs] def _embed(self, text: str) - np.ndarray: vec np.zeros(256) for token in set(text.split()): vec[hash(token) % 256] 1 return vec def recall(self, query: str, top_k: int 3, min_score: float 0.25): qv self._embed(query) scores [] for v in self.vectors: denom np.linalg.norm(qv) * np.linalg.norm(v) 1e-6 scores.append(np.dot(qv, v) / denom) idx np.argsort(scores)[::-1][:top_k] results [(self.docs[i], float(scores[i])) for i in idx if scores[i] min_score] return resultsrecall 返回的是文档片段和相似度分数列表由 Agent 层决定怎么用。top_k 控制最多召回几条min_score 是过滤阈值低于它的结果会被丢弃宁缺毋滥。提示内置 hash 在同一个进程内稳定跨进程不保证一致所以这段代码只能用来演示流程。真实项目里把 _embed 换成专门的 embedding 接口或者本地向量模型向量库换成可持久化的方案。这段代码的价值在流程不在性能。4.3 用 MCP 风格暴露一个工具get_stock_priceMCP 的正式接入需要走协议握手和工具发现但最小示例可以先用具字典模拟它的核心语义每个工具声明自己的描述、参数结构、执行函数。等真接协议时把这本字典换成一个标准工具列表即可def get_stock_price(stock_code: str) - str: # 模拟行情接口真实项目替换成内部行情源 mock {600001: 12.35, 000002: 8.77} return mock.get(stock_code, unknown_code) TOOLS { get_stock_price: { description: 查询指定股票代码的最新价, params: {stock_code: str}, handler: get_stock_price, } }把工具参数收进一个函数而不是让 Agent 直接读取一个全局变量是值得养成的习惯。这样每个工具都是独立单元后续接 MCP Server 时只需要加一层适配把 handler 暴露给协议层。这个示例工具返回的是纯字符串。真实工具往往返回结构化 JSON会把 Agent 上下文塞得很长这一点放到避坑章节再展开。4.4 Agent 主循环先检索再决定要不要调工具最后生成把前面三块拼起来就是 Agent 最核心的循环。为了让模型具备“决定调用什么工具”的能力我们约定模型输出一个 JSON格式为 {action: tool_call, name: ..., args: {...}} 时Agent 执行对应工具否则把输出当作最终回答import json def parse_action(reply: str): try: obj json.loads(reply.strip()) if obj.get(action) tool_call: return obj except json.JSONDecodeError: pass return None def agent_run(client, rag, query: str, max_steps: int 3) - str: context_parts [f[资料] {text} for text, score in rag.recall(query, top_k3)] messages [ {role: system, content: 你是客服助手可以调用工具获取实时信息 回答必须基于资料或工具结果。}, {role: user, content: 可用资料\n \n.join(context_parts) f\n\n问题{query}}, ] for step in range(max_steps): reply client.complete(messages) action parse_action(reply) if action and action[name] in TOOLS: tool_result TOOLS[action[name]][handler](**action[args]) # 把工具结果回填让模型基于真实数据做下一步 messages.append({role: assistant, content: reply}) messages.append({role: tool, content: str(tool_result)}) continue return reply return 达到最大步骤请换一种问法。这个循环的逻辑很直接先做一次 RAG 检索把资料固定放进首轮上下文然后每一轮先让 LLM 输出如果它要求调工具就执行并把结果回填再进下一轮当它不再请求工具时就把它的话当作最终回答。max_steps 参数是这四段代码里最容易被忽略但最重要的一个。没有它遇到模型连续要求调工具的死循环Agent 会无限消耗预算。设为 3 在大多数原型场景够用。真实生产里还要再加一个总超时比如 60 秒双保险。跑通整个示例时你会看到一次典型对话在 messages 里经历三轮首轮带资料二轮插入工具请求和工具结果三轮模型基于行情数据生成最终回复。标题里[项目代码]要演示的关系核心就在这三轮 messages 的变化里。5. 避坑记录把四个组件串起来时最常翻车的 5 个地方5.1 资料塞太多模型反而答不准现象RAG 召回设置很大top_k 调到 8 甚至 10自认为“给模型更多资料总能更准”结果回答质量明显下滑开始东拉西扯。原因上下文里无关片段增多模型注意力被稀释另外召回结果本身就含有低相关内容模型分辨不出哪条是真正依据。把一段不相关的资料放在显眼位置它就可能顺着编。解决先收紧 top_k从 3 开始试再加 min_score 过滤。如果确实需要保留多份候选让 RAG 管线先做一轮粗排再由重排序模型挑出最相关的前 2~3 条。我在某图像处理 Demo 里用这个方法把回答准确率从七成拉到了九成以上。5.2 Agent 循环不设上限卡死在“再试一次”现象日志里模型反复输出同一条工具调用请求Agent 一遍遍执行永远不收敛。原因退出条件太软模型发现自己少了某字段就一直补调或者工具返回错误信息被原样回填模型看不出症结只能重复同一种请求。解决强制加 max_steps 和总超时。另外在 system prompt 里写明“如果同一工具连续调用两次仍未成功停止尝试并告知用户”。更好的做法是在工具结果超长或报错时Agent 层先做规范化把错误信息提炼成一句话再回填。5.3 工具返回的 JSON 全量塞回上下文污染后续生成现象一个查询接口返回几百行 JSONAgent 把原始字符串直接追加给模型后面模型的回答变得又长又碎甚至把内部字段当结论说出来。原因工具结果对模型而言只是“证据”不需要全量呈现。原始 JSON 里大量字段与用户问题无关模型分不清主次。解决每个工具在返回前定义结果提炼逻辑比如只保留数值、时间、摘要。或者由 Agent 层对工具结果做截断超过 500 字的部分裁掉。模型需要的是结论不是接口文档。5.4 一套 temperature 走天下工具调用参数飘了现象Agent 时而把股票代码改写成别的数字时而把查询日期写错看起来像模型“不听话”。原因生成回答和调用工具是两种不同任务。模型决定要不要调工具、传什么参数靠的是低随机性温度设高之后参数生成开始自由发挥。解决把工具调用阶段的 temperature 设为 0或者至少低于 0.2创意生成阶段再恢复高温度。这要求代码里区分两个调用入口而不是让 agent_run 里所有 complete 共享一个客户端配置。5.5 只测最终回答不测检索和工具环节现象整体准确率看起来还行但一压测特定问题就崩有的问题是资料没召回到有的问题是工具参数传错全部被最终回答掩盖。原因端到端评测看不到中间环节。最终回答可能蒙对了也可能把错误结果包装得像模像样。没有分环节的观测指标问题定位靠猜。解决在 RAG 的 recall 和工具调用前后各留一条结构化日志至少记录召回文档 ID、相似度分数、工具名、入参、出参。评测时分别统计三个指标召回命中率、工具正确率、最终生成引用准确率。这样一次翻车能立刻定位到具体环节。6. 进阶在同一套代码里同时跑 RAG 和 MCP 工具时怎么验证效果6.1 三环节分开做评测别只盯最终回复前面说过只测最终回答是坑这里给出具体做法。准备 30~50 条覆盖两种任务类型的难题一类依赖文档资料一类依赖工具实时数据还有一类两者都要。对每个问题记录三个结果检索是否召回到正确片段、工具调用参数是否正确、最终回答是否引用了正确来源。统计时三列分别算通过率任何一列低于九成都要返工。这个验证方法成本很低不需要标注平台一张表格就能维护。它真正的价值是让问题暴露在对应环节而不是等用户反馈“答错了”。6.2 把 RAG 也注册成工具让 Agent 决定何时检索当工具数量上来之后更优雅的做法是不要把检索结果无条件塞进首轮上下文而是把“search_docs”这个检索能力也注册成一个工具由 Agent 判断当前问题是否需要。这样能减少无效检索也能让 Agent 在多轮对话中针对不同子问题分别检索。我现在做这类架构的习惯是先跑通无条件检索版本观察哪些问题其实不需要资料再把检索改成工具调用。当时在模拟项目X里试过这种切换改动量不大但上下文占用和调用成本都降下来了。养成记录 messages 每次变化的习惯会让这套四组件架构从“能跑”进入“能调”。希望帮到你。本文还有配套的精品资源点击获取