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_groupuser_id、percenthash 分桶
bucket_balancetotal、percent分桶均匀性检查
ab_test两组 p 和 nz 检验
check_balance两组用户、特征键分组均衡性
ci_gatecurrent、baselineCI 门禁

十一、一句话总结

容器化解决"能不能跑",运营闭环解决"跑得好不好"。

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

灰度比例、A/B 显著性、CI 的 baseline 可比性——三个地方,三种问法。

这三件事都不在技术栈里,在判断里。而判断这件事,只能靠一次次踩坑攒出来的。


代码已通过 py_compile 语法校验。运行环境:Python 3.11+ / 纯标准库 / 离线零成本。

Logo

更多推荐