
最近在做一个网页端的代码推荐生成服务技术栈里绕不开两个关键词Senparc.AI 和 MCP。标题里提到“提前看到了 MCPSSE的相关……”——这其实是我们开发早期就把 MCP 的 SSE 传输方式摸了一遍因为网页端和后端之间的实时交互绕不开服务端推送这条链路。这篇文章就聊聊我们怎么用 Senparc.AI 串联起 MCP 协议把一套完整的代码推荐服务做到浏览器里包括架构选型、协议细节、SSE 踩坑点以及一套可以直接参考的实现方案。如果你正在做类似的东西——比如网页版代码编辑器补全、AI 编码助手、私有代码库问答——这篇文章应该能给你省不少时间。不管你是第一次接触 MCP还是已经在用 SSE 做流式输出里面的一些设计取舍和排查经验都值得看一下。1. 整体设计网页代码推荐的链路为什么长这样1.1 代码推荐生成服务要解决的三个核心问题代码推荐生成服务听起来就是“给一段代码让 AI 补全”但真正落到网页端问题会被拆成三块。第一块是上下文从哪来。AI 模型不能凭空知道你当前项目里有什么类、什么函数、用什么框架。要让推荐结果贴近真实项目需要把当前文件内容、光标位置、选中区域、项目文件结构、甚至同仓库的相似代码片段都喂给模型。这些数据分散在编辑器的前端状态、后端文件系统、代码索引服务里怎么把它们组织起来统一给到模型是整个服务设计的第一步。第二块是交互模式。代码推荐不是一次性请求。用户在编辑器里触发一次补全期望的是几百毫秒内开始输出、逐行逐词地看到推荐内容而不是等两三秒后一次性拿到一整块代码。这种流式反馈决定了后端的通信方式必须是“边生成边推送”。第三块是工具能力。模型本身只会“按上下文续写”但推荐服务往往还需要查询代码库、读取特定文件、搜索类似实现。这些能力不能硬编码进提示词里而是应该通过工具调用暴露给模型让模型在生成过程中自己决定“我现在需要看一下 UserService 的接口定义”。这里就自然引出了 MCP。1.2 Senparc.AI 在整条链路里的定位Senparc.AI 是 .NET 生态里的一套 AI 应用开发框架。它做的事情可以简单理解为把大模型接入、提示词管理、工具调用、流式输出这些繁琐的底层逻辑封装好让你在 ASP.NET Core 项目里像调用普通服务一样去调 AI。选择 Senparc.AI 而不是直接裸调 OpenAI SDK主要考虑三点。一是模型无关性。代码推荐服务未必只绑定一家模型。今天可能用 OpenAI明天可能换成国内模型或者私有化部署的模型。Senparc.AI 在模型接入层做了抽象切换模型时业务代码改动很小这个对实际项目很重要。二是工具调用支持。代码推荐场景中模型要调用 MCP 工具这要求框架层支持 function calling / tool calling 的声明、参数解析和结果回填。Senparc.AI 在这块做了封装不用自己手写工具调用的循环逻辑。三是和 ASP.NET Core 集成顺滑。服务本身是网页后端需要暴露 SSE 接口、管理会话、处理并发。Senparc.AI 的实例管理和请求管线可以自然地挂在依赖注入容器里跟现有 Web 项目的技术栈融为一体。1.3 MCP 工具与 SSE 传输架构层面的必然选择为什么这套服务从架构上就决定了要用 MCP 加 SSE而不是别的组合这是项目早期被讨论得最多的问题。MCP 是 Model Context Protocol 的缩写解决的问题是“模型的工具接口碎片化”。以往每一个 AI 应用都自己定义一套工具接口A 应用的工具在 B 应用里不能复用。MCP 把工具统一成一份协议工具方实现 MCP ServerAI 应用作为 MCP Client 去连接。标准化的好处是工具可以复用、可以独立部署、可以跨服务共享。而 SSE 是 MCP 在远程场景下的主要传输方式。浏览器端跑不了 MCP 的 stdio 传输因为 stdio 面向本地进程通信必须通过后端代理中转。后端到 MCP Server 之间用 HTTP SSE 建立长连接浏览器到后端之间再用 SSE 接收流式结果这条链路是网页端方案里最自然的选型。也就是说解决方案的骨架天然是这样的网页编辑器 → 后端服务Senparc.AI MCP Client → MCP Server代码库工具 → 大模型全程用流式响应把结果推回页面。2. 看懂 MCP 与 SSE网页端代码推荐的关键协议2.1 MCP 协议解决的是工具碎片化问题MCP 协议的核心模型可以概括为Client 和 Server 通过消息交换来发现工具、调用工具、获取结果。MCP Server 启动后会暴露一组工具列表。每个工具有名称、描述、输入参数的 JSON Schema。AI 应用启动时拉取一次工具列表把工具的 name 和 description 拼到模型请求里模型在生成过程中如果判定需要某个工具就会返回一个 tool_call 请求AI 应用再去调用对应工具把结果塞回上下文继续生成。这个模型跟 OpenAI 的 function calling 本质上是同一套思路但 MCP 多了一层标准化。它不关心你的工具是什么语言写的、跑在哪台机器上只关心你们之间按什么协议通信。代码推荐服务里MCP Server 可能部署在一台单独的机器上挂载了代码索引库、文件读取接口、仓库搜索服务提供给多个 AI 应用复用这是它最大的价值。2.2 为什么是 SSE 而不是 WebSocket 或 stdio网页端代码推荐服务涉及两条通信链路选择的逻辑不一样。先看后端到 MCP Server 的链路。MCP 最早的传输方式是 stdio适合本地拉起一个子进程。但代码推荐服务里MCP Server 通常不是本地进程而是一个远程服务。此时就得走 HTTP 类的传输。MCP 规范里的 HTTP SSE 模式是Client 先发 POST 请求初始化会话拿到一个 SSE 端点然后订阅该端点的消息流后续的工具调用请求通过 POST 发送结果通过 SSE 推送回来。为什么用 SSE 而不是 WebSocketSSE 是单向的服务端推送基于普通 HTTP天然适配 MCP 这种“请求是请求、响应是事件流”的消息模型。WebSocket 是双向全双工能力更强但协议更重、调试更麻烦。MCP 规范本身也经历过调整从早期的 HTTPSSE 到后来的 Streamable HTTP核心思想没有变服务端往客户端持续推送事件流客户端向上游发送请求。再看浏览器到后端的链路。浏览器原生支持 EventSource 对象可以直接消费 SSE 流不需要额外引入 WebSocket 客户端库。对于代码补全这种服务端往页面单向推送结果的场景SSE 是成本最低、稳定性最高的选择。选择 SSE 并不是因为它“新潮”而是因为两条链路的需求刚好都落在“单向推送、HTTP 友好、无状态易扩展”这个交集上。2.3 SSE 在 .NET 后端的标准实现方式在 ASP.NET Core 里实现 SSE 非常简单。核心是拿到 HttpResponse 对象设置响应头Content-Type: text/event-stream然后循环写入data:前缀的事件块最后以两个换行符结尾。下面是一个最基础的服务端 SSE 推送结构app.MapGet(/api/stream, async (HttpContext context) { var response context.Response; response.Headers.ContentType text/event-stream; response.Headers.CacheControl no-cache; for (var i 0; i 10; i) { await response.WriteAsync($data: {i}\n\n); await response.Body.FlushAsync(); await Task.Delay(100); } });代码里有两个关键点。一是FlushAsync必须调用否则数据会在缓冲区里攒着不推给浏览器。二是每一条 SSE 消息必须以data:开头、以两个换行符结束浏览器端 EventSource 才能正确解析出消息边界。实际项目中我们通常会再包一层格式化把事件类型和数据内容分开方便前端区分“工具调用中”“开始生成”“代码增量”“生成结束”等不同阶段。3. 实操从零搭一个基于 Senparc.AI MCP 的代码推荐服务3.1 准备阶段环境与依赖先梳理一下需要准备的东西。开发环境是 .NET 8 的 ASP.NET Core 项目除此之外需要安装 Senparc.AI 相关的 NuGet 包。不同版本包的命名略有差异实际操作中直接在 NuGet 里搜索 Senparc.AI把主包和依赖包一起装上即可。MCP 这边需要准备好一个可用的远程 MCP Server 端点SSE 传输地址通常是类似http://xxx/mcp/sse的形式。如果没有现成的服务可以先用一个简单的本地 MCP Server 模拟只要能响应工具列表查询和工具调用请求就行。大模型方面准备一个 OpenAI 兼容的 API 地址和密钥。代码推荐对模型要求不低建议使用上下文窗口较大、代码生成能力较强的模型至少要支持 function calling。3.2 步骤一初始化 Senparc.AI 客户端Senparc.AI 的初始化方式在不同的版本里有差异但核心思路大致相同。在 Program.cs 里注册相关服务配置模型供应商的 API 地址、密钥、默认模型名称。可以参考下面的结构builder.Services.AddSenparcAi(setting { setting.Endpoint https://api.openai.com/v1; setting.ApiKey 你的密钥; setting.Model gpt-4o; });初始化完成之后Senparc.AI 就会提供一个统一的 AI 客户端接口业务层只需要注入这个接口调用方法来发起对话。这里补充一个实践上的建议如果有多个模型供应商可以在初始化阶段就注册多个配置并将其中一个设为默认。代码推荐场景里建议把“语义理解能力强的模型”和“代码生成能力强的模型”分开不同任务走不同模型。3.3 步骤二把 MCP Server 挂载成可调用工具这是整条链路最核心的一步。Senparc.AI 要能调用 MCP Server 上的工具需要做两件事建立 MCP 连接、把工具列表映射成模型可识别的工具声明。MCP 连接部分Senparc.AI 封装了客户端能力但不同版本的 API 会差挺多。不管具体 API 怎么变逻辑上都是一样的用 SSE 端点初始化 MCP Client。拉取工具列表遍历每个工具读取名称和描述。把工具信息转换成模型能识别的 JSON Schema 格式。在发起 AI 请求时把工具列表随请求一起传给模型。大体伪代码如下var mcpTools await mcpClient.GetToolsAsync(); var aiTools mcpTools.Select(t new AiToolDefinition { Name t.Name, Description t.Description, Parameters t.InputSchema }).ToList(); var request new AiRequest { Messages messages, Tools aiTools }; var result await senparcAi.ChatCompletionAsync(request);注意一个细节MCP Server 返回的输入 Schema 通常是 JSON Schema 格式可以直接透传给模型接口不需要额外转换。关键是工具描述要写得足够精细。代码推荐场景里工具描述写得好不好直接影响模型是否会在合适的时机调用它。不要写“读取文件”要写“读取指定路径文件内容适用于查看当前项目中某个文件的完整代码实现”。3.4 步骤三后端接口与 SSE 流式输出后端接口承担两个职责接收网页端提交的推荐请求然后一边跟模型对话、一边把结果通过 SSE 流推回前端。接口的入参设计很简单前端把当前文件内容、光标位置、选中代码块、用户需求描述等信息封装成一个 DTO 传过来。后端拿到请求后先做上下文拼装包括注入系统提示词、当前文件内容、光标前后的代码片段再调用 Senparc.AI 发起流式对话。流式输出的实现思路是先把 HTTP 响应升级成 SSE然后循环读取模型返回的增量内容每读到一段就写入 response。关键代码如下[HttpPost] public async Task StreamCodeRecommendations(CodeRecommendRequest request) { Response.Headers.ContentType text/event-stream; Response.Headers.CacheControl no-cache; var messages BuildMessages(request); await foreach (var delta in senparcAi.StreamChatAsync(messages, tools)) { var eventPayload new { type delta.IsToolCall ? tool_call : delta, content delta.IsToolCall ? delta.ToolName : delta.Content }; await Response.WriteAsync($data: {JsonSerializer.Serialize(eventPayload)}\n\n); await Response.Body.FlushAsync(); } await Response.WriteAsync(data: {\type\:\done\}\n\n); await Response.Body.FlushAsync(); }这里有两个重点。第一个是事件类型设计。不要把所有内容都无差别推给前端。工具调用事件、代码增量事件、完成事件要分开。前端拿到工具调用事件时可以展示“正在检索代码库”之类的状态拿到增量事件时再把内容插入编辑器体验完全不一样。第二个是Flush 的时机。StreamChatAsync返回的是一个异步迭代器每产生一个增量就立刻 flush保证前端能及时收到。如果统一在循环结束后才 flushSSE 就失去了意义。3.5 步骤四前端接收并增量渲染推荐代码前端最省事的方案是使用浏览器原生 EventSource。因为我们的接口是 POST 请求且带请求体EventSource 默认只支持 GET这里有两个解决方案方案一是把请求体参数放到 URL Query 里用 GET 发起事件流。这种方式代码最简单但受 URL 长度限制。代码推荐请求体通常不大文件内容加上需求描述一般不会超过几 KB实际完全够用。方案二是用 fetch 去读取 POST 接口的响应流然后手动解析。这种方式灵活但需要自己处理流式解析逻辑。两种方案在项目里都可用如果追求简单稳妥推荐方案一。代码如下const es new EventSource(/api/code-recommend/stream?content${encodeURIComponent(code)}cursor${cursor}); es.onmessage (event) { const payload JSON.parse(event.data); if (payload.type delta) { editor.insertCode(payload.content); } if (payload.type tool_call) { statusBar.show(正在调用工具${payload.content}); } if (payload.type done) { es.close(); } };增量渲染是前端体验的关键。直接把每次得到的 delta 内容 append 到编辑器末尾是不够的更好的做法是维护一个缓存字符串配合编辑器的 model 替换机制实现类似“灰色推荐文本”的效果。推荐实现方式在代码编辑器上方叠加一个只读推荐层每次收到增量就更新推荐层内容。用户按 Tab 键接受推荐按 Esc 键取消完全模仿大型 IDE 的补全交互。4. 链路细节与参数控制4.1 一次完整请求的时序分解把整条链路串起来看一次代码推荐请求的完整时序大概是这样用户在网页编辑器里触发推荐前端采集上下文发送 SSE 请求。后端收到请求初始化 Senparc.AI 会话先查询已连接的 MCP Server 工具列表。后端把上下文和工具列表一起发给模型。模型返回结果如果结果是工具调用请求后端将调用转发给 MCP Server。MCP Server 执行工具逻辑返回结果。后端把工具结果追加回对话上下文再次请求模型继续生成。模型返回最终代码增量后端逐段推送给前端。前端增量渲染推荐代码等待用户接受或拒绝。这一步看起来顺畅但实际开发时有一个细节非常容易忽略模型在一次响应里可能返回多个工具调用而且工具调用之后往往还要继续生成需要循环处理。也就是所谓的 tool calling loop。如果代码里只处理一轮工具调用就会遇到推荐结果明显不完整的情况。处理方式是在业务层写一个循环设定最大轮数判断模型返回的是工具调用还是最终内容。如果是工具调用执行并回填如果是内容推送给前端并结束。这个循环逻辑建议单独抽成一个方法不要散落在接口代码里。4.2 上下文窗口与 Token 预算代码推荐场景对上下文的占用非常敏感。一个真实项目里随便一个文件就有几百行代码多个文件塞进去轻松超过模型上下文窗口。我们内部的方案是分级注入。第一级是系统提示词描述代码推荐的规则和输出格式第二级是当前文件的光标前后代码这里可以用maxTokens限制截取范围比如光标前保留 4000 token、光标后保留 2000 token第三级是 MCP 工具按需拉取的内容也就是模型调用工具返回的文件内容。Token 预算的控制需要经验。光标前的代码不是越多越好模型关注的是最近的上下文和当前语义环境。超过一定量之后信息密度下降反而容易干扰推荐质量。一个实用的技巧是在拼装上下文前先做一次 token 估算超出预算时优先丢弃距离光标最远的代码片段而不是全部截断。这个逻辑可以用简单的字符数估算代替不需要引入专门的分词库因为代码的平均 token 密度比较稳定。4.3 多用户并发与超时控制网页端代码推荐服务一旦上线就面临多用户并发问题。Senparc.AI 的会话本身是无状态的每次请求可以开新会话但 MCP Server 的连接不能无限创建。MCP Client 建议做成单例复用连接保持在内存里。并发请求通过异步锁或者连接池来控制。超时控制也是重点。模型生成代码可能比较慢SSE 连接挂太久网关层很容易断。后端的处理方式是给模型请求设置一个合理的超时时间比如 120 秒超时后主动关闭 SSE 流给前端推一个错误事件。同时建议在链路里增加心跳机制每隔 15 到 30 秒向 SSE 流写入一个注释行防止中间层的空闲连接回收。5. 常见问题与排查技巧实录5.1 SSE 连接被孤儿化空闲超时与心跳保活群里很多朋友遇到过类似的报错stream disconnected before completion: idle timeout waiting for SSE。这个错误的意思是连接因为没有数据流转发而空闲超时被中间层断开了。排查思路分三步。第一步检查后端与 MCP Server 之间的 HTTP 连接。如果 MCP Server 长时间没有向客户端推送任何消息连接会被认为是空闲的。很多实现里MCP Server 空闲时不会推心跳这时需要客户端自己定时发送 ping 或注释行来保活。第二步检查反向代理和网关的超时配置。Nginx、IIS、负载均衡器都有自己的空闲超时时间。默认值往往在 60 到 120 秒之间一旦空闲时间超过限制连接就会被回收。解决办法是调大proxy_read_timeout一类的参数或者让应用层主动发心跳。第三步检查服务端代码里是否每一条 SSE 消息都正确调用了FlushAsync。忘了 flush 的情况下数据在缓冲区里积压浏览器端收不到任何消息中间层同样会判空闲。在线上的实际经验是心跳必须写。哪怕只是每隔 20 秒写一个: keep-alive\n\n注释行也能显著减少连接被莫名断掉的情况。5.2 流式响应乱序或解析失败SSE 流的数据解包偶尔会出现问题尤其是自己用 fetch 解析流式响应的时候。常见症状是前端收到的文本偶尔被截断或者 JSON 解析报错。原因通常是分块边界和数据内容边界不一致。SSE 的消息边界是双换行但 TCP/IP 层的分块不关心这个边界。后端可能一次WriteAsync写了半条消息或者前端一次ReadAsync读到了两条消息的一部分。自己实现解析时必须做缓冲处理把读到的数据追加到缓冲区每次尝试按双换行切分完整的消息取出来处理不完整的片段留在缓冲区等待下一次读取。这里建议不要嫌麻烦直接封装一个 SseParser 类内部维护缓冲区。浏览器原生 EventSource 对这种问题做了处理但如果用的 fetch 方案这个坑就躲不掉。另一个经验是后端写入 SSE 时不要把一整段 JSON 在多个WriteAsync里分次写。JSON 内容可能很大要保证一条消息一次性写入前端解析就简单很多。5.3 模型调用了多余工具或重复工具代码推荐服务初版上线时经常出现一种情况模型明明只需要查看一个文件却连续调用了五六次工具而且很多是重复调用。这不是模型本身有问题而是工具暴露得太粗了。比如我们之前把“读文件”和“列目录”拆成了多个独立工具工具之间缺乏引导模型就会犹豫不决。解决办法有两个方向。一是合并工具粒度把相关功能合并成一个工具通过参数来区分行为。工具数量越少模型越容易做决策。二是在系统提示词里写清楚工具使用原则比如“优先调用 ReadFile 查看当前文件只有需要了解项目结构时才调用 SearchProject”。还有一个细节值得注意MCP Server 里工具的描述文本往往来自工具的注释这些注释如果不是面向模型的就可能导致模型误解。排查时先看工具描述是否清晰、是否和目标场景匹配。5.4 跨域、鉴权与服务端事件源的限制网页端代码推荐服务上线后第一个遇到的工程问题是跨域。如果前端页面和后端 API 不在同一个域名下就需要配置 CORS。SSE 请求同样是普通 HTTP 请求遵循 CORS 规则。在 ASP.NET Core 里配置 CORS 时要注意 EventSource 默认不会携带凭证信息。如果需要带 Cookie 或 Token需要在前端代码里显式设置同时后端 CORS 配置需要开启 AllowCredentials。鉴权方面也值得专门说一下。SSE 连接是长连接如果用户中途 Token 过期连接不会自动断开。实际项目里我们采用的方式是鉴权只负责建立连接连接建立后通过心跳来检查会话有效性。心跳消息里带上会话标识后端每次收到心跳检查一次状态发现失效就主动断开连接。还有一个浏览器端的限制某些浏览器对同时打开的 SSE 连接数有限制比如 HTTP/1.1 下每个域名默认最多 6 个并发连接。如果页面同时开启了多个代码补全流需要做队列控制避免连接数挤压其他请求。6. 一点收尾这套方案还能往哪儿扩展我们这个服务上线后把同一套 MCP 工具链用到了两个场景里一个是网页端的代码推荐另一个是内部的知识库问答机器人。这其实就是 MCP 标准化带来的红利——同样的代码库检索工具模型可以用在不同应用里不用重复开发。后续如果有精力我准备把 RSS MCP 的方式扩展成双通道模式HTML 页面直接依赖 SSE 获取文本补全同时保留一条 WebSocket 通道专门传结构化数据比如代码诊断结果、文件差异 diff 等。SSE 负责单向流WebSocket 负责双向交互各做各擅长的事。最后聊一个个人体会。做这套方案时我一开始把注意力全放在“怎么调通 MCP”上后来才发现真正花时间的其实是上下文拼装和工具粒度的设计。协议层的东西一旦跑通就稳定了但代码推荐质量的高低全看你给了模型什么、让模型能碰什么。如果你想复刻这套方案建议先把一个最小的链路跑通——Senparc.AI 加一个只有两个工具的 MCP Server再慢慢往上加复杂度这条路会顺畅很多。