Claude Code 进阶 : 从协作者变成可编程的智能基础设施

小新 正七品 (知县) 2026-03-27 02:56 2 0 返回 码工码农
小新 正七品 (知县) 楼主
2026-03-27 02:56
第1楼

摘要:你可以声明「Task B 依赖 Task A」,当 A 完成,B 自动解锁,teammates 自主领取下一个可执行的任务,不需要 lead 一直盯着。 INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=[logging.getenv("DB_READONLY_USER"), #


进阶阶段的核心是把 Claude Code 从协作者变成可编程的智能基础设施

Skills 让领域知识沉淀为可复用的组织资产,Hooks 钩子在工具调用的生命周期中植入质量门禁与状态持久化,多智能体架构让并行任务成为可能,自定义 MCP 服务将内部系统无缝接入,而上下文压缩与用量分析则保障大规模协作的效率与成本可控。这个阶段结束时,你构建的不只是一个趁手的助手,而是一套会学习、能扩展、可治理的 AI 工程体系。

本系列有三篇文章

本系列内容大多均由 Claude Code 生成, 目的是快速建立 Claude 生态概念

Skills 技能系统

理解 .claude/skills/ 目录结构与加载机制

Skills 是 Claude Code 里相对较新的能力,也是从中级迈向进阶的关键分水岭。之前所有的配置——CLAUDE.md、自定义命令、MCP——都是在告诉 Claude"这个项目是什么样的"。Skills 做的事情更进一步:告诉 Claude"在这个项目里,某类任务应该按照这套固定流程来做"。


Skills 是什么

一个 Skill 是放在 .claude/skills/ 目录下的一个文件夹,里面包含一组相关的指令、脚本和资源。Claude Code 在启动时会扫描这个目录,把所有 Skill 的描述加载进上下文,在执行任务时根据任务类型自动选择并激活对应的 Skill。

和 CLAUDE.md 的区别在于粒度和动态性。CLAUDE.md 是静态的全局上下文,每次都全量加载。Skills 是动态的专项能力,按需激活——处理推荐系统时激活推荐相关的 Skill,处理数据库迁移时激活迁移相关的 Skill,两者互不干扰,也不会同时占用上下文。


目录结构

.claude/
└── skills/
    ├── new-feature/
    │   ├── SKILL.md          # 技能描述和激活条件(必须)
    │   ├── steps.md          # 具体执行步骤
    │   ├── templates/        # 代码模板
    │   │   ├── controller.java.tmpl
    │   │   ├── service.java.tmpl
    │   │   └── req-resp.java.tmpl
    │   └── examples/         # 示例代码
    │       └── UserController.java
    ├── db-migration/
    │   ├── SKILL.md
    │   ├── checklist.md
    │   └── scripts/
    │       └── validate-migration.sh
    ├── write-test/
    │   ├── SKILL.md
    │   └── patterns.md
    └── code-review/
        ├── SKILL.md
        └── review-criteria.md

每个 Skill 是一个独立的目录,目录名就是 Skill 的标识符。目录里的文件结构没有强制要求,除了 SKILL.md 是必须的——它是 Claude Code 识别和加载 Skill 的入口。


SKILL.md 的结构

SKILL.md 是每个 Skill 最重要的文件,它决定了三件事:Claude 怎么识别这个 Skill、什么时候激活它、激活后做什么。

一个完整的 SKILL.md 示例:

---
name: new-feature
description: 在项目中新建一个完整的业务功能,包含 Controller、Service、Mapper、Req/Resp 和单元测试
triggers:
  - 新建功能
  - 新增接口
  - 创建模块
  - new feature
  - add endpoint
version: 1.0.0
---

# 新建功能 Skill

## 适用场景
需要从零开始创建一个新的业务功能时使用。
覆盖从 Controller  Mapper 的完整垂直切片,包含单元测试。

## 执行前确认
在开始之前,先向用户确认:
1. 功能名称(用于生成类名)
2. 所属模块(user / trade / recommend / notify)
3. 主要操作类型(查询 / 创建 / 更新 / 删除)
4. 是否需要缓存
5. 是否需要发 MQ 消息

## 执行步骤
详见 steps.md

## 代码模板
详见 templates/ 目录,所有新代码必须基于模板生成,不要自由发挥结构

--- 包裹的部分是 YAML frontmatter,包含机器可读的元数据:name 是 Skill 的唯一标识,description 是 Claude 判断是否激活这个 Skill 的主要依据,triggers 是触发关键词列表。


加载机制

Claude Code 启动时对 Skills 的处理分三个阶段:

扫描阶段——遍历 .claude/skills/ 目录,找到所有包含 SKILL.md 的子目录,读取每个 SKILL.md 的 frontmatter,建立一个 Skill 索引:名称、描述、触发词。这个索引会占用少量上下文,但比把所有 Skill 的完整内容全部加载进来要轻量得多。

匹配阶段——当你发出一个任务请求时,Claude 会把任务描述和 Skill 索引里的 descriptiontriggers 做语义匹配。不是简单的关键词搜索,而是语义层面的相似度判断——"帮我加一个新的 REST 接口"和 new-feature Skill 的描述能匹配上,即使没有出现任何触发词。

激活阶段——匹配到合适的 Skill 之后,Claude 读取该 Skill 目录下的所有文件内容,加载进当前会话的上下文。此时 Skill 里定义的步骤、模板、示例才真正对 Claude 可见,它会按照 Skill 的指导来执行任务。

这套机制的核心优势是按需加载。你可以定义十几个 Skill,每次只激活和当前任务相关的一个或几个,避免所有 Skill 的内容同时占用上下文。


多级 Skills 目录

除了项目级的 .claude/skills/,Skills 支持多级目录结构,加载优先级从高到低:

用户级 ~/.claude/skills/——跨所有项目生效,适合放通用的技术 Skill,比如"写单元测试"、"生成 API 文档"。

项目级 .claude/skills/——只在当前项目生效,适合放项目专属的业务 Skill。

附加目录——通过 --add-dir 参数指定额外的 Skills 目录,适合在多个项目之间共享一套 Skills 而不想复制文件的场景:

claude --add-dir /shared/team-skills

指定了附加目录后,该目录下的 Skills 和项目级 Skills 一起被加载,团队级和项目级的 Skill 库可以分开维护。


Skills 和 CLAUDE.md 的分工

两者解决不同层次的问题,应该配合而不是替代:

CLAUDE.md 放项目认知——这是什么项目、用什么技术栈、有哪些全局规范。这些信息是所有任务的共同前提,需要始终在上下文里。

Skills 放任务流程——特定类型的任务应该按什么步骤执行、用什么模板、注意什么细节。这些信息只在执行对应类型的任务时才需要,不应该永久占用上下文。

实际使用中,CLAUDE.md 告诉 Claude 项目用 MyBatis Plus、禁止 BeanUtils,new-feature Skill 告诉 Claude 新建功能时应该生成哪几个文件、每个文件遵循什么模板。两层信息叠加,Claude 生成的代码既符合项目规范,又有标准化的结构。


手动激活 Skill

除了自动匹配,也可以在对话里手动指定使用某个 Skill:

new-feature skill 帮我创建一个账号举报功能
按照 db-migration skill 的流程,帮我新建一个给 game_account 表加索引的迁移脚本

显式指定在两种情况下特别有用:任务描述比较模糊,不确定自动匹配是否会选到正确的 Skill;或者想用某个特定的 Skill 处理一个看起来不那么典型的任务场景。

编写自定义 Skill,封装领域知识与工作流

理解了 Skills 的结构和加载机制之后,下一步是真正动手写一个。一个写得好的 Skill 和一个写得差的 Skill,在实际使用中的效果差距可能比有 Skill 和没有 Skill 之间的差距还大。这一节从头到尾走完一个 Skill 的设计和编写过程。


从痛点出发,不要从功能出发

很多人写第一个 Skill 时的错误是:想到什么就封装什么,结果做出来一堆形式上的 Skill,实际用起来和直接描述任务差不多。

正确的起点是问自己:哪些任务我反复在做,而且每次都要费劲向 Claude 解释同样的背景和步骤?

答案往往集中在几类:新建一套完整的业务代码(每次都要解释项目结构和模板)、写单元测试(每次都要说明测试框架和风格约定)、数据库迁移(每次都要提醒检查同样的几个风险点)、排查线上问题(每次都要交代日志位置和排查流程)。

这些就是值得封装成 Skill 的候选。一个好的 Skill 封装的是重复性的领域知识,而不只是一个任务描述。


一个完整示例:新建业务功能

以"新建业务功能"为例,从头设计并编写一个 Skill。

第一步:确定 Skill 的边界

这个 Skill 应该覆盖什么,不覆盖什么。覆盖:从 Controller 到 Mapper 的完整垂直切片,含 Req/Resp、MapStruct Converter、单元测试。不覆盖:数据库表结构设计(那是另一个 Skill 的职责)、MQ 消费者(有专门的 MQ Skill)。

边界清晰,Skill 才不会变成一个什么都管但什么都管不好的大杂烩。

第二步:创建目录结构

mkdir -p .claude/skills/new-feature/{templates,examples}
.claude/skills/new-feature/
├── SKILL.md
├── steps.md
├── checklist.md
├── templates/
│   ├── Controller.java.tmpl
│   ├── Service.java.tmpl
│   ├── ServiceImpl.java.tmpl
│   ├── Mapper.java.tmpl
│   ├── Req.java.tmpl
│   ├── Resp.java.tmpl
│   ├── Converter.java.tmpl
│   └── ServiceTest.java.tmpl
└── examples/
    └── GameAccountFeature/
        ├── GameAccountController.java
        ├── GameAccountService.java
        └── GameAccountServiceImpl.java

第三步:编写 SKILL.md

---
name: new-feature
description: 在交易平台中新建一个完整的业务功能,生成 Controller、Service 接口、ServiceImpl、Mapper、Req、Resp、Converter 和单元测试
triggers:
  - 新建功能
  - 新增接口
  - 创建业务模块
  - 添加 API
  - new feature
  - add endpoint
  - create module
version: 1.2.0
author: duoli
---

# 新建业务功能 Skill

## 适用场景

从零创建一个新的业务功能,需要完整的垂直切片代码。
如果只是在已有功能上增加方法,不需要使用这个 Skill,直接描述需求即可。

## 执行前必须确认的信息

在开始生成任何代码之前,先向用户确认以下信息。
不要跳过这个步骤,缺少任何一项都可能导致生成的代码需要大量修改。

1. **功能名称**:用于生成类名,如"账号举报"→ `AccountReport`
2. **所属模块**:user / trade / recommend / notify
3. **包路径**:如 `com.xxx.trade.accountreport`
4. **主要操作**:列出需要的接口(查询列表、查询详情、创建、更新、删除)
5. **是否需要缓存**:如需要,说明缓存策略
6. **是否发 MQ 消息**:如需要,说明 topic 和触发时机
7. **特殊约束**:任何不符合常规的地方

## 核心规范(必须遵守)

- 所有类的结构必须严格对照 templates/ 目录下的模板
- 参照 examples/ 目录下的示例理解模板的实际应用
- 不允许自行发明项目中未使用的模式
- 完整执行步骤见 steps.md
- 生成完毕后执行 checklist.md 中的自检项

第四步:编写 steps.md

# 新建功能执行步骤

## 步骤 1:信息确认

完成信息确认后,整理成如下格式再开始:

```
功能名:AccountReport(账号举报)
模块:trade
包路径:com.xxx.trade.accountreport
接口:创建举报、查询举报列表(分页)、处理举报
缓存:无
MQ:举报创建后发送 TRADE_ACCOUNT_REPORTED topic
```

## 步骤 2:生成顺序

严格按以下顺序生成,后面的文件依赖前面的定义:

1. `AccountReportReq.java``AccountReportResp.java`
2. `AccountReport.java`(实体类,对照表结构)
3. `AccountReportMapper.java`
4. `AccountReportConverter.java`(MapStruct)
5. `AccountReportService.java`(接口)
6. `AccountReportServiceImpl.java`(实现)
7. `AccountReportController.java`
8. `AccountReportServiceTest.java`

## 步骤 3:每个文件生成后的检查

每生成一个文件,立即检查:
- 包名和导入是否正确
- 是否引用了不存在的类
- 命名是否符合约定(见 CLAUDE.md)

## 步骤 4:生成后整体检查

所有文件生成完毕后,运行 checklist.md 中的自检项。

## 步骤 5:提示用户

生成完毕后,告诉用户:
- 还需要手动创建的数据库表结构
- 需要在 ErrorCode 枚举里添加的错误码
- 如果有 MQ,需要在 MqConstants 里添加的 topic 常量

第五步:编写代码模板

模板是 Skill 里信息密度最高的部分,直接决定生成代码的质量。以 ServiceImpl 模板为例:

// templates/ServiceImpl.java.tmpl
package {{packagePath}};

import com.xxx.common.exception.BizException;
import com.xxx.common.exception.ErrorCode;
import com.xxx.common.result.Result;
import com.xxx.common.result.PageResp;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * {{featureName}} Service 实现
 *
 * @author {{author}}
 * @since {{date}}
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class {{className}}ServiceImpl implements {{className}}Service {

    private final {{className}}Mapper {{instanceName}}Mapper;
    private final {{className}}Converter converter;
    // 如果有缓存,在此注入 RedissonClient
    // 如果有 MQ,在此注入对应的 EventPublisher

    @Override
    @Transactional(readOnly = true)
    public {{className}}Resp getById(Long id) {
        {{entityName}} entity = {{instanceName}}Mapper.selectById(id);
        if (entity == null) {
            throw new BizException(ErrorCode.{{ERROR_CODE_NOT_FOUND}});
        }
        return converter.toResp(entity);
    }

    @Override
    @Transactional(readOnly = true)
    public PageResp<{{className}}Resp> page({{className}}PageReq req) {
        Page<{{entityName}}> page = {{instanceName}}Mapper.selectPage(
            new Page<>(req.getPageNum(), req.getPageSize()),
            buildQueryWrapper(req)
        );
        return PageResp.of(page, converter::toResp);
    }

    @Override
    @Transactional
    public void save({{className}}SaveReq req) {
        {{entityName}} entity = converter.toEntity(req);
        {{instanceName}}Mapper.insert(entity);
        log.info("{{featureName}}创建成功, id={}", entity.getId());
        // 如果有 MQ:eventPublisher.publishXxxCreated(entity.getId());
    }

    @Override
    @Transactional
    public void update(Long id, {{className}}UpdateReq req) {
        {{entityName}} entity = {{instanceName}}Mapper.selectById(id);
        if (entity == null) {
            throw new BizException(ErrorCode.{{ERROR_CODE_NOT_FOUND}});
        }
        converter.updateEntity(req, entity);
        {{instanceName}}Mapper.updateById(entity);
        log.info("{{featureName}}更新成功, id={}", id);
    }

    @Override
    @Transactional
    public void remove(Long id) {
        {{entityName}} entity = {{instanceName}}Mapper.selectById(id);
        if (entity == null) {
            throw new BizException(ErrorCode.{{ERROR_CODE_NOT_FOUND}});
        }
        {{instanceName}}Mapper.deleteById(id);
        log.info("{{featureName}}删除成功, id={}", id);
    }

    private LambdaQueryWrapper<{{entityName}}> buildQueryWrapper({{className}}PageReq req) {
        return new LambdaQueryWrapper<{{entityName}}>()
            // 根据实际查询条件补充
            .orderByDesc({{entityName}}::getCreateTime);
    }
}

模板里用 {{变量名}} 标记需要替换的部分。Claude 在激活 Skill 后会读取模板,根据用户确认的信息(功能名、包路径等)把占位符替换成实际值。

第六步:编写 checklist.md

# 生成完毕自检清单

生成所有文件后,逐项检查:

## 代码正确性
- [ ] 所有 import 是否都能在项目里找到对应的类
- [ ] 包路径是否和文件实际位置一致
- [ ] MapStruct Converter 的方法签名是否和 Req/Resp/Entity 的字段匹配
- [ ] Mapper 里的 LambdaQueryWrapper 泛型是否正确

## 规范遵守
- [ ] ServiceImpl 的写操作是否都有 @Transactional
- [ ] 查询方法是否都有 @Transactional(readOnly = true)
- [ ] Controller 是否只有参数处理,无业务逻辑
- [ ] 所有写操作是否都有 INFO 日志,包含业务 ID
- [ ] 是否有返回实体类而不是 Resp 对象的接口(不允许)

## 遗漏项提示
检查完毕后,告知用户还需要手动完成的事项:
- 数据库建表 SQL
- ErrorCode 枚举新增错误码
- MqConstants 新增 topic 常量(如果有 MQ)
- application.yml 新增配置(如果有特殊配置)

让 Skill 学会提问而不是乱猜

Skill 里最重要的设计决策之一:遇到不确定的信息,提问而不是假设

在 SKILL.md 或 steps.md 里明确写出"执行前必须确认的信息",并且告诉 Claude 不确认完这些信息就不要开始生成。这个约束很重要——Claude 的默认行为是尽量不打断用户直接完成任务,但在代码生成场景里,基于错误假设生成的一堆代码往往比没有代码更让人头疼。

## 执行前必须确认

以下信息缺失时,停止生成并向用户提问:
- 功能名称(直接影响所有类名)
- 所属模块(影响包路径和依赖关系)

以下信息可以有合理默认值,但告知用户你的假设:
- 缓存策略(默认:无缓存)
- MQ(默认:无 MQ 消息)

迭代改进 Skill 的节奏

第一版 Skill 不会是最好的。实际使用几次之后,你会发现生成的代码还是有一些固定的错误或遗漏——这些就是 Skill 需要改进的地方。

建立一个简单的习惯:每次发现 Claude 用这个 Skill 生成的代码有问题,不只是在对话里纠正它,同时更新 Skill 的模板或 checklist,把这个问题的修复固化进去。几轮迭代下来,Skill 生成的代码质量会越来越接近你的标准,需要手动修改的地方越来越少。

这个过程本质上是把你的领域知识和质量标准,逐步沉淀进 Skill 的定义里。Skill 越成熟,它替你承担的认知负担就越多。

通过 Skills API 管理和分发组织级 Skill

从个人习惯到团队规范

当你独自开发时,自定义 Skill 放在 ~/.claude/skills/ 就够了——只要自己能用到就行。但当团队规模扩大,问题随之而来:你写了一个封装公司 API 规范的 Skill,同事怎么获取最新版本?新人入职第一天,谁来告诉他有哪些 Skill 可用?某个 Skill 里的安全策略更新了,怎么同步到所有人的本地环境?

靠"口口相传"或者群里发压缩包,是管不住这件事的。Skills API 解决的正是这个问题:通过 /v1/skills 端点,把 Skill 提升为工作区(Workspace)级别的共享资源,所有成员通过 API 统一获取,由管理员集中版本控制。

Skills API 的基本模型

通过 /v1/skills 端点上传的自定义 Skill 在整个工作区内共享,所有成员都可以访问。这与 Claude Code 的文件系统模式截然不同——后者是每个人自己维护本地目录,前者是统一的中心化分发。

Skills API 提供工作区范围的分发能力,支持上传、版本管理和权限控制。每个 Skill 目录(包含 SKILL.md 及其捆绑文件)与 Git 追踪的文件夹自然对应。

理解这个模型之后,我们来看一个完整的管理流程。假设你在一个交易平台的后端团队工作,需要把「Spring Boot 代码审查规范」这个 Skill 统一下发给所有后端工程师。

上传一个组织级 Skill

Skill 的结构本身没有变化,仍然是一个目录加一个 SKILL.md。以 Spring Boot 代码审查 Skill 为例:

springboot-review/
├── SKILL.md
└── checkstyle-rules.xml

SKILL.md 内容如下:

---
name: springboot-review
description:  Spring Boot 项目进行代码审查,包括 API 设计、异常处理、事务边界和安全规范检查
---

# Spring Boot 代码审查规范

## 核心检查项

审查时必须验证以下几个关键领域:

### API 层
- Controller 方法必须使用 `@Valid` 注解校验入参
- 统一返回 `Result<T>` 包装对象,禁止直接返回裸实体
- 异常信息不得透传到响应体,使用错误码替代

### 事务管理
- `@Transactional` 只加在 Service 层,Controller 层不允许开事务
- 涉及多表写操作必须显式声明 `rollbackFor = Exception.class`
- 禁止在事务方法内调用外部 HTTP 接口

### 安全规范
- 敏感字段(手机号、身份证)必须脱敏后返回
- 账号交易金额字段必须使用 `BigDecimal`,禁止 `double`

## 示例:标准 Controller 结构

参考 checkstyle-rules.xml 执行自动化格式检查。

准备好目录之后,通过 API 上传:

# 将 Skill 目录打包为 zip
zip -r springboot-review.zip springboot-review/

# 上传到工作区
curl -X POST "https://api.anthropic.com/v1/skills" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "anthropic-beta: skills-2025-10-02" \
  -F "files[]=@springboot-review/SKILL.md;filename=springboot-review/SKILL.md" \
  -F "files[]=@springboot-review/checkstyle-rules.xml;filename=springboot-review/checkstyle-rules.xml"

上传成功后,API 返回一个 skill_id,格式类似 skill_01AbCdEfGhIjKlMnOpQrStUv。这个 ID 是后续版本管理和调用的锚点,需要存入你的内部注册表。

版本管理:让更新可控

在生产环境中,建议将 Skill 固定到特定版本,并在每次发布新版本前运行完整的评估套件,将每次更新视为需要完整审查的新部署。

版本管理的操作通过对已有 Skill 创建新版本来完成:

# 修改 SKILL.md 后,创建新版本
NEW_VERSION=$(curl -X POST \
  "https://api.anthropic.com/v1/skills/skill_01AbCdEfGhIjKlMnOpQrStUv/versions" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "anthropic-beta: skills-2025-10-02" \
  -F "files[]=@springboot-review/SKILL.md;filename=springboot-review/SKILL.md" \
  | jq -r '.version')

echo "新版本号: $NEW_VERSION"

在 API 调用时指定版本,可以确保灰度发布期间不同环境使用不同版本:

# 调用时固定版本
curl https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "anthropic-beta: code-execution-2025-08-25,skills-2025-10-02" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-sonnet-4-6",
    "max_tokens": 4096,
    "container": {
      "skills": [{
        "type": "custom",
        "skill_id": "skill_01AbCdEfGhIjKlMnOpQrStUv",
        "version": "2"
      }]
    },
    "messages": [{"role": "user", "content": "审查这段 OrderService 代码"}],
    "tools": [{"type": "code_execution_20250825", "name": "code_execution"}]
  }'

