12884 字
64 分钟
2026-11-06 AI Agent 面试训练
AI Agent 面试训练
2026-11-06

2026-11-06 AI Agent 面试训练#

今日学习主题#

今天是”撤销都是新的写入”主题:Saga 的补偿不是回滚而是新的正向事务,批处理推理把执行时机卖给了成本、结果回来要靠幂等键重新对齐,代码执行沙箱把不可信代码的破坏半径限在租户与本次执行之内、重跑不能靠”删了重来”,不可变制品让”回滚”退化成”切换到另一个内容标识”,打家劫舍的动态规划则说明状态一旦装错约束,后续每一步递推都在放大这个错误。五道题共用一个判断:系统里没有时间倒流,只有可补偿、可重放、可隔离、可复现、可递推的正向操作。

题目目录#

编号题目题型一级类型难度
B01补偿不是回滚:Saga 到底保证了什么非 Agent 基础分布式系统中高
B02把 24 小时卖给成本:批处理推理的适用边界Agent 基础LLM 应用工程中高
S01沙箱里的 rm -rf:隔离强度、冷启动与重跑副作用Agent 场景AI Agent 核心高
S02回滚到上一个版本,行为却变了:制品不可变性与回滚对象非 Agent 场景软件工程与平台工程高
L01198. 打家劫舍LeetCode基础动态规划medium

B01 · Q-20261106-B01 · 补偿不是回滚:Saga 的语义边界与隔离性缺口#

题目#

你的团队把下单流程拆成了订单、库存、账户三个服务:创建订单要先在库存服务预占额度,再在账户服务冻结余额,最后把订单置为已确认。三套数据库彼此独立,跨服务事务没有可用的 2PC。面试官问:Saga 是什么?它保证了什么、不保证什么?补偿事务”补”的到底是什么?为什么说 Saga 缺少隔离性,工程上靠什么对冲?

先思考#

  1. 第二步失败时,第一步已经提交的扣减,靠什么”撤回”?数据库能帮你回滚另一台机器上的事务吗?
  2. 补偿动作本身也会失败、也会超时。如果补偿只成功了一半,系统处于什么状态?这个状态谁来收尾?
  3. 在”库存已占、订单未确认”的这几百毫秒里,另一个用户看到的是什么?他能不能把这批库存买走?
  4. 上游调用超时了,这是”失败”还是”不知道”?如果直接触发补偿,而下游其实已经成功,会发生什么?

参考答案#

Saga 是把一个跨服务的业务事务拆成一串本地事务,由协调机制(编排或协同)保证整串要么全部完成,要么对已完成的步骤逆序执行补偿事务。它保证的是最终的业务一致性,不保证原子性,也不保证隔离性。适用边界:步骤能拆成独立的本地事务、每一步都有业务上可定义的补偿动作、允许中间态对外短暂可见;不适合要求强原子性的场景(借贷必须同时生效的记账),也不适合补偿代价高于收益的步骤(已发货、已对外通知)。示例:订单创建 → 库存预占(写预占记录)→ 账户冻结(写冻结记录)→ 订单置为已确认;账户余额不足时,账户服务返回业务拒绝,Saga 逆序补偿:解冻(本次未成功,空操作)、释放预占,订单置为已取消。注意补偿不是”删掉那条预占记录”,而是新增一条”释放”记录,可用量由”总量减有效预占”算出,历史仍可审计。相近概念区别:本地事务靠 undo log 真正回滚,Saga 的补偿是新的正向业务动作,语义上是”业务抵消”而不是”抹除”;2PC/XA 提供原子提交,但要求资源管理器配合、同步阻塞、跨组织基本不可行;TCC 是 Saga 的一种特殊形态,把每一步显式拆成 Try / Confirm / Cancel 三个接口。典型工程应用:电商下单、跨行转账、机票酒店组合预订、多云资源开通——凡是跨边界、长耗时、参与者异构的业务,基本都落在 Saga 或 TCC 上。

面试现场回答(1–2 分钟):Saga 用一串本地事务加补偿动作替代分布式事务,保证的是最终一致,不是原子性。它的关键语义是:补偿是一次新的正向事务,不是把状态倒回去。所以设计时要回答四个问题——每一步的补偿动作在业务上是什么意思、补偿本身失败了怎么办、中间态对别的请求可见会引发什么、什么条件下才允许触发补偿。触发条件尤其容易被做错:只有业务规则拒绝才该补偿,超时和连接中断是未知态,必须先查询或重试,直接补偿会和下游的成功写入叠加成双扣。它的第二个缺陷是没有隔离性,中间态被别人看到并据此做了决策,补偿也救不回来,所以要用预占、悬置状态、重读校验这类语义锁去对冲。

关键点#

  1. 补偿是新的正向事务,会留下轨迹、也会失败;它不是状态回退,已发货、已发短信这类副作用只能业务抵消。
  2. Saga 只保证最终一致,不保证隔离:中间态对外可见,需要预占/悬置态/重读校验等语义锁对冲,且必须逐步骤分析,不能一句”最终一致”带过。
  3. 补偿必须幂等、可重试、有终态;补偿反复失败不能无限重试,要落到人工介入队列并留可诊断上下文。
  4. 触发补偿的判定要区分”业务拒绝”与”技术性未知”:超时/连接断是未知态,先查询或按幂等键重试,否则重试成功与补偿叠加造成双重扣减。
  5. 编排式 Saga 流程显式、易观测、便于加补偿与超时策略;协同式耦合低但流程隐式、排障困难。可靠性前提是”改库与发消息原子”,通常需要 Transactional Outbox 兜底。

