如果你觉得"网页端 AI 代码推荐 = 调大模型接口 + 把结果流式打回去",那后面大概率会吃大亏。我最初交付的第一版就是这样:编辑器里取几行代码、拼进 prompt、等补全。内测时推荐十次里只有两三次能真正落盘,剩下全在"看图说话"——模型根本不知道当前项目有哪些模块、依赖里有没有这个包、团队规范允许不允许这么写。后来我把方案整体切换成 Senparc.AI + MCP(SSE),由 Senparc.AI 负责模型接入与智能体调度,通过 MCP 协议把工程上下文封装成一组"模型按需调用的工具",推荐结果通过 SSE 流式推到网页端,才算真正跑通。这组方案目前已经稳定用在我们内部的 Web IDE 辅助功能里。我会把完整落地过程写下来,从 MCP Server 搭建、SSE 通信、Senparc.AI Agent 接入,到前端编辑器集成和线上排坑,给正在做代码推荐生成服务或内部代码助手的开发者一份可参考的实战记录。
1. 这不是一个"调大模型 API"的项目:先看清需求全貌
1.1 第一版为什么长满"幻觉代码"
第一版 demo 只有几百行代码,逻辑简单到不需要画图:前端把光标前的代码截 2000 字符,后端调大模型补全接口,模型返回的文本直接塞进编辑器。给领导和同事演示的时候,效果确实唬人——敲完函数签名,按一下快捷键,后续代码就"自动"续了出来。
可一进入真实项目试用,问题就全暴露了。最典型的一类错误我印象很深:模型推荐了一个OrderService.GetByUserAsync()的调用,看起来命名风格、参数类型都像是我们团队会写的东西,但代码库里根本没有这个方法。因为 prompt 里只有光标前方那段代码,模型完全看不到项目里实际有什么,它只能根据"训练数据里大多数项目的惯例"去猜。这类推荐我统称为"幻觉代码"。它最大的危害不是报错,而是看起来特别合理,容易让开发者不做检查就直接写进去。
1.2 补全之外的四个隐性需求
第一版被打回去之后,我把需求重新拆了一遍。做网页端代码推荐生成服务,真正要满足的其实有四件事:
第一,推荐必须"知道"项目上下文。包括文件结构、命名空间、常用类与方法、关键依赖。模型不应该靠猜,而是按需求去查。
第二,结果要够快、且能流式展示。网页端体验和本地 IDE 的补全不一样,用户等不了十几秒的转圈,必须边生成边渲染。
第三,推荐结果要能安全地插入编辑器。不是纯文本覆盖,而是能预览、接受、拒绝,甚至是"部分接受"。
第四,能力要能持续扩展。今天做推荐,明天可能就要做"提交信息生成""接口文档补全",不能让每一次新能力都从零搭一套 AI 接入管道。
前两点直接促成了我对 MCP 的选择:把"查询上下文"做成模型按需调用的工具,让工具层去接各类数据源;把"流式输出"做成 SSE 通道。后两点则落在了 Senparc.AI 上:它本身内置了 Agent、工具调度和会话管理,后续扩展新能力不需要我再单独维护一套调度逻辑。
2. MCP(SSE)与 Senparc.AI 在链路中的职责边界
这一节先把几个关键概念对齐一下,后面写实现的时候不用来回解释。
2.1 MCP 的标准化夹层:把工具变成 AI 可发现的资源
MCP(Model Context Protocol,模型上下文协议)解决的核心问题,是让 AI 应用能以标准方式发现并调用外部能力和数据。你可以把它理解成"给 AI 配了一套外部设备的 USB 接口标准":只要是支持 MCP 的服务,Agent 不需要额外写对接代码,就能通过一套约定好的协议去发现它有什么工具、每个工具要什么参数、调用后返回什么。
在协议层面,MCP 基于 JSON-RPC 2.0。客户端连接服务器后,第一步做 initialize 握手并协商能力,然后通过 tools/list 获取工具清单,需要执行时通过 tools/call 发起调用。整个流程不依赖任何特定模型,也不绑定任何特定语言。
对我们做网页端代码推荐来说,这套"工具层"的价值非常大。工程上下文查询(查文件结构、搜代码模式、解析依赖版本)原本要后端写一堆 REST 接口,然后我再去 Agent 里硬编码适配。现在只需要把一个一个查询能力注册成 MCP 工具,Agent 里的模型就能在生成代码时自主决定"是不是该查一下项目结构再写",这是我第一版方案里最缺的环节。
2.2 为什么远程场景必须走 HTTP + SSE
MCP 的传输层有两种主流模式:stdio 和 HTTP + SSE。stdio 模式适合本地进程直接拉起的场景,例如在命令行工具里运行一个 MCP Server,两个进程通过标准输入输出通信,零网络开销。我们做的是网页端服务,Server 要么部署在内网机房里,要么作为独立容器运行,客户端来自浏览器,所以只能走远程传输。
从 MCP 官方对 HTTP 传输的定义来看,远程模式使用 Server-Sent Events(SSE)往客户端推送数据。SSE 是单工、基于 HTTP 长连接的技术,服务器可以在一个连接上持续向客户端发送事件。它和 WebSocket 最大的区别是方向性:WebSocket 会建立全双工通道,而 SSE 只需要服务端往客户端推,客户端发请求走普通 HTTP POST 就够了。MCP 正好符合这个形态:浏览器/后端 Agent 发起一次 POST 请求并携带工具调用指令,MCP Server 在执行期间把进度消息、工具结果通过 SSE 逐个推送回来。
我对 SSE 的另一个偏好是它天然带重连和事件格式。连接意外断开时,浏览器端 EventSource 对象会自动重连;如果是自定义 fetch 读取,也可以根据事件流里的 retry 字段做重试策略。这对线上环境很重要,后面会专门讲我在这个环节踩的坑。
2.3 Senparc.AI 的定位:Agent 调度与模型接入的统一入口
Senparc.AI 在整套链路里不是"又一个模型 SDK",而是模型接入与智能体调度的中间层。我在项目里同时面对了 OpenAI、内部大模型网关等几家不同的模型来源,如果每家服务的请求格式、鉴权方式都自己维护一遍,工作量会失控。Senparc.AI 把这层差异封装掉了,我只需要在配置里改模型提供方和 Key,上层业务代码不用跟着变。
它更关键的能力是 Agent 管道。代码推荐并不是"用户问一句、模型回一句"那么简单,而是"理解当前代码 -> 决定是否需要查询工程上下文 -> 调用工具 -> 结合工具结果生成代码"的多步过程。Senparc.AI 的 Agent 会把模型的意图分析、工具调用、结果回填串成一个可编排的流程,并且允许我把 MCP Server 地址注册进来,让 Agent 直接使用 MCP 工具。这样一来,我的业务层代码只需要关心"拿到推荐结果并推给前端",工具边界和上下文管理都交给了 Agent 框架。
为了避免误解,我补充一句:MCP 和 Senparc.AI 不是竞争关系,而是不同层的组件。MCP 解决的是"模型如何标准化地访问工具",Senparc.AI 解决的是"如何把模型、工具、会话编排在一起服务业务"。二者组合起来,才会出现"模型能按需调工具、Agent 能管理工具调用过程"的完整链路。
3. 服务端实现:从 MCP Server 注册到 Senparc.AI 接入
3.1 用 C# 起一个最小的 MCP Server(SSE 传输)
因为整个后端本来就是 .NET 技术栈,MCP Server 我直接挂在 ASP.NET Core 进程里,省掉一类额外进程的部署成本。下面是一段简化的 MCP Server 注册代码,核心是把代码推荐需要的能力暴露成工具:
// 示意代码:具体 API 以当前使用的 MCP SDK 版本为准 var builder = WebApplication.CreateBuilder(args); var mcpServer = builder.Services.AddMcpServer() .WithTool( name: "get_project_context", description: @"获取当前项目的目录结构、命名空间、关键依赖摘要。 当模型需要了解项目整体情况、或不确定引用的包是否存在时使用。 参数 projectId:目标项目标识。", handler: async (string projectId) => { return await projectService.GetCompactContextAsync(projectId); }) .WithTool( name: "search_code_pattern", description: @"在项目代码库中搜索与关键字相关的类、方法或代码片段。 模型准备推荐代码前,若不确定项目是否已有相同实现或约定接口,应先用本工具确认。", handler: async (string keyword, int limit = 5) => { return await codeIndex.SearchAsync(keyword, limit); }) .WithTool( name: "get_package_usage", description: "查询指定依赖包在项目中的安装版本和常用用法摘要。", handler: async (string packageName) => { return await dependencyService.GetPackageUsageAsync(packageName); }); mcpServer.WithHttpTransport("/mcp/sse"); var app = builder.Build(); app.MapMcpServer(); app.Run();我建议工具粒度按"模型决策的最小单元"来切,不要做"一个工具干所有事"。比如把"获取项目上下文"和"搜索代码模式"分开,模型在推荐一个 Service 方法时,可能只需要搜代码模式,不一定非要读整个项目结构;合并成一个大工具会让模型图省事,每次把一大堆不相关内容拉进上下文,既费 token 又干扰推理。
3.2 工具 Handler 内部的数据压缩技巧
工具 Handler 的返回值会直接作为上下文喂给模型,所以"返回什么"和工具本身一样重要。我最初的 Handler 直接返回项目文件树,结果一个中型仓库的树文本就有几万 token,模型根本处理不过来。
后来我总结出一个原则:给模型的结构优先返回"摘要 + 全文兜底"。比如 get_project_context 默认只返回:
- 一级目录结构
- 核心项目文件清单(如工程文件、解决方案、配置文件)
- 每个关键类文件的"类名 + 命名空间 + 公开方法签名",而不是完整源码
等模型真的需要看某个文件的实现细节时,再由另一个工具(比如 read_file_snippet)去取指定文件、指定行区间的原文。这样一次工具调用消耗的 token 能压到几百,模型反而更容易从摘要里找到它需要的信息,调用下一轮工具的准确度也更高。
3.3 Senparc.AI Agent 配置与一次推荐请求的完整内部流转
MCP Server 准备好之后,剩下的工作就是把 Senparc.AI 的 Agent 接进来。我这边大致是这样配置的(示意代码):
var client = new SenparcAiClient(new SenparcAiOptions { ModelProvider = ModelProvider.OpenAI, ApiKey = Environment.GetEnvironmentVariable("AI_API_KEY") }); var agent = await client.CreateAgentAsync("code-recommend-agent"); agent.SystemInstructions = @"你是一个代码推荐助手。你的任务是: 1. 先理解用户提供的当前代码上下文与光标位置; 2. 如果当前代码中引用到的类型、方法或包存在不确定性,必须先调用 MCP 工具确认; 3. 未经工具确认,不得输出可能不存在的 API 或类; 4. 最终只提交代码块和极简注释,不要输出冗长解释。"; agent.RegisterMcpServer("code-context", "http://localhost:5231/mcp/sse");一次网页端推荐请求,内部流转大致是这样的:
- 前端把"当前文件内容 + 光标偏移 + 项目标识"发过来;
- Senparc.AI 的 Agent 收到消息,模型综合分析后决定先调用 get_project_context 或 search_code_pattern;
- Agent 启动 MCP 客户端,通过 SSE 长连接向 MCP Server 发起调用请求,等待工具结果;
- MCP Server 的 Handler 执行查询,先把结果压缩成摘要再返回;
- 工具结果被回填到模型上下文,模型开始续写推荐代码;
- 模型输出的文本流式返回给业务层,业务层再包装成 SSE 事件推给前端。
这个过程里有一点很容易被忽略:模型不是每次推荐都会调用工具,它可能判断当前内容足够清楚就直接生成了。所以 Agent 的系统提示词和工具的 description 写得越具体,模型"什么时候该查、什么时候不该查"就越不容易出错。我在后台留过一段日志,能直观看到这种多步决策:
[Agent] 收到推荐请求 (file: Controllers/OrderController.cs, cursor: line 42) [Agent] 意图分析:用户正在编写 CreateOrder 方法,不确定项目内是否已有订单校验逻辑 [Agent] 决定调用工具 search_code_pattern,关键字 "OrderValidator" [MCP] 通过 SSE 通道发送 tools/call [MCP] 工具返回:找到 OrderValidator.ValidateAsync(3 处引用),摘要完成 [Agent] 工具结果已回填,继续生成推荐代码 [Agent] 流式输出开始...4. 前端接入:Monaco Editor 如何拼接流式推荐
4.1 用 fetch + ReadableStream 解析 SSE,而不是 EventSource
很多教程里提到 SSE 就默认用 EventSource,但它有一个硬限制:只能发 GET 请求。代码推荐要提交的是完整文件内容和光标位置,体量动辄几千字符,甚至还带鉴权 header,塞进 URL 既不合适也不安全。所以我用的是 fetch 发起 POST,然后通过 response.body 的 ReadableStream 手动解析 SSE 格式。
SSE 的消息格式其实很简单:多个字段以换行分隔,不同消息之间隔一个空行。最常见的字段是 data。一个推荐片段大概长这样:
data: {"type":"delta","content":" public async Task<Order>"}前端解析的示意代码如下:
async function requestCodeRecommend(content, cursorOffset, filePath, signal) { const resp = await fetch('/api/code-recommend', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Token': token }, body: JSON.stringify({ content, cursorOffset, filePath }), signal: signal }); const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop(); // 保留未完成的半条事件 for (const part of parts) { for (const line of part.split('\n')) { if (line.startsWith('data:')) { const payload = line.slice(5).trim(); try { const json = JSON.parse(payload); if (json.type === 'delta') appendSuggestion(json.content); } catch (e) { // 兼容非 JSON 的纯文本事件 } } } } } }一个实用细节:TextDecoder 一定要开启{ stream: true }。中文等多字节字符在 UTF-8 下可能被分在两个网络包里,如果不传 stream 参数,解码器遇到截断的字节序列会抛错或者输出乱码。我第一次没注意,线上出现过一阵偶发的乱码字幕,排查了很久才发现是 TextDecoder 的默认非流式模式导致的。
4.2 触发时机、取消与悬浮建议的交互策略
流式解析只是第一步,真正影响体感的是编辑器交互策略。我用的是 Monaco Editor 提供的 completion provider 机制:用户输入停顿约 500ms 后触发一次推荐请求,返回值通过一个自定义的"建议槽位"展示,用户按 Tab 或点击才接受。
有个交互细节值得单独说:在流式生成过程中,用户可能已经继续打字了。如果此时还在把生成的代码往建议里灌,会出现"推荐结果跟用户新输入打架"的情况。所以我在发请求时就保存了 AbortController,一旦编辑器内容发生变化且偏移量和请求时不一致,立刻中止还在运行的 SSE 流。宁可这次推荐作废,也不要展示一份基于旧代码的结果。
另外,Monaco 的 completion provider 是同步返回建议项的,流式内容不能直接塞进去。我的处理方法是先注册一个"空的占位建议项",在推荐服务流式返回片段时,更新该项的 insertText 和 detail 描述。这样用户看到的效果就是推荐内容逐字往上跳,而且按 Tab 能正确插入。
提示:代码推荐这类场景,取消机制不是附加功能,而是必需功能。没有取消的流式推荐,几乎必然会在用户快速输入时造成"旧推荐覆盖新意图"的体验事故。
5. 线上踩坑记录:断流、超时与"上下文漂移"
5.1 SSE 流在半路断开:一份完整的排查链路
服务上线后第一个投诉是:网页端的推荐流"偶尔生成到一半就停了",浏览器控制台里能看到类似 stream disconnected before completion 的报错,也有人管它叫 idle timeout waiting for sse。第一次遇到这个错,我以为是后端代码问题,但开发环境怎么测都复现不了。
排查链路大概走了一遍,严格来说是从连接链路的外层向内层逐层排除的:
先是 nginx。线上流量都走 nginx 反代,开发环境直连后端端口,这个差异立刻成为最可疑点。我用 curl 连着打了几次线上 SSE 端点,发现长时间没有数据时,连接在大约 60 秒后被 nginx 掐断。SSE 虽然能维持连接,但 nginx 的 proxy_read_timeout 默认 60 秒,期间没有任何数据返回,上游就认为超时了。修复方式是调整代理配置,并为 SSE 单独设置路由:
location /mcp/sse { proxy_pass http://backend; proxy_buffering off; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_http_version 1.1; proxy_set_header Connection ""; }这里 proxy_buffering off 也很关键。nginx 如果开着缓冲,会把上游吐出来的小块事件攒成大批再发给浏览器,流式效果直接被打没了。
外层搞定后,我又在后端加了一层"心跳保证"。因为代码推荐工具在正常执行时会有几秒空窗,这段没有事件输出的时间足够让任何中间层以为连接死了。我在后端按固定间隔输出 SSE 注释行: ping,SSE 格式里以冒号开头的行是注释,浏览器会忽略它,但连接被"续命"。对长期保持的 MCP SSE 连接,这套"注释行心跳"是标准做法。
5.2 "推荐过的代码"被当成"项目已有代码":上下文漂移处理
第二个坑更隐蔽。我的会话机制最初设计成保留历史消息,方便用户连续追问"改成异步版本""加上异常处理"。结果跑了一段时间后,推荐质量明显劣化:模型动不动就把上一轮自己推荐过的代码当成"项目里已经存在的代码",围绕它继续展开,甚至出现自相矛盾的推荐。
根因是上下文策略不对。代码推荐这个场景里,历史消息里的"AI 推荐的代码"和"用户原本的代码"对模型来说都是文本,它根本分不清哪段是事实、哪段是自己生成过的假设。既然分不清,模型就很容易把假设当作事实继续推理。
我的处理方案有三层,缺一不可:
- 代码推荐会话只保留最近两轮上下文,超过的部分定期裁剪,不再做完整历史回溯;
- 系统提示词里明确写了一句:你在此会话中生成过的任何代码,不得作为后续推荐的依据;
- 在消息数据结构里,给 AI 推荐结果单独打一个 role 标记,工具层面把它与用户代码隔离存储。
这个经验倒不只在代码推荐场景里成立。凡是"AI 输出物会被写回上下文、并且后续还会继续生成"的场景,都建议用类似的方式把"生成的东西"和"事实的东西"分开,否则模型一定会在某一轮开始自我引用。
提示:发生了上下文漂移,不要只靠改 prompt 解决。prompt 是告诉模型"不要这么干",而数据结构隔离是让模型"无法这么干",后者往往更可靠。
5.3 多用户并发的隐性串话:会话边界必须显式隔离
第三个问题在压测时暴露。同时开三个浏览器标签做推荐,A 标签页的推荐内容里混进了 B 标签页才可能出现的代码引用。查了很久才发现原因不是模型调错了,而是我的 Agent 被当成了单例复用,工具调用的返回值放在一个静态缓存里,并发请求到达时后一个请求覆盖了前一个的结果。
这类问题在普通 Web 应用里不常见,但 AI 工具调用是异步的,时间线会拉长,静态状态很容易被交叉污染。我给每个"编辑器页签/用户会话"创建独立的 ChatSession 实例,并在每次推荐请求中显式携带 sessionId;MCP Server 的 Handler 一律只按请求参数计算,不读任何全局状态。
这里最反直觉的一点是:MCP 工具本身设计成"无状态服务",但你自己写的 Handler 可能会因为贪方便把结果缓存到静态字段里。正确做法是让工具执行期间的所有中间结果都走请求作用域,连并发缓存都要带 SessionId 作为 key。
6. 性能复盘与让推荐结果更可靠的调优思路
6.1 一次推荐请求的时间都花在了哪儿
线上稳定之后,我统计了一批请求的耗时分布。用表格整理出来:
| 阶段 | 约占比 | 说明 |
|---|---|---|
| 模型意图判断与工具选择 | 15% | 受工具数量和 description 质量影响 |
| MCP 工具执行与结果压缩 | 20% | 查询索引、读依赖、摘要生成 |
| 模型生成推荐代码 | 55% | 与 max_tokens、模型参数量相关 |
| 网络与前端渲染 | 10% | SSE 分发 + Monaco diff 计算 |
最值得压缩的是"模型生成"阶段。一个常见的误区是把 max_tokens 拉到很大,希望模型一次把方案写完整。我的实践是把它控制在 300~600 token 左右,让模型专注生成关键代码片段;如果用户需要完整方案,再通过后续指令继续展开。推荐任务不是写论文,一次给太多的输出反而容易失控。
工具执行阶段也有优化空间。原本 get_project_context 每次都是即时查询并实时做摘要,后来我在内部索引服务里加了缓存,项目结构变化触发的版本号更新才刷新缓存。实测这块耗时从 1.2 秒降到了 300 毫秒以内。
6.2 工具描述与采样参数:两个常被忽视的质量杠杆
很多人调推荐质量只盯着 prompt 和模型版本,却忽略了两个同样重要的点。
一个是 MCP 工具的 description 要写得"会让模型困惑"。description 写得太泛,模型会把不相干的请求也调工具;写得太窄,该调的时候又漏调。我后来遵循的模板是:这个工具什么时候用、什么时候不用、参数应该怎么传、返回结构是什么。比如 get_package_usage 的 description 明确写了"当用户代码引用了某个包而不确定版本是否兼容时使用",模型就能更准确地触发它。
另一个是采样参数。代码类任务不适合高随机性,我用的 temperature 在 0.2~0.4 之间,top_p 也收敛在 0.8 左右。这个配置下,生成速度快于激进采样,且"幻觉代码"出现的频率有肉眼可见的下降。当然不同模型提供方的参数意义略有差异,但逻辑是一致的:代码推荐更接近"检索增强的补全",不是创意写作,模型的确定性应该优先。
除了这些调优之外,还有两个调试层面的习惯想提醒一下。第一个,MCP Server 的工具都注册完之后,先别急着接 Agent。用官方调试控制台或者一个简单的测试客户端,把每个工具手动调一遍,确认返回值结构是模型友好的扁平 JSON。否则等模型调用时报错,排查链路会长很多——你没法确定到底是工具写错了,还是模型的调用参数传错了。第二个,务必保留一条"无 MCP 工具"的降级链路。当 MCP Server 短暂不可用的时候,直接走"仅用当前文件上下文推荐"的老逻辑。哪怕推荐质量低一点,也比整个服务报错强;用户对"没有推荐"的容忍度,远低于对"推荐了但不如不推荐"的容忍度。至少在我们内部,这两条建议帮我节约了大量应急排查时间。