在 Spring Boot 项目中集成管理脚本

实际工程中,与其手动执行 curl 命令,不如把 Skill 的生命周期管理封装成项目脚本。下面是一个用 Java 编写的 Skill 管理客户端,适合集成到内部运维工具或 CI/CD 流水线:

@Service
public class SkillManagementService {

    private static final String API_BASE = "https://api.anthropic.com/v1";
    private static final String BETA_HEADER = "skills-2025-10-02";

    @Value("${anthropic.api-key}")
    private String apiKey;

    private final RestTemplate restTemplate;

    /**
     * 上传或更新一个组织级 Skill
     * @param skillDir Skill 目录路径
     * @param existingSkillId 若为 null 则创建新 Skill,否则创建新版本
     */
    public SkillUploadResult uploadSkill(Path skillDir, String existingSkillId) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("x-api-key", apiKey);
        headers.set("anthropic-version", "2023-06-01");
        headers.set("anthropic-beta", BETA_HEADER);
        headers.setContentType(MediaType.MULTIPART_FORM_DATA);

        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();

        // 遍历目录,将所有文件加入请求体
        try (Stream<Path> files = Files.walk(skillDir)) {
            files.filter(Files::isRegularFile).forEach(file -> {
                String relativePath = skillDir.getParent()
                    .relativize(file).toString();
                body.add("files[]", new FileSystemResource(file) {
                    @Override
                    public String getFilename() {
                        return relativePath;
                    }
                });
            });
        } catch (IOException e) {
            throw new SkillUploadException("读取 Skill 目录失败", e);
        }

        String url = existingSkillId == null
            ? API_BASE + "/skills"
            : API_BASE + "/skills/" + existingSkillId + "/versions";

        HttpMethod method = existingSkillId == null
            ? HttpMethod.POST : HttpMethod.POST;

        ResponseEntity<Map> response = restTemplate.exchange(
            url, method,
            new HttpEntity<>(body, headers),
            Map.class
        );

        return SkillUploadResult.from(response.getBody());
    }

    /**
     * 查询工作区内所有已上传的 Skill
     */
    public List<SkillInfo> listWorkspaceSkills() {
        HttpHeaders headers = new HttpHeaders();
        headers.set("x-api-key", apiKey);
        headers.set("anthropic-version", "2023-06-01");
        headers.set("anthropic-beta", BETA_HEADER);

        ResponseEntity<Map> response = restTemplate.exchange(
            API_BASE + "/skills",
            HttpMethod.GET,
            new HttpEntity<>(headers),
            Map.class
        );

        List<Map<String, Object>> skills =
            (List<Map<String, Object>>) response.getBody().get("data");

        return skills.stream()
            .map(SkillInfo::from)
            .collect(Collectors.toList());
    }
}

配合 Spring Boot 的配置管理,可以把已发布的 Skill ID 和版本号维护在 application.yml 中:

anthropic:
  api-key: ${ANTHROPIC_API_KEY}
  skills:
    springboot-review:
      skill-id: skill_01AbCdEfGhIjKlMnOpQrStUv
      version: "2"   # 固定版本,避免自动升级引发行为变化
    db-migration-helper:
      skill-id: skill_02XyZwVuTsRqPoNmLkJiHgFe
      version: "latest"  # 内部工具可跟最新版

企业管控的关键:安全审查流程

部署企业级 Skill 需要回答两个独立问题:Skills 平台层面是否安全?以及如何评估某个具体 Skill 的风险?

在批准任何来自第三方或内部贡献者的 Skill 之前,需要完成以下步骤:阅读 Skill 目录的全部内容,确认跳转目标的合法性(若 Skill 引用外部 URL,验证其指向预期域名),并检查是否存在数据泄露模式。同时要求 Skill 评估人与作者分离,避免自审。

对于交易这类涉及资金的业务场景,这一点尤为重要。一个 Skill 如果在执行代码时能访问数据库连接字符串或支付密钥,其审查标准应该等同于审查一段生产代码。

分发方式的选择

Team 和 Enterprise 计划的管理员可以通过管理员设置集中下发 Skill,管理员下发的 Skill 默认对所有用户启用,用户也可以根据自己的偏好将单个 Skill 关闭。

对于 Claude Code 用户,除了 API 上传方式,还有两条分发路径值得了解:

第一是 Git 仓库。将 Skill 目录存入 Git 作为唯一事实来源,通过 Pull Request 进行代码审查和版本回滚。团队成员克隆仓库后,将 Skill 目录软链接到 ~/.claude/skills/ 即可本地使用,更新时只需 git pull。这个方式最轻量,适合规模较小且技术背景一致的团队。

第二是 Plugin 机制。通过将 Skill 提交到版本控制,项目成员可以直接使用;也可以通过创建含 skills/ 目录的 Plugin,在 Claude Code 中集中安装。这种方式可以把多个相关 Skill 打包成一个 Plugin 分发,安装命令简洁明了:

/plugin install springboot-tools@your-org

Skills API 把 Skill 从「个人工具」升级为「组织资产」。它的价值不在于技术复杂度,而在于把团队知识沉淀下来并让它可流动:一位资深工程师提炼出的代码审查经验,通过几个 API 调用,就能成为所有人都能调用的能力。对于正在构建内部工程工具链的团队而言,这是值得投入的基础设施。

设计 Skill 的 token budget 与上下文策略

一个容易被忽视的约束

大多数人在写完第一批 Skill 之后,不会立刻遇到问题。三个、五个 Skill,运行得很顺畅,Claude 总能找到正确的那一个。但当你认真对待 Skill 体系、开始为团队沉淀知识时,数量慢慢增加到二三十个,然后某天你忽然发现 Claude 对某个 Skill "视而不见"——你明确描述的场景,它就是没有触发。

这不是 Claude 变笨了,而是你踩到了 Skill 的 token budget 上限。

Skill 的描述字段会被加载进上下文,用于让 Claude 判断哪个 Skill 与当前任务相关。当 Skill 数量增多时,可能超出字符预算。这个预算随上下文窗口动态调整,基准值是上下文窗口的 2%,并有一个 16,000 字符的兜底上限。可以运行 /context 命令检查是否出现 Skill 被排除的警告。

16,000 字符,听起来不少。但一旦认真量化,你会发现它比想象中更紧张。

预算的真实消耗量

社区对这个预算做了实证测量。每个 Skill 在 available_skills 区域中消耗的字符量由两部分组成:固定开销(XML 标签、Skill 名称、位置字段等)约 109 个字符,加上 description 字段本身的长度。预算大约在 15,700 字符时填满。

换算下来,容量与 description 长度的关系大致是这样:

description 长度能容纳的 Skill 数量
263 字符(典型值)~42 个
200 字符~52 个
150 字符~60 个
130 字符~67 个

这个数字有一个关键含义:当 63 个 Skill 安装时,系统提示中出现了 <!-- Showing 42 of 63 skills due to token limits -->,有 21 个 Skill(33%)被完全隐藏,Claude 既无法发现也无法调用它们。截断是按累积总量计算的,而非单个 description 的长度——被隐藏的 Skill 和被显示的 Skill,平均 description 长度几乎完全相同,这证明顺序靠后的 Skill 会被整体丢弃。

这意味着你在 ~/.claude/skills/ 里排列目录的顺序,实际上决定了哪些 Skill 有机会被 Claude 看到。

Description 的写法:从散文到精准触发器

理解了预算机制之后,description 的写法就不再是「描述清楚就好」,而是一个需要刻意设计的字段。

一个常见的反面写法是这样的:

---
name: springboot-review
description: 这个 Skill 用于对 Spring Boot 项目进行全面的代码审查,
  包括检查 API 设计规范、异常处理方式、事务边界划分、
  安全配置以及代码风格等各个方面的问题,帮助团队保持
  代码质量和技术一致性。
---

这段 description 约 110 个汉字,换算成字符超过 110 个,且触发条件模糊。模糊的 description 会导致误触发——Claude 加载了一个并不匹配当前任务的 Skill,白白消耗上下文。description 中应该点名具体场景。

改写后的版本:

---
name: springboot-review
description: Spring Boot 代码审查:Controller/Service 分层、
  事务边界、BigDecimal 金额、敏感字段脱敏。
  用于:review 代码、检查规范、发现潜在问题。
---

这个版本做了三件事:说明了 Skill 处理的技术域(Spring Boot 分层、事务、金融字段),列出了具体的触发关键词(review、检查规范、发现潜在问题),以及把字符数压缩到 80 字符以内。

对于一个 Spring Boot 后端项目,你可能同时维护多个 Skill,每个都需要这种精简写法:

# db-migration-helper/SKILL.md
---
name: db-migration-helper
description: MyBatis Plus + Flyway 数据库迁移:
  生成 migration SQL、检查索引、处理字段变更。
  用于:添加表字段、创建索引、数据迁移任务。
---
# api-doc-generator/SKILL.md
---
name: api-doc-generator
description:  Spring Boot Controller 生成 OpenAPI 文档。
  用于:写接口文档、生成 Swagger、补充接口注释。
---

SKILL.md 内容本身的分层策略

description 只是冰山一角。真正决定上下文效率的,是 SKILL.md 正文的组织方式。

核心原则是:让 Skill 的正文只包含 Claude 在执行这个任务时真正需要的信息

一个常见的错误是把所有背景知识都塞进 SKILL.md,比如在一个代码生成 Skill 里附上完整的公司技术规范文档。这些内容在 Skill 被触发时会全部注入上下文,但实际上大部分内容对当前这次调用毫无意义。

更好的做法是按「参考型」和「任务型」两种内容分开设计。

参考型内容适合写轻量的原则和约束,让 Claude 把它当成背景知识:

---
name: order-service-conventions
description: 订单服务编码约定,写 OrderService 相关代码时自动加载。
---

# 订单服务约定

金额字段统一用 BigDecimal,精度 scale=2。
状态流转顺序:PENDING  PAID  SHIPPED  COMPLETED。
订单号生成规则:`ORD-{yyyyMMdd}-{6位序号}`,由 OrderIdGenerator 统一生成。
所有数据库操作必须经过 OrderRepository,禁止在 Service 中直接调用 Mapper。

这种 Skill 全文不超过 200 字,却精准传递了新人需要一周才能摸清楚的隐性规范。

任务型内容则需要包含完整的步骤,但要避免用大段文字解释「为什么」——原因留给 CLAUDE.md,步骤才属于 Skill:

---
name: add-api-endpoint
description:  Spring Boot 项目中新增 REST 接口的完整流程。
  用于:加接口、新增 API、实现新功能端点。
disable-model-invocation: true
---

# 新增 REST 接口

1.  `dto/request/` 下创建请求 DTO,加 `@Valid` 注解
2.  `dto/response/` 下创建响应 DTO,继承 `BaseResponse<T>`
3.  Controller 中添加方法,统一用 `Result<T>` 包装返回值
4.  Service 接口和实现类中添加业务方法
5.  `src/test/` 下创建对应的单元测试
6. 更新 Swagger 注解

参考现有示例:`src/main/java/com/example/controller/AccountController.java`

注意这里使用了 disable-model-invocation: true。任务型内容适合通过 /skill-name 直接调用,而不是让 Claude 自主判断何时运行。加上 disable-model-invocation: true 可以防止 Claude 在你没有明确意图时自动触发它。

Subagent 分叉:把上下文消耗隔离到子空间

对于计算量大、会产生大量中间结果的任务,还有另一种上下文策略:把任务分叉给 Subagent 执行,让主会话保持干净。

---
name: codebase-audit
description: 对整个代码库做架构合规性审查,扫描禁用模式和潜在风险。
context: fork
agent: Explore
---

对当前项目的 `src/` 目录执行以下检查:

1. 扫描所有 Controller,确认返回值是否统一使用 Result<T> 包装
2. 检查 @Transactional 是否只出现在 Service 
3. 找出所有直接使用 double/float 存储金额的字段
4. 输出问题列表,格式:文件路径 + 行号 + 问题描述

context: fork 让任务在一个分叉的 Explore agent 中运行,Skill 内容成为该 agent 的任务,agent 只返回最终结论,主会话的上下文不会被大量的文件读取结果污染。代价是真实的:子 agent 对主 agent 的完整上下文不可见,无法进行整体性推理。在上下文隔离真正有价值的场景才使用它——平行探索、沙箱工具调用、或需要保持主会话干净的长任务。

环境变量与诊断

当你需要调整默认预算,或者排查某个 Skill 为什么没有触发,有几个实用的工具:

# 检查当前上下文状态,包括 Skill 加载情况
/context

# 临时扩大 Skill 字符预算(适合本地开发调试)
export SLASH_COMMAND_TOOL_CHAR_BUDGET=32000

# 查看当前会话的 token 消耗
/cost

相比把所有内容放进 CLAUDE.md 一次性加载,按需触发的 Skill 架构在实践中每次会话能节省约 15,000 token,效率提升约 82%。 这个差距在单次对话里看不出来,但对于一个每天都在运行的团队,积累下来是显著的成本和速度收益。


Token budget 并不是一个需要绕开的限制,而是迫使你把 Skill 设计得更精准的约束。description 是触发信号,不是说明书;SKILL.md 正文是执行指令,不是知识库。把握住这两点区别,你的 Skill 体系才能在数量增长的同时保持有效。

Hooks 钩子系统

了解 PreToolUse / PostToolUse 等钩子生命周期

资料充足,现在来写这篇文章。


Hooks 解决的是什么问题

Claude 很擅长「记住」你在提示词里写的约定,但它不会每次都执行它们。你告诉它「修改完代码后跑一下测试」,有时它照做了,有时它直接结束任务。这不是 Claude 在偷懒,而是语言模型的本质:指令是概率性的,不是确定性的。

Hooks 是自动化触发器——它们在特定条件满足时必然触发,与 AI 决定做什么无关。这一点至关重要:Hooks 不依赖模型「记得」去格式化代码或运行测试,它们在条件匹配时每次都执行。

这是 Hooks 的核心价值:把「应该做」变成「必然做」。

生命周期全景

理解 Hooks 最直观的方式是把一次 Claude Code 会话想象成一条流水线。你提交一个 Prompt,Claude 开始思考,然后调用各种工具(读文件、写代码、执行命令),最终给出回答。这条流水线上的每一个关键节点,都对应一个可以挂载 Hook 的事件。

完整的生命周期覆盖三个层次:会话层(SessionStart / SessionEnd)、主对话循环层(UserPromptSubmit、工具执行三件套、Stop)、以及 Subagent 子层(SubagentStart / SubagentStop)。此外还有一个维护层的 PreCompact,在上下文压缩前触发。

对于日常开发工作,最核心的是工具执行三件套:

PreToolUse 在工具执行之前触发,它是最强大的钩子,因为它可以批准或拒绝待执行的操作。如果你的 Hook 返回 deny 信号,Claude 就无法继续执行那个工具调用,这使得 PreToolUse 成为安全策略、文件保护规则和强制审查门禁的执行机制。

PostToolUse 在工具成功完成之后触发。它的输入同时包含 tool_input(发给工具的参数)和 tool_response(工具返回的结果),适合做格式化、代码检查等后处理工作。

PostToolUseFailure 在工具执行失败时触发,用于结构化记录错误日志,或在失败后自动触发补救动作。

配置方式与作用域

Hooks 写在 JSON 配置文件里,根据放置位置决定作用范围:

~/.claude/settings.json          # 全局,对所有项目生效
.claude/settings.json            # 项目级,提交到版本库,团队共享
.claude/settings.local.json      # 本地覆盖,不提交版本库

一个最小化的配置结构如下:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write $(echo $CLAUDE_TOOL_INPUT | jq -r '.file_path')"
          }
        ]
      }
    ]
  }
}