常见误区#

  1. 把超时当失败直接补偿:下游可能已经成功,补偿会与成功写入叠加,最终表现为丢单或双扣,而日志上两边都是”成功”。
  2. 认为补偿能把系统恢复到事务开始前的状态:库存可能已被其他订单买走、解冻后的余额可能已被别的消费用掉,“原状”在补偿执行时早已不存在,补偿只能是业务语义上的等价抵消。
  3. 只答”我们用 Saga 所以最终一致”,不谈隔离性对冲与补偿幂等,等于把分布式事务里最难的两件事全部跳过。

延伸学习#


B02 · Q-20261106-B02 · 批处理推理改变了哪一份契约#

题目#

团队要把两类负载从在线同步调用迁到批处理接口:一是夜间对 12 万条历史会话做记忆抽取,二是每天对 3000 条评测样本跑回归。产品侧希望顺便把”会话结束后的自动摘要”也搬过去,因为量最大。面试官问:批处理推理改变的到底是什么契约?它的适用边界在哪?在 Agent 系统里它应该承担什么角色、不能承担什么角色?

先思考#

  1. 同步调用你买到的是”多久返回”;批处理你买到的是折扣,那你卖出去了什么?谁在为这个交换付代价?
  2. 结果回来时,你怎么知道哪一行对应哪个请求?如果供应商明确不保证输出顺序呢?
  3. 一批 5 万条里有 300 条失败、另有一批没在窗口内跑完——你是整批重跑还是只补失败子集?重跑会不会产生重复写入?
  4. Agent 的多轮工具调用循环,能不能整段搬进批处理?

参考答案#

批处理推理把请求聚合成文件提交、在约定的时间窗口内异步完成,本质是用延迟弹性换单位成本:它改变的不是”推理能力”,而是”完成时点”与”结果对齐方式”这两条契约。以 OpenAI Batch 为例(官方文档已核验):相对同步定价低 50%,每批在 24 小时内完成,创建时的完成窗口目前只能设为 24h,单批上限 5 万个请求 / 200MB,结果通过 output_file_id 下载,输出行顺序可能与输入不一致、必须用 custom_id 映射,输出文件在批次完成后 30 天自动删除,过期批次中未完成请求被取消、已完成请求仍然计费。适用边界:结果可等待、单请求自包含、可重放、失败可精确定位的负载。示例:夜间对 12 万条会话做记忆抽取,提交一个 batch,custom_id 编码为 tenant:session:prompt_version,结果回写时按 custom_id 幂等 upsert;白天在线路径仍走同步。相近概念区别:与自建异步队列的差别在于,批处理是供应商侧的资源打包与定价模式,队列是你自己的调度;与流式相反(首 token 延迟无关紧要);与提示缓存不同——缓存省的是重复计算,批处理省的是单位价格。Agent 中的典型应用:离线轨迹回放评测、记忆固化与摘要、知识库批量 embedding 与重建、prompt 版本对比实验。但它不能承担在线 Agent 循环——每一步依赖上一步的工具结果,搬进批处理等于每一步等一个 24 小时窗口;也不能承担用户可感知的路径,比如”会话结束后自动摘要”如果要在列表页立刻展示,就属于在线契约。

面试现场回答(1–2 分钟):批处理卖的是时间换价格,所以它替换掉的第一份契约是延迟承诺——从 P99 毫秒级变成小时级窗口,任何用户当场要看的输出都不能搬。第二份契约是结果对齐:同步调用靠请求响应天然配对,批处理靠你自己设计的幂等键,因为输出顺序不保证、可能部分失败、可能过期,你必须能用 custom_id 精确定位每一条并做幂等回写。所以判断一个负载能不能搬,看三条:结果能不能等、单条请求是不是自包含、失败能不能只重跑子集。在 Agent 系统里它适合放在离线侧——评测回归、记忆固化、批量向量化;在线主循环不适合,因为那里的每一步都依赖上一步的结果。

关键点#

  1. 批处理替换的是延迟承诺,不是推理能力:SLA 从毫秒级变成小时级窗口,用户可感知路径一律不搬。
  2. 幂等键必须在提交前设计:custom_id 承载业务主键加版本,因为输出顺序不保证、部分失败与重投递都是常态。
  3. 部分成功是默认形态:窗口内未完成的请求被取消、已完成的仍计费,必须能续跑未完成子集而不是全量重跑。
  4. 单请求必须自包含:有多轮工具依赖的 Agent 循环会被”每步等一个窗口”放大成不可用。
  5. 结果落地要带版本维度(模型、prompt、配置),否则新旧结果混在一张表里,评测对比直接失真。

常见误区#

  1. 用输入行号或位置对齐输出:供应商明确不保证顺序,靠位置映射会把 A 的摘要写到 B 的记忆里,且错误是静默的。
  2. 把在线失败请求”转批处理重试”却没有幂等键:重跑会重复写入,比失败本身更难清理。
  3. 因为”便宜一半”就全量搬迁,忽略时效性业务(日报、告警、会话列表摘要)对窗口的硬要求,最后用同步补偿把它搬回来。

延伸学习#

  • OpenAI《Batch API》官方指南(已核验:50% 折扣、24 小时窗口、5 万请求 / 200MB 上限、custom_id 映射与 30 天输出保留;platform.openai.com/docs/guides/batch 现重定向至此):https://developers.openai.com/api/docs/guides/batch
  • 实践方向:在批处理结果回写链路上做一次”过期 + 部分失败”演练——构造一个 expired 批次,验证你的续跑逻辑只补未完成子集且不产生重复写入。

S01 · Q-20261106-S01 · 代码执行沙箱:隔离强度、冷启动与重跑副作用#

题目#

背景:你负责一个数据分析型 Agent,用户用自然语言提问,模型生成 Python / Shell 脚本在服务端执行,结果回传后再由模型总结。客户包括 2000 家企业租户,每天约 40 万次执行,峰值 120 QPS,平均单次执行 6 秒,约 30% 的执行会写文件;其中约 8% 会调用”发报告邮件""写 CRM 商机”这类外部工具。

