← 回到知识图谱
实践持续完善

技能、MCP 与集成

本文总结技能按需加载,外部工具按命名空间接入、按宿主策略授权,集成的难点在挂载点与顺序。

创建 2026-11-05更新 2026-11-05阅读约 24 分钟
AgentHarnesslearn-claude-code

跟读 learn-claude-code 第 6 站 · 上一站:多 Agent 团队 · 下一站:固定编排与目标闸门 · 全程:总览

前五站的能力都写在 harness 自己的代码里。这一站处理外部来的东西:s07 是放在磁盘上的领域知识(技能),s14 是别的进程提供的工具(MCP),s15 把前面十四章全部装进同一个循环。前两章的共同问题是:外来内容什么时候进上下文、以什么身份进、谁来决定它能不能执行。s15 的问题是:每个机制单独看都对,装在一起以后谁先谁后。

s07 在课程里排在 s08 之前,这里把它挪到第 6 站和 MCP 放在一起讲,因为两者都是把外部的能力挂进来。s07 和 s14 的代码都从 s04 内核派生,彼此不继承。

s07 技能:目录常驻,全文按需

问题

项目里攒了几份操作手册:怎么做代码评审、怎么处理 PDF、怎么写一个 MCP server。每份几百到几千字。全塞进 system prompt,用户只是问"这个函数什么意思",也要为三份无关手册付 token,而且每轮都付;手册越多,真正相关的那份越被稀释。完全不告诉模型,它又不知道有这些手册可用,只能从零摸索。

所以要分两层:模型一直知道有哪些手册,只在需要时才看到某一份的全文。

机制

