MCP / ReAct / RAG

前沿

MCP / ReAct / RAG 是AI 应用里面开发最不可缺少的环境,那我们需要来搞懂这些

一、MCP / ReAct / RAG 各是什么角色

先用一句话定性:ReAct 是骨架(执行循环),MCP 是关节(标准接口),RAG 是其中一个器官(知识工具)​。

先看它们在一个真实系统里的位置:

e5a96c43-38a0-4617-9d55-68b8eb850848

 

对着这张图逐个说:

    ReAct = 执行循环(骨架)。​ 它解决“Agent 怎么干活”的问题:Thought(推理)→ Action(调工具)→ Observation(看结果)→ 再推理 → … 直到产出最终答案。

                                                  前端类比:就像你在写一个复杂的异步编排函数——先判断还缺什么数据,await 一个接口,拿到结果再决定下一步。ReAct 就是把这个“人肉判断”交给了 LLM。

    MCP = 工具接入协议(关节)。​ 它解决“工具怎么标准化地暴露给模型”的问题:每个 MCP Server 用 JSON-RPC 声明自己有哪些 tools(名称、参数 schema、描述),

                                                 MCP Client 动态发现后把工具列表塞进 LLM 的上下文,模型就能“看到”并调用。

                                                前端类比:MCP 之于 AI 应用 ≈ USB-C 之于外设——任何主机(MCP Client)都能插任何设备(MCP Server),不需要为每个工具写定制集成。

                                                  再贴一点:很像 axios 的 adapter 设计,或 npm 包都必须有标准 package.json 入口。

     RAG = 知识供给(器官)。  ​ 它解决“模型不知道你公司的私有数据、且知识会过时”的问题:提前把文档切块、向量化入库;

                                                 查询时检索最相关的 top-k chunk 塞进 prompt,让模型基于给定材料作答。

                                                本质是给模型外挂了一个“可更新、可带权限过滤的搜索引擎”。

                                                 注意在 Agent 架构里,RAG 通常被封装成 MCP 下的一个工具,比如 search_knowledge_base(query)——模型按需调用,而不是每次都强制检索。

 

 这是RAG的过程

58193b15-ba24-4ae5-a305-071d3310f438

 

串起来的完整链路

用户问一句“我上个月的订单能退款吗,退款政策是什么”,后端发生的事:

  1. 前端 POST /chat(带 JWT),同时建立 SSE 通道;
  2. Agent 把「系统提示 + 工具列表(由 MCP Client 发现)+ 用户问题」发给 LLM;
  3. LLM 返回 Thought:“需要查退款政策 → 调 search_knowledge_base”;
  4. Agent 经 MCP 调用 RAG 工具 → 向量检索(带 user_id/租户过滤)→ 返回带引用的 chunks;
  5. 模型看完 Observation,发现还要订单数据 → 再调业务工具 get_order;
  6. 循环直到模型认为信息够了 → 生成最终答案,流式经 SSE 推给前端,答案带 [1][2] 引用角标。

一句话总结:ReAct 决定“怎么调用”,MCP 定义“以什么协议调用”,RAG 提供“调用后知识从哪来”。

 

二、多用户场景下,Agent 如何不越权查他人数据

这是最容易答偏的题。核心认知一句话:权限是工程问题,不是模型问题——LLM 是不可信的执行者,身份永远不能经过它。​

为什么?用户完全可以在对话里说“帮我查一下张三的订单”,模型也可能真的生成 userId=张三 这样的参数。所以设计原则是:

 

55968fa7-31c6-43ba-bdfe-77d778d33f28

 关键在第 ③ 层,给你一个前端秒懂类比:这就像 axios 的请求拦截器统一注入 token——业务代码从来不手动传 token,框架层替你加。

  Agent 工具层同理:

// 工具定义:参数 schema 里根本没有 userId 这个字段
@Tool(description = "查询当前用户的订单")
public List<Order> getOrders(String keyword) {
    // userId 不来自模型,来自安全上下文
    Long userId = SecurityContext.getCurrentUserId();
    return orderService.query(userId, keyword);  // SQL: where user_id = ?
}

模型“以为”自己在调用 get_orders(keyword="退款"),但真正执行时 userId 是服务端从 JWT 里解出来强制塞进去的。用户在对话里说“查张三的订单”,模型最多生成 keyword,永远无法指定查谁。

纵深防御的完整说法(面试按这个层次讲):

  1. 身份层:JWT/Session,签名保证不可伪造;
  2. 工具层:工具签名不含身份参数 + 服务端注入 + 对模型传入的其他参数做白名单校验(防注入,比如 keyword 里塞 SQL);
  3. 数据层:SQL 强制 where user_id = ?;RAG 侧是向量库的 metadata filter(chunk 入库时打上 user_id/tenant_id 标签,检索时 filter={"user_id": 当前用户},Milvus/Qdrant/pgvector 都支持);更进一步可用 PostgreSQL RLS(行级安全)​,连写错 SQL 都漏不了;
  4. 审计层:每次工具调用记录 谁 + 调了什么工具 + 什么参数 + 结果行数,越界尝试直接告警

三、如何约束模型不编造知识库里没有的内容

先纠正预期:幻觉无法 100% 消除,工程目标是“可控 + 可发现”。类比:这是一场开卷考试——考卷(context)上没给的内容就不能写,写了就是作弊(幻觉)。所以要从“出题”到“阅卷”设四道闸:

