21|部署、发布与运维:把 Agent 变成一个能上线、能放量、能撤回的服务
Agent 工程实战系列 · 开发篇 ·
上一篇(20)我们把 SupportPilot 收拾得又省又稳:提示缓存、模型路由、限流、熔断、降级都装在"模型网关"里了。可是到现在为止,它还是一个在你电脑上跑的脚本 :没有人能通过网络调它,没有"线上"和"测试"的区别,改一句提示词就直接生效,出了问题只能靠人盯着看。这一篇把它变成一个真正能上线的服务: 服务化封装(同步 / 流式 / 异步三种调用)、环境隔离、灰度放量与快速回滚、提示词与工具的变更管理,以及上线后的监控告警。主线还是 SupportPilot("包裹没到,要求补发"),评估数据复用第 18 篇的 26 条用例,观测复用第 19 篇的 trace 与看板,网关与路由复用第 20 篇。前置阅读:15 规划器与工作流编排、17 多 Agent 实战、18 评估体系、19 可观测与调试、20 性能、成本与稳定性优化。本篇的代码只用 Python 标准库,不需要装任何框架,也不需要 API Key,已在 Python 3.11、3.12、3.13 上跑过测试)。
本章代码说明:
-
"模型"仍然是模拟的。 服务、队列、灰度、回滚、门禁这些机制是真的在跑,但每个请求背后的"大脑"还是第 17 篇的规则脑加第 20 篇的模拟模型。所以本篇能证明的是这些发布与运维机制怎么工作、怎么组合、怎么测,不能证明"在你的业务上灰度几个百分点最合适"。
-
第一次
docker build如果出问题,请以你机器上的报错为准。 -
HTTP 层用的是标准库
http.server,是示例写法。 它够用来讲清楚鉴权、状态码、SSE、优雅下线,但生产上请换 gunicorn / uvicorn 之类的服务器,前面再放一层反向代理。另外,本篇的请求处理得很快(几十毫秒),所以流式接口里的步骤事件几乎是一起到的;真实模型慢的时候才能看到一条一条蹦出来。 -
异步任务队列放在内存里,进程重启就丢。 这是有意的简化(为了零依赖)。真要"不丢",得换外部队列(Redis、SQS、RabbitMQ 一类),并且让工作线程可重复执行同一个任务(靠幂等)。
-
这里用影子对比相对安全,因为灰度版只处理请求,不会真正执行写操作。 影子对比会把同一个真实请求再交给灰度版处理一遍。本篇示例里把"审批"操作强制设置为拒绝,避免它真的执行写操作。
但要注意,不执行写操作,不代表完全没有副作用。影子请求仍然可能调用真实的下游服务(比如订单、物流接口),增加额外的请求开销;如果调用了模型,也会多花一份费用。因此,所有可能修改真实数据的操作,都必须在执行前被可靠地拦截,不能只靠示例里的审批逻辑来保证安全。具体实现会在 3.5 节展开。
-
灰度仿真的数字是示例数字,不是线上阈值。 流量是参考生成的(固定的 12 个请求一轮,里面按比例混着正常、转人工、缺订单号、"停滞才 30 小时"几类),样本门槛调小到 12 个(默认 30 个)、从 5% 起放量,为的是几十秒内跑完;本机只有 2 个 CPU,延迟有噪声,所以仿真里把 p95 的上限放宽到 5 倍、花费上限放宽到 1.5 倍。你的阈值请用你自己的流量定。
-
评估基线是 25/26,不是 26/26。 第 18 篇起评估集里就有一条故意造的"物流专员偶发崩溃(30%)"用例(r04),单次运行不一定过。所以本篇的门禁比的是"不低于当前线上版本现在的成绩",基线是现测出来的,不是写死的 26。
-
本篇改了第 20 篇的 1 个文件:
optimize/adapters.py(ModelBrain和install多了一个可选参数head_fn,用来演示"提示词开头拼时间戳"这个坏发布)。没传这个参数时行为不变,第 15~20 篇的测试仍然全部通过(7.1 有真实输出)。第 20 篇的这个文件,请用本篇对应文件覆盖。 本篇其余都是新增文件。另外,同样的命令连跑几次,花费和调用次数会有约 1% 的波动 (第 20 篇已经说过:并行专员谁先到不固定)。本篇引用的"每请求花费""评估成功数"都是实测,第 4 位小数只是参考,不是结论。
一、问题场景:能跑,但没法上线
SupportPilot 的评估全绿,插上网线就出事了。下面这些问题,一个也没有答案:
| # | 现象 | 根因 |
|---|---|---|
| 1 | 别的系统想调它,只能 import 进同一个进程 |
没有服务化封装,没有网络接口 |
| 2 | 一个请求要处理十几秒,调用方的 HTTP 请求超时了,任务却还在后台白跑 | 长任务硬塞进同步接口,没有"提交任务 → 轮询结果"的异步通道 |
| 3 | 调用方超时后重试,同一个补发被处理了两次,建了两张工单 | 异步提交没有幂等键,重试等于重复执行 |
| 4 | 用户盯着空白页面等 8 秒,不知道系统有没有在干活 | 没有流式接口,看不到"现在做到哪一步了" |
| 5 | 测试环境的密钥拿到线上就能用;本地调试的配置被带到了线上 | 环境没有隔离,配置没有校验,启动时什么都不检查 |
| 6 | 一次发布出了问题,要"重新部署上一个版本",花了二十分钟 | 没有"版本"和"回滚"的概念,回滚等于重新发布 |
| 7 | 新版本一发就是 100% 流量,坏了所有人一起遭殃 | 没有灰度放量 |
| 8 | 灰度放了 5%,指标看起来都正常,转正后才发现该"继续等"的订单被补发了 | 监控指标只看错误率、转人工占比、花费、延迟,看不见"结论悄悄变了" |
| 9 | 有人改了一句系统提示词,没人知道;一个星期后成功率掉了,也没人知道是哪次改的 | 提示词没有版本和指纹,改动不过评估 |
| 10 | 工具的参数加了一个必填项,老的调用方全部报错 | 工具定义的改动没有"是否向后兼容"的检查 |
| 11 | 容器重启时,正在处理的请求被直接掐断,异步队列里的任务不知所踪 | 没有优雅下线:收到信号就退出,没有"先不接新活,把手上的做完" |
| 12 | 出事了才发现没有人在看:既没有服务层的看板,也没有告警 | 只有请求内部的观测(第 19 篇),没有服务本身的健康指标 |
本篇回答五个问题:怎么把 Agent 包成一个服务(同步、流式、异步);怎么让开发、预发、线上互不影响(环境隔离);怎么把新版本一点点放出去,坏了怎么在 30 秒内撤回(灰度与回滚);提示词和工具的每一次改动怎么被管起来(变更管理);以及上线之后怎么知道它健康不健康(监控告警)。
二、核心概念
2.1 服务怎么组织:HTTP 接口负责收发请求,核心模块负责业务处理

