Agent 容器化:镜像分层、密钥注入、健康检查
容器化一个 Agent 服务,三件必须做对的事:
多阶段构建、健康检查、密钥不落镜像。
但真正决定你日常开发体验的,是第四件事:Dockerfile 里指令的顺序。
这篇讲容器化的四个要点,然后讲上线之后三个"用数据决策"的陷阱。
一、指令顺序决定构建速度
先看两版 Dockerfile。
版本 A(正确顺序):
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY . .
版本 B(错误顺序):
COPY . .
RUN pip install --user --no-cache-dir -r requirements.txt
Docker 的每条指令是一层,层没变就复用缓存。
版本 A:改一行业务代码,requirements.txt 没变 → 装依赖那层命中缓存 → 只重做 COPY . .。构建几秒。
版本 B:改一行业务代码 → 第一层变了 → 后面所有层的缓存失效 → 重新装一遍依赖,几分钟。
原则一句话:变化频率低的放前面,变化频率高的放后面。
requirements.txt 一个月改一次,业务代码一天改十次。
这个顺序差异,在一个团队里会被放大成每天几十分钟的等待。
二、多阶段构建省的不只是体积
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
COPY . .
两个收益。
体积。 单阶段构建带编译工具链,轻松上 1GB。多阶段只拷装好的包,400MB 左右。镜像小,push/pull 快,节点盘占得少。
攻击面。 运行环境里没有 gcc、没有 pip。即便有人拿到 shell,也没法现场编译恶意程序。
--no-cache-dir 是配套的——pip 缓存能白胖两三百兆,运行时完全用不到。
三、健康检查:容器"活着"和"能干活"是两件事
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request;urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
没有探针,编排系统只知道"进程还在"。它可能卡在死循环、连不上数据库、内存泄漏到快 OOM——但容器状态还是 running,流量继续打进来。
用 python 而不是 curl 检查。 python:3.11-slim 里不保证有 curl,但一定有 python。少一次依赖。
depends_on 只保证顺序,不保证就绪
depends_on:
- redis
它保证 redis 容器先启动,不保证 redis 已经能接受连接。
redis 启动要 3 秒,agent 1 秒就起来了。第一个查询打到 redis,连接被拒。
三种解法:
- 应用层重试(推荐):连不上退避重试三次。不依赖编排行为,换 k8s 也不用改。
condition: service_healthy:等依赖容器健康检查通过。要求 redis 也定义 healthcheck。- 入口脚本等端口:
while ! nc -z redis 6379; do sleep 1; done。
四、密钥只走运行时注入
environment:
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
${} 是 compose 从宿主机环境或 .env 读取,不写进镜像。
两个错误写法:
ENV API_KEY=xxx—— 写进镜像层,docker history能看到COPY .env .—— 更糟,连文件都在里面
镜像会被 push、被 pull、被缓存、被别人的机器留下。任何写进去的东西都等于公开。
五、灰度:hash 分桶保证稳定,但不保证均匀
def assign_group(user_id, percent):
h = int(hashlib.md5(user_id.encode()).hexdigest()[:8], 16)
return (h % 100) < percent
为什么不用随机数?因为同一用户必须始终落同一组。 否则刷新一下跳到另一个版本。
实测:
200 用户 → 灰度组 13 人 / 稳定组 187 人(5% 应约 10 人)
稳定性:user_0000 两次路由一致 ✅
放量 50% → 灰度组 95 人(应约 100 人)
稳定分桶通过。但 13 vs 10、95 vs 100 这两个偏差值得算一下。
200 人分 100 个桶,平均每桶 2 人。二项分布:期望 10,标准差 √(200×0.05×0.95) ≈ 3.1。
13 落在 ±1 个标准差以内,正常。
但它说明:小流量下,5% 这个比例本身不精确。
要精确控制比例,两个办法:
- 加大桶数(
% 10000),粒度细两个数量级 - 按用户总量排序切分(但新用户加入会打乱分组)
六、A/B:48.3% 的提升,要先算 z
实测:
A 组:198 人,转化率 0.424
B 组:202 人,转化率 0.629
相对提升:+48.3%
def ab_test(p1, n1, p2, n2, z_crit=1.96):
se1 = math.sqrt(p1 * (1 - p1) / n1)
se2 = math.sqrt(p2 * (1 - p2) / n2)
se_diff = math.sqrt(se1**2 + se2**2)
diff = p2 - p1
z = diff / se_diff
return {
"abs_lift": round(diff, 3),
"z": round(z, 2),
"ci_95": (round(diff - z_crit*se_diff, 3),
round(diff + z_crit*se_diff, 3)),
"significant": abs(z) > z_crit,
}
>>> ab_test(0.424, 198, 0.629, 202)
{'abs_lift': 0.205, 'z': 4.19, 'ci_95': (0.109, 0.301), 'significant': True}
z=4.19,p<0.0001,绝对值涨 20.5 个百分点。统计上没问题。
但两组真的可比吗
A 组 198、B 组 202——分配就不均匀(应该各 200)。
而上一节刚证明:同一个分桶机制,200 人规模下会有 3 到 5 个人的偏差。
那这 4 个人的差异意味着什么?
意味着 A/B 两组的人员构成可能不是随机的。
如果 B 组恰好新用户比例更高(新用户转化率通常更高),那 20.5 个点里有相当一部分是"用户本身不同"造成的,不是 Prompt 变好。
同一个分桶机制在灰度那里暴露了不均匀,在 A/B 这里就不能假设它均匀。
三个验证动作
一,看两组的基础特征。
def check_balance(a, b, key):
ra = sum(1 for u in a if u[key]) / len(a)
rb = sum(1 for u in b if u[key]) / len(b)
return {"gap": round(abs(ra - rb), 3)}
gap 超过 0.05 要警惕。
二,看分桶是否稳定。 这次验证了。
三,看提升的绝对值合不合理。
20.5 个百分点的转化率提升,在真实客服场景里是异常大的。
我见过的真实 A/B,5 到 10 个点就算很成功。20 个点通常意味着:分组不均、埋点口径变了、或两组流量来源不同。
当结果好得不像真的,第一反应应该是查分组,不是庆祝。
不查分组会怎样
假设真实情况是:B 组的新用户比例比 A 组高 15 个百分点,而新用户转化率天然高 8 个点。
那 20.5 个点的提升里,有 1.2 个点是"新用户带来的",剩下 19.3 才是 Prompt 的效果。
单看总体看不出这个拆分。但按新老用户分层再看一次,两组各自内部的提升可能只有 15 个点。
分层看,是拆解"这个提升来自哪"的唯一办法。
七、反馈闭环:用户是免费的标注员
上线之后,用户每条反馈都是不要钱的人工标注。
实测分布:
{'好评': 2, '差评': 1, '投诉': 2, '建议': 1}
四类要分开处理:
| 类型 | 怎么用 |
|---|---|
| 好评 | 统计"哪些回答被夸",反哺成 Prompt 示例 |
| 差评 | 归类,找具体错在哪一层 |
| 投诉 | 高频痛点提炼,补进知识库 |
| 建议 | 单独看,可能是新需求 |
关键是按内容 hash 去重。
同一条痛点被 20 个用户提,只应该入库一次。否则知识库里全是重复条目,检索时互相挤占 Top-K 名额,反而降低召回质量。
seen = set()
def add_knowledge(text):
h = hashlib.md5(text.encode()).hexdigest()
if h in seen:
return False
seen.add(h)
knowledge_base.append(text)
return True
八、归因:不知道出在哪一层,就只能瞎改
出问题不可怕,可怕的是不知道修哪。
| 症状 | 归到哪一层 | 怎么修 |
|---|---|---|
| 编造价格 | 作答层 | 改 RAG / 价格口径 |
| 路由错 | 路由层 | 补词表 |
| 护栏漏 | 护栏层 | 补正则 |
| 答不上 | 知识层 | 补知识 |
| 工具错 | 工具层 | 查工具实现 |
归因的价值是让修改变得可预测:
知道是路由层,就不用翻 Prompt;知道是知识层,就不用查代码。
没有归因,所有的修都是"试试看"——试对了不知道为什么对,试错了也不知道错在哪。
九、CI 门禁的噪声带
def ci_gate(current, baseline, threshold=0.02):
diff = current - baseline
if diff < -threshold:
return False, f"🚫 退化 {diff:.3f}"
if diff > threshold:
return True, f"📈 进步 +{diff:.3f}"
return True, f"➖ 持平({diff:+.3f},在噪声内)"
threshold=0.02 是噪声带。小于 2 个点的变化不算变化——评测本身就有波动(样本量的影响)。
还有一条容易忽略:记录这次评测用的模型版本。
服务背后是外部 API 的话,它某天更新了版本,你的分数变化可能来自模型,不是代码。不记录历史数据全作废。
十、结论
这五句话,从容器化一路走到运营:
- Dockerfile 指令顺序决定构建速度。 变化频率低的放前面。
- 多阶段构建省体积也省攻击面。 运行镜像里不该有编译工具链。
- hash 分桶保证稳定,不保证均匀。 小流量下 5% 会有几个人的偏差。
- A/B 先算 z 再看提升。 而且算完 z 还要检查两组构成是否可比。
- CI 要有噪声带。 小于噪声的变化不算变化。
最后一句:容器化解决"能不能跑",运营闭环解决"跑得好不好"。
而所有"用数据决策"的环节,都要先问一句:这个数据可信吗。