
上个月我把一批本地脚本接进了日常的 AI 编程流程本意很简单让大模型帮我读日志、查表结构、跑回归脚本。结果第一周就翻车了。工具一多它开始挑错工具系统提示写到两千多字关键约束被淹在中间改一个脚本参数我得把整段提示词重写一遍改完还得重新验证会不会误触发别的功能。折腾到第三周我才想明白问题不在模型不够聪明而在我把两件完全不同的事混成了一件能力的封装方式和接口的标准化。标题里提到的Skills和MCP正好对应这两件事。Skills 解决的是模型该在什么时机、按什么步骤做事本质是知识与时序的组织MCPModel Context Protocol解决的是模型怎么用统一的方式够到外部世界本质是通信契约。这两个东西单独看都不复杂难的是把它们串成一条能跑稳的全链路。这篇文章就是我这几周的完整复盘概念怎么拆、技能包怎么写、服务端怎么调、token 怎么算、哪里最容易崩。适合已经动手在搭 AI 编程工作流、但被工具膨胀和上下文失焦折磨过的同行也适合刚接触大模型应用、想搞清这两块边界在哪的读者。1. 先把概念掰开Skills 与 MCP 到底谁管什么1.1 我踩过的第一个坑把所有能力都塞进系统提示最开始我的做法特别朴素一个大系统提示里面写清楚你可以查询数据库、可以读日志文件、可以跑测试脚本然后把每个能力的具体用法、参数说明、示例全写进去。刚开始只有三个能力跑得挺顺。加到第八个的时候提示词膨胀到三千多字模型的表现在两个地方明显变差一是简单任务上它开始过度思考明明只需要读个文件它会把整套排查流程走一遍二是复杂任务上它漏步骤尤其是提示词中段那些约束比如只读查询不要加锁经常被忽略。后来我做了个对照实验把同样的能力清单分别用全塞系统提示和拆成多个技能包、只在需要时加载两种方式跑同一批任务。结论很明确——上下文不是越多越好而是越相关越好。全塞的方式下每一轮对话都要为全部能力描述付出 token 成本而真正用得上的往往只有一两个。拆成技能包之后常驻的只有每个技能的几十字元数据正文等触发了再读平均每轮省下的输入 token 相当可观更关键的是模型的注意力没被无关描述稀释。这就是 Skills 这个机制存在的根本理由。它不是一个新概念你可以把它理解成给模型看的操作手册书架书架上贴着书脊标签名称和一句话说明模型先看标签判断要不要拿这本书拿下来才读里面的正文。而不是一上来就把整个图书馆搬进它的工作台。1.2 一张表看清两者的分工很多人第一次接触会把这俩当成竞品其实它们连层次都不一样。我用一张表把边界划清楚维度SkillsMCP解决的问题模型知不知道怎么做模型能不能够得着载体形态文件夹 描述文件 附属脚本客户端与服务端之间的协议生效方式按需加载进上下文工具列表注册后按调用触发内容性质流程、判断标准、踩坑经验、模板函数签名、输入输出约束变更成本改文件即生效不需要重启服务改工具定义需要客户端重新拉取列表典型失败触发条件写得模糊导致误触发工具描述不清导致选错工具或参数错谁来决定用不用模型读元数据后自主判断模型根据工具描述自主选择调用一句话总结我的理解Skills 是教练手册MCP 是工具箱接口。手册告诉模型什么场景该干什么、按什么顺序干、什么情况要停下来问人接口保证模型伸手去拿扳手的时候拿到的确实是扳手而且知道扳手的口径是几毫米。这个区分带来一个很实际的架构决策凡是需要判断、需要多步、需要经验的东西写进 Skill凡是需要触碰外部系统、有明确输入输出的东西做成 MCP 工具。举个具体例子——排查一次慢查询这件事判断逻辑先看执行计划、再看索引命中、最后看数据量分布属于 Skill而执行一条 SQL 并返回结果这个动作属于 MCP 工具。Skill 里会写如果扫描行数超过阈值先别急着加索引回去确认统计信息是不是过期了这种判断 MCP 工具永远写不出来因为工具只负责执行。1.3 为什么不是反过来把流程也塞进 MCP有人会问那我把整个排查流程做成一个大工具不就完了模型只要调用一次服务端内部把整套逻辑跑完。这个方案我试过短期内确实省事长期有三个问题。第一是黑盒化。流程藏在服务端代码里模型看不见中间步骤出问题时你既不知道它走到哪一步失败的也没法在中间插入人工确认。第二是复用性差。同一个数据库查询工具在排查慢查询和生成日报两个场景里的调用方式是一样的但如果我把流程焊死在工具里就得写两个工具重复实现查询逻辑。第三是调整成本高。流程里加一条判断我得改代码、重新部署、重启服务而改 Skill 只是编辑一个文本文件存盘就生效模型下一轮读到的就是新版本。所以我的原则是MCP 工具保持原子Skill 负责编排。工具越原子复用面越广编排越外置迭代越快。这条原则在后面对接更多系统时体现得越来越明显。2. Skills 落地细节写一个模型真会用的技能包2.1 目录结构与元数据设计技能包不是一篇文档它是一个有约定结构的文件夹。我现在的标准结构是这样skills/ db-inspect/ SKILL.md # 入口含元数据与正文 scripts/ explain.py # 可被调用的辅助脚本 sample_rows.py references/ schema-notes.md # 表结构说明、字段含义 known-issues.md # 历史踩坑记录入口文件顶部用一段结构化元数据很多实现用 YAML frontmatter 的形式最少要有两个字段--- name: db-inspect description: 需要查看表结构、字段含义、统计行数或排查查询变慢时使用。仅处理只读查询涉及写入、删除、DDL 变更的场景不要使用本技能。 --- ## 适用场景 ... ## 执行步骤 1. ... 2. ... ## 判断标准 ...这里有个细节值得单独说description 字段是整个技能包里最重要的三十个字。它是唯一常驻上下文的部分模型完全靠它决定要不要加载正文。我早期写的 description 是数据库相关操作技能结果模型在帮我写一个建表语句这种明显不该触发的场景下也加载了它白白消耗上下文还因为正文里的只读约束和建表需求打架输出变得犹豫。改法是把 description 写成正向场景 反向排除的组合式。正向说清什么时候用反向说清什么时候别用。改完之后误触发率明显下降。这个技巧在所有需要选择的场景里都通用——不只是技能描述MCP 工具描述也一样后面还会讲到。2.2 触发条件怎么写才不误触发写触发条件最容易犯的错是求全。想把所有可能相关的场景都覆盖进去结果描述越来越宽最后变成一个万能触发器。我的做法是给自己定三条硬规矩。第一条一个技能只对应一类动作。数据库排查和数据库备份看起来都是数据库但前者是只读诊断、后者是高危写操作混在一个技能里模型很难稳定区分该走哪条分支。拆成两个技能包各自描述清楚反而更省心。第二条用具体名词锚定不用抽象动词。写当用户提到表结构、字段类型、索引、行数、执行计划时使用比写当用户需要分析数据时使用精确得多。抽象动词的覆盖面太宽具体名词才是可靠的触发器。我在技能描述里会刻意收集一批用户真实会说的词从历史对话里扒效果比拍脑袋想好得多。第三条把高危场景写进排除项并且语气要硬。比如涉及 UPDATE、DELETE、DROP、TRUNCATE 的请求一律不要使用本技能不要写成建议谨慎使用。模型对语气的敏感度比想象中高温和的建议容易被忽略明确的禁止更管用。2.3 渐进式加载为什么不要一次全塞进去技能包的加载应该分三层这个分层设计是我认为整个 Skills 机制里最值得学的部分层级内容加载时机典型体量第一层元数据name description常驻每轮都在50120 token第二层SKILL.md 正文步骤、标准判定触发后加载5002000 token第三层references 下的参考资料、脚本输出正文里明确要求时才读按需可能上万 token这个设计的价值在于把决策成本和执行成本分开了。第一层极轻二十个技能全挂上也才两千多 token模型扫一眼就能判断该不该深入。第二层是主力控制在两千 token 以内写清楚步骤和判断标准。第三层是重型资料比如完整的表结构文档动辄几千行绝对不能进正文只能在正文里写一句需要确认字段含义时读取 references/schema-notes.md 中对应表的章节。我踩过的坑是把一份八百行的表结构说明直接贴进了 SKILL.md 正文。结果每次触发这个技能上下文直接多出六七千 token而且模型开始过度参考——本来只需要查一张表它把整份文档过一遍输出里混进大量无关表的字段。后来改成按表拆分成多个小文件正文里只写索引问题就解决了。注意第三层文件不要用大而全的单一文件按主题或按对象拆成多个小文件并在正文里给出明确的读取路径。模型的按需读取能力依赖你给的路标足够精确。3. MCP 接入与调试从本地进程到远程服务3.1 三类原语与它们的适用边界MCP 的通信建立在 JSON-RPC 2.0 上客户端和服务端之间通过标准方法名交互比如初始化、列出工具、调用工具、列出资源、读取资源、获取提示模板。它把外部能力抽象成三类原语这个划分是理解整个协议的关键原语谁发起有无副作用典型用途tools工具模型自主决定调用可能有执行查询、调用接口、跑脚本resources资源客户端或用户选择加载无只读文件内容、配置、日志片段prompts提示模板用户主动触发无预置的常用指令模板我见过最常见的误用是把一切都做成 tool。比如读取项目说明文档这件事做成工具当然能跑但语义上它是只读的资源应该走 resources。区别在哪工具调用是模型自己决定、自己执行的而资源的加载通常由客户端控制、用户可见可干预。把只读数据做成工具等于把往上下文里塞内容的决策权完全交给模型容易出现它反复读同一个文件、或者读了不该读的东西。反过来把有副作用的操作做成资源也是灾难——资源语义上是幂等的只读操作客户端可能缓存、可能预加载一个删除临时文件的资源会在你意想不到的时候被执行。我的原则很干脆有副作用的一律 tools只读数据一律 resources固定指令一律 prompts。工具定义里除了名称和描述还有一块必须认真对待输入参数的 JSON Schema。这是约束模型输出的硬手段。举个我实际用到的例子查询工具的时间范围参数{ name: query_metrics, description: 查询指定指标在时间区间内的聚合值。仅支持只读查询返回按小时聚合的结果最多返回 168 个数据点。, inputSchema: { type: object, properties: { metric: { type: string, enum: [request_count, error_rate, p95_latency] }, start: { type: string, format: date-time }, end: { type: string, format: date-time }, granularity: { type: string, enum: [hour, day], default: hour } }, required: [metric, start, end], additionalProperties: false } }几个刻意的设计enum把可选项焊死模型不可能编出不存在的指标名additionalProperties: false阻止它自作主张加参数default减少必填项降低调用失败率描述里写明最多返回 168 个数据点是为了防止模型请求一个跨年的区间然后被服务端拒绝它读到这个约束会自己先算一下范围。3.2 一个最小可用的本地服务端本地服务端用标准输入输出stdio传输是最省事的起步方式客户端启动一个子进程双方用换行分隔的 JSON 消息通信。下面是一个最小骨架只实现初始化和工具调用两条路径import json import sys TOOLS [ { name: count_lines, description: 统计指定文本文件的行数。仅适用于 UTF-8 编码的纯文本二进制文件请勿使用。, inputSchema: { type: object, properties: {path: {type: string}}, required: [path], additionalProperties: False, }, } ] def handle(req): method req.get(method) rid req.get(id) if method initialize: return {jsonrpc: 2.0, id: rid, result: { protocolVersion: 2025-06-18, capabilities: {tools: {}}, serverInfo: {name: demo-server, version: 0.1.0}, }} if method tools/list: return {jsonrpc: 2.0, id: rid, result: {tools: TOOLS}} if method tools/call: name req[params][name] args req[params].get(arguments, {}) if name ! count_lines: return {jsonrpc: 2.0, id: rid, error: { code: -32601, message: f未知工具: {name}}} try: with open(args[path], r, encodingutf-8) as f: n sum(1 for _ in f) return {jsonrpc: 2.0, id: rid, result: { content: [{type: text, text: f{n} 行}]}} except Exception as e: return {jsonrpc: 2.0, id: rid, result: { isError: True, content: [{type: text, text: f读取失败{type(e).__name__}。请确认路径存在且为文本文件。}]}} return {jsonrpc: 2.0, id: rid, error: { code: -32601, message: 不支持的方法}} for line in sys.stdin: line line.strip() if not line: continue resp handle(json.loads(line)) sys.stdout.write(json.dumps(resp, ensure_asciiFalse) \n) sys.stdout.flush()这里有几个容易翻车的地方我逐个说。第一标准输出不能有任何杂质。调试用的打印语句千万不要往标准输出写协议消息和日志混在一起客户端直接解析失败。日志一律走标准错误。第二每条消息后面必须刷新缓冲否则客户端会一直等。第三初始化之后客户端可能发一条已初始化的通知这条消息没有 id不需要也不应该回响应回了会导致协议错乱。客户端侧的配置通常是一个 JSON 文件声明怎么启动这个服务{ mcpServers: { local-files: { command: python, args: [-u, ./servers/file_server.py], env: { LOG_LEVEL: warn } } } }-u这个参数不要省它强制 Python 不做输出缓冲否则你会发现消息发出去了但迟迟不到。这个坑我排了快一个小时。3.3 调试链路三方对齐MCP 出问题时最难的地方在于链路上有三个角色模型、客户端、服务端。任何一方的问题表现都可能是工具调不通需要分层定位。我的排查顺序是自下而上。先单独跑服务端手工往标准输入里喂一条初始化消息和一条工具调用消息看标准输出返回什么。这一步能把服务端的问题全部隔离出来——最常见的是 JSON 格式错、方法名拼错、必填参数没校验。服务端确认没问题之后再看客户端工具列表能不能拉出来拉出来的定义是不是完整很多客户端的日志里会打印工具注册情况看一眼就知道。最后才怀疑模型工具确实注册了但模型不调用或者调用时参数不对这时候回头看工具描述。这套顺序的价值在于成本递增手工测服务端几秒钟的事翻客户端日志几十秒调模型提示要跑好几轮对话。从便宜的开始别一上来就怀疑模型。4. 全链路联调让 Skill 指挥 MCP 干活4.1 调用链路拆解与超时设置串起来之后的完整链路是这样的用户提需求 → 模型读技能元数据判断加载哪个技能 → 读技能正文得到执行步骤 → 按步骤选择并调用 MCP 工具 → 工具结果回到上下文 → 模型继续下一步或输出结论。这条链路上最容易出事的是超时。默认超时设置往往一刀切但不同工具的实际耗时差好几个数量级。我的分层设置工具类型超时设置理由本地文件读取、目录扫描10 秒磁盘操作正常毫秒级返回本地脚本执行60 秒留出解释器启动和依赖导入时间远程接口查询30 秒网络抖动需要余量但过久会拖住整个会话批量任务、全量扫描异步任务 轮询同步等待必然超时异步那类要额外设计。同步等待的做法是工具调用挂在那儿直到完成超过超时阈值整个会话就崩了。我的做法是工具立刻返回一个任务标识Skill 正文里写清楚拿到任务标识后每隔一段时间调用一次状态查询工具未完成就继续等待连续三次状态不变则中止并报告。这样长任务不会卡死链路模型也知道什么时候该放弃。4.2 token 预算测算这笔账必须自己算很多人抱怨接入工具后成本翻倍其实算一下就明白钱花在哪了。一个工具定义进入上下文常驻部分的构成大致是名称十几个 token、描述几十到一百多 token、输入参数的 Schema 一百多到三百 token。取个中间值单个工具定义约 200250 token。按这个基数算十个工具每轮对话固定携带约 20002500 token 的工具定义。一次任务如果来回十轮光工具定义就是两万到两万五千 token 的输入而这部分内容和当前任务往往没半点关系。二十个工具翻一倍四十个工具再翻一倍。这还没算技能正文。假设每次触发加载 1500 token 的技能正文一次任务触发两次又是 3000 token。所以我的优化顺序是这样的优化手段效果实施难度精简工具描述砍掉冗余解释每个工具省 3060 token低收紧参数 Schema去掉用不上的可选字段每个工具省 50150 token低按场景拆分成多个服务按需挂载直接减少常驻工具数中高危/低频工具改为二级工具先列出再调用进一步压缩常驻面高第三行是我认为性价比最高的。把工具按日常高频和专项低频分成两个服务日常只挂前者的五个工具需要专项操作时再临时挂上后者。这样常驻部分从二十个工具的三千多 token 降到五个工具的一千出头模型的选择准确率也明显提升——因为干扰项少了。4.3 工具数量与选择准确率我做了一组对照同一批任务分别在不同工具数量的环境下跑。样本量不大只能算经验值但趋势很清楚可用工具数选对工具的比例主要失败形态15基本无失误几乎没有615偶发选错相近语义的工具混淆1630明显下降忽略排除条件、参数填错工具30 以上需要人工纠正反复试错、绕远路十六个以上开始明显下滑这个拐点和我实际体感吻合。失效的典型模式是语义相近的工具混淆比如同时存在查询当前配置和查询历史配置两个工具模型在不看描述细节的情况下容易抓错。对应的三个改法按见效速度排序第一工具名带上前缀或领域词。config_current_get和config_history_get的区分度比get_config和get_config_history高得多。名字是模型最先看到的信息别浪费。第二描述里写清和谁容易混。在查询当前配置的描述里加一句本工具只返回生效中的配置如需查看变更历史请使用历史配置查询工具比什么都不写强很多。这条我一开始觉得很傻——工具描述里提别的工具合适吗实测下来非常有效因为模型在犹豫的时候最需要的就是一个明确的分流指引。第三合并同质工具。两个工具参数一样、只差一个筛选条件直接合并成一个带枚举参数的工具。工具数量减少选择空间变小准确率自然上来。4.4 错误回传格式别把堆栈丢给模型工具执行失败时返回什么直接决定模型能不能自我修复。我踩过的最蠢的坑是直接把异常堆栈原样返回结果模型对着一堆调用栈开始编故事猜测哪里出了问题越猜越偏。好的错误回传应该包含三样东西发生了什么、为什么、下一步可以怎么做。对比一下{ isError: true, content: [{ type: text, text: 查询超时30 秒内未返回。原因通常是时间范围过大或未命中索引。建议把时间范围收窄到 24 小时以内重试或先调用 explain_query 查看扫描行数。 }] }这个格式里模型拿到的不是出错了而是错在哪、怎么改。实测下来带建议的错误信息能让模型的自愈率提升非常明显——它不需要猜直接按建议重试即可。注意错误信息里绝对不要带内部路径、连接串、密钥片段。这些内容一旦进入上下文就可能被模型复述到最终输出里。统一的处理方式是服务端先脱敏再拼装错误消息。5. 常见问题与排查速查表5.1 典型故障现象对照表现象高概率原因定位动作处理方式客户端启动即报协议错误服务端标准输出混入日志手工跑服务端看第一行输出日志改走标准错误工具列表为空初始化响应缺少能力声明抓初始化响应原文补上 tools 能力字段模型始终不调用工具工具描述太抽象读描述问自己能据此判断何时用吗补正向场景与反向排除调用参数总是不全Schema 必填项太多统计失败调用的缺失字段加默认值或改可选反复调用同一工具返回结果被截断或格式难读看返回内容长度精简返回只留关键字段长任务必然超时同步等待看耗时分布改异步任务 轮询技能频繁误触发描述用了抽象动词翻历史对话找触发词改成具体名词锚定这张表是我这几周实际遇到过的按出现频率排的序。第一行那个标准输出混入日志害我时间最长因为客户端只报一个笼统的解析错误完全指不到根因。后来我养成习惯任何新写的服务端第一件事就是手工喂一条消息看输出干净不干净。5.2 权限与安全边界这块必须单独拎出来说因为它不是优化项是底线项。第一读写分离到不同服务。只读工具和写操作工具放在两个不同的服务实例里只读的用只读账户、只挂载到日常会话写操作的单独启动、需要显式确认才挂载。物理隔离比我在描述里写了不要写可靠一万倍。第二危险操作的二次确认放在客户端不放在服务端。靠 Skill 正文里写执行前请询问用户是不够的模型可能跳过。真正的拦截应该做在客户端对标记为高危的工具调用先弹确认再执行。协议里有标注工具危险等级的能力用上它。第三工具描述里写清副作用。本工具会修改文件本工具会产生计费调用这类信息必须写在描述里模型才知道什么时候该停下来问人。我见过一个删除工具的描述只写了清理资源模型在排查内存时顺手就调了结果把一个还在用的缓存目录清了。第四返回内容脱敏前置。工具返回的日志、配置、数据在进入上下文之前就要过滤掉密钥、令牌、内部地址。这个过滤必须在服务端做不能指望模型自觉。5.3 我的避坑清单攒了这些天挑几条最值的写下来。别在 Skill 正文里写应该尽可能仔细这种话。没有信息量的形容词只会稀释真正的约束。要写就写可判定的条件比如如果扫描行数超过一百万先不要加索引。技能包的版本要能追溯。我一开始直接在原文件上改改坏了想回退都找不到上一版。后来把技能目录纳入版本管理每次调整写一句变更原因。这个习惯救过我一次——某次改了触发条件导致大面积误触发两分钟就定位到是哪次改动。MCP 服务端的启动时间要测。客户端重启时会重新拉起所有服务如果某个服务启动要三秒整个会话的响应就慢三秒。我有个服务因为加载了一个大依赖启动要五秒多后来改成懒加载启动降到两百毫秒以内。工具返回值要控制体量。返回一个几万字符的日志上下文直接被撑爆后续推理质量断崖下跌。我的做法是服务端先截断只返回头尾各若干行加一句共 N 行已截断完整内容请指定行号区间再查。给模型一个再查一次的口子比一次塞满强。给每个工具加调用计数日志。跑一段时间回头看你会发现有些工具几乎没被调用过有些工具调用量大得离谱。前者该下线后者该优化返回体。这个数据比拍脑袋判断准得多。最后分享一个我最近才体会到的点这套东西的价值不在接了多少工具而在接的每个工具是不是都在正确的位置上。我前期追求工具数量接了一堆结果链路又慢又不准后来砍掉一半把剩下的每个工具的边界重新写清楚同时把流程性内容全部挪进 Skill整条链路的稳定性反而上了一个台阶。工具是手技能是脑手不是越多越好够用、好用、知道什么时候不该伸出去才是关键。