当前架构:K8s 集群,沙箱调度器维护 50 个预热 Pod,每个 Pod 内是普通容器(runc)+ seccomp 默认策略 + 只读 rootfs + 一块被同节点多个租户共享的 /workspace PVC;执行完把产物推到对象存储;外部工具统一走工具网关。

目标:① 租户之间不可见、不可破坏;② 重跑不产生重复的外部副作用;③ 沙箱启动 p95 回到 2.5 秒以内;④ 全过程可审计(谁、在什么镜像里、执行了什么、产生了什么副作用)。

限制:单节点密度不得低于 20 个沙箱;不改业务侧代码(模型生成的脚本已经上线,不能要求用户改写法);不能给每个租户独立集群;基础设施预算最多上浮 30%。

已知现象:

  1. 一次脚本把 /workspace 上的共享目录清空,事后发现 A 租户的中间文件能被 B 租户读到;安全团队要求给出”结构性修复”,不接受在业务层加路径黑名单。
  2. 客户投诉同一份报告收到两封邮件、CRM 里出现两条相同商机。排查显示任务在工具调用阶段超时,编排层重试了整个任务。
  3. 试点把运行时换成 gVisor,隔离性评审通过,但沙箱启动 p95 从 1.8 秒涨到 4.5 秒,产品侧不接受。

需要取舍的问题:隔离强度与启动延迟、单机密度之间的取舍;同步等待与异步通知的取舍;默认禁止出网与”脚本要装依赖”之间的取舍。

先思考#

  1. 共享 PVC 上的”路径校验”能构成隔离边界吗?如果校验通过之后、真正打开文件之前,目录被换成符号链接呢?
  2. 普通容器 + seccomp 的隔离依赖什么?当被执行的代码是模型生成、可能恶意时,“策略写得足够全”这个前提还成立吗?
  3. 任务重试为什么会导致两封邮件?“删掉工作目录重跑”能撤销已发出的邮件吗?
  4. gVisor 的 2.7 倍启动差距来自哪里?是所有负载都付这个代价,还是只有某类负载付?

参考答案#

结论先行:三个现象是三个独立缺陷——共享挂载把租户边界做在了应用层(等于没有边界)、重试没有副作用幂等键(把”再执行一次”当成”撤销上一次”)、隔离强度被当成全局开关而不是按风险分级。修复要分三层落地:数据面在挂载层就不存在别人的数据,执行面按风险分级选运行时,副作用面用幂等台账把”重跑”变成”重放”。

问题拆解:① 跨租户可见/可删,根因是同一块共享卷被多个租户以可写方式挂载,任何应用层的路径判断都只是”校验”,不是”边界”——校验对象与生效对象之间存在窗口(符号链接、大小写、Unicode 归一化、相对路径都能让两者不一致),而真正的边界只能由 mount namespace、运行时或虚拟机提供。② 重复副作用,根因是重试的粒度错了:编排层重试的是”整个任务”,而工具调用不是幂等的,重试等于再发一次邮件。③ 启动延迟,根因是把隔离强度做成了全局二选一,而实际上 40 万次执行里真正需要强隔离的是”模型生成 + 需要出网 + 写共享资源”这一小部分。

核心方案:

  1. 数据面:取消跨租户共享可写卷。每次执行一个独立工作目录(emptyDir 或本地盘 + overlay 上层),只挂载”本租户本次执行”的目录;租户之间需要交换数据时走对象存储 + 短时效预签名 URL,作为显式授权动作,而不是靠同一块盘上的目录权限。rootfs 保持只读,/tmp 用 tmpfs 并设容量上限。
  2. 执行面:运行时分级。只读计算类(模板化脚本、纯数据分析)用 runc + seccomp + 非 root + 只读 rootfs + 禁出网;写文件、装依赖、执行模型生成的自由代码走 gVisor(用户态应用内核拦截系统调用、不直通宿主内核,文件访问经 Gofer 中介);处理外部不可信内容或需要出网的走 microVM 级隔离。分级依据三个可判定输入:代码来源(模板 / 模型生成 / 用户上传)、是否需要出网、是否写共享资源。
  3. 副作用面:工具网关统一收口 + 副作用台账。幂等键 = task_id + step_id + 规范化参数指纹,邮件与 CRM 写入前先查台账,命中则直接返回上次结果;task_id 由编排层在任务创建时生成,重试复用同一个,不重新生成。台账采用”先占位(pending)→ 执行 → 回填(done/failed)“,占位冲突即视为已在执行。
  4. 延迟:沙箱生命周期与任务解耦(一个沙箱连续执行同一任务的多步,减少冷启动次数);预热池按运行时分别维护,gVisor 池单独预热;超过 5 秒的执行改为异步提交 + 事件通知,把”必须同步等”的路径缩到最短。

执行流程:请求进入 → 编排层生成 task_id 并写任务记录 → 调度器按风险分级选运行时与预热池 → 分配独立工作目录并挂载 → 执行(墙钟 + CPU 时间双超时、cgroup 限制内存与进程数)→ 产物写对象存储 → 工具调用经网关并过台账 → 结果汇总 → 沙箱销毁并回收目录 → 审计事件落库。

技术选型与依据:runc 作为基线(启动快、密度高、兼容性好,隔离依赖宿主内核,适合可信输入);gVisor 用于不可信代码(隔离来自”系统调用不直通宿主内核”,代价是官方明确承认的应用兼容性下降与单次系统调用开销上升,系统调用密集负载最吃亏);microVM 用于需要出网或处理外部不可信内容的场景(隔离最强、密度最低、启动最慢)。备选方案:全部 gVisor(统一但延迟与成本不达标)、全部 microVM(隔离最好但 30% 预算装不下)、仅靠 seccomp 收敛系统调用(对任意未知程序难以定义完备策略)。

