Agent 容器化:镜像分层、密钥注入、健康检查

2 阅读7分钟

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,连接被拒。

三种解法:

  1. 应用层重试(推荐):连不上退避重试三次。不依赖编排行为,换 k8s 也不用改。
  2. condition: service_healthy:等依赖容器健康检查通过。要求 redis 也定义 healthcheck。
  3. 入口脚本等端口: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 的话,它某天更新了版本,你的分数变化可能来自模型,不是代码。不记录历史数据全作废。

十、结论

这五句话,从容器化一路走到运营:

  1. Dockerfile 指令顺序决定构建速度。 变化频率低的放前面。
  2. 多阶段构建省体积也省攻击面。 运行镜像里不该有编译工具链。
  3. hash 分桶保证稳定,不保证均匀。 小流量下 5% 会有几个人的偏差。
  4. A/B 先算 z 再看提升。 而且算完 z 还要检查两组构成是否可比。
  5. CI 要有噪声带。 小于噪声的变化不算变化。

最后一句:容器化解决"能不能跑",运营闭环解决"跑得好不好"。

而所有"用数据决策"的环节,都要先问一句:这个数据可信吗。