matcher 字段是一个正则表达式,用于过滤何时触发。使用 *、空字符串或直接省略 matcher,可以匹配所有情况。Edit|Write 会匹配两种工具,Bash 只匹配 Bash 命令。

也可以通过交互式命令配置,在 Claude Code 会话中直接输入 /hooks,会进入逐步引导流程,适合初次配置时使用。

在 Spring Boot 项目里落地

理解了生命周期之后,来看几个对 Spring Boot 开发实际有用的 Hook 配置。

场景一:代码格式化

每次 Claude 修改 Java 文件后,自动用 Google Java Format 格式化:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'FILE=$(echo $CLAUDE_TOOL_INPUT | jq -r ".file_path // empty"); if [[ "$FILE" == *.java ]]; then java -jar ~/.tools/google-java-format.jar --replace "$FILE"; fi'"
          }
        ]
      }
    ]
  }
}

场景二:阻止危险的 SQL 操作

在测试或开发环境里,防止 Claude 通过 Bash 执行带 DROP TABLEDELETE FROM 的命令:

#!/bin/bash
# scripts/guard-sql.sh

INPUT=$(cat)  # Hook 通过 stdin 传入工具调用的 JSON
COMMAND=$(echo "$INPUT" | jq -r '.command // empty')

if echo "$COMMAND" | grep -qiE 'DROP\s+TABLE|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\w+\s*(;|$)'; then
  echo "危险 SQL 操作被拦截:$COMMAND" >&2
  exit 2  # exit code 2 = deny,阻止执行并将 stderr 反馈给 Claude
fi

exit 0  # 允许执行
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash scripts/guard-sql.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

当 Hook 脚本以 exit code 2 退出时,操作被拒绝,stderr 的内容会作为反馈信息传回给 Claude,让它了解为什么被阻止。

场景三:提交前强制运行测试

Stop 事件在 Claude 完成一轮回答时触发,适合做收尾检查:

#!/bin/bash
# scripts/pre-stop-check.sh

# 检查是否有未提交的 Java 文件修改
MODIFIED=$(git diff --name-only | grep '.java$')

if [ -n "$MODIFIED" ]; then
  echo "检测到 Java 文件修改,运行相关测试..."
  # 只运行修改文件对应的测试模块
  mvn test -pl $(echo "$MODIFIED" | head -1 | cut -d'/' -f1) -q 2>&1
  if [ $? -ne 0 ]; then
    echo '{"decision": "block", "reason": "测试未通过,请先修复失败的测试用例"}' 
    exit 1
  fi
fi
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash scripts/pre-stop-check.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

PreToolUse 的输入修改能力

除了「拦截」,PreToolUse 还有一个更精妙的用法:在不告知 Claude 的情况下,悄悄修改工具调用的参数。

从 v2.0.10 开始,PreToolUse Hook 可以在执行前修改工具输入。Hook 通过 stdin 接收工具调用的 JSON,修改后输出到 stdout,Claude Code 使用修改后的参数执行工具。这些修改对 Claude 不可见,可以用于透明的参数修正、自动添加安全标志、或修正路径等。

一个实际例子:强制让 Bash 里的 mvn 命令总是带上 -q(静默模式),避免大量构建日志把上下文撑大:

#!/bin/bash
# scripts/normalize-mvn.sh

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.command // empty')

# 如果包含 mvn 命令但没有 -q 标志,自动添加
if echo "$COMMAND" | grep -q '\bmvn\b' && ! echo "$COMMAND" | grep -q '-q\b'; then
  MODIFIED=$(echo "$INPUT" | jq --arg cmd "$(echo "$COMMAND" | sed 's/\bmvn\b/mvn -q/')" '.command = $cmd')
  echo "$MODIFIED"  # 输出修改后的 JSON
  exit 0
fi

# 不修改,直接允许
echo "$INPUT"
exit 0

Hooks 的配置作用域与安全边界

Hook 以你的完整用户权限运行,没有沙箱隔离。配置错误的 Hook 可能删除文件、暴露密钥或执行任意代码。

对于团队环境,有几个实践值得遵循:

把团队必须共同遵守的质量门禁放进项目级的 .claude/settings.json 提交到版本库,让每个人的本地环境自动获得相同的约束。个人偏好(比如你自己习惯的格式化工具)放进 .claude/settings.local.json 并加到 .gitignore

Hook 脚本本身建议放在项目 scripts/claude/ 目录下统一管理,和代码一起走 Code Review 流程。一个配错了的 Hook 的破坏力不亚于一段有 bug 的业务代码。


Hooks 的核心思路是:Claude 负责推理和生成,Hooks 负责守纪律。两者分工清晰,前者灵活,后者确定。理解了这个分工,你就知道哪些事情该写进 CLAUDE.md 让 Claude 去「记住」,哪些事情该写成 Hook 让系统去「强制」。

编写 hook 脚本实现质量门禁(lint / test 强制运行)

门禁的本质:从建议变成约束

在没有 Hooks 的情况下,你能做的最多是在 CLAUDE.md 里写「修改完代码后请运行 mvn checkstyle:check 和单元测试」。Claude 大多数时候会照做,但不是每次——尤其是在长会话里,指令会随着上下文被稀释。

质量门禁的本质是把保证(guarantee)分为三类:格式化保证在写入后自动修正代码风格,属于事后纠偏;安全保证在执行前拦截危险操作,属于事前阻断;质量保证在关键决策点校验状态,比如在 git commit 前阻断 lint 不通过的提交。每类保证对应不同的钩子时机,混用会导致逻辑错乱。

在 Spring Boot 项目里,质量门禁通常有三道:代码风格(Checkstyle)、编译检查(mvn compile)、测试(mvn test)。这三道门禁分别对应不同的触发时机,下面逐一落地。

项目目录结构

先把 Hook 脚本统一组织到项目里,方便版本管理和团队共享:

your-project/
├── .claude/
│   ├── settings.json          # Hook 配置
│   └── hooks/
│       ├── post-edit-lint.sh  # 写入后运行 Checkstyle
│       ├── pre-commit-gate.sh # commit 前的测试门禁
│       └── stop-gate.sh       # Claude 回答结束前的完整检查
└── pom.xml

Hook 脚本放在 .claude/hooks/ 而不是项目根目录,理由是项目根已经够乱了——Hook 脚本是 Claude Code 的专属基础设施,单独隔离。

第一道门:写入后立即 Lint

每次 Claude 修改或新建 Java 文件后,立刻运行 Checkstyle,让 Claude 在本次回答周期内就能看到并修复格式问题,而不是积累到最后一起爆发。

#!/bin/bash
# .claude/hooks/post-edit-lint.sh
set -euo pipefail

# 从 stdin 读取 Hook 传入的 JSON 数据
INPUT=$(cat)

# 提取被修改的文件路径
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# 只处理 Java 文件
if [[ -z "$FILE_PATH" || "$FILE_PATH" != *.java ]]; then
  exit 0
fi

# 确认文件存在(Claude 可能删除了文件)
if [[ ! -f "$FILE_PATH" ]]; then
  exit 0
fi

echo "🔍 Checkstyle: $FILE_PATH" >&2

# 只检查这一个文件,避免全量扫描拖慢速度
# -Dcheckstyle.includes 接受 Ant 风格路径
mvn checkstyle:check \
  -Dcheckstyle.includes="$(basename "$FILE_PATH")" \
  -q --no-transfer-progress 2>&1

if [[ $? -ne 0 ]]; then
  echo "❌ Checkstyle 不通过,请修复格式问题后继续。" >&2
  exit 2  # exit 2 = deny,阻断并将 stderr 反馈给 Claude
fi

echo "✅ Checkstyle 通过" >&2
exit 0

对应的 .claude/settings.json 配置:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/post-edit-lint.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

这里有个细节:timeout 设为 30 秒。PostToolUse Hook 同步执行,每次文件修改都会触发,因此必须快,超过 500ms 的 Hook 会让整个会话感觉迟滞。只检查单个文件而不是全量扫描,正是为了保证响应速度。

第二道门:拦截不合规的 git commit

这道门禁用 PreToolUse 拦截 Bash 工具里的 git commit 命令,在 Claude 真正提交之前,强制通过编译和测试。

#!/bin/bash
# .claude/hooks/pre-commit-gate.sh
set -euo pipefail

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# 只在执行 git commit 时触发,其他 Bash 命令直接放行
if ! echo "$COMMAND" | grep -q 'git commit'; then
  exit 0
fi

echo "🚦 提交前质量检查..." >&2

# 第一步:编译
echo "→ 编译检查" >&2
if ! mvn compile -q --no-transfer-progress 2>&1; then
  echo "❌ 编译失败,无法提交。请先修复编译错误。" >&2
  exit 2
fi

# 第二步:只运行与本次改动相关的测试模块
CHANGED_MODULES=$(git diff --cached --name-only \
  | grep '.java$' \
  | sed 's|/src/.*||' \
  | sort -u \
  | tr '\n' ',')

if [[ -n "$CHANGED_MODULES" ]]; then
  MODULES="${CHANGED_MODULES%,}"  # 去掉末尾逗号
  echo "→ 运行受影响模块测试: $MODULES" >&2
  if ! mvn test -pl "$MODULES" -q --no-transfer-progress 2>&1; then
    echo "❌ 测试未通过,无法提交。请先修复失败的测试。" >&2
    exit 2
  fi
fi

echo "✅ 质量检查通过,允许提交" >&2
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/pre-commit-gate.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

这里用了一个关键优化:只运行 git diff --cached 里改动文件所属的模块,而不是跑整个项目的测试套件。一个有二十个子模块的 Spring Boot 工程,全量测试可能需要十分钟,但按模块过滤后通常在一分钟内完成。

第三道门:Claude 结束回答前的完整检查

Stop 事件在 Claude 认为自己完成了本轮任务时触发。这是最适合做「最终确认」的时机。但 Stop 钩子有一个危险的陷阱必须处理——无限循环。

在 Stop Hook 里必须检查 stop_hook_active 字段。当它为 true 时,Claude 正在因为前一个 Stop Hook 的阻断而继续工作。此时必须立即 exit 0。不做这个检查,Hook 会永远阻止 Claude 停止。这是新手最常犯的错误。

#!/bin/bash
# .claude/hooks/stop-gate.sh
set -euo pipefail

INPUT=$(cat)

# ⚠️ 关键:防止无限循环
if [[ "$(echo "$INPUT" | jq -r '.stop_hook_active')" == "true" ]]; then
  exit 0
fi

# 检查是否有未提交的 Java 文件改动
MODIFIED_JAVA=$(git diff --name-only 2>/dev/null | grep '.java$' || true)

# 如果没有 Java 文件改动,不做检查
if [[ -z "$MODIFIED_JAVA" ]]; then
  exit 0
fi

FILE_COUNT=$(echo "$MODIFIED_JAVA" | wc -l | tr -d ' ')
echo "📋 检测到 $FILE_COUNT 个 Java 文件改动,执行收尾检查..." >&2

# 快速 Checkstyle 全量扫描(只扫 src/main/java,排除测试代码)
echo "→ Checkstyle 扫描" >&2
if ! mvn checkstyle:check -q --no-transfer-progress 2>&1; then
  echo "" >&2
  echo "❌ 存在 Checkstyle 错误,请修复后再结束。" >&2
  exit 2
fi

echo "✅ 收尾检查通过" >&2
exit 0

本地调试 Hook 脚本

在挂载到 Claude Code 之前,直接在命令行测试 Hook 脚本,效率更高:

# 模拟 PostToolUse 传给 Hook 的 JSON 数据
echo '{"tool_name":"Write","tool_input":{"file_path":"src/main/java/com/example/service/OrderService.java","content":"..."}}' \
  | bash .claude/hooks/post-edit-lint.sh
echo "exit code: $?"

# 模拟 PreToolUse 拦截 git commit
echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m "feat: add order status tracking""}}' \
  | bash .claude/hooks/pre-commit-gate.sh
echo "exit code: $?"

# 模拟 Stop 事件(正常情况)
echo '{"stop_hook_active":false}' \
  | bash .claude/hooks/stop-gate.sh
echo "exit code: $?"

# 模拟 Stop 事件(已在循环中)
echo '{"stop_hook_active":true}' \
  | bash .claude/hooks/stop-gate.sh
echo "exit code: $?"

通过 stdin 管道直接测试是验证 Hook 行为最快的方式,输入样本 JSON 后检查 exit code 即可确认逻辑是否正确。

当 Hook 不按预期触发时,在 Claude Code 里开启调试模式可以看到完整的匹配和执行日志:

claude --debug

也可以在会话中按 Ctrl+O 切换 verbose 模式,在对话界面里实时查看 Hook 输出。

团队共享与精细控制

.claude/settings.json 提交到版本库,团队所有人克隆代码后自动获得相同的质量门禁。但有时候你需要让个别团队成员能临时绕过(比如在紧急修复时),可以利用 settings.local.json 提供一个逃生通道:

// .claude/settings.local.json(加入 .gitignore,不提交)
{
  "hooks": {
    "PreToolUse": [],
    "PostToolUse": [],
    "Stop": []
  }
}

对于企业环境,还有一个更严格的方向:企业可以使用 allowManagedHooksOnly 配置,限制用户只能使用组织批准的 Hook,阻止有善意但存在风险的开发者自行试验。这和 Skill 的组织级分发是同一套管控思路,适合对代码安全有高要求的团队。


三道门禁各司其职:PostToolUse 管风格,PreToolUse 管提交,Stop 管收尾。它们不是孤立的脚本,而是一套有层次的自动化策略。写完第一版之后,用 --debug 模式跑几轮真实任务,观察哪些 Hook 触发频率过高或执行太慢,再做针对性调整——这套门禁本身也需要迭代。

用 hook 实现跨会话的内存与状态持久化

Claude Code 的记忆边界

每次你用 claude 命令开启一个新会话,Claude 对上次做了什么一无所知。它不记得你昨天把哪个接口从 GET 改成了 POST,不记得你讨论了半小时决定放弃某个方案,也不记得那个还没修完的 TODO。

Claude Code 从 v2.0.64 起引入了原生 Session Memory,会在后台自动压缩会话内容并在下次启动时召回。但这个系统依赖 Anthropic API 基础设施,部分账户尚未全量开放,Bedrock 和 Vertex 用户也无法使用。更重要的是,它不能持久化你想要精确保留的东西——比如当前功能开发到哪一步、哪些类已经修改了但还没测试、数据库迁移是否已经跑过。

用 Hooks 自己实现状态持久化,控制更精准,也不依赖任何外部能力。

设计思路:两个锚点

跨会话持久化的核心是两个时机的配合:SessionStart 负责上下文注入,Stop 负责持久化。对话是短暂的,Session 结束时触发的 Hook 是你连接持久状态的桥梁。

具体来说,流程如下:每次 Stop 事件触发时,把本轮会话的关键信息(做了什么、遗留了什么)写入一个状态文件;下次 SessionStart 时,把这个文件的内容通过 additionalContext 注入到 Claude 的初始上下文里。Claude 一开场就知道上次在哪里停下来,不需要你重新解释。

项目目录结构先确定好:

your-project/
├── .claude/
│   ├── settings.json
│   ├── hooks/
│   │   ├── session-start.sh   # 注入上次状态
│   │   └── session-end.sh     # 保存本次状态
│   └── state/
│       ├── session-memory.md  # 持久化的记忆文件(提交到 git)
│       └── session-log.jsonl  # 原始事件日志(加入 .gitignore)
└── pom.xml

session-memory.md 是人类可读、Claude 可理解的结构化文件,应该提交到版本库——这样团队其他成员(包括 CI 环境里运行的 Claude)也能从同一份上下文出发。session-log.jsonl 是原始事件记录,量大且含噪音,不用提交。

SessionStart:把记忆注入初始上下文

SessionStart Hook 收到 JSON 输入后,stdout 的输出会被直接添加到 Claude 的上下文里。官方推荐格式是输出带 hookSpecificOutput.additionalContext 字段的 JSON,这样内容会作为 Claude 的隐式上下文注入,而不是作为用户消息出现。

#!/bin/bash
# .claude/hooks/session-start.sh
set -euo pipefail

INPUT=$(cat)
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"')
STATE_FILE="$(pwd)/.claude/state/session-memory.md"

# resume 模式说明用户在继续上一次会话,记忆已在上下文里,不重复注入
if [[ "$SOURCE" == "resume" ]]; then
  exit 0
fi

# 状态文件不存在时,说明是全新项目,跳过
if [[ ! -f "$STATE_FILE" ]]; then
  exit 0
fi

# 读取状态文件,构造注入内容
MEMORY_CONTENT=$(cat "$STATE_FILE")
LAST_UPDATED=$(date -r "$STATE_FILE" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "unknown")

# 通过 hookSpecificOutput 注入,不打扰用户界面
cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "## 上次会话记忆(更新于 $LAST_UPDATED)\n\n$MEMORY_CONTENT\n\n---\n以上为跨会话持久记忆,优先级低于当前对话指令。"
  }
}
EOF

exit 0

SessionStart 的 matcher 对应会话的启动方式:startup 是新会话,resume 是恢复,clear 是执行了 /clear 之后,compact 是压缩之后。针对不同来源做区分处理,避免在已有完整上下文的 resume 场景里重复注入导致信息冗余。

SessionEnd / Stop:把本轮对话写入记忆

状态的保存发生在 Stop 事件——Claude 完成本轮回答时。这里需要解决一个实际问题:Hook 脚本本身不知道「这轮对话做了什么」,它只知道 Hook 被触发了。

解决方案是在 Stop 时主动读取 transcript_path,从对话记录里提取有价值的信息。每个 Hook 收到的 JSON 输入里都包含 transcript_path,指向当前会话的 .jsonl 文件。

#!/bin/bash
# .claude/hooks/session-end.sh
set -euo pipefail

INPUT=$(cat)

# 防无限循环
if [[ "$(echo "$INPUT" | jq -r '.stop_hook_active')" == "true" ]]; then
  exit 0
fi

SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty')
STATE_DIR="$(pwd)/.claude/state"
MEMORY_FILE="$STATE_DIR/session-memory.md"
LOG_FILE="$STATE_DIR/session-log.jsonl"

mkdir -p "$STATE_DIR"

# --- 1. 记录原始事件日志 ---
echo "{"session_id":"$SESSION_ID","timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","event":"stop"}" >> "$LOG_FILE"