方案取舍:隔离强度上升 → 冷启动上升、密度下降、系统调用密集负载性能下降。分级的代价是两套运行时、两套兼容性矩阵和更复杂的调度逻辑,收益是让 90% 的负载不付强隔离的税。同步改异步提升了吞吐与成本效率,代价是产品交互从”等结果”变成”提交后通知”,需要产品侧配合。

失败路径与边界:gVisor 池不可用时不得静默降级到 runc——高风险任务应排队或失败并明确告知,低风险任务才允许降级;台账写入失败时,副作用工具必须拒绝执行(fail-closed),否则幂等保证失效;已发出的邮件无法撤回,只能发更正邮件,这属于业务补偿而不是技术回滚。边界条件:脚本需要安装依赖但默认禁出网——用私有镜像代理放行包索引域名;产物超过本地盘容量——直写对象存储并只保留句柄;需要 GPU 的任务不在这套沙箱内。

超时、重试、幂等与恢复:超时按墙钟与 CPU 时间双限,脚本”空转但活着”也要能被杀掉;重试预算 2 次并带抖动,重试复用 task_id;幂等键覆盖所有外部副作用,而不是只覆盖数据库写入;孤儿沙箱按创建时间扫描回收(Pod 失联时容器可能仍在跑);恢复策略是”从最近的检查点重放任务步骤”,已完成的步骤通过台账直接短路。

安全、成本、延迟、可靠性:安全上收敛到最小权限——非 root、禁特权、只读 rootfs、drop 全部 capabilities、默认禁出网、按需白名单;审计记录”任务、租户、镜像 digest、运行时类型、执行命令摘要、副作用台账键”。成本上密度按单节点 20 个沙箱设计,预热池是固定成本,需要按峰谷调整池大小。延迟上把启动拆成”调度 + 拉镜像 + 运行时初始化 + 挂载”,预热池只解决后两段。可靠性上靠 cgroup 做租户间资源隔离,避免一个租户的脚本打满节点影响其他租户。

验证指标:跨租户访问尝试数与拦截数、由隔离层(而非应用层)拒绝的越界写次数、重跑导致的重复副作用数(目标恒为 0)、沙箱启动 p95 与预热池命中率、gVisor 兼容性失败率(按租户与脚本类型分桶)、单次执行成本、超时率与孤儿沙箱回收数。

追问 1:为什么不能在业务层做路径校验,拒绝 .. 和绝对路径就够了吗? 不够,而且方向错了。校验对象必须等于生效对象:校验时是字符串,使用时是内核解析后的真实路径,中间隔着符号链接、硬链接、大小写不敏感文件系统、Unicode 归一化、.. 的相对基准,任何一处不一致都能让”校验通过”变成”越界写入”。正确做法是让越界目标在挂载层就不存在——容器只看到本次执行的工作目录,其余路径没有挂载点,应用层校验只承担”早失败并给出清晰报错”的职责。

追问 2:重跑为什么不能”清空工作目录重来”? 文件系统可以清空,外部世界不能。邮件已投递、CRM 商机已创建,这些是既成事实,“删了重来”只会制造第二份。所以幂等必须做在副作用边界上:工具网关用幂等键判定”这一步是否已经发生过”,已发生则返回上次结果;确实需要纠正时走业务补偿(更正邮件、关闭重复商机),而不是技术上假装没发生。

追问 3:只给高风险租户开 gVisor,怎么防止分级退化成拍脑袋? 把分级输入限定为三个可判定字段:代码来源(模板 / 模型生成 / 用户上传)、是否请求出网、是否写共享资源,规则写成配置并有单元测试覆盖;灰度期按级别统计拦截数、p95 启动与兼容性失败率,用数据反推阈值。分级规则要能被审计(每次执行记录命中哪一级、为什么),否则出事后无法解释”为什么这次没进强隔离”。

关键点#

  1. 隔离边界必须由挂载、命名空间或运行时提供,应用层路径校验不是边界——校验对象与生效对象之间存在窗口。
  2. 重试的幂等要落在副作用边界(工具网关 + 幂等键),落在任务粒度上等于允许重复发送。
  3. 隔离强度应按”代码来源 × 是否出网 × 是否写共享资源”分级,而不是全局二选一;分级的代价是两套运行时与兼容性矩阵。
  4. 强隔离运行时不可用时必须显式拒绝高风险任务,静默降级会让隔离承诺变成一句空话。
  5. 已发生的外部副作用不可撤销:技术层做幂等防重,业务层做补偿纠错,两者不能互相替代。

常见误区#

  1. 在脚本执行前做一次路径规范化与黑名单校验就认为解决了越权:TOCTOU、符号链接与大小写变体都能绕过,安全团队要的是”目标不存在”,不是”目标被拒绝”。
  2. 把重试当成”再执行一次也没关系”,只给数据库写入做了唯一约束,忘了邮件、CRM、消息推送这些非事务副作用——它们才是客户能直接感知的重复。
  3. 为了过安全评审把所有流量切到 gVisor,然后因为 p95 不达标又整体切回来:两次都是全局开关,问题从来不在”用不用 gVisor”,而在”谁必须用”。

小型实践#

用 Docker 起两个容器验证隔离层级:第一个 --read-only --tmpfs /tmp --user 1000,在其中尝试写 /etc 与读取宿主路径,确认失败原因来自内核而不是脚本判断;第二个不设限制,观察差异。随后写一个 SQLite 幂等台账:以 task_id + step_id + 参数指纹 为主键,模拟同一任务重跑两次,验证第二次的”发邮件”动作被台账短路并返回首次结果。最后把两次执行的外部调用次数打印出来对照。约 40–60 分钟。

