不少朋友问我,AI工程到底该怎么入门。市面上聊AI工程的课程和帖子,动不动就是RAG、Agent、微调、大规模分布式推理,看起来高深,真正上手却发现离自己的日常工作很远。我自己的体会是,与其从那些宏大概念开始,不如找一个能塞进真实工作流的微型项目,把AI能力真正用起来。我最近做了一个代号为ai-engineering-from-scratch的小工程——给Claude Code写一个MCP反馈插件,用来做代码库安全风险扫描和重构影响评估。整个过程从设计、实现到部署、评测,覆盖了AI工程的全链路。这篇内容不是理论科普,就是一次完整实操的复盘,希望对想系统接触AI工程、却又不知道从哪里下手的开发者有点帮助。
1. 为什么“从零开始做AI工程”值得认真对待
1.1 AI工程不是“调大模型接口”,而是“让模型变可靠”
很多开发者以为,接上大模型API就算做了AI工程。真不是。你调通一个OpenAI或Anthropic的接口,那只是拿到了一个“会说话的引擎”,而AI工程解决的是把引擎装进真实系统后的一系列麻烦:输入不可控、输出不稳定、失败成本高、响应时延飘忽不定、安全和隐私怎么兜底、效果怎么评估。这些才是日常开发中真正消耗精力的地方。
传统软件工程里,你写的函数只要逻辑正确、资源不泄漏,基本就能交付;AI工程里,模型给出的答案可能部分正确、可能完全自信地说错,还可能因为一段措辞不同的Prompt产生完全不同的行为。你没法像断言返回值那样约束它。所以做AI工程,本质上是在技术和不确定性之间搭桥,把“模型输出”变成可以纳入工程体系的“结构化资源”。
这也是为什么我特别认“from scratch”这个思路。它不是说要你从反向传播开始手写Transformer,而是说,把AI能力从想法到落地跑通一遍完整路径,亲身体会各个环节的取舍。一次完整的血泪实操,比刷十篇架构文章有用得多。
1.2 一个迷你但完整的案例:给Claude Code写MCP反馈插件
我选择的切入点非常小:给Claude Code写一个MCP扩展插件。MCP全称是Model Context Protocol,简单理解就是给AI模型外接工具的标准协议。Claude Code本身是Anthropic推出的命令行编程助手,它能读懂代码库、改代码、运行命令。但它默认并不知道你们公司内部API的调用规范,也不知道某个依赖在你们生产环境里的真实风险。MCP插件就是用来补这些信息差的。
我写的这个插件叫yio,它做了三件事:
- 扫描依赖清单文件,对比内部漏洞库,输出高风险、中风险的依赖建议;
- 分析代码调用链,估算一次重构会影响的文件范围和测试集合;
- 把工具调用频率、失败率、耗时时长等匿名指标通过事件上报到自己的分析服务,为后续迭代UI和Prompt提供数据依据。
范围很小,但它精准命中了AI工程的核心环节:协议接入、工具设计、上下文管理、评测闭环、可观测性。做完这个项目,你会对“AI工程”这四个字有一个很扎实的体感,而不是停留在名词层面。
1.3 这篇分享适合谁读、你能带走什么
如果你是有几年经验的后端、前端或测试开发,想转AI应用方向,那这篇内容很适合你。你不需要懂模型训练,只要会写TypeScript或者Python,能理解基本的进程通信,就能跟上。如果你已经在写一些AI辅助脚本,但总觉得散不成体系,这篇内容也会帮你梳理出一条完整的工程路径。
跟着走一遍之后,你会带走三样东西:
- 一套可以直接复用的MCP插件工程模板,包括配置管理、工具注册、协议调试;
- 一套针对AI工具效果的评估思路,知道怎么从“能跑”到“靠谱”;
- 一份踩坑清单,都是我实际开发中会耽误一整天的类型问题,你可以直接绕过去。
2. 动手前的系统性设计:模块划分与工具链选型
2.1 模块边界:四个组件,少一个都不行
接到这个任务,我的第一反应不是打开编辑器写代码,而是先花半小时把模块边界画清楚。任何工程只要注入了“模型能力”,就不太可能像写小脚本那样一个文件搞定,因为你要同时面对协议解析、工具逻辑、外部API、数据上报四类问题,混在一起后期会非常痛苦。
我最终拆成了四个组件:
config:负责读取环境变量、配置文件、合并默认值,所有工具的鉴权信息、超时控制、开关策略都在这里统一管理;protocol:负责MCP协议层的消息解析、请求校验、错误码转换,让上层工具完全不用关心JSON-RPC细节;tools:实现具体业务工具,比如依赖安全扫描、重构影响评估,它们是纯粹的输入处理+逻辑计算+结果返回;telemetry:负责事件上报,包括工具调用次数、失败原因分类、耗时分布,并且支持开关和脱敏。
这样划分的逻辑很简单:协议层会跟着SDK升级变,工具层会跟着业务需求变,上报逻辑会跟着指标口径变。如果三者耦合在一个文件里,任何一方变动都会引发连锁故障。而分开之后,每个组件都可以独立测试、独立替换,甚至独立上版本。这个设计原则,和你在传统后端写service、repository、controller的拆分思路是一致的。
2.2 技术选型:Node.js + TypeScript 为什么比 Python 更顺手
技术选型上,我几乎没有犹豫就选了Node.js + TypeScript,而不是很多人默认的Python。原因很实际。
MCP官方SDK对TypeScript的支持非常完善,Claude Code本身通过npx就能拉起Node进程,标准输入输出通信在Node里处理起来很顺。而Python生态在这一块也不差,但如果你用的是一个集成开发工具,最终总会遇到“要给Claude Code临时装一个Python环境”的尴尬——版本冲突、依赖缺失、环境变量不一致,这些破事会拉低开发体验。
TypeScript带来的类型安全不是“锦上添花”,在MCP插件开发里它是刚需。每个工具要做输入参数描述,这些描述会经过序列化、传输、再解析,最后落到一个JSON对象里。没有类型定义,你根本不知道模型给你传进来的参数到底是字符串还是数字,是一个路径还是直接塞了一段文件内容。有了类型和运行时校验,协议层后面才有机会做更细的容错。
另外,MCP插件的本质是一个长驻进程,Node在处理长驻I/O任务上的表现非常稳定,资源占用也可控。配合tsx做开发时的热运行,迭代起来很舒服。
2.3 关键参数设计:超时、置信度与评分阈值不能拍脑袋
写这类插件,最忌讳的就是参数全凭感觉。我在动手前就把几个核心参数定下来了,每个参数都经过了推导。
第一个是外部API的超时时间。我的安全扫描需要调用内部漏洞库接口,这个接口的P99延迟实测大概是2.8秒。超时如果设在1秒,线上有1%的请求必然失败;如果设在10秒,用户等一个扫描结果要卡半天。我取的是5秒——在P99基础上预留了接近两倍余量,保证了绝大多数请求能成功,又不会让交互显得拖沓。
第二个是置信度阈值。安全评分逻辑会综合依赖版本落后情况、已知漏洞数量、维护状态给出0到1的置信度。我用一个包含40个真实案例的标注集,分别跑了一遍阈值设为0.7、0.8、0.9时的准确率和召回率,最后选了0.8。这个数值的含义是:宁可少报两条边缘风险,也不允许把安全结果报错。
第三个是返回内容大小限制。工具返回给模型的结果不是越多越好,上下文窗口有限,塞太多信息反而会干扰后续推理。我把每条建议的token上限控制在200以内,一次扫描总时长不超过几十秒,整体返回控制在1500 token以内。这个参数直接影响了后面评测集的上下文膨胀率指标,后面会详细说。
3. 核心实现:一个MCP插件从0到1
3.1 初始化工程:先搭一个能跑的MCP服务骨架
我习惯把项目初始化这一步当成“冒烟测试”来做,确保最小骨架能跑通,再往里填业务逻辑。工程结构很简单,src目录下放了四个子目录,对应前面说的四个模块,根目录放package.json和tsconfig.json。
下面是MCP服务骨架的代码,这个骨架是后续所有工具的基础:
// src/index.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { loadConfig } from "./config/index.js"; import { registerSecurityTools } from "./tools/security.js"; import { registerRefactorTools } from "./tools/refactor.js"; const config = loadConfig(); const server = new McpServer({ name: "yio", version: "0.1.0", }); registerSecurityTools(server, config); registerRefactorTools(server, config); const transport = new StdioServerTransport(); await server.connect(transport);这段代码的核心是McpServer实例加StdioServerTransport。前者负责维护工具注册表和处理JSON-RPC消息,后者把进程的标准输入输出变成通信管道。Claude Code启动插件时,会和这个进程建立双向通信:模型发送请求,插件返回结果。整个过程对用户是透明的,你只要保证这个进程能正常被拉起、正常响应请求就行。
骨架搭好之后立刻验证:启动进程,往里塞一个JSON-RPC初始化请求,看它是否返回标准响应。只有这一步通了,后面写工具才有意义。
3.2 配置加载:环境变量、默认值与密钥处理
配置模块是整个插件的“入口命门”,处理不好,后面所有工具都会跟着遭殃。我做配置加载时,遵循了一个分层合并的原则:默认值优先,其次是环境变量,最后是显式配置文件,后面的覆盖前面的。
安全扫描工具需要访问内部漏洞库接口,必须带上一个API Key。这个Key是团队内的共享凭证,不能写死在代码里,也不能提交到Git仓库。我的处理方式是:
- 默认从环境变量读取,变量名叫
YIO_API_KEY; - 如果环境变量里没有,再尝试读取
~/.yio/config.json; - 两份都找不到,工具直接返回错误,而不是带着空Key去请求外部API。
这个设计看起来简单,实际运行中救了很多次命。团队里新同学拉下代码,只要配置好环境变量就能跑,不用改任何代码。密钥信息只存在于运行环境的上下文中,代码仓库里永远干干净净。
此外,配置里还放了一个“开关”:telemetry.enabled。如果用户不想上报任何使用数据,一键关掉。从工程伦理角度讲,任何数据采集都必须给用户明确的选择权。我这个插件默认开启,但首次运行时会打印提示,告知数据用途和关闭方法。
3.3 实现第一个工具:依赖安全风险扫描
安全扫描工具的目标是:给定一个依赖清单文件,输出每个依赖的风险等级和修复建议。我用TypeScript写了一个简化版的分析逻辑:
// src/tools/security.ts import { z } from "zod"; import { checkVulnerabilityDB } from "../services/vuln-db.js"; export function registerSecurityTools(server, config) { server.tool( "scan_dependency_risks", { manifestPath: z.string().describe("依赖清单文件的绝对路径"), }, async ({ manifestPath }) => { const entries = await parseManifest(manifestPath); const checks = entries.map(entry => checkVulnerabilityDB(entry.name, entry.version, config)); const results = await Promise.all(checks); const high = results.filter(r => r.level === "high"); const medium = results.filter(r => r.level === "medium"); const suggestions = buildSuggestions(results, config.threshold); return { content: [ { type: "text", text: formatReport(high, medium, suggestions), }, ], }; } ); }这里有几个值得展开的细节。
manifestPath的类型描述不是随便写的。我把“这是一个文件路径”这个语义写得很清楚,模型才能把用户口中的“检查一下package.json”映射成对这个参数的正确赋值。如果描述写成任意字符串,模型就会把整个文件内容当作参数传进来,不仅浪费token,还会破坏后续解析逻辑。
Promise.all并行请求确保了扫描效率。真实场景下,一个大型项目的依赖可能有四五百个,串行请求会把整个插件拖到超时。并行之后,几十毫秒就能拿到绝大多数结果。
最后返回给模型的是格式化后的文本报告。我刻意不让工具直接返回原始JSON,因为模型擅长读自然语言,而不是解析嵌套结构。格式化的重点是:风险等级放最前,受影响函数和修复建议紧随其后,优先级一目了然。
3.4 实现第二个工具:重构影响面评估
第二个工具解决的是重构场景。模型在代码库里找到一段需要重构的函数,但不确定改完会影响哪些调用方。我的工具会做一个静态调用链分析,粗略估算影响范围。
// src/tools/refactor.ts export function registerRefactorTools(server, config) { server.tool( "estimate_refactor_impact", { sourcePath: z.string().describe("待重构源文件路径"), functionName: z.string().describe("需要重构的函数或方法名"), }, async ({ sourcePath, functionName }) => { const graph = await buildCallGraph(sourcePath); const impacted = graph.findImpactedFiles(functionName); const tests = findRelatedTests(impacted); return { content: [ { type: "text", text: describeImpact(sourcePath, functionName, impacted, tests), }, ], }; } ); }这个工具的实现难点在于,调用图构建不能太重。如果用上完整的AST分析库,处理一个中型项目可能要花好几秒,体验很差。我的方案是走“正则+启发式”的轻量路径:先提取文件间的导入关系,再通过函数名匹配找出直接和间接引用点。这样牺牲了一部分准确性,但换来的是几十毫秒的响应速度。
权衡是可以接受的。这个工具的目的是给模型一个“初步判断”,让它在改代码之前心里有数。真正精确的影响面评估,完全可以交给CI流水线里的静态检查工具去做。在AI辅助编程的场景里,速度带来的交互价值远大于一点点的精度提升。
3.5 接入Claude Code:mcpServers配置与本地联调
工具写完,最后一步是让Claude Code认识这个插件。Claude Code的MCP配置放在项目根目录的.mcp.json里,我是这样配置的:
{ "mcpServers": { "yio": { "command": "node", "args": ["dist/index.js"], "env": { "YIO_API_BASE_URL": "https://api.internal.example.com", "YIO_API_KEY": "xxxxxxxx" } } } }这段配置的意思是:Claude Code会在需要调用yio工具时,用node dist/index.js拉起一个子进程,通过标准输入输出通信。env字段会把密钥和API地址注入到插件的进程环境变量里。
联调时有个特别坑的细节:改完配置后必须重启Claude Code会话,它不会自动重新加载MCP服务。我一开始改完配置直接在会话里继续聊,结果模型始终报“工具不存在”,浪费了大半天。后来养成习惯:每次改完代码,npm run build,然后重启会话,再验证。
本地联调还有一个神器叫MCP Inspector,用npx @modelcontextprotocol/inspector就能启动。它提供一个可视化界面,可以手动给插件发各种请求、看原始协议消息。我在开发阶段几乎每个工具都先用Inspector过一遍,确认请求响应完整了再回Claude Code里做端到端验证。这一步能帮你把“插件逻辑问题”和“模型调用问题”快速区分开。
4. 工程化闭环:部署、评测与迭代
4.1 用MCP Inspector做协议级调试
很多开发者写完工具插件,只会在Claude Code里随口问一句“你能不能扫描一下我的package.json”,看到结果就跑,根本不检查底层的协议通信。这样出了Bug完全不知道是模型不会调用,还是工具逻辑有错。
MCP Inspector的价值在于,跳过模型直接和工具对话。你可以手动输入一个工具名和参数,看它返回什么;也可以查看原始JSON-RPC消息的每一帧,确认参数解析是否命中了预期结构。我在调试scan_dependency_risks时,就发现过一个问题:Inspector里传入的manifestPath是带引号的字符串,而模型有时候会传入不带引号的路径。如果没有这一层调试,这种问题只有在真实对话里随机出现,排查成本极高。
使用Inspector还有一个技巧:把environment参数设置成和Claude Code完全一样的配置,包括环境变量和启动命令。这样能保证你在Inspector里验证通过的行为,在Claude Code里也是可复现的。我见过有人因为两边配置不一致,导致调试半天没问题、一上真实环境就崩。
4.2 自建回归评测集:从“能跑”到“靠谱”
工程化最容易被忽略、但价值极高的一步,是给AI工具建一个回归评测集。模型不像普通程序,改一行代码可能让它在20%的场景下表现突变。没有评测集,你根本无法判断一次改动是优化还是退化。
我花了点时间建了一个小评测集,包含20个典型请求,分布在四个维度:
- 常规请求:比如“扫描我的package.json”“估算修改logger.js里init函数的影响”;
- 边界请求:比如依赖清单文件不存在、函数名拼写错误、版本号缺失;
- 恶意/异常请求:比如路径指向
/etc/passwd、参数包含超长字符串; - 多轮上下文请求:在前面对话基础上追加请求,看工具能否正确复用上下文。
每次代码改动后,我会跑一遍评测集,记录三个指标:工具调用成功率、平均响应时延、返回文本的token膨胀率。成功率不用解释;时延直接影响交互体验;token膨胀率则代表这条工具输出占用了多少模型上下文。膨胀率过高,会压缩后续对话的可用窗口,导致模型“忘记”早期指令。
我最终给自己定的及格线是:成功率不低于85%,P95时延不超过5秒,单条工具返回不超过1500 token。这套指标不一定适合所有项目,但“用可量化的指标来约束AI行为”这个思路,应该成为AI工程的默认动作。它把“我感觉变好了”变成了“评测集上成功率从82%提到了91%”,这两者的可信度完全不同。
4.3 发布策略与版本管理:别急着打1.0
插件开发完,要上线给团队用,版本策略就得跟上。我采用的是语义化版本号,规则很简单:
- 修复一个Bug,比如API地址拼错、某个边界场景抛异常,涨
patch,比如0.1.0到0.1.1; - 新增一个不影响已有工具行为的工具或参数,涨
minor,比如0.1.1到0.2.0; - 改动工具的参数结构或返回格式,导致旧版Prompt无法正常调用,涨
major,哪怕只是0.x到0.y,也要遵守这个约定。
这个策略的核心逻辑是:MCP插件的“接口契约”是模型通过Prompt和工具schema建立的。一旦工具的参数或返回结构变了,模型中缓存的对这个工具的理解就失效了,表现为“之前用得好好的,突然不会调了”。所以任何破坏契约的改动,都必须是显式的、大版本的、需要人工确认的。
发布方面,我打包成了npm包,团队同学在项目里通过.mcp.json直接指向安装后的dist/index.js。这样升级时只需要换依赖版本号,不需要大家手动改路径。早期版本我尝试过把源码直接放在共享盘让大家clone,后来发现版本混乱问题频出,果断放弃。发布工具插件也是一等工程事物,该走流程就走流程。
4.4 避坑清单:五条真金白银的经验
这部分是我最想写的内容。以下是这个项目过程中真实踩过的坑,每个都耽误了至少半天,希望你看完能直接绕过去。
坑一:工具参数的描述写得太宽泛。最初我把manifestPath描述成“manifest文件相关信息”,模型就会有时传路径、有时传文件内容、有时传目录名。改成“依赖清单文件的绝对路径,例如/src/app/package.json”之后,几乎不再出错。模型对参数的理解,完全取决于你在schema描述里给出多少约束和示例。描述越具体,模型行为越稳定。这是AI工程里投入产出比极高的一件小事。
坑二:stdio传输时日志污染协议。插件进程的标准输出是用来传MCP消息的,如果你在代码里顺手console.log一条调试信息,这串字符会混进协议流,直接把通信干崩。我之前就因为一个调试日志没清理,导致Claude Code隔几分钟就报协议错误,排查了很久。正确的做法是:所有日志写入独立文件,或者走标准错误输出stderr。这个教训对任何基于stdio的插件都适用。
坑三:密钥写进配置文件被提交到Git。有一次我把API Key写进了.mcp.json的env字段,忘了加忽略规则,差点推到公共仓库。以后我的做法是:.mcp.json只作为模板提交,真正的密钥放在~/.yio/config.json,并用gitignore排除;.mcp.json里通过env字段显式指向本地配置文件。这样既方便团队协作,又不会泄密。
坑四:改完代码忘了重启会话。Claude Code对MCP插件的加载是“启动时快照”,修改插件代码不会热更新。我至少碰到五次改完代码以为没用,后来发现是没重启。最稳妥的做法:写一个小脚本,一键完成build、杀掉旧进程、重启会话,把操作成本降到最低,你才更容易每次都做对。
坑五:返回给模型的结果塞了太多噪声。早期版本我把漏洞库返回的原始JSON几乎原封不动地塞给模型,结果上下文窗口被大量无关字段占满,模型反而抓不住重点。后来我加了一个格式化层,只保留“风险等级、影响版本、修复版本、一句话描述”,效果立竿见影。AI工程的上下文管理,不只是“给多了会超限”的问题,更是“给对了模型才能答对”的问题。
5. 做完这个项目之后,再看AI工程的全貌
5.1 输入端与输出端:可控性是一等公民
做完这个插件之后,我再去看各种AI系统,第一反应就是看它的输入和输出两端是否可控。
输入端,模型能拿到哪些上下文、能调用哪些工具、参数结构是什么,必须在协议层面用schema和权限锁死。就像你不会让一个实习生直接访问生产数据库并执行任意SQL一样,你也不该让模型在没有任何约束的情况下去调用内部工具。MCP这类协议本质上就是给模型画了一个“可以做/不可以做”的操作边界。
输出端,模型返回的内容要尽量结构化、可验证、可降级。我的插件每次返回前都会做一次格式校验,发现结果字段异常就降级成普通文本提示,而不是把一个坏结构抛给上层。这样即使模型调用出错,用户得到的也是一个可理解的错误信息,而不是一坨解析不了的乱码。可控性不是一个锦上添花的设计,而是AI系统能不能放心交给用户的关键。
5.2 可观测性:给AI系统装上仪表盘
这个项目里,我花了不少精力在事件上报上:每次工具调用的耗时、成功失败、耗时分布、模型传参是否命中预期结构。这些数据看起来不起眼,但它直接决定了后续优化方向。
举一个具体例子。通过上报数据,我发现estimate_refactor_impact这个工具的平均耗时是scan_dependency_risks的三倍,而调用成功率只有78%。进一步排查发现,大部分失败都来自函数名匹配不到调用点。于是我把匹配逻辑从“精确匹配”改成了“精确匹配+模糊匹配双通道”,成功率一下提升到了91%。如果没有可观测的数据,这种优化完全靠猜,效率极低。
可观测性的核心原则是:每次请求都留痕,每条错误都可归类,每个指标都可对比。只要做到这三点,AI系统就不再是不可捉摸的黑盒,而是可以持续打磨的工程产品。我见过很多团队把AI应用上线后就不管了,出了问题时只能靠用户反馈去猜,这其实是工程意识的缺失。
5.3 AI工程师的能力成长路径:从小工具到平台
回顾从设想到落地的整个过程,我更坚定了“AI工程师能力是在小项目里长出来”的看法。你不需要先精通分布式系统、再掌握模型原理,才能碰AI工程。从一个给IDE装“眼睛”和“手”的小插件开始,你就能接触到AI工程最核心的命题:如何理解模型的行为边界,如何设计工具接口,如何评估效果,如何用数据驱动迭代。
随着经验积累,你自然会往更多方向扩展:接入更多工具类型、优化上下文策略、引入更复杂的评测体系,甚至把手上的MCP插件扩展成支撑团队的平台服务。但起步阶段,成本最低、反馈最快的方式,永远是做一个你每天都在用的小工具。这个工具,会让你对AI工程的真实复杂度产生敬畏,也会让你在谈论Agent、RAG这些概念时,不再是纸上谈兵。
按照我个人的经验,做AI工程最忌讳的是一开始就想搭一个面面俱到的平台。找个小场景,做深做透,比画一张宏大的架构图有用得多。你需要的不是更多的理论,而是把一个真实问题从发现到解决完整地走一遍。这个过程会逼你把协议、配置、评测、发布、可观测性这些环节全部过一遍,而恰恰是这些环节,构成了AI工程区别于普通脚本开发的全部厚度。
如果你也想试试,不要犹豫,从你最常用的AI工具里找一个痛点,给它写一个MCP插件。这个周末就可以开始。