先名词解释,后面都用这几个名词:
| 名词 | 就是 | 对应代码 |
|---|---|---|
| 核心业务模块(Service) | 来一个请求:选版本、占名额、跑、记账、算监控指标。完全不碰 HTTP,所以不开端口就能测 | service/core.py 的 Service |
| HTTP 层 | 只做收发:鉴权、请求体上限、把异常翻译成状态码、输出 SSE | service/server.py |
| Runtime(运行环境) | 一个版本对应一个 Runtime:自己的一套订单 / 物流 / 工单 / 知识库 / 缓存,互不共享状态 | service/runtime.py |
| 同步 / 流式 / 异步 | 三种调用方式,最后都进同一个内核 | POST /v1/support/requests、?stream=1、POST /v1/support/jobs |
| 名额(slot) | 同时最多处理 N 个请求,满了立刻拒绝(429),不排队 | BoundedSemaphore |
| 幂等键(Idempotency-Key) | 调用方给每个"操作"起一个唯一的名字;同一个名字再来,不重复执行 | JobQueue.submit |
| 就绪检查(readiness) | "我现在能不能接活"。下线时它先变红,负载均衡就不再把新请求发过来 | GET /readyz |
| 存活检查(liveness) | "我这个进程还活着吗"。和就绪不同:下线排空期间它仍然是绿的 | GET /healthz |
| 平滑下线(graceful shutdown) | 收到 SIGTERM:就绪变红 → 不收新活 → 做完手上的 → 退出 | Service.shutdown、serve_forever |
| 发布(Release) | 一个不可变的版本:版本号 + 变体(代表提示词 / 工具 / 逻辑的改动)+ 路由策略 + 提示词开头 | service/releases.py 的 Release |
| 稳定版 / 灰度版 / 上一个稳定版 | stable 吃大多数流量;canary 只吃一部分;previous 是回滚的目的地 | Rollout |
| 影子对比(shadow compare) | 稳定版处理真请求时,让灰度版"旁观"再做一遍,只比较结论,不生效 | Service._maybe_shadow |
| 自动检查规则((Guard) | 拿灰度版和稳定版的实时统计比:越线回滚、样本不够等、正常放大一档 | Guard、AutoRoller |
| 变更清单(manifest) | 一个版本里所有"能改变行为的东西"各算一个指纹:共用提示词、每个角色的提示词、每个工具的定义、路由、变体、提示词开头 | build_manifest |
| 发布前检查(gate) | 有行为相关的改动,就必须带评估结果,达标才放行 | gate |
整个设计的要点是:把会影响程序行为的提示词、工具定义和路由配置统一记录成一个版本。要改变行为,就发布新版本;发现问题,就切回上一个版本。 提示词、工具定义、路由配置都当成代码------有指纹、有 diff、有评估、有版本号。这样发布就不再是"改一下重启",而是一个有流程、可撤回、可审计的动作。
2.2 同步、流式、异步:三种调用方式,选哪个
| 同步 | 流式(SSE) | 异步(任务) | |
|---|---|---|---|
| 请求 | POST /v1/support/requests |
POST .../requests?stream=1 |
POST /v1/support/jobs |
| 响应 | 处理完才返回 200 | 立刻 200,之后一条条推事件 | 立刻 202 + job_id,之后轮询 GET .../jobs/<id> |
| 适合 | 几秒内能完成、调用方愿意等 | 有人在屏幕前等,想看到"做到哪一步了" | 慢任务、批量、调用方不想占着连接 |
| 超时怎么办 | 504 | 流里推一条 error 事件 |
任务继续跑,调用方稍后来取 |
| 重试安全吗 | 不安全(没有幂等键) | 不安全 | 安全 :带 Idempotency-Key,同一个键只执行一次 |
| 占用 | 一个名额 | 一个名额,整个流期间都占着 | 一个队列位 + 一个工作线程 |
SSE(Server-Sent Events)就是一个"一直不关的 HTTP 响应":Content-Type: text/event-stream,每条消息是 event: 名字 加 data: 内容,消息之间空一行(参考SSE 的事件格式)。本篇流式接口的事件顺序:accepted(拿到名额、告诉你哪个版本在处理)→ 若干条 step(某个专员 / 决策 / 审批 / 写入刚做完)→ 最后一条 result 或 error。
有一个容易踩的坑:一旦开始流式输出,HTTP 状态码已经是 200 了,后面出错只能放在事件里。 Anthropic 的流式接口也是这样:文档里写着流里可能出现 error 事件(比如 overloaded_error),"在非流式的场景里它对应 HTTP 529"。所以本篇的处理是:能提前失败的,一定要在发出 200 之前失败 ------名额满了(429)、正在下线(503)这两种,在发送响应头之前就判断完,翻译成真正的状态码;只有开始处理之后才出的错,才走 error 事件。
2.3 环境隔离:让"拿错环境"在启动那一刻就失败
三个环境:dev(本地开发)、staging(预发,接近线上)、prod(线上)。隔离靠四条规则,全部在启动时检查,不对就拒绝启动:
- 环境名必须明确给出,没有默认值。 默认成 prod 是事故,默认成 dev 会让线上悄悄带着开发配置跑着。
- 密钥只从环境变量来,不进仓库、不进镜像。 业务密钥和管理密钥分开,管理密钥不能和任何业务密钥相同。
- 每个环境的密钥带环境前缀(
dev-/stg-/prod-),而且有最小长度(8 / 16 / 24)。 把 staging 的密钥配到 prod 上,启动就报错,不用等到出事。 - 数据目录各用各的,目录名里不能出现别的环境的名字;prod 必须是绝对路径,而且不能开调试。
2.4 灰度放量:先让一小部分人用,监控指标盯着,坏了立刻撤

灰度(canary)的意思:新版本只吃一小部分真实流量,其余流量仍走稳定版,两边并排比较。 Google SRE 工作手册里对金丝雀发布的定义是"对一个服务的变更做部分的、限时的部署并评估",而且它把没有被改动的那部分叫做"对照组",像 A/B 测试一样比较。灰度推荐的几条规则:
- 并排比,不要前后比。 拿"发布前一小时"和"发布后一小时"比,会被一天里的流量高低峰淹没;拿"同一时刻的灰度组"和"对照组"比才干净。本篇的监控指标比的是同一个时间窗内的两个版本。
- 样本不够不要下结论。 数据太少,结果是噪声。所以监控指标有"最少样本数",不够就等。
- 一次只放一个灰度。 文档强烈建议一次只跑一个金丝雀,否则信号互相污染。
Rollout里已经有灰度版时,再开第二个会被拒绝。 - 相对比较之外,还要有绝对的底线。 文档提醒"相对于对照组"的比较要用"绝对的 SLO 检查"兜底(对照组本身也可能坏了)。本篇在监控里放了绝对的错误率告警(2.6)。
分流用的是稳定哈希 :把一个 key(用户号,没有就用订单号,再没有就用请求号)哈希到 [0, 100) 的一个桶里,桶号小于放量百分比的走灰度版。这样做有两个好处:同一个人每次都落在同一个版本上(不会一会儿新、一会儿旧);从 5% 放大到 25% 的时候,原来那 5% 的人一定还在灰度里,只会增加新人,不会把已经在新版里的人踢回去。
2.5 影子对比:发现指标正常、业务结果却变错的问题
自动检查主要看的是错误率、转人工占比、每请求花费、p95 延迟。这些指标能发现比较明显的问题,例如:新版本把物流专员的一条结论弄丢了,结果几乎所有请求都转人工,转人工占比一下子飙上去,监控指标立刻回滚。
但它们发现不了另一类问题:监控指标都正常,实际业务判断却错了 。例如:新版本把"多少小时可以补发"读成了 24(实际是 72),流程全程不报错、结局种类也和以前一样(都是"已回复"),错误率、转人工占比、花费、延迟统统不变;只是该"继续等"的订单被补发了,还建了工单。四个指标一个也不会动。
发现这类问题,可以使用影子对比:稳定版处理一个真实请求的同时,让灰度版拿同一个请求"旁观"着再做一遍(审批恒定拒绝,什么都不会真的写出去 ),然后比较两边的结论签名 :结局是"回复 / 转人工 / 补问",如果是"提议执行",再加上"要不要建工单"。不一致的比例超过 5%(样本够 20 个)就回滚。它还有一个好处:不管灰度放了多少百分比,影子对比的样本都来自稳定版的全部流量,所以放 1% 的时候,灰度版自己没几个样本,影子对比已经能给出结论。
代价要说清楚:影子对比让每个被抽中的请求多跑一遍,多花一份模型的钱 ,多占一份算力(所以有 shadow_rate 采样率);在真实系统里,影子请求仍然会去读真实的下游,写工具必须被真正拦住------这里的"拦"靠的是审批恒拒绝,换成别的系统,你要自己确认这一层不会漏。
2.6 监控告警:两套指标,各回答各的问题
第 19 篇的指标(agent_*)回答"请求内部 发生了什么":哪个专员慢、哪个工具错、花了多少 token。本篇新增 14 个 sp_* 指标,回答"服务本身健康吗、发布顺利吗":
| 指标 | 类型 | 回答什么 |
|---|---|---|
sp_requests_total{version,decision} |
计数器 | 每个版本处理了多少请求,结局分布 |
sp_request_errors_total{version} |
计数器 | 每个版本出了多少错(超时、内部异常) |
sp_rejected_total{reason} |
计数器 | 被拒绝的请求:busy / queue_full / draining / auth |
sp_request_duration_seconds{version} |
直方图 | 入口处理耗时 |
sp_cost_usd_total{version} |
计数器 | 每个版本花了多少钱 |
sp_inflight |
仪表 | 正在处理的同步请求数 |
sp_jobs_queue_depth、sp_jobs_oldest_queued_seconds |
仪表 | 异步队列排了多少、最老的等了多久 |
sp_jobs_total{status} |
计数器 | 任务提交 / 完成 / 失败 / 放弃 / 去重 |
sp_rollout_percent、sp_rollout_info{stable,canary} |
仪表 | 现在放了百分之几、谁是稳定版谁是灰度版 |
sp_rollout_events_total{action} |
计数器 | 发布事件:canary / percent / promote / rollback |
sp_shadow_total{version,result} |
计数器 | 影子对比:一致 / 不一致 |
sp_draining |
仪表 | 是否正在下线 |
和第 19 篇一样,每个指标都先在 SERVICE_METRICS 里登记 (名字、类型、标签、含义),看板和告警的每一个表达式都要和这份登记对账:指标改了名,没改看板,检查就红;这是为了避免"图悄悄变空"。直方图的桶是累计 的(le="0.5" 的桶数的是"≤ 0.5 秒"的全部请求,包含更快的),最后一个 +Inf 桶等于总数,这是 Prometheus 约定的格式)。
告警一共 8 条,每一条都带"收到之后先看什么":
| 告警 | 条件 | 级别 | 为什么 |
|---|---|---|---|
| ServiceMetricsMissing | absent(sp_inflight) 持续 2 分钟 |
page | 服务没有上报指标:进程没了,或者被换掉了 |
| RequestErrorRatioHigh | 错误率 > 2%,持续 5 分钟 | page | 绝对的底线,不依赖和对照组比较 |
| RequestLatencyP95High | p95 > 5 秒,持续 10 分钟 | ticket | 入口变慢 |
| RequestsRejectedHigh | busy+queue_full 每秒 > 0.5 |
ticket | 容量不够 |
| JobQueueOldestStale | 最老的排队任务 > 60 秒 | page | 工作线程卡住或下游变慢 |
| RollbackHappened | 15 分钟内发生过回滚 | page | 不管自动还是手动,都要有人确认 |
| CanaryStuck | 灰度停在 0%~50% 之间超过 2 小时 | ticket | 灰度状态不能无限期挂着 |
| ShadowDiffHigh | 影子不一致率 > 5%,持续 10 分钟 | page | 这条告警可以发现监控指标看不出来的业务结果差异 |
三、设计取舍
3.1 一个进程里同时放稳定版和灰度版,还是各部署一套
| 同进程两个 Runtime(本篇) | 各部署一套 + 前面的网关按比例分流 | |
|---|---|---|
| 分流 | 内核里按 key 哈希 | 网关 / 负载均衡 / 服务网格 |
| 影子对比 | 好做:同一个进程里同时拿得到两边的结果 | 难做:要有流量镜像,两边结果要对上号 |
| 隔离性 | 一个进程崩了两个版本一起崩;内存、线程池共享 | 彻底隔离 |
| 回滚 | 改一个状态文件,秒级生效,不用动容器 | 改网关配置,或者缩容 / 删除新版本的部署 |
| 适合 | 教学、小规模、"版本"只是配置 / 提示词的差异 | 版本差异包含代码和依赖,规模大 |
结论:"版本"只是提示词 / 工具 / 路由配置的差异时,同进程最简单,回滚也最快;一旦版本差异包含代码或依赖,就该各部署一套,把分流交给基础设施。 本篇选同进程,是因为它把"发布状态机"这件事讲得最清楚;这个状态机(稳定 / 灰度 / 百分比 / previous)搬到网关上,逻辑一样。
3.2 同步请求满了:排队,还是立刻拒绝
排队看起来更友好,但如果前面的请求处理很慢,后面的请求也只能跟着等。队伍越长,越容易导致一批请求同时超时。本篇的同步接口是满了立刻 429(带 Retry-After) ,不排队。真正需要"先收下、慢慢做"的场景,走异步接口------那里有一个有界队列,满了同样拒绝。拒绝是为了让能处理的请求体面地处理完(第 20 篇讲过的系统过载后性能急剧下降)。
3.3 异步任务的结果放多久
结果放太短,调用方还没来取就没了;放太久,内存只增不减。本篇给每个任务一个 TTL(默认 600 秒,从任务结束时算起 ,不是从提交时),过期的任务和它占着的幂等键一起清掉。幂等键是个有限寿命的东西:TTL 过了,同一个键可以再次提交。Stripe 的文档里也是同样的思路(v1 的幂等键至少 24 小时之后可被清理)。
3.4 幂等键:同一个键、不同的内容,怎么办
幂等键会把新请求的参数和第一次的比,不一样就报错 ,"防止误用"。因为键被两个不同的操作用了,多半是调用方的 bug,悄悄返回上一次的结果等于把错误的结果交给了调用方。处理方式:同键同内容 → 200 + deduplicated: true;同键不同内容 → 422;键太长(超过 128 个字符)或带不可见字符 → 400。
3.5 影子请求:怎么保证它什么都不写
这里用三层保护,避免测试请求真的修改业务数据:
- 审批恒拒绝。 影子请求带着自己的 trace id,Runtime 的审批函数发现是影子,只记下"它想做什么"(要不要建工单),然后返回"不批准"。写操作在发件箱那一层本来就要审批,没批就写不出去。
- 独立的 Runtime。 灰度版有自己的一套订单 / 工单 / 缓存,不和稳定版共用状态。
- 测试盯着。 有专门的用例:影子请求跑完,工单列表和发件箱必须还是空的(7.1);变异检查里也有一条"影子请求被批准",必须被抓到(6.3)。
要提醒的是:这三层成立的前提,是"写"都必须经过审批这个唯一的出口(第 17 篇的设计)。如果你的系统里有哪个工具能绕过审批直接写,影子对比就是在线上多做一遍真实的写操作。
3.6 灰度发布为什么通常不会直接放到 100%
如果新版本接收了 100% 的流量,稳定版就没有请求可处理,也就无法继续把新旧版本放在同一时间段比较。所以我们的档位是 5% → 25% → 50%,50% 这一档正常就转正 :灰度版变成稳定版,旧的稳定版退成 previous。转正之后,旧的稳定版退成 previous,回滚的目的地还在。
3.7 回滚:撤灰度,还是退回上一个稳定版
两种形态,同一个动作(rollback):
- 灰度中:撤掉灰度版,流量全回稳定版;稳定版不变。
- 已经转正 :稳定版退回
previous。previous只留一个,再回滚一次会被拒绝("没有可回滚的版本");要回到更早的版本,只能重新走一遍 canary → promote。
只留一个是故意的:能无限回滚的系统,意味着你要永远保留所有历史版本的运行环境;一个就够应付绝大多数事故,更早的版本靠重新发布。
3.8 自动回滚,还是只告警让人来回滚
两样都要,分工不同:监控指标的自动回滚 覆盖"灰度期间、有明确越线信号"的场景(秒级、不依赖人醒着);告警 覆盖"自动回滚没抓到的"和"回滚之后要有人确认"的场景(RollbackHappened 就是后者)。自动回滚只做最安全的那一个动作------撤掉灰度,让流量回到已经被验证过的稳定版------不做任何有副作用的事。
3.9 配置写在镜像里,还是运行时注入
镜像里不写 环境名和任何密钥(5.1 的 Dockerfile 有测试盯着);它们在运行时通过环境变量注入。这样同一个镜像可以原样从 staging 走到 prod,"测试过的就是上线的"。代价是启动时必须有一套完整的校验(2.3),否则注入错了没人发现。推荐的检验标准:如果你的代码库随时可以开源而不泄露任何凭据,说明配置和代码分得够干净。
四、案例
4.1 主案例(上):把 SupportPilot 变成一个服务,先看它怎么"拒绝启动"
先看第一道关:配置有问题,服务根本起不来 。config-check 只校验、不启动,三种情况的真实输出:
text
$ SP_ENV 没设
配置有问题,拒绝启动:
- 没有设置 SP_ENV(不给默认值:默认成哪个环境都可能出事故)
退出码 2
$ prod 环境,拿了 staging 的密钥、管理密钥和业务密钥一样、数据目录里有 staging
配置有问题,拒绝启动:
- prod 环境的 API 密钥必须以 prod- 开头(拿错环境的密钥?):stg-01...
- 管理密钥(SP_ADMIN_KEY)必须以 prod- 开头且不少于 24 个字符
- 管理密钥不能和业务密钥相同
- 数据目录 /var/lib/supportpilot/staging 里出现了别的环境的名字,疑似共用了数据目录
退出码 2
$ prod 环境,配置正确
配置通过:{"env": "prod", "data_dir": "/var/lib/supportpilot/prod", "workers": 4, "queue_max": 50, "max_inflight": 8, "request_timeout_s": 10.0, "shadow_rate": 0.0, "debug": false, "port": 8080, "api_keys": ["prod...(29)"], "admin_key": "prod...(29)"}
退出码 0
第二段是最典型的"拿错东西":环境是 prod,密钥却是 staging 的、管理密钥和业务密钥是同一个、数据目录路径里带着 staging。四个问题一次全列出来 ,不是改一个报一个。这几条检查都不聪明,就是几个字符串前缀和长度的比较,但它们挡住的是"线上用了测试环境的数据目录"这种出了事故才发现的错。(SP_ENV 不给默认值,也是同一个思路:默认成哪个环境都可能出事。)
再看服务本身。下面是在本机起了一个 dev 环境的服务,用 curl 按顺序打了一遍(serve_demo.sh,输出是脚本抓的,长的响应只截了开头):
text
$ curl -s -w " [HTTP %{http_code}]\n" http://127.0.0.1:18090/readyz
{"ready": true, "reasons": []} [HTTP 200]
$ curl -s -i -X POST http://127.0.0.1:18090/v1/support/requests -H "X-API-Key: ..." -d '{"goal":"包裹一直没到,要求马上补发","order_id":"A1001","user_id":"u1"}' | 头部 + 正文
HTTP/1.0 200 OK
X-Env: dev
X-Release: v1
X-Trace-Id: req-192f121c6e7c
{"version": "v1", "trace_id": "req-192f121c6e7c", "decision": "replied", "reply": "您好,订单系统显示:已发货,顺丰运输中;物流已 120 小时没有更新;《物流停滞与补发政策》v2规定:停滞超过 72 小时可申请补发。...
$ 同一个请求,流式(?stream=1)
event: accepted event: step event: step event: step event: step event: step event: step event: step event: step event: result
$ 异步提交,带 Idempotency-Key(连发两次,第二次是重试)
{"job_id": "650b516c219d", "status": "queued", "status_url": "/v1/support/jobs/650b516c219d", "deduplicated": false} [HTTP 202]
{"job_id": "650b516c219d", "status": "running", "status_url": "/v1/support/jobs/650b516c219d", "deduplicated": true} [HTTP 200]
$ 轮询任务(结果节选)
{"job_id": "650b516c219d", "status": "done", "result": {"version": "v1", "trace_id": "req-2dbac5182c37", "decision": "replied", "reply": "您好,订单系统显示:已发货,顺丰运输中;物流已 120 小时没有...
$ 同一个键换了内容
{"error": "这个 Idempotency-Key 已经用过,而且请求内容不一样"} [HTTP 422]
$ 没带密钥 / 用管理密钥当业务密钥
{"error": "鉴权失败"} [HTTP 401]
{"error": "鉴权失败"} [HTTP 401]
$ 不认识的字段 / 订单号里有路径
{"error": "不认识的字段:['role']"} [HTTP 400]
{"error": "order_id 只能是字母数字和 - _,最长 32 位"} [HTTP 400]
$ /version(密钥只露前缀和长度)
{"env": "dev", "data_dir": "/tmp/sp-serve-demo/dev", "workers": 4, "queue_max": 50, "max_inflight": 8, "request_timeout_s": 10.0, "shadow_rate": 0.0, "debug": false, "port": 18090, "api_keys": ["dev-...(13)"], "admin_key": "dev-...(14)"}
$ /metrics(节选)
sp_draining 0
sp_jobs_total{status="abandoned"} 0
sp_jobs_total{status="deduped"} 1
sp_jobs_total{status="done"} 1
sp_jobs_total{status="failed"} 0
sp_jobs_total{status="submitted"} 1
# TYPE sp_request_duration_seconds histogram
sp_request_duration_seconds_sum{version="v1"} 0.115000
sp_request_duration_seconds_count{version="v1"} 3
sp_requests_total{version="v1",decision="replied"} 3
sp_rollout_percent 0
$ 优雅下线:kill -TERM
退出码 0
服务日志(结构化 JSON,每行一条;节选 5 行):
{"ts": 1792507719.5720966, "level": "INFO", "msg": "服务已启动", "host": "0.0.0.0", "port": 18090, "env": "dev", "stable": "v1"}
{"ts": 1792507719.6952121, "level": "INFO", "msg": "http", "method": "POST", "path": "/v1/support/jobs", "code": 202, "ms": 1, "env": "dev"}
{"ts": 1792507719.7067935, "level": "INFO", "msg": "http", "method": "POST", "path": "/v1/support/jobs", "code": 200, "ms": 0, "env": "dev"}
{"ts": 1792507720.7765534, "level": "INFO", "msg": "http", "method": "POST", "path": "/v1/support/jobs", "code": 422, "ms": 0, "env": "dev"}
{"ts": 1792507721.358666, "level": "INFO", "msg": "已退出"}
按顺序读这份输出,每一段对应一个设计点:
/readyz返回 200:稳定版已经预热、队列没满、没在下线,才算"就绪"。编排系统(Docker、K8s)靠它决定要不要把流量发过来。- 同步请求 :响应头里带着
X-Release: v1和X-Trace-Id,出了问题拿 trace id 去第 19 篇的看板里找那一条。 - 流式(
?stream=1) :事件顺序是accepted → step × 8 → result。这里九个事件几乎是一起到的,原因是模拟模型太快(开头第 3 条)。 - 异步 + 幂等键 :同一个
Idempotency-Key提交两次,第二次返回同一个job_id,状态码从 202 变成 200,deduplicated: true------调用方超时重试,不会建出第二张工单。同一个键换了内容,直接 422,不会悄悄返回旧结果。 - 鉴权:没带密钥、拿管理密钥当业务密钥,都是 401,而且两种失败的响应一模一样,不告诉对方"到底错在哪"。
- 入口校验 :多出来的字段(
role)、订单号里夹路径,都在进入业务逻辑之前就被 400 挡住。 /version和日志 :密钥只露前缀和长度(dev-...(13))。日志是每行一个 JSON,kill -TERM之后有"已退出"这一行,退出码是 0。/metrics:3 个请求(同步、流式、异步各一个)都计进了sp_requests_total。
这一段最重要的是顺序感:校验在最前(配置、鉴权、入口),业务在中间,记账和收尾在最后。每一层只做自己那一件事,所以测试可以一层一层地覆盖。
4.2 主案例(中):灰度发布------一个正常版本和三个容易从监控指标上发现的问题版本
service_cli simulate 把"发布一个新版本"整条流程跑一遍:旧版本 v1 是稳定版,新版本 v2 从 5% 开始放量,监控指标每一轮(tick)看一眼,通过就放大一档,越线就回滚。下面 4 个场景的流量是同一份(固定的 12 个请求一轮),只有 v2 的内容不同。
场景 1:好版本(行为没变)。 列的意思:灰度% 是这一轮灰度版的流量比例;稳定n / 灰度n 是这一档窗口里两边各处理了多少请求;转人工(灰/稳) 是两边转人工的占比;花费比、p95比 都是灰度版 ÷ 稳定版;影子(异/总) 是影子对比里结论不一致的数 / 对比总数;最后一列是监控指标的决定。
text
场景 good:好版本:行为没变
tick 灰度% 稳定n 灰度n 转人工(灰/稳) 花费比 p95比 影子(异/总) 动作
1 5 94 6 0.17/0.24 1.16 0.5 0/94 wait
2 5 191 9 0.22/0.25 1.13 0.5 0/191 wait
3 5 284 16 0.31/0.25 0.98 0.5 0/284 advance:所有监控指标指标在范围内
4 25 74 26 0.27/0.23 0.91 0.8 0/74 advance:所有监控指标指标在范围内
5 50 49 51 0.20/0.33 1.05 1.5 0/49 promote
结果:v2 转正,现在的稳定版是 v2(共发了 500 个请求,被拒绝 0 个)
读法:前两轮灰度版只有 6、9 个样本,不到门槛(仿真里是 12 个),监控指标只是 wait,不下结论、也不放量 ;第 3 轮样本够了,各项都在范围内,放大到 25%;每放大一档,统计窗口清零,下一档只用自己这一档的样本 ;50% 这一档正常,转正。注意第 3 轮的 转人工(灰/稳) 是 0.31 对 0.25------差了 6 个百分点,但没越线(上限差 15 个百分点),监控指标没有大惊小怪。这就是"阈值要留出噪声空间"的意思:样本少的时候,比率本来就会抖。
场景 2:明显的问题(v2 把所有请求都转给人工处理)。
text
场景 loud:明显的异常:转人工飙升(不开影子对比)
tick 灰度% 稳定n 灰度n 转人工(灰/稳) 花费比 p95比 影子(异/总) 动作
1 5 94 6 1.00/0.24 0.76 0.2 0/0 wait
2 5 191 9 1.00/0.25 0.70 0.2 0/0 wait
3 5 284 16 0.94/0.25 0.61 0.2 0/0 rollback:转人工占比 94% 比稳定版 25% 高出超过 15%
结果:已回滚,稳定版仍是 v1(共发了 300 个请求,被拒绝 0 个)
灰度版的转人工占比是 100%、100%、94%,稳定版 24%~25%。前两轮样本不够,监控指标只能等;第 3 轮样本够了,立刻回滚 ,整个过程只放过了 300 个请求里很小一部分(5% 的流量)。这是灰度最直观的价值:坏版本只伤到了 5% 的人,而且发现得快 。注意这个场景里"花费比"是 0.6~0.8------比稳定版还便宜。一个版本把所有请求都转人工,当然便宜,因为它什么都没做。只看花费的话,这个坏版本看起来是个好消息。
场景 3:变慢(v2 每个请求多等 1 秒)。
text
场景 slow:变慢:延迟安全检查机制
tick 灰度% 稳定n 灰度n 转人工(灰/稳) 花费比 p95比 影子(异/总) 动作
1 5 94 6 0.17/0.24 1.36 4.4 0/0 wait
2 5 191 9 0.22/0.25 1.27 4.5 0/0 wait
3 5 284 16 0.31/0.25 1.06 4.6 0/0 advance:所有护栏指标在范围内
4 25 74 26 0.27/0.23 0.91 6.0 0/0 rollback:p95 延迟是稳定版的 6.0 倍(上限 5 倍)
结果:已回滚,稳定版仍是 v1(共发了 400 个请求,被拒绝 0 个)
在 5% 那一档,p95 比是 4.4~4.6 倍,接近上限但没越线 ,护栏放行了;放大到 25% 之后是 6.0 倍,越过 5 倍的上限,回滚。这个场景展示了一个问题:小流量的时候,慢也许没被看见。 5% 那一档灰度版只有 16 个样本,p95 本来就不稳。放大一档才暴露------这是"分档放量"设计的原因,每一档都要重新过一遍护栏,不是只过一次。(这个场景在不同机器上的数字会不同:本机只有 2 个 CPU,延迟有噪声;它是否在 5% 那一档就被抓住,不是确定的。)
场景 4:缓存失效(v2 的提示词开头拼了时间戳)。
text
场景 cache:缓存失效:每请求花费上跳
tick 灰度% 稳定n 灰度n 转人工(灰/稳) 花费比 p95比 影子(异/总) 动作
1 5 94 6 0.17/0.24 3.31 0.2 0/0 wait
2 5 191 9 0.22/0.25 3.24 0.2 0/0 wait
3 5 284 16 0.31/0.25 2.84 0.2 0/0 rollback:每请求花费是稳定版的 2.8 倍(上限 1.5 倍)
结果:已回滚,稳定版仍是 v1(共发了 300 个请求,被拒绝 0 个)
第 20 篇讲过:把每次都变的内容放在提示词最前面,前缀缓存永远命不中,缓存开着反而更贵。这里花费比是 3.3、3.2、2.8 倍,第 3 轮样本够了,回滚。回复内容和稳定版完全一样------用户感觉不到任何变化,错误率、转人工占比、延迟全都正常,只有账单变了。如果不盯"每请求花费"这一项,这个坏版本会一路放到 100%。
到这里的一个小结:监控检查分别能发现一类问题,没有一项是多余的。但这四类问题都有一个共同点:错误率、转人工比例、处理时长或成本会发生明显变化,因此比较容易从监控数据(错误多了、转人工多了、慢了、贵了)中发现。下一节讲的是监控数据看不出、但业务结果已经出错的情况。
4.3 主案例(下):监控指标都正常,但业务判断已经变错了
假设有人"顺手"改了政策判断:把"停滞超过 72 小时可以补发"读成了 24 小时(演示变体 policy_threshold_24)。这个版本的错误率不变、转人工占比不变、花费不变、延迟不变------它只是对一部分订单给出了不一样的结论:停滞才 30 小时的订单,本来该回复"请再等等",现在被直接建了补发工单。
同样的坏版本、同样的流量,各放一遍灰度,只差一个开关(对比脚本 examples/canary_blind_spot.py 只有 20 来行,代码放在第八章):
先看不开影子对比:
text
场景 silent:业务结果出错:不开影子对比 → 护栏看不见
tick 灰度% 稳定n 灰度n 转人工(灰/稳) 花费比 p95比 影子(异/总) 动作
1 5 94 6 0.17/0.24 1.36 0.2 0/0 wait
2 5 191 9 0.22/0.25 1.26 0.2 0/0 wait
3 5 284 16 0.31/0.25 1.06 0.2 0/0 advance:所有护栏指标在范围内
4 25 74 26 0.27/0.23 0.91 0.3 0/0 advance:所有护栏指标在范围内
5 50 49 51 0.20/0.33 1.05 0.9 0/0 promote
结果:v2 转正,现在的稳定版是 v2(共发了 500 个请求,被拒绝 0 个)
四项监控检查全部绿,影子(异/总) 是 0/0(没开),一路放到 50%,转正。这一步之后,所有人都在用一个会乱补发的版本,没有任何告警。
再看开影子对比:
text
场景 silent_shadow:业务结果出错:开影子对比 → 抓得住
tick 灰度% 稳定n 灰度n 转人工(灰/稳) 花费比 p95比 影子(异/总) 动作
1 5 94 6 0.17/0.24 1.15 0.5 16/94 rollback:影子对比:17% 的请求结论和稳定版不一致(上限 5%)
结果:已回滚,稳定版仍是 v1(共发了 100 个请求,被拒绝 0 个)
第 1 轮就回滚了:灰度版处理的请求里,影子对比的 94 个里有 16 个(17%)结论和稳定版不一致,上限是 5%。影子对比的做法 是:稳定版处理一个真实请求的同时,让灰度版拿同一个请求在旁边再做一遍,只比较"结论的签名"(最终决策是什么;如果是"提议补发",再加上它想不想建工单),不把灰度版的结果交给用户,也不执行它的写操作(3.5 讲了三层保险)。
它的代价就是:每一个被采样的请求多花一份模型的钱;它只能抓"和稳定版不一样"的变化,不能判断谁对谁错------如果稳定版本来就是错的,新版本把它改对了,影子对比会把"改对"当成"不一致",报警。所以它的告警必须是"请人来看",不是"自动判定新版本有错"。(本篇的流程里,越线会自动回滚,改对了的版本被误回滚,损失只是"这次没发出去",可以接受;如果你的回滚代价更高,应该改成告警 + 人工确认。)
最后看如果让坏版本转正会怎样:
text
场景 silent:业务结果出错:不开影子对比 → 护栏看不见
tick 灰度% 稳定n 灰度n 转人工(灰/稳) 花费比 p95比 影子(异/总) 动作
1 5 94 6 0.17/0.24 1.36 0.2 0/0 wait
2 5 191 9 0.22/0.25 1.26 0.2 0/0 wait
3 5 284 16 0.31/0.25 1.06 0.3 0/0 advance:所有护栏指标在范围内
4 25 74 26 0.27/0.23 0.91 0.4 0/0 advance:所有护栏指标在范围内
5 50 49 51 0.20/0.33 1.05 1.0 0/0 promote
结果:v2 转正,现在的稳定版是 v2(共发了 500 个请求,被拒绝 0 个)
场景 silent_shadow:业务结果出错:开影子对比 → 抓得住
tick 灰度% 稳定n 灰度n 转人工(灰/稳) 花费比 p95比 影子(异/总) 动作
1 5 94 6 0.17/0.24 1.15 0.5 16/94 rollback:影子对比:17% 的请求结论和稳定版不一致(上限 5%)
结果:已回滚,稳定版仍是 v1(共发了 100 个请求,被拒绝 0 个)
如果让坏版本转正,120 个请求里"停滞才 30 小时、不该补发"的订单被建了工单:稳定版 0 张,坏版本 20 张
最后一行是真实的伤害:120 个请求里,稳定版建了 0 张不该建的工单,坏版本建了 20 张。
4.4 主案例(终):平滑下线和版本回滚
灰度和回滚是"版本"层面的事,下线是"进程"层面的事。第二个是更容易被忘的:收到下线信号时,手上还有请求在处理 ,你准备怎么办?这里写了一个对比(examples/drain_demo.py,代码在第八章):同样的服务、同样的一个慢请求(这个版本每个请求至少要 1 秒),在请求处理到一半时分别发 SIGKILL 和 SIGTERM:
text
--- 模式 kill:SIGKILL(不给机会收尾) ---
t=0.00s 发出一个请求(这个版本每个请求至少要 1 秒)
t=0.20s 发出 SIGKILL
t=0.20s 在途请求的结局 → 失败:RemoteDisconnected(用时 0.20s)
t=0.20s 进程退出码 -9;服务日志里的收尾记录:没有
--- 模式 term:SIGTERM(优雅下线) ---
t=0.00s 发出一个请求(这个版本每个请求至少要 1 秒)
t=0.20s 发出 SIGTERM
t=0.30s 探测 /readyz → 503
t=0.31s 新请求 → HTTP 503
t=1.03s 在途请求的结局 → HTTP 200,decision=replied,release=v2(用时 1.03s)
t=1.39s 进程退出码 0;服务日志里的收尾记录:['收到下线信号,开始排空', '已退出']
- SIGKILL :0.20 秒时发出信号,在途请求在 0.20 秒就失败了(
RemoteDisconnected),进程退出码 -9,日志里没有任何收尾记录。调用方看到的是一个莫名其妙的断连。 - SIGTERM :0.20 秒发出信号后,
/readyz在 0.30 秒变成 503(编排系统不再往这里发新流量),新来的请求在 0.31 秒得到 503 (明确告诉调用方"换一个实例重试"),而在途的那个请求跑完了(1.03 秒,HTTP 200),随后进程干净地退出(退出码 0,日志里有"收到下线信号,开始排空"和"已退出")。
这背后是一条时间线:SIGTERM → /readyz 变红 → 不接新活 → 排空异步任务和在途请求 → 退出。关键的一条是时间约束 :服务的"最长排空时间"(--grace,默认 10 秒)必须小于 编排系统愿意等的时间------docker stop 默认等 10 秒,超过就发 SIGKILL。所以 docker-compose.yml 里 stop_grace_period 是 20 秒,并且有一个测试专门盯着"compose 的等待时间必须大于服务的排空时间"。
再看回滚 。下面是对一个运行中的服务做的真实操作(rollback_demo.sh,操作人 oncall 是设的环境变量,ctl 会把系统用户名带进审计事件):
text
$ ctl register --version v2 --variant policy_threshold_24 --note "演示用:补发门槛读成了 24 小时"
{"stable": "v1", "canary": "", "percent": 0.0, "previous": ""}
$ ctl canary --version v2 --percent 50
{"stable": "v1", "canary": "v2", "percent": 50.0, "previous": ""}
$ 12 个不同用户各发一次,看各自走了哪个版本(次数 版本)
8 v1 4 v2
$ ctl rollback --reason "影子对比发现结论不一致"
{"stable": "v1", "canary": "", "percent": 0.0, "previous": ""}
$ 再发 12 个
12 v1
$ 另一种形态:先把 v3 转正,再回滚
$ ctl promote
{"stable": "v3", "canary": "", "percent": 0.0, "previous": "v1"}
$ ctl rollback --reason "转正后发现问题"
{"stable": "v1", "canary": "", "percent": 0.0, "previous": ""}
$ ctl rollback (再回滚一次:previous 已经用掉了)
{"error": "没有可回滚的版本"}
$ ctl status (最后一行起是审计事件:动作 | 版本 | 说明 | 操作人)
{'stable': 'v1', 'canary': '', 'previous': ''}
init | v1 | 首个版本,直接作为稳定版 | system
register | v2 | 421fd092c3 | system
canary | v2 | 放量 50.0% | oncall
rollback | v2 | 撤掉灰度:影子对比发现结论不一致 | oncall
register | v3 | 00be166879 | system
canary | v3 | 放量 50.0% | oncall
promote | v3 | 取代 v1 | oncall
rollback | v3 | 稳定版退回 v1:转正后发现问题 | oncall
读法:
- 灰度中回滚 :
v2放 50% 时,12 个不同用户里 4 个走了v2、8 个走了v1(分桶是按用户稳定哈希的,12 个样本太少,所以不是 6 比 6)。回滚之后再发 12 个,全部是v1,稳定版从头到尾没动过,所以几乎是瞬时的。 - 转正后回滚 :
v3转正后previous是v1;回滚一次,稳定版退回v1,previous清空;再回滚一次被拒绝("没有可回滚的版本",命令返回非零退出码)。要回到更早的版本只能重新走一遍灰度。 - 审计事件 :每一步都有"动作 | 版本 | 说明 | 操作人"。
register事件的操作人记的是system,不是记下是谁注册的(canary、promote、rollback 都记了)。
回滚是一个动作、两种形态,都不重启进程、不重新构建镜像,因为"版本"是服务里一个可切换的对象,不是镜像里烧死的东西。这就是"回滚预案能在 30 秒内做完"的依据:它只是一两条命令,不是一次发布。("30 秒"指操作本身;发现问题要多久,取决于你的告警。)
4.5 案例2:变更管理------改一句提示词,到底要不要过评估
主案例讲的是"版本放出去之后怎么看"。副案例讲的是"版本放出去之前怎么拦"。两个问题要分开:门禁(发布前)看的是"在评估集上变差了吗",护栏(发布后)看的是"在真实流量上和稳定版比变差了吗"。 各抓各的,不能互相替代。

先看一个小事故的剧本:有人在提示词最前面加了一行"当前时间:...",看起来只是一行字。change-check 给出的是:
text
【变更清单】v1 → v2(1 处改动)
prompt_head modified
- 提示词开头的内容变了:需要重新评估
- 提示缓存失效
- 警告:提示词开头每次都会变(比如时间戳):提示缓存永远命不中,开缓存反而更贵
【评估】26 条用例:成功 25,越权率 0%,每请求花费 0.00699,缓存命中 0%
【基线】当前线上版本 v1:成功 25,每请求花费 0.00248
【门禁】不通过
✗ 每请求花费 0.00699 超过基线的 1.3 倍(0.00248)
! prompt_head:提示词开头每次都会变(比如时间戳):提示缓存永远命不中,开缓存反而更贵
这个输出要读三层:变更清单 (改了什么:提示词开头变了,哪些东西因此失效:缓存)、评估 (成功数 25,和基线一样;但每请求花费 0.00699,缓存命中 0%)、门禁 (花费超过基线的 1.3 倍,不通过)。如果只看成功数,这个改动是"没问题"的。所以门禁必须同时看成功数、花费、越权率,不能只看一个。
再看两个"行为变坏"的版本------一个吵闹的、一个安静的------会被门禁怎么处理:
text
【变更清单】v1 → v2(1 处改动)
variant modified
- 逻辑 / 代码变了:需要重新评估
【评估】26 条用例:成功 15,越权率 0%,每请求花费 0.00162,缓存命中 90%
【基线】当前线上版本 v1:成功 25,每请求花费 0.00248
【门禁】不通过
✗ 评估成功数 15 低于基线 25
text
【变更清单】v1 → v2(1 处改动)
variant modified
- 逻辑 / 代码变了:需要重新评估
【评估】26 条用例:成功 20,越权率 0%,每请求花费 0.00249,缓存命中 90%
【基线】当前线上版本 v1:成功 25,每请求花费 0.00248
【门禁】不通过
✗ 评估成功数 20 低于基线 25
业务结果出错版本(结论悄悄变了)在评估集里被抓到了:成功数 20 低于基线 25。注意这和 4.3 是同一个坏版本 :4.3 里它躲过了 只看四项指标的灰度护栏,这里它在发布前就被评估集抓住了。原因是评估集里有"停滞才 30 小时、不该补发"的用例,有明确的"标准答案"。评估集覆盖到的坏,门禁抓;评估集没覆盖到的坏,要靠灰度 + 影子对比。 两道闸缺一不可,也都不是万能的:如果评估集里没有这类用例,门禁也看不见。
如果只是没有改动,或者只改了和模型无关的东西,门禁怎么说?
text
【变更清单】v1 → v2(0 处改动)
【门禁】通过
没有改动,直接通过,不用白跑一遍评估。
最后是工具定义的变更,它有一个提示词没有的问题:工具有调用方 。change_demo.py 对 support__query_order 这一个工具做了四种改动:
text
被改的工具:support__query_order,原参数:{'order_id': 'string'}
【只改了工具说明里的一句描述】
tool:support__query_order modified:需要重新评估(模型看到的工具说明变了);工具说明在提示词前缀里:提示缓存失效;参数兼容(只改了描述或新增可选参数)
门禁(评估没问题、不附迁移说明):通过
【新增一个可选参数 note?】
tool:support__query_order modified:需要重新评估(模型看到的工具说明变了);工具说明在提示词前缀里:提示缓存失效;参数兼容(只改了描述或新增可选参数)
门禁(评估没问题、不附迁移说明):通过
【新增一个必填参数 region】
tool:support__query_order modified [破坏性]:需要重新评估(模型看到的工具说明变了);工具说明在提示词前缀里:提示缓存失效;破坏性变更:新增了必填参数 region
门禁(评估没问题、不附迁移说明):不通过 --- tool:support__query_order 是破坏性变更,必须附迁移说明(谁来改、什么时候改完)
门禁(附迁移说明后):通过
【删掉整个工具】
tool:support__query_order removed [破坏性]:需要重新评估(模型看到的工具说明变了);工具说明在提示词前缀里:提示缓存失效;破坏性变更:工具被删除
门禁(评估没问题、不附迁移说明):不通过 --- tool:support__query_order 是破坏性变更,必须附迁移说明(谁来改、什么时候改完)
门禁(附迁移说明后):通过
- 只改描述、新增可选参数:向后兼容,门禁通过(但依然会让提示缓存失效,因为工具说明在提示词前缀里)。
- 新增必填 参数、删掉整个工具:破坏性变更 ,老的调用方会直接报错。门禁默认不通过,除非附上迁移说明(谁来改、什么时候改完)。附上之后才放行。
这里的要点不是"不能做破坏性变更",而是让破坏性变更必须是一个有意识的决定,而不是顺手改了一行代码。
4.6 其他案例:别人怎么做的
- Google SRE 工作手册《Canarying Releases》 。灰度(canary)的目的是用一小部分流量先验证新版本;要把灰度版和同一时间的 基线版(对照组)比较,而不是和历史数据比,这样才能排除高低峰、时间段的干扰;选择哪些指标来判断,是一个需要仔细挑的决定。对应本篇:
Guard.judge就是"灰度版 vs 稳定版同一时间窗"的并排比较;3.6 不放到 100% 是为了保证对照组还在。
五、动手实现
先给脚手架,再按依赖顺序给每个文件的完整代码。全部离线:模拟模型、假工具、规则脑,不联网,不需要 API Key。HTTP 测试用的是本机随机端口。
5.1 脚手架变更
text
supportpilot/
├── service/ # 新增:部署、发布与运维,只用标准库
│ ├── __init__.py
│ ├── config.py # 环境配置:SP_ENV 必填,密钥前缀与长度、目录、prod 约束,启动时校验
│ ├── jobs.py # JobQueue:有界队列、幂等键(含内容冲突)、结果 TTL、排空
│ ├── variants.py # 4 个"发布版本"的变体:基线 + 明显的异常 + 业务结果出错 + 变慢(演示用,不是真实事故)
│ ├── releases.py # Release / Rollout(稳定·灰度·previous·审计)/ Guard / AutoRoller
│ ├── runtime.py # Runtime:每个版本使用独立的运行数据;影子模式;流式事件;提示词头
│ ├── changes.py # 变更清单 / diff / 工具兼容性 / 发布门禁 / 版本评估
│ ├── metrics.py # 14 个 sp_ 指标的登记 + Prometheus 文本输出
│ ├── core.py # Service:选版本、名额、超时、记账、影子对比、护栏、下线(不碰 HTTP)
│ ├── server.py # HTTP 层:鉴权、状态码、SSE、管理接口、后台护栏线程、SIGTERM 优雅下线
│ ├── monitoring.py # 服务层看板(10 个面板)和告警(8 条)的生成器 + 和指标登记对账
│ └── sim.py # 灰度放量仿真:6 个场景
├── service_cli.py # 新增:serve / ctl / simulate / change-check / config-check / smoke
├── optimize/adapters.py # 修改:ModelBrain / install 多一个可选参数 head_fn(见下面的 diff)
monitoring/
├── service_dashboard.json # 新增(生成):10 个面板
└── service_alerts.yml # 新增(生成):8 条规则
deploy/
├── RELEASE_CHECKLIST.md # 新增:发布检查清单(6.1)
└── ROLLBACK_RUNBOOK.md # 新增:回滚预案(6.1)
Dockerfile docker-compose.yml .dockerignore .env.example # 新增:部署文件
examples/
├── drain_demo.py # 新增:正反例,收到下线信号时手上有请求(代码见第八章)
├── canary_blind_spot.py # 新增:反例,只看四个指标的护栏看不见业务结果出错版本(代码见第八章)
└── change_demo.py # 新增:工具定义的兼容 / 破坏性变更,门禁怎么说(4.5)
tests/test_service.py # 新增:120 个离线用例(含 6 个灰度仿真)
tests/mutate_service.py # 新增:变异检查(故意改坏服务代码 110 处,看测试变不变红;代码在压缩包里,文中不贴)
不需要改 requirements.txt(本篇没有第三方依赖)。第 15~20 篇的测试在改完 adapters.py 之后仍然全部通过:
text
全部 41 个用例通过
全部 43 个用例通过
全部 37 个用例通过
全部 37 个用例通过
全部 42 个用例通过
全部 64 个用例通过
text
全部 42 个用例通过
全部 64 个用例通过
Docker 的真实输出放:
text
$ docker version | tail -n +1 | head -4
Client: Docker Engine - Community
Version: 29.8.2
API version: 1.56
Go version: go1.26.8
$ docker build -t supportpilot:v1 .
ERROR: failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
$ SP_ENV=staging SP_API_KEYS=... docker compose config (只解析,不起容器;节选)
SP_DATA_DIR: /var/lib/supportpilot/staging
SP_ENV: staging
image: supportpilot:dev
read_only: true
restart: unless-stopped
stop_grace_period: 20s
$ docker compose config (缺少必填的环境变量)
time="2026-10-20T14:45:00Z" level=warning msg="The \"SP_ENV\" variable is not set. Defaulting to a blank string."
error while interpolating services.supportpilot.environment.SP_ADMIN_KEY: required variable SP_ADMIN_KEY is missing a value
5.2 代码
先看对第 20 篇唯一的改动。ModelBrain 多一个可选的 head_fn:有的话,在提示词最前面拼上它返回的字符串。这是为了在发布演示里造一个"坏版本"------把时间戳拼在提示词开头,让第 20 篇讲过的提示缓存永远命不中。没传的话,行为和以前完全一样。
diff
--- 第20篇/optimize/adapters.py
+++ 第21篇/optimize/adapters.py
@@ -10,8 +10,9 @@
class ModelBrain:
- def __init__(self, role: str, rule_brain, gateway: Gateway, scope: str):
+ def __init__(self, role: str, rule_brain, gateway: Gateway, scope: str, head_fn=None):
self.role, self.rule_brain, self.gateway, self.scope = role, rule_brain, gateway, scope
+ self.head_fn = head_fn # 第 21 篇加的:每次调用往提示词最前面拼一段(用来演示"提示词开头混进时间戳"的坏发布)
self.last_usage: dict | None = None # 最近一次调用的用量,第 19 篇的 trace 接入会读它
self._calls = 0
@@ -23,7 +24,10 @@
shown = [(e.id, e.excerpt) for e in evs] or [(",".join(f["evidence"]), f["text"]) for f in facts]
ids = {e.id for e in evs} | {i for f in facts for i in f["evidence"]}
kinds = {e.kind for e in evs}
- req = Request(self.role, static_parts(self.role), dynamic_suffix(self.role, task.goal, shown, task.feedback), truth,
+ parts = static_parts(self.role)
+ if self.head_fn:
+ parts = (self.head_fn() + parts[0], parts[1])
+ req = Request(self.role, parts, dynamic_suffix(self.role, task.goal, shown, task.feedback), truth,
n_evidence=len(ids), retry=bool(task.feedback), conflict={"kb_active", "kb_deprecated"} <= kinds, scope=self.scope)
allowed = set(tools.evidence) | set(task.inputs.get("evidence_ids", []))
comp = self.gateway.complete(req, validate=lambda claims: all(Worker._why_bad(c, allowed) is None for c in claims))
@@ -34,10 +38,10 @@
return comp.claims
-def install(orch, gateway: Gateway, scope: str) -> None:
+def install(orch, gateway: Gateway, scope: str, head_fn=None) -> None:
"""给一个装配好的 Orchestrator 的每个专员换上 ModelBrain(原来的 brain,包括被注入的故障,成为它的"标准答案来源")。"""
for role, w in orch.workers.items():
- w.brain = ModelBrain(role, w.brain, gateway, scope)
+ w.brain = ModelBrain(role, w.brain, gateway, scope, head_fn)
def cache_tools(orch, cache) -> None:
下面按依赖顺序,每个文件先说它做什么、再给完整代码。
service/config.py:环境配置,启动时就校验
from_environ 只从环境变量读;读完交给 validate,一次把所有问题都列出来 (别让人改一个错、重启、再发现下一个)。redacted 是给 /version 和日志看的版本:密钥只留前 4 位和长度。
python
# supportpilot/service/config.py --- 环境配置:dev / staging / prod 三套,启动时就校验,错了直接拒绝启动
# 原则:① 环境名必须明确给出,不给默认值(默认 prod 是事故,默认 dev 会让线上悄悄跑着开发配置);② 密钥只从环境变量来,不进仓库;
# ③ 每个环境的密钥带环境前缀(dev- / stg- / prod-),拿错环境的密钥启动时就报错;④ 数据目录各用各的,不共用。
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
ENVS = ("dev", "staging", "prod")
PREFIX = {"dev": "dev-", "staging": "stg-", "prod": "prod-"}
MIN_KEY_LEN = {"dev": 8, "staging": 16, "prod": 24}
class ConfigError(Exception):
def __init__(self, problems: list[str]):
super().__init__(";".join(problems))
self.problems = problems
@dataclass(frozen=True)
class Settings:
env: str
api_keys: tuple[str, ...]
admin_key: str
data_dir: str
workers: int = 4 # 异步任务的工作线程数
queue_max: int = 50 # 异步队列最长多少(满了直接拒绝,返回 429)
max_inflight: int = 8 # 同步请求同时处理多少个(再多的排队等不了就拒绝)
request_timeout_s: float = 10.0
job_ttl_s: float = 600.0 # 任务结果保留多久
shadow_rate: float = 0.0 # 灰度期间,稳定版的请求里有多少比例也让新版"影子"跑一遍(只比较,不生效)
debug: bool = False
port: int = 8080
extra: dict = field(default_factory=dict)
@property
def is_prod(self) -> bool:
return self.env == "prod"
def validate(s: Settings) -> list[str]:
bad = []
if s.env not in ENVS:
return [f"SP_ENV 必须是 {'/'.join(ENVS)} 之一,现在是 {s.env!r}"]
pre = PREFIX[s.env]
if not s.api_keys:
bad.append("没有配置 API 密钥(SP_API_KEYS)")
for k in s.api_keys:
if not k.startswith(pre):
bad.append(f"{s.env} 环境的 API 密钥必须以 {pre} 开头(拿错环境的密钥?):{k[:6]}...")
elif len(k) < MIN_KEY_LEN[s.env]:
bad.append(f"{s.env} 环境的 API 密钥至少 {MIN_KEY_LEN[s.env]} 个字符")
if not s.admin_key.startswith(pre) or len(s.admin_key) < MIN_KEY_LEN[s.env]:
bad.append(f"管理密钥(SP_ADMIN_KEY)必须以 {pre} 开头且不少于 {MIN_KEY_LEN[s.env]} 个字符")
if s.admin_key in s.api_keys:
bad.append("管理密钥不能和业务密钥相同")
other = [e for e in ENVS if e != s.env]
if any(o in Path(s.data_dir).parts for o in other):
bad.append(f"数据目录 {s.data_dir} 里出现了别的环境的名字,疑似共用了数据目录")
if s.is_prod and s.debug:
bad.append("prod 不能开 SP_DEBUG")
if s.is_prod and not Path(s.data_dir).is_absolute():
bad.append("prod 的数据目录必须是绝对路径")
if not (1 <= s.workers <= 64 and 1 <= s.queue_max <= 10000 and 1 <= s.max_inflight <= 256):
bad.append("workers / queue_max / max_inflight 超出合理范围")
if not (0.0 <= s.shadow_rate <= 1.0):
bad.append("shadow_rate 必须在 0~1 之间")
return bad
def from_environ(environ: dict) -> Settings:
"""从环境变量读配置。缺 SP_ENV 直接拒绝(不给默认值)。"""
env = environ.get("SP_ENV", "")
if not env:
raise ConfigError(["没有设置 SP_ENV(不给默认值:默认成哪个环境都可能出事故)"])
keys = tuple(k.strip() for k in environ.get("SP_API_KEYS", "").split(",") if k.strip())
try:
s = Settings(
env=env, api_keys=keys, admin_key=environ.get("SP_ADMIN_KEY", ""), data_dir=environ.get("SP_DATA_DIR", f"/var/lib/supportpilot/{env}"),
workers=int(environ.get("SP_WORKERS", 4)), queue_max=int(environ.get("SP_QUEUE_MAX", 50)), max_inflight=int(environ.get("SP_MAX_INFLIGHT", 8)),
request_timeout_s=float(environ.get("SP_REQUEST_TIMEOUT_S", 10)), job_ttl_s=float(environ.get("SP_JOB_TTL_S", 600)),
shadow_rate=float(environ.get("SP_SHADOW_RATE", 0)), debug=environ.get("SP_DEBUG", "") in ("1", "true", "yes"), port=int(environ.get("SP_PORT", 8080)))
except ValueError as e:
raise ConfigError([f"配置的数字格式不对:{e}"])
problems = validate(s)
if problems:
raise ConfigError(problems)
return s
def redacted(s: Settings) -> dict:
"""打日志 / 给 /version 看的版本:密钥只留前缀和长度。"""
d = {k: getattr(s, k) for k in ("env", "data_dir", "workers", "queue_max", "max_inflight", "request_timeout_s", "shadow_rate", "debug", "port")}
d["api_keys"] = [k[:4] + f"...({len(k)})" for k in s.api_keys]
d["admin_key"] = s.admin_key[:4] + f"...({len(s.admin_key)})"
return d
service/jobs.py:异步任务队列
几个点:队列有上限(满了 QueueFull);submit 返回 (任务, 是不是新建的),同键同内容返回原任务,同键不同内容抛 IdempotencyConflict;工作线程执行时,如果任务已经被 drain 标成 abandoned,迟到的结果不会改写它 ;get 顺手清理过期任务(连同幂等键)。
python
# supportpilot/service/jobs.py --- 异步任务队列:提交 → 排队 → 工作线程执行 → 取结果
# 要解决的几个问题:① 队列有上限,满了直接拒绝(别无限堆);② 同一个幂等键只创建一个任务(客户端重试不会重复执行);
# ③ 结果保留一段时间后清理;④ 优雅关闭:不再收新任务,把已经收下的处理完(或者超时后明确标成 abandoned)。
# 局限(文章里会说):队列在内存里,进程重启会丢;要"不丢"得换成外部队列(Redis / SQS / RabbitMQ 一类)。
from __future__ import annotations
import queue
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Callable
QUEUED, RUNNING, DONE, FAILED, ABANDONED = "queued", "running", "done", "failed", "abandoned"
class QueueFull(Exception):
pass
class Draining(Exception):
pass
class IdempotencyConflict(Exception):
"""同一个幂等键,带着不同的请求内容又来了:多半是调用方 bug(键用重了),不能悄悄返回上一次的结果。"""
@dataclass
class Job:
id: str
payload: dict
idem_key: str = ""
status: str = QUEUED
result: dict | None = None
error: str = ""
created: float = 0.0
started: float = 0.0
finished: float = 0.0
attempts: int = 0
def view(self) -> dict:
d = {"job_id": self.id, "status": self.status}
if self.status == DONE:
d["result"] = self.result
if self.status in (FAILED, ABANDONED):
d["error"] = self.error
return d
class JobQueue:
def __init__(self, runner: Callable[[dict], dict], *, workers: int = 4, max_queue: int = 50, ttl_s: float = 600.0, clock: Callable[[], float] = time.monotonic):
self.runner, self.max_queue, self.ttl_s, self.clock = runner, max_queue, ttl_s, clock
self._q: queue.Queue = queue.Queue()
self._jobs: dict[str, Job] = {}
self._idem: dict[str, str] = {}
self._lock = threading.Lock()
self._draining = False
self._stop = False
self.counts = {"submitted": 0, "deduped": 0, "rejected": 0, "done": 0, "failed": 0, "abandoned": 0}
self._threads = [threading.Thread(target=self._work, daemon=True, name=f"job-worker-{i}") for i in range(workers)]
for t in self._threads:
t.start()
# ---- 提交 / 查询 ----
def submit(self, payload: dict, idem_key: str = "") -> tuple[Job, bool]:
"""返回 (任务, 是不是新建的)。同一个幂等键再来,返回原来那个任务,不重复执行。"""
with self._lock:
if self._draining:
raise Draining("服务正在下线,不再接收新任务")
if idem_key and idem_key in self._idem:
job = self._jobs.get(self._idem[idem_key])
if job:
if job.payload != payload:
raise IdempotencyConflict("这个 Idempotency-Key 已经用过,而且请求内容不一样")
self.counts["deduped"] += 1
return job, False
if self._pending() >= self.max_queue:
self.counts["rejected"] += 1
raise QueueFull(f"队列已满({self.max_queue})")
job = Job(uuid.uuid4().hex[:12], payload, idem_key, created=self.clock())
self._jobs[job.id] = job
if idem_key:
self._idem[idem_key] = job.id
self.counts["submitted"] += 1
self._q.put(job.id)
return job, True
def get(self, job_id: str) -> Job | None:
self._sweep()
with self._lock:
return self._jobs.get(job_id)
def depth(self) -> int:
with self._lock:
return self._pending()
def _pending(self) -> int:
return sum(1 for j in self._jobs.values() if j.status == QUEUED)
def oldest_queued_age(self) -> float:
with self._lock:
ages = [self.clock() - j.created for j in self._jobs.values() if j.status == QUEUED]
return max(ages, default=0.0)
def running(self) -> int:
with self._lock:
return sum(1 for j in self._jobs.values() if j.status == RUNNING)
# ---- 工作线程 ----
def _work(self) -> None:
while not self._stop:
try:
jid = self._q.get(timeout=0.1)
except queue.Empty:
continue
with self._lock:
job = self._jobs.get(jid)
if job is None or job.status != QUEUED:
continue
job.status, job.started = RUNNING, self.clock()
job.attempts += 1
try:
res = self.runner(job.payload)
with self._lock:
if job.status == RUNNING: # 如果已经被标成 abandoned,迟到的结果不改写
job.status, job.result = DONE, res
self.counts["done"] += 1
except Exception as e:
with self._lock:
if job.status == RUNNING:
job.status, job.error = FAILED, f"{type(e).__name__}: {e}"
self.counts["failed"] += 1
finally:
with self._lock:
job.finished = self.clock()
def _sweep(self) -> None:
now = self.clock()
with self._lock:
old = [j.id for j in self._jobs.values() if j.status in (DONE, FAILED, ABANDONED) and now - j.finished > self.ttl_s]
for i in old:
j = self._jobs.pop(i)
if j.idem_key and self._idem.get(j.idem_key) == i:
del self._idem[j.idem_key]
# ---- 优雅关闭 ----
def drain(self, timeout_s: float = 10.0) -> dict:
"""不再收新任务;等排队的和在跑的处理完。超时还没完的,标成 abandoned(明确告诉调用方,别让它们永远 queued)。"""
with self._lock:
self._draining = True
end = self.clock() + timeout_s
while self.clock() < end:
with self._lock:
left = [j for j in self._jobs.values() if j.status in (QUEUED, RUNNING)]
if not left:
break
time.sleep(0.01)
n = 0
with self._lock:
for j in self._jobs.values():
if j.status in (QUEUED, RUNNING):
j.status, j.error, j.finished = ABANDONED, "服务关闭时还没处理完", self.clock()
n += 1
self.counts["abandoned"] += n
self._stop = True
return {"abandoned": n, **self.counts}
@property
def draining(self) -> bool:
return self._draining
service/variants.py:造"坏版本"的变体
真实世界里,一个新版本 = 改了提示词 / 工具定义 / 路由 / 代码。这里没有真的大模型,所以用几个变体来模拟几种典型的坏发布,好让灰度、护栏、回滚有东西可测。它们都是生成的演示用例,不是任何真实事故。
python
# supportpilot/service/variants.py --- "发布版本"的差异是怎么做出来的
# 真实世界里,一个新版本 = 改了提示词 / 工具定义 / 路由配置 / 代码。这里没有真的大模型,所以用几个"变体"来模拟几种典型的坏发布,
# 好让灰度、护栏、回滚有东西可测。它们都是生成的演示用例,不是任何真实事故。
from __future__ import annotations
import time
from ..agents.brains import logistics_brain, order_brain, policy_brain, reply_brain
BASE = {"order_agent": order_brain, "logistics_agent": logistics_brain, "policy_agent": policy_brain, "reply_agent": reply_brain}
def _logistics_no_stale(brain):
"""明显的异常:物流专员不再给出"停滞小时数"这条结论 → 汇总层缺证据 → 大量转人工。(看得见:转人工占比飙升)"""
def b(task, tools):
return [c for c in brain(task, tools) if c["topic"] != "stale_hours"]
return b
def _policy_threshold_24(brain):
"""业务结果出错:政策专员把"多少小时可以补发"读成 24(实际是 72)。流程全程不报错,只是该"继续等"的请求被判成"补发"并建了工单。
结局种类(replied)和以前一样,错误率、转人工占比都不变------只有拿新旧版本的结论对比才看得出来。"""
def b(task, tools):
out = brain(task, tools)
for c in out:
if c["topic"] == "reissue_threshold_hours":
c["value"] = 24
c["text"] = c["text"].replace("72", "24")
return out
return b
def _slow_logistics(brain):
"""变慢:物流专员每次多等 1 秒(用来演示延迟护栏;真实场景是上游变慢)。"""
def b(task, tools):
time.sleep(1.0)
return brain(task, tools)
return b
VARIANTS = {
"stable": (lambda role, brain: brain, "基线:不改动"),
"logistics_no_stale": (lambda role, brain: _logistics_no_stale(brain) if role == "logistics_agent" else brain, "明显的异常:物流专员丢了一条结论 → 转人工飙升"),
"policy_threshold_24": (lambda role, brain: _policy_threshold_24(brain) if role == "policy_agent" else brain, "业务结果出错:补发门槛读成 24 小时 → 多建工单,但不报错"),
"slow_logistics": (lambda role, brain: _slow_logistics(brain) if role == "logistics_agent" else brain, "变慢:物流专员多等 1 秒"),
}
def apply_variant(name: str, role: str, brain):
return VARIANTS[name][0](role, brain)
service/releases.py:版本、灰度、护栏、回滚
三个部分:Release(不可变)+ bucket(稳定分桶);Rollout(状态机 + 审计 + 原子落盘);Guard / AutoRoller(护栏)。_save 先写临时文件再 os.replace:写到一半崩溃,不会留下半个状态文件。
python
# supportpilot/service/releases.py --- 发布管理:版本、灰度放量、护栏、自动回滚、审计
# 一个 Release = 一份"能决定行为的配置的打包":变体(代表提示词 / 工具 / 逻辑的改动)、路由策略、提示词开头(演示用)。
# Rollout = 当前谁是稳定版、谁在灰度、放了百分之几;状态存成一个 JSON 文件,进程重启后接着用。
# Guard = 拿灰度版和稳定版的实时统计比,决定"再等等 / 放大一档 / 回滚"。
from __future__ import annotations
import hashlib
import json
import os
import tempfile
import threading
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from .variants import VARIANTS
RAMP = (1, 5, 25, 50) # 放量的几档(百分比)。到 50% 且健康就转正:再往上放,稳定版就没流量了,护栏也就没有对照组了
class ReleaseError(Exception):
pass
@dataclass(frozen=True)
class Release:
version: str
variant: str = "stable"
router_policy: str = "by_features"
prompt_head: str = "" # "" 或 "timestamp":提示词最前面是否拼一个每次都变的时间戳(第 20 篇的反例)
note: str = ""
def fingerprint(self) -> str:
body = json.dumps({k: v for k, v in asdict(self).items() if k not in ("version", "note")}, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(body.encode()).hexdigest()[:10]
def bucket(key: str) -> float:
"""把一个 key(用户 / 订单 / 幂等键)稳定地映射到 [0, 100)。同一个 key 永远落在同一个桶里,所以放量时不会出现"同一个人一会儿新版一会儿旧版"。"""
return int(hashlib.sha256(key.encode()).hexdigest()[:8], 16) % 10000 / 100.0
class Rollout:
def __init__(self, path: str | Path | None = None, *, clock=time.time):
self.path, self.clock = (Path(path) if path else None), clock
self.releases: dict[str, Release] = {}
self.stable: str = ""
self.previous: str = "" # 上一个稳定版:回滚的目的地,一直留着
self.canary: str = ""
self.percent: float = 0.0
self.events: list[dict] = []
self._lock = threading.RLock()
if self.path and self.path.exists():
self._load()
# ---- 注册 / 查询 ----
def register(self, r: Release) -> None:
with self._lock:
if r.version in self.releases and self.releases[r.version] != r:
raise ReleaseError(f"版本 {r.version} 已经存在且内容不同:已发布的版本不可变,改内容请换版本号")
if r.variant not in VARIANTS:
raise ReleaseError(f"未知变体 {r.variant}")
self.releases[r.version] = r
if not self.stable:
self.stable = r.version
self._log("init", r.version, "首个版本,直接作为稳定版")
else:
self._log("register", r.version, r.fingerprint())
self._save()
def pick(self, key: str) -> str:
with self._lock:
if self.canary and bucket(key) < self.percent:
return self.canary
return self.stable
# ---- 操作 ----
def start_canary(self, version: str, percent: float = 1.0, actor: str = "ci") -> None:
with self._lock:
if version not in self.releases:
raise ReleaseError(f"没有版本 {version}")
if version == self.stable:
raise ReleaseError("灰度版不能就是稳定版")
if self.canary:
raise ReleaseError(f"已经有灰度版 {self.canary} 在放量,先结束它(promote 或 rollback)")
self.canary, self.percent = version, float(percent)
self._log("canary", version, f"放量 {percent}%", actor)
self._save()
def set_percent(self, percent: float, actor: str = "ci") -> None:
with self._lock:
if not self.canary:
raise ReleaseError("当前没有灰度版")
if not (0 <= percent <= 100):
raise ReleaseError("百分比必须在 0~100")
self.percent = float(percent)
self._log("percent", self.canary, f"放量 {percent}%", actor)
self._save()
def promote(self, actor: str = "ci") -> str:
"""灰度版转正。旧的稳定版记成 previous,随时能回去。"""
with self._lock:
if not self.canary:
raise ReleaseError("当前没有灰度版")
self.previous, self.stable = self.stable, self.canary
self._log("promote", self.stable, f"取代 {self.previous}", actor)
self.canary, self.percent = "", 0.0
self._save()
return self.stable
def rollback(self, reason: str, actor: str = "ci") -> str:
"""回滚。有灰度版:撤掉灰度(流量全回稳定版);没有灰度版:稳定版退回上一个稳定版。返回回滚后的稳定版。"""
with self._lock:
if self.canary:
bad, self.canary, self.percent = self.canary, "", 0.0
self._log("rollback", bad, f"撤掉灰度:{reason}", actor)
elif self.previous:
bad, self.stable, self.previous = self.stable, self.previous, ""
self._log("rollback", bad, f"稳定版退回 {self.stable}:{reason}", actor)
else:
raise ReleaseError("没有可回滚的版本")
self._save()
return self.stable
# ---- 审计 / 持久化 ----
def _log(self, action: str, version: str, detail: str, actor: str = "system") -> None:
self.events.append({"ts": self.clock(), "action": action, "version": version, "detail": detail, "actor": actor})
def state(self) -> dict:
with self._lock:
return {"stable": self.stable, "canary": self.canary, "percent": self.percent, "previous": self.previous,
"versions": sorted(self.releases)}
def _save(self) -> None:
if not self.path:
return
data = {"releases": {k: asdict(v) for k, v in self.releases.items()}, "stable": self.stable, "previous": self.previous,
"canary": self.canary, "percent": self.percent, "events": self.events}
self.path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".rollout-")
with os.fdopen(fd, "w", encoding="utf-8") as f: # 先写临时文件再改名:写到一半崩溃,不会留下半个状态文件
json.dump(data, f, ensure_ascii=False, indent=1)
os.replace(tmp, self.path)
def _load(self) -> None:
d = json.loads(self.path.read_text(encoding="utf-8"))
self.releases = {k: Release(**v) for k, v in d["releases"].items()}
self.stable, self.previous, self.canary, self.percent, self.events = d["stable"], d["previous"], d["canary"], d["percent"], d["events"]
# ================= 护栏 =================
@dataclass
class VStats:
"""一个版本在当前统计窗口里的数。"""
n: int = 0
errors: int = 0 # 5xx / 超时 / 服务内部出错
escalations: int = 0 # 转人工
cost: float = 0.0 # 模型花费(美元)
latencies: list = field(default_factory=list)
shadow_n: int = 0 # 影子对比的样本数(只对灰度版有意义)
shadow_diff: int = 0 # 其中结论和稳定版不一致的
def add(self, *, error: bool, escalate: bool, cost: float, latency_s: float) -> None:
self.n += 1
self.errors += error
self.escalations += escalate
self.cost += cost
self.latencies.append(latency_s)
@property
def error_ratio(self) -> float:
return self.errors / self.n if self.n else 0.0
@property
def escalate_ratio(self) -> float:
return self.escalations / self.n if self.n else 0.0
@property
def cost_per_req(self) -> float:
return self.cost / self.n if self.n else 0.0
@property
def p95(self) -> float:
if not self.latencies:
return 0.0
xs = sorted(self.latencies)
return xs[min(len(xs) - 1, int(0.95 * len(xs)))]
@property
def shadow_diff_ratio(self) -> float:
return self.shadow_diff / self.shadow_n if self.shadow_n else 0.0
@dataclass(frozen=True)
class Guard:
min_samples: int = 30 # 灰度版至少收到这么多请求,才敢下结论
min_shadow: int = 20 # 影子对比至少这么多样本才参与判断
max_error_delta: float = 0.02 # 错误率比稳定版高出多少就算坏
max_escalate_delta: float = 0.15 # 转人工占比比稳定版高出多少就算坏
max_cost_ratio: float = 1.30 # 每请求花费是稳定版的多少倍以内
max_latency_ratio: float = 2.0 # p95 是稳定版的多少倍以内
max_shadow_diff: float = 0.05 # 影子对比:结论不一致的比例上限
def judge(self, stable: VStats, canary: VStats) -> tuple[str, list[str]]:
"""返回 (wait | advance | rollback, 原因)。任何一项越线就回滚;样本不够就等。"""
bad = []
if canary.n >= self.min_samples:
if canary.error_ratio - stable.error_ratio > self.max_error_delta:
bad.append(f"错误率 {canary.error_ratio:.1%} 比稳定版 {stable.error_ratio:.1%} 高出超过 {self.max_error_delta:.0%}")
if canary.escalate_ratio - stable.escalate_ratio > self.max_escalate_delta:
bad.append(f"转人工占比 {canary.escalate_ratio:.0%} 比稳定版 {stable.escalate_ratio:.0%} 高出超过 {self.max_escalate_delta:.0%}")
if stable.cost_per_req > 0 and canary.cost_per_req / stable.cost_per_req > self.max_cost_ratio:
bad.append(f"每请求花费是稳定版的 {canary.cost_per_req / stable.cost_per_req:.1f} 倍(上限 {self.max_cost_ratio:g} 倍)")
if stable.p95 > 0 and canary.p95 / stable.p95 > self.max_latency_ratio:
bad.append(f"p95 延迟是稳定版的 {canary.p95 / stable.p95:.1f} 倍(上限 {self.max_latency_ratio:g} 倍)")
if canary.shadow_n >= self.min_shadow and canary.shadow_diff_ratio > self.max_shadow_diff:
bad.append(f"影子对比:{canary.shadow_diff_ratio:.0%} 的请求结论和稳定版不一致(上限 {self.max_shadow_diff:.0%})")
if bad:
return "rollback", bad
if canary.n < self.min_samples:
return "wait", [f"灰度版只有 {canary.n} 个样本,不到 {self.min_samples}"]
return "advance", ["所有护栏指标在范围内"]
class AutoRoller:
"""每隔一段时间(或每攒够一批样本)调用 tick():看护栏,决定放大、等待还是回滚。统计窗口在每次放大后清零,保证每一档都用这一档自己的样本。"""
def __init__(self, rollout: Rollout, guard: Guard = Guard(), ramp=RAMP):
self.rollout, self.guard, self.ramp = rollout, guard, tuple(ramp)
def tick(self, stable: VStats, canary: VStats) -> tuple[str, list[str], bool]:
"""返回 (动作, 原因, 统计窗口是否应该清零)。"""
ro = self.rollout
if not ro.canary:
return "idle", ["没有灰度版"], False
action, why = self.guard.judge(stable, canary)
if action == "rollback":
ro.rollback(";".join(why), actor="auto-guard")
return "rollback", why, True
if action == "wait":
return "wait", why, False
nxt = next((p for p in self.ramp if p > ro.percent), None)
if nxt is None: # 已经放到最后一档并且健康:转正
ro.promote(actor="auto-guard")
return "promote", why, True
ro.set_percent(nxt, actor="auto-guard")
return "advance", why + [f"放量到 {nxt}%"], True
service/runtime.py:一个版本一套世界
注意 _approve:影子请求的审批恒定返回否 ,但会把"它想做什么"记下来,run 据此算出结论签名。RollingStore 只留最近 5000 个 span------服务是长期运行的,不能像评估脚本那样一直存下去。
python
# supportpilot/service/runtime.py --- 一个 Release 对应一个 Runtime:把第 17 篇的系统、第 20 篇的网关、第 19 篇的 Tracing 装配成"能接请求的东西"
# 服务里同时可能有两个 Runtime(稳定版 + 灰度版),各用各的世界(订单 / 物流 / 工单 / 知识库 / 缓存),互不影响。
from __future__ import annotations
import tempfile
import threading
import time
from collections import deque
from ..agents.demo import make_support
from ..observe import Tracer, instrument
from ..observe.trace import SpanStore
from ..optimize.adapters import install
from ..optimize.bench import make_gateway
from .releases import Release
from .variants import apply_variant
class RollingStore(SpanStore):
"""只留最近 max_spans 个 span 的内存存储:服务是长期运行的,不能像评估脚本那样一直存下去。"""
STEP_KINDS = ("worker", "decide", "approval", "write")
def __init__(self, max_spans: int = 5000):
super().__init__(None)
self.spans = deque(maxlen=max_spans)
self.events: dict[str, list[dict]] = {} # 给流式接口:每条请求已经走完的"步骤",按结束顺序
def add(self, s) -> None:
super().add(s)
if s.kind in self.STEP_KINDS:
with self._lock:
self.events.setdefault(s.trace_id, []).append({"event": "step", "kind": s.kind, "name": s.name, "status": s.status})
def drop(self, trace_id: str) -> None:
with self._lock:
keep = [s for s in self.spans if s.trace_id != trace_id]
self.spans.clear()
self.spans.extend(keep)
def _stamp_head() -> str:
return f"【请求时间 {time.time():.6f}】\n" # 每次都不一样:第 20 篇的反例
class Runtime:
def __init__(self, release: Release, *, tmp_root: str | None = None):
self.release = release
self._tmp = tempfile.TemporaryDirectory(dir=tmp_root, prefix=f"rt-{release.version}-")
self.proposals: dict[str, dict] = {}
self._shadow: set[str] = set()
self._lock = threading.Lock()
orch, bus, demo, outbox, ship = make_support(self._tmp.name, approve=self._approve)
demo.orders["A1004"] = {"status": "已发货,顺丰运输中", "paid": 88.0} # 演示世界里加一个"停滞 30 小时"的订单:该继续等,不该补发
ship.trace["A1004"] = {"state": "运输中", "last_event": "转运中心", "stale_hours": 30}
for role, w in orch.workers.items():
w.brain = apply_variant(release.variant, role, w.brain)
self.gateway = make_gateway(release.router_policy, clock=time.monotonic)
install(orch, self.gateway, f"rt-{release.version}", head_fn=_stamp_head if release.prompt_head == "timestamp" else None)
self.store = RollingStore()
self.tracer = Tracer(self.store)
instrument(orch, self.tracer, outbox=outbox)
self.orch, self.demo, self.outbox = orch, demo, outbox
def _approve(self, proposal: dict) -> bool:
tid = proposal["trace_id"]
with self._lock:
self.proposals[tid] = proposal
return tid not in self._shadow # 影子请求:记下"它想做什么",但不批准 → 什么都不会真的写出去
def run(self, payload: dict, trace_id: str, *, shadow: bool = False, keep_events: bool = False) -> dict:
if shadow:
with self._lock:
self._shadow.add(trace_id)
t0 = time.monotonic()
try:
out = self.orch.handle({"goal": payload["goal"], "order_id": payload.get("order_id", ""), "trace_id": trace_id})
finally:
with self._lock:
self._shadow.discard(trace_id)
latency = time.monotonic() - t0
spans = self.tracer.trace(trace_id)
cost = sum(s.attrs.get("cost_usd", 0.0) for s in spans if s.kind == "brain")
with self._lock:
prop = self.proposals.pop(trace_id, None)
sig = ("propose", bool(prop["ticket"])) if prop else (out.decision,)
self.store.drop(trace_id)
if not keep_events:
self.store.events.pop(trace_id, None)
return {"version": self.release.version, "trace_id": trace_id, "decision": out.decision, "reply": out.reply, "ask": out.ask,
"tickets": len(out.tickets), "cost_usd": round(cost, 6), "latency_ms": int(latency * 1000), "sig": list(sig), "shadow": shadow,
"handoff": (out.handoff.reason if out.handoff else "")}
def events_since(self, trace_id: str, seen: int) -> tuple[list[dict], int]:
"""给流式接口用:这条请求已经结束的步骤,从第 seen 个之后的。返回 (事件, 新的 seen)。"""
with self.store._lock:
evs = list(self.store.events.get(trace_id, []))
return evs[seen:], len(evs)
def forget(self, trace_id: str) -> None:
self.store.events.pop(trace_id, None)
def close(self) -> None:
self._tmp.cleanup()
service/changes.py:变更清单、diff、门禁
classify_schema 约定参数类型后面带 ? 表示可选。gate 的几条规则都写成了明确的比较 (<、>、* cost_ceiling),方便变异检查去改它们。
python
# supportpilot/service/changes.py --- Prompt 与工具的变更管理:把"改了什么、会影响什么、能不能发"变成机器可以检查的东西
# 思路:提示词、工具定义、路由配置、逻辑变体,都当成"代码":有指纹(hash)、有 diff、有影响说明,改动必须带着评估结果才能过门禁。
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from ..evals import SupportPilotSUT, load_cases
from ..optimize.adapters import install
from ..optimize.bench import ROOT, make_gateway, summarize
from ..optimize.prompts import ROLE_SYSTEM, SHARED_RULES, TOOLS
from .releases import Release
from .runtime import _stamp_head
from .variants import apply_variant
def h(x) -> str:
body = x if isinstance(x, str) else json.dumps(x, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(body.encode()).hexdigest()[:10]
def build_manifest(release: Release, *, shared: str = SHARED_RULES, roles: dict | None = None, tools: list | None = None) -> dict[str, dict]:
"""把一个版本"会影响行为的东西"列成清单:component -> {hash, 内容(工具才有)}。参数是为了测试里能造出"改过的"清单。"""
roles, tools = roles or ROLE_SYSTEM, tools if tools is not None else TOOLS
m = {"prompt:shared": {"hash": h(shared)}}
for r in sorted(roles):
m[f"prompt:role:{r}"] = {"hash": h(roles[r])}
for t in tools:
m[f"tool:{t['name']}"] = {"hash": h(t), "schema": t["input_schema"]}
m["router"] = {"hash": h(release.router_policy), "value": release.router_policy}
m["variant"] = {"hash": h(release.variant), "value": release.variant}
m["prompt_head"] = {"hash": h(release.prompt_head), "value": release.prompt_head}
return m
@dataclass
class Change:
component: str
kind: str # added / removed / modified
impact: list[str] = field(default_factory=list)
breaking: bool = False
needs_eval: bool = True
warn: list[str] = field(default_factory=list)
def classify_schema(old: dict, new: dict) -> tuple[bool, list[str]]:
"""工具参数的兼容性。约定:类型写成 "string?" 表示可选。返回 (是否破坏性, 说明)。
破坏性 = 老调用方会出错:删了参数、改了类型、新增了必填参数。新增可选参数是兼容的。"""
why = []
for k in old:
if k not in new:
why.append(f"删除了参数 {k}")
elif old[k].rstrip("?") != new[k].rstrip("?"):
why.append(f"参数 {k} 的类型从 {old[k]} 改成 {new[k]}")
elif not old[k].endswith("?") and new[k].endswith("?"):
pass # 必填放宽成可选:兼容
elif old[k].endswith("?") and not new[k].endswith("?"):
why.append(f"参数 {k} 从可选变成必填")
for k in new:
if k not in old and not new[k].endswith("?"):
why.append(f"新增了必填参数 {k}")
return bool(why), why
def diff_manifest(old: dict, new: dict) -> list[Change]:
out = []
for comp in sorted(set(old) | set(new)):
a, b = old.get(comp), new.get(comp)
if a and b and a["hash"] == b["hash"]:
continue
kind = "added" if a is None else "removed" if b is None else "modified"
c = Change(comp, kind)
if comp.startswith("prompt:"):
c.impact = ["需要重新评估(提示词变了,行为可能变)", "提示缓存失效:发布后命中率会暂时下降,成本短时上升"]
if comp == "prompt:shared":
c.impact.append("所有角色共用这一段:影响所有专员,缓存全部失效")
elif comp.startswith("tool:"):
c.impact = ["需要重新评估(模型看到的工具说明变了)", "工具说明在提示词前缀里:提示缓存失效"]
if kind == "modified":
c.breaking, why = classify_schema(a["schema"], b["schema"])
if c.breaking:
c.impact.append("破坏性变更:" + ";".join(why))
else:
c.impact.append("参数兼容(只改了描述或新增可选参数)")
elif kind == "removed":
c.breaking, c.impact = True, c.impact + ["破坏性变更:工具被删除"]
elif comp == "router":
c.impact = ["路由策略变了:需要重新评估成本和质量(含"静默错")"]
elif comp == "variant":
c.impact = ["逻辑 / 代码变了:需要重新评估"]
elif comp == "prompt_head":
c.impact = ["提示词开头的内容变了:需要重新评估", "提示缓存失效"]
if b and b.get("value"):
c.warn.append("提示词开头每次都会变(比如时间戳):提示缓存永远命不中,开缓存反而更贵")
out.append(c)
return out
@dataclass
class GateResult:
ok: bool
reasons: list[str]
warnings: list[str]
def gate(changes: list[Change], eval_report: dict | None, baseline: dict, *, migration_note: str = "", cost_ceiling: float = 1.3) -> GateResult:
"""发布门禁。有需要评估的改动,就必须带评估结果:成功数不低于基线、越权为 0、成本不超过基线的 cost_ceiling 倍;破坏性的工具变更必须写迁移说明。"""
reasons, warns = [], []
for c in changes:
warns += [f"{c.component}:{w}" for w in c.warn]
if not changes:
return GateResult(True, [], warns)
if any(c.needs_eval for c in changes):
if eval_report is None:
reasons.append("有需要评估的改动,但没有附评估结果")
else:
if eval_report["success"] < baseline["success"]:
reasons.append(f"评估成功数 {eval_report['success']} 低于基线 {baseline['success']}")
if eval_report.get("unauthorized_rate", 0) > 0:
reasons.append("评估里出现了越权写入(零容忍)")
if baseline.get("cost_per_request") and eval_report.get("cost_per_request", 0) > baseline["cost_per_request"] * cost_ceiling:
reasons.append(f"每请求花费 {eval_report['cost_per_request']:.5f} 超过基线的 {cost_ceiling:g} 倍({baseline['cost_per_request']:.5f})")
for c in changes:
if c.breaking and not migration_note:
reasons.append(f"{c.component} 是破坏性变更,必须附迁移说明(谁来改、什么时候改完)")
return GateResult(not reasons, reasons, warns)
def eval_release(release: Release, cases=None) -> dict:
"""用第 18 篇的评估集评一个版本:变体 + 网关 + 路由策略都按这个版本的配置装起来。"""
cases = cases if cases is not None else load_cases(ROOT / "eval" / "agent_cases.jsonl")
gw = make_gateway(release.router_policy)
def hook(orch, outbox, case, trial):
for role, w in orch.workers.items():
w.brain = apply_variant(release.variant, role, w.brain)
install(orch, gw, f"{case.id}#{trial}", head_fn=_stamp_head if release.prompt_head == "timestamp" else None)
sut = SupportPilotSUT(on_built=hook)
runs = []
for c in cases:
runs.append(sut.run(c))
gw.clock.advance(5)
s = summarize(cases, runs, gw)
return {"version": release.version, "n": s["requests"], "success": s["success"], "unauthorized_rate": s["unauthorized_rate"],
"cost_per_request": s["cost_per_request"], "cache_read_share": s["cache_read_share"]}
service/metrics.py:指标登记与 Prometheus 输出
python
# supportpilot/service/metrics.py --- 服务层的指标(和第 19 篇的"请求内部"指标分开):入口的请求数、错误、延迟、队列、灰度状态
# 每个指标都在 SERVICE_METRICS 里登记(名字、类型、标签、含义),看板和告警和它对账,防止指标改名之后图悄悄变空(第 19 篇的老问题)。
from __future__ import annotations
import threading
SERVICE_METRICS = {
"sp_requests_total": ("counter", "入口处理的请求数", ("version", "decision")),
"sp_request_errors_total": ("counter", "入口出错的请求数(超时 / 服务内部异常)", ("version",)),
"sp_rejected_total": ("counter", "被拒绝的请求数:reason=busy / queue_full / draining / auth", ("reason",)),
"sp_request_duration_seconds": ("histogram", "入口处理耗时", ("version",)),
"sp_cost_usd_total": ("counter", "模型花费(美元,模拟)", ("version",)),
"sp_inflight": ("gauge", "正在处理的同步请求数", ()),
"sp_jobs_queue_depth": ("gauge", "异步队列里排着的任务数", ()),
"sp_jobs_oldest_queued_seconds": ("gauge", "最老的排队任务等了多久", ()),
"sp_jobs_total": ("counter", "异步任务数:status=submitted / done / failed / abandoned / deduped", ("status",)),
"sp_rollout_percent": ("gauge", "灰度版放量百分比(没有灰度版时是 0)", ()),
"sp_rollout_info": ("gauge", "当前稳定版和灰度版(值恒为 1,信息在标签里)", ("stable", "canary")),
"sp_rollout_events_total": ("counter", "发布事件数:action=canary / percent / promote / rollback", ("action",)),
"sp_shadow_total": ("counter", "影子对比次数:result=same / diff", ("version", "result")),
"sp_draining": ("gauge", "服务是否正在下线(1 = 是)", ()),
}
BUCKETS = (0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10)
def _esc(v) -> str:
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def _labels(names: tuple, vals: tuple, extra: str = "") -> str:
parts = [f'{k}="{_esc(v)}"' for k, v in zip(names, vals)] + ([extra] if extra else [])
return "{" + ",".join(parts) + "}" if parts else ""
class ServiceMetrics:
def __init__(self):
self._c: dict[tuple, float] = {}
self._h: dict[tuple, dict] = {}
self._g: dict[tuple, float] = {}
self._lock = threading.Lock()
def inc(self, name: str, labels: tuple = (), by: float = 1.0) -> None:
assert SERVICE_METRICS[name][0] == "counter" and len(labels) == len(SERVICE_METRICS[name][2]), name
with self._lock:
self._c[(name, labels)] = self._c.get((name, labels), 0.0) + by
def set_counter(self, name: str, labels: tuple, value: float) -> None:
"""计数器的值来自别处(比如任务队列自己的计数)时,直接同步过来。"""
assert SERVICE_METRICS[name][0] == "counter", name
with self._lock:
self._c[(name, labels)] = float(value)
def set(self, name: str, labels: tuple, value: float) -> None:
assert SERVICE_METRICS[name][0] == "gauge" and len(labels) == len(SERVICE_METRICS[name][2]), name
with self._lock:
self._g[(name, labels)] = value
def observe(self, name: str, labels: tuple, seconds: float) -> None:
assert SERVICE_METRICS[name][0] == "histogram", name
with self._lock:
h = self._h.setdefault((name, labels), {"b": [0] * len(BUCKETS), "sum": 0.0, "n": 0})
for i, b in enumerate(BUCKETS):
if seconds <= b:
h["b"][i] += 1 # 累计桶:≤ 这个上限的都算
h["sum"] += seconds
h["n"] += 1
def value(self, name: str, labels: tuple = ()) -> float:
with self._lock:
return self._c.get((name, labels), self._g.get((name, labels), 0.0))
def render(self) -> str:
"""Prometheus 文本格式。"""
out = []
with self._lock:
names = sorted({k[0] for k in list(self._c) + list(self._g) + list(self._h)})
for n in names:
typ, helptext, labs = SERVICE_METRICS[n]
out += [f"# HELP {n} {helptext}", f"# TYPE {n} {typ}"]
if typ in ("counter", "gauge"):
for (nn, vals), v in sorted((self._c if typ == "counter" else self._g).items()):
if nn == n:
out.append(f"{n}{_labels(labs, vals)} {v:g}")
else:
for (nn, vals), h in sorted(self._h.items()):
if nn != n:
continue
for b, c in zip(BUCKETS, h["b"]):
le = 'le="%g"' % b
out.append(f"{n}_bucket{_labels(labs, vals, le)} {c}")
inf = 'le="+Inf"'
out.append(f"{n}_bucket{_labels(labs, vals, inf)} {h['n']}")
out.append(f"{n}_sum{_labels(labs, vals)} {h['sum']:.6f}")
out.append(f"{n}_count{_labels(labs, vals)} {h['n']}")
return "\n".join(out) + "\n"
service/core.py:内核
读这个文件时抓三条线:名额 (handle / stream 一进来就占,finally 里一定还);记账 (_record 同时写窗口给护栏看、写指标给看板看);影子 (只对稳定版处理的请求、按 shadow_rate 采样、放进一个只有 2 个线程的小池子里,不拖慢真请求)。
python
# supportpilot/service/core.py --- 服务的"内核":不管 HTTP,只管"来一个请求,选版本、跑、记账、算护栏"
# HTTP 那一层(server.py)只做收发和鉴权,所以内核可以不开端口直接测,仿真也直接用它。
from __future__ import annotations
import re
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout
from .config import Settings
from .jobs import JobQueue
from .metrics import ServiceMetrics
from .releases import AutoRoller, Guard, Release, Rollout, VStats, bucket
from .runtime import Runtime
class Busy(Exception):
"""同时处理的请求太多:快速拒绝(HTTP 429),别排队排到所有人都超时。"""
class Unavailable(Exception):
"""服务正在下线(HTTP 503)。"""
class BadRequest(Exception):
pass
ORDER_RE = re.compile(r"^[A-Za-z0-9_-]{0,32}$")
def validate(payload) -> dict:
"""入口校验:只收我们认识的字段、长度有上限。不信任任何输入(它们会进提示词、进日志、进 trace)。"""
if not isinstance(payload, dict):
raise BadRequest("请求体必须是 JSON 对象")
extra = set(payload) - {"goal", "order_id", "user_id"}
if extra:
raise BadRequest(f"不认识的字段:{sorted(extra)}")
goal, order, user = payload.get("goal"), payload.get("order_id", ""), payload.get("user_id", "")
if not isinstance(goal, str) or not goal.strip():
raise BadRequest("goal 必须是非空字符串")
if len(goal) > 500:
raise BadRequest("goal 太长(上限 500 字)")
if not isinstance(order, str) or not ORDER_RE.match(order):
raise BadRequest("order_id 只能是字母数字和 - _,最长 32 位")
if not isinstance(user, str) or len(user) > 64:
raise BadRequest("user_id 必须是不超过 64 位的字符串")
return {"goal": goal.strip(), "order_id": order, "user_id": user}
class Service:
def __init__(self, settings: Settings, rollout: Rollout, *, guard: Guard = Guard(), runtime_factory=Runtime):
self.settings, self.rollout, self.guard, self._factory = settings, rollout, guard, runtime_factory
self.m = ServiceMetrics()
self._rts: dict[str, Runtime] = {}
self._rt_lock = threading.Lock()
self.windows: dict[str, VStats] = {}
self._win_lock = threading.Lock()
self._slots = threading.BoundedSemaphore(settings.max_inflight)
self._inflight = 0
self._inflight_lock = threading.Lock()
self._pool = ThreadPoolExecutor(max_workers=settings.max_inflight, thread_name_prefix="req")
self._shadow_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="shadow")
self._shadow_futs: list = []
self.draining = False
self.jobs = JobQueue(self._run_job, workers=settings.workers, max_queue=settings.queue_max, ttl_s=settings.job_ttl_s)
self.roller = AutoRoller(rollout, guard)
# ---- 版本 -> Runtime(用到才建,建好复用)----
def runtime(self, version: str) -> Runtime:
with self._rt_lock:
if version not in self._rts:
self._rts[version] = self._factory(self.rollout.releases[version])
return self._rts[version]
def warm(self) -> None:
"""启动时先把稳定版建好:就绪检查要等它。"""
self.runtime(self.rollout.stable)
@property
def ready(self) -> bool:
return (not self.draining) and bool(self.rollout.stable) and self.rollout.stable in self._rts
# ---- 同步请求 ----
def handle(self, payload: dict, *, key: str = "", timeout_s: float | None = None) -> dict:
if self.draining:
self.m.inc("sp_rejected_total", ("draining",))
raise Unavailable("服务正在下线")
if not self._slots.acquire(blocking=False):
self.m.inc("sp_rejected_total", ("busy",))
raise Busy(f"同时处理的请求已满({self.settings.max_inflight})")
with self._inflight_lock:
self._inflight += 1
self.m.set("sp_inflight", (), self._inflight)
try:
return self._handle(payload, key, timeout_s if timeout_s is not None else self.settings.request_timeout_s)
finally:
with self._inflight_lock:
self._inflight -= 1
self.m.set("sp_inflight", (), self._inflight)
self._slots.release()
def _handle(self, payload: dict, key: str, timeout_s: float) -> dict:
ctx = self._begin(payload, key)
return self._end(ctx, payload, key, timeout_s)
def _begin(self, payload: dict, key: str, keep_events: bool = False) -> dict:
trace_id = "req-" + uuid.uuid4().hex[:12]
version = self.rollout.pick(key or payload.get("user_id") or payload.get("order_id") or trace_id)
rt = self.runtime(version)
return {"trace_id": trace_id, "version": version, "rt": rt, "t0": time.monotonic(), "fut": self._pool.submit(lambda: rt.run(payload, trace_id, keep_events=keep_events))}
def _end(self, ctx: dict, payload: dict, key: str, timeout_s: float) -> dict:
version, trace_id, t0 = ctx["version"], ctx["trace_id"], ctx["t0"]
try:
res = ctx["fut"].result(timeout=max(0.0, timeout_s - (time.monotonic() - t0)))
except FutTimeout:
self._record(version, error=True, escalate=False, cost=0.0, latency=time.monotonic() - t0, decision="timeout")
raise TimeoutError(f"处理超过 {timeout_s:g} 秒")
except Exception:
self._record(version, error=True, escalate=False, cost=0.0, latency=time.monotonic() - t0, decision="error")
raise
self._record(version, error=False, escalate=res["decision"] == "escalate", cost=res["cost_usd"], latency=res["latency_ms"] / 1000, decision=res["decision"])
self._maybe_shadow(payload, key or trace_id, version, trace_id, res)
res["release"] = version
return res
# ---- 流式:边跑边把"走到哪一步了"推出去(SSE)----
def stream(self, payload: dict, *, key: str = "", timeout_s: float | None = None):
"""生成器:先 accepted,再每个专员 / 决策 / 审批 / 写入结束就来一条 step,最后一条 result(或 error)。"""
if self.draining:
self.m.inc("sp_rejected_total", ("draining",))
raise Unavailable("服务正在下线")
if not self._slots.acquire(blocking=False):
self.m.inc("sp_rejected_total", ("busy",))
raise Busy(f"同时处理的请求已满({self.settings.max_inflight})")
with self._inflight_lock:
self._inflight += 1
self.m.set("sp_inflight", (), self._inflight)
timeout_s = timeout_s if timeout_s is not None else self.settings.request_timeout_s
try:
ctx = self._begin(payload, key, keep_events=True)
yield {"event": "accepted", "release": ctx["version"], "trace_id": ctx["trace_id"]}
seen = 0
while not ctx["fut"].done() and time.monotonic() - ctx["t0"] < timeout_s:
evs, seen = ctx["rt"].events_since(ctx["trace_id"], seen)
yield from evs
time.sleep(0.01)
try:
res = self._end(ctx, payload, key, timeout_s)
except Exception as e:
ctx["rt"].forget(ctx["trace_id"])
yield {"event": "error", "error": f"{type(e).__name__}: {e}"}
return
evs, seen = ctx["rt"].events_since(ctx["trace_id"], seen)
yield from evs
ctx["rt"].forget(ctx["trace_id"])
yield {"event": "result", **res}
finally:
with self._inflight_lock:
self._inflight -= 1
self.m.set("sp_inflight", (), self._inflight)
self._slots.release()
def _record(self, version: str, *, error: bool, escalate: bool, cost: float, latency: float, decision: str) -> None:
with self._win_lock:
self.windows.setdefault(version, VStats()).add(error=error, escalate=escalate, cost=cost, latency_s=latency)
self.m.inc("sp_requests_total", (version, decision))
self.m.observe("sp_request_duration_seconds", (version,), latency)
if error:
self.m.inc("sp_request_errors_total", (version,))
if cost:
self.m.inc("sp_cost_usd_total", (version,), cost)
# ---- 影子对比:稳定版处理真请求时,让灰度版"旁观"跑一遍,只比较结论,不生效 ----
def _maybe_shadow(self, payload: dict, key: str, served: str, trace_id: str, res: dict) -> None:
ro = self.rollout
if not ro.canary or served != ro.stable or self.settings.shadow_rate <= 0:
return
if bucket("shadow:" + key) >= self.settings.shadow_rate * 100:
return
canary = ro.canary
def run():
try:
alt = self.runtime(canary).run(payload, trace_id + "~shadow", shadow=True)
same = alt["sig"] == res["sig"]
except Exception:
same = False
with self._win_lock:
w = self.windows.setdefault(canary, VStats())
w.shadow_n += 1
w.shadow_diff += (not same)
self.m.inc("sp_shadow_total", (canary, "same" if same else "diff"))
self._shadow_futs.append(self._shadow_pool.submit(run))
def wait_shadow(self) -> None:
for f in list(self._shadow_futs):
f.result()
self._shadow_futs.clear()
# ---- 异步任务 ----
def _run_job(self, payload: dict) -> dict:
return self.handle(payload["req"], key=payload.get("key", ""), timeout_s=payload.get("timeout_s"))
# ---- 护栏 ----
def tick(self) -> tuple[str, list[str]]:
"""看一眼护栏:放量 / 等待 / 回滚 / 转正。放量、回滚、转正之后统计窗口清零(下一档用自己的样本)。"""
ro = self.rollout
with self._win_lock:
stable = self.windows.get(ro.stable, VStats())
canary = self.windows.get(ro.canary, VStats()) if ro.canary else VStats()
before = len(ro.events)
action, why, reset = self.roller.tick(stable, canary)
if reset:
self.windows = {}
for e in ro.events[before:]:
self.m.inc("sp_rollout_events_total", (e["action"],))
return action, why
# ---- 状态 / 指标 / 下线 ----
def refresh_gauges(self) -> None:
ro = self.rollout
self.m.set("sp_inflight", (), self._inflight) # 没有请求的时候也要有这条指标:ServiceMetricsMissing 靠它判断"服务还在不在上报"
self.m.set("sp_jobs_queue_depth", (), self.jobs.depth())
self.m.set("sp_jobs_oldest_queued_seconds", (), self.jobs.oldest_queued_age())
self.m.set("sp_rollout_percent", (), ro.percent if ro.canary else 0)
self.m.set("sp_rollout_info", (ro.stable, ro.canary), 1)
self.m.set("sp_draining", (), 1 if self.draining else 0)
for st in ("submitted", "done", "failed", "abandoned", "deduped"):
self.m.set_counter("sp_jobs_total", (st,), self.jobs.counts.get(st, 0))
def shutdown(self, timeout_s: float = 10.0) -> dict:
"""优雅下线:先让就绪检查变红(负载均衡不再发新请求),再把在跑的处理完,最后关。"""
self.draining = True
info = self.jobs.drain(timeout_s)
end = time.monotonic() + timeout_s
while self._inflight and time.monotonic() < end:
time.sleep(0.01)
self._pool.shutdown(wait=False, cancel_futures=True)
self._shadow_pool.shutdown(wait=False, cancel_futures=True)
return {**info, "inflight_left": self._inflight}
service/server.py:HTTP 层
_handle 里那一串 except 就是"状态码映射表",是整个文件最值得看的地方。_stream 先 next(gen) 取第一个事件------名额满了、正在下线这些失败在这里就抛出来,还来得及返回真正的 429 / 503。
python
# supportpilot/service/server.py --- HTTP 这一层:只做收发、鉴权、状态码映射。真正的逻辑都在 core.py。
# 用标准库的 ThreadingHTTPServer,不装任何框架:为了让你看清"一个 Agent 服务到底需要哪几个接口"。生产上换成 FastAPI / Flask + gunicorn 都行,接口约定不变。
# POST /v1/support/requests 同步:等结果,200
# POST /v1/support/requests?stream=1 流式(SSE):边跑边推"走到哪一步了",最后一条是结果
# POST /v1/support/jobs 异步:立刻返回 202 + job_id;Idempotency-Key 头防重复提交
# GET /v1/support/jobs/<id> 查任务
# GET /healthz /readyz /version /metrics
# GET/POST /admin/rollout... 发布控制(要管理密钥)
from __future__ import annotations
import hmac
import json
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from .config import redacted
from .core import BadRequest, Busy, Service, Unavailable, validate
from .jobs import Draining, IdempotencyConflict, QueueFull
from .releases import Release, ReleaseError
MAX_BODY = 16 * 1024
MAX_IDEM = 128 # Idempotency-Key 最长多少(Stripe 的文档里是 255;我们不需要那么长)
def _same(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode(), b.encode())
def make_handler(svc: Service, *, log=lambda rec: None):
class H(BaseHTTPRequestHandler):
server_version = "SupportPilot"
protocol_version = "HTTP/1.0" # 响应以"关闭连接"结尾,SSE 也不用分块
def log_message(self, *a): # 关掉自带的访问日志,改用结构化日志
pass
# ---- 小工具 ----
def _send(self, code: int, body, headers: dict | None = None, ctype: str = "application/json; charset=utf-8") -> None:
raw = body if isinstance(body, bytes) else (body if isinstance(body, str) else json.dumps(body, ensure_ascii=False)).encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(raw)))
self.send_header("X-Env", svc.settings.env)
for k, v in (headers or {}).items():
self.send_header(k, str(v))
self.end_headers()
self.wfile.write(raw)
self._code = code
def _err(self, code: int, msg: str, headers: dict | None = None) -> None:
self._send(code, {"error": msg}, headers)
def _body(self):
n = int(self.headers.get("Content-Length") or 0)
if n > MAX_BODY:
raise BadRequest(f"请求体太大(上限 {MAX_BODY} 字节)")
try:
return json.loads(self.rfile.read(n) or b"{}")
except json.JSONDecodeError:
raise BadRequest("请求体不是合法的 JSON")
def _auth(self, admin: bool = False) -> bool:
if admin:
ok = _same(self.headers.get("X-Admin-Key", ""), svc.settings.admin_key)
else:
k = self.headers.get("X-API-Key", "")
ok = any(_same(k, x) for x in svc.settings.api_keys)
if not ok:
svc.m.inc("sp_rejected_total", ("auth",))
self._err(401, "鉴权失败")
return ok
def _route(self, method: str) -> None:
u = urlparse(self.path)
path, q = u.path.rstrip("/") or "/", parse_qs(u.query)
if method == "GET":
if path == "/healthz":
return self._send(200, {"status": "ok"})
if path == "/readyz":
why = []
if not svc.ready:
why.append("draining" if svc.draining else "稳定版还没就绪")
if svc.jobs.depth() >= svc.settings.queue_max:
why.append("任务队列已满")
return self._send(200 if not why else 503, {"ready": not why, "reasons": why})
if path == "/version":
return self._send(200, {**svc.rollout.state(), "config": redacted(svc.settings)})
if path == "/metrics":
svc.refresh_gauges()
return self._send(200, svc.m.render(), ctype="text/plain; version=0.0.4; charset=utf-8")
if path.startswith("/v1/support/jobs/"):
if not self._auth():
return
j = svc.jobs.get(path.rsplit("/", 1)[1])
return self._send(200, j.view()) if j else self._err(404, "没有这个任务(可能已过期)")
if path == "/admin/rollout":
if not self._auth(admin=True):
return
return self._send(200, {**svc.rollout.state(), "events": svc.rollout.events[-20:],
"window": {v: {"n": w.n, "errors": w.errors, "escalations": w.escalations, "cost_per_req": round(w.cost_per_req, 6),
"p95_s": round(w.p95, 3), "shadow_n": w.shadow_n, "shadow_diff": w.shadow_diff} for v, w in svc.windows.items()}})
if method == "POST":
if path == "/v1/support/requests":
if not self._auth():
return
payload = validate(self._body())
key = self.headers.get("X-Routing-Key", "")
if q.get("stream") == ["1"]:
return self._stream(payload, key)
res = svc.handle(payload, key=key)
return self._send(200, res, {"X-Release": res["release"], "X-Trace-Id": res["trace_id"]})
if path == "/v1/support/jobs":
if not self._auth():
return
payload = validate(self._body())
idem = self.headers.get("Idempotency-Key", "")
if len(idem) > MAX_IDEM or not idem.isprintable():
raise BadRequest(f"Idempotency-Key 最长 {MAX_IDEM} 个可见字符")
if svc.draining:
raise Unavailable("服务正在下线")
job, new = svc.jobs.submit({"req": payload, "key": self.headers.get("X-Routing-Key", "")}, idem)
return self._send(202 if new else 200, {"job_id": job.id, "status": job.status, "status_url": f"/v1/support/jobs/{job.id}", "deduplicated": not new})
if path.startswith("/admin/rollout"):
if not self._auth(admin=True):
return
b, act = self._body(), path.rsplit("/", 1)[1]
actor = self.headers.get("X-Actor", "admin")
if act == "register":
svc.rollout.register(Release(version=b["version"], variant=b.get("variant", "stable"), router_policy=b.get("router_policy", "by_features"),
prompt_head=b.get("prompt_head", ""), note=b.get("note", "")))
elif act == "canary":
svc.rollout.start_canary(b["version"], float(b.get("percent", 1)), actor)
svc.runtime(b["version"]) # 先建好再放量:别让第一批用户来触发初始化
elif act == "percent":
svc.rollout.set_percent(float(b["percent"]), actor)
elif act == "promote":
svc.rollout.promote(actor)
elif act == "rollback":
svc.rollout.rollback(b.get("reason", "手动回滚"), actor)
elif act == "tick":
action, why = svc.tick()
return self._send(200, {"action": action, "why": why, **svc.rollout.state()})
else:
return self._err(404, "没有这个操作")
svc.m.inc("sp_rollout_events_total", (svc.rollout.events[-1]["action"],))
with svc._win_lock:
svc.windows = {}
return self._send(200, svc.rollout.state())
self._err(404, "没有这个接口")
def _stream(self, payload: dict, key: str) -> None:
gen = svc.stream(payload, key=key)
first = next(gen) # 先把 429 / 503 这类"还没开始就失败"的情况变成正常的状态码
self.send_response(200)
for k, v in (("Content-Type", "text/event-stream; charset=utf-8"), ("Cache-Control", "no-cache"), ("X-Accel-Buffering", "no"),
("X-Env", svc.settings.env), ("X-Release", first["release"]), ("X-Trace-Id", first["trace_id"])):
self.send_header(k, v)
self.end_headers()
self._code = 200
try:
for ev in [first, *gen]:
self.wfile.write(f"event: {ev['event']}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n".encode())
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError): # 客户端走了:生成器会在 finally 里释放名额
gen.close()
def _handle(self, method: str) -> None:
t0, self._code = time.monotonic(), 0
try:
self._route(method)
except BadRequest as e:
self._err(400, str(e))
except Busy as e:
self._err(429, str(e), {"Retry-After": 1})
except IdempotencyConflict as e:
self._err(422, str(e))
except QueueFull as e:
svc.m.inc("sp_rejected_total", ("queue_full",))
self._err(429, str(e), {"Retry-After": 5})
except (Unavailable, Draining) as e:
self._err(503, str(e), {"Retry-After": 5})
except TimeoutError as e:
self._err(504, str(e))
except ReleaseError as e:
self._err(409, str(e))
except (KeyError, ValueError) as e:
self._err(400, f"参数不对:{e}")
except Exception as e: # 兜底:不把内部异常的细节(路径、堆栈)发给调用方
log({"level": "ERROR", "msg": "unhandled", "error": f"{type(e).__name__}: {e}"})
if not self._code:
self._err(500, "服务内部错误")
log({"level": "INFO", "msg": "http", "method": method, "path": urlparse(self.path).path, "code": self._code, "ms": int((time.monotonic() - t0) * 1000), "env": svc.settings.env})
def do_GET(self):
self._handle("GET")
def do_POST(self):
self._handle("POST")
return H
def make_server(svc: Service, host: str = "127.0.0.1", port: int = 0, *, log=lambda rec: None) -> ThreadingHTTPServer:
srv = ThreadingHTTPServer((host, port), make_handler(svc, log=log))
srv.daemon_threads = True
return srv
def json_stderr_log(rec: dict) -> None:
sys.stderr.write(json.dumps({"ts": time.time(), **rec}, ensure_ascii=False) + "\n")
sys.stderr.flush()
def _ticker(svc: Service, every_s: float) -> None:
"""后台线程:每隔一会儿让护栏看一眼灰度版。没有这个,灰度版放上去就没人管了。"""
while not svc.draining:
time.sleep(every_s)
try:
if svc.rollout.canary and not svc.draining:
action, why = svc.tick()
if action != "wait":
json_stderr_log({"level": "WARN" if action == "rollback" else "INFO", "msg": "护栏", "action": action, "why": why})
except Exception as e: # 护栏线程自己出错不能把服务带崩,但要大声说出来
json_stderr_log({"level": "ERROR", "msg": "护栏线程出错", "error": f"{type(e).__name__}: {e}"})
def serve_forever(svc: Service, host: str, port: int, *, grace_s: float = 10.0, tick_s: float = 30.0) -> None:
"""启动服务;收到 SIGTERM / Ctrl-C 时优雅下线:先让 /readyz 变红,处理完在途的,再退出。"""
import signal
srv = make_server(svc, host, port, log=json_stderr_log)
svc.warm()
def stop(signum, frame):
json_stderr_log({"level": "INFO", "msg": "收到下线信号,开始排空", "signal": signum})
threading.Thread(target=lambda: (svc.shutdown(grace_s), srv.shutdown()), daemon=True).start()
threading.Thread(target=_ticker, args=(svc, tick_s), daemon=True, name="guard-ticker").start()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
json_stderr_log({"level": "INFO", "msg": "服务已启动", "host": host, "port": srv.server_address[1], "env": svc.settings.env, "stable": svc.rollout.stable})
srv.serve_forever()
json_stderr_log({"level": "INFO", "msg": "已退出"})
service/monitoring.py:看板与告警生成器
python
# supportpilot/service/monitoring.py --- 服务层的看板和告警:和第 19 篇同一个思路,用代码生成,再和 SERVICE_METRICS 对账
# 第 19 篇的看板回答"请求内部发生了什么"(agent_ 开头的指标);这里回答"服务本身健康吗、发布顺利吗"(sp_ 开头的指标)。两套互不覆盖。
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from .metrics import SERVICE_METRICS
ROOT = Path(__file__).resolve().parent.parent.parent
DS = {"type": "prometheus", "uid": "${datasource}"}
PANELS = [
("请求量(按版本和结局)", "timeseries", [("sum by (version, decision) (rate(sp_requests_total[5m]))", "{{version}} {{decision}}")], "reqps", "稳定版和灰度版各自的请求量、结局分布"),
("错误率(按版本)", "timeseries", [("sum by (version) (rate(sp_request_errors_total[5m])) / sum by (version) (rate(sp_requests_total[5m]))", "{{version}}")], "percentunit", "灰度版明显高于稳定版:回滚的第一信号"),
("请求耗时 p95(按版本)", "timeseries", [("histogram_quantile(0.95, sum by (le, version) (rate(sp_request_duration_seconds_bucket[5m])))", "{{version}}")], "s", "新版本变慢要在这里看到"),
("每请求花费(按版本)", "timeseries", [("sum by (version) (rate(sp_cost_usd_total[10m])) / sum by (version) (rate(sp_requests_total[10m]))", "{{version}}")], "currencyUSD", "提示词前缀被改坏(缓存失效)会让灰度版的这条线跳起来"),
("被拒绝的请求(按原因)", "timeseries", [("sum by (reason) (rate(sp_rejected_total[5m]))", "{{reason}}")], "reqps", "busy / queue_full:容量不够;draining:正在发布或下线;auth:密钥问题"),
("异步队列", "timeseries", [("sp_jobs_queue_depth", "排队数"), ("sp_jobs_oldest_queued_seconds", "最老等待秒数")], "short", "队列在涨、最老的任务等很久:工作线程不够或者下游慢"),
("灰度放量百分比", "stat", [("sp_rollout_percent", "灰度%")], "percent", "0 = 没有灰度版在放量"),
("影子对比不一致率", "stat", [('sum(rate(sp_shadow_total{result="diff"}[10m])) / sum(rate(sp_shadow_total[10m]))', "结论不一致")], "percentunit", "灰度版和稳定版对同样的请求给出不同结论的比例;静默变坏靠它发现"),
("发布事件(按动作)", "timeseries", [("sum by (action) (increase(sp_rollout_events_total[1h]))", "{{action}}")], "short", "canary / percent / promote / rollback"),
("同时处理中的请求", "timeseries", [("sp_inflight", "inflight")], "short", "接近 max_inflight:马上开始 429"),
]
ALERTS = [
("ServiceMetricsMissing", "absent(sp_inflight)", "2m", "page", "服务没有上报指标", "先看进程和 /readyz;是不是在发布中被换掉了、或者 /metrics 被网络策略挡了"),
("RequestErrorRatioHigh", "sum(rate(sp_request_errors_total[5m])) / sum(rate(sp_requests_total[5m])) > 0.02", "5m", "page", "请求错误率超过 2%", "看是哪个版本(按 version 分):只有灰度版高 → 回滚灰度;两个版本都高 → 查上游(模型接口、工具后端)"),
("RequestLatencyP95High", "histogram_quantile(0.95, sum by (le) (rate(sp_request_duration_seconds_bucket[5m]))) > 5", "10m", "ticket", "入口 p95 超过 5 秒", "对比各版本;再看第 19 篇看板里各专员的耗时"),
("RequestsRejectedHigh", 'sum(rate(sp_rejected_total{reason=~"busy|queue_full"}[5m])) > 0.5', "5m", "ticket", "容量不足:每秒被拒绝超过 0.5 个请求", "看 inflight 和队列;该扩容就扩容,该限流就限流"),
("JobQueueOldestStale", "sp_jobs_oldest_queued_seconds > 60", "2m", "page", "有异步任务排了超过 1 分钟还没开始", "工作线程是不是卡住了;下游是不是变慢;必要时扩 worker 数"),
("RollbackHappened", 'sum(increase(sp_rollout_events_total{action="rollback"}[15m])) > 0', "0m", "page", "刚刚发生了回滚", "不管是自动还是手动:先确认现在的稳定版是谁,再去看回滚原因(/admin/rollout 的 events)"),
("CanaryStuck", "sp_rollout_percent > 0 and sp_rollout_percent < 50", "2h", "ticket", "灰度放量停了超过 2 小时", "灰度版样本不够或护栏一直在等;不要让灰度状态无限期挂着"),
("ShadowDiffHigh", 'sum(rate(sp_shadow_total{result="diff"}[10m])) / sum(rate(sp_shadow_total[10m])) > 0.05', "10m", "page", "灰度版和稳定版的结论不一致率超过 5%", "拿几条不一致的请求,用第 19 篇的 replay / diff 看第一处分叉;确认是不是故意的行为变化"),
]
def build_dashboard() -> dict:
panels = []
for i, (title, typ, targets, unit, desc) in enumerate(PANELS):
stat = typ == "stat"
panels.append({"id": i + 1, "type": typ, "title": title, "description": desc, "datasource": DS,
"gridPos": {"h": 6 if stat else 8, "w": 6 if stat else 12, "x": (i % 2) * 12 if not stat else (0 if i % 2 == 0 else 6), "y": (i // 2) * 8},
"fieldConfig": {"defaults": {"unit": unit}, "overrides": []},
"targets": [{"refId": chr(65 + j), "expr": e, "legendFormat": leg} for j, (e, leg) in enumerate(targets)]})
return {"title": "SupportPilot 服务与发布", "uid": "supportpilot-service", "tags": ["agent", "supportpilot", "service"], "timezone": "browser", "schemaVersion": 39,
"refresh": "30s", "time": {"from": "now-6h", "to": "now"},
"templating": {"list": [{"name": "datasource", "type": "datasource", "query": "prometheus", "label": "数据源"}]}, "panels": panels}
def dashboard_text() -> str:
d = build_dashboard()
head = {k: v for k, v in d.items() if k != "panels"}
body = ",\n".join(" " + json.dumps(p, ensure_ascii=False) for p in d["panels"])
return json.dumps(head, ensure_ascii=False)[:-1] + ',\n "panels": [\n' + body + "\n]}\n"
def build_alert_rules() -> str:
out = ["# monitoring/service_alerts.yml --- 服务与发布的告警规则(由 supportpilot/service/monitoring.py 生成,不要手改)", "groups:", " - name: supportpilot-service", " rules:"]
for name, expr, for_, sev, summary, runbook in ALERTS:
out += [f" - alert: {name}", f" expr: {json.dumps(expr, ensure_ascii=False)}", f" for: {for_}", " labels:", f" severity: {sev}",
" annotations:", f" summary: {json.dumps(summary, ensure_ascii=False)}", f" runbook: {json.dumps(runbook, ensure_ascii=False)}"]
return "\n".join(out) + "\n"
_METRIC = re.compile(r"\b(sp_[a-z_]+?)(?:_bucket|_sum|_count)?\b(\{[^}]*\})?")
_LABEL = re.compile(r"(\w+)\s*(?:=~|!~|!=|=)\s*\"")
_BY = re.compile(r"\bby\s*\(([^)]*)\)")
def check_expr(expr: str) -> list[str]:
problems, used, labels_ok = [], [], {"le"}
if expr.count("(") != expr.count(")") or expr.count("{") != expr.count("}"):
problems.append(f"括号不配对:{expr}")
for m in _METRIC.finditer(expr):
raw = m.group(0).split("{")[0]
base = m.group(1)
if base not in SERVICE_METRICS:
alt = re.sub(r"_(bucket|sum|count)$", "", raw)
base = alt if alt in SERVICE_METRICS else base
if base not in SERVICE_METRICS:
problems.append(f"未声明的指标 {raw}")
continue
used.append(base)
labels_ok |= set(SERVICE_METRICS[base][2])
for lab in _LABEL.findall(m.group(2) or ""):
if lab not in SERVICE_METRICS[base][2]:
problems.append(f"指标 {base} 没有标签 {lab}")
for grp in _BY.findall(expr):
for lab in (x.strip() for x in grp.split(",") if x.strip()):
if lab not in labels_ok:
problems.append(f"by ({lab}) 的标签不属于表达式里的任何指标:{expr[:60]}")
if not used:
problems.append(f"表达式里没有任何 sp_ 指标:{expr[:60]}")
return problems
def check_monitoring(dashboard: dict | None = None, alerts: list | None = None) -> list[str]:
dashboard = dashboard or build_dashboard()
problems = []
for p in dashboard["panels"]:
for t in p["targets"]:
problems += [f"[看板·{p['title']}] {x}" for x in check_expr(t["expr"])]
for name, expr, *_ in (alerts or ALERTS):
problems += [f"[告警·{name}] {x}" for x in check_expr(expr)]
return problems
def main() -> int:
(ROOT / "monitoring").mkdir(exist_ok=True)
(ROOT / "monitoring" / "service_dashboard.json").write_text(dashboard_text(), encoding="utf-8")
(ROOT / "monitoring" / "service_alerts.yml").write_text(build_alert_rules(), encoding="utf-8")
bad = check_monitoring()
print(f"已生成 monitoring/service_dashboard.json({len(PANELS)} 个面板)和 monitoring/service_alerts.yml({len(ALERTS)} 条规则);对账:" + ("通过" if not bad else f"{len(bad)} 个问题"))
for b in bad:
print(" " + b)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
service/sim.py:灰度放量仿真
python
# supportpilot/service/sim.py --- 灰度放量仿真:造一批流量,让"放量 → 看护栏 → 放大 / 回滚"自己跑一遍
# 为什么要仿真:灰度和回滚平时一年用不了几次,等真出事才第一次跑,就是在线上做实验。这里用几个造出来的"坏版本"提前演练。
# 注意:这是仿真(模型是模拟的、流量是生成的示例),样本门槛也调小了(12 个而不是 30 个),为的是几十秒内能跑完;真实环境请按自己的流量定。
from __future__ import annotations
import tempfile
from concurrent.futures import ThreadPoolExecutor
from .config import Settings
from .core import Busy, Service
from .releases import Guard, Release, Rollout
GOAL = "包裹一直没到,要求马上补发"
# 一轮 12 个请求:6 个正常补发、2 个都说已签收(转人工)、1 个系统打架(转人工)、1 个没给订单号(补问)、2 个"停滞 30 小时"(该继续等,不该补发)
MIX = ["A1001"] * 6 + ["A1002"] * 2 + ["A1003"] + [""] + ["A1004"] * 2
SCENARIOS = {
"good": (Release("v2", note="重新打包,行为不变"), 1.0, "好版本:行为没变"),
"loud": (Release("v2", variant="logistics_no_stale", note="物流专员丢了一条结论"), 0.0, "明显的异常:转人工飙升(不开影子对比)"),
"silent": (Release("v2", variant="policy_threshold_24", note="补发门槛读成 24 小时"), 0.0, "业务结果出错:不开影子对比 → 护栏看不见"),
"silent_shadow": (Release("v2", variant="policy_threshold_24", note="补发门槛读成 24 小时"), 1.0, "业务结果出错:开影子对比 → 抓得住"),
"slow": (Release("v2", variant="slow_logistics", note="物流专员多等 1 秒"), 0.0, "变慢:延迟护栏"),
"cache": (Release("v2", prompt_head="timestamp", note="提示词开头拼了时间戳"), 0.0, "缓存失效:每请求花费上跳"),
}
def traffic(n: int, start: int = 0) -> list[dict]:
return [{"goal": GOAL, "order_id": MIX[(start + i) % len(MIX)], "user_id": f"u{start + i}"} for i in range(n)]
def simulate(name: str, *, per_tick: int = 100, clients: int = 6, max_ticks: int = 40, guard: Guard | None = None, start_percent: float = 5.0) -> dict:
"""跑一个场景:稳定版 v1,灰度 v2 从 start_percent% 起;每个 tick 发 per_tick 个请求,然后让护栏看一眼。"""
cand, shadow_rate, label = SCENARIOS[name]
guard = guard or Guard(min_samples=12, min_shadow=20, max_latency_ratio=5.0, max_cost_ratio=1.5)
with tempfile.TemporaryDirectory() as d:
s = Settings(env="dev", api_keys=("dev-key-00001",), admin_key="dev-admin-0001", data_dir=d + "/data", max_inflight=8, shadow_rate=shadow_rate)
ro = Rollout(d + "/rollout.json")
ro.register(Release("v1", note="当前线上"))
ro.register(cand)
svc = Service(s, ro, guard=guard)
svc.warm()
svc.runtime("v2")
ro.start_canary("v2", start_percent, actor="ci")
rows, sent, rejected = [], 0, 0
try:
for tick in range(1, max_ticks + 1):
pct = ro.percent
reqs = traffic(per_tick, sent)
sent += per_tick
def one(p):
try:
svc.handle(p, key=p["user_id"])
return 0
except Busy:
return 1
with ThreadPoolExecutor(clients) as ex:
rejected += sum(ex.map(one, reqs))
svc.wait_shadow()
st, ca = svc.windows.get("v1"), svc.windows.get("v2")
row = {"tick": tick, "percent": pct, "stable_n": st.n if st else 0, "canary_n": ca.n if ca else 0,
"escalate": round(ca.escalate_ratio, 2) if ca else 0.0, "stable_escalate": round(st.escalate_ratio, 2) if st else 0.0,
"cost_ratio": round(ca.cost_per_req / st.cost_per_req, 2) if ca and st and st.cost_per_req else 0.0,
"p95_ratio": round(ca.p95 / st.p95, 1) if ca and st and st.p95 else 0.0,
"shadow_n": ca.shadow_n if ca else 0, "shadow_diff": ca.shadow_diff if ca else 0}
action, why = svc.tick()
row.update(action=action, why=why)
rows.append(row)
if not ro.canary:
break
outcome = "promoted" if ro.stable == "v2" else "rolled_back" if any(e["action"] == "rollback" for e in ro.events) else "stuck"
return {"scenario": name, "label": label, "outcome": outcome, "stable": ro.stable, "requests": sent, "rejected": rejected,
"rows": rows, "events": list(ro.events), "state": ro.state()}
finally:
svc.shutdown(2.0)
for rt in svc._rts.values():
rt.close()
def render(res: dict) -> str:
out = [f"场景 {res['scenario']}:{res['label']}", f"{'tick':>4} {'灰度%':>6} {'稳定n':>6} {'灰度n':>6} {'转人工(灰/稳)':>14} {'花费比':>6} {'p95比':>6} {'影子(异/总)':>11} 动作"]
for r in res["rows"]:
out.append(f"{r['tick']:>4} {r['percent']:>6g} {r['stable_n']:>6} {r['canary_n']:>6} {r['escalate']:>7.2f}/{r['stable_escalate']:<6.2f} {r['cost_ratio']:>6.2f} {r['p95_ratio']:>6.1f} {r['shadow_diff']:>5}/{r['shadow_n']:<5} {r['action']}" + (f":{r['why'][0]}" if r["action"] in ("rollback", "advance") else ""))
end = {"promoted": "结果:v2 转正,现在的稳定版是 v2", "rolled_back": "结果:已回滚,稳定版仍是 v1", "stuck": "结果:跑到上限还没结论"}[res["outcome"]]
out.append(f"{end}(共发了 {res['requests']} 个请求,被拒绝 {res['rejected']} 个)")
return "\n".join(out)
service_cli.py:命令行
python
# supportpilot/service_cli.py --- 部署与发布的命令行
# serve 启动服务(配置全部来自环境变量)
# ctl 对运行中的服务做发布操作(status / register / canary / percent / promote / rollback / tick),走 /admin 接口
# simulate 灰度放量仿真(good / loud / silent / silent_shadow / slow / cache)
# change-check 发布前检查:这次改了什么、影响什么、能不能过门禁
# config-check 只校验环境配置,不启动
# smoke 对运行中的服务做发布后冒烟检查
# 运行:python -m supportpilot.service_cli simulate loud
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
from .service.changes import build_manifest, diff_manifest, eval_release, gate
from .service.config import ConfigError, from_environ, redacted
from .service.core import Service
from .service.releases import Release, Rollout
from .service.server import serve_forever
from .service.sim import SCENARIOS, render, simulate
from .service.variants import VARIANTS
def cmd_config_check(a) -> int:
try:
s = from_environ(dict(os.environ))
except ConfigError as e:
print("配置有问题,拒绝启动:")
for p in e.problems:
print(" - " + p)
return 2
print("配置通过:" + json.dumps(redacted(s), ensure_ascii=False))
return 0
def cmd_serve(a) -> int:
try:
s = from_environ(dict(os.environ))
except ConfigError as e:
print("配置有问题,拒绝启动:", file=sys.stderr)
for p in e.problems:
print(" - " + p, file=sys.stderr)
return 2
Path(s.data_dir).mkdir(parents=True, exist_ok=True)
ro = Rollout(Path(s.data_dir) / "rollout.json")
if not ro.releases:
ro.register(Release("v1", note="首次启动的默认版本"))
svc = Service(s, ro)
serve_forever(svc, a.host, s.port, grace_s=a.grace, tick_s=a.tick)
return 0
def _call(a, method: str, path: str, body: dict | None = None, admin: bool = True):
key = os.environ.get("SP_ADMIN_KEY", "") if admin else os.environ.get("SP_CLIENT_KEY", "")
req = urllib.request.Request(a.url.rstrip("/") + path, method=method, data=(json.dumps(body).encode() if body is not None else None),
headers={"Content-Type": "application/json", ("X-Admin-Key" if admin else "X-API-Key"): key, "X-Actor": os.environ.get("USER", "cli")})
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode() or "{}")
def cmd_ctl(a) -> int:
if a.action == "status":
code, d = _call(a, "GET", "/admin/rollout")
else:
body = {"register": {"version": a.version, "variant": a.variant, "router_policy": a.router, "prompt_head": a.head, "note": a.note},
"canary": {"version": a.version, "percent": a.percent}, "percent": {"percent": a.percent},
"rollback": {"reason": a.reason}, "promote": {}, "tick": {}}[a.action]
code, d = _call(a, "POST", f"/admin/rollout/{a.action}", body)
print(json.dumps(d, ensure_ascii=False, indent=1))
return 0 if code == 200 else 1
def cmd_simulate(a) -> int:
names = list(SCENARIOS) if a.scenario == "all" else [a.scenario]
for i, n in enumerate(names):
if i:
print()
print(render(simulate(n)))
return 0
def cmd_change_check(a) -> int:
old = Release("v1")
new = Release(a.version, variant=a.variant, router_policy=a.router, prompt_head=a.head)
changes = diff_manifest(build_manifest(old), build_manifest(new))
print(f"【变更清单】v1 → {a.version}({len(changes)} 处改动)")
for c in changes:
print(f" {c.component} {c.kind}" + (" [破坏性]" if c.breaking else ""))
for x in c.impact + [f"警告:{w}" for w in c.warn]:
print(" - " + x)
report = None
if changes and not a.no_eval:
report = eval_release(new)
print(f"【评估】26 条用例:成功 {report['success']},越权率 {report['unauthorized_rate']:.0%},每请求花费 {report['cost_per_request']:.5f},缓存命中 {report['cache_read_share']:.0%}")
base = eval_release(old) if report else {"success": 0, "unauthorized_rate": 0.0} # 基线 = 当前线上版本现在的评估成绩(不是写死的数)
if report:
print(f"【基线】当前线上版本 v1:成功 {base['success']},每请求花费 {base['cost_per_request']:.5f}")
g = gate(changes, report, base, migration_note=a.migration_note or "")
print("【门禁】" + ("通过" if g.ok else "不通过"))
for r in g.reasons:
print(" ✗ " + r)
for w in g.warnings:
print(" ! " + w)
return 0 if g.ok else 1
def cmd_smoke(a) -> int:
ok = True
def check(name, cond, detail=""):
nonlocal ok
ok &= bool(cond)
print(f" {'✓' if cond else '✗'} {name}" + (f" {detail}" if detail else ""))
for p in ("/healthz", "/readyz", "/version"):
req = urllib.request.Request(a.url.rstrip("/") + p)
try:
with urllib.request.urlopen(req, timeout=5) as r:
check(p, r.status == 200)
except Exception as e:
check(p, False, str(e))
req = urllib.request.Request(a.url.rstrip("/") + "/v1/support/requests", method="POST", data=json.dumps({"goal": "包裹一直没到,要求马上补发", "order_id": "A1001"}).encode(),
headers={"Content-Type": "application/json", "X-API-Key": os.environ.get("SP_CLIENT_KEY", "")})
try:
with urllib.request.urlopen(req, timeout=15) as r:
d = json.loads(r.read().decode())
check("真实请求走通", r.status == 200 and d["decision"] == "replied", f"decision={d['decision']} release={d['release']}")
except Exception as e:
check("真实请求走通", False, str(e))
print("冒烟检查:" + ("通过" if ok else "失败"))
return 0 if ok else 1
def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="service_cli", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("config-check").set_defaults(fn=cmd_config_check)
p = sub.add_parser("serve")
p.add_argument("--host", default="0.0.0.0")
p.add_argument("--grace", type=float, default=10.0, help="下线时最多等多少秒让在途请求处理完")
p.add_argument("--tick", type=float, default=30.0, help="护栏多久看一次灰度版")
p.set_defaults(fn=cmd_serve)
p = sub.add_parser("ctl")
p.add_argument("action", choices=["status", "register", "canary", "percent", "promote", "rollback", "tick"])
p.add_argument("--url", default="http://127.0.0.1:8080")
p.add_argument("--version", default="")
p.add_argument("--variant", default="stable", choices=list(VARIANTS))
p.add_argument("--router", default="by_features")
p.add_argument("--head", default="")
p.add_argument("--note", default="")
p.add_argument("--percent", type=float, default=1.0)
p.add_argument("--reason", default="手动回滚")
p.set_defaults(fn=cmd_ctl)
p = sub.add_parser("simulate")
p.add_argument("scenario", choices=list(SCENARIOS) + ["all"])
p.set_defaults(fn=cmd_simulate)
p = sub.add_parser("change-check")
p.add_argument("--version", default="v2")
p.add_argument("--variant", default="stable", choices=list(VARIANTS))
p.add_argument("--router", default="by_features")
p.add_argument("--head", default="")
p.add_argument("--migration-note", default="")
p.add_argument("--no-eval", action="store_true")
p.set_defaults(fn=cmd_change_check)
p = sub.add_parser("smoke")
p.add_argument("--url", default="http://127.0.0.1:8080")
p.set_defaults(fn=cmd_smoke)
a = ap.parse_args(argv)
return a.fn(a)
if __name__ == "__main__":
sys.exit(main())
部署文件
dockerfile
# SupportPilot 服务镜像。构建:docker build -t supportpilot:v1 .
# 只用 Python 标准库,所以没有 pip install 这一步;镜像小、没有依赖漂移。
FROM python:3.12-slim
# 非 root 用户跑服务:容器被攻破时权限小一点
RUN useradd --system --uid 10001 --create-home sp && mkdir -p /var/lib/supportpilot && chown sp /var/lib/supportpilot
WORKDIR /app
COPY --chown=sp supportpilot/ ./supportpilot/
COPY --chown=sp eval/ ./eval/
COPY --chown=sp knowledge_base/ ./knowledge_base/
COPY --chown=sp agent_kb/ ./agent_kb/
USER sp
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
# 注意:这里故意不写 SP_ENV / 密钥。没有它们,服务会拒绝启动,而不是悄悄用默认值。
EXPOSE 8080
# 就绪检查:优雅下线时 /readyz 会变 503,编排系统据此不再发新请求
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import os,sys,urllib.request as u; sys.exit(0 if u.urlopen('http://127.0.0.1:%s/readyz' % os.environ.get('SP_PORT','8080'), timeout=2).status==200 else 1)"
# exec 形式:python 是 PID 1,能直接收到 SIGTERM 做优雅下线(用 shell 形式的话信号会被 sh 吞掉)
CMD ["python", "-m", "supportpilot.service_cli", "serve", "--grace", "10"]
yaml
# 本地起一个"staging 形态"的服务。密钥从 .env.staging 来(不提交):docker compose --env-file .env.staging up --build
services:
supportpilot:
build: .
image: supportpilot:${SP_IMAGE_TAG:-dev}
ports:
- "8080:8080"
environment:
SP_ENV: ${SP_ENV:?SP_ENV 必须明确给出}
SP_API_KEYS: ${SP_API_KEYS:?}
SP_ADMIN_KEY: ${SP_ADMIN_KEY:?}
SP_DATA_DIR: /var/lib/supportpilot/${SP_ENV}
SP_SHADOW_RATE: ${SP_SHADOW_RATE:-0}
volumes:
- sp-data:/var/lib/supportpilot # 发布状态(rollout.json)放在卷里:容器重建后还在
stop_grace_period: 20s # 比服务的 --grace 10 秒长,留出排空时间;到点还不退才会被 SIGKILL
restart: unless-stopped
read_only: true
tmpfs:
- /tmp # 运行时目录要写临时文件;其余文件系统只读
volumes:
sp-data: {}
text
tests/
monitoring/
examples/
**/__pycache__/
*.pyc
.env
.env.*
!.env.example
*.zip
.git/
text
# 复制成 .env.dev / .env.staging / .env.prod 之后再填。真正的密钥不要提交进仓库。
# 占位值故意不带环境前缀:不改就启动会被拒绝,不会带着示例密钥上线。
# 每个环境一套:密钥必须以环境前缀开头(dev- / stg- / prod-),拿错环境的密钥启动时就会被拒绝。
SP_ENV=dev
SP_API_KEYS=CHANGE_ME
SP_ADMIN_KEY=CHANGE_ME
SP_DATA_DIR=/var/lib/supportpilot/dev
# 以下可选(括号里是默认值)
# SP_PORT=8080
# SP_WORKERS=4
# SP_QUEUE_MAX=50
# SP_MAX_INFLIGHT=8
# SP_REQUEST_TIMEOUT_S=10
# SP_JOB_TTL_S=600
# SP_SHADOW_RATE=0
5.3 设计要点对照
| 设计点 | 在哪 | 为什么这样做 |
|---|---|---|
| 内核不碰 HTTP | core.Service |
能不开端口直接测、直接仿真;HTTP 只是一层翻译 |
| 同步满了立刻 429,不排队 | handle 的 acquire(blocking=False) |
排队等于所有人一起超时;要排队走异步 |
| 能提前失败的在发出 200 之前失败 | server._stream 先取第一个事件 |
流一开始状态码就定了;429 / 503 必须是真的状态码 |
| 版本不可变 | Rollout.register |
同一个版本号永远是同一份内容,才能放心说"回滚到 v1" |
| 稳定分桶 | bucket |
同一个人总在同一个版本;放量只加人,不踢人 |
| 一次只有一个灰度 | start_canary |
两个灰度同时放,信号互相污染 |
| 灰度版比稳定版,不比历史 | Guard.judge |
同一时间窗的并排比较,排除高低峰的干扰 |
| 样本不够不下结论 | min_samples / min_shadow |
少量样本的比率是噪声 |
| 影子对比用于发现监控指标看不出来的业务错误 | _maybe_shadow + Runtime._approve |
错误率、转人工、花费、延迟都不动的坏版本,只有比结论才看得出 |
| 影子请求不写 | 审批恒拒 + 独立 Runtime | 三层保险(3.5) |
| 放量后窗口清零 | AutoRoller.tick 的返回值 |
每一档只用这一档自己的样本 |
| 不放到 100% | RAMP=(1,5,25,50) |
稳定版没流量,护栏就没有对照组 |
| 回滚只做最安全的动作 | Rollout.rollback |
撤灰度 / 退回 previous,不做有副作用的事 |
| 状态原子落盘 | Rollout._save |
写一半崩溃不留半个文件;重启后接着用 |
| 启动即校验 | config.validate |
拿错环境的密钥在启动那一刻失败,不是在出事那一刻 |
| 幂等键同键不同内容 → 422 | JobQueue.submit |
悄悄返回旧结果等于把错误交给调用方(Stripe 文档的做法) |
| 优雅下线 | Service.shutdown + serve_forever |
先红 /readyz、再排空、最后退出 |
| 门禁在发布前,护栏在发布后 | changes.gate / releases.Guard |
两道闸各抓各的 |
| 指标先登记,看板告警对账 | SERVICE_METRICS + check_monitoring |
改名之后图不会悄悄变空 |
没有请求时也上报 sp_inflight |
refresh_gauges |
否则 absent() 告警在冷启动时误报 |
六、落地方案
6.1 发布检查清单 + 回滚预案(本篇的可落地产物)
本篇的可交付物是两份能直接贴进你们发布单的文档:发布检查清单 (发布人逐项打勾)和回滚预案(出事时照着做,30 秒内做完)。它们不是写给"理解了原理的人"看的,是写给"半夜被叫醒的人"看的,所以句子都很短、动作都能直接复制。
发布检查清单(deploy/RELEASE_CHECKLIST.md):
markdown
# SupportPilot 发布检查清单
发布人填写,贴进发布单。每一项要么打勾,要么写明为什么跳过。
## 发布前(改动合入之前)
- [ ] `python -m supportpilot.service_cli change-check --version <新版本> ...` 门禁通过(改了什么 / 影响什么 / 评估成功数不低于基线 / 越权 0 / 成本 ≤ 1.3 倍)
- [ ] 如果有工具参数的破坏性变更:迁移说明写了,调用方确认改完了
- [ ] 全套测试通过(`python -m unittest discover -s tests`)
- [ ] 提示词开头没有每次都变的内容(时间戳、请求号),否则提示缓存永远命不中
- [ ] 回滚目的地确认:现在的稳定版是 ______,发布后它会成为 previous,能一键回去
## 发布(staging)
- [ ] 用目标环境自己的密钥启动;`config-check` 通过
- [ ] `smoke` 通过
- [ ] 在 staging 用 `?stream=1` 和 `/v1/support/jobs` 各走一遍
## 发布(prod)
- [ ] `ctl register` 注册新版本(不可变:改内容要换版本号)
- [ ] `ctl canary --percent 5`,并且 `SP_SHADOW_RATE` 不是 0(静默变坏只有影子对比抓得到)
- [ ] 看板「服务与发布」打开着:错误率 / p95 / 每请求花费 / 影子不一致率
- [ ] 每档观察,护栏自动放量;人不要手动绕过护栏
- [ ] 转正后观察 30 分钟再离开
## 发布后
- [ ] `RollbackHappened` 告警没有响,或者响了且已写明原因
- [ ] 发布记录(`ctl status` 的 events)存档
回滚预案(deploy/ROLLBACK_RUNBOOK.md):
markdown
# 回滚预案(Runbook)
**原则:先回滚,再查原因。** 不要在线上边看边猜;回滚是一个动作,30 秒内能做完。
## 判断要不要回滚(满足任一条就回滚)
- 告警 `RequestErrorRatioHigh` / `ShadowDiffHigh` 响了,并且只有新版本异常
- 看板里灰度版的错误率、转人工占比、每请求花费、p95 任何一项明显高于稳定版
- 业务方反馈"结论变了",且能对上发布时间
## 动作
1. 灰度中(还有 canary):
`python -m supportpilot.service_cli ctl rollback --url <服务地址> --reason "<一句话原因>"`
→ 灰度版被撤掉,流量全部回稳定版。
2. 已经转正(没有 canary,发现新稳定版有问题):同一条命令。
→ 稳定版退回 previous(上一个稳定版)。
3. 确认:`ctl status` 里 stable 是期望的版本;看板上错误率回落;`sp_rollout_events_total{action="rollback"}` +1。
## 回滚不管的事(要另外处理)
- 新版本已经**写出去**的东西(已建的工单、已发的回复)不会撤回:用请求 trace 查出受影响的范围,交给人工处理
- 异步队列里排着的任务:服务重启会丢(队列在内存里),通知调用方用同一个 Idempotency-Key 重新提交
- 回滚的目的地 previous 只留一个。连续回滚第二次会被拒绝("没有可回滚的版本"):要回到更早的版本,重新走 canary → promote
## 事后
- 在发布单里写:现象、发现方式(哪条告警 / 谁发现的)、回滚用时、根因、要补的测试或护栏
这两份文档里,注意最后一节"回滚不管的事 "。回滚只是让后续的请求回到老版本,新版本已经写出去的东西不会撤回(已建的工单、已发的回复)。能撤销的只有"以后",已经发生的要靠 trace 查出范围、交给人工处理。把这句话写进预案,比任何"回滚很安全"的承诺都靠谱。
6.2 参数怎么调
| 参数 | 默认 | 作用 | 怎么调 |
|---|---|---|---|
SP_MAX_INFLIGHT |
8 | 同步请求同时处理几个,满了 429 | 看 sp_inflight 和 sp_rejected_total{reason="busy"}。模型慢、请求长,就别设太大(线程多了只是一起慢);先看下游扛不扛得住 |
SP_WORKERS |
4 | 异步任务的工作线程数 | 看 sp_jobs_oldest_queued_seconds:最老的任务总在等,就加;下游已经吃紧就别加 |
SP_QUEUE_MAX |
50 | 异步队列上限 | 按"能接受排队多久"倒推:队列长度 ÷ 处理速度 = 最长等待。满了就 429 + /readyz 变红 |
SP_REQUEST_TIMEOUT_S |
10 | 同步请求的超时(504) | 比调用方自己的超时短一点,免得调用方放弃了你还在白干;要比 p99 长,不然正常请求也被砍 |
SP_JOB_TTL_S |
600 | 任务结果保留多久(从结束时算) | 调用方最晚多久来取,就留多久;别留太久,内存只增不减 |
SP_SHADOW_RATE |
0 | 稳定版请求里多少比例做影子对比 | 灰度期间设成 0.2~1;每个被抽中的请求多一份模型的钱。默认 0 是故意的:不灰度的时候不该白花钱 |
Guard.min_samples |
30 | 灰度版至少多少样本才下结论 | 按你的流量定:流量低就要放更久,不是把它调小 |
Guard.min_shadow |
20 | 影子样本够多才参与判断 | 同上 |
Guard.max_error_delta |
0.02 | 错误率比稳定版高多少算坏 | 看稳定版自己的波动:稳定版错误率经常在 1%~3% 晃,就别设 2% |
Guard.max_escalate_delta |
0.15 | 转人工占比高多少算坏 | 同上;业务上"转人工"贵不贵决定它该多严 |
Guard.max_cost_ratio |
1.3 | 每请求花费是稳定版的几倍以内 | 和变更门禁的 cost_ceiling 保持一致,别让门禁放行的东西被护栏拦住(或者反过来) |
Guard.max_latency_ratio |
2.0 | p95 是稳定版的几倍以内 | 样本少时 p95 很抖,宁可多等样本,别放宽比例 |
Guard.max_shadow_diff |
0.05 | 影子结论不一致比例上限 | 有"故意改变行为"的发布时,这条会红:要么先在门禁评估里确认,要么临时放宽并写进发布单 |
RAMP |
1 / 5 / 25 / 50 | 放量的档位 | 每档要观察多久取决于样本够不够,不取决于时钟;流量低的服务,1% 那档可能要几个小时 |
--tick |
30 秒 | 护栏多久看一次 | 太短没有新样本,只是空转;太长回滚慢 |
--grace |
10 秒 | 下线时最多等多久排空 | 取"最长的正常请求耗时"的 1.5~2 倍;编排系统的 stop 超时必须比它更长(compose 里是 20 秒,有测试盯着) |
6.3 怎么评估:发布系统自己靠不靠谱
评估一个"发布系统",不能只问"能不能放量",要问"坏版本能不能被拦住、拦不住的是哪一类、拦住之后现场干不干净"。四种标准:
- 发布门禁拦得住哪几类坏版本。 用 3 个造出来的坏版本(监控指标明显异常的问题、监控指标正常但业务结果出错的问题、缓存失效)分别过一遍
change-check(4.5 有输出)。门禁挡的是"评估集能暴露的问题"。 - 灰度护栏拦得住哪几类。 6 个仿真场景(4.2、4.3 有输出):好版本能转正;转人工比例明显上升、处理变慢、缓存失效会被对应的护栏回滚;监控指标正常但业务结果出错的问题,必须开启影子对比才更容易发现------不开,它会一路转正。
- 变异检查。 故意把服务代码改坏一处(比如让鉴权不比较密钥、让影子请求被批准、让幂等键不去重、让
/readyz不看是否在下线),测试必须变红。第一轮写了 106 处,3 处漏网("灰度版的流量也做影子对比""任务队列满了就绪检查也是绿的""提交任务时不鉴权"),另外 3 处是被灰度仿真"碰巧"抓到的(它们和仿真本来没关系,是仿真在负载下的偶发失败)。最后一次完整重跑的结果:
text
[1/110] 抓到 环境名有默认值(没设 SP_ENV 就当 prod)
[2/110] 抓到 不检查业务密钥的环境前缀
[3/110] 抓到 不检查密钥最小长度
[4/110] 抓到 不检查管理密钥
[5/110] 抓到 管理密钥可以和业务密钥相同
[6/110] 抓到 数据目录可以共用别的环境的
[7/110] 抓到 prod 可以开 debug
[8/110] 抓到 prod 可以用相对路径
[9/110] 抓到 脱敏视图泄露业务密钥
[10/110] 抓到 脱敏视图泄露管理密钥
[11/110] 抓到 不校验 shadow_rate 范围
[12/110] 抓到 幂等键不去重
[13/110] 抓到 队列不设上限
[14/110] 抓到 排空后还接新任务
[15/110] 抓到 失败任务不记录原因
[16/110] 抓到 迟到的结果改写 abandoned
[17/110] 抓到 结果永不过期
[18/110] 抓到 清理任务时不释放幂等键
[19/110] 抓到 排空超时不标 abandoned
[20/110] 抓到 最老排队时间恒为 0
[21/110] 抓到 桶号不稳定(每次不一样)
[22/110] 抓到 按百分比分流写反
[23/110] 抓到 灰度版可以就是稳定版
[24/110] 抓到 可以同时放两个灰度版
[25/110] 抓到 转正时不记上一个稳定版
[26/110] 抓到 撤灰度时不清百分比
[27/110] 抓到 回滚时稳定版不退回上一个
[28/110] 抓到 已发布的版本可以被改写
[29/110] 抓到 状态不落盘
[30/110] 抓到 写完临时文件不改名
[31/110] 抓到 放量百分比不校验范围
[32/110] 抓到 审计日志不记操作人
[33/110] 抓到 错误率刚好等于阈值也算坏
[34/110] 抓到 护栏不看转人工占比
[35/110] 抓到 护栏不看每请求花费
[36/110] 抓到 护栏不看延迟
[37/110] 抓到 影子样本不够也参与判断
[38/110] 抓到 护栏不看影子对比
[39/110] 抓到 样本不够也下结论
[40/110] 抓到 样本不够也放量
[41/110] 抓到 p95 取最小值
[42/110] 抓到 放量后统计窗口不清零
[43/110] 抓到 放到最后一档也不转正
[44/110] 抓到 自动回滚不记是谁干的
[45/110] 抓到 入口放过未知字段
[46/110] 抓到 入口不限 goal 长度
[47/110] 抓到 入口不校验订单号格式
[48/110] 抓到 同步请求满了也不拒绝
[49/110] 抓到 同步请求结束不还名额
[50/110] 抓到 下线时还接同步请求
[51/110] 抓到 流式请求结束不还名额
[52/110] 抓到 超时不算错误
[53/110] 抓到 内部异常不算错误
[54/110] 抓到 路由忽略 user_id(同一人会来回切版本)
[55/110] 抓到 影子采样率不起作用
[56/110] 抓到 灰度版的流量也做影子对比
[57/110] 抓到 影子运行失败当作"一致"
[58/110] 抓到 影子不一致不计数
[59/110] 抓到 影子运行不是 shadow 模式(会真写)
[60/110] 抓到 护栏动作之后统计窗口不清零
[61/110] 抓到 护栏的发布事件不进指标
[62/110] 抓到 下线时不标记 draining
[63/110] 抓到 就绪检查不看是否在下线
[64/110] 抓到 花费不进指标
[65/110] 抓到 业务鉴权不比较密钥
[66/110] 抓到 管理接口也认业务密钥
[67/110] 抓到 没有请求时不上报 sp_inflight(absent 告警会误报)
[68/110] 抓到 500 把内部错误细节发给调用方
[69/110] 抓到 429 不带 Retry-After
[70/110] 抓到 超时返回 500 而不是 504
[71/110] 抓到 请求体大小不设上限
[72/110] 抓到 发布冲突返回 400 而不是 409
[73/110] 抓到 就绪检查不看稳定版是否就绪
[74/110] 抓到 幂等命中也返回 202
[75/110] 抓到 同一个幂等键换了内容,也悄悄返回旧结果
[76/110] 抓到 任务队列满了就绪检查也是绿的
[77/110] 抓到 幂等键不限长度 / 不查可见字符
[78/110] 抓到 提交任务时不鉴权
[79/110] 抓到 影子请求被批准(会真的写出去)
[80/110] 抓到 幂等冲突被当成 500
[81/110] 抓到 流式请求的事件被提前清掉
[82/110] 抓到 时间戳头不生效
[83/110] 抓到 花费不统计
[84/110] 抓到 结论签名只看结局种类(看不出静默变坏)
[85/110] 抓到 变体不生效
[86/110] 抓到 提示词头不拼进去
[87/110] 抓到 清单不含共用提示词内容
[88/110] 抓到 diff 把没变的也报成改动
[89/110] 抓到 改参数类型不算破坏性
[90/110] 抓到 新增必填参数不算破坏性
[91/110] 抓到 可选变必填不算破坏性
[92/110] 抓到 提示词开头会变也不警告
[93/110] 抓到 门禁不要求评估
[94/110] 抓到 门禁不比成功数
[95/110] 抓到 门禁允许越权
[96/110] 抓到 门禁的成本上限放宽一倍
[97/110] 抓到 直方图不是累计桶
[98/110] 抓到 破坏性变更不要迁移说明
[99/110] 抓到 +Inf 桶恒为 0
[100/110] 抓到 计数器覆盖而不是累加
[101/110] 抓到 标签值不转义
[102/110] 抓到 对账不查标签
[103/110] 抓到 对账不查 by 里的标签
[104/110] 抓到 对账不查未声明的指标
[105/110] 抓到 对账不查括号
[106/110] 抓到 管理密钥不对时命令行也返回 0
[107/110] 抓到 冒烟失败时命令行也返回 0
[108/110] 抓到 配置有问题时命令行返回 0
[109/110] 抓到 门禁不通过时命令行也返回 0
[110/110] 抓到 基线写死而不是现测
==== 汇总 ====
抓到 环境名有默认值(没设 SP_ENV 就当 prod) ← [快测试] AssertionError: 没设 SP_ENV 必须拒绝启动
抓到 不检查业务密钥的环境前缀 ← [快测试] AssertionError: []
抓到 不检查密钥最小长度 ← [快测试] AssertionError
抓到 不检查管理密钥 ← [快测试] AssertionError: []
抓到 管理密钥可以和业务密钥相同 ← [快测试] AssertionError
抓到 数据目录可以共用别的环境的 ← [快测试] AssertionError
抓到 prod 可以开 debug ← [快测试] AssertionError
抓到 prod 可以用相对路径 ← [快测试] AssertionError
抓到 脱敏视图泄露业务密钥 ← [快测试] AssertionError: {"env": "prod", "data_dir": "/var/lib/supportpilot/pro
抓到 脱敏视图泄露管理密钥 ← [快测试] AssertionError: {"env": "prod", "data_dir": "/var/lib/supportpilot/pro
抓到 不校验 shadow_rate 范围 ← [快测试] AssertionError
抓到 幂等键不去重 ← [快测试] AssertionError
抓到 队列不设上限 ← [快测试] AssertionError: 第 3 个排队的应该被拒绝
抓到 排空后还接新任务 ← [快测试] AssertionError
抓到 迟到的结果改写 abandoned ← [快测试] AssertionError: 迟到的结果不能把 abandoned 改回 done
抓到 失败任务不记录原因 ← [快测试] AssertionError
抓到 结果永不过期 ← [快测试] AssertionError: 超过 ttl 应该被清掉
抓到 清理任务时不释放幂等键 ← [快测试] AssertionError: 过期任务连同它的幂等键一起清掉(否则内存只增不减)
抓到 排空超时不标 abandoned ← [快测试] AssertionError
抓到 最老排队时间恒为 0 ← [快测试] AssertionError
抓到 桶号不稳定(每次不一样) ← [快测试] AssertionError: 0.0
抓到 按百分比分流写反 ← [快测试] AssertionError
抓到 灰度版可以就是稳定版 ← [快测试] AssertionError: v1
抓到 可以同时放两个灰度版 ← [快测试] AssertionError
抓到 转正时不记上一个稳定版 ← [快测试] AssertionError
抓到 撤灰度时不清百分比 ← [快测试] AssertionError
抓到 回滚时稳定版不退回上一个 ← [快测试] AssertionError
抓到 已发布的版本可以被改写 ← [快测试] AssertionError
抓到 状态不落盘 ← [快测试] AssertionError
抓到 写完临时文件不改名 ← [快测试] AssertionError
抓到 放量百分比不校验范围 ← [快测试] AssertionError
抓到 审计日志不记操作人 ← [快测试] AssertionError
抓到 错误率刚好等于阈值也算坏 ← [快测试] AssertionError: 刚好 2% 不算越线
抓到 护栏不看转人工占比 ← [快测试] AssertionError
抓到 护栏不看每请求花费 ← [快测试] AssertionError
抓到 护栏不看延迟 ← [快测试] AssertionError
抓到 影子样本不够也参与判断 ← [快测试] AssertionError: 影子样本不到 20 个不参与判断
抓到 护栏不看影子对比 ← [快测试] AssertionError
抓到 样本不够也下结论 ← [快测试] AssertionError: 样本不够不下结论(哪怕全错)
抓到 样本不够也放量 ← [快测试] AssertionError: 样本不够不下结论(哪怕全错)
抓到 p95 取最小值 ← [快测试] AssertionError: 0.01
抓到 放量后统计窗口不清零 ← [快测试] AssertionError: [('advance', 5.0, False), ('advance', 25.0, False), ('
抓到 放到最后一档也不转正 ← [快测试] TypeError: '<=' not supported between instances of 'int' and 'NoneType
抓到 自动回滚不记是谁干的 ← [快测试] AssertionError
抓到 入口放过未知字段 ← [快测试] AssertionError: {'goal': 'a', 'admin': True}
抓到 入口不限 goal 长度 ← [快测试] AssertionError: {'goal': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
抓到 入口不校验订单号格式 ← [快测试] AssertionError: {'goal': 'a', 'order_id': '../etc'}
抓到 同步请求满了也不拒绝 ← [快测试] ValueError: Semaphore released too many times
抓到 下线时还接同步请求 ← [快测试] RuntimeError: cannot schedule new futures after shutdown
抓到 同步请求结束不还名额 ← [快测试] supportpilot.service.core.Busy: 同时处理的请求已满(1)
抓到 流式请求结束不还名额 ← [快测试] supportpilot.service.core.Busy: 同时处理的请求已满(1)
抓到 超时不算错误 ← [快测试] AssertionError
抓到 内部异常不算错误 ← [快测试] AssertionError
抓到 路由忽略 user_id(同一人会来回切版本) ← [快测试] AssertionError: 同一个用户总是同一个版本
抓到 灰度版的流量也做影子对比 ← [快测试] AssertionError: 灰度版处理的真请求,不应该再让它自己影子一遍
抓到 影子采样率不起作用 ← [快测试] AssertionError: 200
抓到 影子运行失败当作"一致" ← [快测试] AssertionError
抓到 影子不一致不计数 ← [快测试] AssertionError: 影子请求不计入灰度版的真实样本
抓到 影子运行不是 shadow 模式(会真写) ← [快测试] AssertionError: 灰度版收到的全是 shadow=True
抓到 护栏动作之后统计窗口不清零 ← [快测试] AssertionError
抓到 护栏的发布事件不进指标 ← [快测试] AssertionError
抓到 下线时不标记 draining ← [快测试] AssertionError
抓到 就绪检查不看是否在下线 ← [快测试] AssertionError
抓到 没有请求时不上报 sp_inflight(absent 告警会误报) ← [快测试] AssertionError: sp_inflight 在没有任何请求时也要出现,否则 absent() 告警会误报
抓到 花费不进指标 ← [快测试] AssertionError
抓到 业务鉴权不比较密钥 ← [快测试] AssertionError
抓到 管理接口也认业务密钥 ← [快测试] AssertionError: 业务密钥不能管发布
抓到 500 把内部错误细节发给调用方 ← [快测试] AssertionError
抓到 429 不带 Retry-After ← [快测试] KeyError: 'retry-after'
抓到 超时返回 500 而不是 504 ← [快测试] AssertionError
抓到 发布冲突返回 400 而不是 409 ← [快测试] AssertionError
抓到 请求体大小不设上限 ← [快测试] AssertionError
抓到 就绪检查不看稳定版是否就绪 ← [快测试] AssertionError
抓到 任务队列满了就绪检查也是绿的 ← [快测试] AssertionError: {'ready': True, 'reasons': []}
抓到 幂等命中也返回 202 ← [快测试] AssertionError
抓到 同一个幂等键换了内容,也悄悄返回旧结果 ← [快测试] AssertionError: 同一个键、不同内容:不能悄悄返回上一次的结果
抓到 幂等键不限长度 / 不查可见字符 ← [快测试] AssertionError
抓到 幂等冲突被当成 500 ← [快测试] AssertionError
抓到 提交任务时不鉴权 ← [快测试] AssertionError: 提交异步任务也要鉴权
抓到 影子请求被批准(会真的写出去) ← [快测试] AssertionError: 但什么都没有真的写出去
抓到 流式请求的事件被提前清掉 ← [快测试] AssertionError: []
抓到 时间戳头不生效 ← [快测试] AssertionError: (0.00406, 0.00406)
抓到 结论签名只看结局种类(看不出静默变坏) ← [快测试] AssertionError
抓到 花费不统计 ← [快测试] AssertionError
抓到 变体不生效 ← [快测试] AssertionError: 只有对比结论才看得出
抓到 提示词头不拼进去 ← [快测试] AssertionError: (0.00406, 0.00406)
抓到 清单不含共用提示词内容 ← [快测试] AssertionError: []
抓到 diff 把没变的也报成改动 ← [快测试] AssertionError
抓到 改参数类型不算破坏性 ← [快测试] AssertionError: ({'order_id': 'int', 'note': 'string?'}, [])
抓到 新增必填参数不算破坏性 ← [快测试] AssertionError: ({'order_id': 'string', 'note': 'string?', 'extra': 's
抓到 可选变必填不算破坏性 ← [快测试] AssertionError: ({'order_id': 'string', 'note': 'string'}, [])
抓到 提示词开头会变也不警告 ← [快测试] AssertionError: 提示词开头会变:要警告
抓到 门禁不要求评估 ← [快测试] AssertionError
抓到 门禁不比成功数 ← [快测试] AssertionError
抓到 门禁允许越权 ← [快测试] AssertionError
抓到 门禁的成本上限放宽一倍 ← [快测试] AssertionError
抓到 破坏性变更不要迁移说明 ← [快测试] AssertionError
抓到 直方图不是累计桶 ← [快测试] AssertionError: [1, 0, 0, 0, 0, 0, 0, 0, 1]
抓到 +Inf 桶恒为 0 ← [快测试] AssertionError: [1, 1, 1, 1, 1, 1, 1, 1, 0]
抓到 标签值不转义 ← [快测试] AssertionError
抓到 计数器覆盖而不是累加 ← [快测试] AssertionError
抓到 对账不查标签 ← [快测试] AssertionError
抓到 对账不查 by 里的标签 ← [快测试] AssertionError
抓到 对账不查未声明的指标 ← [快测试] KeyError: 'sp_nope_total'
抓到 对账不查括号 ← [快测试] AssertionError
抓到 管理密钥不对时命令行也返回 0 ← [快测试] AssertionError: 管理密钥不对:命令行要返回非 0
抓到 冒烟失败时命令行也返回 0 ← [快测试] AssertionError
抓到 门禁不通过时命令行也返回 0 ← [快测试] AssertionError
抓到 配置有问题时命令行返回 0 ← [快测试] AssertionError
抓到 基线写死而不是现测 ← [快测试] AssertionError
变异总数: 110 漏网数: 0 其中靠灰度仿真才抓到: 0
最后一行是漏网数:0(全部抓到)。
有一点要说在前面:变异检查只证明"想到的这些改坏方式,测试都能发现" ,不证明测试没有盲区。已知的盲区至少有两个:一是密钥比较用的是 hmac.compare_digest(常量时间比较),把它换成 == 在功能上没有任何区别,只会引入时序侧信道,这种东西行为测试抓不到;二是 Runtime.run 末尾的 store.drop(trace_id)(把这条请求的 span 从内存里清掉),不清也只是多占点内存(RollingStore 本身有上限),没有为它写专门的断言。
- 现场演练。 优雅下线(SIGTERM 对比 SIGKILL,第八章的反例)、回滚(4.4)、配置拒绝启动(4.1)都真的跑过一遍,不是只读代码。
6.4 上线前检查清单
-
SP_ENV、两种密钥、SP_DATA_DIR都由部署系统注入,镜像里没有;config-check通过 - prod 的数据目录是独立的持久卷(发布状态
rollout.json在里面,容器重建后还在) -
/healthz接存活检查,/readyz接就绪检查;编排系统的停止超时 > 服务的--grace - 前面有反向代理:做 TLS、限制请求体、关掉对 SSE 的缓冲 (加了
X-Accel-Buffering: no,那是 nginx 的约定;换别的代理要查它自己的开关) - Prometheus 抓
/metrics;导入monitoring/service_dashboard.json;加载monitoring/service_alerts.yml;RollbackHappened和ServiceMetricsMissing接到会叫醒人的渠道 - 灰度期间
SP_SHADOW_RATE不是 0 - 变更门禁接进 CI:
change-check退出码非 0 就不能合并 - 回滚预案演练过一次:谁来按、命令是什么、多久能生效、回滚不管的事谁来收尾
- 异步任务的调用方都带
Idempotency-Key,并且知道"服务重启会丢排队中的任务"(本篇的队列在内存里) - 日志里没有密钥、没有用户的完整
goal(本篇的日志只记方法、路径、状态码、耗时、环境名)
七、运行与验证
7.1 离线测试
bash
python -m tests.test_service # 全部 120 个,约 1.5 分钟(含 6 个灰度仿真)
python -m tests.test_service --fast # 跳过灰度仿真,约 25 秒
python -m tests.test_service -k jobs # 只跑名字里带 jobs 的
用例分组:
| 组 | 数量 | 覆盖 |
|---|---|---|
环境配置 test_config_* |
9 | 没设环境名、拿错环境的密钥、长度、管理密钥相同、目录串味、prod 约束、脱敏 |
异步队列 test_jobs_* |
9 | 运行、幂等去重与冲突、队列满、失败原因、排空、排空超时与迟到结果、TTL 与幂等键释放、排队时长 |
发布与回滚 test_bucket / pick / ramping / canary / promote / rollback / released / rollout_* |
9 | 分桶均匀与稳定、放量只加人不踢人、灰度规则、转正与 previous、两种回滚、版本不可变、落盘与重启、审计 |
护栏 test_guard_* / test_autoroller_* / test_vstats_* |
10 | 每一条越线条件、边界值(刚好等于阈值不算坏)、样本门槛、影子单独触发、放量节奏 |
入口校验 test_validate_* |
2 | 好输入与各种坏输入 |
内核 test_core_* |
18 | 记账、名额与释放、超时、内部错误、路由、影子(只对稳定版流量、采样、失败当不一致)、流式、护栏 tick、下线、异步 |
真实运行时 test_real_runtime_* |
7 | 稳定版行为、业务结果出错只改结论不改结局种类、明显的异常、影子不写、时间戳头击穿缓存、流式事件 |
HTTP test_http_* |
21 | 检查、鉴权矩阵、状态码映射、500 不泄露细节、SSE 格式、异步与幂等、下线、指标格式、管理接口 |
指标 test_metrics_* |
3 | 累计桶与 +Inf、转义、登记校验、计数与仪表 |
变更管理 test_manifest / diff / classify / gate / eval_* |
13 | 指纹、影响说明、工具兼容性分类、门禁每一条、评估拦住 3 个坏版本 |
看板与告警 test_monitoring_* |
5 | 对账、错误检测、告警必带处置建议、生成文件与生成器同步 |
部署文件 test_deploy_* |
4 | Dockerfile 约定、compose 的超时、.dockerignore、示例环境文件不改就不能启动 |
命令行 test_cli_* |
4 | 配置检查、门禁退出码、ctl 与冒烟 |
灰度仿真 test_sim_* |
6 | 6 个场景的结局 |
最后几行输出:
text
通过 test_cli_ctl_and_smoke_against_a_running_server
通过 test_sim_good_release_ramps_to_promotion
通过 test_sim_loud_bad_release_is_rolled_back_by_the_escalation_guard
通过 test_sim_silent_bad_release_slips_through_without_shadow_compare
通过 test_sim_silent_bad_release_is_caught_by_shadow_compare
通过 test_sim_slow_release_is_rolled_back_by_the_latency_guard
通过 test_sim_cache_busting_release_is_rolled_back_by_the_cost_guard
全部 120 个用例通过(95 秒)
7.2 命令行常用操作
bash
python -m supportpilot.service_cli <子命令>
| 想干什么 | 命令 |
|---|---|
| 只校验配置,不启动 | config-check |
启动服务(环境变量见 .env.example) |
serve [--host 0.0.0.0] [--grace 10] [--tick 30] |
| 看现在谁是稳定版、灰度放了多少、最近的发布事件 | ctl status --url http://127.0.0.1:8080 |
| 注册新版本(不可变) | ctl register --version v2 --variant policy_threshold_24 --note "说明" |
| 开始灰度 | ctl canary --version v2 --percent 5 |
| 手动放量 / 转正 / 立刻让护栏看一眼 | ctl percent --percent 25 / ctl promote / ctl tick |
| 回滚 | ctl rollback --reason "一句话原因" |
| 发布前门禁 | change-check --version v2 [--variant V] [--router R] [--head timestamp] [--migration-note "..."] |
| 灰度仿真 | `simulate good |
| 发布后冒烟 | smoke --url http://127.0.0.1:8080(用 SP_CLIENT_KEY) |
| 生成看板与告警并对账 | python -m supportpilot.service.monitoring |
ctl 用 SP_ADMIN_KEY 环境变量里的管理密钥;smoke 用 SP_CLIENT_KEY。ctl 会带上当前系统用户名作为操作人(X-Actor),出现在审计事件里。
7.3 接上真实环境
- 真实模型。 在
Runtime.__init__里把第 20 篇的make_gateway换成指向真实厂商的网关实现即可,其余服务代码不用动。别忘了影子对比会让每个被抽中的请求多花一份真实的钱。 - 真实的服务器。 把
server.py的make_handler里的分支搬进 FastAPI / Starlette 的路由,用 uvicorn 起;Service完全不用改(它不依赖 HTTP)。SSE 用框架自带的流式响应。注意:框架的异步事件循环里不要直接调用Service.handle(它会阻塞),用线程池包一层。 - 真实的队列。 把
JobQueue换成 Redis / SQS / RabbitMQ 的生产者 + 独立的消费者进程。接口保持submit(payload, idem_key) → (job, is_new),幂等键存在队列后端里。这样重启不丢任务。 - 多副本。 本篇的
Rollout状态存在本进程 的一个 JSON 文件里。多副本部署时,每个副本各读各的文件,灰度状态就不一致了。要把状态放进共享存储(数据库 / 配置中心),并让副本定期拉取;护栏的统计窗口也得汇总到一个地方再判断。这是本篇与真实多副本部署之间最大的差距。 - Prometheus / Grafana。
/metrics是标准文本格式,抓取配置就是普通的scrape_configs;monitoring/service_dashboard.json是按 Grafana 面板 JSON 的结构写的,service_alerts.yml按 Prometheus 告警规则文件的结构写的。本篇没有真的把它们导入过 Grafana / Prometheus,只做了和指标登记的对账,所以导入时如果有字段不兼容,请以你那边的版本为准。 - Kubernetes。
/healthz接livenessProbe,/readyz接readinessProbe;terminationGracePeriodSeconds要大于服务的--grace。这里只讲 Docker 的行为:docker stop发 SIGTERM,等 10 秒(Linux 默认),然后 SIGKILL,Kubernetes 的细节请你自己对照官方文档确认。
八、踩坑清单
| # | 坑 | 现象 | 怎么避免 |
|---|---|---|---|
| 1 | 护栏只看"吵"的指标 | 错误率、转人工占比、花费、延迟全绿,版本转正后才发现结论变了(4.3) | 开影子对比,比"结论"而不是比"有没有出错";评估集里放"该等待却被补发"这类用例 |
| 2 | 收到下线信号就退出 | 在途请求被掐断,调用方看到断连;异步任务不知所踪(4.4) | SIGTERM → /readyz 变红 → 不接新活 → 排空 → 退出;排空时间 < 编排系统的等待时间 |
| 3 | 编排系统的等待时间比服务的排空时间短 | 服务还在排空,容器已经被 SIGKILL |
compose 的 stop_grace_period 写成服务 --grace 的两倍,用测试盯着 |
| 4 | 同步请求满了就排队 | 所有人一起等、一起超时,超时的请求还在后台白跑 | 同步满了立刻 429 + Retry-After;要排队走异步 |
| 5 | 流一开始才发现该返回 429 | 状态码已经是 200,没法再改 | 先取第一个事件,能提前知道的失败在发 200 之前就失败(_stream) |
| 6 | 异步提交没有幂等键 | 调用方超时重试,建出两张工单 | Idempotency-Key;同键同内容返回原任务,同键不同内容报 422 |
| 7 | 幂等键同键不同内容悄悄返回旧结果 | 调用方以为新内容被处理了 | 不一致就报错 |
| 8 | 环境名有默认值 | 忘了设环境变量,服务按"生产"或"开发"悄悄启动了 | SP_ENV 必填、不给默认值;启动时校验密钥前缀、目录、debug |
| 9 | 示例配置文件拷过去就能启动 | .env.example 里的密钥被原样带上线 |
示例里写 CHANGE_ME,让它不改就过不了校验(有测试) |
| 10 | 灰度放到 100% | 稳定版没流量,护栏没有对照组 | 最多放到 50% 就转正;之后的比较靠指标趋势和告警 |
| 11 | 放量后不清零统计窗口 | 上一档的好数据稀释了这一档的坏数据 | 每放大一档窗口清零,下一档只用自己的样本 |
| 12 | 样本不够也下结论 | 6 个样本里 1 个转人工就回滚,或者 0 个出错就放量 | 门槛(min_samples)以下只等,不决定 |
| 13 | 回滚等于重新发布 | 二十分钟才撤回,期间一直在出事 | 版本是服务里可切换的对象;回滚只切换,不构建、不重启 |
| 14 | absent() 告警在冷启动时误报 |
服务刚起、还没有请求,"指标消失"告警就响了 | 启动时就上报 sp_inflight 等仪表(有测试和变异) |
| 15 | 改提示词开头拼时间戳 / 请求号 | 评估没问题、用户没感觉,账单涨了几倍(4.5) | 门禁同时看成功数、花费和缓存命中,不只看成功数 |
两个反例,都是同一件事做对与做错的并排,输出和第四章的是同一份。
反例一:只看四项监控指标,发现不了业务结果出错的问题
python
# examples/canary_blind_spot.py --- 反例:只看错误率 / 转人工占比 / 花费 / 延迟的护栏,看不见"结论悄悄变了"的坏版本
# 同样的坏版本(补发门槛从 72 小时读成 24 小时)、同样的流量,各放一遍灰度:不开影子对比 → 一路转正;开影子对比 → 第一档就回滚。
# 最后算一下,如果让它转正,每 12 个请求会多建几张不该建的工单。运行:python -m examples.canary_blind_spot
from supportpilot.service.releases import Release
from supportpilot.service.runtime import Runtime
from supportpilot.service.sim import GOAL, MIX, render, simulate
def wrong_tickets(variant: str, n: int = 120) -> int:
rt = Runtime(Release("x", variant=variant))
try:
for i in range(n):
rt.run({"goal": GOAL, "order_id": MIX[i % len(MIX)]}, f"t{i}")
return sum(1 for t in rt.demo.tickets if t["order_id"] == "A1004") # A1004 停滞才 30 小时,没到 72 小时门槛:不该建工单
finally:
rt.close()
if __name__ == "__main__":
for name in ("silent", "silent_shadow"):
print(render(simulate(name)))
print()
good, bad = wrong_tickets("stable"), wrong_tickets("policy_threshold_24")
print(f"如果让坏版本转正,120 个请求里"停滞才 30 小时、不该补发"的订单被建了工单:稳定版 {good} 张,坏版本 {bad} 张")
运行(python -m examples.canary_blind_spot,完整输出见 4.3)。关键的三行:
- 不开影子对比:一路
advance,最后promote,转正; - 开影子对比:第 1 轮
rollback:影子对比:17% 的请求结论和稳定版不一致(上限 5%); - 最后一行:
120 个请求里...被建了工单:稳定版 0 张,坏版本 20 张。
读法:这不是护栏写得不好------四项指标各自都完全正常。它们回答的是"服务健康吗",不是"结论对吗"。 后者需要拿一个参照物比(稳定版的结论,或者评估集的标准答案)。
反例二:收到下线信号时手上有请求
python
# examples/drain_demo.py --- 反例 vs 正例:服务收到下线信号时,手上正有一个请求在处理
# kill:直接 SIGKILL(等于"没有优雅下线",比如 docker stop 超时之后的下场)→ 在途请求被掐断
# term:SIGTERM → /readyz 先变红、新请求被拒绝、在途请求处理完再退出
# 为了让"在途"的窗口够长,先把一个"物流专员多等 1 秒"的版本放到 100% 流量上。运行:python -m examples.drain_demo
import http.client
import json
import os
import signal
import socket
import subprocess
import sys
import threading
import time
ENV = {"SP_ENV": "dev", "SP_API_KEYS": "dev-key-00001", "SP_ADMIN_KEY": "dev-admin-0001", "PYTHONPATH": ".", "PYTHONUNBUFFERED": "1"}
KEY, ADMIN = {"X-API-Key": "dev-key-00001"}, {"X-Admin-Key": "dev-admin-0001"}
BODY = {"goal": "包裹一直没到,要求马上补发", "order_id": "A1001", "user_id": "u1"}
def free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def call(port, method, path, body=None, headers=None, timeout=10):
c = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
c.request(method, path, body=json.dumps(body).encode() if body is not None else None, headers={"Content-Type": "application/json", **(headers or {})})
r = c.getresponse()
out = r.read()
c.close()
return r.status, out
def run(mode: str) -> None:
port = free_port()
data = f"/tmp/drain-demo-{mode}/dev"
env = {**os.environ, **ENV, "SP_PORT": str(port), "SP_DATA_DIR": data}
subprocess.run(["rm", "-rf", f"/tmp/drain-demo-{mode}"])
p = subprocess.Popen([sys.executable, "-m", "supportpilot.service_cli", "serve", "--grace", "5", "--tick", "3600"], env=env, stderr=subprocess.PIPE, text=True)
t0 = time.monotonic()
stamp = lambda: f"t={time.monotonic() - t0:4.2f}s"
for _ in range(100):
try:
if call(port, "GET", "/readyz")[0] == 200:
break
except OSError:
time.sleep(0.1)
call(port, "POST", "/admin/rollout/register", {"version": "v2", "variant": "slow_logistics"}, ADMIN)
call(port, "POST", "/admin/rollout/canary", {"version": "v2", "percent": 100}, ADMIN)
print(f"--- 模式 {mode}:{'SIGKILL(不给机会收尾)' if mode == 'kill' else 'SIGTERM(优雅下线)'} ---")
t0 = time.monotonic()
result = {}
def client():
try:
st, out = call(port, "POST", "/v1/support/requests", BODY, KEY, timeout=10)
result["r"] = f"HTTP {st},decision={json.loads(out)['decision']},release={json.loads(out)['release']}"
except Exception as e:
result["r"] = f"失败:{type(e).__name__}"
result["t"] = time.monotonic() - t0
th = threading.Thread(target=client)
th.start()
print(f"{stamp()} 发出一个请求(这个版本每个请求至少要 1 秒)")
time.sleep(0.2)
p.send_signal(signal.SIGKILL if mode == "kill" else signal.SIGTERM)
print(f"{stamp()} 发出 {'SIGKILL' if mode == 'kill' else 'SIGTERM'}")
if mode == "term":
time.sleep(0.1)
try:
print(f"{stamp()} 探测 /readyz → {call(port, 'GET', '/readyz', timeout=2)[0]}")
print(f"{stamp()} 新请求 → HTTP {call(port, 'POST', '/v1/support/requests', BODY, KEY, timeout=2)[0]}")
except Exception as e:
print(f"{stamp()} 探测失败:{type(e).__name__}")
th.join(10)
print(f"{stamp()} 在途请求的结局 → {result.get('r')}(用时 {result.get('t', 0):.2f}s)")
try:
p.wait(10)
except subprocess.TimeoutExpired:
p.kill()
err = p.stderr.read()
msgs = [json.loads(l)["msg"] for l in err.splitlines() if l.startswith("{") and json.loads(l).get("msg") in ("收到下线信号,开始排空", "已退出")]
print(f"{stamp()} 进程退出码 {p.returncode};服务日志里的收尾记录:{msgs or '没有'}\n")
if __name__ == "__main__":
run("kill")
run("term")
运行(python -m examples.drain_demo,完整输出见 4.4)。两种模式的结局:
kill:在途请求 0.20 秒就失败(RemoteDisconnected),日志里没有任何收尾记录;term:/readyz变 503(0.30 秒)→ 新请求得到 503(0.31 秒)→ 在途请求跑完,HTTP 200(1.03 秒)→ 退出码 0,日志里有"收到下线信号,开始排空"和"已退出"。
读法:区别不在代码的"智能",而在顺序:先让别人别再来,再把手上的做完。两边的业务逻辑一行没变。
九、练习与自测
练习都在本篇代码上改,每道都有明确的验收标准。做完把测试补进 tests/test_service.py,并在 tests/mutate_service.py 里加变异(故意改坏你的新代码,测试必须变红)。
练习
- 多副本的发布状态共享 :现在的
Rollout状态存在本进程的一个 JSON 文件里,起两个副本就会各管各的。把它改成"状态文件 + 乐观锁":状态里加一个rev数字,写入时检查rev没被别人改过,否则重试。验收:两个线程同时canary不同版本,结果只有一个成功、另一个得到明确错误;不会出现"两个灰度同时存在";补测试;变异:去掉rev检查,测试变红。并回答:这样还不够的地方在哪(提示:两个进程 、两台机器)。 - 绝对下限护栏:现在的护栏全是"灰度版比稳定版差多少"。如果稳定版本来就很差(转人工 90%),灰度版 95% 也"没差多少"。加一条绝对下限:灰度版的转人工占比不能高于某个固定值。验收:稳定版 90%、灰度版 95% 时护栏回滚;原有场景(good 等)的结果不变;补测试;说明这个固定值怎么定、为什么不能太紧。
- 把任务队列存到 SQLite :
JobQueue重启就丢(开头第 4 条)。用标准库sqlite3存任务;服务重启后,状态是running的任务应当重新入队。验收:提交一个任务,kill -9服务再启动,任务最终是done;同一个幂等键仍然去重;说明"重新入队"为什么要求任务是幂等的,如果不是会怎样;补测试和变异。 - 工具下线窗口 :给
changes.gate加一条规则------删除一个工具时,迁移说明里必须带"最早下线日期",并且距今不少于 N 天;classify_schema之外新增一个"已弃用但保留"的状态。验收:不带日期、日期太近、日期合格,三种情况的门禁结果正确;补测试;变异:把<改成<=,测试变红(边界值)。 - 影子写拦截的自检 :影子请求"什么都不写"现在靠三层保险(审批恒拒绝、独立 Runtime、测试)。再加第四层:服务启动时自己跑一个影子请求,然后检查工单和发件箱必须还是空的,不是就拒绝启动。验收:正常启动通过;你故意在某个工具里绕过审批直接写,启动失败并说出是哪一个;补测试。说明这一层拦不住什么(提示:只测了一个样例请求)。
自测 5 题
- 灰度 5% 时所有指标都正常,是不是可以直接放到 100%?为什么本篇最多只放到 50%?
- 一个版本错误率、转人工占比、花费、延迟全都和稳定版一样,它一定是好版本吗?你怎么抓住"不一样"?
- 容器收到
docker stop之后,服务应当按什么顺序做事?stop_grace_period为什么必须比服务自己的排空时间长? - 调用方超时后重试,为什么异步接口需要幂等键?同一个键带着不同的内容来,为什么应当报错而不是返回旧结果?
- 改了系统提示词里的一句话,评估成功数没变。可以直接发布吗?
参考答案
- 不能直接放。 5% 的样本少,很多问题(尤其是慢、结论变化)要在更大的流量下才暴露,所以每一档都要重新过一遍护栏(4.2 的变慢场景:5% 那一档没抓住,25% 才抓住)。最多放到 50%,是因为护栏靠"灰度版 vs 稳定版"的并排比较,放到 100% 稳定版就没流量了,没有对照组。
- 不一定。 这四项都是"健康度",不是"正确性"。"补发门槛读成 24 小时"的版本就是这样:四项都一样,只是对一部分订单给出了不同结论(4.3)。抓法:影子对比(拿稳定版的结论当参照)、评估集里放"标准答案明确"的用例(发布前的门禁)。要注意影子对比抓的是"不一样",不判断谁对。
- 顺序 :
SIGTERM → /readyz 变 503(不再被分配新流量)→ 新请求直接 503 → 排空异步任务和在途请求 → 退出。stop_grace_period要更长,因为编排系统到点就会发SIGKILL(docker stop默认等 10 秒再强杀);服务排空到一半被杀,等于没有优雅下线。 - 调用方超时,只代表它没等到回复 ,不代表服务没处理;重试没有幂等键就会处理第二次(建出两张工单)。同键同内容应当返回原任务。同键不同内容说明调用方把两件不同的事用了同一个标记,悄悄返回旧结果等于让调用方以为新内容已经处理------错误被藏起来了,所以要报 422。
- 不能直接发布。 提示词的改动要过门禁:不只看成功数,还要看花费和缓存命中(提示词开头一变,缓存就可能整个失效,见 4.5 的时间戳例子),并且评估集覆盖不到的变化,仍要靠灰度 + 影子对比。成功数没变只能说明"评估集上没变差"。
十、下一篇预告
到这里,SupportPilot 有了评估(18)、观测(19)、成本和稳定性外壳(20),也有了能上线、能放量、能撤回的服务外壳(21):服务化封装、环境隔离、灰度和回滚、变更管理、监控告警,每一块都有真实输出、有测试、有变异检查。开发篇到这里收尾------它现在是一个能被别人调用、也能被放心修改的服务了。
但"能被别人调用"也意味着能被别人攻击 。下一篇《Agent 威胁建模》是安全篇的开头:先不写防御代码,先回答"攻击面在哪 "------Agent 比普通服务多出来的是什么(它读不可信的文字,并且会照着去做);信任边界画在哪;OWASP 给大模型应用和 Agent 列的风险有哪些,哪些真的落在 SupportPilot 上;以及一个很实用的判断框架:私有数据 + 不可信输入 + 对外通信,三样凑齐就是高危。可交付的是一份威胁模型文档和一张风险矩阵。本篇留下的东西会直接用上:审批、越权率、服务的鉴权和管理接口、影子请求拦写,都是第 22 篇要拿来"攻"的对象。
最后想问问大家: 你们的 Agent 是怎么上线的------一次全量,还是灰度?有没有遇到过"所有指标都正常,但结果悄悄变了"的发布?回滚用了多久,是重新发布还是切换版本?容器重启时,在途的请求和异步任务是怎么处理的?提示词和工具定义的改动,有没有像代码一样过评审和评估?欢迎在评论区聊聊你的做法和踩过的坑。