进阶阶段的核心是把 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 索引里的 description 和 triggers 做语义匹配。不是简单的关键词搜索,而是语义层面的相似度判断——"帮我加一个新的 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 TABLE 或 DELETE 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
}
]
}
]
}
}
Stop 和 PreCompact 使用 "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 Web(claude.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:read、email:send、contacts: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 timeout 或 process 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 服务器,这就是一个立刻可以解决的浪费点。
五个主要浪费来源及对策
-
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 语法显式引入,而不是让它永远占据上下文空间。
-
无边界的文件读取
这是长会话里增长最快的开销来源。Claude 在探索代码时会读取大量文件,每个文件的内容都会进入上下文,而且不会自动清除——即使那个文件后来完全不相关了。
「重构认证系统」的任务,如果让主会话处理:Claude 读取 15 个文件(~50K token)→ 推理(~10K token)→ 修改(~20K token)。如果用 Subagent 处理:主会话只看到探索摘要(~500 token),Subagent 消耗的 50K token 在返回后被丢弃,主会话保持精简。
写进 CLAUDE.md 的一条规则,可以系统性地解决这个问题:
## 上下文管理原则
默认用 Subagent 处理以下任务:
- 代码库探索(需要读取 3 个以上文件来回答问题)
- 代码审查或分析(会产生大量详细输出)
- 任何「只需要结论」的调查任务
留在主上下文的任务:
- 直接修改用户请求的文件
- 1-2 个文件的精准读取
- 需要来回交互的对话
- 用户需要看到中间步骤的任务
-
命令输出未裁剪就进上下文
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
-
模型选型错误
Opus 的输出 token 成本是 Haiku 的近 19 倍。生成 5000 个输出 token 用 Opus 花 0.02。对于每月数百次任务的团队,这个差距会显著积累。
不同任务对模型能力的需求差异很大:
# 在会话中切换模型
/model sonnet # 大多数实现任务用 Sonnet,性价比最高
/model opus # 只在需要复杂架构决策时切到 Opus
/model haiku # 格式检查、简单变更、重复性任务用 Haiku
一个实用的任务-模型映射原则:需要创造性推理和架构判断的任务用 Opus;需要写代码、修 bug、解释概念的任务用 Sonnet(覆盖约 80% 的场景);只是格式化、重命名、简单查询的任务用 Haiku。
对于 Plan Mode(Shift+Tab 两次进入),Opus 的推理质量值得额外成本——但只在规划阶段,执行阶段切回 Sonnet。
-
跨任务的上下文污染
一个会话里处理完权限模块的 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 范围 | 说明 |
|---|---|---|
| 修单文件 Bug | 5K - 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 名称;终端类型(vscode、iTerm.app、tmux 等);核心指标(会话数、代码行增减、提交数、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 体系的本质是关注点分离:全局的放全局,团队的放项目根,模块的放模块目录,个人的放个人文件。每一层只承载那一层真正需要的内容,不多也不少。这样设计出来的配置体系,在项目成长、团队扩大时自然扩展,而不是变成一个越来越难维护的单一大文件。