后端架构面试指南:从高并发、分布式系统到 AI Agent
写在文章开头
AI时代,开发者更多是系统工作流的定义者和架构设计者,所以市场考察的重点也随之偏向候选人的软件系统设计能力。这篇文章是笔者在24年发表的一篇关于后端软件架构设计文章,结合笔者近几年工作上的沉淀与行业对于开发范式的变化,笔者也尝试对这篇文章知识点引入一些更进阶的思考以及一些AI架构的理念,保证文章的时效性。
总的来说,文章大体会按照基础架构概念、Java系架构设计、线上服务治理和AI架构设计逐步递进,对应章节的编排如下:
基础架构篇
- 高并发系统需要关注那些指标
- 如何评估系统当前qps
- 设计缓存架构需要考虑那些问题
- 如何进行服务器选型
- cdn架构的设计与理解
Java系架构设计篇
- 微服务与分布式架构的理解
- 如何在JVM层面优化高并发系统
- 虚拟线程下并发调优
- 分库分表的运用场景和注意事项
- MQ削峰填谷方案的设计
- 服务拆分后的机器评估
线上服务治理篇:
- 如何监控和判定系统性能瓶颈
- 服务启动时指标飙升问题如何排查
- 如何应对业务流量突增
- 服务限流的治理
AI架构篇:
- AI分层架构的理解
- Claude code 四层模型
- 谈谈你对claude code记忆层、拓展层、集成层和编程层的理解
- 如何理解harness工程
- 拓展层三类组件的触发机制
- claude code架构的工作流是什么样的
- 谈谈headless、skills与hooks如何组合
- hook和skill在架构设计上的权衡
SharkChili · 与AI斗其乐无穷
开源贡献
- mini-redis:教学级 Redis 精简实现 ·
https://github.com/shark-ctrl/mini-redis
关注公众号,回复 【加群】 获得笔者联系方式加入技术交流群
基础架构篇
高并发系统需要关注哪些指标?
面向高并发系统设计,我们就需要正确理解系统指标的定义,只有了解可量化的系统指标,才能够准确计算和设计符合业务SLA的软件系统,高并发系统核心监控指标为:
- response time:响应时间即常说的RT,它代表发送请求到响应数据所花费的时间
- qps:每秒请求数,表示系统每秒完成的请求数,从web开发角度来说,一次http调用计算1次
- tps:每秒事务数,表示系统每秒完成的事务数,它可以理解为一次业务的原子,我们以下单操作为例,它包含下单、库存扣减和支付回调操作,涉及3个接口调用,对应qps计数为3。按照tps维度来说,3个接口调用本质是一次完整的支付原子,所以计数为1:

- throughput:吞吐量,表示单位时间内可以处理多少请求,在web场景下我们也会用qps或者tps来衡量
如何基于压测结果衡量系统吞吐量
日常软件开发都会通过一轮全链路压测获得直观的系统性能分析报告,有了AI之后对于此类压测的成本变得非常低,假设我们通过jmeter配置100并发压测query接口1min,获得如下指标:
- 总请求数为12000
- 600个请求失败
对应系统吞吐量是多少?
结合系统分析报告,我们可知本次压测持续1min,总请求数为12000,其中有600个请求失败,我们需要按照有效请求数进行推导计算,对应步骤为:
有效请求数 = 总请求数 - 失败数
= 12000 - 600
= 11400
从query接口维度出发,对应吞吐量可等价于qps,即有 吞吐量 = 有效请求数/时间
= 11400/60
=190
所以,当前系统吞吐量为190
设计Redis缓存系统需要考虑那些问题
引入缓存中间件通常是为了分担后端压力或降低热点数据访问延迟。针对缓存中间件而言,它利用Redis的内存访问特性提升热点数据的响应速度,从而提高系统吞吐量。从软件系统架构设计角度出发,引入中间件我们无非是需要考虑以下几个问题:
- 业务上,数据一致性,确保业务上准确
- 性能表现保证毫秒级响应
- 架构层面,保证基座高可用
从业务角度来说,数据实时性要求尽可能保证链路顺序一致性以及缓存准确写入,从第一性原理出发,若仅需保证最终一致性,我们可利用消费队列的有序性和可靠性,通过监听数据库变更保证可靠写入:

性能方面,按照redis官方的说法,无pipeline的情况下,入门级的机器同步调用qps在7~8w左右,大部分性能瓶颈都是在客户端或者网络。从架构设计层面来说,我们要尽可能按照如下规约使用中间:
- 选用合适的数据结构和指令,能走O(1)级别的哈希结构,就尽可能避免使用列表
- 避免使用KEYS阻塞核心线程,SCAN也要控制单次COUNT并评估遍历成本
- 加强对于redis链路网络和带宽维护
架构层面,即合理设计redis集群架构,利用哨兵架构的投票选举或者raft一致性协议保证故障转移和快速恢复,同时合理分配rdb和aof间隔,尽可能避免数据丢失。
每日有500万个请求,如何推算高峰时段qps
在没有监控的情况下,我们推荐采用二八法估算,即80%的请求集中在20%的有效访问时段内,对应推算步骤为:
1. 假设业务每天的有效访问时段为 12小时,对应高峰期 = 12 × 20% = 2.4小时
2. 换算成秒为 2.4 × 3600 = 8640s
3. 按照二八原则对应请求量=500万 × 80% = 400 w
4. 高峰时段平均QPS = 高峰时段请求量 ÷ 高峰时段秒数
= 400万 ÷ 8640
≈ 463 QPS
由此我们初步推算系统qps大约是500,然后结合这个指标分配指定机器数,并做好动态扩容方案以确保业务上线后的稳定。
2C4G机器16台和 4C8G机器8台要选哪个
这题并没有标准答案,更多是考察候选人对于不同业务系统行性质的理解,我们要学会在架构层面分析两种机器的优劣才能完明确的服务器选型:
对于2C4G * 16台服务器来说,其核心优势在于,较小的爆炸半径,即单台服务器出现故障对于全局流量的影响为1/16,同时较小的机器占用也意味着更加轻量级的资源加载,启动更快。这也进一步说明小型号服务器在也会更快到达性能瓶颈,我们无法对其应用一些对于硬件要求较高的gc算法,例如:高并发回收器zgc因为并行性能要求服务器不得低于4c:

与之对应4c8g机器8台也就意味着更充裕的单机资源,对于一些追求高吞吐和重资源消耗的应用场景更加从容,在单机性能调优上我们有着更灵活的方案选择,当然更小的机器也就意味着故障的影响面积更大,一旦出现问题,将会有1/8的流量收到影响:

所以,对典型的无状态、请求短平快的Web服务,微服务理念倾向 2C4G × 16台,服务器更轻量、弹性和容错都更好。但这不是无条件的,一旦出现单机高吞吐的硬约束,判断就要倒向4C8G × 8台。
Java系架构设计篇
你是如何在架构层面配置调整JVM参数的
JVM参数不是按经验值逐个调整,对应参数一定要结合实际应用场景需求而定,对应推算步骤为:
- 明确容器内存限额、业务SLA、峰值流量
- 由SLA反推停顿目标和GC器
- 再通过GC日志、RSS与压测结果验证
- 参数不达标时再逐项调整。
在当前主流的应用独立部署模式下,可以先将进程或容器内存限额的50%~70%分配给Java堆,其余30%~50%用于元空间、线程栈、Direct Buffer、Code Cache等堆外内存,并为操作系统和其他进程保留余量。线程较多(线程栈使用堆外内存-Xss 常见 512KB~1MB)或大量使用堆外内存时应取区间下限,最终再根据GC日志、RSS和压测结果调整。
例如:某订单服务的容器内存限额为16GB,日常监控指标显示无GC时接口P99约为40ms,而接口SLA要求P99小于100ms,对应的gc参数验算和调整步骤为:
- 先预留6GB给元空间、线程栈、Direct Buffer和系统开销
- 将Java堆设为
-Xms10g -Xmx10g,约占容器内存的60% - 针对gc参数调优,接口剩余约60ms的延迟余量,因此可以先选择G1,并将
-XX:MaxGCPauseMillis=50设为停顿软目标,额外保留10ms缓冲。该参数只是GC器的调优目标,实际停顿仍受堆占用、分配速率和收集器行为影响,不能保证接口一定满足SLA。
结合上述的初步量化的业务指标,我们务必进行一次压测时,简合监控面板针对stw耗时进行进一步调优:
- 如果平均一次stw为42ms,撞上该停顿的请求耗时最长近似为
40ms + 42ms = 82ms;结合这一指标复核接口P99阈值,若没有问题则参数调优完毕。 - 如果每分钟出现多次stw,极端情况会有一个时间窗口stw达到120ms,撞上停顿的请求耗时最长近似为
40ms + 120ms = 160ms,此时就需要警惕,观测P99是否符合要求,若接口耗时有所上升,就需要定位接口耗时的位置进判断是堆堆内存不足、还是对象分配不合理或单机性能瓶颈:

如何针对GC耗时进行监控治理
对于GC次数和时长,这些指标高度依赖GC器、堆大小和业务的对象生命周期,只能当经验参考、不宜套死数字。常被引用的 STW 不超过 200ms,其实是G1默认停顿目标 MaxGCPauseMillis 的默认值,是延迟与吞吐折中出来的工程默认、不是理论门槛(ZGC、Shenandoah这类低延迟收集器能把停顿压到十毫秒乃至亚毫秒级)。
对于GC调优而言,我们最更多是要关注STW这个指标,同时,对于JVM参数调优的干预更多是需要结合SLA进行分析,对应推理分析步骤为:
- 从监控面板查看每小时Young GC次数和平均停顿时间
- 折算出平均每秒GC次数和累计STW
- 用接口正常耗时 + 每秒累计STW估算一次请求在极端重叠情况下可能达到的耗时。
例如:监控面板显示每小时发生36000次Young GC,平均每次停顿5ms,而线上业务接口平均耗时为40ms,按照SLA要求的p99小于100ms的验收标准下,对应推理思路为:
平均每秒GC停顿为= 每小时GC总耗时 / 3600
= 36000*0.005 / 3600
= 0.05s
= 50ms
按这一秒内的停顿全部与该请求重叠进行保守估算,耗时为= 40ms + 50ms
= 90ms
由此即初步得出,针对JVM层面的STW对于系统性能不会造成极端的影响。
虚拟线程了解吗?假设每个请求平均耗时200ms,我希望单机QPS达到2000,线程数设置多少合适?为什么?
按照第一性原理,我们要从目标、事实、约束和待验证假设逐步推出候选答案,对应推算步骤为:
首先我们需要明确目标,即单机稳定达到2000 QPS,同时接口延迟和错误率满足SLA 200ms。
拆解问题本质和约束事实:有了明确的目标之后,我们就需要获得问题本质,按照技术选型可知我们选用虚拟线程,对应线程创建和上下文切换开销远小于传统线程模型,按照预定接口耗时要求可推导:
虚拟线程每秒可处理请求数=1000/200
=5
单机目标为2000 QPS,每个并发处理位置每秒可以处理5个请求,因此平均需要:
平均并发量
= 2000 ÷ 5
= 400
这个结果也符合Little定律。Little定律描述的是稳定系统中三个平均值的关系:
平均在途数量 = 平均到达速率 × 平均停留时间
L = λ × W
= 2000次/秒 × 0.2秒
= 400个
最后,根据当前事实推出的候选并发量,进行一轮压测时应按2000 QPS持续加压,并观察平均耗时、P99、错误率、CPU、数据库连接池和下游容量。