启动时  扫描 skills/*/SKILL.md,解析 YAML 头,拼成目录
        SYSTEM = "...Skills available:\n- code-review: 一行描述\n- pdf: 一行描述..."
运行时  模型判断某技能相关 → load_skill(name="code-review")
        harness 按名字查注册表 → 全文作为 tool_result 回到上下文

两层分工很清楚。目录(名字加一行描述)在启动时拼进 SYSTEM(s07_skill_loading/code.py 第 134–144 行),只构建一次;全文走工具调用,作为 tool_result 进上下文,和读文件的结果是同一种身份。

load(第 123–128 行)按名字查字典,查不到时返回可用列表:

def load(self, name: str) -> str:
    skill = self.skills.get(name)
    if skill:
        return skill["content"]
    available = ", ".join(self.skills) or "none"
    return f"Error: Unknown skill '{name}'. Available: {available}"

扫描时(第 89–113 行)有几层防御:yaml.safe_load 解析头部,解析失败或结果不是字典就当作没有元数据;--- 分隔符必须单独成行;description 缺失时退回正文第一行,再去掉开头的 # 并压成一行;SKILL.md 解析后的真实路径必须仍在 skills 目录内,防止软链接把目录外的文件挂进来(第 94–98 行)。

为什么这么设计

目录常驻,全文按需,也就是渐进式披露。 全文塞进 system prompt,每轮都为无关知识付费,注意力被稀释;只给工具不给目录,模型不知道有什么可查,load_skill 形同虚设。目录的成本是每个技能一行,几十个技能也只有几百字,换来的是模型能自己判断"这个任务该查哪本手册"。

判断权交给模型,不让 harness 做检索。 另一种做法是 harness 侧做 RAG:按用户问题检索手册片段,自动拼进上下文。这样判断权在 harness,检索偏了模型无从纠正,片段还可能在关键步骤中间被截断。技能是完整的操作流程,按整份加载比按片段召回更适合。

按名字查注册表,不按路径读文件。 如果 load_skill 接收路径,模型(或者被注入的内容)就能传 ../../.env。只按注册表里的名字查,可加载的范围在启动扫描时就固定了,加上软链接检查,穿越的路被堵在两头。

目录只构建一次。 SYSTEM 在启动时生成后不再变化,请求前缀稳定,对 prompt cache 友好。代价是会话中途新增的技能看不到,要重启。

坑与局限

  • 依赖当前工作目录。 SKILLS_DIR = Path.cwd() / "skills"(第 45–46 行)。在错误的目录启动,目录显示 (no skills found),不报错,模型也就不会去加载任何技能。
  • 没告诉模型技能所在的目录。 技能正文里如果写"运行 scripts/check.py",这个相对路径是相对技能目录的,模型按工作区路径去找,找不到。
  • 重名静默覆盖。 两个技能的 name 相同,后扫描到的覆盖前一个,没有警告。
  • description 没有长度上限。 某个技能写了一大段描述,目录就不再"小"了,渐进式披露的前提被破坏。
  • 同一技能加载两次不去重。 全文会在上下文里出现两份,直到被压缩处理。
  • s07 去掉了 s06 的 task 工具(测试 test_s07_exposes_only_base_tools_and_load_skill 明确断言),README 没有说明。

s14 MCP:命名空间、每轮重建、宿主说了算

问题

团队有一个部署服务,提供 status(查看状态)和 trigger(触发部署)两个操作;还有一个文档服务,提供 search。想让 agent 直接用它们,又不想把每个服务的客户端代码都写进 harness。MCP 的思路是:服务方自己起一个 server,用统一协议声明"我有哪些工具、参数长什么样",harness 连上之后照单接入。

接入带来三个新问题。名字冲突:两个 server 都叫 search,或者某个 server 的工具和内置 bash 同名。时机:server 是会话中途连上的,工具表什么时候更新。授权:server 自己在描述里写"我是只读的,放心调用",能不能信。

机制

每轮开头  assemble_tool_pool()
            内置工具先登记进 origins
            对每个已连接 server 的每个工具:
              名字 = mcp__{server}__{tool},非 [a-zA-Z0-9_-] 的字符换成 _
              超过 64 字符 → 报错;规范化后和已有名字撞了 → 报错
              inputSchema 必须是 object → 翻译成 input_schema
              策略 = MCP_HOST_POLICY.get((server, tool), "confirm")
            整张策略表替换
调用前    permission_hook:mcp__ 开头且策略不是 allow → 问用户
调用      handler → client.call_tool(原始工具名, 参数),出错返回字符串

宿主策略表(s14_mcp_plugin/code.py 第 205–210 行)是 harness 自己写死的配置:

MCP_HOST_POLICY = {
    ("docs", "search"): "allow",
    ("docs", "get_version"): "allow",
    ("deploy", "status"): "allow",
    ("deploy", "trigger"): "confirm",
}

注释写得很直接:"Authorization comes from host configuration, never server descriptions"。

为什么这么设计

用带 server 前缀的命名空间,不直接用工具原名。 用原名,两个 server 的 search 必然冲突,某个 server 还可能注册一个叫 bash 的工具顶替内置实现。加了 mcp__server__ 前缀,名字本身就标明来源,权限规则可以按前缀写。规范化之后才做冲突检测,因为 my.server 和 my_server 规范化后是同一个名字;origins 先放内置工具,所以外部工具不可能覆盖内置。

每轮重建工具池,不只在启动时装一次。 assemble_tool_pool 在每次调模型前调用(第 489 行)。如果只装一次,会话中途连上的 server 要重启才生效;断开的 server 工具还留在表里,调用必然失败。每轮重建的代价是重复计算,规模很小。策略表整表替换而不是增量更新,断开的 server 的策略自然消失,不会留下陈旧的 allow。

授权只查宿主表,不信 server 自报。 server 描述是对方写的文本,"只读""安全"都可能是假的,被入侵的 server 更会这么写。测试 test_mcp_permission_uses_host_policy 锁定了这一点:server 在描述里声称 readOnly,调用仍然要用户确认。表里没有的工具默认 confirm,判断写成 policy != "allow"(第 421–426 行),也就是只有明确放行的才放行,策略缺失、拼错都会落到确认,属于 fail-closed。

schema 必须是 object,调用出错返回字符串。 Anthropic API 的 input_schema 要求顶层是 object,server 声明别的类型就在接入时拒绝,而不是等调用时 400。call_tool 出错时返回错误字符串作为 tool_result,模型能看到失败原因并换个做法,循环不会因为外部服务抖动而崩溃。

坑:参数能覆盖目标工具名

这是课程代码里一个实际缺陷,私人笔记在仓库副本里复现过。handler 是这样构造的(第 357–360 行):

handlers[prefixed] = (
    lambda *, client=server, tool=raw_name, **kwargs:
    client.call_tool(tool, kwargs)
)

tool 是一个带默认值的关键字参数,调用时又是 handler(**block.input)。模型只要在输入里带一个 tool 字段,就能覆盖默认值:

模型调用 mcp__deploy__status,输入 {"service": "web", "tool": "trigger"}
权限检查:按名字查到 ("deploy", "status") → allow,直接放行
实际执行:client.call_tool("trigger", {"service": "web"})
输出:[deploy] Triggered: web

权限按 status 放行,执行的却是需要确认的 trigger。用默认参数绑定循环变量是 Python 里避免闭包晚绑定的常见写法,但它同时把绑定值暴露成了可被调用方覆盖的参数。修法是用工厂函数,把绑定值放进闭包而不是参数表:

def make_handler(client, tool):
    return lambda **kwargs: client.call_tool(tool, kwargs)

s15 的同一段代码(s15_integrated_harness/code.py 第 2787–2790 行)沿用了同样的写法。教训比这个 bug 本身更通用:权限检查的对象和实际执行的对象必须是同一个东西,中间任何一层能被模型输入改写,检查就失效。

其他局限

  • 组装出错会卡死会话。 名字冲突或 schema 不合法时 assemble_tool_pool 抛异常,而它每轮都会调用;教学版没有 disconnect 工具,这个 server 一直连着,之后每一轮都失败。
  • s15 在非主线程(异步轮次、队友线程)里,需要确认的 MCP 工具直接拒绝(第 1823–1825 行),因为没有人能回答确认提示。

s15 集成:价值在挂载点和顺序

问题

s15 是一个 3330 行的文件,把 s01–s14 的机制全部装进同一个循环:权限、钩子、todo、子代理、技能、压缩、记忆、任务图、后台任务、定时任务、团队、MCP,再加一层错误恢复。

单看每个机制,前面各章都讲过。s15 新的东西是冲突:定时任务要往 messages 里注入消息,压缩要改写 messages,工具批次要求 tool_use 和 tool_result 成对,三者同时存在时谁先谁后;后台任务要绕开阻塞,权限审批要拦住危险命令,后台的 bash 还审不审。这些问题在单章里不存在,只在合到一起时出现。

所以读 s15 不该逐行读,而是看每个机制挂在循环的哪个位置、为什么挂在那里。

机制:一轮迭代的挂载顺序

迭代开头(第 3149–3165 行)
  定时任务触发 → 注入 [Scheduled] 消息,并把请求并入 active_request
  后台任务通知 → 注入
  todo 提醒
  prepare_context(压缩管线) → 记忆召回 → assemble_tool_pool
调用模型(第 3171–3182 行)
  with_retry 包裹;prompt too long → 本 turn 一次 reactive compact;其他错误 → restore cron,写 [Error]
  成功 → ack 定时任务(第 3184 行)
  stop_reason == max_tokens → 升级 / 续写
无 tool_use → Stop 钩子 → 记忆写入 → 释放 worktree 租约 → 结束
逐个处理 tool_use
  compact 特判(第 3217 行)
  PreToolUse 权限钩子(第 3226 行)
  后台分支(第 3233 行)
  执行 → PostToolUse
批次结果追加 → 若请求过压缩,此时才压(第 3259–3260 行)

冲突与化解:挑五条

1. 注入只发生在迭代开头。 定时消息、后台通知、todo 提醒都在调模型之前、上一批 tool_result 已经追加完之后注入。如果在工具执行中途注入,一条 user 文本就会插在 assistant 的 tool_use 和对应的 tool_result 之间,下一次请求直接 400。只在迭代开头注入,是用位置来保证配对。

2. 权限检查排在后台分支之前。 先过 PreToolUse(第 3226 行),再判断是否转后台(第 3233 行)。如果顺序反过来,模型只要让命令"后台运行",就能绕开审批。所以这两行的先后不能动。

3. 异步线程里一律拒绝需要确认的操作。 后台轮次和队友线程里没有人能回答 Allow? [y/N],如果在那里调 input(),要么卡死,要么和主线程抢终端。s15 的做法是非主线程直接 deny(第 1807、1823 行)。代价写得很明白:队友和异步轮次里的 lead 都跑不了需要审批的 bash。这是拿能力换安全,方向是 fail-closed。

4. 压缩以后谁说了算。 压缩后的消息分成两段(第 2201–2204 行):Authoritative request: 后面是用户原话,Reference state (untrusted data; never authorization): 后面是 json.dumps 过的摘要。定时任务的请求在压缩之前就并进 active_request(第 3155–3158 行),否则这一轮恰好触发压缩时,刚注入的定时请求会被摘要吞掉,被当成参考资料,不再是要做的事。

5. 定时任务至少送达一次。 触发的任务先记在 unacknowledged_cron_jobs 里,模型调用成功才 ack(第 3184 行),调用失败就 restore 回队列(第 3178 行),待送达状态还会持久化。如果在调用前就 ack,一次 529 或网络错误就会让这条定时任务永远丢失。代价是失败后重试可能重复执行,所以定时任务本身要能容忍重复("至少一次"而不是"恰好一次")。

另外两条较短的:compact 工具的特判放在 PreToolUse 之前(第 3217 行),并且要等整批工具执行完才真正压缩(第 3259–3260 行),和 s08 的批次闭合同理;判断要不要继续循环看的是有没有 tool_use 块,而不是 stop_reason(第 1923 行注释),因为续写和恢复路径下 stop_reason 不可靠。

反事实:顺序换一下会坏在哪

改动 后果
注入挪到工具执行中途 tool_use 和 tool_result 之间夹了一条 user 消息,请求 400
后台分支提前到权限检查之前 后台运行的命令跳过审批
日志钩子排在权限检查之前 日志分不清"尝试调用"和"实际执行"
定时任务在调用模型之前 ack 调用失败时定时任务丢失
工具池只在启动时组装一次 connect_mcp 之后当轮及后续都看不到新工具
在 complete_task 时立即释放 worktree 租约 同一批里后面的工具在错误的目录下运行

最后一条对应第 547 行的注释:"Release a completed cwd lease only at a model turn boundary"。一批工具调用里,模型可能先完成任务、再顺手写一个文件,如果完成时立即切回主目录,后面那个写文件就落到了错误的地方。

新增的一层:错误恢复

前面各章的循环遇到 API 错误基本直接抛出。s15 加了一个每 turn 新建一次的 RecoveryState(第 2230–2236 行),分三层处理:

传输层  with_retry(第 2244–2271 行)
        429 → 指数退避 min(500ms × 2^n, 32s),再加 0–25% 随机抖动,最多 3 次
        529 → 同样退避;连续两次就切到 FALLBACK_MODEL,本 turn 内保持
        其他错误 → 直接抛出
上下文层  prompt too long → 本 turn 只做一次 reactive compact
输出层  stop_reason == max_tokens
        第一次:max_tokens 从 8000 升到 16000,重新请求
        仍截断:保留这段输出,追加"从上次停下的地方继续",最多续写 2 次
兜底    以上都不行 → 恢复定时任务,写一条 [Error] assistant 消息,结束本 turn

几个设计点:抖动是为了避免多个客户端在同一时刻一起重试;切换模型在 turn 内保持,避免在主备模型之间来回跳;RecoveryState 每 turn 新建,所以"只补救一次"的限制是按 turn 算的,不会因为上一个 turn 用过就永久失效;兜底写成一条 assistant 消息而不是抛出,会话还能继续。

局限也很具体。错误分类靠类名和消息字符串匹配,5xx 和连接错误不在匹配范围内,直接抛出。续写路径有一个风险(推断):如果升级到 16000 后输出仍然截断在一个 tool_use 块中间,这条带 tool_use 的 assistant 消息被原样保留,接着追加的是一段续写文本而不是 tool_result,下一次请求可能因为 tool_use 没有对应结果而被拒。

坑与局限

  • prepare_context 在 snip 之后不会把 active_request 补回去,s08 有这一步,s15 没有。
  • 记忆召回在每次迭代都做,而不是每个用户 turn 做一次,长工具链里重复召回。
  • PreToolUse 钩子和 assemble_tool_pool 抛出的异常没有人接,会直接打断循环。
  • compact 工具的特判在 PreToolUse 之前,所以它绕过了所有钩子,日志里看不到。
  • 子代理不使用当前 worktree 的工作目录。
  • README 前后分别写了 26 个和 25 个工具。
  • 同一个 response 里先 connect_mcp 再调用新 server 的工具,后者会得到 Unknown,因为 handler 表是本轮开头组装的,下一轮才有。
  • todo 机制退化:返回值从渲染清单变成 "Updated N todos"(第 1094–1101 行),不再校验单一 in_progress,计数从按轮改成按工具调用,提醒变成一条单独的 user 消息。

和真实 Claude Code 的差别

  • 技能。 真实 Claude Code 也是"目录常驻,全文按需":技能列表通过 <system-reminder> 注入上下文(从模型侧收到的上下文里能看到),全文通过 Skill 工具加载。插件提供的技能用 plugin:skill 形式的命名空间区分;用户输入斜杠命令时,命令内容会以 <command-name> 块的形式展开;有的技能不是把全文塞进当前上下文,而是交给子代理执行,只把结果交回。这些来自 Claude Code 提供给模型的 Skill 工具定义,版本更新后可能变化。
  • MCP。 工具命名同样是 mcp__server__tool,这一点在真实工具列表里可以直接核对。真实产品支持 stdio、SSE、HTTP 几种传输,项目级配置写在 .mcp.json,权限规则可以在 settings 里按 mcp__server 或具体工具名写 allow/deny。是否每轮重建工具池、策略缺失时的默认行为,属于推断。
  • 权限与钩子。 真实产品有多种权限模式和 settings 里的规则,不是写死在代码里的策略表;钩子是用户配置的外部命令,事件类型也比 s15 多。具体事件列表以官方文档为准。
  • prompt cache。 s15 的 system prompt 里带着精确到秒的当前时间(第 858 行),推断每一轮的 system 前缀都会变化,prompt cache 很难命中。真实产品会尽量保持前缀稳定,把易变信息放在后面(推断)。

拿自己的导游 Agent 对照过,最有收获的是 s14 那条:外部工具的授权要落在宿主侧,而且要检查"实际执行的是不是被授权的那个"。

自测

渐进式披露里,目录和全文分别放在上下文的什么位置?为什么不用 RAG 替代?

参考:目录(名字加一行描述)在启动时拼进 system prompt,只构建一次,保持前缀稳定;全文通过 load_skill 按需加载,作为 tool_result 进入上下文。不用 RAG,是因为 RAG 把"该看什么"的判断交给 harness 的检索,检索偏了模型无法纠正,片段也可能在流程中间被截断;技能是完整的操作流程,由模型判断后整份加载更合适。

MCP 工具为什么要加 mcp__server__ 前缀,冲突检测为什么要在规范化之后做?

参考:前缀让同名工具(两个 server 都有 search)不冲突,也防止外部工具顶替内置的 bash;名字本身标明来源,权限规则可以按前缀写。规范化会把非法字符换成下划线,my.server 和 my_server 规范化后相同,只在规范化前比较会漏掉这种冲突。内置工具先登记,所以外部工具永远不能覆盖内置实现。

server 在工具描述里写"只读、安全",s14 为什么仍然要求确认?课程代码里有什么办法让模型绕过这个确认?

参考:描述是 server 自己写的文本,不能作为授权依据;授权只查宿主配置的策略表,表里没有的默认 confirm,判断写成"不是 allow 就确认",属于 fail-closed。绕过方式来自 handler 的写法:目标工具名是带默认值的关键字参数,模型在调用一个已放行工具时传入 tool 字段就能覆盖它,权限按放行的名字检查,实际执行的是另一个工具。修法是用工厂函数把工具名放进闭包。

s15 里,为什么注入只在迭代开头做,权限检查为什么要排在后台分支前面?

参考:注入如果发生在工具执行中途,user 消息会夹在 tool_use 和 tool_result 之间,下一次请求 400,所以用位置保证配对。权限检查在后台分支之前,是为了不让转后台运行成为绕过审批的通道。这两条都说明,集成以后对不对,很大程度取决于挂载顺序。

s15 的错误恢复分哪几层?各自只尝试几次?

参考:传输层:429 和 529 指数退避加抖动,最多 3 次,529 连续两次切换备用模型并在本 turn 保持。上下文层:prompt too long 每个 turn 只做一次 reactive compact。输出层:max_tokens 截断时先升到 16000 重试一次,再最多续写 2 次。都不行就写一条 [Error] assistant 消息并恢复定时任务。每层都有次数上限,避免恢复本身进入死循环。

连接

  • 多 Agent 团队:s15 里异步线程一律拒绝确认类操作,代价落在队友身上,这一站的取舍要和上一站的团队机制一起看。
  • 固定编排与目标闸门:下一站在集成好的 harness 之上加固定编排和目标检查,前提是这里的循环顺序已经稳定。
  • 计划、子代理与上下文预算:s15 的压缩、todo、子代理都来自第 2 站,这里能看到它们合并后的变化和退化。
  • Coding / Harness:面试里讲 harness 的组成和顺序,s15 的挂载点和反事实表是现成的例子。
  • 安全边界:让权限落到工具层:MCP 授权落在宿主、权限检查先于后台分支、参数覆盖导致越权,都是"权限落到工具层"的具体案例。
  • 工具 / 协议:Function Calling、MCP、Skill 三者的区别,可以用 s07 和 s14 的机制对照回答。
  • 可靠执行:状态、幂等与恢复:s15 的退避、抖动、备用模型和至少一次送达,是可靠执行在 agent 循环里的落地。
  • 工具调用:命名空间、schema 校验、出错返回字符串,都是工具入口该做的检查。
这是一篇可以继续生长的笔记。沿着关系继续阅读 ↗