250个AI智能体,塞进8个Pod——这个项目刚开始立项时,我们内部开玩笑说这就是个“Agent大通铺”:一个Pod里塞进几十个AI智能体,资源共享、任务分着干。别人做Agent平台,通常是“一号一Pod”或“一号一容器”,每个智能体独立部署,资源确实充裕,但250个Agent就意味着250个Pod、250份冗余进程,K8s资源开销直接失控。我们这次偏偏反着来,在8个Pod里跑250个Agent,还要保证响应速度、稳定性、可观测性都不拉胯。
这篇文章不是我写产品PR,也不是教程式PPT,而是把这套方案从设计、踩坑到压测的完整复盘。适合正在做AI Agent平台、多智能体协作系统,或者说服务化Agent部署的工程师参考;哪怕你只是刚接触K8s,也能从里面get到Pod资源规划、ConfigMap配置管理和Agent进程编排的基本盘。
1. 项目背景与整体设计思路
1.1 “大通铺”模式:把一个Pod当一间共享宿舍
传统做法是每个Agent独立占一个容器或一个Pod,比如企业客服Agent、文档解析Agent、代码辅助Agent各跑各的。这个思路清晰,隔离也好,但到250个Agent规模时,成本和资源浪费肉眼可见地失控:每个容器里都有一份Python解释器、一堆模型依赖库、一个常驻HTTP框架,空闲时占着内存不干活,高峰期又都挤在一起抢CPU。
我们换个思路:把Pod当一间共享宿舍。一个Pod就是一个小型计算单元,里面塞多个Agent进程,共享同一份基础镜像、同一个网络栈、同一个生命周期。这样做的好处非常直接:镜像只拉一次、Pod数量从250降到8、内存通过进程级复用大幅压下来。代价是隔离性差一些,某个Agent崩了可能波及其他Agent——所以后面必须用进程管理和可观测性来兜底。
我见过不少团队盲目模仿“一个Pod多Agent”,结果把几十个线程全部塞到一个Python进程里,出现一个Agent的全局锁拖垮所有Agent的惨案。所以这个大通铺不是简单堆数量,而是“进程级别隔离 + 线程级别共享”:每个Agent是一个独立进程,各自内存隔离,互相通过消息队列通信,宿主的Pod只负责资源配额和生命周期。
1.2 250个Agent的职责拆解:先分类再谈编排
250个Agent不是250个复制品。我们当时把这些Agent按职责分成了几类,这也决定了下面Pod的划分逻辑。
短平快的工具型Agent:查天气、查库存、做翻译、格式化数据,这类任务平均不到1秒,并发高但每个请求消耗资源小。长文本理解型Agent:会议纪要点提取、合同条款分析、文章总结,单次调用可能要读几万字上下文,内存占用大、推理耗时久。多轮对话型Agent:客服、销售助手,需要维护会话状态,上下文窗口不断叠加,容易形成内存增长。编排型Agent:负责调度其他Agent,像是“老板”,自己不直接干活,但会派活。
如果不分类直接均分,很容易出现某个Pod里全是长文本Agent,高峰期内存被打爆;另一个Pod里全是短任务Agent,CPU闲置。经验是:按任务特性分Pod,而不是按编号均分,这是Agent大通铺架构里第一个要做的决策。后续所有资源规划、滚动更新策略、压测瓶颈分析,都建立在这个分类基础上。
1.3 为什么是8个Pod:数量不是拍脑袋定的
8这个数字不是玄学。当时我们根据三类因素算出来的:单Pod里的Agent密度上限、容错要求、以及运维成本。
先看密度上限。单个Pod里进程数量不是无限的:每个Agent进程至少有一个HTTP客户端连接池、日志文件句柄、若干线程,操作系统默认的文件描述符限制(通常1024)很容易被吃满。我们测试过,通用配置下单个Pod塞30个Agent比较稳,超过35个就开始出现连接复用失败、端口耗尽这类问题。如果优化了系统参数和连接池,可以到45个左右,但没必要为极限值牺牲稳定性。
再看容错要求。8个Pod意味着单Pod故障时,损失约12.5%的Agent产能,这类损失可以通过其他Pod临时接管任务来对冲,不需要3个副本的冗余。如果只做4个Pod,单点故障影响面太大;如果做16个Pod,密度太低,又退回到“大Pod小用”的老路。8个是我们的平衡点。
资源侧的计算放到第3章细说,这里先给结论:8个Pod,250个Agent,平均每个Pod约31个Agent,分布均匀后,CPU和内存都留有20%以上的buffer。
2. 核心细节解析与关键技术实现
2.1 Agent运行时容器:基础镜像与进程管理
大通铺的核心是容器内进程编排。我们统一选择了Python 3.11-slim作为基础镜像,而不是完整版Anaconda镜像——后者动辄几个GB,拉取一次要几分钟,启动还要导入一堆用不到的包。镜像能瘦则瘦,我们的最终镜像控制在了900MB以内,包含了常用的HTTP客户端、向量检索库、结构化输出解析库,还算能接受。
每个Agent进程用supervisor统一托管,而不是裸启动一堆python agent.py后台任务。Supervisor在这套架构里好处明确:能自动拉起崩溃的Agent、统一管理标准输出、控制并发启动顺序。配置大概长这样:
[program:agent_weather] command=python /app/agents/weather_agent.py --role weather directory=/app/agents autostart=true autorestart=true startsecs=5 stopsignal=INT不过supervisor也有局限,它只管进程拉起,管不了“哪个Agent是否已经注册到调度中心”。所以我们在应用层做了一次心跳上报,Agent启动后先向Redis写入一条注册信息,再由Pod里的健康检查端汇总,这个后面会说。
2.2 Pod内Agent调度与并发控制:信号量才是主角
250个Agent并发跑任务,第一反应可能是“每个Agent开一个线程池”,错了。我们踩过这个坑:早期版本里每个Agent进程起10个线程,结果Pod里250个线程同时抢解释器锁和数据库连接,吞吐没上去,CPU倒是打满了。
后来改成统一的事件循环(asyncio)加全局信号量。所有任务进入一个Redis Stream队列,消费端按agent_type分发到具体Agent的逻辑,但同一时刻活跃的Agent数量由信号量控制。例如一个Pod里有31个Agent,允许同时处理的任务上限设为24,剩下的额度给请求排队和状态收集留余量。这样做的好处是削峰填谷,不会出现所有Agent同时被塞满任务然后集体超时。
真正决定并发上限的不是Agent数量,而是Pod的资源配额、下游模型API的RPS限制、以及数据库连接池大小。我们当时的经验公式是:Pod并发上限 = min(Agent数量, CPU核数 x 4, 下游API RPS限制 / Pod数)。这个公式看起来简单,但能避免很多“看起来配置没问题但一压测就崩”的场景。
2.3 ConfigMap与Deployment:250个Agent的配置管理
250个Agent肯定不能把配置写死在镜像里,否则改一个Prompt都要重新构建镜像。我们的做法是:所有Agent的元信息、系统提示词、模型参数、工具开关,全部放到ConfigMap里。
apiVersion: v1 kind: ConfigMap metadata: name: agent-catalog data: agent_list.json: | [ {"name": "weather-agent", "role": "tool", "model": "qwen-plus", "temperature": 0.1, "max_context": 4096}, {"name": "contract-agent", "role": "longtext", "model": "qwen-max", "temperature": 0.0, "max_context": 32000}, {"name": "orchestrator", "role": "router", "model": "qwen-plus", "temperature": 0.3, "children": ["weather-agent", "contract-agent"]} ]Deployment里通过configMapRef把它挂到每个Pod的/etc/agents/agent_list.json,启动脚本读取这个文件,动态拉起对应的Agent进程。这样改配置只需kubectl apply新的ConfigMap,Pod内部监测文件变化后热加载,不用重启整个Deployment。
为什么用Deployment而不是StatefulSet?因为250个Agent共享同一套配置模板,没有固定的网络标识需求,Deployment的滚动更新和随机Pod名足够;至于Agent的身份、会话状态,放到外部的Redis和对象存储里,不依赖Pod的持久化。这是我比较推崇的模式——Agent应该是“无状态进程 + 有状态存储”,否则任何Pod重建都会导致状态错乱。
滚动更新时还遇到一个坑:默认maxUnavailable策略是25%,也就是8个Pod中最多同时下线2个。但Agent重启需要重新注册,如果下线速度太快,线上注册表会瞬间少一批Agent,导致编排型Agent派活失败。我们把maxUnavailable调成1,maxSurge调成1,确保至少7个Pod在线,秒级损失可控。
2.4 对外暴露与负载均衡:入口不能直连Pod
还有一个关键设计是流量入口。250个Agent不是外部直接调用的——对外只暴露一个统一网关,网关根据任务类型路由到对应Pod里的对应Agent。如果外部直接打Pod,负载会倾斜,比如某个热点Agent集中在同一个Pod,单个Pod被打挂后整个Agent类型就瘫痪了。所以我们在网关层做了二级路由:先按agent_type选Pod,再在Pod内部路由到具体进程。
这里补充一个我后来才悟到的点:大通铺架构下,Pod不是服务单元,整个Agent集群才是服务单元。所有对外能力、弹性伸缩、故障转移,都要放在集群维度考虑。Pod只是承载进程的木桶,而不是服务的边界。
3. 实操过程:从0到1搭建这套系统
3.1 资源预估与Pod规格计算
我不喜欢拍脑袋定资源,所以这里把我们的计算过程完整列出来,照着改就行。
当时250个Agent的模型调用全部走云端API,所以Pod本身不需要GPU,只需要CPU、内存和网络带宽。真正的内存大头是三个地方:Agent进程自身的Python运行时(约80MB)、上下文窗口缓存(长文本型Agent尤其大,单进程轻松到500MB)、以及向量检索的本地索引(视规模而定,一般每个Agent预留100MB)。短任务型Agent平均120MB,长文本型Agent平均600MB,编排型Agent平均200MB。
按比例加权,250个Agent的稳定内存需求约45GB。为了保证波动空间,我们按峰值需求的1.3倍预留,也就是约58.5GB。分摊到8个Pod,每个Pod的requests.memory设为6Gi,limits.memory设为7Gi,保证单Pod内即使某个Agent异常膨胀,也有1Gi缓冲区给K8s做驱逐决策的时间。
CPU这边:250个Agent,按每个平均消耗0.2核、高峰期1.5倍超卖计算,需要约75核。但实际任务大多阻塞在外部模型API返回和网络IO上,CPU利用率没有线性增长。我们实测下来40核配额(每个Pod 5核)即可稳定运行,预留配额做弹性,避免限额卡脖子。看下面的表格:
| Pod | Agent数 | requests.memory | limits.memory | requests.cpu | 主要Agent类型 |
|---|---|---|---|---|---|
| pod-agent-0 | 32 | 6Gi | 7Gi | 5 | 短任务工具型 |
| pod-agent-1 | 30 | 6Gi | 7Gi | 5 | 短任务工具型 |
| pod-agent-2 | 31 | 6Gi | 7Gi | 5 | 多轮对话型 |
| pod-agent-3 | 31 | 6Gi | 7Gi | 5 | 多轮对话型 |
| pod-agent-4 | 30 | 6Gi | 7Gi | 5 | 长文本理解型 |
| pod-agent-5 | 31 | 6Gi | 7Gi | 5 | 长文本理解型 |
| pod-agent-6 | 33 | 6Gi | 7Gi | 5 | 编排+回调 |
| pod-agent-7 | 32 | 7Gi | 8Gi | 5 | 混合兜底 |
注意最后一行多给了1Gi内存,它承担了兜底任务,允许调度系统临时把其他Pod溢出的任务迁过来。这是我的个人习惯:永远留一个“脏活Pod”,专门接突发流量和故障转移,其他地方能少则少。
3.2 Deployment与ConfigMap配置示例
直接给一份可以抄的Deployment片段。
apiVersion: apps/v1 kind: Deployment metadata: name: agent-mesh spec: replicas: 8 strategy: rollingUpdate: maxUnavailable: 1 maxSurge: 1 selector: matchLabels: app: agent-mesh template: metadata: labels: app: agent-mesh tier: agent-runtime spec: containers: - name: agent-runtime image: registry.internal/agent-runtime:2.3.1 imagePullPolicy: IfNotPresent env: - name: POD_INDEX valueFrom: fieldRef: fieldPath: metadata.name - name: AGENT_CATALOG_PATH value: /etc/agents/agent_list.json - name: REDIS_DSN valueFrom: secretKeyRef: name: infra-secret key: redis-dsn volumeMounts: - name: agent-config mountPath: /etc/agents readOnly: true ports: - name: metrics containerPort: 9100 readinessProbe: httpGet: path: /ready port: metrics initialDelaySeconds: 20 periodSeconds: 10 resources: requests: cpu: "5" memory: 6Gi limits: cpu: "5" memory: 7Gi volumes: - name: agent-config configMap: name: agent-catalog这里有个细节:POD_INDEX通过fieldRef取Pod名,是因为启动脚本要根据Pod序号来决定加载哪一份Agent分组配置。ConfigMap里的agent_list.json是所有Agent的汇总,脚本根据Pod序号截取自己负责的那一段,这样不用为每个Pod单独写不同的ConfigMap。
readinessProbe打到/ready接口,这个接口返回的是“当前Pod内已注册Agent数 / 期望Agent数”。只有当注册数量达到期望值的90%以上,Pod才被标记为Ready,进入负载均衡池。这个设计直接解决了“Pod起来了但Agent还没就绪就被派活”的问题。
3.3 启动验证与压测实测
启动时有个明显的顺序要求:先确认Redis和网关服务可用,再部署Deployment。我们踩过一个坑:第一次直接部署Deployment,8个Pod同时启动,Redis连接数瞬间被打满,大量Agent注册失败,然后Readiness探针一直不通过,Pod被不断重建,越重建越乱。
解决方法是两个:一是把批量启动改成批次启动,通过K8s的initContainers先探活Redis;二是在启动脚本里给每个Agent注册加随机延迟(sleep random(1~3)),避免250个注册请求同时到达。
压测结果也很有参考价值。我们用500、1000、2000并发请求各压了20分钟,数据如下:
| 并发量 | 成功率 | P99延迟 | 单Pod最大CPU | 单Pod最大内存 |
|---|---|---|---|---|
| 500 | 99.98% | 1.5s | 78% | 5.8Gi |
| 1000 | 99.92% | 2.3s | 86% | 6.4Gi |
| 2000 | 98.76% | 4.1s | 95% | 7.2Gi |
2000并发时已经能看到部分Pod触及内存limit,有轻微驱逐风险,所以线上建议把业务请求控制在1500并发以内。如果要扩容,不要简单加Pod副本,而是先看是哪种类型的Agent成为瓶颈——比如长文本型Agent打满,那应该只扩容负责长文本的那两个Pod,而不是全量扩容。
4. 常见问题与排查技巧实录
4.1 内存泄漏与OOM:AI智能体最典型的慢性病
这个项目上线一周后,我最先遇到的就是OOMKilled。现象是Pod重启频率忽高忽低,看kubectl describe pod发现被系统杀掉的进程集中在几个长文本Agent上,内存一直涨到limit。
排查思路分三步:先看是不是上下文缓存问题。Agent每次对话会把历史消息攒在内存里,如果用户多轮问答不清理,内存只增不减。我们在Agent层增加max_context_len的截断机制,超过长度就做摘要压缩,而不是硬塞更多token。
再看是不是工具调用链路里的文件句柄泄漏。有个Agent频繁调用本地文档解析,每次打开文件后没有关闭句柄,导致内存和文件描述符同步上涨。用lsof -p排查时,看到数百个残留的文件句柄,修复后内存立刻平稳。
最后是Python GC问题。为了赶进度,早期代码里用了一些“临时列表存全量结果”的写法,对象一多GC就跟不上了,内存碎片化严重。改成流式处理,一次只保留一个结果对象,GC压力小了很多。经验就是:大通铺里边角料泄漏会放大250倍,单Agent看起来几十MB的泄漏,乘上数量就是灾难。
4.2 冷启动慢与注册失败
250个Agent同时冷启动,第一次实际跑下来花了近5分钟。原因不是K8s拉镜像,而是启动时每个Agent都要初始化模型客户端、加载本地轻量词表、然后逐个注册。我们的优化:用initContainers预下载依赖和配置,把公共词表做成只读层挂载,Agent真正启动时只需读取本地文件;注册从同步改为异步重试,失败后指数退避。
还有一个我觉得特别实用的技巧是:把启动日志分两段看。第一段是基础设施初始化,第二段才是Agent业务注册,这样当启动慢时能一眼定位是卡在哪一段。我们后来把每段的耗时都输出到结构化日志,配合Prometheus的agent_boot_duration_seconds直方图,基本不用再猜。
4.3 日志爆炸与可观测性:250个Agent的日常怎么管
250个Agent同时打日志,不夸张地说,一天的原始日志就有好几个GB。如果按默认格式2025-... INFO xxx这样打,排查问题基本靠眼睛找,纯纯的灾难现场。
我们把所有日志改成JSON结构化输出,每一条都带agent_id、task_id、model、latency_ms、status_code:
{"ts": "2025-06-01T10:23:11.882Z", "level": "INFO", "agent_id": "contract-agent-07", "task_id": "t-8839201", "model": "qwen-max", "latency_ms": 1830, "status_code": 200, "msg": "contract clause extracted"}这样排查问题时,直接grep '"agent_id":"contract-agent-07"' | grep '"status_code":500'就能拿到单一Agent的完整失败链路。我们还在Pod内做了日志轮转(单个文件100MB切分),再由轻量采集器汇总到Loki,元数据和K8s标签关联,查询效率高了不少。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决措施 |
|---|---|---|
| Pod反复OOMKilled | 长文本Agent上下文缓存膨胀 | 设置max_context_len,超限摘要压缩 |
| Agent进程启动即崩 | 配置缺失或模型API认证失败 | 先单独跑python agent.py,确认环境变量 |
| Pod Ready很慢 | 250个Agent并发注册打满Redis | 启动时加随机延迟,注册改异步重试 |
| 压测时单个Pod延迟暴涨 | 网关层未做二级路由,请求全打到一个Pod | 按agent_type分配Pod,入口层加柔和权重 |
| 更新ConfigMap后Agent不生效 | 进程没有监听配置变更 | 启动脚本轮询文件hash,变化后热加载 |
| Agent间状态错乱 | 全局共享一个内存对象 | 状态强制隔离,会话状态全部外置到Redis |
| 滚动更新后注册数下降 | maxUnavailable过大 | 调整为1,确保至少7个Pod在线 |
| 响应不稳定,偶发超时 | 信号量设置过小,任务排队过多 | 按公式min(Agent数, CPU核数*4, API RPS/Pod数)调整 |
如果只说一条经验,我推荐先做“稳定性底线设计”:无论Agent多智能,先把崩溃回收、注册重试、流量熔断这三件事做成默认能力。这三件事做完,大通铺里住多少Agent都不慌。
最后说点我个人真实的感受。这个项目做到后面,我最大的体会不是K8s技巧有多重要,而是250个Agent的规模,会让任何“侥幸”都被放大:一个Agent的内存泄漏看起来不起眼,250个就是OOM;一个Agent的注册失败看起来是偶发,250个同时启动就是雪崩。我们最后把Agent当成“住在同一个宿舍里的人”来管理:每个人都有独立床位(进程隔离),有公共厨房(共享依赖),有事找宿管(编排Agent)——这套比喻听起来很糙,但确实帮我把架构讲清楚了,也让后续加Agent时团队能快速对齐。
还有个扩展方向我可以直接推荐:如果业务继续涨,不要盲目把250变成500,先拆一层“Agent区域”——也就是把Agent按业务域分成几个小型大通铺,再用一级网关做区域路由,这样故障爆炸半径会更小,人也更容易维护。这套玩法我还在迭代中,等跑完几个月再分享第二批数据。