# --- 2. 提取本次会话修改了哪些文件 ---
CHANGED_FILES=""
if [[ -n "$TRANSCRIPT_PATH" && -f "$TRANSCRIPT_PATH" ]]; then
  # 从 transcript 里提取所有 Write/Edit 工具调用的 file_path
  CHANGED_FILES=$(jq -r '
    select(.type == "tool_use") |
    select(.name == "Write" or .name == "Edit" or .name == "MultiEdit") |
    .input.file_path // empty
  ' "$TRANSCRIPT_PATH" 2>/dev/null | sort -u | head -20 | tr '\n' '\n' || echo "")
fi

# --- 3. 提取 git 状态作为项目状态快照 ---
GIT_STATUS=""
if git rev-parse --git-dir > /dev/null 2>&1; then
  BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
  UNCOMMITTED=$(git status --short 2>/dev/null | head -10 || echo "")
  LAST_COMMIT=$(git log --oneline -1 2>/dev/null || echo "no commits")
  GIT_STATUS="分支: $BRANCH\n最近提交: $LAST_COMMIT"
  if [[ -n "$UNCOMMITTED" ]]; then
    GIT_STATUS="$GIT_STATUS\n未提交变更:\n$UNCOMMITTED"
  fi
fi

# --- 4. 更新状态文件 ---
# 读取现有记忆里的"持久规则"部分(## 项目约定 及以下),不覆盖
PERSISTENT_RULES=""
if [[ -f "$MEMORY_FILE" ]]; then
  PERSISTENT_RULES=$(awk '/^## 项目约定/{found=1} found{print}' "$MEMORY_FILE" 2>/dev/null || echo "")
fi

# 写入新的状态文件
cat > "$MEMORY_FILE" <<MEMEOF
# 项目记忆 - $(date '+%Y-%m-%d %H:%M')

## 上次会话概况

- 会话 ID:$SESSION_ID
- 结束时间:$(date '+%Y-%m-%d %H:%M:%S')

## Git 状态

$(echo -e "$GIT_STATUS")

## 本次会话修改的文件

$(echo "$CHANGED_FILES" | sed 's/^/- /' | head -20 || echo "(无文件修改)")

## 待继续的工作

(此处由 Claude 在会话结束时填写——如有明确的下一步,请在结束前告知)

$PERSISTENT_RULES
MEMEOF

exit 0

有一处设计值得注意:状态文件保留了 ## 项目约定 之后的内容不覆盖。这样你可以手动在状态文件里写下跨会话都适用的约定(比如「这个项目禁止用 Lombok」),它们不会因为每次 Stop 更新而消失。

让 Claude 主动参与记录

上面的脚本可以自动提取文件修改和 Git 状态,但它不知道「这轮对话决定了什么」「遇到了什么坑」「下一步打算做什么」。这些语义信息只有 Claude 知道。

解决方法是在 CLAUDE.md 里加一条约定,让 Claude 在结束前主动更新状态文件里的"待继续工作"部分:

<!-- .claude/CLAUDE.md 相关片段 -->

## 会话结束规范

每次任务完成后,在结束回答前,用 Write 工具更新
`.claude/state/session-memory.md` 的"待继续的工作"部分,
格式如下:

待继续的工作

  • [状态] 正在实现的功能或修复

    • 进度:已完成 X,待做 Y
    • 注意事项:...
  • [待办] 下一步需要处理的事项


这是给下次会话的交接文档,越具体越好。

这样每次会话结束,状态文件里就有了两层信息:机器自动提取的文件和 Git 状态,加上 Claude 自己写的语义摘要。

PreCompact:防止压缩丢失进度

PreCompact 事件在 /compact 执行前触发,可以用来备份当前 transcript,配合 SessionStart 实现上下文恢复。这对长会话尤其重要——当上下文到达 80% 被迫压缩时,你不想丢掉当前的工作进度。

#!/bin/bash
# .claude/hooks/pre-compact.sh

INPUT=$(cat)
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty')
BACKUP_DIR="$(pwd)/.claude/state/backups"

if [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]]; then
  exit 0
fi

mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date '+%Y%m%d-%H%M%S')
cp "$TRANSCRIPT_PATH" "$BACKUP_DIR/transcript-$TIMESTAMP.jsonl"

# 只保留最近 5 份备份
ls -t "$BACKUP_DIR"/transcript-*.jsonl 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true

exit 0

把三个 Hook 配置进 .claude/settings.json

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|clear|compact",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/session-start.sh",
            "timeout": 5
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/session-end.sh",
            "async": true
          }
        ]
      }
    ],
    "PreCompact": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/pre-compact.sh",
            "async": true
          }
        ]
      }
    ]
  }
}

StopPreCompact 使用 "async": true。异步 Hook 触发后 Claude Code 立即继续,不等待脚本执行完毕;脚本完成后如果有 additionalContext 字段,会在下一个对话轮次时传入。状态保存是副作用,不需要阻塞主流程。而 SessionStart 是同步的,必须等脚本返回后 Claude 才开始工作,所以 timeout 要控制在合理范围内——5 秒足够读文件,不要在里面做网络请求。


这套系统运行起来后,你会感觉到一个细微但持续的变化:每次打开新会话,Claude 已经知道昨天在哪里收工,哪个模块改了一半还没测,下一步应该做什么。它不是万能的——语义摘要的质量取决于 Claude 是否认真填写。但即便只有文件清单和 Git 状态,也比每次从零讲起要好得多。

多智能体与并行任务

开启实验性 Agent Teams 功能(多 agent 协作)

单线程的天花板

用 Claude Code 开发到一定程度,你会遇到一类特殊的任务:它不是「难」,而是「宽」。

比如你要给交易平台做一次安全加固——涉及接口层的参数校验、Service 层的权限逻辑、数据库敏感字段的脱敏、以及相关的测试覆盖。这四件事技术上是独立的,互相不依赖,但一个 Claude Code 会话只能串行处理,做完 API 层再做权限层,依次推进。

Subagents 可以并行,但它们的通信是单向的——只能把结果汇报给父 Agent,不能彼此交流。你发现 API 层有个问题需要同步给正在写测试的 Agent?做不到,必须绕一圈。

Agent Teams 解决了这个问题:一个会话担任 team lead,负责协调工作、分配任务和综合结果;teammates 各自在独立的上下文窗口里工作,并且可以直接互相通信。与 subagents 不同,你还可以不经过 team lead 直接与某个 teammate 交互。

开启方式

Agent Teams 是实验性功能,默认关闭,需要 Claude Code v2.1.32 或更高版本。 先确认版本:

claude --version

开启方式有两种。临时开启(当前 shell 会话有效):

export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
claude

持久化到 settings.json(推荐):

// ~/.claude/settings.json 或项目 .claude/settings.json
{
  "env": {
    "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
  }
}

如果想在终端里清晰地看到每个 teammate 各自的输出,安装 tmux 并在其中启动 Claude Code——每个 teammate 会占据独立的 pane,交互体验好很多。但这不是必须的,没有 tmux 一样能用,只是所有输出混在一个终端里。

架构:四个组件协同工作

Agent Team 由四个组件构成:Team Lead(主 Claude Code 会话,负责创建团队、生成任务、综合结果)、Teammates(各自有独立上下文窗口的 Claude 实例)、共享任务列表(所有 Agent 可见的中心任务队列,支持依赖追踪)、以及 Mailbox(Agent 之间的消息通信系统)。

团队配置和任务列表存储在本地:~/.claude/teams/{team-name}/config.json~/.claude/tasks/{team-name}/

关键机制是任务依赖追踪。你可以声明「Task B 依赖 Task A」,当 A 完成,B 自动解锁,teammates 自主领取下一个可执行的任务,不需要 lead 一直盯着。

与 subagents 不同的是,teammates 运行时的上下文相互独立,可以直接向彼此发消息,也可以向全团队广播。当一个 teammate 完成了其他任务的依赖项时,被阻塞的任务自动解锁,无需人工干预。

Subagents vs Agent Teams:怎么选

这两者经常被混淆,核心区分标准只有一个:workers 之间需不需要互相通信?

Subagents 是委托模式——发出去,等结果,所有协调都经过主 Agent。Agent Teams 是项目团队模式——每人做自己的,但一直保持沟通。当后端改了数据模型,前端 teammate 立刻知道,而不是等到集成测试挂了才发现。

实际开发中,这两类场景的判断相当清晰:

对于「写这个模块的单元测试」「分析这个目录的所有依赖」这类有明确边界、结果独立的任务,Subagent 就够了,成本更低,不需要 Agent Teams 的通信层开销。

对于「安全审计 + 性能分析 + 测试覆盖三个维度同时看,最后综合出结论」这类需要多个视角互相印证的任务,或者「前后端 + 数据库层同步重构」这类改动牵连多层但需要及时对齐接口的任务,Agent Teams 才发挥出真正价值。

实测数据:Agent Teams 在大型可并行任务上比单 Agent 模式快 3—5 倍,但 token 消耗也同比例增加,一个三人团队大约是单会话的 2.5—4 倍费用。 对于按量计费的 API 用户,这是真实的成本,需要在任务规模上做判断。

第一次实验:让 Claude 来决定团队结构

最简单的起点是直接用自然语言描述任务,让 Claude 自主决定怎么组队。以下是一个针对 Spring Boot 项目的实际 prompt:

我们的订单服务(src/main/java/com/example/order/)
需要一次全面审查,请创建一个 agent team 来并行处理:

- API 审查员:检查 OrderController 的参数校验、返回值包装、
  权限注解是否完整
- 业务逻辑审查员:检查 OrderService 的事务边界、异常处理、
  金额是否使用 BigDecimal
- 测试覆盖审查员:检查已有测试的覆盖率,找出缺少测试的关键路径

三位 teammate 各自完成审查后,汇总发现的问题并按优先级排列。

Claude 会基于你的描述创建团队、生成共享任务列表、派生 teammates,并协调工作;也可能主动建议创建团队——无论哪种情况,都需要你确认才会真正执行。

手动控制团队进度

会话运行后,几个常用的键盘快捷键:

  • Shift+↑/↓:在 team lead 和各 teammate 之间切换
  • Ctrl+T:查看共享任务列表及各任务状态
  • Enter:进入某个 teammate 的会话,直接和它交互
  • Escape:中断当前 Agent 的执行

任务卡住是 Agent Teams 最常见的问题之一。teammates 有时无法及时标记任务完成,导致依赖任务一直处于阻塞状态。如果某个任务看起来已经实际完成但状态没更新,可以手动更新任务状态,或者告诉 lead 去推一下那个 teammate。

一个完整的分工设计:并行重构

下面是一个更结构化的 prompt,适合实际开发场景。在用 Agent Teams 之前先单独跑一次 plan mode(Shift+Tab 两次),把任务切分做好,再启动团队执行:

# Step 1: 先规划(在普通 Claude Code 会话里跑,不需要开 Agent Teams)
请为以下重构任务做详细的任务分解:
- 将 AccountService 从直接使用 Mapper 改为通过 Repository 层
- AccountRepository 需要新增 5 个查询方法
- 相关的 AccountController 接口保持不变
- 已有的单元测试需要同步更新

输出一份任务列表,标注哪些任务可以并行,哪些有依赖关系。

# Step 2: 用规划结果启动 Agent Teams
请基于上面的任务分解创建一个 agent team:
- Repository 开发者:实现 AccountRepository 的所有查询方法
- Service 重构者:依赖 Repository 任务完成后,重构 AccountService
- 测试更新者:与 Service 重构并行,更新所有受影响的测试文件

任务依赖关系:Service 重构和测试更新都依赖 Repository 开发完成。

这是一种经过验证的模式:先用 plan mode 做廉价的规划,生成明确的任务分解,再把这个计划交给 Agent Team 并行执行(花费高但速度快)。规划阶段是你在提交大量 token 之前的检查点。

当前的限制

已知限制需要提前了解:/resume/rewind 不能恢复进行中的 teammates,如果恢复一个含有 teammates 的会话,lead 可能会尝试向已不存在的 teammates 发消息,这时告诉它重新派生即可。此外,一个 lead 同时只能管理一支团队,而且 teammates 不能再派生自己的团队,没有嵌套 team 的机制。

还有一个实际的成本意识问题:多 Agent 工作流并不适合所有情况,目前是完成大型项目的一种昂贵且实验性的方式。在启动 Agent Teams 之前,要确认任务确实有可并行的独立部分——用来重命名一个变量是纯粹的浪费。


Agent Teams 把 Claude Code 从「一个很能干的工程师」变成了「一个小团队」。这个转变带来的不只是并行速度,而是一种新的思维方式:面对复杂任务,不再问「怎么让这个 Agent 做得更快」,而是问「怎么把这个任务切分,让多个 Agent 同时工作」。实验性标签意味着它还有粗糙的地方,但核心机制已经可用,值得在真实项目里试一次。

设计主 agent 与子 agent 的任务分工策略

一个根本性的认知转变

当你开始认真使用 Claude Code 的多 Agent 能力时,需要接受一个认知上的调整:Claude Code 不是一个「很聪明的助手」,而是一套可编程的调度系统。你的主会话是指挥者,负责规划和综合;子 Agent 是执行者,负责独立完成边界清晰的工作单元。

委托的理想场景是那些重复性强、相互隔离、有明确输入输出契约的任务——运行单元测试、对某个文件做 lint、按照明确说明重构某个函数。通过把这些任务交给子 Agent,主 Agent 得以保持自己的上下文专注于更宏观的规划和状态管理。相反,需要广泛上下文、涉及战略决策、或者以复杂方式修改全局项目状态的任务,应该留在主 Agent 的执行线程里。

这句话点出了分工的核心原则:主 Agent 做「需要全局视野的事」,子 Agent 做「范围清晰的事」。

自定义子 Agent 的定义方式

子 Agent 定义为带有 YAML frontmatter 的 Markdown 文件。每个子 Agent 运行在独立的上下文窗口里,拥有自定义的系统提示、特定的工具权限和独立的执行环境。Claude 遇到与某个子 Agent 描述匹配的任务时,会将其委托给那个子 Agent,由它独立工作并返回结果。

文件存放位置决定作用域:

~/.claude/agents/          # 用户级,对所有项目可用
.claude/agents/            # 项目级,优先级更高,提交到版本库

对于一个 Spring Boot 电商平台,你的项目级子 Agent 目录可能长这样:

.claude/agents/
├── api-reviewer.md        # API 层代码审查
├── db-migrator.md         # 数据库迁移执行
├── test-writer.md         # 单元测试生成
└── security-auditor.md    # 安全漏洞扫描

每个文件的结构:

---
name: api-reviewer
description: >
  对 Spring Boot Controller 进行 API 规范审查。
  当用户提交了新 Controller 代码、修改了接口定义、
  或请求 API review 时使用。
tools: Read, Grep, Glob, Bash
model: sonnet
---

你是一位专注于 Spring Boot API 层审查的专家。

每次被调用时,执行以下检查:

1. 运行 `git diff --cached --name-only | grep Controller` 找出改动的 Controller
2. 对每个 Controller 检查:
   - 所有入参是否使用了 `@Valid` 注解
   - 返回值是否统一包装为 `Result<T>`
   - 敏感操作是否有权限注解(`@PreAuthorize` 或自定义注解)
   - 异常是否被统一处理,没有裸 Exception 透传
3. 输出格式:

```json
{
  "files_reviewed": ["文件路径"],
  "issues": [
    {"file": "路径", "line": 行号, "severity": "high|medium|low", "description": "问题描述"}
  ],
  "passed": true|false
}

issues 为空时,passed 为 true,不要捏造问题。


注意这里几个关键设计:`description` 用动词开头,点名触发场景;`tools` 明确白名单,只给 Read/Grep/Glob/Bash,没有 Write 权限——这个子 Agent 的职责只是审查,不该修改文件;`model` 指定 sonnet,因为代码审查不需要 opus 级别的推理,省成本;输出格式是结构化 JSON,方便主 Agent 解析和判断是否继续流程。

## 最小权限原则:工具白名单的意义

工具访问范围应该按 Agent 定制。PM 和架构 Agent 偏重读操作(搜索、通过 MCP 读文档);实现 Agent 需要 Edit/Write/Bash;发布 Agent 只需要它必须用到的工具。如果不列出 tools,就是隐式授予所有可用工具。应当有意识地控制。

用一个反例来说明这个原则的重要性:如果你的 `test-writer`  Agent 拥有 Bash 权限但没有限制,它理论上可以执行 `rm -rf`——即便你从未打算让它做这件事。工具白名单让子 Agent 的能力范围和它的职责范围严格对应。

```markdown
---
name: test-writer
description: >
  为 Spring Boot Service 层生成 JUnit 5 单元测试。
  当用户实现了新的 Service 方法、需要补充测试覆盖时使用。
tools: Read, Glob, Write  # 只需要读现有代码、写测试文件
model: sonnet
---

你是一位专注于 Spring Boot 单元测试的工程师。

被委托时,接收以下信息:
- 目标 Service 类的路径
- 需要覆盖的方法列表(如果没有指定,覆盖所有 public 方法)

工作步骤:
1. 读取目标 Service 类,理解方法签名和业务逻辑
2. 读取同目录下现有的测试文件(如有),了解已有测试风格
3. 为每个目标方法生成测试用例,覆盖:
   - 正常路径(happy path)
   - 边界条件
   - 异常情况(Service 应抛出的异常)
4. 使用 Mockito mock 所有依赖,不做真实数据库调用
5. 将测试写入 `src/test/java/` 对应路径

返回:
- 创建的测试文件路径列表
- 每个方法的测试覆盖数量

上下文移交:给子 Agent 什么,不给什么

父 Agent 不应该把自己的全部状态传给子 Agent。这样做会完全抵消上下文隔离的价值,还会产生昂贵而缓慢的过程。正确做法是为子 Agent 构建最小化的、任务专用的上下文——只打包完成该子任务所必需的相关文件、函数签名和指令。子 Agent 完成后,返回输出(通常是 diff 或状态报告),由主 Agent 集成回主项目状态。

在实践中,这意味着主 Agent 在派生子 Agent 时,应该像写一张工单一样精确:

# 好的委托方式(给子 Agent 的上下文足够精准)
请用 test-writer 子 Agent 为以下内容生成测试:
- 目标文件:src/main/java/com/example/service/OrderService.java
- 需要覆盖的方法:createOrder, cancelOrder, getOrderById
- 特殊要求:cancelOrder 需要覆盖「订单状态不允许取消」的异常情况

# 不好的委托方式(上下文模糊,子 Agent 需要自己猜)
请用 test-writer 子 Agent 给订单服务写测试

前者让子 Agent 可以立刻开始工作,后者需要它先花时间探索,探索过程产生的 token 消耗会进入它自己的上下文窗口而非主 Agent 的——这是隔离的优点,但如果探索方向错了,整个子任务就白跑了。

构建流水线:主 Agent 如何串联多个子 Agent

实际工程中,子 Agent 很少单独使用,通常是多个子 Agent 串联成一条流水线。以「实现新功能并保证质量」这个场景为例,分工可以这样设计:

主 Agent(规划 + 协调)
  │
  ├─ 1. 自己完成:理解需求、设计接口、规划文件结构
  │
  ├─ 2. 派生 → db-migrator(执行数据库迁移)
  │         等待完成,检查迁移是否成功
  │
  ├─ 3. 派生 → 自己实现 Service + Controller
  │         (涉及全局架构决策,留在主 Agent)
  │
  ├─ 4. 并行派生 → api-reviewer + test-writer
  │         (两者互不依赖,可同时运行)
  │         等待两者完成,综合结果
  │
  └─ 5. 如果 api-reviewer 发现问题,自己修复;
        如果测试覆盖不足,告知 test-writer 补充

任务分解可以是递归的。主 Agent 可以把功能请求分解为创建 model、controller 和 view,然后派生子 Agent 来处理 controller,controller 子 Agent 还可以派生自己的子子 Agent 来写单独的方法和对应的测试。这创建了一种反映代码逻辑结构的执行层级。 不过官方文档也明确说明,子 Agent 在 Plan 模式下不能再派生子 Agent(防止无限嵌套),实际使用时需要注意这个约束。