3647fbe9-d312-4e24-bc29-c494efd56617

 

四道闸的具体工程手段:

① 检索质量(治本)。​ 很多“幻觉”其实是检索没召回,模型没材料只能编。

       手段: 合理的 chunk 切分(带上下文重叠)、

                   混合检索(向量 + BM25 关键词,专有名词靠关键词更准)、

                   rerank 重排模型(如 bge-reranker)筛掉噪声 chunk。

                  top-1 相似度低于阈值 → 直接走拒答分支,根本不给模型编的机会。

② 生成约束(治标但有效)。​ System prompt 写死规则并给拒答示例:

你只能依据 <context> 标签内的资料回答。
- 上下文中没有的信息,回答"知识库中暂无相关内容"
- 每个论点必须标注来源编号,如 [1][2]
- 禁止基于常识或训练记忆补充任何上下文外的细节

 

同时 temperature 调低(0~0.3)​,上下文和问题之间用明确分隔符隔开,减少模型把“自己的知识”混进来的空间。

③ 强制引用 + 后置校验(可发现)。​ 要求答案每个论点带 [chunk_id]。生成后跑一个校验器:引用的 chunk_id 必须真实存在于本次检索结果中,且论点与该 chunk 的语义相似度(embedding 余弦)高于阈值,不满足就打回重生成或降级为拒答。这是把“开卷考试”变成“还要核对笔迹”。

④ 兜底与监控。​ 拒答话术设计成对用户友好(“没查到,可以换个问法或转人工”);线上按“拒答率、引用命中率、用户点踩”做监控,badcase 回流优化知识库。

总结 :"我们的目标是把幻觉从‘概率问题’变成‘工程问题’——检索保证材料对,prompt 约束生成范围,引用校验让漏网的能被发现,拒答兜底保证最坏情况也是诚实的。"

四、SSE 把工具调用过程实时推给前端

先看整条时序,画的图

c7791ddf-8cec-4d10-9b56-320a835b41f1

 

先答“为什么是 SSE 而不是 WebSocket”():

对话场景天然是“一次请求 → 持续流式响应”的单向通信。

SSE 只走普通 HTTP,自带断线重连(Last-Event-Id 机制)、能复用现有网关/鉴权体系;

WebSocket 是全双工,需要升级协议,运维成本高。​单向场景用 SSE 是更克制的选择。

后端(Spring Boot + SseEmitter)​,Spring Boot,

 

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestParam String q, @AuthenticationPrincipal User user) {
    SseEmitter emitter = new SseEmitter(60_000L);          // 超时时间
    // 关键:业务放到线程池里跑,立刻 return 释放 Tomcat 线程
    agentExecutor.execute(() -> agent.run(q, user.getId(), emitter));
    emitter.onCompletion(() -> registry.remove(emitter));   // 必须清理,否则内存泄漏
    emitter.onTimeout(() -> emitter.complete());
    return emitter;
}

Agent 的 ReAct 循环里,每个阶段都对应一个命名事件推出去:

emitter.send(SseEmitter.event().name("thought").data("正在查询退款政策..."));
emitter.send(SseEmitter.event().name("tool_result").data(jsonChunk)); // 引用卡片
emitter.send(SseEmitter.event().name("answer").data(token));          // 逐 token
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete();

 

前端——用 addEventListener 按事件名分发,对应不同 UI 组件:

const es = new EventSource(`/chat/stream?q=${encodeURIComponent(q)}`);

es.addEventListener('thought',    e => appendStep(e.data));        // 思考步骤条
es.addEventListener('tool_result',e => renderCard(JSON.parse(e.data))); // 引用卡片
es.addEventListener('answer',     e => appendToken(e.data));       // 打字机效果
es.addEventListener('done',       () => es.close());               // 主动关,别等超时
es.onerror = () => { /* EventSource 会自动重连;服务端已 done 就 close */ };

 

  问题:

  1. Nginx 缓冲:默认会攒够 buffer 再发,前端就“卡住一下全出来”。解法:响应头 X-Accel-Buffering: no 或 Nginx 配 proxy_buffering off;
  2. 心跳保活:长思考期间连接空闲,网关 60s 就掐。每 15~30s 发一行冒号开头的注释行(: ping),客户端会忽略但连接不判死;
  3. emitter 泄漏:必须注册 onCompletion / onTimeout / onError 回调清理,否则用户量一大内存爆掉——这是高频生产事故;
  4. 异步线程模型:SseEmitter 返回后 Tomcat 线程已释放,写入必须在工作线程里做;要控制并发连接数(每个连接占用一根线程/连接资源);
  5. 事件协议设计:别只发裸 data,用命名事件区分 thought / tool_result / answer / done / error,前端才能渲染出“Agent 在思考→调工具→出结果”的完整过程——这正是题眼:把 ReAct 的中间状态结构化地暴露给 UI。

收尾:总结一句话答案

c3f0a7d6-8406-4adc-bf9b-e060fc54d14f

 这三个其实是同一条主线的三个切面:Agent 的可信(权限)、可靠(不编造)、可见(过程透明)​

 

posted @ 2026-09-30 17:29  -鹿-  阅读(4)  评论(0)    收藏  举报