延伸学习#

  • gVisor 官方文档《What is gVisor?》(已核验:Sentry 拦截系统调用且不直通宿主内核、Gofer 经 9P 中介文件访问,并明确承认兼容性下降与单次系统调用开销上升):https://gvisor.dev/docs/
  • Kubernetes 官方文档中的 RuntimeClass(同一集群内按 Pod 选择不同运行时,是”分级隔离”的落地机制):https://kubernetes.io/docs/concepts/containers/runtime-class/

S02 · Q-20261106-S02 · 制品不可变性与”回滚”的对象#

题目#

背景:一个电商平台,200 个微服务、日均 60 次发布、500 个节点(K8s 为主,少量虚拟机),私有 Harbor 作为镜像仓库。发布系统以镜像 tag 为单位:pay-service:2.31.0 这样写进部署清单,回滚就是把 tag 改回上一个版本号。

规模:单服务日均 30 万次部署拉取,镜像平均 400MB,单次滚动发布 40 个 Pod,回滚要求 5 分钟内完成。

当前数据流:CI 构建镜像 → 推送 Harbor(latest 与语义化 tag 同时打)→ 部署清单引用 tag → 节点 kubelet 按 tag 拉取(本地已有则命中缓存)→ 配置通过 ConfigMap 独立下发。

目标:让”回滚”变成确定操作——5 分钟内回到一个已知良好的状态,并且能证明回去的就是那个状态。

限制:发布不能停;不能强制 200 个团队全部更换 CI 工具链;制品存储预算有限;数据库迁移不能停机。

已知现象:

  1. 支付服务发版后错误率上升,回滚到 2.31.0 后故障没有恢复。
  2. 巡检发现同一 Deployment 的 20 个 Pod 里,3 个运行的镜像 digest 与其余 17 个不同,但 tag 都是 2.31.0。
  3. 同一个 commit 连续构建两次,产物 digest 不同;某服务依赖写的是内部包 ^1.4.0。
  4. 回滚过程中有 6 个 Pod 因为”镜像拉取失败”卡在 ImagePullBackOff,回滚被判定失败并自动中止,最后靠人工逐台处理。

需要取舍的问题:构建速度与可复现性;制品保留成本与回滚窗口;集中式发布平台与团队自治。

先思考#

  1. tag 和 digest,哪一个才是”内容”的标识?如果同一个 tag 被重新推送覆盖,回滚到那个 tag 意味着回到什么?
  2. 同一个 tag 出现两种 digest,可能来自哪几个环节?节点缓存、多地域副本、离线导入,还是构建本身就不确定?
  3. 同一 commit 两次构建结果不同,说明构建输入里有哪些”没被声明的变量”?
  4. 回滚卡在 ImagePullBackOff 就整体中止,这个判定合理吗?回滚应该以什么为完成标准?

参考答案#

结论先行:现象 1 与 2 是同一个根因的两面——回滚的对象选错了:tag 是可变的引用,digest 才是内容标识;现象 3 让”同一 tag 多 digest”成为必然;现象 4 是回滚完成条件定义错误。修复的主线是:发布与回滚只认 digest,tag 退化为人类可读的别名;制品不可变;构建输入显式化;回滚的完成标准改为”目标副本全部运行目标 digest 且健康检查通过”。

问题拆解:① 回滚无效,是因为 2.31.0 这个 tag 在不同时间指向了不同内容(可能被重新推送、被 CI 重跑覆盖、被不同地域仓库各自同步),“回到 tag”不等于”回到当时的那份字节”;② 同 tag 多 digest,来自节点本地缓存与拉取策略(imagePullPolicy 与 tag 组合时的缓存命中差异)、多仓库副本、以及构建不可复现三者叠加;③ 构建不可复现,来自没被固定的输入:依赖范围 ^1.4.0 每次解析到不同小版本、基础镜像用浮动 tag、构建时写入时间戳或 Git 状态、构建缓存跨环境复用;④ 回滚中止,是因为把”部分 Pod 拉取失败”当成”回滚失败”,而实际上发布系统缺少”回滚是收敛过程”的模型。

核心方案:

  1. 回滚对象改为 digest:部署清单与发布单记录 image@sha256:...;tag 只用于人读与检索。回滚动作是”把 ReplicaSet 的镜像指向已知良好 digest”,与 tag 当前指向无关。
  2. 制品不可变:Harbor 开启 tag 不可变策略,禁止覆盖推送;同一版本重复构建推送到新 tag,绝不复用旧 tag。发布门禁校验”目标 digest 已存在且带签名”。
  3. 构建输入显式化:锁文件必须入库并在 CI 中冻结解析;基础镜像固定到 digest;构建参数固定;剥离时间戳与随机量(或在镜像内保留构建元数据但不参与内容哈希);开启”两次构建 digest 一致”的校验任务,结果作为该服务可发布性的一个信号。
  4. 签名与物料清单:cosign 签名 + SBOM 随制品入库;准入控制器只允许”已签名且 SBOM 可查”的 digest 进入集群。
  5. 配置与制品同版本绑定:ConfigMap 走同一套版本化与回滚机制(配置也有 digest),否则”代码回滚了、配置没回滚”会复现同类故障。
  6. 回滚完成条件重定义:以”目标副本数全部 Running 且运行目标 digest”为收敛条件,允许部分节点失败后重试与替换,而不是一次拉取失败就整体中止。

执行流程:构建(锁定输入)→ 产出 digest + SBOM + 签名 → 推送并登记”版本 → digest”映射表 → 发布单引用 digest → 灰度批次按 digest 滚动 → 观测指标达标后全量 → 回滚时读取映射表,取上一个”已知良好”的 digest → 按批次替换并等待收敛 → 记录回滚前后的 digest 与配置版本。