用 CLAUDE.md 教会主 Agent 如何委托

如果你不在 CLAUDE.md 里说明什么时候该委托给哪个子 Agent,主 Agent 会凭感觉决定——有时候委托,有时候自己做。要让分工策略变得可预测,需要在 CLAUDE.md 里明确写出路由规则:

<!-- .claude/CLAUDE.md -->

## Agent 委托规则

### 什么时候委托给子 Agent

**api-reviewer**:每次 Controller 有修改后,在 git commit 之前调用。
**test-writer**:Service 层新增方法后,如果对应测试文件里没有相关测试方法时调用。
**db-migrator**:需要执行 Flyway 迁移脚本时调用(不要直接在主会话里跑 mvn flyway:migrate)。
**security-auditor**:涉及认证、鉴权、加解密相关代码修改时调用。

### 什么时候不要委托

- 需要跨模块理解整体架构才能做的决策
- 修改量很小(单个文件、几行代码)的改动
- 需要和你确认设计方向的工作

### 委托格式

委托时必须提供:目标文件路径、任务具体范围、输出预期格式。
不要发送含糊的委托指令。

你的主会话是「中枢 AI」,负责协调专业子 Agent。工作质量取决于你在多大程度上教会了这个中枢如何委托。CLAUDE.md 里的路由规则立竿见影——加上去之后你会立刻看到主 Agent 在委托决策上更加一致。


分工策略设计好之后,实际运行中最常见的反馈是:子 Agent 返回的结果和预期不符。这通常不是子 Agent 本身的问题,而是委托指令写得不够精确,或者工具权限设置有误导致它无法完成部分步骤。迭代的方向是逐步收紧每个子 Agent 的定义,让它的职责范围越来越清晰,而不是越做越大——做大了就该拆成两个子 Agent。

使用 claude.ai/code 在浏览器端并行运行多任务

两种截然不同的运行模式

在浏览器端使用 Claude Code,实际上涉及两种不同的运行模式,经常被混淆,需要先分清楚:

第一种是 Claude Code on the Webclaude.ai/code)——任务运行在 Anthropic 的云端基础设施上。你无需本地环境,直接在浏览器里开启一个全新的 Claude Code 会话,针对 GitHub 仓库执行任务。适合无需本地依赖的工作。

第二种是 Remote Control——任务仍在你的本地机器上执行,浏览器和手机只是一个远程窗口,让你能从任何设备观察进度、提供指令。这两种模式都通过 claude.ai/code 界面访问,但本质区别在于代码在哪里跑:Remote Control 会话跑在你本地,保留你完整的 .claude/ 配置、MCP 集成和本地文件系统;云端会话从零开始,没有任何本地上下文。

实际使用中,这两种模式的定位是互补的:把 Claude Code on the Web 用于边界清晰的可并行任务(写测试、补文档、升级依赖),把 Remote Control 用于需要本地环境的工作(调用内部 MCP 服务、访问私有数据库、使用自定义工具链)。

Claude Code on the Web:零配置的并行起点

Claude Code on the Web 适合的场景:回答关于代码架构的问题、边界清晰的 bug 修复、并行处理多个任务、处理没有在本地 checkout 的仓库,以及后端变更(Claude Code 可以先写测试再写实现代码)。

从终端启动一个云端会话,最简单的方式是 --remote 参数:

# 在当前仓库启动一个云端会话,任务在云端跑,本地终端不阻塞
claude --remote "为 OrderService 的所有 public 方法补充 Javadoc 注释"

命令执行后立刻返回,你可以继续在本地做其他事。任务在云端运行,可以用 /tasks 检查进度,或者直接在 claude.ai 或 Claude 移动 App 里打开会话来交互——在那里可以引导 Claude、提供反馈或回答问题。

并行运行多个云端会话的方式很直接:

# 同时启动三个独立的云端任务
claude --remote "给 src/main/java/com/example/service/ 下所有 Service 补充 Javadoc"
claude --remote "检查 src/test/ 目录下测试覆盖率,找出缺少测试的方法列表"
claude --remote "把 pom.xml 里的依赖版本更新到最新稳定版,逐一检查兼容性"

每个 --remote 命令创建一个独立的 Web 会话并独立运行,所有任务真正同时执行,互不干扰。

打开 claude.ai/code,会看到左侧边栏列出所有正在运行的会话。每个会话显示它当前在做什么——读了哪些文件、执行了什么命令。你可以随时点进某个会话,在它进行到一半时提供追加指令,或者回答它提出的问题。

Remote Control:本地执行,随处监控

如果任务需要访问本地 MCP 服务、内部数据库,或者你已经建立了完善的 .claude/ 配置不想丢失,应该使用 Remote Control 而不是纯云端模式。

在本地终端启动一个可远程控制的会话:

# 方式一:直接启动 remote-control 模式
claude remote-control

# 方式二:在现有会话里启用(会话历史会一并带过去)
/remote-control

终端会显示一个 URL 和二维码,用手机扫码或在浏览器里打开,就能从任何设备接管这个会话。关键设计:你的代码从不离开本地机器,只有聊天消息和工具执行结果通过加密通道传输。文件、MCP 服务器、环境变量和项目配置全部保持在本地。

一个实际场景:在下班前启动一个代码重构任务,开启 Remote Control,然后关上电脑盖离开。在地铁上用手机查看进度,在 Claude 遇到决策点时给出指引,完全不需要回到桌前。

先规划,再远程执行

云端会话最容易出问题的地方是:一旦任务跑起来,中途调整方向的代价很高。一个有效的工作模式是先在本地做规划,然后把执行工作发给云端:

# Step 1:本地开 plan mode(Shift+Tab 两次进入)
# 只读不写,和 Claude 对齐重构策略
claude
# 进入 plan mode 后...
# "分析 AccountController,制定把权限检查从 Controller 层
#  移到 AOP 切面的重构计划,列出需要改动的文件和步骤"

# Step 2:计划确认后,把执行发给云端
# (退出 plan mode,在普通会话里)
claude --remote "按以下计划执行重构:
1. 创建 PermissionAspect.java 实现权限拦截逻辑
2. 从 AccountController 移除所有 @PreAuthorize 注解和手动权限检查代码
3. 在 pom.xml 添加 spring-boot-starter-aop 依赖
4. 为 PermissionAspect 编写单元测试
执行完成后提交一个 draft PR"

这个模式给了你策略上的掌控,同时让 Claude 在云端自主执行——可以在本地继续其他工作,或者彻底从电脑前离开。

会话共享:团队协作的基础

浏览器端的会话可以共享给团队成员,这是一个容易被忽视但很实用的功能。

切换会话的可见性后,就可以分享会话链接。接收者打开链接会看到会话的最新状态,但页面不会实时更新——它是状态快照。对于企业和团队账户,可见性选项是「私有」和「团队」,团队可见性让组织内所有成员都能看到这个会话。

在 Spring Boot 项目中,这能解决一个常见的协作痛点:你让 Claude 做了一次复杂的代码分析,想让同事看结论,不需要再让对方重新跑一次——分享会话链接就够了。

需要注意的安全事项:分享前检查会话内容,确认没有私有仓库的代码凭据或敏感配置。默认不启用仓库访问验证,可以在设置里开启「Settings > Claude Code > Sharing settings」。

从浏览器「传送」回本地终端

有时候你在浏览器里开始了一个任务,中途发现需要调用本地工具或调试某个细节,可以把云端会话「传送」回本地继续:

# 从 Web 界面点击 "Open in CLI",复制命令并粘贴到本地终端
# Claude Code 会验证你在正确的仓库,fetch 并 checkout 远程会话的分支,
# 加载完整的对话历史到本地终端

传送前会检查几个前提条件:本地和远程会话指向相同的仓库,本地 git 没有未提交的冲突变更。如果有要求不满足,会看到错误提示或引导解决。

反方向也可以:在本地终端工作到一半,想切换到移动端继续,用 /remote-control 把当前会话暴露出去,然后用手机连进来。


claude.ai/code 的价值不只是「可以在浏览器里用 Claude Code」,更重要的是它把「在哪里执行」和「在哪里监控」解耦了。一台机器可以同时跑三个独立的重构任务,而你在另一台设备上统一查看进度、提供指引。这是一个调度中心的角色,而不只是一个访问界面。

自定义 MCP 服务

从零搭建一个自定义 MCP Server

MCP 到底解决什么问题

Claude Code 默认只能读写文件、执行 Bash 命令、调用内置工具。这对大多数任务够用,但有一类需求它天然触碰不到:你的内部系统。内部订单数据库、私有 API 接口、团队专属的发布流水线——这些东西没有公开的 MCP 服务器,你需要自己造一个。

MCP Server 可以提供三类能力:Resources(类似文件的数据,客户端可以读取)、Tools(LLM 可以调用的函数)、Prompts(预写好的任务模板)。 大多数自定义服务器的核心是 Tools——把你的内部 API 包装成 Claude 可以直接调用的工具。

传输方式上,Stdio 服务器以本地进程运行,最适合需要直接系统访问的工具。HTTP 是远程服务器的推荐选项,SSE 已被弃用,有条件尽量用 HTTP。

本文以一个真实场景为主线:为交易平台搭建一个 MCP Server,让 Claude Code 能直接查询订单数据和检查支付状态,不再需要手动查数据库。

项目初始化

选 Python 作为实现语言,因为官方 MCP SDK 的 FastMCP 封装非常简洁。

# 使用 uv 管理依赖
curl -LsSf https://astral.sh/uv/install.sh | sh

# 创建项目
uv init game-account-mcp
cd game-account-mcp

# 激活虚拟环境并安装依赖
uv venv
source .venv/bin/activate

# MCP SDK + 数据库驱动 + HTTP 客户端
uv add "mcp[cli]" pymysql httpx python-dotenv

目录结构:

game-account-mcp/
├── server.py          # MCP Server 主体
├── .env               # 数据库连接等敏感配置(不提交版本库)
├── pyproject.toml
└── .venv/

编写 Server 主体

# server.py
import os
import json
import logging
from typing import Any
from dotenv import load_dotenv

import httpx
import pymysql
import pymysql.cursors
from mcp.server.fastmcp import FastMCP

load_dotenv()

# 重要:stdio 模式下 print() 会污染 JSON-RPC 消息流
# 所有日志必须写到 stderr
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    handlers=[logging.StreamHandler(__import__('sys').stderr)]
)
logger = logging.getLogger(__name__)

mcp = FastMCP("game-account-platform")

# ─── 数据库连接池 ──────────────────────────────────────────────────────────────

def get_db_connection():
    return pymysql.connect(
        host=os.getenv("DB_HOST", "127.0.0.1"),
        port=int(os.getenv("DB_PORT", "3306")),
        user=os.getenv("DB_USER"),
        password=os.getenv("DB_PASSWORD"),
        database=os.getenv("DB_NAME"),
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
        connect_timeout=5,
    )

# ─── Tools ─────────────────────────────────────────────────────────────────────

@mcp.tool()
def query_order(order_id: str) -> str:
    """查询指定订单的详细信息,包括买卖双方、金额、状态和时间戳。

    Args:
        order_id: 订单号,格式为 ORD-YYYYMMDD-XXXXXX
    """
    try:
        conn = get_db_connection()
        with conn.cursor() as cursor:
            cursor.execute("""
                SELECT o.order_id, o.status, o.amount,
                       o.created_at, o.updated_at,
                       b.username AS buyer, s.username AS seller,
                       a.game_name, a.account_level
                FROM orders o
                JOIN users b ON o.buyer_id = b.id
                JOIN users s ON o.seller_id = s.id
                JOIN game_accounts a ON o.account_id = a.id
                WHERE o.order_id = %s
            """, (order_id,))
            row = cursor.fetchone()
        conn.close()

        if not row:
            return f"未找到订单 {order_id}"

        return json.dumps(row, ensure_ascii=False, default=str)

    except Exception as e:
        logger.error("query_order error: %s", e)
        return f"查询失败:{e}"


@mcp.tool()
def list_pending_orders(limit: int = 20) -> str:
    """列出所有待处理的订单,按创建时间倒序排列。

    Args:
        limit: 返回条数,默认 20,最大 100
    """
    limit = min(limit, 100)
    try:
        conn = get_db_connection()
        with conn.cursor() as cursor:
            cursor.execute("""
                SELECT order_id, amount, created_at,
                       buyer_id, seller_id, status
                FROM orders
                WHERE status = 'PENDING'
                ORDER BY created_at DESC
                LIMIT %s
            """, (limit,))
            rows = cursor.fetchall()
        conn.close()

        if not rows:
            return "当前没有待处理订单"

        return json.dumps(rows, ensure_ascii=False, default=str)

    except Exception as e:
        logger.error("list_pending_orders error: %s", e)
        return f"查询失败:{e}"


@mcp.tool()
def check_payment_status(order_id: str) -> str:
    """通过内部支付服务查询订单的实时支付状态。

    Args:
        order_id: 订单号
    """
    payment_api = os.getenv("PAYMENT_API_BASE", "http://internal-payment-service")
    api_key = os.getenv("PAYMENT_API_KEY", "")

    try:
        with httpx.Client(timeout=10) as client:
            resp = client.get(
                f"{payment_api}/v1/payment/status",
                params={"order_id": order_id},
                headers={"X-API-Key": api_key},
            )
            resp.raise_for_status()
            return resp.text

    except httpx.HTTPStatusError as e:
        return f"支付服务返回错误 {e.response.status_code}"
    except Exception as e:
        logger.error("check_payment_status error: %s", e)
        return f"查询失败:{e}"


@mcp.tool()
def get_platform_stats(date: str = "") -> str:
    """获取平台当日或指定日期的业务统计数据。

    Args:
        date: 日期,格式 YYYY-MM-DD,留空表示今天
    """
    try:
        conn = get_db_connection()
        with conn.cursor() as cursor:
            date_filter = f"DATE(created_at) = '{date}'" if date else "DATE(created_at) = CURDATE()"
            cursor.execute(f"""
                SELECT
                    COUNT(*) AS total_orders,
                    SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed,
                    SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END) AS pending,
                    SUM(CASE WHEN status = 'CANCELLED' THEN 1 ELSE 0 END) AS cancelled,
                    COALESCE(SUM(CASE WHEN status = 'COMPLETED' THEN amount END), 0) AS total_gmv
                FROM orders
                WHERE {date_filter}
            """)
            stats = cursor.fetchone()
        conn.close()

        return json.dumps(stats, ensure_ascii=False, default=str)

    except Exception as e:
        logger.error("get_platform_stats error: %s", e)
        return f"查询失败:{e}"


if __name__ == "__main__":
    mcp.run(transport="stdio")

对 Stdio 服务器有一个关键约束:绝对不能把任何内容写到 stdout,否则会破坏 JSON-RPC 消息流,导致 Server 无法正常工作。print() 默认写到 stdout,必须改为 print(..., file=sys.stderr) 或使用标准 logging 库。

配置 .env 文件

# .env(加入 .gitignore)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=claude_readonly
DB_PASSWORD=your_secure_password
DB_NAME=game_platform_db
PAYMENT_API_BASE=http://internal-payment-service:8080
PAYMENT_API_KEY=your_payment_api_key

数据库用户建议创建一个只读账号,不要用 root 或有写权限的账号——MCP Server 的权限应该与它的职责对称。

注册到 Claude Code

# 注册到当前项目(只在这个项目里可用)
claude mcp add --scope project \
  game-platform \
  -- uv --directory $(pwd) run server.py

# 或者注册为全局可用(所有项目都能用)
claude mcp add --scope user \
  game-platform \
  -- uv --directory /absolute/path/to/game-account-mcp run server.py

注册后验证:

# 列出所有已注册的 MCP Server
claude mcp list

# 或者在 Claude Code 会话里运行
/mcp

如果看到 game-platform 在列表里,说明注册成功。此时 Claude Code 可以调用这四个工具,在会话里直接说「查一下订单 ORD-20260327-001234 的状态」,Claude 就会自动调用 query_order 工具。

调试:MCP Inspector

服务器行为不符合预期时,最直接的工具是官方提供的 MCP Inspector:

# 安装并启动 Inspector
npx @modelcontextprotocol/inspector uv --directory $(pwd) run server.py

Inspector 会在浏览器里打开一个界面,让你直接调用每个 Tool 并查看原始的输入输出,不需要通过 Claude Code 中转。这对排查工具定义、参数类型或返回格式的问题极为高效。

常见问题排查:

# 在 Claude Code 会话里查看 MCP 日志
/mcp

# 以 debug 模式启动,可以看到完整的 MCP 通信过程
claude --debug

# 也可以直接测试 stdio 通信
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
  | uv --directory $(pwd) run server.py

把配置提交到版本库

团队多人使用时,应该把 MCP 配置提交到项目里。Claude Code 支持在 .mcp.json 里声明项目级配置:

// .mcp.json(提交到 git)
{
  "mcpServers": {
    "game-platform": {
      "command": "uv",
      "args": ["--directory", "${PROJECT_ROOT}/tools/game-account-mcp", "run", "server.py"],
      "env": {
        "DB_HOST": "${DB_HOST}",
        "DB_PORT": "${DB_PORT}",
        "DB_USER": "${DB_USER}",
        "DB_PASSWORD": "${DB_PASSWORD}",
        "DB_NAME": "${DB_NAME}",
        "PAYMENT_API_BASE": "${PAYMENT_API_BASE}",
        "PAYMENT_API_KEY": "${PAYMENT_API_KEY}"
      }
    }
  }
}

${VAR} 语法引用环境变量,敏感信息放在 .env 里不入库。团队成员克隆代码库、配置好本地 .env 之后,就能直接使用同一套 MCP 工具,不需要每个人手动执行 claude mcp add


自定义 MCP Server 的本质是一个适配器:把你的内部系统转化成 Claude 可以理解和调用的接口形式。Tool 定义的 docstring 就是 Claude 识别「何时调用这个工具」的依据,写得越具体,Claude 的调用时机就越准确。搭好之后,调试阶段的主要工作是检查工具是否按预期触发——MCP Inspector 比肉眼看日志要高效得多。

将内部 API / 数据库暴露为 MCP 工具

Tool 设计的核心矛盾

把内部系统暴露给 Claude 访问,本质上是在两个相互拉锯的目标之间寻找平衡:能力越强越好,暴露面越小越好

一个把内部数据库的所有表和所有字段都暴露出来的 MCP Server,Claude 能做的事情极多,但风险也极大。一个暴露面过窄的 Server,Claude 频繁无法完成任务,又会让人觉得工具没用。

这一章的核心是三个问题的系统性回答:Tool 的接口应该怎么设计才能精准触发?输入输出应该怎么处理才安全可靠?权限边界应该划在哪里?

Tool 与 Resource 的界线

在设计之前,先厘清两个容易混淆的概念。

Tools 是动词——它们代表可以修改状态或与外部系统交互的动态操作;

Resources 是名词——它们是 AI 可以读取的静态数据,类似文件。

