MCP / ReAct / RAG
前沿
MCP / ReAct / RAG 是AI 应用里面开发最不可缺少的环境,那我们需要来搞懂这些
一、MCP / ReAct / RAG 各是什么角色
先用一句话定性:ReAct 是骨架(执行循环),MCP 是关节(标准接口),RAG 是其中一个器官(知识工具)。
先看它们在一个真实系统里的位置:

对着这张图逐个说:
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的过程

串起来的完整链路
用户问一句“我上个月的订单能退款吗,退款政策是什么”,后端发生的事:
- 前端
POST /chat(带 JWT),同时建立 SSE 通道; - Agent 把「系统提示 + 工具列表(由 MCP Client 发现)+ 用户问题」发给 LLM;
- LLM 返回 Thought:“需要查退款政策 → 调
search_knowledge_base”; - Agent 经 MCP 调用 RAG 工具 → 向量检索(带 user_id/租户过滤)→ 返回带引用的 chunks;
- 模型看完 Observation,发现还要订单数据 → 再调业务工具
get_order; - 循环直到模型认为信息够了 → 生成最终答案,流式经 SSE 推给前端,答案带
[1][2]引用角标。
一句话总结:ReAct 决定“怎么调用”,MCP 定义“以什么协议调用”,RAG 提供“调用后知识从哪来”。
二、多用户场景下,Agent 如何不越权查他人数据
这是最容易答偏的题。核心认知一句话:权限是工程问题,不是模型问题——LLM 是不可信的执行者,身份永远不能经过它。
为什么?用户完全可以在对话里说“帮我查一下张三的订单”,模型也可能真的生成 userId=张三 这样的参数。所以设计原则是:

关键在第 ③ 层,给你一个前端秒懂类比:这就像 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,永远无法指定查谁。
纵深防御的完整说法(面试按这个层次讲):
- 身份层:JWT/Session,签名保证不可伪造;
- 工具层:工具签名不含身份参数 + 服务端注入 + 对模型传入的其他参数做白名单校验(防注入,比如 keyword 里塞 SQL);
- 数据层:SQL 强制
where user_id = ?;RAG 侧是向量库的 metadata filter(chunk 入库时打上user_id/tenant_id标签,检索时filter={"user_id": 当前用户},Milvus/Qdrant/pgvector 都支持);更进一步可用 PostgreSQL RLS(行级安全),连写错 SQL 都漏不了; - 审计层:每次工具调用记录
谁 + 调了什么工具 + 什么参数 + 结果行数,越界尝试直接告警
三、如何约束模型不编造知识库里没有的内容
先纠正预期:幻觉无法 100% 消除,工程目标是“可控 + 可发现”。类比:这是一场开卷考试——考卷(context)上没给的内容就不能写,写了就是作弊(幻觉)。所以要从“出题”到“阅卷”设四道闸:

四道闸的具体工程手段:
① 检索质量(治本)。 很多“幻觉”其实是检索没召回,模型没材料只能编。
手段: 合理的 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 把工具调用过程实时推给前端
先看整条时序,画的图

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

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

浙公网安备 33010602011771号