技术选型与依据:digest 作为唯一标识(OCI 规范中 digest 就是内容标识,从不可信来源取内容时应按 digest 校验,这是”回滚可证明”的基础);Harbor 的 tag 不可变策略(成本最低、见效最快的一层);cosign + SBOM(把”这个 digest 由哪些输入产生”变成可回答的问题);部署工具用 digest 渲染而非 tag。备选方案:只做 tag 不可变(挡住覆盖推送,挡不住构建漂移);只做可复现构建(成本高、短期难达标);用”发布单快照 + 人工核对”(依赖流程纪律,规模化后必然失效)。

方案取舍:可复现构建要求固定一切输入,构建速度与灵活性都会下降(尤其对依赖频繁变动的服务),所以要允许”声明式例外”:短期做不到比特级可复现的服务,至少做到”输入清单可审计 + 两次构建差异可解释”。制品保留成本与回滚窗口直接冲突:保留 90 天全量 digest 成本高,可按”最近 N 个版本 + 每个版本至少一个已知良好 digest”分层保留,GC 策略必须排除被回滚单引用的 digest。

失败路径与边界:目标 digest 已被 GC 删除——回滚直接不可能,所以 GC 必须与版本登记表联动;数据库迁移不可回滚——采用 expand-migrate-contract,旧代码必须能读新 schema,破坏性变更(删列)延后两个版本;外部依赖(第三方 API 版本)变更不受制品控制——需要在发布单里登记外部依赖版本;回滚过程中节点磁盘满导致拉取失败——属于容量问题,应作为回滚前置检查。

超时、重试、幂等与恢复:回滚本身要幂等:重复执行收敛到同一组 digest,不产生中间态残留;批次超时后继续推进而不是中止;镜像拉取失败按节点重试并允许替换节点;回滚失败要留下”部分节点在旧版本、部分在新版本”的明确记录,而不是回滚一个虚假的”成功”。

性能、可用性与验证指标:回滚耗时 p95(目标 5 分钟内)、回滚成功率、ImagePullBackOff 次数、tag 与 digest 一致性巡检通过率、两次构建 digest 一致率(可复现性信号)、SBOM 覆盖率、签名验证拒绝次数、回滚后错误率恢复时间。上线后观测应包含”回滚演练”:每月对一个非核心服务做一次真实回滚,验证 5 分钟承诺不是纸面数字。

追问 1:开了 tag 不可变,是不是就够了? 不够。tag 不可变只防”覆盖推送”,不防”同名不同源”:不同地域仓库各自同步、离线导入、节点本地缓存都可能让同一 tag 对应不同 digest;更不能解决构建漂移——两次构建产物本来就不同,只是各自被推成了不同 tag。真正的保证来自”部署只认 digest”加”构建输入显式化”。

追问 2:数据库迁移破坏了回滚,怎么处理? 用 expand-migrate-contract 把一次变更拆成三步:先加新列/新表并双写(expand),再回填并切换读路径(migrate),最后在确认旧版本代码不再被回滚到之后删除旧列(contract)。关键约束是”任何时刻,旧版本代码与新 schema 必须能共存”,破坏性变更至少延后两个发布周期,回滚窗口内不允许执行 contract 步骤。

追问 3:可复现构建做不到 100%,工程上怎么降级? 分三级:比特级可复现(两次构建 digest 相同,最强);输入可复现(锁文件、基础镜像 digest、构建参数全部固定,差异可解释);输入可审计(至少能列出这次构建用了哪些输入与版本)。多数团队的现实目标是第二级,配合签名与 SBOM,能回答”这个 digest 由什么产生、谁签的、有没有已知漏洞”,回滚的可证明性已经足够。

关键点#

  1. tag 是可变引用,digest 才是内容标识;回滚必须指向 digest,否则”回到 2.31.0”这句话没有确定含义。
  2. 同一 tag 多 digest 有三个独立来源:覆盖推送、多副本/缓存、构建漂移;只堵一个不够。
  3. 构建不可复现的典型漏项是依赖范围、基础镜像浮动 tag、时间戳与跨环境缓存;锁文件与 digest 固定是最低成本的两刀。
  4. 回滚的完成条件是”全部副本运行目标 digest 且健康”,不是”命令执行成功”;部分失败要能继续收敛。
  5. 制品 GC 必须与版本登记表联动,否则回滚会在最需要的时候发现目标已经被删。

常见误区#

  1. 把”回滚失败”归因为发布系统不稳,实际上是回滚对象本身不确定:同一个 tag 在不同节点、不同时间指向不同内容,任何系统都无法保证回到”当时那份”。
  2. 只做镜像签名不做 digest 部署:签名证明了”某个 digest 是谁构建的”,但如果部署还按 tag 拉取,签名和运行时的内容可以不是同一个东西。
  3. 认为数据库迁移可以靠”回滚脚本”解决:drop column、数据格式转换这类操作没有可靠逆操作,正确做法是让新旧版本在 schema 上并存,把不可逆动作推迟到回滚窗口之外。

小型实践#

在本地用两个 Dockerfile 复现构建漂移:一个写入 RUN date,一个固定 SOURCE_DATE_EPOCH,各构建两次比较 docker images --digests 的结果。然后在私有 registry(可用本地 registry:2)上推送同名 tag 两次,观察 digest 变化,验证”tag 未变、内容已变”。最后写一个 20 行的发布单校验脚本:输入服务名与 tag,输出该 tag 当前 digest 以及是否与登记表中的”已知良好 digest”一致,不一致就拒绝回滚。约 45–60 分钟。

延伸学习#


L01 · Q-20261106-L01 · 198. 打家劫舍#

题目#

LeetCode 198 · House Robber · 打家劫舍 · Medium(难度按标准分类记录为 medium;本次抓取的英文页与中文页均未直接渲染难度标签,标注”未直接核验”,题面、示例与约束两页一致)

官方链接:

