容器化部署:Docker 镜像构建与上线配置
AgentScope 2.0 容器化部署:Docker 镜像构建与上线配置
上一篇把服务跑起来了,但还只是个"在我机器上能跑"的脚本。
这一篇讲三件事:容器化(让别人也能跑)、灰度(新版不炸)、A/B(用数据决定切不切)。
代码纯标准库、离线零成本。Docker 相关部分以文件内容的形式输出,不实际构建镜像。
一、环境
python 014_cloud_ops.py
灰度、A/B、反馈、归因、CI 全是纯规则和统计,不需要 Key,不需要 Docker。
二、多阶段构建
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 . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
四个要点。
两阶段。 builder 阶段装依赖,运行阶段只拷装好的包。运行镜像里没有 pip、没有 gcc、没有任何编译工具链。
镜像更小。 python:3.11-slim 基础镜像约 120MB,装完依赖可能到 400MB。单阶段构建带编译工具链的话,轻松上 1GB。
攻击面更小。 运行环境里没有 gcc,意味着即使有人拿到 shell,也没法现场编译恶意程序。
--no-cache-dir 必须加。 pip 的缓存能让镜像白胖两三百兆,而且运行时完全用不到。
指令顺序决定构建速度
这一条经常被忽略,但它决定了你每次改代码要等多久。
Docker 的每条指令是一层,层没变就复用缓存。
看上面那个顺序:
COPY requirements.txt . # 层 1
RUN pip install ... # 层 2
COPY . . # 层 3
你改一行业务代码,层 1(requirements.txt 没变)和层 2(装依赖)都命中缓存,只有层 3 重做。构建时间几秒。
如果反过来写:
COPY . . # 层 1:所有代码
RUN pip install ... # 层 2:装依赖
改一行代码,层 1 变了,层 2 的缓存也失效——每次都要重新装一遍依赖,几分钟。
原则很简单:变化频率低的放前面,变化频率高的放后面。
requirements.txt 一个月改一次,业务代码一天改十次。所以前者在前。
三、健康检查
services:
minchen-agent:
build: .
ports:
- "8000:8000"
environment:
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
- REDIS_URL=redis://redis:6379
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request;urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
depends_on:
- redis
redis:
image: redis:7-alpine
restart: unless-stopped
三个细节。
健康检查用 python 而不是 curl。 python:3.11-slim 里不保证有 curl,但一定有 python。用 python 检查少一次依赖。
depends_on 只管启动顺序,不管就绪状态。 它保证 redis 容器先启动,但不保证 redis 已经能接受连接。真正的就绪要靠应用层重试,或者用 condition: service_healthy。
密钥走环境变量。 DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY} 里的 ${} 是 compose 从宿主机环境或 .env 文件读取,不会写进镜像。
ENV API_KEY=xxx 和 COPY .env . 都是错的。镜像会被 push、被 pull、被缓存,任何写进去的东西都等于公开。
depends_on 的坑
depends_on: [redis] 只保证启动顺序,不保证就绪状态。
redis 启动要 3 秒。agent 容器 1 秒就起来了,/health 也可能返回 200(因为它的健康检查只看自己)。然后第一个查询打到 redis,连接被拒。
三种解法:
一,应用层重试。 连不上就退避重试三次。最健壮,因为不依赖编排行为。
二,condition: service_healthy。 compose 支持等依赖容器的健康检查通过再启动。但要求 redis 也定义了 healthcheck。
三,入口脚本等端口。 启动前 while ! nc -z redis 6379; do sleep 1; done。
我优先用第一种。因为重试逻辑在应用里,换编排工具(compose → k8s)也不用改。
四、灰度:hash 分桶
def assign_group(user_id, percent):
"""按 user_id 的 hash 分桶。返回 True 表示进灰度组。"""
h = int(hashlib.md5(user_id.encode()).hexdigest()[:8], 16)
return (h % 100) < percent
为什么用 hash 而不是随机数?
因为同一个用户必须始终落在同一组。 用 random() 的话,用户刷新一下可能跳到另一个版本,体验直接崩。
实测:
200 用户 → 灰度组 13 人 / 稳定组 187 人(5% 应约 10 人)
稳定性:user_0000 两次路由一致 ✅
放量到 50% → 灰度组 95 人(应约 100 人)
稳定分桶验证通过。但 13 vs 10、95 vs 100 这两个偏差值得说。
这个偏差是数学,不是 bug
200 个用户、100 个桶,平均每桶 2 人。
二项分布:期望 10,标准差 √(200 × 0.05 × 0.95) ≈ 3.1。
13 人落在 ±1 个标准差以内,完全正常。
但它说明一件事:小流量下,5% 这个数字本身不精确。
如果要精确控制比例,两个办法:
一,加大桶数。 用 % 10000 而不是 % 100,粒度细两个数量级。
二,按用户总量切分。 排序后取前 N%,而不是 hash 取模。但这样新用户加入会打乱分组,不适合长期灰度。
一个均匀性检查
def bucket_balance(total, percent, expected=None):
"""检查分桶是否符合二项分布预期。"""
expected = expected or total * percent / 100
sd = math.sqrt(total * (percent / 100) * (1 - percent / 100))
return {
"expected": round(expected, 1),
"sd": round(sd, 2),
"expected_range": (round(expected - 2*sd, 1), round(expected + 2*sd, 1)),
}
200 人、5% → 期望 10,标准差 3.1,正常范围 3.8 到 16.2。13 落在里面。
五、A/B:先算 z,再看提升
实测数据:
A 组:198 人,转化率 0.424
B 组:202 人,转化率 0.629
相对提升:+48.3%
48.3% 看起来很漂亮。但决策不能看这个数,得先算显著性。
def ab_test(p1, n1, p2, n2, z_crit=1.96):
"""两组转化率的 z 检验。"""
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
ci = (round(diff - z_crit * se_diff, 3),
round(diff + z_crit * se_diff, 3))
return {
"abs_lift": round(diff, 3),
"z": round(z, 2),
"ci_95": ci,
"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,远超 1.96,p < 0.0001。绝对值涨了 20.5 个百分点,95% 区间是 10.9% 到 30.1%。
统计上没问题。
但还有第二个问题:两组可比吗
A 组 198 人、B 组 202 人,这个分配本身就不均匀(应该各 200)。
而上一节刚证明:同一个 hash 分桶机制,在 200 人规模下会有 3 到 5 个人的偏差。
那这 4 个人的差异意味着什么?
意味着 A/B 两组的人员构成可能不是随机的。
如果落进 B 组的人恰好新用户比例更高(新用户转化率通常更高),那 20.5 个点里有相当一部分是"用户本身不同"造成的。
同一个分桶机制,在灰度那里暴露了不均匀,在 A/B 这里就不能假设它是均匀的。
三个验证动作
一,看两组的基础特征。
新老用户比例、平均活跃天数、历史转化率。如果 B 组新用户占比明显高,提升要打折。
def check_balance(group_a, group_b, key):
"""检查两组在某个特征上的分布是否接近。"""
ra = sum(1 for u in group_a if u[key]) / len(group_a)
rb = sum(1 for u in group_b if u[key]) / len(group_b)
return {"a": round(ra, 3), "b": round(rb, 3), "gap": round(abs(ra - rb), 3)}
gap 超过 0.05 就要警惕。
二,看分桶是否稳定。 同一用户跑两次落桶是否一致。这次验证了,通过。
三,看提升的绝对值合不合理。
这一条最容易被忽略。20.5 个百分点的转化率提升,在真实客服场景里是异常大的。
我见过的真实 A/B,5 到 10 个点就算很成功。20 个点通常意味着:分组不均、埋点口径变了、或者两组流量来源不同。
当一个结果好得不像真的,第一反应应该是查分组,不是庆祝。
六、反馈闭环:用户是免费标注
反馈分布:{'好评': 2, '差评': 1, '投诉': 2, '建议': 1}
三类反馈的处理方式不同:
- 好评:不用处理,但可以统计"哪些回答被夸",反哺到 Prompt 示例里
- 差评/投诉:归类 → 提炼高频痛点 → 补知识库
- 建议:单独看,可能是新需求
关键是按内容 hash 去重。同一条痛点被 20 个用户提,只应该入库一次,否则知识库里全是重复。
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):
"""CI 门禁:退化超过阈值就拦截。"""
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,它某天更新了版本,你的分数变化可能来自模型,不是代码。不记录的话,历史数据全部作废。
九、常见报错
| 现象 | 原因 | 处理 |
|---|---|---|
| 镜像 1GB+ | 单阶段构建,带编译工具链 | 改多阶段 + --no-cache-dir |
| 容器"运行中"但不响应 | 没有健康检查 | 加 healthcheck 探 /health |
| 密钥泄露 | ENV 写进镜像 | 改运行时注入 |
| 用户刷新后换了版本 | 用 random() 分桶 | 改 hash 分桶 |
| 灰度比例总是不准 | 桶数太少(100) | 加大到 10000,或按总量切 |
| A/B 提升虚高 | 两组构成不均 | 先查基础特征 |
| 反馈入库全是重复 | 没做 hash 去重 | 加上 |
| CI 天天误报 | 阈值小于评测噪声 | 阈值设 ≥0.02 |
十、接口速查
| 函数 | 关键参数 | 作用 |
|---|---|---|
assign_group | user_id、percent | hash 分桶 |
bucket_balance | total、percent | 分桶均匀性检查 |
ab_test | 两组 p 和 n | z 检验 |
check_balance | 两组用户、特征键 | 分组均衡性 |
ci_gate | current、baseline | CI 门禁 |
十一、一句话总结
容器化解决"能不能跑",运营闭环解决"跑得好不好"。
而所有"用数据决策"的环节,都要先问一句:这个数据可信吗。
灰度比例、A/B 显著性、CI 的 baseline 可比性——三个地方,三种问法。
这三件事都不在技术栈里,在判断里。而判断这件事,只能靠一次次踩坑攒出来的。
代码已通过 py_compile 语法校验。运行环境:Python 3.11+ / 纯标准库 / 离线零成本。
更多推荐

所有评论(0)