调用 Tool 是主动请求(tools/call),而访问 Resource 是被动的,AI 在需要时可以直接获取,不需要显式请求执行。

对内部系统来说,这个区分非常实用:查询订单列表是 Resource(只读的数据),提交退款请求是 Tool(有副作用的操作)。这两类应该用不同的实现方式,前者成本低,Claude 会频繁调用;后者应该有额外的保护措施。

Tool 接口设计:docstring 是给 Claude 看的

FastMCP 通过 Python 类型注解和 docstring 自动生成工具定义。这意味着你写的 docstring 会直接成为 Claude 决定「何时调用这个工具」的依据。Tool 描述负责高层次的功能说明,参数的 schema 描述负责指导正确用法,二者分工明确:Tool 描述帮助选择工具,schema 描述引导正确使用。

下面以一个交易平台为例,对比好坏写法:

# ❌ 触发描述过于模糊
@mcp.tool()
def get_account_info(account_id: str) -> str:
    """获取账号信息"""
    ...

# ✅ 明确说明「何时调用」「能做什么」
@mcp.tool()
def get_game_account_detail(
    account_id: str = Field(description="账号 ID,格式 ACC-XXXXXXXX"),
    include_valuation: bool = Field(
        default=False,
        description="是否包含估值信息,True 会额外查询估值服务(较慢)"
    ),
) -> str:
    """查询账号的详细信息,包括角色等级、装备列表、绑定状态和历史价格。

    适用场景:用户询问某个账号的具体属性、评估账号价值、
    或在创建交易前核实账号状态。
    """
    ...

JSON Schema 支持深层嵌套和复杂验证逻辑,但应尽量保持 schema 扁平。深层嵌套会增加 token 消耗和 LLM 的认知负担,导致高延迟或解析错误。如果工具需要复杂的对象层级,拆分成更简单的参数,或将功能拆成多个更具体的工具。

输入验证:在数据触碰后端之前拦截

将内部工具或敏感操作通过 MCP Server 暴露是有风险的。MCP 的设计使 AI Agent 更容易在你的环境里执行操作,其中一些影响很大,比如修改数据库、触发金融交易或控制系统设置。如果 AI 或未授权用户可以在没有检查的情况下调用错误的工具,后果可能很严重。

针对数据库操作,输入验证的核心是 SQL 注入防护和操作类型限制:

import re
from pydantic import BaseModel, Field, field_validator
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import ToolAnnotations

mcp = FastMCP("game-platform-internal")

# ─── 使用 Pydantic 模型做结构化输入验证 ───────────────────────────────────────

class OrderSearchParams(BaseModel):
    status: str = Field(
        description="订单状态筛选:PENDING / COMPLETED / CANCELLED / ALL",
        default="ALL",
    )
    date_from: str = Field(
        description="开始日期,格式 YYYY-MM-DD",
        default="",
    )
    date_to: str = Field(
        description="结束日期,格式 YYYY-MM-DD",
        default="",
    )
    limit: int = Field(
        description="返回条数,最大 50",
        default=20,
        ge=1,
        le=50,  # Pydantic 自动验证范围
    )

    @field_validator("status")
    @classmethod
    def validate_status(cls, v: str) -> str:
        allowed = {"PENDING", "COMPLETED", "CANCELLED", "ALL"}
        if v.upper() not in allowed:
            raise ValueError(f"status 必须是 {allowed} 之一,收到: {v}")
        return v.upper()

    @field_validator("date_from", "date_to")
    @classmethod
    def validate_date_format(cls, v: str) -> str:
        if v and not re.match(r"^\d{4}-\d{2}-\d{2}$", v):
            raise ValueError(f"日期格式必须是 YYYY-MM-DD,收到: {v}")
        return v


@mcp.tool(
    annotations=ToolAnnotations(
        readOnlyHint=True,    # 告知 Claude 这是只读操作,可以安全重试
        idempotentHint=True,  # 相同参数的多次调用结果相同
    )
)
def search_orders(params: OrderSearchParams) -> str:
    """按条件搜索订单列表,支持状态筛选和日期范围过滤。

    适用场景:查看待处理订单、分析特定时间段的成交情况、
    排查某状态下的异常订单。
    """
    # 参数已经通过 Pydantic 验证,可以安全使用
    conditions = []
    query_params = []

    if params.status != "ALL":
        conditions.append("status = %s")
        query_params.append(params.status)

    if params.date_from:
        conditions.append("DATE(created_at) >= %s")
        query_params.append(params.date_from)

    if params.date_to:
        conditions.append("DATE(created_at) <= %s")
        query_params.append(params.date_to)

    where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""

    # 使用参数化查询,彻底避免 SQL 注入
    sql = f"""
        SELECT order_id, status, amount, created_at, buyer_id, seller_id
        FROM orders
        {where_clause}
        ORDER BY created_at DESC
        LIMIT %s
    """
    query_params.append(params.limit)

    try:
        conn = get_db_connection()
        with conn.cursor() as cursor:
            cursor.execute(sql, tuple(query_params))
            rows = cursor.fetchall()
        conn.close()
        return json.dumps(rows, ensure_ascii=False, default=str)
    except Exception as e:
        logger.error("search_orders error: %s", e)
        # 不暴露内部错误细节给 Claude
        return json.dumps({"error": "查询失败,请联系管理员", "code": "DB_ERROR"})

错误处理上有一条规则需要强调:工具错误应该在结果对象内部报告,而不是作为 MCP 协议级别的错误。这样 LLM 可以看到并可能处理这个错误。同时,错误信息不应该暴露内部细节(堆栈、SQL 语句、数据库结构),只返回语义化的错误码。

写操作:加一层显式确认

读操作可以放开给 Claude 自主调用,但写操作——尤其是涉及资金的——应该加一个「确认环节」。一种简单实现是两步模式:先 preview(返回将要执行的内容),再 execute(真正执行):

class RefundRequest(BaseModel):
    order_id: str = Field(description="需要退款的订单号,格式 ORD-YYYYMMDD-XXXXXX")
    reason: str = Field(description="退款原因,不少于 10 个字", min_length=10)
    amount: float = Field(description="退款金额,单位元,必须 > 0", gt=0)


@mcp.tool(
    annotations=ToolAnnotations(
        readOnlyHint=False,   # 有副作用
        idempotentHint=False, # 重复调用有危险
    )
)
def preview_refund(request: RefundRequest) -> str:
    """预览退款操作的详细信息,不实际执行。

    调用时机:在用户明确要求退款后,先调用此工具展示退款预览,
    等用户确认后再调用 execute_refund。
    不要在没有显示预览的情况下直接执行退款。
    """
    try:
        conn = get_db_connection()
        with conn.cursor() as cursor:
            cursor.execute(
                "SELECT order_id, amount, status, buyer_id FROM orders WHERE order_id = %s",
                (request.order_id,)
            )
            order = cursor.fetchone()
        conn.close()

        if not order:
            return json.dumps({"error": f"订单 {request.order_id} 不存在"})

        if order["status"] != "COMPLETED":
            return json.dumps({
                "error": f"只有已完成订单可以退款,当前状态: {order['status']}"
            })

        if request.amount > float(order["amount"]):
            return json.dumps({
                "error": f"退款金额 {request.amount} 超过订单金额 {order['amount']}"
            })

        return json.dumps({
            "preview": True,
            "order_id": request.order_id,
            "refund_amount": request.amount,
            "original_amount": float(order["amount"]),
            "reason": request.reason,
            "message": "以上是退款预览,请确认后调用 execute_refund 执行"
        }, ensure_ascii=False)

    except Exception as e:
        logger.error("preview_refund error: %s", e)
        return json.dumps({"error": "预览失败"})


@mcp.tool(
    annotations=ToolAnnotations(readOnlyHint=False, idempotentHint=False)
)
def execute_refund(order_id: str, confirmed: bool) -> str:
    """执行退款操作。必须先调用 preview_refund,用户确认后再调用此工具。

    confirmed 参数必须为 True,表示用户已明确确认退款。
    如果用户没有明确说「确认退款」或「同意」,不要调用此工具。
    """
    if not confirmed:
        return json.dumps({"error": "confirmed 必须为 True,表示用户已确认"})

    # 实际执行退款逻辑...
    logger.info("AUDIT: refund executed for order %s", order_id)
    return json.dumps({"success": True, "order_id": order_id})

注意 execute_refund 的 docstring 里有一句关键指令:「如果用户没有明确说「确认退款」或「同意」,不要调用此工具」。这句话会直接影响 Claude 的调用决策。

权限分层:用只读账户访问数据库

实施每个工具级别的权限范围。不要给 Agent 全量访问所有工具的权限。定义像 calendar:reademail:sendcontacts:delete 这样的权限范围,并在每个请求上强制执行。

在数据库层面,最直接的做法是针对不同类型的操作使用不同的数据库账户:

import os

def get_readonly_db():
    """返回只读数据库连接——用于所有查询操作"""
    return pymysql.connect(
        host=os.getenv("DB_HOST"),
        user=os.getenv("DB_READONLY_USER"),    # 只有 SELECT 权限
        password=os.getenv("DB_READONLY_PASS"),
        database=os.getenv("DB_NAME"),
        cursorclass=pymysql.cursors.DictCursor,
    )

def get_write_db():
    """返回有写权限的数据库连接——严格限制使用场景"""
    return pymysql.connect(
        host=os.getenv("DB_HOST"),
        user=os.getenv("DB_WRITE_USER"),       # 仅 INSERT/UPDATE,无 DELETE
        password=os.getenv("DB_WRITE_PASS"),
        database=os.getenv("DB_NAME"),
        cursorclass=pymysql.cursors.DictCursor,
    )

在数据库里为 MCP Server 创建专属账号时,权限应该精确到表和操作类型:

-- 只读账号:只能查 SELECT
CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY '...';
GRANT SELECT ON game_platform_db.orders TO 'mcp_readonly'@'%';
GRANT SELECT ON game_platform_db.users TO 'mcp_readonly'@'%';
GRANT SELECT ON game_platform_db.game_accounts TO 'mcp_readonly'@'%';

-- 写账号:允许插入和更新,不允许删除
CREATE USER 'mcp_writer'@'%' IDENTIFIED BY '...';
GRANT INSERT, UPDATE ON game_platform_db.refunds TO 'mcp_writer'@'%';
GRANT UPDATE ON game_platform_db.orders TO 'mcp_writer'@'%';
-- 明确不授予 DELETE 权限

即使 Claude 被注入了恶意指令试图执行 DROP TABLE,数据库层的权限控制也会直接拒绝。这是比在应用层做检查更可靠的防护,因为应用层的检查可能被绕过,数据库的权限控制不会。

审计日志:记录 Claude 的每一次工具调用

启用结构化审计日志——记录谁在什么时候访问了什么,以及为什么。对于涉及资金或敏感操作的系统,审计日志不是可选项。用一个装饰器统一处理:

import functools
import time