题目描述:你是一个专业小偷,计划偷窃沿街的房屋,每间房内藏有一定现金。相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚被闯入,系统会自动报警。给定一个代表每间房屋金额的非负整数数组 nums,在不触动警报的前提下,计算一夜之内能偷窃到的最高金额。

输入输出:输入 int[] nums,输出一个整数,表示最大金额。

约束:

  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 400

示例:

输入: nums = [1,2,3,1]
输出: 4
解释: 偷 1 号房屋(金额 1),然后偷 3 号房屋(金额 3),总额 4。
输入: nums = [2,7,9,3,1]
输出: 12
解释: 偷 1 号(2)、3 号(9)、5 号(1),总额 12。

先思考#

  1. 从左往右扫,走到第 i 间房时,为了决定”偷不偷它”,你需要记住前面多少信息?是整段历史,还是最近两间的结论?
  2. 如果决定偷第 i 间,第 i-1 间一定不能偷。那前 i-2 间的最优解,能不能直接接上?
  3. 如果决定不偷第 i 间,前 i 间的最优解等于什么?
  4. 这两种情况是否覆盖了所有可能,并且互不重叠?如果互不重叠,就可以直接取最大值。

参考答案#

推荐解法:一维动态规划 + 滚动变量。定义 f(i) 为只考虑前 i 间房(下标 0..i-1)能偷到的最大金额,转移为 f(i) = max(f(i-1), f(i-2) + nums[i-1]),边界 f(0) = 0、f(1) = nums[0]。含义是:最后一间房只有”不偷”和”偷”两种情形——不偷则等于前 i-1 间的最优值;偷则第 i-1 间必不能偷,等于前 i-2 间的最优值加上本间金额。两种情况构成完备且互斥的划分,取最大即为最优。由于 f(i) 只依赖前两项,可以用两个变量滚动,空间降到 O(1)。

正确性说明:设 OPT(i) 为前 i 间房的最优解。任一最优方案对第 i 间房只有两种处置:不含第 i 间,则它必然是前 i-1 间的最优解(否则替换成更优的会得到更优整体,矛盾);含第 i 间,则第 i-1 间必不含,剩余部分必然是前 i-2 间的最优解(同理)。两类覆盖全部可行解且交集为空,故递推式成立;归纳可得 f(i) = OPT(i)。

时间复杂度 O(n):每个元素常数次操作。空间复杂度 O(1):只保留前两项。

from typing import List
class Solution:
def rob(self, nums: List[int]) -> int:
# prev2 = f(i-2), prev1 = f(i-1)
prev2, prev1 = 0, 0
for x in nums:
prev2, prev1 = prev1, max(prev1, prev2 + x)
return prev1

边界情况:nums 长度为 1 时直接返回该值(滚动写法自然满足);nums[i] = 0 时不偷更好,结果可能为 0,不要用”结果大于 0”作为是否更新的条件;全部元素相等时取隔一间偷,例如 [5,5,5] 得 10;全 0 数组返回 0;nums 长度为 100 且全为 400 时为 50 × 400 = 20000,不会溢出 32 位。

测试用例:

from typing import List
class Solution:
def rob(self, nums: List[int]) -> int:
prev2, prev1 = 0, 0
for x in nums:
prev2, prev1 = prev1, max(prev1, prev2 + x)
return prev1
def case(name, nums, expected):
got = Solution().rob(nums)
assert got == expected, f"{name}: got {got}, want {expected}"
case("示例1", [1, 2, 3, 1], 4)
case("示例2", [2, 7, 9, 3, 1], 12)
case("贪心反例", [2, 3, 2], 4) # 取最大 3 得 3,最优 2+2=4
case("单元素", [7], 7)
case("全零", [0, 0, 0], 0)
case("上界规模", [400] * 100, 20000)
# 与暴力解对拍(n <= 14,枚举子集判断相邻冲突)
import random
def brute(nums: List[int]) -> int:
n = len(nums)
best = 0
for mask in range(1 << n):
if mask & (mask << 1):
continue
best = max(best, sum(nums[i] for i in range(n) if mask >> i & 1))
return best
random.seed(6)
for _ in range(300):
arr = [random.randint(0, 400) for _ in range(random.randint(1, 12))]
assert Solution().rob(arr) == brute(arr), arr
print("all passed")

面试官追问(变体):① 若房屋围成环(LeetCode 213),首尾也不能同时偷——拆成”不偷首”与”不偷尾”两个线性问题取最大;② 若房屋是二叉树(LeetCode 337),后序返回 (偷当前的最大值, 不偷当前的最大值) 二元组,父节点据此选择;③ 若约束改成”不能连续偷 k 间”,状态要扩成”最近 k 间的偷窃模式”,滚动窗口变成 k 维,这是”约束维度进入状态”的一般套路。可迁移能力:用”最后一个元素的处置”作为状态划分维度——分类完备、互不重叠、且能压掉历史依赖,这条思路适用于所有”相邻/间隔约束下的序列最优选择”问题(如带冷却期的买卖股票、间隔调度加权版本)。

关键点#

  1. 状态定义是 f(i) = 前 i 间房的最优值,“最后一间偷/不偷”构成完备划分。
  2. 偷第 i 间时第 i-1 间必不偷,这是转移里 f(i-2) 的来源,不能写成 f(i-1)。
  3. 只依赖前两项,滚动变量即可 O(1) 空间;赋值顺序要用元组同时更新,避免覆盖。
  4. 元素可为 0,因此”结果是否变大为更新条件”的写法会漏解。
  5. 该题不能贪心:[2,3,2] 取最大 3 得 3,最优是 2+2=4。

常见误区#

  1. 写成 f(i) = max(f(i-1), f(i-1) + nums[i]) 或漏掉 f(i-2):把”偷当前”与”前 i-1 最优”混在一起,等于允许相邻同偷。
  2. 用”每次取当前最大值并删除邻居”的贪心:局部最优不保证全局最优,[2,3,2] 就是最小反例。
  3. 滚动变量更新写成先改 prev1 再算 prev2:prev2 拿到的是已更新的值,结果在部分用例上偏大,且难以通过示例 1 之外的测试发现。