如果单表数据量大,只能考虑分库分表吗
分库分表要抓住每种拆分方案所要解决的痛点,对于此类问题,我们可以通过渐进式的方式进行方案递进:
- 如果单表数据量大,在并发和数据体量都不算很大(这里按照经验主义,我们一般以2000w作为阈值)且数据带有时效性可以定期清除的,在明确分区键的情况下(例如查询用时间范围),采用分区即可解决
CREATE TABLE t (
id INT,
created_at DATETIME,
PRIMARY KEY (id, created_at) -- 分区键必须包含在唯一键/主键里
) PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p0 VALUES LESS THAN (2024),
PARTITION p1 VALUES LESS THAN (2025),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
- 单表体量超过阈值,并发量不大,数据需要长期保存的,采用分表,例如:用_year 作为表名后缀,业务代码根据路由进行读写
- 并发量大,单机数据库无法扛住持续频繁的请求,需要散列并发请求,则考虑分库
- 综合上述痛点,并发量和数据量都超过阈值情况下,需要结合业务进行分库分表,例如:根据用户id路由数据库、根据创建时间选择路由表
所以,我们回到问题本身,仅针对单表体量,结合业务增长如果没有超过阈值,且数据不需要长期保存的,可以考虑分区,例如:任务表仅保存近3个月数据,对应分区就可以设置为4,每个月开始新的分区n 时,删除(n+1)%4 的分区:

反之,如果数据需要长期保存,且明确分片路由算法的情况下,可采用分表算法,例如:按照年月进行分表:

谈谈你对消息队列的方案设计
削峰填谷常用于:
- 解耦非核心业务
- 异步处理延迟不敏感的业务
- 削峰填谷,避免瞬时流量压垮下游
从架构设计的角度,一套消息队列方案落地,应明确定义宏观的延迟指标、消息保留时间以及磁盘空间进行完整评估,假设当前业务数据指标如下:
- 每秒生产2000条消息
- 消费端每秒最多处理5000条
- 峰值流量上升到每秒10000条,并持续10分钟
首先是明确的业务SLA定义,确保团队能够明确消息队列方案的限制,按照上面的说明,流量低谷期不存在消息积压,SLA需针对业务高峰期消息和持续时间推导出积压恢复时间,对应的推导方式为:
高峰期每秒消息积压数 = 峰值投递消息数-消费数
= 10000 - 5000
= 5000
高峰期持续消息积压总量= 每秒积压数 * 总时间
= 5000 * 600
= 300_0000
从接水问题角度考虑,业务高峰期,我们实际推导:
消费的消息数为 = 消费消息数 - 实时产生消息数
= 5000 - 2000
= 3000
所以,在高峰期之后,队列恢复正常水位时间 = 积压消息数/实际消费能力
=300_0000 ÷ 3000
= 1000 s
≈ 16.7 min
最终推算结果为16.7min,注意这个值是队列清空本次积压、恢复正常水位的时间,在此之上,考虑到消费速度抖动、消息重试和业务处理开销,可将这类峰值场景的恢复SLA设为30min:

然后就是消息保存时间的可量化推算,假设消费者发生故障并持续6h,生产端仍按每秒2000条写入消息,要想计算最大保留时间本质上要抓住故障时间+恢复后追评时间总耗时进行推算,我们按照故障产生在业务高峰期,也就说6h里面有10min产生5000条消息,对应计算步骤为:
故障期间累计积压 = (低峰期持续时间* 低峰期产生消息数)+(高峰期持续时间* 高峰期产生消息数)
= 21000*200+ 600 * 10000
= 4200_0000+ 600_0000
= 4800_0000
消费者恢复后,每秒可净清理=5000 - 2000 = 3000
清空故障期间的积压需要:4800_0000 ÷ 3000 =16000s =4.5h
注意,这里的4.5h只是消费者恢复后的追平时间,故障开始时产生的最老消息,需要经历6小时故障 + 4.5h才可能被消费,因此保留时间必须大于10h。在此基础之上,我们还要考虑消息重试、消费速度抖动和故障恢复余量,可将消息保留时间设置为24h:

最后就是磁盘容量问题了,该问题只需抓住每日增量即可进行评估,假设24小时内出现一次持续10min的峰值,平均每条消息占用1kb,对应计算方式为:
正常流量产生= 2000 × 86400 = 1.728亿条
峰值相对正常流量多产生 = (10000 - 2000) × 600 = 480万条
24小时总消息量:`1.728亿 + 480万 = 1.776亿条`
双副本并预留`1.5倍`容量:`1.776亿 × 1KB × 2 × 1.5 ≈ 532.8GB
这里的532.8GB是双副本合计需要预留的容量,平均到每个副本约为266.4GB。示例中的1KB只是计算假设,实际规划时应替换为线上统计到的平均CommitLog消息大小,并结合磁盘水位配置继续校验余量。
微服务和分布式架构是什么关系
分布式架构和微服务不是同一维度上的递进关系:
- 分布式架构描述系统的运行和部署形态,即多个进程或节点通过网络协作完成业务
- 微服务描述系统如何按业务边界拆分和治理,强调服务能够独立开发、部署和演进。
微服务通常运行在分布式环境中,因此它本身也是一种分布式架构,但分布式系统不一定采用微服务。
例如:一个订单单体应用部署10个相同实例,通过负载均衡对外提供服务,它已经跨多个节点运行,属于分布式部署,但所有实例仍使用同一套代码和同一个部署单元,因此不是微服务:

如果进一步按业务边界拆成订单、库存和支付三个能够独立开发、独立发布的服务,服务之间再通过RPC或消息协作,这才是微服务架构,同时它仍然属于分布式架构:

某查询接口需要承载5000QPS,压测得到平均RT为200ms,现要求预估8c服务器要几台
评估具体的服务器数量,我们首先要了解接口耗时的特性然后结合服务器配置进行预算,即处理步骤为:
- 根据接口耗时定位任务类型为IO还是计算类型,例如:一个200ms的接口执行代码运算逻辑10ms,按照CPU调度角度,理想情况下,单核每秒可处理 1000/10 即100个请求
- 结合计算耗时推算CPU有限资源情况下处理上限,步骤1推算的是100%的阈值,我们还需要按照业界通用规范定义实际可处理任务数,例如:CPU使用率不可超过70,那么单核理论情况下处理任务数为70
- 按照tomcat线程维度分析程序理论处理上限,这一个在操作系统调度以外针对程序维度的评估,Java程序本质是一个线程处理一次完整的请求,无视IO和运算比的,以200ms为例,单线程理论处理请求为1000/200则是5个请求
- 根据二者能力上限取最小值推算单机处理性能,推导出最终服务器数量

按题目所说接口平均耗时在200ms,这实际上是一个比较感性的数字,对于接口的评估,笔者一般会分析IO和计算耗时,假设我们通过arhtas等链路观测改接口计算耗时10ms,IO耗时在190ms。
从CPU处理计算任务维度来推算,CPU单机理论上限为:
单CPU理论上限= 单位时间/CPU运算耗时
= 1000/10
= 100
所以8c服务器的理论上限应该是800,同时考虑到业界对于CPU负载阈值设定的70%,实际上从CPU维度分析的理论上限为560。
然后再推算Java程序维度,假设TomcatmaxThreads=200,在传统同步模型中,一个线程需要占用200ms才能处理完一次请求,因此单线程每秒最多处理1 ÷ 0.2 = 5QPS,200个线程对应的理论上限为:
200 ÷ 0.2 = 1000QPS
取线程和CPU两个维度的较小值,单机稳定容量为min(1000, 560) = 560QPS。承载5000QPS至少需要:
5000 ÷ 560 ≈ 8.93
维稳起见,我们一般会向上取整并冗余一台机器,所以最终结果为10台,由此反推负载:
单机流量=总qps/机器数
= 5000 /10
=500
按照CPU 100% 推算流量占比= 500/800
= 62.5%
也就是说分配10台服务器,在理想情况下,CPU负载约为62.5,可由此机器数进行压测验收和调整。
线上服务器治理篇
如何判断系统性能瓶颈
CPU利用率阈值:一般建议在高负载情况下不要超过70%。
系统负载:这里指Linux的load average,它统计的不是CPU使用率,而是一段时间内如下状态任务数的移动平均:
- R:正在运行或等待CPU
- D:不可中断睡眠,多为等待IO)
需要注意的是,内核每约5秒采样一次当前R与D状态的任务数,记为n(比如某次采样3个正在运行或等待CPU、2个等待IO,n就是5),再拿上一次的值加权平滑。
以1分钟load为例,假设新旧load为0.7,load为0.2计算步骤为:
新load = 旧load * 0.92 + n * 0.08
= 0.7 * 0.92 + 0.2 * 0.08
= 0.66
同时,系统负载情况分析不可一概而论,只有符合以下条件情况下,才能确定当前系统存在性瓶颈:
- load主要由R状态任务贡献
- CPU利用率接近饱和
这种情况下,按照业界标准,这种情况下,cpu核心数 *0.7即系统负载的阈值,例如:
8核机器在全为R任务的系统阈值计算公式为:
cpu核心数 * 0.7
= 8 * 0.7
= 5.6
即当一段时间平均负载超过5.6时,系统才需要警惕是否存在性能瓶颈。
磁盘阈值:磁盘容量为存量文件持久化介质,可通过df -h查看,按照经验主义,我们完全建议70%作为提前治理的经验水位。若持续超过70%时,应检查空间增长趋势,清理或归档过期数据,并提前评估扩容。
你是如何排查系统启动时指标飙升的
注意问题所强调的启动后的几分钟,说明这些问题大部分是发生在启动初期,一般来说服务启动可能存在如下问题:
- 滚动发布:假如日常100台服务器刚好承载业务流量,滚动发布会分批下掉几台、逐批替换升级,这期间在线的机器不足100台,却仍要承载原本100台的流量,剩下的节点被迫扛下比平时更多的请求,各项指标就可能随之飙升
- 资源初始化预热:我们的服务启动前都需要连接池、线程池、缓存预热等工作,包括Java层面也存在服务启动的JIT编译和代码预热问题。
所以,针对服务启动指标飙升的问题,我们可以从以下几个方面处理:
- 如果使用支持JWarmUp的JDK发行版,例如Dragonwell 8,可以先记录代表性运行中的热点方法信息,再在下次启动时读取记录并提前触发JIT编译。JWarmUp保存的是热点编译信息,不是可直接复用的机器码,应用代码或JDK版本变化后应重新录制:

- 滚动发布时可以先让新实例承接少量真实流量,通过真实请求触发核心业务代码的JIT编译;从监控面板观察RT和P99是否逐步下降,耗时稳定后再逐渐放开流量,其本质也是预热再放开,只不过是通过线上流量方式逐步放开,实现成本更高,当时方案更加普适通用:

如何应对业务流量突增问题
无论单体还是分布式系统,都可能因为热点事件、大促或攻击出现流量突增。在云上部署的业务中,DDoS等攻击流量主要由云厂商的高防和WAF在入口处过滤:高防负责清洗大流量攻击,WAF负责按IP、设备、账号和接口限制恶意高频请求。源站应只允许云防护或负载均衡的回源流量,避免攻击者绕过防护直接访问。
对开发者来说,更需要关注正常业务流量突增时的应用保护。可以先通过压测确定集群和下游的稳定容量,再使用Alibaba Sentinel按业务资源设置集群流控阈值。这里的Sentinel指Alibaba Sentinel,不是Redis Sentinel。
例如:某集群压测得到的稳定容量为10000 QPS,我们可以按照如下保守措施进行统一治理:
- 按70%安全水位将Sentinel集群流控阈值设为
7000 QPS。 - 流量超过阈值时立即Fail Fast:查询接口返回缓存或降级数据,写入接口返回
429,由客户端在保证幂等的前提下重试,避免超量请求压垮数据库和下游服务。 - 执行云上扩容,等新实例预热就绪且下游容量验证通过后,再提高Sentinel阈值。
关于服务限流的探讨
为什么针对流量需要进行特定的服务限流,增加服务器承载更多的流量不是更好吗?
原则上服务优先,针对这个问题要从以下三个角度考虑:
- 服务好客户没有错,但是我们要知晓客户是否是正常客户,请求突增的原因不一定是业务量上涨,也可能是被攻击了。
- 突发流量:出问题一般是由于突发流量,例如我们系统最高
qps为1000,但是突发流量是远远高于这个数的,如果没有提前预测不做限流,服务可能直接被打挂了。 - 限流本质要做的就是自我保护,是系统的最后一道防线,只有通过限流保证服务器正常,然后再针对性排查流量来源,针对性扩容。
前面这套从单体到微服务的演进,练的是"把系统拆开、把流量扛住"的功夫,主角始终是人写的代码。而当AI开始接管一部分编码工作,架构的重心也随之上移——我们不再只关心服务怎么拆,还要关心怎么给模型套上"缰绳"、让它稳定干活。下面这几道题,就是笔者近期面试和实践里高频撞见的AI架构考点。
谈谈你对AI与系统架构的理解
在Agent工程中,模型提供推理和生成能力,harness负责把这些能力组织成可控的任务执行过程。harness原意是缰绳或挽具:它不会代替模型思考,而是通过上下文、规则、工具、权限和评测机制约束模型的执行边界,让模型围绕用户目标持续推进。
一次完整任务通常会经历理解目标、制定计划、调用工具和验证结果几个阶段。harness会在每个阶段提供必要信息和边界;如果验证结果不符合目标,再将结果反馈给模型,触发纠偏和重新执行,直到任务完成。
因此,Agent最终效果由模型能力和harness共同决定。工程师不能只追求更强的模型,还需要围绕具体任务设计harness,并通过持续评测调整上下文、工具、权限和执行流程。

基于上述理解,Claude Code的关键技术架构组成如下:
- CLAUDE.md:根治失忆顽疾,将项目规范写入配置文件中,即每次会话中都在自动加载,不需要反复重新声明。
- skills:对于过程性知识经验的复用沉淀,下次可以直接让Claude Code呼出直接使用
- MCP:赋予Claude Code链接外部工具、数据的能力
- headless模式:支持在CI/CD流水线中无人值守运行,实现真正的自动化交付
- Agent SDK:允许通过代码编写复杂的工作流,提升任务的执行的灵活性
- plugins:将上述能力整体打包封装,便于团队内高效分发与复用
Claude Code四层模型
Claude Code的架构自底向上分为4层:
- 记忆层:通过CLAUDE.md、rule、memory等记忆文档,让Claude启动时加载这些记忆,注入相关背景知识,提升Claude Code推理准确性
- 扩展层:Claude Code支持通过commands、skills、subagent、hooks,承载Claude Code的核心功能,是用户最直观感知到的一层
- 集成层:通过headless模式将Claude Code嵌入CI/CD流水线、并由MCP引入外部工具与数据,拓展其能力边界
- 编程层: 将Claude Code通过编程语言集成调用,构建自动化工作流

记忆层如何为Claude Code提供项目上下文
记忆层可以理解为Claude Code地基,他直接定义了Claude Code对项目的理解深度。本质上Claude是一个无状态且强大的模型,每次会话都相当于一个新的开始。所以,通过CLAUDE.md rules memory等Claude Code启动时自动加载的md文件,将项目背景信息注入上下文中,就像给AI注入员工手册,确保其执行任务的准确性。
CLAUDE.md分为四个作用域:
- 企业级
- 用户级
- 项目级
- 本地级
各层内容都会拼接进上下文,并非相互覆盖;发生冲突时,越贴近当前工作目录、描述越具体的内层规则,Claude会优先考虑。

如图所示,用户级和项目级分别定义了不同称呼,两条信息都会进入上下文,但Claude最终采用更具体的项目级定义。明确作用域和优先关系后,接下来还要解决一个更实际的问题:CLAUDE.md中究竟应该沉淀哪些内容。通常包括项目背景、技术栈、代码规范和执行指令,也可以通过/init生成初始版本。
例如,笔者个人开源项目mini-redis就是通过该文件维护当前项目背景信息,确保Claude Code编码时能够按照规范进行落地。

所以,这也就是笔者为什么认为记忆系统是Claude Code架构的基石,通过沉淀的记忆上层的skill、hooks、commands才能精准理解项目语义并准确执行。
扩展层如何按需增强Claude Code能力
在记忆系统的基础上,扩展层主要通过Skills、Subagents和Hooks增强Claude Code的任务执行能力。
这里需要区分内置命令与自定义Skill。/compact等内置命令由Claude Code CLI执行固定逻辑;团队内部需要反复复用的工作流程,则更适合封装为Skill。旧的.claude/commands/*.md仍可兼容使用,但新流程建议统一放在.claude/skills/<skill-name>/SKILL.md中。
例如,笔者经常需要重构团队中的历史核心代码。为了让Claude稳定按照Clean Code原则完成函数重构、补充单元测试并验证行为不变,笔者将这套流程封装为clean-func Skill。
该Skill在SKILL.md的YAML frontmatter中声明适用场景和参数提示,并通过$ARGUMENTS接收需要重构的方法:

使用时只需指定目标方法,例如/clean-func ArticleService#publish:

Skill与CLAUDE.md的定位不同。CLAUDE.md用于沉淀项目背景、技术栈和通用规范,会作为常驻上下文加载;Skill用于封装特定任务的过程性知识,只有匹配当前任务时才按需加载。
上面的clean-func由开发者通过斜杠命令主动触发。Skill也可以根据description由Claude自动判断是否调用。例如,Superpowers中的头脑风暴Skill会声明自身适用场景和执行流程,当任务语义匹配时,Claude即可自动加载并执行:

在Skill之外,Subagent会在独立的上下文窗口中执行主Agent委派的子任务,并将结果返回主会话,从而避免检索、分析等过程信息大量占用主会话上下文。这种机制适合将边界清晰的代码检索、专项审查等任务交给Subagent处理,让主Agent继续聚焦整体目标。
也因为二者之间的上下文隔离,近期主流的loop engineering最佳实践就利用这一机制,用一套不可修改、带批判性质的强制验收标准来审AI生成的代码,形成高效的开发与验收闭环。

例如:笔者在日常开发时,为确保功能能够准确的验收,在代码发布前都会通过5个性格迥异的agent在不同维度对代码进行审判和走查,而agent的创建规范也很简单,在.claude/agents以md格式编写即可:

以笔者的代码格式审查agent为例,可以看到agent的声明和skill类似,通过YAML frontmatter声明agent名称和信息描述,然后给出工具调用权限和使用模型。然后给定预期的职责和工作内容即可:
---
name: reviewer-cleancode
description: 按 Clean Code 原则走查可读性与可维护性
tools: Read, Grep, Glob
model: opus
---
你是只盯「Clean Code 可读性」的走查者,默认这段代码能跑但不够干净。逐条检查:
- 命名:是否见名知意,有没有无意义缩写、魔法数字/字符串。
- 函数:是否短小、单一职责,参数是否过多(超过 3 个),有没有布尔 flag 参数、隐藏的副作用。
- 嵌套:有没有深层 if 嵌套,能否用早返回(卫语句)拍平。
- 重复:有没有可提取的重复逻辑(DRY)。
- 注释:有没有用注释掩盖烂代码、注释掉的死代码、与代码不符的过期注释。
- 错误处理:有没有吞异常、用返回码代替异常。
每条写成「文件:行号 + 违反了哪条 + 怎么改」,只挑可读性与可维护性,不重复 bug 类问题。
hooks是拓展层唯一一个具有硬性约束的组件。如果说skills定义Claude做什么、怎么做,那么hooks就是判断Claude能不能做——它运行在推理之外,是绕不过去的硬约束。
hooks支持在不同的事件节点自动触发,常用的例如:
- 工具调用前(PreToolUse)
- 工具调用后(PostToolUse)
- 响应结束后(Stop,还可阻止Claude停止、强制继续)

开发者可以在这些加点插入,检查、拦截或者增强逻辑,例如:笔者为了能够直观的审查AI的代码,定义了git提交前的检查,通过PreToolUse强制约束git commit操作一次性不可以提交超过400行代码:
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "sh \"$CLAUDE_PROJECT_DIR/.claude/gate.sh\"",
"statusMessage": "commit 死规则闸门"
}
]
},
],
为什么Claude Code将Commands并入Skills
这道题需要先点破一个前提:Anthropic官方其实已经完成了这个合并。官方文档明确写道——“Custom commands have been merged into skills”,.claude/commands/deploy.md 和 .claude/skills/deploy/SKILL.md 现在都生成同一个 /deploy 指令、彼此等价,旧的commands文件向后兼容。
合并带来的便利很明显:skills成为唯一的扩展原语,通过 disable-model-invocation: true 这类frontmatter精确控制是否允许Claude自动触发,用户也可以随时用 /skill-name 主动调用,一套机制兼顾了原来commands(仅人触发)和skills(Claude自动判断)的全部场景。commands与skills支持相同的frontmatter,而skills进一步采用目录结构,可以携带脚本、模板和参考资料等支持文件。
从架构角度看,commands和skills本质上都在解决可复用工作流的封装问题。Claude Code将两套相近的概念统一到skills中,用一套抽象同时承载手动触发、Claude按需调用和支持文件。在笔者看来,这体现了Claude Code追求简洁架构的设计思路:能力可以继续扩展,但开发者不必同时理解两套相似机制,从而降低认知负担。按照当前版本,新工作流应优先封装为skills,commands仅作为存量兼容路径保留。
下图展示了commands与skills如何从两套工作流复用形式收敛为skills统一抽象,以及旧commands保留为兼容路径后的推荐用法:

集成层如何将Claude Code接入外部系统
集成层让Claude可以链接外部系统,使其真正融入开发生态中。headless就是关键所在,它使得Claude Code可以直接脱离交互终端,即通过-p参数并配合–output-format json输出规范格式,使得Claude Code可以无缝嵌入GitHub Actions、Jenkins、GitLab等CI/CD流水线中。
例如:我们希望在CI/CD流水线中审查开发者提交的代码,就可以在流水线中执行如下指令,对应的参数含义为:
- p:激活headless非交互模式,并传入本次要执行的提示词内容
- output-format:参数返回结果为JSON格式
- max-turns :Claude最大循环次数为10次
- allowed-tools:允许Read、Grep和Glob匹配上下文权限
# 在 CI/CD 流水线中调用 Claude Code
claude -p "审查最近一次提交的代码变更,重点关注安全隐患与性能问题" \
--output-format json \
--max-turns 10 \
--allowed-tools Read,Grep,Glob
除了由代码提交触发,Headless模式也可以由Cron定时调用,用于CPU、Memory和异常日志巡检。例如,下面的任务每小时执行一次Claude Code,通过已配置的监控MCP读取过去1小时的指标和日志,输出JSON巡检报告;出现异常时,报告中同时给出可能原因和排查建议:
0 * * * * cd /opt/ops && /usr/local/bin/claude -p "通过已配置的监控MCP读取过去1小时的CPU、Memory和异常日志,只做分析,不执行变更;输出异常指标、可能原因和排查建议" --output-format json --max-turns 8 --no-session-persistence --allowed-tools "mcp__prometheus__* mcp__elasticsearch__*" >> /var/log/claude-inspection.jsonl 2>&1
示例中的Claude可执行文件路径和MCP服务名需要替换为实际环境配置。生产环境应默认只开放读取监控指标和日志所需的工具,涉及重启、扩容或配置修改的操作仍需单独审批。

与之对应MCP又是一个维度的集成能力,它赋予Claude调用外部工具、获取外部数据的本领,如果说headless让Claude走出去,那么MCP就是让外部的能力走进来。无论是三方API还是GitHub issue亦或者其他三方带有MCP协议规范的工具,皆可直接打通。
以笔者为例,为保证Claude Code回答质量,对于一些无法直接印证的结果,笔者强制让Claude走deepwiki获取开源项目最新信息并构建文档,确保方案集成的准确性。
编程层如何通过Agent SDK封装工作流
编程层是让用户从使用者转为开发者的关键。开发者可以通过Python或者TypeScript调用Claude Agent SDK,将Claude Code封装成代码审查、巡检或者CI/CD工作流中的一个自动化节点。
下面用一个死锁审查案例说明最基础的编程方式:Python读取待审查的Java源码,将源码和审查要求一起传给Claude Code,最后从ResultMessage中取出审查结果并打印到终端。
待审查的DeadlockDemo.java如下所示,AService.aFunc和BService.bFunc分别持有自己的实例锁,再调用对方的synchronized方法,从而形成A→B和B→A的反向加锁顺序。
public class DeadlockDemo {
public static void main(String[] args) {
AService aService = new AService();
BService bService = new BService();
Thread aThread = ThreadUtil.newThread(() -> aService.aFunc(bService), "a-service-thread", false);
Thread bThread = ThreadUtil.newThread(() -> bService.bFunc(aService), "b-service-thread", false);
aThread.start();
bThread.start();
}
private static class AService {
private int count;
public synchronized void aFunc(BService bService) {
Console.log("[{}] 进入 AService.aFunc,已获得 AService 实例锁", Thread.currentThread().getName());
ThreadUtil.sleep(200);
Console.log("[{}] 调用 BService.func,开始等待 BService 实例锁", Thread.currentThread().getName());
bService.func(this);
count++;
Console.log("[{}] AService.aFunc 执行完成,count={}", Thread.currentThread().getName(), count);
}
public synchronized void func(BService bService) {
Console.log("[{}] 进入 AService.func,已获得 AService 实例锁", Thread.currentThread().getName());
}
}
private static class BService {
private int count;
public synchronized void bFunc(AService aService) {
Console.log("[{}] 进入 BService.bFunc,已获得 BService 实例锁", Thread.currentThread().getName());
ThreadUtil.sleep(200);
Console.log("[{}] 调用 AService.func,开始等待 AService 实例锁", Thread.currentThread().getName());
aService.func(this);
count++;
Console.log("[{}] BService.bFunc 执行完成,count={}", Thread.currentThread().getName(), count);
}
public synchronized void func(AService aService) {
Console.log("[{}] 进入 BService.func,已获得 BService 实例锁", Thread.currentThread().getName());
}
}
}
Python Demo只保留三个步骤:读取源码、调用query()、从ResultMessage.result读取最终结果。
import asyncio
from claude_agent_sdk import ResultMessage, query
async def review():
# 读取需要 Claude Code 审查的源码。
code = open("server/src/test/java/com/sharkchili/blog/DeadlockDemo.java").read()
# 把审查要求和源码一起交给 Claude Code。
prompt = f"""将以下 Java 代码当作生产代码审查,只指出一个最关键问题。
并按照如下格式输出:
问题:
论证:
改法(用diff的方式给出修改位置并注释说明):
{code}"""
async for message in query(prompt=prompt):
if isinstance(message, ResultMessage):
# ResultMessage.result 就是 Claude Code 的最终回答。
print(message.result)
asyncio.run(review())
完整执行流程如下:开发者运行脚本后,Python读取Java源码并组织Prompt,通过Claude Agent SDK调用Claude Code;Claude Code分析锁调用链,再将问题、论证和diff改法封装到ResultMessage中返回。

在项目中执行一键脚本,对应内容也就是执行上述py脚本:
python simple_deadlock_review.py
本次实际输出如下:
谈谈Harness在日常开发中的实践
Claude模型负责理解输入、推理并输出文本或工具调用请求,但它本身不会直接读取本地文件、执行命令或者调用外部系统。harness负责加载记忆、组织上下文、注入本次任务代码、控制权限、执行获准的工具调用,再将执行结果返回模型,形成持续迭代的agentic loop。
以前面的死锁审查为例,harness负责将项目记忆、上下文、权限规则和DeadlockDemo.java投喂给Claude;模型负责判断A→B和B→A的循环等待,并决定是否还需要调用工具。模型提出工具调用请求后,harness先完成权限校验,再执行获准操作并将结果继续返回Claude,直到模型输出问题、论证和diff改法。
若用公式来表达,即agent=harness+model,如果把模型比作引擎,harness则是围绕该引擎构建的一系列基础设施:
- 工具系统
- 权限控制
- 上下文管理
- 会话持久化
- 事件钩子
这个过程并不是模型一次性给出答案,而是harness持续为模型提供记忆、上下文、权限约束和工具执行结果,引导模型沿任务目标反复推理、判断和验证。模型提供推理能力,harness则像缰绳一样控制执行边界和推进方向。

工程实践中普遍观察到,harness的约束也是决定模型处理结果的关键,好的harness工程配置能明显提升模型在Terminal-Bench这类基准上的表现。
谈谈扩展层三类组件的触发机制
从架构关系看,扩展层建立在记忆层提供的稳定项目背景之上,但它不是继续向记忆层堆内容,而是把不需要每轮加载的知识、流程约束和复杂任务按需引入。
扩展层主要包含skills、hooks和subagents:
- skills:任务命中时才加载完整知识,既可以由用户通过
/skill-name显式触发,也可以由Claude根据描述自动触发。旧custom commands已经并入skills,不再作为独立架构组件。 - hooks:由工具调用、会话开始或者任务结束等系统事件触发,在模型推理之外执行固定检查和处理逻辑。
- subagents:由用户或者Claude发起任务委派,在独立上下文中执行复杂子任务,避免无关信息持续占用主上下文。
这三类组件分别体现了不同的架构设计思想:skills通过按需加载避免上下文膨胀,hooks将固定校验从概率性的模型推理中拆出,subagents通过上下文隔离实现职责拆分。记忆层负责建立稳定基线,扩展层则在需要时加载知识、施加约束或者拆分任务。
需要注意,hooks可以承担确定性的流程校验,但不能独立替代安全边界。高风险操作仍需通过permission deny、sandbox和资源侧鉴权限制实际权限。

日常开发中,如何选择Hooks、Skills和Subagents?三者如何组合?
- 对于需要独立上下文执行的领域任务,可以让Subagent加载对应Skill获取领域知识,再由Hooks对工具调用、文件范围和结果格式等可量化条件执行强制约束。例如,code-review Agent加载代码审查Skill工作,Hooks检查文件路径,禁止修改包含
prod的文件。

- Skill适合封装特定领域的知识、流程和工具。主Agent或Subagent命中Skill后,按统一步骤执行任务;Skill也可以根据任务需要委派Subagent完成专项工作。例如,线上排查Skill委派Subagent读取ES日志、分析错误,再将结论返回主会话。

- Hooks不仅可以拦截工具调用,还可以通过
additionalContext向Claude或Subagent补充检查信息;结合SubagentStart、SubagentStop或agent类型Hook,可以调度Agent审查工作进度,未达到要求时阻止结束并要求继续验证。

从架构上谈谈headless、skills与hooks如何组合
headless、skills和hooks不是互斥的三种组件。headless是通过claude -p运行Claude Code的非交互方式,skills负责按需提供任务知识和流程,hooks则在匹配的生命周期事件前后执行校验。一次headless代码审查可以同时使用三者:
- 通过
claude -p接收任务Prompt,以非交互方式启动本次任务。 - Claude理解任务,并判断完成任务需要哪些知识和操作。
- 如果任务命中skill,则按需加载完整任务指令;未命中时直接进入本轮判断。
- Claude根据当前任务发起Read、Grep或者Test等工具调用。
- 工具执行前触发匹配的
PreToolUseHook,完成前置校验。 - 工具执行后触发匹配的
PostToolUseHook,校验并记录执行结果。 - 工具结果返回Claude,Claude基于新证据判断任务是否完成;未完成时继续下一轮工具调用。
- 任务完成后,根据调用参数将结果格式化为text、JSON或者stream-json输出。

日常开发中,哪些操作可以使用hooks约束,哪些适合固化为skills
skills适合固化代码审查、发布检查、故障排查等标准工作流,它告诉Claude“这类任务应该按什么步骤完成”,但最终仍依赖模型理解和执行,属于软约束。hooks则运行在Claude推理之外,适合约束“绝对不能发生”的操作。
对于工具调用,可以在同步Hook中检查工具名和参数,校验不通过时,脚本通过exit 2或者JSON中的hookSpecificOutput.permissionDecision: "deny"拒绝,本次工具调用不会执行,所以对于可量化的约束,建议使用hooks进行约束:

以笔者个人开发为例,笔者希望Claude每次只提交一小批改动,确保可以直观审查核心代码,因此在.claude/settings.json中为Bash工具注册了PreToolUse Hook,即通过强制物理约束即每次提交不超过400行代码模型的推理行为:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "sh \"$CLAUDE_PROJECT_DIR/.claude/hooks/gate.sh\"",
"statusMessage": "commit 死规则闸门"
}
]
}
]
}
}
所有Bash调用都会先进入gate.sh,脚本再从tool_input.command中筛选git commit。命中提交操作后,它统计暂存区新增和删除的总行数;超过400行,或者发现删除@Test、新增@Disabled,就通过exit 2阻断提交并把原因反馈给Claude:
cmd=$(jq -r '.tool_input.command // empty')
case "$cmd" in
*"git commit"*) ;;
*) exit 0 ;;
esac
block() { echo "$1" >&2; exit 2; }
changed=$(git diff --cached --numstat | awk '{s+=$1+$2} END{print s+0}')
[ "$changed" -gt 400 ] && block "diff 太大($changed 行),拆小再提"
git diff --cached | grep -qE '^\+.*@Disabled|^-.*@Test' \
&& block "动了测试,人工确认前不许提"
Hook是Claude Code执行链内的硬约束,但不能替代资源本身的权限控制,更稳的做法是继续按最小权限设计:给Claude使用的数据库账号只开放查询权限和必要表权限;Git主分支设置为受保护分支,必须通过CI和人工审核才能合并。这样即使Hook漏配或脚本失效,危险操作仍然无法真正落地。
小结
从分布式架构到AI架构,核心都是用确定的工程手段管理系统中的不确定性。这两条线,笔者会在后续系列里继续往深挖。
SharkChili · 禅与计算机程序设计的艺术
开源项目
- mini-redis:笔者用Go从零手写的教学级Redis,适合对着源码把缓存与网络底层一行行啃透,欢迎Star和交流 · https://github.com/shark-ctrl/mini-redis
如果想进一步交流,欢迎关注笔者的公众号 写代码的SharkChili ,发送关键字 【加群】 添加笔者好友、进技术交流群。
参考
ZGC垃圾回收对于CPU核数与内存大小的要求:https://www.php.cn/faq/2719654.html
《Claude Code实战 Harness工程之道》
更多推荐



所有评论(0)