def audit_log(tool_name: str):
    """装饰器:为所有工具调用记录审计日志"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            start = time.time()
            # 记录调用开始
            logger.info(
                "AUDIT_START tool=%s args_summary=%s",
                tool_name,
                _sanitize_args(kwargs),  # 脱敏后记录参数
            )
            try:
                result = func(*args, **kwargs)
                elapsed = time.time() - start
                logger.info(
                    "AUDIT_END tool=%s elapsed=%.3fs success=True",
                    tool_name, elapsed,
                )
                return result
            except Exception as e:
                elapsed = time.time() - start
                logger.error(
                    "AUDIT_END tool=%s elapsed=%.3fs success=False error=%s",
                    tool_name, elapsed, type(e).__name__,
                )
                raise
        return wrapper
    return decorator

def _sanitize_args(kwargs: dict) -> str:
    """脱敏参数,避免日志里出现密码或手机号"""
    sensitive_keys = {"password", "phone", "id_card", "bank_account"}
    sanitized = {
        k: "***" if k in sensitive_keys else str(v)[:50]
        for k, v in kwargs.items()
    }
    return str(sanitized)

# 使用:
@mcp.tool()
@audit_log("execute_refund")
def execute_refund(order_id: str, confirmed: bool) -> str:
    ...

把内部系统暴露为 MCP 工具,设计时要始终带着一个假设:Claude 有时会被提示注入攻击,可能在意想不到的场景调用你的工具。对这个假设的防御,不能只依赖 docstring 里的「不要这样做」——数据库只读账户、参数范围限制、写操作的双步确认,才是真正可靠的护城河。Docstring 决定了 Claude 的正常行为,这些机制决定了系统的最坏情况。

在 Claude Code 中测试与调试自定义 MCP

调试的核心思路:分层隔离

调试 MCP Server 最常见的误区是把所有问题都扔给 Claude Code 去验证。这样效率极低——一旦工具没有触发,你不知道是 Server 启动失败了、连接握手出错了、工具定义有问题,还是 Claude 在决策层面决定不调用它。

正确的策略是分层隔离,每层单独验证,确认后再进入下一层:

1. Server 能独立运行                → 命令行 + stdin/stdout 测试
2. Server 协议通讯正常              → MCP Inspector
3. Claude Code 能连接并看到工具     → /mcp 命令 + --debug 模式
4. Claude 在对话中能正确调用工具    → 实际会话 + 日志验证

逐层排查比每次都拉起 Claude Code 重试快得多。

第一层:最简单的命令行验证

在做任何其他事之前,先确认 Server 本身能独立跑起来:

# 直接启动,看有无报错
cd /path/to/game-account-mcp
uv run server.py

如果 Server 启动成功,会阻塞在那里等待 stdin 输入。此时在另一个终端模拟 MCP 握手:

# 完整的 MCP 初始化序列(必须先 initialize,再发 tools/list)
echo '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' \
  | uv run server.py

# 如果想测试 tools/list,需要先做握手,可以用管道串联
printf '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}\n{"jsonrpc":"2.0","method":"notifications/initialized","jsonrpc":"2.0"}\n{"jsonrpc":"2.0","method":"tools/list","id":2,"params":{}}\n' \
  | uv run server.py

直接发送 tools/list 请求会收到错误,因为 MCP Server 要求先完成初始化握手,然后才能接受其他请求。这是正常行为,不是 Server 的 bug。

如果 Server 返回了带工具列表的 JSON,说明 Server 本体没有问题,可以进入下一层。

第二层:MCP Inspector 可视化测试

MCP Inspector 是首选的调试工具——一个可以连接 stdio 或 HTTP Server、调用工具、查看资源并监控通知流的交互式界面。它应该是调试的第一站。

# 启动 Inspector(会自动打开浏览器,默认 http://localhost:5173)
npx @modelcontextprotocol/inspector uv --directory /path/to/game-account-mcp run server.py

# 如果 Server 需要环境变量
DB_HOST=127.0.0.1 DB_USER=mcp_readonly DB_PASSWORD=secret \
  npx @modelcontextprotocol/inspector uv --directory /path/to/game-account-mcp run server.py

Inspector 界面左边显示所有可用工具,点击某个工具后右边出现参数输入表单,填完直接调用。调用结果和原始 JSON 都会显示,看一眼就知道:

  • 工具列表是否完整
  • 参数 schema 是否生成正确
  • 实际调用是否返回期望的数据
  • 错误信息是什么

对于上一章写的 search_orders 工具,在 Inspector 里填入 {"status": "PENDING", "limit": 5} 并调用,如果返回订单列表说明 Server 端完全没问题。如果返回错误,就在这里看原始响应调试,不需要把 Claude Code 卷进来。

第三层:Claude Code 连接验证

Server 本体验证通过后,注册到 Claude Code 并确认连接:

# 注册(如果还没注册)
claude mcp add --scope project \
  game-platform \
  --env DB_HOST=127.0.0.1 \
  --env DB_USER=mcp_readonly \
  --env DB_PASSWORD=secret \
  -- uv --directory /absolute/path/to/game-account-mcp run server.py

# 检查连接状态
claude mcp list

claude mcp list 的输出包含每个 Server 的连接状态,成功时显示 ✓ Connected 以及工具数量:

game-platform  stdio  ✓ Connected  (4 tools)

如果显示 ✗ Failed 或连接但工具数为 0,进入会话里用 /mcp 命令看详情:

# 在 Claude Code 会话里
/mcp

这会列出所有 MCP 服务器的当前状态,以及每个服务器暴露的工具名。

第四层:debug 模式看完整通信

当工具连接看起来正常,但 Claude 在会话里就是不调用,或者调用结果不对,需要看完整的通信日志:

# 以 debug 模式启动 Claude Code,会输出所有 MCP 通信细节
claude --debug

debug 模式的输出很详细,关键字段包括:

[DEBUG] MCP server "game-platform": Starting connection with timeout of 30000ms
[DEBUG] MCP server "game-platform": Successfully connected to stdio server in 412ms
[DEBUG] MCP server "game-platform": Connection established with capabilities: {"hasTools":true,...}
[DEBUG] MCP tool call: game-platform/search_orders {"status":"PENDING","limit":5}
[DEBUG] MCP tool result: {"orders":[...]}

关注握手过程中的连接尝试、连接错误或传输关闭消息,这些是定位问题根源的关键信息。

如果看到 connection timeoutprocess exited,说明 Server 进程在启动时崩溃——通常是路径问题或环境变量未传入。如果连接成功但看不到 tool call 日志,说明 Claude 决定不调用工具,需要改进 docstring 的触发描述。

日志文件也可以在会话外直接查看:

# macOS
tail -f ~/Library/Logs/Claude/mcp*.log

# 或者 Claude Code 自己的 debug 目录
ls ~/.claude/debug/
cat ~/.claude/debug/latest

常见问题速查

问题:Server 连接成功,但工具不出现

这是一个实际存在的已知问题:MCP Server 显示 Connected 状态,但工具未注册给 Claude。先用 Inspector 确认 Server 确实在 tools/list 响应里返回了工具。如果 Inspector 里能看到工具但 Claude Code 看不到,重启 Claude Code 会话(退出重新进入),连接状态有时需要刷新。

问题:stdio Server 里日志出现在 Claude 的对话里

这是把日志写到了 stdout。本地 MCP Server 绝对不能把日志写到 stdout,这会污染 JSON-RPC 消息流。所有调试日志必须写到 stderr。

# ❌ 会破坏协议
print("连接成功")

# ✅ 安全
import sys
print("连接成功", file=sys.stderr)

# ✅ 更好:用 logging 库
import logging
logging.basicConfig(handlers=[logging.StreamHandler(sys.stderr)])
logger = logging.getLogger(__name__)
logger.info("连接成功")

问题:工具定义正常,但 Claude 从不主动调用

根源是 docstring 写得不够精准。Claude 根据工具描述来决定何时调用,描述太泛会导致匹配不上。测试方法是直接在会话里说「请用 search_orders 工具查询待处理订单」,如果这样能触发但自然语言触发不了,就是描述问题。

改进方向:在 docstring 里加上「适用场景」段落,把用户可能说的话和工具能力直接对应起来:

@mcp.tool()
def search_orders(params: OrderSearchParams) -> str:
    """按条件搜索订单列表,支持状态筛选和日期范围过滤。

    适用场景:
    - 用户说「查一下今天有哪些待付款订单」
    - 用户说「给我看看上周的成交记录」
    - 用户说「有多少订单是取消状态」

    不适用:查询单笔订单详情(用 query_order)、提交退款(用 execute_refund)
    """

问题:Server 能连接,工具调用时报错

用 Inspector 先重现这个错误,在不涉及 Claude 的情况下直接调用工具,看原始错误信息。常见原因是:数据库连接字符串用了相对路径(Server 启动时工作目录不确定)、环境变量没有传入、或者参数类型与 Pydantic 模型不匹配。

服务器启动时的工作目录可能是 /(macOS),因为客户端可能从任意位置启动。始终在配置文件和 .env 里使用绝对路径。

# ❌ 相对路径——启动目录不固定时会找不到文件
load_dotenv(".env")

# ✅ 绝对路径
import os
load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))

调试 MCP Server 的核心态度是:一次只验证一层。当 Inspector 测试通过、Claude Code 连接状态正常、debug 日志里能看到 tool call 记录,基本上就没什么解决不了的问题了。真正棘手的边界情况(比如 Claude 决策层面的工具选择逻辑)可以通过改进 docstring 迭代,这比排查底层协议问题容易得多。

上下文与性能优化

掌握 Compaction(服务端上下文自动压缩)机制

为什么上下文会「变坏」

在用 Claude Code 做长任务时,你可能有过这样的体验:会话进行到后半段,Claude 开始给出一些莫名其妙的建议——它似乎忘了你们一小时前达成的架构决策,或者在一个它已经修过的文件里又引入了同样的错误。这不是偶发现象,而是有规律的:上下文窗口越满,模型的推理质量越低。

研究和开发者经验都表明,当上下文窗口接近上限时,LLM 的性能会显著下降。在长会话中,上下文会「中毒」——模型开始与早期决策矛盾,或忘记它一直遵守的项目特定约定。

Compaction 就是对抗这个问题的机制。理解它的工作原理,才能合理地驾驭它,而不是被动地承受它。

三层压缩架构

Claude Code 通过三种机制管理上下文:微压缩(Microcompaction)在工具输出变大时尽早卸载;自动压缩(Auto-compaction)在会话接近满载时触发;手动压缩(Manual compaction)让你在任务边界点主动控制。

微压缩是最低调的一层,持续在后台运行。当工具输出(Read、Bash、Grep 等)变得过大时,Claude Code 把它们保存到磁盘,上下文里只保留一个引用路径。最近的工具结果保持「内联」可见;更早的结果变成「存储在磁盘、可通过路径检索」的冷存储。 你平时感知不到这个过程,它默默地为你节省着宝贵的 token 空间。

自动压缩是大多数人真正遇到的那个。当上下文窗口接近上限(约 83.5%,对应 200K 窗口约 167K token),Claude Code 会自动触发压缩。系统会保留一个约 33K token 的缓冲区,确保压缩过程本身有足够空间运行而不会中途失败。

手动压缩是你主动出手的时机,下文重点讨论如何用好它。

压缩的本质:不只是摘要

很多人以为 Compaction 就是「让 Claude 写一段摘要」,但实际上远比这精细。

压缩完成后,Claude Code 会重建上下文,依次注入:边界标记(标注压缩发生点)、压缩摘要、最近访问的文件(重新读取)、待办事项列表、计划状态,以及任何启动钩子注入的上下文。关键设计在于文件重新注入:系统会重新读取你刚才在处理的几个文件,这样你不会在工作中失去位置。

压缩后,摘要被包装进这样一段继续消息:「此会话从一个上下文已耗尽的前一次对话中继续。以下摘要涵盖了对话的前半部分……请从我们离开的地方继续,不要向用户再提问。继续处理你被要求的最后一个任务。」

摘要本身也有明确的信息契约,要求包含:用户意图(要求了什么、改变了什么)、关键技术决策、接触过的文件及原因、遇到的错误和解决方式、待处理任务和当前精确状态、以及与最近用户意图匹配的下一步行动。

用 CLAUDE.md 控制压缩质量

自动压缩的触发时机不可配置,但压缩的质量可以引导。最可靠的方式是在 CLAUDE.md 里加一个 Compact Instructions 段落。

CLAUDE.md 在每次压缩后会从磁盘重新加载,因此这里的压缩指令在会话全程持续有效。

<!-- .claude/CLAUDE.md -->

## Compact Instructions

压缩上下文时,必须保留以下信息:
- 所有已修改文件的路径及修改目的
- 当前正在处理的功能或 Bug 的具体状态
- 本次会话发现并修复的错误及其根本原因
- 数据库 Schema 中的约定(BigDecimal 存金额、订单状态流转规则)
- 尚未完成的 TODO 事项列表,按优先级排列
- 最近一次运行的测试结果(哪些通过、哪些失败、失败原因)

这一段描述的是「什么必须从压缩中幸存」,而不是一般的代码风格或规范——那些已经在 CLAUDE.md 的其他部分了,不需要重复。写得越具体,压缩后的摘要越能保留有价值的信息。

手动压缩:在对的时机出手

有经验的用户普遍建议不要等待自动压缩,因为它有时会导致 Agent 丢失重要上下文,甚至开始失控偏转。

手动压缩的时机应该是任务边界点,而不是等到快满时再急着用。比如:

# 完成了一个功能,开始下一个之前
/compact

# 需要聚焦在特定内容时,加焦点提示
/compact 重点保留 OrderService 的重构变更和已确认的接口设计

# 刚修完一个复杂 Bug,切换到新任务前
/compact 保留刚才修复的事务死锁问题的根本原因分析和解决方案

每次自动压缩循环都会让下一次压缩更早来临——Agent 重新读取文件来恢复丢失的上下文,重新运行命令来验证状态,这些操作产生更多 token,触发更快的下一次压缩。一旦压缩过一次的会话往往会在短时间内连续压缩三到五次,每次摘要都离原始细节更远。

主动在任务边界压缩,就是打破这个反馈循环的最有效手段。

/context:诊断上下文消耗

在使用 /compact/clear 之前,先用 /context 看看上下文空间被谁占用:

/context

输出会显示各部分的 token 占比,典型的结构类似:

Context window usage: 142K / 200K (71%)

System prompt:        12K
CLAUDE.md + rules:    8K
MCP tool definitions: 18K    如果有大量 MCP 工具
Conversation history: 89K
Tool outputs (recent):15K

Free space:           58K
Compaction buffer:    33K
Available for work:   25K

如果 MCP 工具定义吃掉了大量空间,可以在会话里临时禁用当前任务不需要的 MCP 服务:

# 禁用当前任务不需要的 MCP 服务,释放上下文空间
/mcp
# 在 MCP 管理界面里关闭不需要的服务器

在决定压缩之前,用 /context 找出占用空间大但当前不需要的 MCP 服务器并禁用,有时可以完全避免压缩的必要。

自动压缩触发阈值调整

CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 环境变量允许调整自动压缩的触发时机:

# 更早触发压缩(70%时触发),每次压缩后上下文更干净
export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=70

# 更晚触发压缩(90%时触发),可以使用更多上下文但风险更高
export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=90

这个值接受 1-100,直接控制自动压缩触发的百分比阈值。设置更高的值让你在压缩前使用更多上下文,但留给压缩过程本身的缓冲区更少。

对于 Spring Boot 这类代码量大、文件读取频繁的项目,设置为 70-75 是合理的——让 Claude 在还有充足空间时就整理一次,而不是等到快爆了才仓促处理,往往能得到更高质量的摘要。

API 层的精细控制

如果你在构建内部工具或自动化流水线,Anthropic 的 Messages API 提供了对 Compaction 更精细的控制(目前处于 beta 阶段):

import anthropic

client = anthropic.Anthropic()

response = client.beta.messages.create(
    betas=["compact-2026-01-12"],
    model="claude-sonnet-4-6",
    max_tokens=4096,
    messages=messages,
    context_management={
        "edits": [
            {
                "type": "compact_20260112",
                # 当输入 token 超过 150K 时触发
                "trigger": {"type": "input_tokens", "value": 150000},
                # 自定义压缩指令:聚焦于代码和技术决策
                "instructions": "专注保留代码片段、变量名和技术决策,忽略中间的讨论过程。",
                # 压缩后暂停,允许你在继续之前注入额外上下文
                "pause_after_compaction": True,
            }
        ]
    },
)

# 检查是否因为压缩而暂停
if response.stop_reason == "compaction":
    # 可以在这里向 messages 里注入额外的系统指令
    messages.append({"role": "assistant", "content": response.content})
    # 然后继续
    response = client.beta.messages.create(
        betas=["compact-2026-01-12"],
        model="claude-sonnet-4-6",
        max_tokens=4096,
        messages=messages,
        context_management={"edits": [{"type": "compact_20260112"}]},
    )

pause_after_compaction 让 API 在生成压缩摘要后暂停,允许你在继续之前添加额外的内容块——比如保留最近的消息或注入特定的指令性消息。当压缩触发暂停时,响应的 stop_reason 会是 "compaction"


理解 Compaction 机制的最终目的是:知道上下文里什么会消失、什么会保留,从而在会话设计上做对应的安排。持久的规范放 CLAUDE.md,任务进度放压缩指令,关键的中间状态在任务边界主动压缩而不是等系统自动处理。这三件事做好了,即使是跨越数百轮交互的长任务,也能保持相当稳定的质量。

分析并优化长会话的 token 消耗

先量化,再优化

优化 token 消耗的第一步不是改配置,而是建立对「我的会话在烧多少 token、烧在哪里」的直觉。没有测量,优化就是瞎猜。

# 查看当前会话的 token 详情
/cost

# 典型输出:
# Total tokens:    89,234
# Input tokens:    82,100   ← 占大头,是优化的主战场
# Output tokens:    7,134
# Estimated cost:  $0.48

输入 token 是大多数用户最大的成本驱动因素,因为上下文在会话中不断累积。它的构成大致是:对话历史占 40-50%,Claude 读取的文件内容占 30-40%,CLAUDE.md 和 MCP 元数据等系统上下文占 10-15%。降低输入 token 是最高影响力的优化方向。

/context 命令提供更细的分解:

/context

# 上下文用量:142K / 200K (71%)
#
# 系统提示:         12K
# CLAUDE.md:         8K
# MCP 工具定义:     18K   ← 有时候这里藏着大坑
# 对话历史:         89K
# 工具输出(近期):  15K
#
# 空闲:             58K
# 压缩缓冲区:       33K
# 可用工作空间:     25K

如果 MCP 工具定义吃掉了 18K,但当前任务根本用不到大部分 MCP 服务器,这就是一个立刻可以解决的浪费点。

五个主要浪费来源及对策

  1. CLAUDE.md 越写越长

CLAUDE.md 在每次会话开始时全量加载,每一个 token 都会出现在每一轮对话的输入里。许多人把它变成了项目百科全书,导致每次问 Claude 一个简单问题,都要先把数千 token 的背景知识塞进去。

把 CLAUDE.md 控制在 50 行以内。不要放项目的历史信息,不要包含 Claude 可以通过读取源文件找到的文档。目的是防止 Claude 漫无目的地探索,而不是提前把每个细节都塞进去。

把 CLAUDE.md 拆分成两个层次:

<!-- CLAUDE.md(核心层,始终加载,保持精简)-->

## 架构概览
Spring Boot + MyBatis Plus,MySQL,Redis。
模块:account(账号管理)、order(交易)、payment(支付)。

## 关键约定
- 金额字段统一 BigDecimal,scale=2
- 返回值统一 Result<T> 包装
- 禁止在 Controller 里开事务

## 禁止读取的目录
- .git/
- target/
- logs/

把详细的规范、技术文档、历史决策放到 .claude/docs/ 目录下独立文件,需要时用 @docs/api-conventions.md 语法显式引入,而不是让它永远占据上下文空间。

  1. 无边界的文件读取

这是长会话里增长最快的开销来源。Claude 在探索代码时会读取大量文件,每个文件的内容都会进入上下文,而且不会自动清除——即使那个文件后来完全不相关了。

「重构认证系统」的任务,如果让主会话处理:Claude 读取 15 个文件(~50K token)→ 推理(~10K token)→ 修改(~20K token)。如果用 Subagent 处理:主会话只看到探索摘要(~500 token),Subagent 消耗的 50K token 在返回后被丢弃,主会话保持精简。

写进 CLAUDE.md 的一条规则,可以系统性地解决这个问题:

## 上下文管理原则

默认用 Subagent 处理以下任务:
- 代码库探索(需要读取 3 个以上文件来回答问题)
- 代码审查或分析(会产生大量详细输出)
- 任何「只需要结论」的调查任务

留在主上下文的任务:
- 直接修改用户请求的文件
- 1-2 个文件的精准读取
- 需要来回交互的对话
- 用户需要看到中间步骤的任务
  1. 命令输出未裁剪就进上下文

Shell 命令的输出会完整地进入上下文。mvn test 的详细输出可能有几千行,git log 不加限制可以列出几百条提交,cat 一个大文件……这些操作一次性可以消耗上万 token。

用 Hook 来裁剪工具输出:

#!/bin/bash
# .claude/hooks/trim-output.sh
# PostToolUse Hook:裁剪 Bash 工具的输出

INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# 针对 mvn test 输出截断——保留最后 50 行(含失败详情)
if echo "$COMMAND" | grep -q 'mvn.*test'; then
  OUTPUT=$(echo "$INPUT" | jq -r '.tool_response.output // empty')
  TRIMMED=$(echo "$OUTPUT" | tail -50)
  echo "📊 输出已截断至最后 50 行(总输出更长)"
  echo "$TRIMMED"
fi

exit 0

对于不需要截断的情况,使用精确的命令参数本身就是最好的裁剪:

# ❌ 完整日志全部进上下文
mvn test

# ✅ 只看失败的测试,无关输出不进上下文
mvn test 2>&1 | grep -E 'FAILED|ERROR|Tests run' | head -20

# ❌ 完整 git log
git log

# ✅ 只看近 5 条,简短格式
git log --oneline -5
  1. 模型选型错误

Opus 的输出 token 成本是 Haiku 的近 19 倍。生成 5000 个输出 token 用 Opus 花 0.375,用Haiku只花0.375,用 Haiku 只花 0.375,用Haiku只花0.02。对于每月数百次任务的团队,这个差距会显著积累。

不同任务对模型能力的需求差异很大:

# 在会话中切换模型
/model sonnet   # 大多数实现任务用 Sonnet,性价比最高
/model opus     # 只在需要复杂架构决策时切到 Opus
/model haiku    # 格式检查、简单变更、重复性任务用 Haiku

一个实用的任务-模型映射原则:需要创造性推理和架构判断的任务用 Opus;需要写代码、修 bug、解释概念的任务用 Sonnet(覆盖约 80% 的场景);只是格式化、重命名、简单查询的任务用 Haiku。

对于 Plan Mode(Shift+Tab 两次进入),Opus 的推理质量值得额外成本——但只在规划阶段,执行阶段切回 Sonnet。

  1. 跨任务的上下文污染

一个会话里处理完权限模块的 Bug,接着去做支付模块的新功能,上下文里就混入了大量与支付任务无关的权限相关文件和对话。这些内容不会凭空消失,它们会影响后续每次请求的 token 消耗。

每个逻辑任务用一个会话效果最好——一个 Bug、一个功能、一次重构。不要试图在一次对话里修三个 Bug 再加两个功能。

# 任务完成后,在清除前给这次会话起个名字
/rename fix-order-status-null-pointer

# 然后清除,开始新任务
/clear

# 需要回到之前那个会话时
/resume fix-order-status-null-pointer

用 ccusage 追踪消耗趋势

/cost 只看当前会话,ccusage 可以看历史趋势,帮助找出哪类任务在无谓烧钱:

npm install -g ccusage

ccusage daily              # 每日分解
ccusage blocks --live      # 实时查看 5 小时计费窗口
ccusage daily --breakdown  # 按模型分解成本

对于 Spring Boot 项目,根据历史数据可以建立一个任务成本参照:

任务类型典型 token 范围说明
修单文件 Bug5K - 15K可接受,正常操作
新增一个 Service + 测试20K - 50K正常,注意文件读取范围
代码库级别的重构规划50K - 120K考虑用 Subagent 拆解探索阶段
「帮我看看整个项目有没有问题」150K+这是反模式,拆成小任务

写进 CLAUDE.md 的最终形态

把上面所有策略整合进一条 CLAUDE.md 规则,让优化行为自动发生:

## Token 效率原则(请严格遵守)

**文件读取**:除非明确要求,每次任务读取的文件不超过 5 个。
需要探索更多时,先告知我,等我确认后再继续,或使用 Subagent。

**Subagent 默认规则**- 需要读取 3 个以上文件的探索任务 → 派生 Subagent
- 代码审查、分析类任务 → 派生 Subagent
- 只需要结论的调查 → 派生 Subagent

**命令输出**- 测试输出超过 20 行时,只显示失败摘要
- git log 默认 `--oneline -5`
- 构建日志只显示错误和警告

**模型使用**- 规划、架构决策:可使用 Opus
- 代码实现、Bug 修复:使用 Sonnet(默认)
- 格式化、简单变更:使用 Haiku

Token 优化本质上是一个信息论问题:把尽可能少但尽可能有效的信息放进上下文。在 Claude Code 里花费最少的开发者不是那些使用工具最少的人,而是那些主动管理上下文、有意识地选择模型、写出精准提示词,并在不相关任务之间清除会话的人。 这些习惯一旦养成,优化就变成了自动运行的背景行为,而不是每次都需要刻意去做的操作。

通过 Claude Code Analytics API 监控团队用量

两套 API,定位不同

在开始之前,需要区分两个容易混淆的 API:

Claude Code Analytics API/v1/organizations/usage_report/claude_code)是面向 Admin 的接口,提供每日聚合的用户生产力指标:会话数、代码行变化、提交次数、PR 数量、工具采纳率、成本分解。它的定位是帮助团队分析使用模式、构建自定义仪表盘、向管理层汇报 ROI。

Usage & Cost API/v1/organizations/usage_report/messages)则专注于 API 调用的原始 token 消耗和费用,支持按工作区、模型、时间桶分组。两者互补——前者回答「团队在用 Claude Code 做什么」,后者回答「花了多少钱」。

两个 API 都需要 Admin API Key(以 sk-ant-admin 开头),只有组织管理员才能从 Console 申请。

拉取一天的团队数据

最基础的用法是直接查询某一天所有成员的使用情况:

# 查询指定日期的团队用量(数据有约 1 小时延迟)
curl "https://api.anthropic.com/v1/organizations/usage_report/claude_code?\
starting_at=2026-03-26&\
limit=100" \
  --header "anthropic-version: 2023-06-01" \
  --header "x-api-key: $ADMIN_API_KEY"

API 返回的数据结构按用户和日期聚合,每条记录包含:用户邮箱(OAuth 登录)或 API Key 名称;终端类型(vscodeiTerm.apptmux 等);核心指标(会话数、代码行增减、提交数、PR 数);工具操作的接受/拒绝数(Edit、Write、NotebookEdit);以及按模型分解的 token 用量和估算费用。

一条完整的响应记录大致是这样:

{
  "date": "2026-03-26T00:00:00Z",
  "actor": {
    "type": "user_actor",
    "email_address": "zhang.wei@example.com"
  },
  "terminal_type": "vscode",
  "core_metrics": {
    "num_sessions": 8,
    "lines_of_code": { "added": 1243, "removed": 387 },
    "commits_by_claude_code": 5,
    "pull_requests_by_claude_code": 1
  },
  "tool_actions": {
    "edit_tool":       { "accepted": 67, "rejected": 4 },
    "multi_edit_tool": { "accepted": 18, "rejected": 2 },
    "write_tool":      { "accepted": 11, "rejected": 0 }
  },
  "model_breakdown": [
    {
      "model": "claude-sonnet-4-6",
      "tokens": { "input": 82000, "output": 12000, "cache_read": 45000 },
      "estimated_cost": { "currency": "USD", "amount": 320 }
    }
  ]
}

estimated_cost.amount 的单位是分(cents),所以 320 表示 $3.20。

用 Python 构建团队周报

单次查询只覆盖一天,对于团队管理来说通常需要周维度的汇总。由于 API 只支持按天查询,需要客户端遍历日期范围:

#!/usr/bin/env python3
"""
team_weekly_report.py
生成团队 Claude Code 使用周报,输出 Markdown 格式
"""

import os
import json
import httpx
from datetime import date, timedelta
from collections import defaultdict

ADMIN_API_KEY = os.environ["ANTHROPIC_ADMIN_KEY"]
BASE_URL = "https://api.anthropic.com/v1/organizations/usage_report/claude_code"
HEADERS = {
    "anthropic-version": "2023-06-01",
    "x-api-key": ADMIN_API_KEY,
}


def fetch_day(target_date: date) -> list[dict]:
    """拉取指定日期的全部数据(处理分页)"""
    records = []
    params = {"starting_at": target_date.isoformat(), "limit": 1000}

    while True:
        resp = httpx.get(BASE_URL, headers=HEADERS, params=params)
        resp.raise_for_status()
        body = resp.json()
        records.extend(body.get("data", []))

        if not body.get("has_more"):
            break
        params["page"] = body["next_page"]

    return records


def fetch_week(end_date: date) -> list[dict]:
    """拉取最近 7 天的数据"""
    all_records = []
    # API 数据有 3 天延迟(Analytics API)或 1 小时延迟(Claude Code Analytics API)
    for i in range(7):
        day = end_date - timedelta(days=i)
        all_records.extend(fetch_day(day))
    return all_records


def calc_accept_rate(tool_actions: dict) -> float:
    """计算综合工具接受率"""
    total_accepted = sum(
        v.get("accepted", 0) for v in tool_actions.values()
    )
    total_all = total_accepted + sum(
        v.get("rejected", 0) for v in tool_actions.values()
    )
    return total_accepted / total_all if total_all > 0 else 0.0


def aggregate_by_user(records: list[dict]) -> dict:
    """按用户汇总一周数据"""
    user_stats = defaultdict(lambda: {
        "sessions": 0,
        "lines_added": 0,
        "lines_removed": 0,
        "commits": 0,
        "prs": 0,
        "accepted": 0,
        "rejected": 0,
        "cost_cents": 0,
        "input_tokens": 0,
        "cache_read_tokens": 0,
        "active_days": set(),
    })

    for record in records:
        actor = record["actor"]
        # 统一用邮箱或 API Key 名作为标识
        user_id = (
            actor.get("email_address")
            or f"[API Key] {actor.get('api_key_name', 'unknown')}"
        )

        s = user_stats[user_id]
        s["sessions"]      += record["core_metrics"]["num_sessions"]
        s["lines_added"]   += record["core_metrics"]["lines_of_code"]["added"]
        s["lines_removed"] += record["core_metrics"]["lines_of_code"]["removed"]
        s["commits"]       += record["core_metrics"]["commits_by_claude_code"]
        s["prs"]           += record["core_metrics"]["pull_requests_by_claude_code"]

        for tool_data in record["tool_actions"].values():
            s["accepted"] += tool_data.get("accepted", 0)
            s["rejected"]  += tool_data.get("rejected", 0)

        for model_data in record["model_breakdown"]:
            s["cost_cents"]       += model_data["estimated_cost"]["amount"]
            s["input_tokens"]     += model_data["tokens"]["input"]
            s["cache_read_tokens"] += model_data["tokens"].get("cache_read", 0)

        # 记录活跃天数
        date_str = record["date"][:10]
        s["active_days"].add(date_str)

    return user_stats


def generate_markdown_report(user_stats: dict, week_end: date) -> str:
    week_start = week_end - timedelta(days=6)
    lines = [
        f"# Claude Code 团队周报",
        f"**统计周期**: {week_start} ~ {week_end}",
        f"**活跃成员数**: {len(user_stats)}",
        "",
        "## 各成员详情",
        "",
        "| 成员 | 活跃天 | 会话数 | 新增行 | 接受率 | 提交数 | PR 数 | 费用 |",
        "|------|--------|--------|--------|--------|--------|-------|------|",
    ]

    # 按代码新增行数排序
    sorted_users = sorted(
        user_stats.items(),
        key=lambda x: x[1]["lines_added"],
        reverse=True,
    )

    total = defaultdict(int)
    for user, s in sorted_users:
        accept_rate = calc_accept_rate({"all": {"accepted": s["accepted"], "rejected": s["rejected"]}})
        cost_usd = s["cost_cents"] / 100
        active_days = len(s["active_days"])

        lines.append(
            f"| {user} | {active_days} | {s['sessions']} | "
            f"{s['lines_added']:,} | {accept_rate:.0%} | "
            f"{s['commits']} | {s['prs']} | ${cost_usd:.2f} |"
        )

        for k in ["sessions", "lines_added", "lines_removed", "commits", "prs", "accepted", "rejected", "cost_cents"]:
            total[k] += s[k]

    total_accept_rate = calc_accept_rate({"all": {"accepted": total["accepted"], "rejected": total["rejected"]}})
    total_cost_usd = total["cost_cents"] / 100

    lines += [
        "",
        "## 团队汇总",
        "",
        f"- **总会话数**: {total['sessions']}",
        f"- **净新增代码行**: {total['lines_added'] - total['lines_removed']:,} 行"
        f"(新增 {total['lines_added']:,} / 删除 {total['lines_removed']:,})",
        f"- **通过 Claude Code 提交**: {total['commits']} 次",
        f"- **通过 Claude Code 创建 PR**: {total['prs']} 个",
        f"- **工具建议采纳率**: {total_accept_rate:.1%}",
        f"- **总费用估算**: ${total_cost_usd:.2f}",
    ]

    return "\n".join(lines)


if __name__ == "__main__":
    today = date.today()
    records = fetch_week(today)
    user_stats = aggregate_by_user(records)
    report = generate_markdown_report(user_stats, today)
    print(report)

    # 也可以保存到文件
    with open(f"reports/weekly_{today}.md", "w") as f:
        f.write(report)

运行后会生成类似这样的 Markdown 报告:

# Claude Code 团队周报
**统计周期**: 2026-03-20 ~ 2026-03-26
**活跃成员数**: 8

## 各成员详情

| 成员 | 活跃天 | 会话数 | 新增行 | 接受率 | 提交数 | PR 数 | 费用 |
|------|--------|--------|--------|--------|--------|-------|------|
| zhang.wei@example.com | 5 | 42 | 3,241 | 92% | 18 | 4 | $24.80 |
| li.na@example.com     | 5 | 38 | 2,876 | 88% | 15 | 3 | $21.20 |
...

## 团队汇总

- **总会话数**: 287
- **净新增代码行**: 12,450 行(新增 18,670 / 删除 6,220)
- **通过 Claude Code 提交**: 103 次
- **通过 Claude Code 创建 PR**: 22 个
- **工具建议采纳率**: 90.3%
- **总费用估算**: $156.40

指标解读:什么信号值得关注

有了数据之后,需要知道哪些数字有实际意义,哪些只是噪音。

工具接受率是最有诊断价值的单一指标。建议采纳率衡量 Claude Code 的建议与团队特定编码需求和实践的相关性与有用性。一个长期保持在 90% 以上的团队,说明他们已经建立了有效的工作流——CLAUDE.md 写得精准,提示词质量高。如果某个成员的采纳率持续低于 70%,通常意味着他们对 Claude Code 的使用姿势有问题,可能值得一对一交流。

活跃天数是比会话数更有参考价值的采纳指标。一周只有 1-2 天有记录的成员,说明他们还没有把 Claude Code 融入日常工作流,而不是真的用不上——这类成员是推广培训的优先目标。

每会话代码行数lines_added / num_sessions)反映每次使用的深度。这个值很高(200+ 行/会话)通常说明成员在做大批量任务(如自动生成测试、重构);这个值很低说明他们主要用于小修小改。两者没有优劣之分,但结合业务背景可以判断用法是否合理。

费用 vs. 产出的比率是向管理层汇报 ROI 最直观的方式。如果一个团队一周花了 $160,但提交了 103 次、合并了 22 个 PR、净新增 12,000 行生产代码,这个数字本身就能说明问题。

设置自动化定时任务

把报告生成纳入 CI/CD 或定时任务,避免手动拉取:

# .github/workflows/cc-analytics.yml
name: Claude Code 周报

on:
  schedule:
    # 每周一早上 9 点运行(UTC)
    - cron: '0 1 * * 1'
  workflow_dispatch:

jobs:
  generate-report:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }

      - run: pip install httpx

      - name: 生成周报
        env:
          ANTHROPIC_ADMIN_KEY: ${{ secrets.ANTHROPIC_ADMIN_KEY }}
        run: |
          mkdir -p reports
          python scripts/team_weekly_report.py > reports/latest.md

      - name: 上传报告
        uses: actions/upload-artifact@v4
        with:
          name: cc-weekly-report
          path: reports/latest.md

Analytics API 的数据精度和延迟有其局限:数据在用户活动完成后约 1 小时可见,且 API 仅提供每日聚合数据。如果需要实时监控,应考虑使用 OpenTelemetry 集成。对于大多数团队管理场景,每日聚合已经足够。真正让这套数据产生价值的不是数据本身,而是持续地把它与团队的开发交付节奏对照——在采纳率下滑时找原因,在成本异常时分析是哪类任务在消耗,在成员活跃度差异大时提供针对性支持。

设计适合生产环境的 CLAUDE.md 多层级配置体系

理解加载机制,再谈设计

在设计配置体系之前,必须弄清楚 Claude Code 加载 CLAUDE.md 的实际行为,否则精心设计的层级结构可能会失效。

Claude Code 使用两种加载策略:父目录加载(向上遍历)和子目录按需加载(向下懒加载)。父目录加载在启动时触发,Claude 从当前工作目录向上遍历文件树,加载每一级找到的 CLAUDE.md——这是根目录的规则如何自动覆盖子目录的机制。子目录加载按需触发,当 Claude 读取某个子目录里的文件时,才检查那个目录是否有 CLAUDE.md。这种按需加载让大型项目的 token 保持精简,直到 Claude 真正需要那些指令才加载。

还有一个关键细节:同级目录之间不会互相加载。如果你从 packages/frontend/ 启动 Claude,它永远不会加载 packages/backend/CLAUDE.md,每个包只能看到根目录以及自己的祖先和子目录。

理解了这两条规则,你就知道该在哪一层放什么内容了。

完整的层级结构

一个生产级的 Spring Boot 项目,配置体系可以这样组织:

game-platform/                      ← 项目根目录
├── CLAUDE.md                       ← 团队共享,提交版本库
├── CLAUDE.local.md                 ← 个人覆盖,加入 .gitignore
│
├── .claude/
│   ├── CLAUDE.md                   ← 项目级补充(可选)
│   ├── rules/                      ← 模块化规则(按需加载)
│   │   ├── testing.md              ← 无 path 过滤:始终加载
│   │   ├── security.md             ← 无 path 过滤:始终加载
│   │   └── payment-api.md          ← path 过滤:只在支付目录加载
│   ├── agents/                     ← 自定义子 Agent
│   ├── hooks/                      ← Hook 脚本
│   └── skills/                     ← 自定义 Skills
│
├── src/
│   ├── main/java/com/example/
│   │   ├── order/
│   │   │   └── CLAUDE.md           ← 订单模块专属规则(按需加载)
│   │   └── payment/
│   │       └── CLAUDE.md           ← 支付模块专属规则(按需加载)
│   └── ...
│
└── ~/.claude/CLAUDE.md             ← 个人全局偏好(所有项目适用)

Settings.json 有独立的层级体系:企业管理策略 → 用户设置 → 项目共享设置 → 项目本地设置。deny 规则拥有最高安全优先级,不能被低层级的 allow 覆盖。CLAUDE.md 和 settings.json 各司其职——前者告诉 Claude「做什么、怎么做」,后者控制「被允许做什么」。

根目录 CLAUDE.md:团队的最小公约数

根目录的 CLAUDE.md 是每个人、每个会话都会加载的内容,必须精简有力。只放那些「缺少这条规则 Claude 就会持续犯错」的内容。

<!-- CLAUDE.md — 提交到版本库,团队共享 -->

# 交易平台

Spring Boot 3.x + MyBatis Plus + MySQL + Redis。
模块划分:account(账号)、order(交易)、payment(支付)、user(用户)。

## 核心约定(违反会导致构建失败)

金额字段统一 BigDecimal,scale=2,禁用 double/float。
返回值统一 Result<T>,禁止裸 POJO 直接返回。
@Transactional 只加在 Service 层,禁止 Controller 开事务。
敏感字段(手机号、身份证)返回前必须脱敏。

## 常用命令

```bash
mvn test -pl <module-name>   # 只跑指定模块的测试
mvn spring-boot:run          # 本地启动,端口 8080
mvn checkstyle:check         # 代码风格检查

禁止读取的目录

  • .git/target/logs/
  • src/main/resources/application-prod.yml(生产配置,只读)

上下文管理

探索超过 3 个文件的任务 → 派生 Subagent 命令输出超过 20 行 → 只显示摘要和错误行


注意这里没有塞入编码风格细节、历史决策、各模块的业务说明——那些内容放进下面的层级。

## `.claude/rules/`:模块化的「按需加载」规则

`.claude/rules/` 目录里的 `.md` 文件会被自动发现,无需任何配置。没有 `paths` frontmatter 的文件和 CLAUDE.md 享有同等的高优先级,始终加载;带有 `paths` 字段的文件只在 Claude 处理匹配路径的文件时才加载。这解决了把所有内容塞进一个大 CLAUDE.md 时「高优先级无处不在反而等于没有优先级」的问题。

```markdown
<!-- .claude/rules/testing.md — 无 path 过滤,始终加载 -->

# 测试规范

Service 层新增方法必须有对应的单元测试。
使用 Mockito Mock 所有外部依赖,禁止在单元测试里真实访问数据库。
测试方法命名:`should_[预期结果]_when_[条件]`。
最低覆盖率要求:核心业务逻辑 80%,工具类 60%。
<!-- .claude/rules/security.md — 无 path 过滤,始终加载 -->

# 安全规范

任何涉及金额计算的方法必须使用 BigDecimal,禁止隐式精度损失。
数据库查询必须使用 MyBatis Plus 参数化,禁止字符串拼接 SQL。
Controller 层所有公开接口必须有权限注解(@PreAuthorize 或自定义)。
日志禁止输出:手机号、身份证、银行卡号、密码。
<!-- .claude/rules/payment-api.md -->
---
paths:
  - "src/main/java/com/example/payment/**"
  - "src/test/java/com/example/payment/**"
---

# 支付模块专属规范

支付金额统一以分(Long)存储和传输,展示时再转换为元。
所有支付接口调用必须记录完整的请求和响应日志(脱敏后)。
支付状态流转:PENDING  PROCESSING  SUCCESS/FAILED,禁止跳过中间态。
退款接口必须实现幂等,使用订单号作为幂等键。
任何资金变动操作必须在事务内,且 rollbackFor = Exception.class。

路径过滤确保只在相关时才获得高优先级。当你在处理数据库迁移时,支付 API 规则不会出现在上下文里;当你切换到支付目录时,它自动加载。这是细粒度的「按需高优先级」,而不是全局噪音。

子模块 CLAUDE.md:深入细节

对于有复杂业务逻辑的模块,直接在模块目录里放一个 CLAUDE.md,记录只有开发那个模块才需要知道的内容:

<!-- src/main/java/com/example/order/CLAUDE.md -->
<!-- 按需加载:只在 Claude 读取订单目录文件时触发 -->

# 订单模块

## 状态机

PENDING → PAID → SHIPPED → COMPLETED
PENDING → CANCELLED(只有用户主动取消或超时才允许)
PAID → REFUNDING → REFUNDED

禁止在状态机之外直接修改订单状态,必须通过 OrderStateMachine.transition()。

## 关键约束

- 订单号生成:`ORD-{yyyyMMdd}-{6位序号}`,由 OrderIdGenerator 统一生成
- 一个用户同一账号同时最多 1 个进行中的订单
- 超时未支付(30分钟)由定时任务自动取消,不走业务逻辑层

## 当前已知技术债

- OrderMapper.findByStatus() 缺少索引,大表查询慢,正在处理 JIRA-4821
- 退款流程与支付网关的回调存在竞态,已知但暂未修复

个人层:不污染团队配置

每个开发者都有自己的偏好,这些应该和团队规范隔离:

<!-- ~/.claude/CLAUDE.md — 全局个人偏好,所有项目适用 -->

# 个人偏好

提交信息格式:feat/fix/chore(scope): description
代码有疑问时先问我再改,不要自行猜测意图。
调试时优先用日志而不是修改代码逻辑。
<!-- CLAUDE.local.md — 项目本地个人覆盖,加入 .gitignore -->

# 本地开发说明

本地数据库连接:mysql://localhost:3306/game_platform_dev
Redis:localhost:6379,密码 local_only_redis
本地不需要跑 checkstyle,太慢了
当前正在做:JIRA-5023 支付回调重试机制

CLAUDE.local.md 放本地环境配置、当前开发上下文、个人临时规则——这些内容不应该出现在团队共享的文件里,但在个人会话中很有价值。

验证配置是否生效

设计完配置体系后,有几个方法验证它是否按预期工作:

# 在会话里直接问 Claude
"你目前加载了哪些 CLAUDE.md 和规则文件?列出它们的路径和来源。"

# 使用 /memory 命令查看当前活跃的内存文件
/memory

# 使用 /context 看 CLAUDE.md 占用了多少上下文空间
/context

如果发现某条规则 Claude 总是忽略,可以把否定形式(「不要……」)改成正向引导(「优先使用 X 而不是 Y」)。正向引导在上下文较大的会话里更可靠,尤其是在 MUST/不要 这类强调词在长会话里容易被稀释的情况下。


多层级 CLAUDE.md 体系的本质是关注点分离:全局的放全局,团队的放项目根,模块的放模块目录,个人的放个人文件。每一层只承载那一层真正需要的内容,不多也不少。这样设计出来的配置体系,在项目成长、团队扩大时自然扩展,而不是变成一个越来越难维护的单一大文件。

暂无回复,快来抢沙发吧!

  • 1 / 1 页
敬请注意:文中内容观点和各种评论不代表本网立场!若有违规侵权,请联系我们