延伸学习#

  • LeetCode 198 官方题目页(已核验题面、示例与约束;难度标签本次未直接渲染):https://leetcode.com/problems/house-robber/
  • 同族变体练习:LeetCode 213 打家劫舍 II(环形)、337 打家劫舍 III(树形),分别对应”环形拆解”与”树形状态二元组”两种状态设计

延伸资料#

  1. microservices.io《Pattern: Saga》——补偿事务与隔离性缺口的权威定义,原文明确列出”无自动回滚”与”无隔离性”两项缺陷(B01):https://microservices.io/patterns/data/saga.html
  2. OpenAI《Batch API》官方指南——50% 折扣、24 小时窗口、5 万请求 / 200MB 上限、custom_id 映射与输出保留期(B02,已核验):https://developers.openai.com/api/docs/guides/batch
  3. gVisor 官方文档《What is gVisor?》——用户态应用内核的隔离原理与其兼容性、系统调用开销代价(S01,已核验):https://gvisor.dev/docs/
  4. Kubernetes 官方文档《RuntimeClass》——同一集群按 Pod 选择不同运行时的机制,是分级隔离的落地方式(S01):https://kubernetes.io/docs/concepts/containers/runtime-class/
  5. OCI Image Spec《Content Descriptors》——digest 作为内容标识的定义与校验要求(S02,已核验):https://github.com/opencontainers/image-spec/blob/main/descriptor.md
  6. LeetCode 198 官方题目页(已核验题面、示例与约束;难度标签未直接渲染):https://leetcode.com/problems/house-robber/

当日重点#

  1. 补偿不是回滚:Saga 的每一步补偿都是一次新的正向事务,会失败、会留痕;触发补偿只能看业务拒绝,不能把超时当失败。
  2. Saga 不提供隔离性,中间态必须靠预占、悬置状态、重读校验这类语义锁对冲,“最终一致”四个字不能替代逐步骤分析。
  3. 批处理推理用延迟承诺换单位成本:能搬的是离线、自包含、可重放的负载;结果对齐靠提交前设计好的幂等键,不靠输出顺序。
  4. 隔离边界必须由挂载与运行时提供,应用层路径校验不是边界;重试的幂等要落在副作用边界,而不是任务粒度。
  5. 回滚的对象是内容标识(digest),不是可变引用(tag);构建输入不显式化,“同一版本”就会有多份不同内容。
  6. 动态规划的状态要把约束装进去:f(i) = max(f(i-1), f(i-2) + nums[i-1]) 里的 i-2 就是”相邻不能同偷”这条约束的全部化身。

运行信息#

  • 运行日期:2026-11-06(Asia/Shanghai);本次为首次生成(status: generated),当日文件此前不存在,非重跑。
  • 题库配置:读取成功,题库配置.md 版本 8(last_maintained: 2026-11-02),只读未改;无暂停类型;“观察中的新技术”8 项均未标记”允许试学”,未从中出题。
  • 历史参考与去重:读取 题目索引.md(110 条,indexed_runs: 20,滚动窗口 2026-08-06 至 2026-11-06)、精选/README.md(4 条)与 README.md,并核验窗口内训练文件的题目速览与元数据;未通读更早正文。本次 5 题与窗口内题目无同题、无换皮、无重复 LeetCode 题号;LeetCode 全局已用题号(含窗口外)已规避。
  • 相邻题目的区分说明:S01 与 2026-10-26 S01 同为”工具服务攻击面”,但该题考察漏洞逐条收敛(SSRF、路径约束、重定向),本题考察隔离强度与启动延迟的取舍以及重跑副作用的幂等边界;S02 与 2026-10-21 B01 同涉版本与依赖,但该基础题考察语义化版本范围与锁文件语义,本题考察制品不可变性与回滚对象的选择。
  • 覆盖变化:分布式系统补”Saga 补偿与语义锁”(基础题首次)、LLM 应用工程补”批处理推理边界”(该类型近期覆盖最低)、AI Agent 核心补”沙箱执行与副作用边界”场景(该类型此前仅 2 条,为覆盖最低类型)、软件工程与平台工程补”制品不可变与回滚对象”场景、LeetCode 轮换到线性动态规划(198,此前未用)。
  • 核验状态:LeetCode 198 英文页与中文页均已核验题面、示例与约束(1 <= nums.length <= 100、0 <= nums[i] <= 400),两页均未渲染难度标签,medium 按标准分类记录并标注”未直接核验”;OpenAI Batch 指南、microservices.io Saga、gVisor 文档、OCI descriptor 文档均已抓取核验,引用的数字与措辞以原文为准。Kubernetes RuntimeClass 页面与 Sigstore cosign 文档为长期稳定的官方入口,本次未逐字核验正文。
  • 质量校验:正文 6 个固定小节各 5 次、场景题**追问**各 3 条、### 小型实践 2 处;L01 代码从本文件中抽取执行,定向用例与 300 组随机对拍(与子集枚举暴力解对照)全部通过。
  • 周期复盘:本次为累计第 21 次运行,本次提示词不含周复盘章节,未触发周复盘动作;4 条精选(建议复习日 10-19 / 10-20 / 10-23 / 10-24)仍处于逾期未复习状态,未自动执行任何精选或复习操作。
  • 任务运行信息仅记录于本节,不随训练消息发送;本地写入不等于远端同步,未做独立同步验证。
2026-11-06 AI Agent 面试训练
https://vissssa.github.io/posts/ai-agent-interview-2026-11-06/
作者
Siegward
发布于
2026-11-06
许可协议
CC BY-NC-SA 4.0