Agent Team 由四个组件构成:Team Lead(主 Claude Code 会话,负责创建团队、生成任务、综合结果)、Teammates(各自有独立上下文窗口的 Claude 实例)、共享任务列表(所有 Agent 可见的中心任务队列,支持依赖追踪)、以及 Mailbox(Agent 之间的消息通信系统)。
这是一种经过验证的模式:先用 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 是执行者,负责独立完成边界清晰的工作单元。
第一种是 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 会话并独立运行,所有任务真正同时执行,互不干扰。
自定义 MCP Server 的本质是一个适配器:把你的内部系统转化成 Claude 可以理解和调用的接口形式。Tool 定义的 docstring 就是 Claude 识别「何时调用这个工具」的依据,写得越具体,Claude 的调用时机就越准确。搭好之后,调试阶段的主要工作是检查工具是否按预期触发——MCP Inspector 比肉眼看日志要高效得多。
将内部 API / 数据库暴露为 MCP 工具
Tool 设计的核心矛盾
把内部系统暴露给 Claude 访问,本质上是在两个相互拉锯的目标之间寻找平衡:能力越强越好,暴露面越小越好。
这是一个实际存在的已知问题:MCP Server 显示 Connected 状态,但工具未注册给 Claude。先用 Inspector 确认 Server 确实在 tools/list 响应里返回了工具。如果 Inspector 里能看到工具但 Claude Code 看不到,重启 Claude Code 会话(退出重新进入),连接状态有时需要刷新。
问题:stdio Server 里日志出现在 Claude 的对话里
这是把日志写到了 stdout。本地 MCP Server 绝对不能把日志写到 stdout,这会污染 JSON-RPC 消息流。所有调试日志必须写到 stderr。
API 返回的数据结构按用户和日期聚合,每条记录包含:用户邮箱(OAuth 登录)或 API Key 名称;终端类型(vscode、iTerm.app、tmux 等);核心指标(会话数、代码行增减、提交数、PR 数);工具操作的接受/拒绝数(Edit、Write、NotebookEdit);以及按模型分解的 token 用量和估算费用。
Analytics API 的数据精度和延迟有其局限:数据在用户活动完成后约 1 小时可见,且 API 仅提供每日聚合数据。如果需要实时监控,应考虑使用 OpenTelemetry 集成。对于大多数团队管理场景,每日聚合已经足够。真正让这套数据产生价值的不是数据本身,而是持续地把它与团队的开发交付节奏对照——在采纳率下滑时找原因,在成本异常时分析是哪类任务在消耗,在成员活跃度差异大时提供针对性支持。
设计适合生产环境的 CLAUDE.md 多层级配置体系
理解加载机制,再谈设计
在设计配置体系之前,必须弄清楚 Claude Code 加载 CLAUDE.md 的实际行为,否则精心设计的层级结构可能会失效。
Claude Code 使用两种加载策略:父目录加载(向上遍历)和子目录按需加载(向下懒加载)。父目录加载在启动时触发,Claude 从当前工作目录向上遍历文件树,加载每一级找到的 CLAUDE.md——这是根目录的规则如何自动覆盖子目录的机制。子目录加载按需触发,当 Claude 读取某个子目录里的文件时,才检查那个目录是否有 CLAUDE.md。这种按需加载让大型项目的 token 保持精简,直到 Claude 真正需要那些指令才加载。
进阶阶段的核心是把 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-featureSkill 的描述能匹配上,即使没有出现任何触发词。激活阶段——匹配到合适的 Skill 之后,Claude 读取该 Skill 目录下的所有文件内容,加载进当前会话的上下文。此时 Skill 里定义的步骤、模板、示例才真正对 Claude 可见,它会按照 Skill 的指导来执行任务。
这套机制的核心优势是按需加载。你可以定义十几个 Skill,每次只激活和当前任务相关的一个或几个,避免所有 Skill 的内容同时占用上下文。
多级 Skills 目录
除了项目级的
.claude/skills/,Skills 支持多级目录结构,加载优先级从高到低:用户级
~/.claude/skills/——跨所有项目生效,适合放通用的技术 Skill,比如"写单元测试"、"生成 API 文档"。项目级
.claude/skills/——只在当前项目生效,适合放项目专属的业务 Skill。附加目录——通过
--add-dir参数指定额外的 Skills 目录,适合在多个项目之间共享一套 Skills 而不想复制文件的场景:指定了附加目录后,该目录下的 Skills 和项目级 Skills 一起被加载,团队级和项目级的 Skill 库可以分开维护。
Skills 和 CLAUDE.md 的分工
两者解决不同层次的问题,应该配合而不是替代:
CLAUDE.md 放项目认知——这是什么项目、用什么技术栈、有哪些全局规范。这些信息是所有任务的共同前提,需要始终在上下文里。
Skills 放任务流程——特定类型的任务应该按什么步骤执行、用什么模板、注意什么细节。这些信息只在执行对应类型的任务时才需要,不应该永久占用上下文。
实际使用中,CLAUDE.md 告诉 Claude 项目用 MyBatis Plus、禁止 BeanUtils,
new-featureSkill 告诉 Claude 新建功能时应该生成哪几个文件、每个文件遵循什么模板。两层信息叠加,Claude 生成的代码既符合项目规范,又有标准化的结构。手动激活 Skill
除了自动匹配,也可以在对话里手动指定使用某个 Skill:
用 new-feature skill 帮我创建一个账号举报功能显式指定在两种情况下特别有用:任务描述比较模糊,不确定自动匹配是否会选到正确的 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.xmlSKILL.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 分发,安装命令简洁明了: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 长度的关系大致是这样:
这个数字有一个关键含义:当 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 0Hooks 的配置作用域与安全边界
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.xmlHook 脚本放在
.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.xmlsession-memory.md是人类可读、Claude 可理解的结构化文件,应该提交到版本库——这样团队其他成员(包括 CI 环境里运行的 Claude)也能从同一份上下文出发。session-log.jsonl是原始事件记录,量大且含噪音,不用提交。SessionStart:把记忆注入初始上下文
SessionStartHook 收到 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 0SessionStart的 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` 的"待继续的工作"部分, 格式如下:待继续的工作
[状态] 正在实现的功能或修复
[待办] 下一步需要处理的事项
这样每次会话结束,状态文件里就有了两层信息:机器自动提取的文件和 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.pyInspector 会在浏览器里打开一个界面,让你直接调用每个 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.pyInspector 界面左边显示所有可用工具,点击某个工具后右边出现参数输入表单,填完直接调用。调用结果和原始 JSON 都会显示,看一眼就知道:
对于上一章写的
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 listclaude mcp list的输出包含每个 Server 的连接状态,成功时显示✓ Connected以及工具数量:game-platform stdio ✓ Connected (4 tools)如果显示
✗ Failed或连接但工具数为 0,进入会话里用/mcp命令看详情:# 在 Claude Code 会话里 /mcp这会列出所有 MCP 服务器的当前状态,以及每个服务器暴露的工具名。
第四层:debug 模式看完整通信
当工具连接看起来正常,但 Claude 在会话里就是不调用,或者调用结果不对,需要看完整的通信日志:
# 以 debug 模式启动 Claude Code,会输出所有 MCP 通信细节 claude --debugdebug 模式的输出很详细,关键字段包括:
[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看看上下文空间被谁占用:输出会显示各部分的 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.375,用Haiku只花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 项目,根据历史数据可以建立一个任务成本参照:
写进 CLAUDE.md 的最终形态
把上面所有策略整合进一条 CLAUDE.md 规则,让优化行为自动发生:
## Token 效率原则(请严格遵守) **文件读取**:除非明确要求,每次任务读取的文件不超过 5 个。 需要探索更多时,先告知我,等我确认后再继续,或使用 Subagent。 **Subagent 默认规则**: - 需要读取 3 个以上文件的探索任务 → 派生 Subagent - 代码审查、分析类任务 → 派生 Subagent - 只需要结论的调查 → 派生 Subagent **命令输出**: - 测试输出超过 20 行时,只显示失败摘要 - git log 默认 `--oneline -5` - 构建日志只显示错误和警告 **模型使用**: - 规划、架构决策:可使用 Opus - 代码实现、Bug 修复:使用 Sonnet(默认) - 格式化、简单变更:使用 HaikuToken 优化本质上是一个信息论问题:把尽可能少但尽可能有效的信息放进上下文。在 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.mdAnalytics 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 体系的本质是关注点分离:全局的放全局,团队的放项目根,模块的放模块目录,个人的放个人文件。每一层只承载那一层真正需要的内容,不多也不少。这样设计出来的配置体系,在项目成长、团队扩大时自然扩展,而不是变成一个越来越难维护的单一大文件。